diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e9681..e16fe56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126) - feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 5b0be5a..3b01e15 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,6 +10,7 @@ ### Features +- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching - 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 one-click random email-name generation to the address creation page (issue #1126) - 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 diff --git a/db/2026-08-30-mail-read-status.sql b/db/2026-08-30-mail-read-status.sql new file mode 100644 index 0000000..362cc16 --- /dev/null +++ b/db/2026-08-30-mail-read-status.sql @@ -0,0 +1 @@ +ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER; diff --git a/db/schema.sql b/db/schema.sql index 321d9bf..3afc8e5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails ( raw TEXT, raw_blob BLOB, metadata TEXT, + is_unread INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index d6fb53f..96723e2 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -27,10 +27,11 @@ export function hashPassword(password: string): string { export async function createTestAddress( ctx: APIRequestContext, name: string, - domain: string = TEST_DOMAIN + domain: string = TEST_DOMAIN, + workerUrl: string = WORKER_URL, ): Promise<{ jwt: string; address: string; address_id: number }> { const uniqueName = `${name}${Date.now()}`; - const res = await ctx.post(`${WORKER_URL}/api/new_address`, { + const res = await ctx.post(`${workerUrl}/api/new_address`, { data: { name: uniqueName, domain }, }); if (!res.ok()) { diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 0756436..0059410 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -18,6 +18,7 @@ JWT_SECRET = "e2e-test-secret-key" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true +ENABLE_MAIL_READ_STATUS = true ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 NO_LIMIT_SEND_ROLE = "case-role" diff --git a/e2e/tests/api/mail-read.spec.ts b/e2e/tests/api/mail-read.spec.ts new file mode 100644 index 0000000..a1d1311 --- /dev/null +++ b/e2e/tests/api/mail-read.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; + +import { + WORKER_URL, + WORKER_URL_ENV_OFF, + createTestAddress, + deleteAddress, + seedTestMail, +} from '../../fixtures/test-helpers'; + +const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` }); + +test.describe('Mail read status', () => { + test('keeps historical mail read and switches one new mail state', async ({ request }) => { + const mailbox = await createTestAddress(request, 'mail-read'); + try { + const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, { + data: { + address: mailbox.address, + source: 'sender@test.example.com', + raw: 'From: sender@test.example.com\r\nSubject: Historical\r\n\r\nHistorical', + }, + }); + expect(historical.ok()).toBe(true); + await seedTestMail(request, mailbox.address, { subject: 'New unread mail' }); + + const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: headers(mailbox.jwt), + }); + const mails = (await list.json()).results; + expect(mails.find((mail: any) => mail.raw.includes('Historical')).is_unread).toBeNull(); + const unreadMail = mails.find((mail: any) => mail.raw.includes('New unread mail')); + expect(unreadMail.is_unread).toBe(1); + + const markRead = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, { + headers: headers(mailbox.jwt), + data: { isUnread: false }, + }); + expect((await markRead.json()).success).toBe(true); + + const updated = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: headers(mailbox.jwt), + }); + const updatedMail = (await updated.json()).results.find((mail: any) => mail.id === unreadMail.id); + expect(updatedMail.is_unread).toBe(0); + + const markUnread = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, { + headers: headers(mailbox.jwt), + data: { isUnread: true }, + }); + expect((await markUnread.json()).success).toBe(true); + + const restored = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: headers(mailbox.jwt), + }); + const restoredMail = (await restored.json()).results.find((mail: any) => mail.id === unreadMail.id); + expect(restoredMail.is_unread).toBe(1); + + const invalid = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, { + headers: headers(mailbox.jwt), + data: { isUnread: 'yes' }, + }); + expect(invalid.status()).toBe(400); + } finally { + await deleteAddress(request, mailbox.jwt); + } + }); + + test('scopes the update and keeps disabled instances unchanged', async ({ request }) => { + const first = await createTestAddress(request, 'mail-read-first'); + const second = await createTestAddress(request, 'mail-read-second'); + try { + await seedTestMail(request, second.address, { subject: 'Second mail' }); + const secondList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: headers(second.jwt), + }); + const secondMail = (await secondList.json()).results[0]; + + await request.patch(`${WORKER_URL}/api/mails/${secondMail.id}/read`, { + headers: headers(first.jwt), + data: { isUnread: false }, + }); + const unchanged = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: headers(second.jwt), + }); + expect((await unchanged.json()).results[0].is_unread).toBe(1); + + const disabledMailbox = await createTestAddress( + request, + 'mail-read-disabled', + 'test.example.com', + WORKER_URL_ENV_OFF, + ); + const oldList = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, { + headers: headers(disabledMailbox.jwt), + }); + expect(oldList.ok()).toBe(true); + const disabledUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/1/read`, { + headers: headers(disabledMailbox.jwt), + data: { isUnread: false }, + }); + expect(disabledUpdate.status()).toBe(403); + } finally { + await deleteAddress(request, first.jwt); + await deleteAddress(request, second.jwt); + } + }); +}); diff --git a/e2e/tests/browser/mail-read.spec.ts b/e2e/tests/browser/mail-read.spec.ts new file mode 100644 index 0000000..6f8372a --- /dev/null +++ b/e2e/tests/browser/mail-read.spec.ts @@ -0,0 +1,79 @@ +import { expect, request as apiRequest, test } from '@playwright/test'; + +import { + FRONTEND_URL, + WORKER_URL, + createTestAddress, + deleteAddress, + seedTestMail, +} from '../../fixtures/test-helpers'; + +test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => { + const request = await apiRequest.newContext(); + let jwt: string | undefined; + try { + const mailbox = await createTestAddress(request, 'mail-read-browser'); + jwt = mailbox.jwt; + const subject = `Unread browser mail ${Date.now()}`; + await seedTestMail(request, mailbox.address, { subject }); + + await page.goto(`${FRONTEND_URL}/en/`); + await page.evaluate(() => localStorage.setItem('mailListView', 'true')); + await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`); + await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 }); + + await page.reload(); + await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 }); + const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject)); + expect(mail.is_unread).toBe(1); + await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible(); + + await page.route('**/api/mails/*/read', async route => { + await new Promise(resolve => setTimeout(resolve, 300)); + await route.continue(); + }); + const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response => + /\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname) + && response.request().method() === 'PATCH' + && response.request().postDataJSON().isUnread === isUnread + ); + const readResponse = waitForReadUpdate(false); + await page.getByText(subject, { exact: true }).click(); + expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0); + expect(await page.locator('.n-spin-content--spinning').count()).toBe(0); + await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/); + expect((await readResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/); + + const unreadResponse = waitForReadUpdate(true); + await page.getByRole('button', { name: 'Mark as Unread' }).click(); + expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1); + expect(await page.locator('.n-spin-content--spinning').count()).toBe(0); + await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/); + expect((await unreadResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/); + + const manualReadResponse = waitForReadUpdate(false); + await page.getByRole('button', { name: 'Mark as Read' }).click(); + expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0); + expect(await page.locator('.n-spin-content--spinning').count()).toBe(0); + await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/); + expect((await manualReadResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/); + + const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id); + expect(updatedMail.is_unread).toBe(0); + } finally { + try { + if (jwt) await deleteAddress(request, jwt); + } finally { + await request.dispose(); + } + } +}); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ee82686..9eeee9b 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -21,7 +21,8 @@ const instance = axios.create({ }); const apiFetch = async (path, options = {}) => { - loading.value = true; + const showLoading = options.showLoading !== false; + if (showLoading) loading.value = true; try { // Get browser fingerprint for request tracking const fingerprint = await getFingerprint(); @@ -67,7 +68,7 @@ const apiFetch = async (path, options = {}) => { } throw error; } finally { - loading.value = false; + if (showLoading) loading.value = false; } } @@ -99,6 +100,7 @@ const getOpenSettings = async (message, notification) => { disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false, disableCustomAddressName: res["disableCustomAddressName"] || false, enableUserDeleteEmail: res["enableUserDeleteEmail"] || false, + enableMailReadStatus: res["enableMailReadStatus"] === true, enableAutoReply: res["enableAutoReply"] || false, enableIndexAbout: res["enableIndexAbout"] || false, copyright: res["copyright"] || openSettings.value.copyright, diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue index ce16d66..0777efa 100644 --- a/frontend/src/components/MailBox.vue +++ b/frontend/src/components/MailBox.vue @@ -55,6 +55,14 @@ const props = defineProps({ default: false, required: false }, + enableMailReadStatus: { + type: Boolean, + default: false + }, + updateMailReadStatus: { + type: Function, + default: () => { } + }, }) const localFilterKeyword = ref('') @@ -94,6 +102,29 @@ const data = computed(() => { }); }) +const openMail = (mail) => { + curMail.value = mail + if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return + mail.is_unread = 0 + void props.updateMailReadStatus(mail.id, false).catch(() => { + mail.is_unread = 1 + }) +} + +const toggleCurrentMailUnread = async () => { + if (!curMail.value || !props.enableMailReadStatus) return + const mail = curMail.value + const previousValue = mail.is_unread + const isUnread = previousValue !== 1 + mail.is_unread = isUnread ? 1 : 0 + try { + await props.updateMailReadStatus(mail.id, isUnread) + message.success(t("success")) + } catch { + mail.is_unread = previousValue + } +} + const canGoPrevMail = computed(() => { if (!curMail.value) return false const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) @@ -111,12 +142,12 @@ const prevMail = async () => { const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) if (currentIndex > 0) { - curMail.value = data.value[currentIndex - 1] + openMail(data.value[currentIndex - 1]) } else if (page.value > 1) { page.value-- await refresh() if (data.value.length > 0) { - curMail.value = data.value[data.value.length - 1] + openMail(data.value[data.value.length - 1]) } } } @@ -126,12 +157,12 @@ const nextMail = async () => { const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) if (currentIndex < data.value.length - 1) { - curMail.value = data.value[currentIndex + 1] + openMail(data.value[currentIndex + 1]) } else if (count.value > page.value * pageSize.value) { page.value++ await refresh() if (data.value.length > 0) { - curMail.value = data.value[0] + openMail(data.value[0]) } } } @@ -205,7 +236,7 @@ const backFirstPageAndRefresh = async () => { await refresh(); } -const clickRow = async (row) => { +const clickRow = (row) => { if (multiActionMode.value) { row.checked = !row.checked; curMail.value = row; @@ -215,7 +246,7 @@ const clickRow = async (row) => { curMail.value = null; return; } - curMail.value = row; + openMail(row); }; @@ -397,7 +428,7 @@ onBeforeUnmount(() => {
+ :class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]"> @@ -461,6 +492,7 @@ onBeforeUnmount(() => { style="overflow: auto; max-height: 100vh;"> @@ -475,7 +507,7 @@ onBeforeUnmount(() => {
+ :class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]"> @@ -536,7 +568,8 @@ onBeforeUnmount(() => {
- +