diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cd1f7..c055bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- feat: |邮件状态| 新增可选邮件状态功能,新邮件支持已读/未读、打开自动已读、手动切换状态、本页全部已读及按状态筛选;前端仅使用已读状态,底层 Flag 存储、计算与后续分组归属统一由后端处理 +- feat: |邮件状态| 新增可选邮件状态功能,新邮件支持已读/未读、打开自动已读、手动切换状态、本页全部已读及按状态筛选;后端统一返回系统状态枚举与自定义分组,前端直接使用返回值显示和移动邮件 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 0a444fa..d87b524 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,7 +10,7 @@ ### Features -- feat: |Mail State| Add optional read state for new mail, automatic read-on-open, manual state toggling, mark-current-page-read and state filters; the frontend only consumes read state, while backend Flags own storage, calculation, and future group membership +- feat: |Mail State| Add optional read state for new mail, automatic read-on-open, manual state toggling, mark-current-page-read and state filters; the backend returns the combined system-state enum and custom groups for clients to render and use as move targets - 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 diff --git a/e2e/tests/api/mail-flags.spec.ts b/e2e/tests/api/mail-flags.spec.ts index 58dafa1..8680d23 100644 --- a/e2e/tests/api/mail-flags.spec.ts +++ b/e2e/tests/api/mail-flags.spec.ts @@ -12,6 +12,13 @@ test.describe('Mail Read Status', () => { const second = await createTestAddress(request, 'mail-flags-second'); try { + const statesRes = await request.get(`${WORKER_URL}/api/mail-states`, { + headers: { Authorization: `Bearer ${first.jwt}` }, + }); + expect(statesRes.ok()).toBe(true); + expect((await statesRes.json()).results.map((state: { value: string }) => state.value)) + .toEqual(['all', 'unread', 'read']); + 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}` }, @@ -23,21 +30,21 @@ test.describe('Mail Read Status', () => { expect(results[0].unread).toBe(true); const unreadRes = await request.get( - `${WORKER_URL}/api/mails?limit=10&offset=0&read_status=unread`, + `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, { headers: { Authorization: `Bearer ${first.jwt}` } }, ); expect((await unreadRes.json()).results).toHaveLength(1); - const deniedRes = await request.patch(`${WORKER_URL}/api/mails/read-status`, { + const deniedRes = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${second.jwt}` }, - data: { ids: [results[0].id], action: 'read' }, + data: { ids: [results[0].id], state: 'read' }, }); expect(deniedRes.ok()).toBe(true); expect((await deniedRes.json()).changes).toBe(0); - const updateRes = await request.patch(`${WORKER_URL}/api/mails/read-status`, { + const updateRes = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${first.jwt}` }, - data: { ids: [results[0].id], action: 'read' }, + data: { ids: [results[0].id], state: 'read' }, }); expect(updateRes.ok()).toBe(true); const updateResult = await updateRes.json(); @@ -50,31 +57,31 @@ test.describe('Mail Read Status', () => { expect((await updatedListRes.json()).results[0].unread).toBe(false); const unreadAfterUpdateRes = await request.get( - `${WORKER_URL}/api/mails?limit=10&offset=0&read_status=unread`, + `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, { headers: { Authorization: `Bearer ${first.jwt}` } }, ); expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0); - const toggleRes = await request.patch(`${WORKER_URL}/api/mails/read-status`, { + const unreadStateRes = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${first.jwt}` }, - data: { ids: [results[0].id], action: 'toggle' }, + data: { ids: [results[0].id], state: 'unread' }, }); - expect(toggleRes.ok()).toBe(true); - expect((await toggleRes.json()).results[0].unread).toBe(true); + expect(unreadStateRes.ok()).toBe(true); + expect((await unreadStateRes.json()).results[0].unread).toBe(true); } finally { await deleteAddress(request, first.jwt); await deleteAddress(request, second.jwt); } }); - test('rejects unsupported read-status actions', async ({ request }) => { + test('rejects unsupported mail states', async ({ request }) => { const { jwt } = await createTestAddress(request, 'mail-flags-invalid'); try { for (const data of [ - { ids: [1], action: 'invalid' }, + { ids: [1], state: 'invalid' }, { ids: [1] }, ]) { - const res = await request.patch(`${WORKER_URL}/api/mails/read-status`, { + const res = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${jwt}` }, data, }); diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue index 194e210..7370b8e 100644 --- a/frontend/src/components/MailBox.vue +++ b/frontend/src/components/MailBox.vue @@ -55,20 +55,26 @@ const props = defineProps({ default: false, required: false }, - enableReadStatus: { + enableMailStates: { type: Boolean, default: false, required: false }, - updateMailReadStatus: { + updateMailState: { type: Function, default: () => { }, required: false }, + fetchMailStates: { + type: Function, + default: () => ({ results: [] }), + required: false + }, }) const localFilterKeyword = ref('') -const readStatusFilter = ref('all') +const mailStateFilter = ref(null) +const mailStates = ref([]) const { isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate, @@ -106,20 +112,23 @@ const data = computed(() => { }) const isMailUnread = (mail) => { - return props.enableReadStatus && mail?.unread === true + return props.enableMailStates && mail?.unread === true } const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread)) -const readStatusFilterOptions = computed(() => [ - { label: t('allMail'), value: 'all' }, - { label: t('unread'), value: 'unread' }, - { label: t('read'), value: 'read' }, -]) +const mailStateFilterOptions = computed(() => mailStates.value.map(state => ({ + label: state.label || t(state.label_key), + value: state.value, +}))) -const updateUnreadState = async (mails, action) => { - if (mails.length === 0) return true +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.updateMailReadStatus(mails.map(mail => mail.id), action) + 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 => { @@ -133,11 +142,11 @@ const updateUnreadState = async (mails, action) => { } } -const markMailsRead = async (mails) => updateUnreadState(mails, 'read') +const markMailsRead = async (mails) => updateUnreadState(mails, getReadStateValue(false)) const toggleCurrentMailUnread = async () => { if (!curMail.value) return - await updateUnreadState([curMail.value], 'toggle') + await updateUnreadState([curMail.value], getReadStateValue(!curMail.value.unread)) } const openMail = async (mail) => { @@ -148,7 +157,7 @@ const openMail = async (mail) => { const markCurrentPageRead = async () => { if (!await markMailsRead(rawData.value)) return message.success(t("success")) - if (readStatusFilter.value === 'unread') await refresh() + if (mailStateFilter.value === getReadStateValue(true)) await refresh() } const canGoPrevMail = computed(() => { @@ -232,14 +241,15 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => { } }) -watch(readStatusFilter, async () => { +watch(mailStateFilter, async (_value, oldValue) => { + if (oldValue === null) return await backFirstPageAndRefresh() }) const refresh = async () => { try { const { results, count: totalCount } = await props.fetchMailData( - pageSize.value, (page.value - 1) * pageSize.value, readStatusFilter.value + pageSize.value, (page.value - 1) * pageSize.value, mailStateFilter.value ); loading.value = true; rawData.value = await Promise.all(results.map(async (item) => { @@ -388,6 +398,11 @@ const multiActionDownload = async () => { } onMounted(async () => { + if (props.enableMailStates) { + const { results = [] } = await props.fetchMailStates() + mailStates.value = results + mailStateFilter.value = results.find(state => state.default)?.value ?? results[0]?.value ?? null + } await refresh(); }); @@ -440,10 +455,10 @@ onBeforeUnmount(() => { {{ t('refresh') }} - + {{ t('markCurrentPageRead') }} - { style="overflow: auto; max-height: 100vh;"> @@ -600,7 +615,7 @@ onBeforeUnmount(() => { {{ t('refresh') }} - + {{ t('markCurrentPageRead') }} @@ -608,8 +623,8 @@ onBeforeUnmount(() => { -
- +
+
@@ -649,7 +664,7 @@ onBeforeUnmount(() => { diff --git a/frontend/src/components/MailContentRenderer.vue b/frontend/src/components/MailContentRenderer.vue index 5922a58..3dc1351 100644 --- a/frontend/src/components/MailContentRenderer.vue +++ b/frontend/src/components/MailContentRenderer.vue @@ -34,7 +34,7 @@ const props = defineProps({ type: Boolean, default: false }, - enableReadStatus: { + enableMailStates: { type: Boolean, default: false }, @@ -154,7 +154,7 @@ const handleSaveToS3 = async (filename, blob) => { {{ t('downloadMail') }} - + {{ mail.unread ? t('markRead') : t('markUnread') }} diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 95e4b22..dd0144a 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -24,7 +24,7 @@ export const useGlobalState = createGlobalState( disableAnonymousUserCreateEmail: false, disableCustomAddressName: false, enableUserDeleteEmail: false, - enableReadStatus: false, + enableMailStates: false, enableAutoReply: false, enableIndexAbout: false, /** @type {string[]} */ diff --git a/frontend/src/views/Index.vue b/frontend/src/views/Index.vue index 2dcf866..68c7127 100644 --- a/frontend/src/views/Index.vue +++ b/frontend/src/views/Index.vue @@ -32,15 +32,15 @@ const SendMail = defineAsyncComponent(() => { const { t } = useScopedI18n('views.Index') -const fetchMailData = async (limit, offset, readStatus = 'all') => { +const fetchMailData = async (limit, offset, mailState) => { if (mailIdQuery.value > 0) { const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`); if (singleMail) return { results: [singleMail], count: 1 }; return { results: [], count: 0 }; } - const readStatusQuery = readStatus === 'all' ? '' : `&read_status=${readStatus}` + const mailStateQuery = mailState ? `&mail_state=${encodeURIComponent(mailState)}` : '' return await api.fetch( - `/api/mails?limit=${limit}&offset=${offset}${readStatusQuery}` + `/api/mails?limit=${limit}&offset=${offset}${mailStateQuery}` ); }; @@ -48,13 +48,17 @@ const deleteMail = async (curMailId) => { await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' }); }; -const updateMailReadStatus = async (ids, action) => { - return await api.fetch(`/api/mails/read-status`, { +const updateMailState = async (ids, state) => { + return await api.fetch(`/api/mails/state`, { method: 'PATCH', - body: JSON.stringify({ ids, action }) + body: JSON.stringify({ ids, state }) }); }; +const fetchMailStates = async () => { + return await api.fetch(`/api/mail-states`) +} + const deleteSenboxMail = async (curMailId) => { await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' }); }; @@ -138,7 +142,8 @@ onMounted(() => { + :enableMailStates="openSettings.enableMailStates" :updateMailState="updateMailState" + :fetchMailStates="fetchMailStates" /> { + return mailStates.value.find(state => state.unread === unread)?.value +} + // 复制地址 const copyAddress = async () => { try { @@ -50,10 +55,12 @@ const fetchMails = async () => { totalCount.value = count > 0 ? count : totalCount.value; const rawMail = results && results.length > 0 ? results[0] : null currentMail.value = rawMail ? await processItem(rawMail) : null - if (openSettings.value.enableReadStatus && rawMail) { - const response = await api.fetch(`/api/mails/read-status`, { + if (openSettings.value.enableMailStates && rawMail) { + const state = getReadStateValue(false) + if (!state) return + const response = await api.fetch(`/api/mails/state`, { method: 'PATCH', - body: JSON.stringify({ ids: [rawMail.id], action: 'read' }) + body: JSON.stringify({ ids: [rawMail.id], state }) }) const updatedMail = response.results?.[0] if (updatedMail) currentMail.value.unread = updatedMail.unread @@ -65,13 +72,15 @@ const fetchMails = async () => { } const toggleCurrentMailUnread = async () => { - if (!currentMail.value || !openSettings.value.enableReadStatus) return + if (!currentMail.value || !openSettings.value.enableMailStates) return try { - const response = await api.fetch(`/api/mails/read-status`, { + const state = getReadStateValue(!currentMail.value.unread) + if (!state) return + const response = await api.fetch(`/api/mails/state`, { method: 'PATCH', body: JSON.stringify({ ids: [currentMail.value.id], - action: 'toggle', + state, }) }) const updatedMail = response.results?.[0] @@ -131,6 +140,10 @@ watch(currentPage, () => { onMounted(async () => { await api.getSettings() + if (openSettings.value.enableMailStates) { + const { results = [] } = await api.fetch(`/api/mail-states`) + mailStates.value = results + } await fetchMails() // 启动自动刷新 @@ -245,7 +258,7 @@ onBeforeUnmount(() => {
diff --git a/frontend/src/views/user/UserMailBox.vue b/frontend/src/views/user/UserMailBox.vue index 645cabd..a92403a 100644 --- a/frontend/src/views/user/UserMailBox.vue +++ b/frontend/src/views/user/UserMailBox.vue @@ -20,12 +20,12 @@ const queryMail = () => { mailBoxKey.value = Date.now(); } -const fetchMailData = async (limit, offset, readStatus = 'all') => { +const fetchMailData = async (limit, offset, mailState) => { return await api.fetch( `/user_api/mails` + `?limit=${limit}` + `&offset=${offset}` - + (readStatus === 'all' ? '' : `&read_status=${readStatus}`) + + (mailState ? `&mail_state=${encodeURIComponent(mailState)}` : '') + (addressFilter.value ? `&address=${addressFilter.value}` : '') ); } @@ -51,13 +51,17 @@ const deleteMail = async (curMailId) => { await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' }); }; -const updateMailReadStatus = async (ids, action) => { - return await api.fetch(`/user_api/mails/read-status`, { +const updateMailState = async (ids, state) => { + return await api.fetch(`/user_api/mails/state`, { method: 'PATCH', - body: JSON.stringify({ ids, action }) + body: JSON.stringify({ ids, state }) }); }; +const fetchMailStates = async () => { + return await api.fetch(`/user_api/mail-states`) +} + watch(addressFilter, async (newValue) => { queryMail(); }); @@ -78,7 +82,7 @@ onMounted(() => {
+ :deleteMail="deleteMail" :showFilterInput="true" :enableMailStates="openSettings.enableMailStates" + :updateMailState="updateMailState" :fetchMailStates="fetchMailStates" />
diff --git a/vitepress-docs/docs/en/guide/feature/mail-api.md b/vitepress-docs/docs/en/guide/feature/mail-api.md index 3341e4d..532750b 100644 --- a/vitepress-docs/docs/en/guide/feature/mail-api.md +++ b/vitepress-docs/docs/en/guide/feature/mail-api.md @@ -19,29 +19,31 @@ res = requests.get( **Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content. -## Mail Read Status API +## Mail State API After enabling `ENABLE_MAIL_FLAGS` and running the database migration, each mail response includes the boolean field `unread`. The backend owns storage, historical `NULL` compatibility, and state calculation. -With an Address JWT, use `PATCH /api/mails/read-status` to update up to 100 mail IDs. The `action` can be `read`, `unread`, or `toggle`. +With an Address JWT, use `GET /api/mail-states` to retrieve available states. The backend combines its system-state enum with address-specific custom states. The frontend uses each returned `value` directly for filtering and moving, and displays its `label_key` or `label`. + +Use `PATCH /api/mails/state` to move the state of up to 100 mail IDs: ```python requests.patch( - "https:///api/mails/read-status", + "https:///api/mails/state", headers={"Authorization": f"Bearer {your-JWT-password}"}, - json={"ids": [1, 2], "action": "read"} + json={"ids": [1, 2], "state": "read"} ) ``` -With a User JWT, send the same body to `PATCH /user_api/mails/read-status`. Only mail belonging to addresses bound to that user can be changed. The response contains the updated `unread` state. +With a User JWT, use `GET /user_api/mail-states` and `PATCH /user_api/mails/state`. Only mail belonging to addresses bound to that user can be changed. The response contains the updated `unread` state. -Mail-list endpoints use the semantic `read_status` filter. For example, list unread mail with: +Mail-list endpoints accept a state `value` returned by the backend. For example, list unread mail with: ```text -GET /api/mails?limit=20&offset=0&read_status=unread +GET /api/mails?limit=20&offset=0&mail_state=unread ``` -Use `read_status=read` for read mail. `/user_api/mails` accepts the same parameter. Future mail groups will also be returned by the backend as group definitions and mail membership; clients only render them. +`/user_api/mails` accepts the same parameter. Future custom groups only require the backend to append custom states and resolve their values; clients do not add another enum. ## Admin Mail API diff --git a/vitepress-docs/docs/zh/guide/feature/mail-api.md b/vitepress-docs/docs/zh/guide/feature/mail-api.md index 1665cda..3afd3d1 100644 --- a/vitepress-docs/docs/zh/guide/feature/mail-api.md +++ b/vitepress-docs/docs/zh/guide/feature/mail-api.md @@ -19,29 +19,31 @@ res = requests.get( **注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。 -## 邮件已读状态 API +## 邮件状态 API 启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应会包含布尔字段 `unread`。数据库存储、历史 `NULL` 兼容和状态计算全部由后端处理。 -地址 JWT 使用 `PATCH /api/mails/read-status` 批量操作状态。每次最多传入 100 个邮件 ID;`action` 可为 `read`、`unread` 或 `toggle`。 +地址 JWT 使用 `GET /api/mail-states` 获取状态列表。后端将系统状态枚举与地址自定义状态合并后返回;前端直接使用其中的 `value` 作为筛选和移动参数,并使用 `label_key` 或 `label` 显示名称。 + +使用 `PATCH /api/mails/state` 批量移动邮件状态,每次最多传入 100 个邮件 ID: ```python requests.patch( - "https://<你的worker地址>/api/mails/read-status", + "https://<你的worker地址>/api/mails/state", headers={"Authorization": f"Bearer {你的JWT密码}"}, - json={"ids": [1, 2], "action": "read"} + json={"ids": [1, 2], "state": "read"} ) ``` -用户 JWT 使用相同请求体访问 `PATCH /user_api/mails/read-status`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `unread` 状态。 +用户 JWT 使用 `GET /user_api/mail-states` 和 `PATCH /user_api/mails/state`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `unread` 状态。 -邮件列表使用语义化的 `read_status` 查询已读状态。例如查询未读邮件: +邮件列表使用后端返回的状态 `value` 查询。例如查询未读邮件: ```text -GET /api/mails?limit=20&offset=0&read_status=unread +GET /api/mails?limit=20&offset=0&mail_state=unread ``` -查询已读邮件使用 `read_status=read`,`/user_api/mails` 支持相同参数。后续邮件分组也由后端返回分组定义和邮件归属,客户端只负责展示。 +`/user_api/mails` 支持相同参数。后续增加自定义分组时,只需由后端将自定义状态拼接到状态列表并解析其 `value`,客户端无需增加枚举。 ## admin 邮件 API diff --git a/worker/src/commom_api.ts b/worker/src/commom_api.ts index ab0f58b..c4bc939 100644 --- a/worker/src/commom_api.ts +++ b/worker/src/commom_api.ts @@ -39,7 +39,7 @@ api.get('/open_api/settings', async (c) => { "disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL), "disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME), "enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL), - "enableReadStatus": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS), + "enableMailStates": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS), "enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY), "enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT), "copyright": c.env.COPYRIGHT, diff --git a/worker/src/gzip.ts b/worker/src/gzip.ts index bd60e8b..31bb58c 100644 --- a/worker/src/gzip.ts +++ b/worker/src/gzip.ts @@ -37,11 +37,11 @@ export async function resolveRawEmail(row: RawMailRow): Promise { */ export async function resolveRawEmailRow( row: RawMailRow, - enableReadStatus = false, + enableMailStates = false, ): Promise { const raw = await resolveRawEmail(row); const { raw_blob: _, ...rest } = row; - return serializeMailState({ ...rest, raw }, enableReadStatus); + return serializeMailState({ ...rest, raw }, enableMailStates); } /** @@ -49,7 +49,7 @@ export async function resolveRawEmailRow( */ export async function resolveRawEmailList( rows: RawMailRow[], - enableReadStatus = false, + enableMailStates = false, ): Promise { - return Promise.all(rows.map(row => resolveRawEmailRow(row, enableReadStatus))); + return Promise.all(rows.map(row => resolveRawEmailRow(row, enableMailStates))); } diff --git a/worker/src/mail_flags.ts b/worker/src/mail_flags.ts index a31666a..301f688 100644 --- a/worker/src/mail_flags.ts +++ b/worker/src/mail_flags.ts @@ -10,7 +10,59 @@ export const MAIL_FLAGS = { export const CUSTOM_MAIL_FLAG_OFFSET = 10; export const CUSTOM_MAIL_FLAG_COUNT = 10; -type MailReadStatusAction = 'read' | 'unread' | 'toggle'; +export enum MailState { + ALL = 'all', + UNREAD = 'unread', + READ = 'read', +} + +export type MailStateOption = { + value: string; + label_key?: string; + label?: string; + unread?: boolean; + default?: boolean; +}; + +export type MailStateDefinition = MailStateOption & { + filter?: { mask: number; set: boolean }; + mutation?: { add: number; remove: number }; +}; + +const SYSTEM_MAIL_STATES: MailStateDefinition[] = [ + { value: MailState.ALL, label_key: 'allMail', default: true }, + { + value: MailState.UNREAD, + label_key: 'unread', + unread: true, + filter: { mask: MAIL_FLAGS.UNREAD, set: true }, + mutation: { add: MAIL_FLAGS.UNREAD, remove: 0 }, + }, + { + value: MailState.READ, + label_key: 'read', + unread: false, + filter: { mask: MAIL_FLAGS.UNREAD, set: false }, + mutation: { add: 0, remove: MAIL_FLAGS.UNREAD }, + }, +]; + +export const getMailStateOptions = ( + customStates: MailStateDefinition[] = [], +): MailStateOption[] => { + return [...SYSTEM_MAIL_STATES, ...customStates].map(state => { + const { filter: _filter, mutation: _mutation, ...option } = state; + return option; + }); +}; + +const getMailStateDefinition = ( + value: unknown, + customStates: MailStateDefinition[] = [], +) => { + if (typeof value !== 'string') return undefined; + return [...SYSTEM_MAIL_STATES, ...customStates].find(state => state.value === value); +}; export const getCustomMailFlag = (slot: number): number => { if (!Number.isInteger(slot) || slot < 0 || slot >= CUSTOM_MAIL_FLAG_COUNT) { @@ -19,6 +71,27 @@ export const getCustomMailFlag = (slot: number): number => { return 1 << (CUSTOM_MAIL_FLAG_OFFSET + slot); }; +export type CustomMailStateConfig = { + slot: number; + name: string; +}; + +const CUSTOM_MAIL_FLAGS_MASK = ((1 << CUSTOM_MAIL_FLAG_COUNT) - 1) << CUSTOM_MAIL_FLAG_OFFSET; + +export const createCustomMailStateDefinitions = ( + configs: CustomMailStateConfig[], +): MailStateDefinition[] => { + return configs.map(config => { + const flag = getCustomMailFlag(config.slot); + return { + value: `custom:${config.slot}`, + label: config.name, + filter: { mask: flag, set: true }, + mutation: { add: flag, remove: CUSTOM_MAIL_FLAGS_MASK & ~flag }, + }; + }); +}; + export const serializeMailState = >( row: T, enabled: boolean, @@ -54,10 +127,11 @@ export const updateInitialMailFlags = async ( await db.prepare(`UPDATE raw_mails SET flags = ? WHERE id = ?`).bind(flags, mailId).run(); }; -export type MailReadStatusUpdate = { +export type MailStateUpdate = { ids: number[]; - mask: number; - action: MailReadStatusAction; + state: string; + add: number; + remove: number; }; export type MailReadStatusQuery = { @@ -65,21 +139,26 @@ export type MailReadStatusQuery = { params: string[]; }; -export const getReadStatusQuery = ( +export const getMailStateQuery = ( value: string | undefined, column: 'flags' | 'rm.flags', + customStates: MailStateDefinition[] = [], ): MailReadStatusQuery | undefined | null => { - if (value === undefined || value === 'all') return undefined; - if (value === 'unread') { - return { clause: `(COALESCE(${column}, 0) & ?) != 0`, params: [String(MAIL_FLAGS.UNREAD)] }; - } - if (value === 'read') { - return { clause: `(COALESCE(${column}, 0) & ?) = 0`, params: [String(MAIL_FLAGS.UNREAD)] }; - } - return null; + if (value === undefined) return undefined; + const definition = getMailStateDefinition(value, customStates); + if (!definition) return null; + if (!definition.filter) return undefined; + const operator = definition.filter.set ? '!=' : '='; + return { + clause: `(COALESCE(${column}, 0) & ?) ${operator} 0`, + params: [String(definition.filter.mask)], + }; }; -const parseMailReadStatusUpdate = (value: unknown): MailReadStatusUpdate | null => { +const parseMailStateUpdate = ( + value: unknown, + customStates: MailStateDefinition[] = [], +): MailStateUpdate | null => { if (!value || typeof value !== 'object') return null; const body = value as Record; if (!Array.isArray(body.ids) || body.ids.length === 0 || body.ids.length > 100) return null; @@ -88,34 +167,25 @@ const parseMailReadStatusUpdate = (value: unknown): MailReadStatusUpdate | null const ids = [...new Set(body.ids.map(Number))]; if (ids.some(id => !Number.isInteger(id) || id <= 0)) return null; - if (body.action !== 'read' && body.action !== 'unread' && body.action !== 'toggle') return null; - - return { ids, mask: MAIL_FLAGS.UNREAD, action: body.action }; + const definition = getMailStateDefinition(body.state, customStates); + if (!definition?.mutation) return null; + return { + ids, + state: definition.value, + add: definition.mutation.add, + remove: definition.mutation.remove, + }; }; -const getMailReadStatusUpdateExpression = ( - update: MailReadStatusUpdate, +const getMailStateUpdateExpression = ( + update: MailStateUpdate, column = 'flags', ): { expression: string; params: number[]; condition?: string; conditionParams?: number[] } => { - if (update.action === 'unread') { - return { - expression: `(COALESCE(${column}, 0) | ?)`, - params: [update.mask], - condition: `(COALESCE(${column}, 0) & ?) = 0`, - conditionParams: [update.mask], - }; - } - if (update.action === 'read') { - return { - expression: `(COALESCE(${column}, 0) & ~?)`, - params: [update.mask], - condition: `(COALESCE(${column}, 0) & ?) != 0`, - conditionParams: [update.mask], - }; - } return { - expression: `((COALESCE(${column}, 0) | ?) - (COALESCE(${column}, 0) & ?))`, - params: [update.mask, update.mask], + expression: `((COALESCE(${column}, 0) | ?) & ~?)`, + params: [update.add, update.remove], + condition: `((COALESCE(${column}, 0) & ?) != ? OR (COALESCE(${column}, 0) & ?) != 0)`, + conditionParams: [update.add, update.add, update.remove], }; }; @@ -124,16 +194,17 @@ type MailScope = { params: (string | number)[]; }; -export const applyMailReadStatusUpdate = async ( +export const applyMailStateUpdate = async ( db: D1Database, scope: MailScope, value: unknown, + customStates: MailStateDefinition[] = [], ) => { - const update = parseMailReadStatusUpdate(value); + const update = parseMailStateUpdate(value, customStates); if (!update) return null; const placeholders = update.ids.map(() => '?').join(','); - const statusUpdate = getMailReadStatusUpdateExpression(update); + const statusUpdate = getMailStateUpdateExpression(update); const condition = statusUpdate.condition ? ` AND ${statusUpdate.condition}` : ''; const result = await db.prepare( `UPDATE raw_mails SET flags = ${statusUpdate.expression}` diff --git a/worker/src/mails_api/index.ts b/worker/src/mails_api/index.ts index 33c0409..b5b454c 100644 --- a/worker/src/mails_api/index.ts +++ b/worker/src/mails_api/index.ts @@ -27,8 +27,9 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl) // mail crud api.get('/api/mails', mails_crud.listMails) +api.get('/api/mail-states', mails_crud.getMailStates) api.get('/api/mail/:mail_id', mails_crud.getMail) -api.patch('/api/mails/read-status', mails_crud.updateMailReadStatus) +api.patch('/api/mails/state', mails_crud.updateMailState) api.delete('/api/mails/:id', mails_crud.deleteMail) // parsed mail (server-side parsed subject/text/html/attachments) diff --git a/worker/src/mails_api/mails_crud.ts b/worker/src/mails_api/mails_crud.ts index f2eb07e..b3077eb 100644 --- a/worker/src/mails_api/mails_crud.ts +++ b/worker/src/mails_api/mails_crud.ts @@ -6,8 +6,9 @@ import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } fr import { resolveRawEmailRow } from '../gzip' import { getSendBalanceState } from './send_balance'; import { - getReadStatusQuery, - applyMailReadStatusUpdate, + getMailStateQuery, + getMailStateOptions, + applyMailStateUpdate, } from '../mail_flags'; const listMails = async (c: Context) => { @@ -15,19 +16,19 @@ const listMails = async (c: Context) => { if (!address) { return c.json({ "error": "No address" }, 400) } - const { limit, offset, read_status } = c.req.query(); + const { limit, offset, mail_state } = c.req.query(); if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address); - const readStatusQuery = getReadStatusQuery(read_status, 'flags'); - if (readStatusQuery === null) return c.json({ error: "Invalid mail read status filter" }, 400); - if (readStatusQuery && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { - return c.json({ error: "Mail read status is disabled" }, 403); + const stateQuery = getMailStateQuery(mail_state, 'flags'); + if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400); + if (stateQuery && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { + return c.json({ error: "Mail states are disabled" }, 403); } const filters = [`address = ?`]; const params = [address]; - if (readStatusQuery) { - filters.push(readStatusQuery.clause); - params.push(...readStatusQuery.params); + if (stateQuery) { + filters.push(stateQuery.clause); + params.push(...stateQuery.params); } const whereClause = filters.join(' AND '); return await handleMailListQuery(c, @@ -64,21 +65,28 @@ const deleteMail = async (c: Context) => { return c.json({ success }); }; -const updateMailReadStatus = async (c: Context) => { +const updateMailState = async (c: Context) => { if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { - return c.json({ error: "Mail read status is disabled" }, 403); + return c.json({ error: "Mail states are disabled" }, 403); } const { address } = c.get("jwtPayload"); - const result = await applyMailReadStatusUpdate( + const result = await applyMailStateUpdate( c.env.DB, { clause: 'address = ?', params: [address] }, await c.req.json().catch(() => null), ); - if (!result) return c.json({ error: "Invalid mail read status request" }, 400); + if (!result) return c.json({ error: "Invalid mail state request" }, 400); if (!result.success) return c.json(result, 500); return c.json(result); }; +const getMailStates = (c: Context) => { + if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { + return c.json({ error: "Mail states are disabled" }, 403); + } + return c.json({ results: getMailStateOptions() }); +}; + const getSettings = async (c: Context) => { const { address, address_id } = c.get("jwtPayload") const msgs = i18n.getMessagesbyContext(c); @@ -153,6 +161,6 @@ const clearSentItems = async (c: Context) => { }; export default { - listMails, getMail, deleteMail, updateMailReadStatus, + listMails, getMail, deleteMail, updateMailState, getMailStates, getSettings, deleteAddress, clearInbox, clearSentItems }; diff --git a/worker/src/user_api/index.ts b/worker/src/user_api/index.ts index bb99da9..3dec574 100644 --- a/worker/src/user_api/index.ts +++ b/worker/src/user_api/index.ts @@ -16,7 +16,8 @@ api.get('/user_api/settings', settings.settings); // mail api api.get('/user_api/mails', user_mail_api.getMails); -api.patch('/user_api/mails/read-status', user_mail_api.updateMailReadStatus); +api.get('/user_api/mail-states', user_mail_api.getMailStates); +api.patch('/user_api/mails/state', user_mail_api.updateMailState); api.delete('/user_api/mails/:id', user_mail_api.deleteMail); // send mail api diff --git a/worker/src/user_api/user_mail_api.ts b/worker/src/user_api/user_mail_api.ts index c1dde10..24f8d62 100644 --- a/worker/src/user_api/user_mail_api.ts +++ b/worker/src/user_api/user_mail_api.ts @@ -3,28 +3,35 @@ import i18n from "../i18n"; import { handleMailListQuery } from "../common"; import { getBooleanValue } from "../utils"; import { - getReadStatusQuery, - applyMailReadStatusUpdate, + getMailStateQuery, + getMailStateOptions, + applyMailStateUpdate, } from "../mail_flags"; export default { + getMailStates: (c: Context) => { + if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { + return c.json({ error: "Mail states are disabled" }, 403); + } + return c.json({ results: getMailStateOptions() }); + }, getMails: async (c: Context) => { const { user_id } = c.get("userPayload"); - const { address, limit, offset, read_status } = c.req.query(); + const { address, limit, offset, mail_state } = c.req.query(); const filterQuerys = [`ua.user_id = ?`]; const filterParams = [String(user_id)]; if (address) { filterQuerys.push(`rm.address = ?`); filterParams.push(address); } - const readStatusQuery = getReadStatusQuery(read_status, 'rm.flags'); - if (readStatusQuery === null) return c.json({ error: "Invalid mail read status filter" }, 400); - if (readStatusQuery && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { - return c.json({ error: "Mail read status is disabled" }, 403); + const stateQuery = getMailStateQuery(mail_state, 'rm.flags'); + if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400); + if (stateQuery && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { + return c.json({ error: "Mail states are disabled" }, 403); } - if (readStatusQuery) { - filterQuerys.push(readStatusQuery.clause); - filterParams.push(...readStatusQuery.params); + if (stateQuery) { + filterQuerys.push(stateQuery.clause); + filterParams.push(...stateQuery.params); } const fromQuery = ` FROM users_address ua` + ` JOIN address a ON a.id = ua.address_id` @@ -55,12 +62,12 @@ export default { success: success }) }, - updateMailReadStatus: async (c: Context) => { + updateMailState: async (c: Context) => { if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { - return c.json({ error: "Mail read status is disabled" }, 403); + return c.json({ error: "Mail states are disabled" }, 403); } const { user_id } = c.get("userPayload"); - const result = await applyMailReadStatusUpdate( + const result = await applyMailStateUpdate( c.env.DB, { clause: `EXISTS (` @@ -72,7 +79,7 @@ export default { }, await c.req.json().catch(() => null), ); - if (!result) return c.json({ error: "Invalid mail read status request" }, 400); + if (!result) return c.json({ error: "Invalid mail state request" }, 400); if (!result.success) return c.json(result, 500); return c.json(result); }