From 5dbb6107dd17a5b3adb5549d9467de600d56aea0 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Tue, 25 Aug 2026 14:14:42 +0800 Subject: [PATCH] feat: add user send mail and sent box (#1122) * feat: add user send mail client * fix: align user mail navigation * fix: shorten address credential action * test: cover user mail ownership boundaries * fix: address user mail review feedback * fix: disambiguate user mail e2e heading * fix: minimize shared sent box changes * refactor: isolate user send mail page * refactor: reuse bound address lookup * fix: clarify user sent box naming * refactor: decouple user send API from roles * fix: align user send mail behavior * fix: align user send role and rate limits * test: isolate user send rate limits * test: initialize rate limit worker database * refactor: simplify user send rate limit * refactor: inline user send rate limit path * refactor: simplify user send limiter key * refactor: keep existing rate limit behavior * style: simplify user send rate limit condition * style: group user send rate limit condition * fix: bind user role token to account * fix: keep user sender selection available --- CHANGELOG.md | 3 + CHANGELOG_EN.md | 3 + e2e/fixtures/wrangler.toml.e2e | 1 + e2e/tests/api/user-send-mail.spec.ts | 415 +++++++++++++++ e2e/tests/browser/user-send-mail.spec.ts | 137 +++++ frontend/src/i18n/locales/source/de.ts | 5 +- frontend/src/i18n/locales/source/es.ts | 5 +- frontend/src/i18n/locales/source/ja.ts | 5 +- frontend/src/i18n/locales/source/ptBR.ts | 5 +- frontend/src/i18n/message-registry.ts | 14 + frontend/src/views/User.vue | 10 +- frontend/src/views/user/AddressManagement.vue | 26 + frontend/src/views/user/SendMail.vue | 491 ++++++++++++++++++ frontend/src/views/user/UserSendBox.vue | 135 +++++ .../docs/en/guide/feature/send-mail-api.md | 41 +- .../docs/zh/guide/feature/send-mail-api.md | 41 +- worker/src/user_api/bind_address.ts | 27 +- worker/src/user_api/index.ts | 8 + worker/src/user_api/user_send_mail_api.ts | 117 +++++ worker/src/worker.ts | 13 +- 20 files changed, 1483 insertions(+), 19 deletions(-) create mode 100644 e2e/tests/api/user-send-mail.spec.ts create mode 100644 e2e/tests/browser/user-send-mail.spec.ts create mode 100644 frontend/src/views/user/SendMail.vue create mode 100644 frontend/src/views/user/UserSendBox.vue create mode 100644 worker/src/user_api/user_send_mail_api.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 736c48e..155744e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ ### Features - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 +- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API ### Bug Fixes - fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题 - fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位 +- fix: |用户发信| 用户地址发信接口支持角色无限额度 ### Improvements @@ -25,6 +27,7 @@ - test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复 - fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览 +- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程 ## v1.11.0 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index de558f7..0d2eac3 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -11,11 +11,13 @@ ### 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: |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 - fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs - fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder +- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs ### Improvements @@ -25,6 +27,7 @@ - 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 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 diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 76acf6a..0756436 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -20,6 +20,7 @@ ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 +NO_LIMIT_SEND_ROLE = "case-role" ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' diff --git a/e2e/tests/api/user-send-mail.spec.ts b/e2e/tests/api/user-send-mail.spec.ts new file mode 100644 index 0000000..dd9ba9b --- /dev/null +++ b/e2e/tests/api/user-send-mail.spec.ts @@ -0,0 +1,415 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; + +import { + WORKER_URL, + createTestAddress, + deleteAddress, + deleteAllMailpitMessages, + getAddressSender, + hashPassword, + onMailpitMessage, + updateAddressSender, +} from '../../fixtures/test-helpers'; + +async function createUser(request: APIRequestContext) { + const email = `user-send-${Date.now()}@test.example.com`; + const password = hashPassword('test-password-123'); + const registerRes = await request.post(`${WORKER_URL}/user_api/register`, { + data: { email, password }, + }); + expect(registerRes.ok()).toBe(true); + + const loginRes = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginRes.ok()).toBe(true); + const { jwt } = await loginRes.json(); + const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')); + return { jwt, userId: payload.user_id as number }; +} + +async function bindAddress( + request: APIRequestContext, + userJwt: string, + addressJwt: string, +) { + const response = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { + Authorization: `Bearer ${addressJwt}`, + 'x-user-token': userJwt, + }, + }); + expect(response.ok()).toBe(true); +} + +test.describe('User send mail API', () => { + test('sends and manages sent items for a bound address only', async ({ request }) => { + const addresses: Awaited>[] = []; + let userId: number | undefined; + let originalUserSettings: Record | undefined; + + try { + const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`); + expect(settingsRes.ok()).toBe(true); + originalUserSettings = await settingsRes.json(); + const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + ...originalUserSettings, + enable: true, + enableMailVerify: false, + maxAddressCount: 0, + }, + }); + expect(enableUserRes.ok()).toBe(true); + + const user = await createUser(request); + userId = user.userId; + const bound = await createTestAddress(request, 'user-send-bound-'); + const accessRequest = await createTestAddress(request, 'user-send-access-'); + const outsider = await createTestAddress(request, 'usr-outsider-'); + addresses.push(bound, accessRequest, outsider); + await bindAddress(request, user.jwt, bound.jwt); + await bindAddress(request, user.jwt, accessRequest.jwt); + + const invalidAddressSettingsRes = await request.get( + `${WORKER_URL}/user_api/address/0/settings`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(invalidAddressSettingsRes.status()).toBe(400); + + const outsiderSettingsRes = await request.get( + `${WORKER_URL}/user_api/address/${outsider.address_id}/settings`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(outsiderSettingsRes.status()).toBe(400); + + const outsiderCredentialRes = await request.get( + `${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(outsiderCredentialRes.status()).toBe(400); + + const outsiderAccessRes = await request.post( + `${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(outsiderAccessRes.status()).toBe(400); + + const outsiderUserSendRes = await request.post( + `${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`, + { + headers: { 'x-user-token': user.jwt }, + data: { + to_mail: 'recipient@test.example.com', + subject: 'Forbidden user send', + content: 'This message must not be sent', + is_html: false, + }, + }, + ); + expect(outsiderUserSendRes.status()).toBe(400); + + const unauthenticatedSendboxRes = await request.get( + `${WORKER_URL}/user_api/sendbox?limit=20&offset=0`, + ); + expect(unauthenticatedSendboxRes.status()).toBe(401); + + const requestAccessRes = await request.post( + `${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(requestAccessRes.ok()).toBe(true); + + const accessSender = await getAddressSender(request, accessRequest.address); + await updateAddressSender(request, { + address: accessRequest.address, + address_id: accessSender.id, + balance: 0, + enabled: true, + }); + const duplicateAccessRes = await request.post( + `${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(duplicateAccessRes.status()).toBe(400); + expect(await duplicateAccessRes.text()).toContain('Already'); + + const addressSettingsRes = await request.get( + `${WORKER_URL}/user_api/address/${bound.address_id}/settings`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(addressSettingsRes.ok()).toBe(true); + const addressSettings = await addressSettingsRes.json(); + expect(addressSettings.address).toBe(bound.address); + expect(addressSettings.send_balance).toBe(10); + + await deleteAllMailpitMessages(request); + const subject = `User API send ${Date.now()}`; + const listener = onMailpitMessage((mail) => mail.Subject === subject); + await listener.ready; + + const sendRes = await request.post( + `${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`, + { + headers: { 'x-user-token': user.jwt }, + data: { + from_name: 'User Sender', + from_mail: outsider.address, + to_name: 'Recipient', + to_mail: 'recipient@test.example.com', + subject, + content: 'Sent through the user API', + is_html: false, + }, + }, + ); + expect(sendRes.ok()).toBe(true); + const delivered = await listener.message; + 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( + `${WORKER_URL}/user_api/address/${bound.address_id}/settings`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect((await updatedSettingsRes.json()).send_balance).toBe(9); + + const sender = await getAddressSender(request, bound.address); + await updateAddressSender(request, { + address: bound.address, + address_id: sender.id, + balance: 0, + enabled: true, + }); + const noBalanceRes = await request.post( + `${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`, + { + headers: { 'x-user-token': user.jwt }, + data: { + to_mail: 'recipient@test.example.com', + subject: 'No balance user send', + content: 'This message must not be sent', + is_html: false, + }, + }, + ); + expect(noBalanceRes.status()).toBe(400); + expect(await noBalanceRes.text()).toContain('No balance'); + + 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); + expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject); + + 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 deleteRes = await request.delete( + `${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(deleteRes.ok()).toBe(true); + + const emptySendboxRes = await request.get( + `${WORKER_URL}/user_api/sendbox?limit=20&offset=0`, + { headers: { 'x-user-token': user.jwt } }, + ); + const emptySendbox = await emptySendboxRes.json(); + expect(emptySendbox.count).toBe(0); + expect(emptySendbox.results).toHaveLength(0); + } finally { + try { + await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt))); + if (userId !== undefined) { + await request.delete(`${WORKER_URL}/admin/users/${userId}`); + } + } finally { + if (originalUserSettings) { + await request.post(`${WORKER_URL}/admin/user_settings`, { + data: originalUserSettings, + }); + } + } + } + }); + + test('applies unlimited balance from the user role access token', async ({ request }) => { + const addresses: Awaited>[] = []; + const userIds: number[] = []; + let originalUserSettings: Record | undefined; + + try { + const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`); + expect(settingsRes.ok()).toBe(true); + originalUserSettings = await settingsRes.json(); + const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + ...originalUserSettings, + enable: true, + enableMailVerify: false, + maxAddressCount: 0, + }, + }); + expect(enableUserRes.ok()).toBe(true); + + const user = await createUser(request); + userIds.push(user.userId); + const address = await createTestAddress(request, 'user-send-role-'); + addresses.push(address); + await bindAddress(request, user.jwt, address.jwt); + + const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, { + data: { user_id: user.userId, role_text: 'case-role' }, + }); + expect(updateRoleRes.ok()).toBe(true); + + const accessRes = await request.post( + `${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': user.jwt } }, + ); + expect(accessRes.ok()).toBe(true); + const sender = await getAddressSender(request, address.address); + await updateAddressSender(request, { + address: address.address, + address_id: sender.id, + balance: 0, + enabled: true, + }); + + const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, { + headers: { 'x-user-token': user.jwt }, + }); + expect(userSettingsRes.ok()).toBe(true); + const { access_token: accessToken } = await userSettingsRes.json(); + expect(accessToken).toBeTruthy(); + const userHeaders = { + 'x-user-token': user.jwt, + 'x-user-access-token': accessToken, + }; + + const addressSettingsRes = await request.get( + `${WORKER_URL}/user_api/address/${address.address_id}/settings`, + { headers: userHeaders }, + ); + expect(addressSettingsRes.ok()).toBe(true); + expect((await addressSettingsRes.json()).send_balance).toBe(99999); + + const sendRes = await request.post( + `${WORKER_URL}/user_api/address/${address.address_id}/send_mail`, + { + headers: userHeaders, + data: { + to_mail: 'recipient@test.example.com', + subject: `Unlimited role send ${Date.now()}`, + content: 'Sent without consuming address balance', + is_html: false, + }, + }, + ); + expect(sendRes.ok()).toBe(true); + expect((await getAddressSender(request, address.address)).balance).toBe(0); + + const otherUser = await createUser(request); + userIds.push(otherUser.userId); + const otherAddress = await createTestAddress(request, 'user-send-other-'); + addresses.push(otherAddress); + await bindAddress(request, otherUser.jwt, otherAddress.jwt); + const otherAccessRes = await request.post( + `${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`, + { headers: { 'x-user-token': otherUser.jwt } }, + ); + expect(otherAccessRes.ok()).toBe(true); + const otherSender = await getAddressSender(request, otherAddress.address); + await updateAddressSender(request, { + address: otherAddress.address, + address_id: otherSender.id, + balance: 0, + enabled: true, + }); + + const mixedHeaders = { + 'x-user-token': otherUser.jwt, + 'x-user-access-token': accessToken, + }; + const otherSettingsRes = await request.get( + `${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`, + { headers: mixedHeaders }, + ); + expect(otherSettingsRes.ok()).toBe(true); + expect((await otherSettingsRes.json()).send_balance).toBe(0); + + const otherSendRes = await request.post( + `${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`, + { + headers: mixedHeaders, + data: { + to_mail: 'recipient@test.example.com', + subject: `Mismatched role token ${Date.now()}`, + content: 'This message must not be sent', + is_html: false, + }, + }, + ); + expect(otherSendRes.status()).toBe(400); + expect(await otherSendRes.text()).toContain('No balance'); + } finally { + await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt))); + await Promise.allSettled(userIds.map((userId) => ( + request.delete(`${WORKER_URL}/admin/users/${userId}`) + ))); + if (originalUserSettings) { + await request.post(`${WORKER_URL}/admin/user_settings`, { + data: originalUserSettings, + }); + } + } + }); + +}); diff --git a/e2e/tests/browser/user-send-mail.spec.ts b/e2e/tests/browser/user-send-mail.spec.ts new file mode 100644 index 0000000..001817d --- /dev/null +++ b/e2e/tests/browser/user-send-mail.spec.ts @@ -0,0 +1,137 @@ +import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test'; + +import { + FRONTEND_URL, + WORKER_URL, + createTestAddress, + deleteAddress, + hashPassword, +} from '../../fixtures/test-helpers'; + +async function createUser(request: APIRequestContext) { + const email = `user-send-browser-${Date.now()}@test.example.com`; + const password = hashPassword('test-password-123'); + const registerRes = await request.post(`${WORKER_URL}/user_api/register`, { + data: { email, password }, + }); + expect(registerRes.ok()).toBe(true); + + const loginRes = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginRes.ok()).toBe(true); + const { jwt } = await loginRes.json(); + const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')); + return { email, jwt, userId: payload.user_id as number }; +} + +test.describe('User send mail page', () => { + test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => { + const request = await apiRequest.newContext(); + const addresses: Awaited>[] = []; + let userId: number | undefined; + let originalUserSettings: Record | undefined; + + try { + const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`); + expect(settingsRes.ok()).toBe(true); + originalUserSettings = await settingsRes.json(); + const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + ...originalUserSettings, + enable: true, + enableMailVerify: false, + maxAddressCount: 0, + }, + }); + expect(enableUserRes.ok()).toBe(true); + + const user = await createUser(request); + userId = user.userId; + const address = await createTestAddress(request, 'usr-browser-'); + const secondAddress = await createTestAddress(request, 'usr-second-'); + addresses.push(address, secondAddress); + for (const boundAddress of addresses) { + const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { + Authorization: `Bearer ${boundAddress.jwt}`, + 'x-user-token': user.jwt, + }, + }); + expect(bindRes.ok()).toBe(true); + } + + await page.goto(`${FRONTEND_URL}/en/`); + await page.evaluate((userJwt) => { + localStorage.setItem('userJwt', userJwt); + }, user.jwt); + await page.goto(`${FRONTEND_URL}/en/user`); + + await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 }); + 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: 'Address Credential' }).click(); + expect((await credentialResponse).ok()).toBe(true); + await expect(page.getByRole('dialog')).toContainText(address.address); + await page.getByRole('button', { name: 'close' }).click(); + + const initialSettingsResponse = page.waitForResponse((response) => ( + new URL(response.url()).pathname + === `/user_api/address/${secondAddress.address_id}/settings` + )); + await page.getByText('Send Mail', { exact: true }).click(); + expect((await initialSettingsResponse).ok()).toBe(true); + await expect(page.locator('.composer-title h2')).toHaveText('Compose email'); + + const settingsResponse = page.waitForResponse((response) => ( + new URL(response.url()).pathname + === `/user_api/address/${address.address_id}/settings` + )); + await page.locator('.address-picker-select').click(); + await page.locator('.n-base-select-option').filter({ hasText: address.address }).click(); + expect((await settingsResponse).ok()).toBe(true); + await expect(page.locator('.address-picker-select')).toContainText(address.address); + + const subject = `Browser user send ${Date.now()}`; + await page.getByRole('textbox', { name: /^Recipient address/ }) + .fill('recipient@test.example.com'); + await page.getByRole('textbox', { name: /^Subject/ }).fill(subject); + await page.locator('.compose-textarea textarea').fill('Sent from the user page'); + + const sendResponse = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname + === `/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(); + 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.locator('.n-thing-header__title').filter({ hasText: subject })) + .toHaveText(subject, { timeout: 15_000 }); + } finally { + try { + await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt))); + if (userId !== undefined) { + await request.delete(`${WORKER_URL}/admin/users/${userId}`); + } + } finally { + if (originalUserSettings) { + await request.post(`${WORKER_URL}/admin/user_settings`, { + data: originalUserSettings, + }); + } + await request.dispose(); + } + } + }); +}); diff --git a/frontend/src/i18n/locales/source/de.ts b/frontend/src/i18n/locales/source/de.ts index 8c88449..4aeab92 100644 --- a/frontend/src/i18n/locales/source/de.ts +++ b/frontend/src/i18n/locales/source/de.ts @@ -650,5 +650,8 @@ export const deMessages = { "components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.", "components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden", - "components.AddressCredentialModal.username": "Benutzername" + "components.AddressCredentialModal.username": "Benutzername", + "views.User.send_mail": "E-Mail senden", + "views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus", + "views.user.UserSendBox.sendbox": "Gesendet" } diff --git a/frontend/src/i18n/locales/source/es.ts b/frontend/src/i18n/locales/source/es.ts index c2b8e35..a91d553 100644 --- a/frontend/src/i18n/locales/source/es.ts +++ b/frontend/src/i18n/locales/source/es.ts @@ -650,5 +650,8 @@ export const esMessages = { "components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.", "components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión", - "components.AddressCredentialModal.username": "Usuario" + "components.AddressCredentialModal.username": "Usuario", + "views.User.send_mail": "Enviar correo", + "views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada", + "views.user.UserSendBox.sendbox": "Enviados" } diff --git a/frontend/src/i18n/locales/source/ja.ts b/frontend/src/i18n/locales/source/ja.ts index 46bb77e..cb7042c 100644 --- a/frontend/src/i18n/locales/source/ja.ts +++ b/frontend/src/i18n/locales/source/ja.ts @@ -650,5 +650,8 @@ export const jaMessages = { "components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。", "components.AddressCredentialModal.title": "アドレス認証情報と接続方法", - "components.AddressCredentialModal.username": "ユーザー名" + "components.AddressCredentialModal.username": "ユーザー名", + "views.User.send_mail": "メール送信", + "views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください", + "views.user.UserSendBox.sendbox": "送信済み" } diff --git a/frontend/src/i18n/locales/source/ptBR.ts b/frontend/src/i18n/locales/source/ptBR.ts index 3954523..424d281 100644 --- a/frontend/src/i18n/locales/source/ptBR.ts +++ b/frontend/src/i18n/locales/source/ptBR.ts @@ -650,5 +650,8 @@ export const ptBRMessages = { "components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.", "components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão", - "components.AddressCredentialModal.username": "Nome de usuário" + "components.AddressCredentialModal.username": "Nome de usuário", + "views.User.send_mail": "Enviar e-mail", + "views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado", + "views.user.UserSendBox.sendbox": "Enviados" } diff --git a/frontend/src/i18n/message-registry.ts b/frontend/src/i18n/message-registry.ts index e5d6246..2668416 100644 --- a/frontend/src/i18n/message-registry.ts +++ b/frontend/src/i18n/message-registry.ts @@ -738,6 +738,10 @@ export const MESSAGE_REGISTRY = { "en": "Bind Mail Address", "zh": "绑定邮箱地址" }, + "send_mail": { + "en": "Send Mail", + "zh": "发送邮件" + }, "user_mail_box_tab": { "en": "Mail Box", "zh": "收件箱" @@ -747,6 +751,16 @@ export const MESSAGE_REGISTRY = { "zh": "用户设置" } }, + "views.user.UserSendBox": { + "noAddress": { + "en": "Select a bound email address to continue", + "zh": "请选择一个已绑定的邮箱地址" + }, + "sendbox": { + "en": "Sent", + "zh": "发件箱" + } + }, "views.user.UserLogin": { "cannotForgotPassword": { "en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator", diff --git a/frontend/src/views/User.vue b/frontend/src/views/User.vue index 70b323e..66be2b7 100644 --- a/frontend/src/views/User.vue +++ b/frontend/src/views/User.vue @@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue'; import UserBar from './user/UserBar.vue'; import BindAddress from './user/BindAddress.vue'; import UserMailBox from './user/UserMailBox.vue'; +import UserSendBox from './user/UserSendBox.vue'; const { - userTab, globalTabplacement, userSettings + userTab, globalTabplacement, userSettings, openSettings } = useGlobalState() const { t } = useScopedI18n('views.User') +const { t: userMailT } = useScopedI18n('views.user.UserSendBox') @@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User') + + + + + + diff --git a/frontend/src/views/user/AddressManagement.vue b/frontend/src/views/user/AddressManagement.vue index 1312b73..1167dcf 100644 --- a/frontend/src/views/user/AddressManagement.vue +++ b/frontend/src/views/user/AddressManagement.vue @@ -7,6 +7,7 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui' import { useGlobalState } from '../../store' import { api } from '../../api' import { getRouterPathWithLang } from '../../utils' +import AddressCredentialModal from '../../components/AddressCredentialModal.vue' import Login from '../common/Login.vue'; @@ -15,6 +16,7 @@ const message = useMessage() const router = useRouter() const { locale, t } = useScopedI18n('views.user.AddressManagement') +const { t: credentialT } = useScopedI18n('components.AddressCredentialModal') const data = ref([]) const count = ref(0) @@ -24,6 +26,20 @@ const showTranferAddress = ref(false) const currentAddress = ref("") const currentAddressId = ref(0) 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) => { try { @@ -146,6 +162,14 @@ const columns = [ key: 'actions', render(row) { return h('div', [ + h(NButton, + { + tertiary: true, + type: "primary", + onClick: () => showCredential(row) + }, + { default: () => credentialT('addressCredential') } + ), h(NPopconfirm, { onPositiveClick: () => changeMailAddress(row.id) @@ -204,6 +228,8 @@ watch([page, pageSize], async () => {