diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e9681..accc592 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..d35c938 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,6 +10,7 @@ ### Features +- feat: |Mail State| Add optional mail states backed by a separate sparse relation table without changing the raw-mail table; read status and low-write Flagged mail use independent switches and support indexed combined filters - 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-25-mail-flags.sql b/db/2026-08-25-mail-flags.sql new file mode 100644 index 0000000..0b72e92 --- /dev/null +++ b/db/2026-08-25-mail-flags.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS mail_flags ( + mail_id INTEGER NOT NULL, + address_id INTEGER NOT NULL, + flag INTEGER NOT NULL, + PRIMARY KEY (mail_id, flag) +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC); diff --git a/db/schema.sql b/db/schema.sql index 321d9bf..eebe4f3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -15,6 +15,15 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at); CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id); +CREATE TABLE IF NOT EXISTS mail_flags ( + mail_id INTEGER NOT NULL, + address_id INTEGER NOT NULL, + flag INTEGER NOT NULL, + PRIMARY KEY (mail_id, flag) +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC); + CREATE TABLE IF NOT EXISTS address ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 0756436..b513bf3 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -18,6 +18,8 @@ JWT_SECRET = "e2e-test-secret-key" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true +ENABLE_MAIL_READ_STATUS = true +ENABLE_MAIL_FLAGGED = true ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 NO_LIMIT_SEND_ROLE = "case-role" diff --git a/e2e/fixtures/wrangler.toml.e2e.gzip b/e2e/fixtures/wrangler.toml.e2e.gzip index f52fb31..086e103 100644 --- a/e2e/fixtures/wrangler.toml.e2e.gzip +++ b/e2e/fixtures/wrangler.toml.e2e.gzip @@ -20,6 +20,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true E2E_TEST_MODE = true ENABLE_MAIL_GZIP = true +ENABLE_MAIL_READ_STATUS = true SMTP_CONFIG = """ {"test.example.com":{"host":"mailpit","port":1025,"secure":false}} """ diff --git a/e2e/fixtures/wrangler.toml.e2e.send-mail-domain b/e2e/fixtures/wrangler.toml.e2e.send-mail-domain index b227ad4..26dbb3e 100644 --- a/e2e/fixtures/wrangler.toml.e2e.send-mail-domain +++ b/e2e/fixtures/wrangler.toml.e2e.send-mail-domain @@ -24,6 +24,7 @@ DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true E2E_TEST_MODE = true +ENABLE_MAIL_FLAGGED = true SMTP_CONFIG = """ {"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} """ diff --git a/e2e/tests/api/mail-flags.spec.ts b/e2e/tests/api/mail-flags.spec.ts new file mode 100644 index 0000000..8e151bd --- /dev/null +++ b/e2e/tests/api/mail-flags.spec.ts @@ -0,0 +1,361 @@ +import { test, expect, type APIRequestContext } from '@playwright/test'; +import { + WORKER_URL, + WORKER_URL_ENV_OFF, + WORKER_GZIP_URL, + WORKER_URL_SEND_MAIL_DOMAIN, + createTestAddress, + deleteAddress, + hashPassword, + seedTestMail, +} from '../../fixtures/test-helpers'; + +const addressHeaders = (jwt: string) => ({ Authorization: `Bearer ${jwt}` }); + +async function createAddressAt( + request: APIRequestContext, + baseUrl: string, + name: string, + domain: string, +) { + const response = await request.post(`${baseUrl}/api/new_address`, { + data: { name: `${name}${Date.now()}`, domain }, + }); + expect(response.ok()).toBe(true); + return await response.json(); +} + +async function receiveMailAt( + request: APIRequestContext, + baseUrl: string, + address: string, +) { + const raw = [ + 'From: sender@example.com', + `To: ${address}`, + `Subject: Split flags ${Date.now()}`, + '', + 'Split flags body', + ].join('\r\n'); + const response = await request.post(`${baseUrl}/admin/test/receive_mail`, { + data: { from: 'sender@example.com', to: address, raw }, + }); + expect(response.ok()).toBe(true); +} + +async function listMails( + request: APIRequestContext, + jwt: string, + state = 'all', + baseUrl = WORKER_URL, +) { + const response = await request.get( + `${baseUrl}/api/mails?limit=100&offset=0&mail_state=${state}`, + { headers: addressHeaders(jwt) }, + ); + expect(response.ok()).toBe(true); + return await response.json(); +} + +async function updateState( + request: APIRequestContext, + jwt: string, + ids: number[], + state: string, +) { + const response = await request.patch(`${WORKER_URL}/api/mails/state`, { + headers: addressHeaders(jwt), + data: { ids, state }, + }); + expect(response.ok()).toBe(true); + return await response.json(); +} + +async function updateFlagged( + request: APIRequestContext, + jwt: string, + ids: number[], + flagged: boolean, +) { + const response = await request.patch(`${WORKER_URL}/api/mails/flagged`, { + headers: addressHeaders(jwt), + data: { ids, flagged }, + }); + expect(response.ok()).toBe(true); + return await response.json(); +} + +test.describe('Mail states', () => { + test('read status and Flagged switches are independent', async ({ request }) => { + test.skip( + !WORKER_GZIP_URL || !WORKER_URL_SEND_MAIL_DOMAIN, + 'Mixed feature workers are not configured', + ); + + const readOnly = await createAddressAt( + request, WORKER_GZIP_URL, 'mail-read-only', 'test.example.com', + ); + const flaggedOnly = await createAddressAt( + request, WORKER_URL_SEND_MAIL_DOMAIN, 'mail-flagged-only', 'TEST.EXAMPLE.COM', + ); + + try { + await receiveMailAt(request, WORKER_GZIP_URL, readOnly.address); + await receiveMailAt(request, WORKER_URL_SEND_MAIL_DOMAIN, flaggedOnly.address); + + const readSettings = await (await request.get( + `${WORKER_GZIP_URL}/open_api/settings`, + )).json(); + expect(readSettings.enableMailReadStatus).toBe(true); + expect(readSettings).not.toHaveProperty('enableMailFlagged'); + + const flaggedSettings = await (await request.get( + `${WORKER_URL_SEND_MAIL_DOMAIN}/open_api/settings`, + )).json(); + expect(flaggedSettings).not.toHaveProperty('enableMailReadStatus'); + expect(flaggedSettings.enableMailFlagged).toBe(true); + + const readList = await request.get( + `${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, + { headers: addressHeaders(readOnly.jwt) }, + ); + const readMail = (await readList.json()).results[0]; + expect(readMail.unread).toBe(true); + expect(readMail).not.toHaveProperty('flagged'); + expect((await request.get(`${WORKER_GZIP_URL}/api/mail-states`, { + headers: addressHeaders(readOnly.jwt), + })).ok()).toBe(true); + expect((await request.patch(`${WORKER_GZIP_URL}/api/mails/flagged`, { + headers: addressHeaders(readOnly.jwt), + data: { ids: [readMail.id], flagged: true }, + })).status()).toBe(403); + + const flaggedList = await request.get( + `${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails?limit=10&offset=0`, + { headers: addressHeaders(flaggedOnly.jwt) }, + ); + const flaggedMail = (await flaggedList.json()).results[0]; + expect(flaggedMail).not.toHaveProperty('unread'); + expect(flaggedMail.flagged).toBe(false); + expect((await request.get(`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mail-states`, { + headers: addressHeaders(flaggedOnly.jwt), + })).status()).toBe(403); + const addedStar = await request.patch( + `${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails/flagged`, + { + headers: addressHeaders(flaggedOnly.jwt), + data: { ids: [flaggedMail.id], flagged: true }, + }, + ); + expect((await addedStar.json()).results).toEqual([{ id: flaggedMail.id, flagged: true }]); + } finally { + await Promise.allSettled([ + request.delete(`${WORKER_GZIP_URL}/admin/delete_address/${readOnly.address_id}`), + request.delete( + `${WORKER_URL_SEND_MAIL_DOMAIN}/admin/delete_address/${flaggedOnly.address_id}`, + ), + ]); + } + }); + + test('supports unread lifecycle, historical mail, filtering and mailbox isolation', async ({ request }) => { + const first = await createTestAddress(request, 'mail-state-first'); + const second = await createTestAddress(request, 'mail-state-second'); + + try { + const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, { + data: { address: first.address, raw: 'Historical mail' }, + }); + expect(historical.ok()).toBe(true); + + await seedTestMail(request, first.address, { subject: 'Unread one' }); + await seedTestMail(request, first.address, { subject: 'Unread two' }); + await seedTestMail(request, second.address, { subject: 'Other mailbox' }); + + const states = await request.get(`${WORKER_URL}/api/mail-states`, { + headers: addressHeaders(first.jwt), + }); + expect(states.ok()).toBe(true); + expect((await states.json()).results.map((state: { value: string }) => state.value)) + .toEqual(['all', 'unread', 'read']); + + const initial = await listMails(request, first.jwt); + expect(initial.count).toBe(3); + expect(initial.results.filter((mail: { unread: boolean }) => mail.unread)).toHaveLength(2); + expect(initial.results.filter((mail: { unread: boolean }) => !mail.unread)).toHaveLength(1); + expect(initial.results.every((mail: { flagged: boolean }) => !mail.flagged)).toBe(true); + + const unreadIds = initial.results + .filter((mail: { unread: boolean }) => mail.unread) + .map((mail: { id: number }) => mail.id); + const denied = await updateState(request, second.jwt, [unreadIds[0]], 'read'); + expect(denied.changes).toBe(0); + const deniedStar = await updateFlagged(request, second.jwt, [unreadIds[0]], true); + expect(deniedStar.changes).toBe(0); + + const addedStar = await updateFlagged(request, first.jwt, [unreadIds[0]], true); + expect(addedStar.results).toEqual([{ id: unreadIds[0], flagged: true }]); + + const markedRead = await updateState(request, first.jwt, unreadIds, 'read'); + expect(markedRead.changes).toBe(2); + expect(markedRead.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true); + expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(0); + expect((await listMails(request, first.jwt, 'read')).results).toHaveLength(3); + + const flagged = await request.get( + `${WORKER_URL}/api/mails?limit=100&offset=0&mail_state=read&flagged=true`, + { headers: addressHeaders(first.jwt) }, + ); + const flaggedMails = (await flagged.json()).results; + expect(flaggedMails).toHaveLength(1); + expect(flaggedMails[0]).toMatchObject({ id: unreadIds[0], unread: false, flagged: true }); + + const markedUnread = await updateState(request, first.jwt, [unreadIds[0]], 'unread'); + expect(markedUnread.results).toEqual([{ id: unreadIds[0], unread: true }]); + expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(1); + + const detail = await request.get(`${WORKER_URL}/api/mail/${unreadIds[0]}`, { + headers: addressHeaders(first.jwt), + }); + expect(await detail.json()).toMatchObject({ unread: true, flagged: true }); + + await updateFlagged(request, first.jwt, [unreadIds[0]], false); + const noFlagged = await request.get( + `${WORKER_URL}/api/mails?limit=100&offset=0&flagged=true`, + { headers: addressHeaders(first.jwt) }, + ); + expect((await noFlagged.json()).results).toHaveLength(0); + + const invalid = await request.patch(`${WORKER_URL}/api/mails/state`, { + headers: addressHeaders(first.jwt), + data: { ids: [unreadIds[0]], state: 'unknown' }, + }); + expect(invalid.status()).toBe(400); + } finally { + await deleteAddress(request, first.jwt); + await deleteAddress(request, second.jwt); + } + }); + + test('user APIs only expose and update bound-address mail', async ({ request }) => { + let originalSettings: Record | undefined; + let userId: number | undefined; + const mailboxes: Awaited>[] = []; + + try { + const settings = await request.get(`${WORKER_URL}/admin/user_settings`); + originalSettings = await settings.json(); + await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { ...originalSettings, enable: true, enableMailVerify: false, maxAddressCount: 0 }, + }); + + const email = `mail-state-user-${Date.now()}@test.example.com`; + const password = hashPassword('mail-state-password'); + expect((await request.post(`${WORKER_URL}/user_api/register`, { + data: { email, password }, + })).ok()).toBe(true); + const login = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + const { jwt: userJwt } = await login.json(); + userId = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString()).user_id; + + const bound = await createTestAddress(request, 'mail-state-bound'); + const outsider = await createTestAddress(request, 'mail-state-outsider'); + mailboxes.push(bound, outsider); + const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { ...addressHeaders(bound.jwt), 'x-user-token': userJwt }, + }); + expect(bind.ok()).toBe(true); + + await seedTestMail(request, bound.address, { subject: 'Bound unread' }); + await seedTestMail(request, outsider.address, { subject: 'Outsider unread' }); + + const userList = await request.get( + `${WORKER_URL}/user_api/mails?limit=20&offset=0&mail_state=unread`, + { headers: { 'x-user-token': userJwt } }, + ); + const userMails = await userList.json(); + expect(userMails.results).toHaveLength(1); + expect(userMails.results[0].address).toBe(bound.address); + + const outsiderMail = (await listMails(request, outsider.jwt)).results[0]; + const denied = await request.patch(`${WORKER_URL}/user_api/mails/state`, { + headers: { 'x-user-token': userJwt }, + data: { ids: [outsiderMail.id], state: 'read' }, + }); + expect((await denied.json()).changes).toBe(0); + + const update = await request.patch(`${WORKER_URL}/user_api/mails/state`, { + headers: { 'x-user-token': userJwt }, + data: { ids: [userMails.results[0].id], state: 'read' }, + }); + expect((await update.json()).results[0].unread).toBe(false); + + const addStar = await request.patch(`${WORKER_URL}/user_api/mails/flagged`, { + headers: { 'x-user-token': userJwt }, + data: { ids: [userMails.results[0].id], flagged: true }, + }); + expect((await addStar.json()).results[0].flagged).toBe(true); + + const flagged = await request.get( + `${WORKER_URL}/user_api/mails?limit=20&offset=0&flagged=true`, + { headers: { 'x-user-token': userJwt } }, + ); + expect((await flagged.json()).results).toHaveLength(1); + } finally { + await Promise.allSettled(mailboxes.map(mailbox => deleteAddress(request, mailbox.jwt))); + if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`); + if (originalSettings) { + await request.post(`${WORKER_URL}/admin/user_settings`, { data: originalSettings }); + } + } + }); + + test('disabled feature keeps existing responses unchanged', async ({ request }) => { + test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured'); + + const created = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, { + data: { name: `mail-state-off-${Date.now()}`, domain: 'test.example.com' }, + }); + const mailbox = await created.json(); + + try { + const raw = [ + 'From: sender@example.com', + `To: ${mailbox.address}`, + 'Subject: States disabled', + '', + 'Disabled body', + ].join('\r\n'); + await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, { + data: { from: 'sender@example.com', to: mailbox.address, raw }, + }); + + const list = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, { + headers: addressHeaders(mailbox.jwt), + }); + const body = await list.json(); + expect(body.results).toHaveLength(1); + expect(body.results[0]).not.toHaveProperty('unread'); + expect(body.results[0]).not.toHaveProperty('flagged'); + + const states = await request.get(`${WORKER_URL_ENV_OFF}/api/mail-states`, { + headers: addressHeaders(mailbox.jwt), + }); + expect(states.status()).toBe(403); + const update = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/state`, { + headers: addressHeaders(mailbox.jwt), + data: { ids: [body.results[0].id], state: 'read' }, + }); + expect(update.status()).toBe(403); + const disabledFlagUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/flagged`, { + headers: addressHeaders(mailbox.jwt), + data: { ids: [body.results[0].id], flagged: true }, + }); + expect(disabledFlagUpdate.status()).toBe(403); + } finally { + await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${mailbox.address_id}`); + } + }); +}); diff --git a/e2e/tests/browser/mail-flags.spec.ts b/e2e/tests/browser/mail-flags.spec.ts new file mode 100644 index 0000000..16571f8 --- /dev/null +++ b/e2e/tests/browser/mail-flags.spec.ts @@ -0,0 +1,160 @@ +import { expect, request as apiRequest, test } from '@playwright/test'; + +import { + FRONTEND_URL, + WORKER_URL, + createTestAddress, + deleteAddress, + seedTestMail, +} from '../../fixtures/test-helpers'; + +test.describe('Mail state browser flow', () => { + test('does not mark the initial desktop preview as read before a click', async ({ page }) => { + const request = await apiRequest.newContext(); + let jwt: string | undefined; + + try { + const mailbox = await createTestAddress(request, 'mail-preview-unread'); + jwt = mailbox.jwt; + const subject = `Preview unread ${Date.now()}`; + await seedTestMail(request, mailbox.address, { subject }); + + await page.goto(`${FRONTEND_URL}/en/`); + await page.evaluate(() => localStorage.setItem('mailListView', 'false')); + await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`); + await expect(page.getByText(subject, { exact: true }).first()).toBeVisible({ timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + const beforeClick = await request.get( + `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, + { headers: { Authorization: `Bearer ${jwt}` } }, + ); + expect((await beforeClick.json()).results).toHaveLength(1); + + const readResponse = page.waitForResponse((response) => { + return new URL(response.url()).pathname === '/api/mails/state' + && response.request().method() === 'PATCH'; + }); + await page.getByText(subject, { exact: true }).first().click(); + expect((await readResponse).ok()).toBe(true); + + const afterClick = await request.get( + `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, + { headers: { Authorization: `Bearer ${jwt}` } }, + ); + expect((await afterClick.json()).results).toHaveLength(0); + } finally { + try { + if (jwt) await deleteAddress(request, jwt); + } finally { + await request.dispose(); + } + } + }); + + test('opens, toggles, filters and marks the current page read', async ({ page }) => { + const request = await apiRequest.newContext(); + let jwt: string | undefined; + + try { + const mailbox = await createTestAddress(request, 'mail-flags-browser'); + jwt = mailbox.jwt; + const subjects = [`Unread A ${Date.now()}`, `Unread B ${Date.now()}`]; + for (const subject of subjects) { + 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}`); + + for (const subject of subjects) { + await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 }); + } + await expect(page.getByText('Unread', { exact: true })).toHaveCount(2); + + const openStateResponse = page.waitForResponse((response) => { + return new URL(response.url()).pathname === '/api/mails/state' + && response.request().method() === 'PATCH'; + }); + await page.getByText(subjects[0], { exact: true }).click(); + expect((await openStateResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Mark as Unread' })).toBeVisible(); + + const flaggedResponse = page.waitForResponse((response) => { + return new URL(response.url()).pathname === '/api/mails/flagged' + && response.request().method() === 'PATCH'; + }); + await page.locator('.mail-content-renderer').getByRole('button', { name: 'Add Star' }).click(); + expect((await flaggedResponse).ok()).toBe(true); + await expect( + page.locator('.mail-content-renderer').getByRole('button', { name: 'Remove Star' }), + ).toBeVisible(); + + const unreadAfterOpen = await request.get( + `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, + { headers: { Authorization: `Bearer ${jwt}` } }, + ); + expect((await unreadAfterOpen.json()).results).toHaveLength(1); + + const toggleResponse = page.waitForResponse((response) => { + return new URL(response.url()).pathname === '/api/mails/state' + && response.request().method() === 'PATCH'; + }); + await page.getByRole('button', { name: 'Mark as Unread' }).click(); + expect((await toggleResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Mark as Read' })).toBeVisible(); + + await page.getByRole('button', { name: 'Back to List' }).click(); + + const flaggedFilterResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/api/mails' && url.searchParams.get('flagged') === 'true'; + }); + await page.getByRole('checkbox', { name: 'Flagged' }).check(); + expect((await flaggedFilterResponse).ok()).toBe(true); + await expect(page.getByText(subjects[0], { exact: true })).toBeVisible(); + await expect(page.getByText(subjects[1], { exact: true })).toHaveCount(0); + const allMailResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/api/mails' && !url.searchParams.has('flagged'); + }); + await page.getByRole('checkbox', { name: 'Flagged' }).uncheck(); + expect((await allMailResponse).ok()).toBe(true); + + const pageReadResponse = page.waitForResponse((response) => { + if (new URL(response.url()).pathname !== '/api/mails/state') return false; + if (response.request().method() !== 'PATCH') return false; + const body = response.request().postDataJSON(); + return body.state === 'read' && body.ids.length === 2; + }); + await page.getByRole('button', { name: 'Mark This Page as Read' }).click(); + expect((await pageReadResponse).ok()).toBe(true); + await expect(page.getByRole('button', { name: 'Mark This Page as Read' })).toBeHidden(); + + const unreadAfterPage = await request.get( + `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, + { headers: { Authorization: `Bearer ${jwt}` } }, + ); + expect((await unreadAfterPage.json()).results).toHaveLength(0); + + const unreadFilterResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === '/api/mails' && url.searchParams.get('mail_state') === 'unread'; + }); + const stateSelect = page.locator('.n-select').filter({ hasText: 'All Mail' }).first(); + await stateSelect.click(); + await page.locator('.n-base-select-option').filter({ hasText: /^Unread$/ }).click(); + expect((await unreadFilterResponse).ok()).toBe(true); + for (const subject of subjects) { + await expect(page.getByText(subject, { exact: true })).toHaveCount(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..4a27bf9 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -99,6 +99,8 @@ const getOpenSettings = async (message, notification) => { disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false, disableCustomAddressName: res["disableCustomAddressName"] || false, enableUserDeleteEmail: res["enableUserDeleteEmail"] || false, + enableMailReadStatus: res["enableMailReadStatus"] === true, + enableMailFlagged: res["enableMailFlagged"] === 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..75e86d6 100644 --- a/frontend/src/components/MailBox.vue +++ b/frontend/src/components/MailBox.vue @@ -3,7 +3,10 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue"; import { useMessage } from 'naive-ui' import { useScopedI18n } from '@/i18n/app' import { useGlobalState } from '../store' -import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material' +import { + CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound, + StarBorderRound, StarRound +} from '@vicons/material' import { useIsMobile } from '../utils/composables' import { processItem } from '../utils/email-parser' import { utcToLocalDate } from '../utils'; @@ -55,9 +58,37 @@ const props = defineProps({ default: false, required: false }, + enableMailReadStatus: { + type: Boolean, + default: false, + required: false + }, + enableMailFlagged: { + type: Boolean, + default: false, + required: false + }, + updateMailState: { + type: Function, + default: () => { }, + required: false + }, + updateMailFlagged: { + type: Function, + default: () => { }, + required: false + }, + fetchMailStates: { + type: Function, + default: () => ({ results: [] }), + required: false + }, }) const localFilterKeyword = ref('') +const mailStateFilter = ref(null) +const flaggedOnly = ref(false) +const mailStates = ref([]) const { isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate, @@ -94,6 +125,73 @@ const data = computed(() => { }); }) +const isMailUnread = (mail) => { + return props.enableMailReadStatus && mail?.unread === true +} + +const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread)) +const mailStateFilterOptions = computed(() => mailStates.value.map(state => ({ + label: state.label || t(state.label_key), + value: state.value, +}))) + +const getReadStateValue = (unread) => { + return mailStates.value.find(state => state.unread === unread)?.value +} + +const updateUnreadState = async (mails, state) => { + if (mails.length === 0 || !state) return true + try { + const response = await props.updateMailState(mails.map(mail => mail.id), state) + const results = response?.results ?? [] + const resultById = new Map(results.map(result => [result.id, result])) + mails.forEach(mail => { + const result = resultById.get(mail.id) + if (result) mail.unread = result.unread + }) + return true + } catch (error) { + message.error(error.message || "error") + return false + } +} + +const markMailsRead = async (mails) => { + return await updateUnreadState(mails.filter(isMailUnread), getReadStateValue(false)) +} + +const toggleCurrentMailUnread = async () => { + if (!curMail.value) return + await updateUnreadState([curMail.value], getReadStateValue(!curMail.value.unread)) +} + +const toggleMailFlagged = async (mail) => { + if (!mail) return + try { + const response = await props.updateMailFlagged([mail.id], !mail.flagged) + const result = response?.results?.[0] + if (result) mail.flagged = result.flagged + if (flaggedOnly.value && !mail.flagged) await backFirstPageAndRefresh() + } catch (error) { + message.error(error.message || "error") + } +} + +const toggleCurrentMailFlagged = async () => { + await toggleMailFlagged(curMail.value) +} + +const openMail = async (mail) => { + curMail.value = mail + await markMailsRead([mail]) +} + +const markCurrentPageRead = async () => { + if (!await markMailsRead(rawData.value)) return + message.success(t("success")) + if (mailStateFilter.value === getReadStateValue(true)) await backFirstPageAndRefresh() +} + const canGoPrevMail = computed(() => { if (!curMail.value) return false const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) @@ -111,12 +209,12 @@ const prevMail = async () => { const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) if (currentIndex > 0) { - curMail.value = data.value[currentIndex - 1] + await 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] + await openMail(data.value[data.value.length - 1]) } } } @@ -126,12 +224,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] + await 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] + await openMail(data.value[0]) } } } @@ -165,6 +263,22 @@ const setupAutoRefresh = async (autoRefresh) => { } } +const loadMailStates = async () => { + if (!props.enableMailReadStatus) { + mailStates.value = [] + mailStateFilter.value = null + return + } + try { + const { results = [] } = await props.fetchMailStates() + mailStates.value = results + mailStateFilter.value = results.find(state => state.default)?.value ?? results[0]?.value ?? null + } catch (error) { + mailStates.value = [] + message.error(error.message || "error") + } +} + watch(autoRefresh, async (autoRefresh, old) => { setupAutoRefresh(autoRefresh) }, { immediate: true }) @@ -175,19 +289,32 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => { } }) +watch(mailStateFilter, async (_value, oldValue) => { + if (oldValue === null) return + await backFirstPageAndRefresh() +}) + +watch(flaggedOnly, async () => { + await backFirstPageAndRefresh() +}) + +watch(() => props.enableMailReadStatus, async (enabled, oldValue) => { + if (enabled === oldValue) return + await loadMailStates() +}) + const refresh = async () => { try { const { results, count: totalCount } = await props.fetchMailData( - pageSize.value, (page.value - 1) * pageSize.value + pageSize.value, (page.value - 1) * pageSize.value, mailStateFilter.value, + flaggedOnly.value ); loading.value = true; rawData.value = await Promise.all(results.map(async (item) => { item.checked = false; return await processItem(item); })); - if (totalCount > 0) { - count.value = totalCount; - } + if (page.value === 1) count.value = totalCount; curMail.value = null; if (!isMobile.value && !mailListView.value && data.value.length > 0) { curMail.value = data.value[0]; @@ -215,7 +342,7 @@ const clickRow = async (row) => { curMail.value = null; return; } - curMail.value = row; + await openMail(row); }; @@ -329,6 +456,7 @@ const multiActionDownload = async () => { } onMounted(async () => { + await loadMailStates() await refresh(); }); @@ -381,6 +509,14 @@ onBeforeUnmount(() => { {{ t('refresh') }} + + {{ t('markCurrentPageRead') }} + + + + {{ t('flagged') }} + @@ -397,12 +533,21 @@ onBeforeUnmount(() => {
-