diff --git a/CHANGELOG.md b/CHANGELOG.md index 155744e..7455f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- feat: |邮件状态| 新增可选 Mail Flags 功能,新邮件支持已读/未读状态、打开自动已读、手动切换状态、本页全部已读及按状态筛选,并为系统状态位及每地址自定义规则 Flag 预留扩展空间 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API @@ -25,6 +26,7 @@ ### Testing +- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法 Flag 掩码校验 - test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复 - fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览 - fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 0d2eac3..f9398be 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,6 +10,7 @@ ### Features +- feat: |Mail State| Add optional Mail Flags with unread state for new mail, automatic read-on-open, manual state toggling, mark-current-page-read and state filters, plus reserved system/custom bits for future per-address rule assignment - 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 @@ -25,6 +26,7 @@ ### Testing +- test: |E2E| Cover unread state on new mail, mailbox isolation, marking mail read, and invalid flag-mask validation - 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 diff --git a/db/2026-08-25-mail-flags.sql b/db/2026-08-25-mail-flags.sql new file mode 100644 index 0000000..7fcff9d --- /dev/null +++ b/db/2026-08-25-mail-flags.sql @@ -0,0 +1 @@ +ALTER TABLE raw_mails ADD COLUMN flags INTEGER; diff --git a/db/schema.sql b/db/schema.sql index 321d9bf..b7e7d9c 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, + flags INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 0756436..4f042f0 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_FLAGS = true ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 NO_LIMIT_SEND_ROLE = "case-role" diff --git a/e2e/tests/api/mail-flags.spec.ts b/e2e/tests/api/mail-flags.spec.ts new file mode 100644 index 0000000..eed09c7 --- /dev/null +++ b/e2e/tests/api/mail-flags.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { + WORKER_URL, + createTestAddress, + deleteAddress, + seedTestMail, +} from '../../fixtures/test-helpers'; + +test.describe('Mail Flags', () => { + test('new mail is unread and can be marked as read without changing other mailboxes', async ({ request }) => { + const first = await createTestAddress(request, 'mail-flags-first'); + const second = await createTestAddress(request, 'mail-flags-second'); + + try { + await seedTestMail(request, first.address, { subject: 'Unread mail' }); + const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: { Authorization: `Bearer ${first.jwt}` }, + }); + expect(listRes.ok()).toBe(true); + const { results } = await listRes.json(); + expect(results).toHaveLength(1); + expect(results[0].flags).toBe(1); + + const unreadRes = await request.get( + `${WORKER_URL}/api/mails?limit=10&offset=0&flag=0&flag_state=set`, + { headers: { Authorization: `Bearer ${first.jwt}` } }, + ); + expect((await unreadRes.json()).results).toHaveLength(1); + + const deniedRes = await request.patch(`${WORKER_URL}/api/mails/flags`, { + headers: { Authorization: `Bearer ${second.jwt}` }, + data: { ids: [results[0].id], add: 0, remove: 1 }, + }); + expect(deniedRes.ok()).toBe(true); + expect((await deniedRes.json()).changes).toBe(0); + + const updateRes = await request.patch(`${WORKER_URL}/api/mails/flags`, { + headers: { Authorization: `Bearer ${first.jwt}` }, + data: { ids: [results[0].id], add: 0, remove: 1 }, + }); + expect(updateRes.ok()).toBe(true); + expect((await updateRes.json()).changes).toBe(1); + + const updatedListRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: { Authorization: `Bearer ${first.jwt}` }, + }); + expect((await updatedListRes.json()).results[0].flags).toBe(0); + + const unreadAfterUpdateRes = await request.get( + `${WORKER_URL}/api/mails?limit=10&offset=0&flag=0&flag_state=set`, + { headers: { Authorization: `Bearer ${first.jwt}` } }, + ); + expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0); + } finally { + await deleteAddress(request, first.jwt); + await deleteAddress(request, second.jwt); + } + }); + + test('rejects unsupported and overlapping flag masks', async ({ request }) => { + const { jwt } = await createTestAddress(request, 'mail-flags-invalid'); + try { + for (const data of [ + { ids: [1], add: 4, remove: 0 }, + { ids: [1], add: 1, remove: 1 }, + ]) { + const res = await request.patch(`${WORKER_URL}/api/mails/flags`, { + headers: { Authorization: `Bearer ${jwt}` }, + data, + }); + expect(res.status()).toBe(400); + } + } finally { + await deleteAddress(request, jwt); + } + }); +}); diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue index ce16d66..341a96e 100644 --- a/frontend/src/components/MailBox.vue +++ b/frontend/src/components/MailBox.vue @@ -8,6 +8,7 @@ import { useIsMobile } from '../utils/composables' import { processItem } from '../utils/email-parser' import { utcToLocalDate } from '../utils'; import { buildReplyModel, buildForwardModel } from '../utils/mail-actions' +import { MAIL_FLAGS, hasMailFlag } from '../utils/mail-flags' import MailContentRenderer from "./MailContentRenderer.vue"; import AiExtractInfo from "./AiExtractInfo.vue"; @@ -55,9 +56,20 @@ const props = defineProps({ default: false, required: false }, + enableMailFlags: { + type: Boolean, + default: false, + required: false + }, + updateMailFlags: { + type: Function, + default: () => { }, + required: false + }, }) const localFilterKeyword = ref('') +const mailFlagFilter = ref('all') const { isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate, @@ -94,6 +106,60 @@ const data = computed(() => { }); }) +const isMailUnread = (mail) => { + return props.enableMailFlags && hasMailFlag(mail?.flags, MAIL_FLAGS.UNREAD) +} + +const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread)) +const mailFlagFilterOptions = computed(() => [ + { label: t('allMail'), value: 'all' }, + { label: t('unread'), value: 'unread' }, + { label: t('read'), value: 'read' }, +]) + +const setMailsUnread = async (mails, unread) => { + const changedMails = mails.filter(mail => isMailUnread(mail) !== unread) + if (changedMails.length === 0) return true + + changedMails.forEach(mail => { + const flags = Number(mail.flags ?? 0) + mail.flags = unread ? flags | MAIL_FLAGS.UNREAD : flags & ~MAIL_FLAGS.UNREAD + }) + try { + await props.updateMailFlags( + changedMails.map(mail => mail.id), + unread ? MAIL_FLAGS.UNREAD : 0, + unread ? 0 : MAIL_FLAGS.UNREAD + ) + return true + } catch (error) { + changedMails.forEach(mail => { + const flags = Number(mail.flags ?? 0) + mail.flags = unread ? flags & ~MAIL_FLAGS.UNREAD : flags | MAIL_FLAGS.UNREAD + }) + message.error(error.message || "error") + return false + } +} + +const markMailsRead = async (mails) => setMailsUnread(mails, false) + +const toggleCurrentMailUnread = async () => { + if (!curMail.value) return + await setMailsUnread([curMail.value], !isMailUnread(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 (mailFlagFilter.value === 'unread') await refresh() +} + const canGoPrevMail = computed(() => { if (!curMail.value) return false const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) @@ -111,12 +177,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 +192,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]) } } } @@ -175,22 +241,24 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => { } }) +watch(mailFlagFilter, async () => { + await backFirstPageAndRefresh() +}) + 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, mailFlagFilter.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]; + await openMail(data.value[0]); } } catch (error) { message.error(error.message || "error"); @@ -215,7 +283,7 @@ const clickRow = async (row) => { curMail.value = null; return; } - curMail.value = row; + await openMail(row); }; @@ -381,6 +449,11 @@ onBeforeUnmount(() => { {{ t('refresh') }} + + {{ t('markCurrentPageRead') }} + + @@ -397,12 +470,15 @@ onBeforeUnmount(() => {
+ :class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">