mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-06 07:57:04 +08:00
fix: align user mail navigation
This commit is contained in:
+2
-2
@@ -11,7 +11,7 @@
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||||
- feat: |用户系统| 用户中心新增绑定邮箱选择、发送邮件和发件箱,提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||||
- test: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心选择绑定邮箱后发信的完整流程
|
- test: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||||
|
|
||||||
## v1.11.0
|
## v1.11.0
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||||
- feat: |User| Add bound-address selection, mail composition, and sent items to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
||||||
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||||
- test: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus the complete user-center address selection and send flow
|
- test: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||||
|
|
||||||
## v1.11.0
|
## v1.11.0
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,38 @@ test.describe('User send mail API', () => {
|
|||||||
const delivered = await listener.message;
|
const delivered = await listener.message;
|
||||||
expect(delivered.From.Address).toBe(bound.address);
|
expect(delivered.From.Address).toBe(bound.address);
|
||||||
|
|
||||||
|
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||||
|
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||||
|
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: outsiderSubject,
|
||||||
|
content: 'This sent item must remain inaccessible to the user',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(outsiderSendRes.ok()).toBe(true);
|
||||||
|
const outsiderAddressSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||||
|
);
|
||||||
|
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||||
|
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||||
|
JSON.parse(item.raw).subject === outsiderSubject
|
||||||
|
));
|
||||||
|
expect(outsiderMail).toBeTruthy();
|
||||||
|
|
||||||
|
const unauthorizedDeleteRes = await request.delete(
|
||||||
|
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||||
|
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||||
|
);
|
||||||
|
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||||
|
|
||||||
const updatedSettingsRes = await request.get(
|
const updatedSettingsRes = await request.get(
|
||||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||||
{ headers: { 'x-user-token': user.jwt } },
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
@@ -130,6 +162,29 @@ test.describe('User send mail API', () => {
|
|||||||
expect(sendbox.results).toHaveLength(1);
|
expect(sendbox.results).toHaveLength(1);
|
||||||
expect(JSON.parse(sendbox.results[0].raw).subject).toBe(subject);
|
expect(JSON.parse(sendbox.results[0].raw).subject).toBe(subject);
|
||||||
|
|
||||||
|
const userSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(userSendboxRes.ok()).toBe(true);
|
||||||
|
const userSendbox = await userSendboxRes.json();
|
||||||
|
expect(userSendbox.count).toBe(1);
|
||||||
|
expect(userSendbox.results).toHaveLength(1);
|
||||||
|
|
||||||
|
const filteredSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(filteredSendboxRes.ok()).toBe(true);
|
||||||
|
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||||
|
|
||||||
|
const outsiderFilterRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderFilterRes.ok()).toBe(true);
|
||||||
|
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||||
|
|
||||||
const outsiderSendboxRes = await request.get(
|
const outsiderSendboxRes = await request.get(
|
||||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/sendbox?limit=20&offset=0`,
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/sendbox?limit=20&offset=0`,
|
||||||
{ headers: { 'x-user-token': user.jwt } },
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
@@ -137,7 +192,7 @@ test.describe('User send mail API', () => {
|
|||||||
expect(outsiderSendboxRes.status()).toBe(400);
|
expect(outsiderSendboxRes.status()).toBe(400);
|
||||||
|
|
||||||
const deleteRes = await request.delete(
|
const deleteRes = await request.delete(
|
||||||
`${WORKER_URL}/user_api/address/${bound.address_id}/sendbox/${sendbox.results[0].id}`,
|
`${WORKER_URL}/user_api/sendbox/${sendbox.results[0].id}`,
|
||||||
{ headers: { 'x-user-token': user.jwt } },
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
);
|
);
|
||||||
expect(deleteRes.ok()).toBe(true);
|
expect(deleteRes.ok()).toBe(true);
|
||||||
|
|||||||
@@ -64,21 +64,27 @@ test.describe('User send mail page', () => {
|
|||||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||||
|
|
||||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||||
await page.getByText('Send Mail', { exact: true }).click();
|
const credentialResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'GET'
|
||||||
|
&& new URL(response.url()).pathname
|
||||||
|
=== `/user_api/bind_address_jwt/${address!.address_id}`
|
||||||
|
));
|
||||||
|
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||||
|
await addressRow.getByRole('button', { name: 'Credentials & Connection Methods' }).click();
|
||||||
|
expect((await credentialResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||||
|
await page.getByRole('button', { name: 'close' }).click();
|
||||||
|
|
||||||
const addressSelect = page.locator('.address-picker-select');
|
|
||||||
await addressSelect.click();
|
|
||||||
const settingsResponse = page.waitForResponse((response) => (
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
new URL(response.url()).pathname
|
new URL(response.url()).pathname
|
||||||
=== `/user_api/address/${address!.address_id}/settings`
|
=== `/user_api/address/${address!.address_id}/settings`
|
||||||
));
|
));
|
||||||
await page.locator('.n-base-select-menu:visible')
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
.getByText(address.address, { exact: true })
|
|
||||||
.click();
|
|
||||||
expect((await settingsResponse).ok()).toBe(true);
|
expect((await settingsResponse).ok()).toBe(true);
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||||
await expect(page.locator('.composer-title')).toContainText(address.address);
|
await expect(page.locator('.composer-title')).toContainText(address.address);
|
||||||
|
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||||
|
|
||||||
const subject = `Browser user send ${Date.now()}`;
|
const subject = `Browser user send ${Date.now()}`;
|
||||||
await page.getByRole('textbox', { name: /^Recipient address/ })
|
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||||
@@ -91,9 +97,15 @@ test.describe('User send mail page', () => {
|
|||||||
&& new URL(response.url()).pathname
|
&& new URL(response.url()).pathname
|
||||||
=== `/user_api/address/${address!.address_id}/send_mail`
|
=== `/user_api/address/${address!.address_id}/send_mail`
|
||||||
));
|
));
|
||||||
|
const sendboxResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'GET'
|
||||||
|
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||||
|
));
|
||||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||||
expect((await sendResponse).ok()).toBe(true);
|
expect((await sendResponse).ok()).toBe(true);
|
||||||
|
expect((await sendboxResponse).ok()).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -653,8 +653,5 @@ export const deMessages = {
|
|||||||
"components.AddressCredentialModal.username": "Benutzername",
|
"components.AddressCredentialModal.username": "Benutzername",
|
||||||
"views.User.send_mail": "E-Mail senden",
|
"views.User.send_mail": "E-Mail senden",
|
||||||
"views.user.UserMailClient.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
"views.user.UserMailClient.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||||
"views.user.UserMailClient.selectAddress": "Absenderadresse",
|
"views.user.UserMailClient.sendbox": "Gesendet"
|
||||||
"views.user.UserMailClient.selectAddressTip": "Wähle eine verknüpfte Adresse, um E-Mails zu schreiben und gesendete Nachrichten anzuzeigen",
|
|
||||||
"views.user.UserMailClient.sendbox": "Gesendet",
|
|
||||||
"views.user.UserMailClient.sendMail": "Verfassen"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,8 +653,5 @@ export const esMessages = {
|
|||||||
"components.AddressCredentialModal.username": "Usuario",
|
"components.AddressCredentialModal.username": "Usuario",
|
||||||
"views.User.send_mail": "Enviar correo",
|
"views.User.send_mail": "Enviar correo",
|
||||||
"views.user.UserMailClient.noAddress": "Selecciona una dirección de correo vinculada",
|
"views.user.UserMailClient.noAddress": "Selecciona una dirección de correo vinculada",
|
||||||
"views.user.UserMailClient.selectAddress": "Dirección del remitente",
|
"views.user.UserMailClient.sendbox": "Enviados"
|
||||||
"views.user.UserMailClient.selectAddressTip": "Elige una dirección vinculada para redactar correos y ver los enviados",
|
|
||||||
"views.user.UserMailClient.sendbox": "Enviados",
|
|
||||||
"views.user.UserMailClient.sendMail": "Redactar"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,8 +653,5 @@ export const jaMessages = {
|
|||||||
"components.AddressCredentialModal.username": "ユーザー名",
|
"components.AddressCredentialModal.username": "ユーザー名",
|
||||||
"views.User.send_mail": "メール送信",
|
"views.User.send_mail": "メール送信",
|
||||||
"views.user.UserMailClient.noAddress": "紐付け済みのメールアドレスを選択してください",
|
"views.user.UserMailClient.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||||
"views.user.UserMailClient.selectAddress": "送信元アドレス",
|
"views.user.UserMailClient.sendbox": "送信済み"
|
||||||
"views.user.UserMailClient.selectAddressTip": "紐付け済みアドレスを選択して、メール作成と送信済みメールの確認ができます",
|
|
||||||
"views.user.UserMailClient.sendbox": "送信済み",
|
|
||||||
"views.user.UserMailClient.sendMail": "作成"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,8 +653,5 @@ export const ptBRMessages = {
|
|||||||
"components.AddressCredentialModal.username": "Nome de usuário",
|
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||||
"views.User.send_mail": "Enviar e-mail",
|
"views.User.send_mail": "Enviar e-mail",
|
||||||
"views.user.UserMailClient.noAddress": "Selecione um endereço de e-mail vinculado",
|
"views.user.UserMailClient.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||||
"views.user.UserMailClient.selectAddress": "Endereço do remetente",
|
"views.user.UserMailClient.sendbox": "Enviados"
|
||||||
"views.user.UserMailClient.selectAddressTip": "Escolha um endereço vinculado para escrever e ver e-mails enviados",
|
|
||||||
"views.user.UserMailClient.sendbox": "Enviados",
|
|
||||||
"views.user.UserMailClient.sendMail": "Escrever"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -756,21 +756,9 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"en": "Select a bound email address to continue",
|
"en": "Select a bound email address to continue",
|
||||||
"zh": "请选择一个已绑定的邮箱地址"
|
"zh": "请选择一个已绑定的邮箱地址"
|
||||||
},
|
},
|
||||||
"selectAddress": {
|
|
||||||
"en": "Sender address",
|
|
||||||
"zh": "发件邮箱"
|
|
||||||
},
|
|
||||||
"selectAddressTip": {
|
|
||||||
"en": "Choose a bound address to compose mail and view its sent items",
|
|
||||||
"zh": "选择已绑定邮箱后,可发送邮件并查看该邮箱的发件箱"
|
|
||||||
},
|
|
||||||
"sendbox": {
|
"sendbox": {
|
||||||
"en": "Sent",
|
"en": "Sent",
|
||||||
"zh": "发件箱"
|
"zh": "发件箱"
|
||||||
},
|
|
||||||
"sendMail": {
|
|
||||||
"en": "Compose",
|
|
||||||
"zh": "写邮件"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"views.user.UserLogin": {
|
"views.user.UserLogin": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const {
|
|||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useScopedI18n('views.User')
|
const { t } = useScopedI18n('views.User')
|
||||||
|
const { t: userMailT } = useScopedI18n('views.user.UserMailClient')
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -28,8 +29,11 @@ const { t } = useScopedI18n('views.User')
|
|||||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||||
<UserMailBox />
|
<UserMailBox />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
|
||||||
|
<UserMailClient mode="sendbox" />
|
||||||
|
</n-tab-pane>
|
||||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
|
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
|
||||||
<UserMailClient />
|
<UserMailClient mode="send_mail" @sent="userTab = 'user_sendbox'" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||||
<UserSettingsPage />
|
<UserSettingsPage />
|
||||||
|
|||||||
@@ -26,9 +26,21 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
userAddressMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
addressOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
addressLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['sent'])
|
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -38,7 +50,7 @@ const {
|
|||||||
|
|
||||||
const { t } = useScopedI18n('views.index.SendMail')
|
const { t } = useScopedI18n('views.index.SendMail')
|
||||||
|
|
||||||
const isUserAddressMode = computed(() => props.addressId > 0)
|
const isUserAddressMode = computed(() => props.userAddressMode || props.addressId > 0)
|
||||||
const mailSettings = computed(() => (
|
const mailSettings = computed(() => (
|
||||||
isUserAddressMode.value ? userAddressSettings.value : settings.value
|
isUserAddressMode.value ? userAddressSettings.value : settings.value
|
||||||
))
|
))
|
||||||
@@ -210,7 +222,7 @@ onMounted(async () => {
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="composer-title">
|
<div class="composer-title">
|
||||||
<h2>{{ t('composeMail') }}</h2>
|
<h2>{{ t('composeMail') }}</h2>
|
||||||
<n-text depth="3">{{ mailSettings.address }}</n-text>
|
<n-text v-if="!isUserAddressMode" depth="3">{{ mailSettings.address }}</n-text>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
@@ -235,7 +247,11 @@ onMounted(async () => {
|
|||||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||||
<n-input :value="mailSettings.address" readonly
|
<n-select v-if="isUserAddressMode" class="address-picker-select" :value="addressId"
|
||||||
|
:options="addressOptions" :loading="addressLoading" filterable
|
||||||
|
@scroll="emit('addressScroll', $event)"
|
||||||
|
@update:value="emit('update:addressId', $event)" />
|
||||||
|
<n-input v-else :value="mailSettings.address" readonly
|
||||||
:input-props="{ id: 'send-mail-sender-address' }" />
|
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
|||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
import { getRouterPathWithLang } from '../../utils'
|
import { getRouterPathWithLang } from '../../utils'
|
||||||
|
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||||
|
|
||||||
import Login from '../common/Login.vue';
|
import Login from '../common/Login.vue';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const message = useMessage()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||||
|
const { t: accountSettingsT } = useScopedI18n('views.index.AccountSettings')
|
||||||
|
|
||||||
const data = ref([])
|
const data = ref([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
@@ -24,6 +26,20 @@ const showTranferAddress = ref(false)
|
|||||||
const currentAddress = ref("")
|
const currentAddress = ref("")
|
||||||
const currentAddressId = ref(0)
|
const currentAddressId = ref(0)
|
||||||
const targetUserEmail = ref('')
|
const targetUserEmail = ref('')
|
||||||
|
const showAddressCredential = ref(false)
|
||||||
|
const currentAddressCredential = ref('')
|
||||||
|
const credentialAddress = ref('')
|
||||||
|
|
||||||
|
const showCredential = async (row) => {
|
||||||
|
try {
|
||||||
|
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
|
||||||
|
currentAddressCredential.value = addressCredential
|
||||||
|
credentialAddress.value = row.name
|
||||||
|
showAddressCredential.value = true
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || "error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const changeMailAddress = async (address_id) => {
|
const changeMailAddress = async (address_id) => {
|
||||||
try {
|
try {
|
||||||
@@ -146,6 +162,14 @@ const columns = [
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render(row) {
|
render(row) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
|
h(NButton,
|
||||||
|
{
|
||||||
|
tertiary: true,
|
||||||
|
type: "primary",
|
||||||
|
onClick: () => showCredential(row)
|
||||||
|
},
|
||||||
|
{ default: () => accountSettingsT('showAddressCredential') }
|
||||||
|
),
|
||||||
h(NPopconfirm,
|
h(NPopconfirm,
|
||||||
{
|
{
|
||||||
onPositiveClick: () => changeMailAddress(row.id)
|
onPositiveClick: () => changeMailAddress(row.id)
|
||||||
@@ -204,6 +228,8 @@ watch([page, pageSize], async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||||
|
:jwt="currentAddressCredential" />
|
||||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||||
<span>
|
<span>
|
||||||
<p>{{ t("transferAddressTip") }}</p>
|
<p>{{ t("transferAddressTip") }}</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
|
||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
@@ -10,17 +10,32 @@ const SendMail = defineAsyncComponent(() => import('../index/SendMail.vue'))
|
|||||||
|
|
||||||
const ADDRESS_PAGE_SIZE = 100
|
const ADDRESS_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'send_mail',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['sent'])
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { openSettings } = useGlobalState()
|
const { openSettings } = useGlobalState()
|
||||||
const { t } = useScopedI18n('views.user.UserMailClient')
|
const { t } = useScopedI18n('views.user.UserMailClient')
|
||||||
|
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
|
||||||
|
|
||||||
const addressId = ref(null)
|
const selectedAddressId = ref(null)
|
||||||
|
const addressFilter = ref(null)
|
||||||
const addressOptions = ref([])
|
const addressOptions = ref([])
|
||||||
const addressCount = ref(0)
|
const addressCount = ref(0)
|
||||||
const addressLoading = ref(false)
|
const addressLoading = ref(false)
|
||||||
const mailTab = ref('send_mail')
|
const sendboxKey = ref(0)
|
||||||
|
|
||||||
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
||||||
|
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
|
||||||
|
label: address.label,
|
||||||
|
value: address.address,
|
||||||
|
})))
|
||||||
|
|
||||||
const fetchAddresses = async () => {
|
const fetchAddresses = async () => {
|
||||||
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
|
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
|
||||||
@@ -35,10 +50,14 @@ const fetchAddresses = async () => {
|
|||||||
addressOptions.value.push(...results.map((address) => ({
|
addressOptions.value.push(...results.map((address) => ({
|
||||||
label: address.name,
|
label: address.name,
|
||||||
value: address.id,
|
value: address.id,
|
||||||
|
address: address.name,
|
||||||
})))
|
})))
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
addressCount.value = count
|
addressCount.value = count
|
||||||
}
|
}
|
||||||
|
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
|
||||||
|
selectedAddressId.value = addressOptions.value[0].value
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || 'error')
|
message.error(error.message || 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,46 +75,47 @@ const handleAddressScroll = async (event) => {
|
|||||||
|
|
||||||
const fetchSendbox = async (limit, offset) => {
|
const fetchSendbox = async (limit, offset) => {
|
||||||
return await api.fetch(
|
return await api.fetch(
|
||||||
`/user_api/address/${addressId.value}/sendbox?limit=${limit}&offset=${offset}`
|
`/user_api/sendbox?limit=${limit}&offset=${offset}`
|
||||||
|
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSendboxMail = async (mailId) => {
|
const deleteSendboxMail = async (mailId) => {
|
||||||
await api.fetch(
|
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
|
||||||
`/user_api/address/${addressId.value}/sendbox/${mailId}`,
|
|
||||||
{ method: 'DELETE' }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const querySendbox = () => {
|
||||||
|
sendboxKey.value = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(addressFilter, querySendbox)
|
||||||
|
|
||||||
onMounted(fetchAddresses)
|
onMounted(fetchAddresses)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-mail-client">
|
<div class="user-mail-client">
|
||||||
<n-card class="address-picker" :bordered="false" embedded size="small">
|
<template v-if="mode === 'send_mail'">
|
||||||
<n-flex align="center" justify="space-between" :wrap="true">
|
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
|
||||||
<div class="address-picker-copy">
|
<SendMail v-else :key="selectedAddressId" user-address-mode :address-id="selectedAddressId"
|
||||||
<n-text strong>{{ t('selectAddress') }}</n-text>
|
:address-options="addressOptions" :address-loading="addressLoading"
|
||||||
<n-text depth="3">{{ t('selectAddressTip') }}</n-text>
|
@address-scroll="handleAddressScroll" @update:address-id="selectedAddressId = $event"
|
||||||
</div>
|
@sent="emit('sent')" />
|
||||||
<n-select v-model:value="addressId" class="address-picker-select" :options="addressOptions"
|
</template>
|
||||||
:loading="addressLoading" :placeholder="t('selectAddress')" filterable clearable
|
<template v-else>
|
||||||
|
<n-input-group>
|
||||||
|
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
|
||||||
|
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
|
||||||
@scroll="handleAddressScroll" />
|
@scroll="handleAddressScroll" />
|
||||||
</n-flex>
|
<n-button @click="querySendbox" type="primary" tertiary>
|
||||||
</n-card>
|
{{ mailboxT('query') }}
|
||||||
|
</n-button>
|
||||||
<n-empty v-if="!addressId" class="address-empty" :description="t('noAddress')" />
|
</n-input-group>
|
||||||
|
<div class="filter-spacing"></div>
|
||||||
<n-tabs v-else v-model:value="mailTab" type="line" animated>
|
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
|
||||||
<n-tab-pane name="send_mail" :tab="t('sendMail')" display-directive="show:lazy">
|
|
||||||
<SendMail :key="addressId" :address-id="addressId" @sent="mailTab = 'sendbox'" />
|
|
||||||
</n-tab-pane>
|
|
||||||
<n-tab-pane name="sendbox" :tab="t('sendbox')" display-directive="show:lazy">
|
|
||||||
<SendBox :key="addressId" :fetch-mail-data="fetchSendbox"
|
|
||||||
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
||||||
:delete-mail="deleteSendboxMail" />
|
:delete-mail="deleteSendboxMail" />
|
||||||
</n-tab-pane>
|
</template>
|
||||||
</n-tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -105,27 +125,12 @@ onMounted(fetchAddresses)
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-picker {
|
.filter-spacing {
|
||||||
margin-bottom: 10px;
|
margin-top: 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.address-picker-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-picker-select {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-empty {
|
.address-empty {
|
||||||
padding: 72px 0;
|
padding: 72px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.address-picker-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ The same user-address API group also provides:
|
|||||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | Request send access for the address |
|
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | Request send access for the address |
|
||||||
| `GET` | `/user_api/address/:address_id/sendbox?limit=20&offset=0` | List sent items for the address with pagination |
|
| `GET` | `/user_api/address/:address_id/sendbox?limit=20&offset=0` | List sent items for the address with pagination |
|
||||||
| `DELETE` | `/user_api/address/:address_id/sendbox/:mail_id` | Delete one sent item for the address |
|
| `DELETE` | `/user_api/address/:address_id/sendbox/:mail_id` | Delete one sent item for the address |
|
||||||
|
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=optional-address` | List the current user's sent items, optionally filtered by a bound address |
|
||||||
|
| `DELETE` | `/user_api/sendbox/:mail_id` | Delete one sent item owned by the current user |
|
||||||
|
|
||||||
All endpoints require a User JWT and verify that `address_id` is bound to the current user before performing the operation.
|
All endpoints require a User JWT. Address-scoped endpoints verify that `address_id` is bound to the current user, while user-level sent-item endpoints only return or delete records for the user's bound addresses.
|
||||||
|
|
||||||
## Send Email via SMTP
|
## Send Email via SMTP
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ res = requests.post(
|
|||||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | 为该地址申请发信权限 |
|
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | 为该地址申请发信权限 |
|
||||||
| `GET` | `/user_api/address/:address_id/sendbox?limit=20&offset=0` | 分页获取该地址的发件箱 |
|
| `GET` | `/user_api/address/:address_id/sendbox?limit=20&offset=0` | 分页获取该地址的发件箱 |
|
||||||
| `DELETE` | `/user_api/address/:address_id/sendbox/:mail_id` | 删除该地址的一条发件记录 |
|
| `DELETE` | `/user_api/address/:address_id/sendbox/:mail_id` | 删除该地址的一条发件记录 |
|
||||||
|
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=可选地址` | 分页获取当前用户的发件箱,可按绑定地址过滤 |
|
||||||
|
| `DELETE` | `/user_api/sendbox/:mail_id` | 删除当前用户的一条发件记录 |
|
||||||
|
|
||||||
以上接口都只接受用户 JWT,并在执行操作前验证 `address_id` 是否绑定到当前用户。
|
以上接口都只接受用户 JWT。地址级接口验证 `address_id` 是否绑定到当前用户,用户级发件箱接口只返回或删除当前用户绑定地址的记录。
|
||||||
|
|
||||||
## 通过 SMTP 发送邮件
|
## 通过 SMTP 发送邮件
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ api.post('/user_api/address/:address_id/request_send_mail_access', user_send_mai
|
|||||||
api.post('/user_api/address/:address_id/send_mail', user_send_mail_api.send);
|
api.post('/user_api/address/:address_id/send_mail', user_send_mail_api.send);
|
||||||
api.get('/user_api/address/:address_id/sendbox', user_send_mail_api.listSendbox);
|
api.get('/user_api/address/:address_id/sendbox', user_send_mail_api.listSendbox);
|
||||||
api.delete('/user_api/address/:address_id/sendbox/:mail_id', user_send_mail_api.removeSendboxMail);
|
api.delete('/user_api/address/:address_id/sendbox/:mail_id', user_send_mail_api.removeSendboxMail);
|
||||||
|
api.get('/user_api/sendbox', user_send_mail_api.listUserSendbox);
|
||||||
|
api.delete('/user_api/sendbox/:mail_id', user_send_mail_api.removeUserSendboxMail);
|
||||||
|
|
||||||
// user api
|
// user api
|
||||||
api.post('/user_api/login', user.login);
|
api.post('/user_api/login', user.login);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
|
|
||||||
import { commonGetUserRole } from "../common";
|
import { commonGetUserRole, handleListQuery } from "../common";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import {
|
import {
|
||||||
deleteSendbox,
|
deleteSendbox,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getSendBalanceState,
|
getSendBalanceState,
|
||||||
requestSendMailAccess,
|
requestSendMailAccess,
|
||||||
} from "../mails_api/send_balance";
|
} from "../mails_api/send_balance";
|
||||||
|
import { getBooleanValue } from "../utils";
|
||||||
|
|
||||||
const getBindedAddress = async (
|
const getBindedAddress = async (
|
||||||
c: Context<HonoCustomType>
|
c: Context<HonoCustomType>
|
||||||
@@ -106,10 +107,50 @@ const removeSendboxMail = async (c: Context<HonoCustomType>): Promise<Response>
|
|||||||
return deleteSendbox(c, address, c.req.param("mail_id"));
|
return deleteSendbox(c, address, c.req.param("mail_id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listUserSendbox = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||||
|
const { user_id } = c.get("userPayload");
|
||||||
|
const { address, limit, offset } = c.req.query();
|
||||||
|
const filters = ["ua.user_id = ?"];
|
||||||
|
const params = [String(user_id)];
|
||||||
|
if (address) {
|
||||||
|
filters.push("sb.address = ?");
|
||||||
|
params.push(address);
|
||||||
|
}
|
||||||
|
const fromQuery = ` FROM users_address ua`
|
||||||
|
+ ` JOIN address a ON a.id = ua.address_id`
|
||||||
|
+ ` JOIN sendbox sb ON sb.address = a.name`
|
||||||
|
+ ` WHERE ${filters.join(" AND ")}`;
|
||||||
|
return await handleListQuery(c,
|
||||||
|
`SELECT sb.*${fromQuery}`,
|
||||||
|
`SELECT count(*) as count${fromQuery}`,
|
||||||
|
params, limit, offset, "sb.id desc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUserSendboxMail = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||||
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
|
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||||
|
return c.text(msgs.UserDeleteEmailDisabledMsg, 403);
|
||||||
|
}
|
||||||
|
const { user_id } = c.get("userPayload");
|
||||||
|
const { mail_id } = c.req.param();
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
`DELETE FROM sendbox WHERE id = ?`
|
||||||
|
+ ` AND EXISTS (`
|
||||||
|
+ `SELECT 1 FROM users_address ua`
|
||||||
|
+ ` JOIN address a ON a.id = ua.address_id`
|
||||||
|
+ ` WHERE ua.user_id = ? AND a.name = sendbox.address`
|
||||||
|
+ `)`
|
||||||
|
).bind(mail_id, user_id).run();
|
||||||
|
return c.json({ success });
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
settings,
|
settings,
|
||||||
requestAccess,
|
requestAccess,
|
||||||
send,
|
send,
|
||||||
listSendbox,
|
listSendbox,
|
||||||
removeSendboxMail,
|
removeSendboxMail,
|
||||||
|
listUserSendbox,
|
||||||
|
removeUserSendboxMail,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user