diff --git a/CHANGELOG.md b/CHANGELOG.md index 7455f33..1a0de68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- feat: |邮件状态| 新增可选 Mail Flags 功能,新邮件支持已读/未读状态、打开自动已读、手动切换状态、本页全部已读及按状态筛选,并为系统状态位及每地址自定义规则 Flag 预留扩展空间 +- feat: |邮件状态| 新增可选 Mail Flags 功能,新邮件支持已读/未读状态、打开自动已读、手动切换状态、本页全部已读及按状态筛选;位运算和状态映射统一由后端处理,并为系统状态位及每地址自定义规则 Flag 预留扩展空间 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API @@ -26,7 +26,7 @@ ### Testing -- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法 Flag 掩码校验 +- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法 Flag 操作校验 - test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复 - fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览 - fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index f9398be..837b95b 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,7 +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: |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; bit operations and state mapping are handled by the backend, with 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 @@ -26,7 +26,7 @@ ### Testing -- test: |E2E| Cover unread state on new mail, mailbox isolation, marking mail read, and invalid flag-mask validation +- test: |E2E| Cover unread state on new mail, mailbox isolation, marking mail read, and invalid flag-operation 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/e2e/tests/api/mail-flags.spec.ts b/e2e/tests/api/mail-flags.spec.ts index eed09c7..ccadb3d 100644 --- a/e2e/tests/api/mail-flags.spec.ts +++ b/e2e/tests/api/mail-flags.spec.ts @@ -19,50 +19,60 @@ test.describe('Mail Flags', () => { expect(listRes.ok()).toBe(true); const { results } = await listRes.json(); expect(results).toHaveLength(1); - expect(results[0].flags).toBe(1); + expect(results[0].flags).toBeUndefined(); + expect(results[0].mail_flags).toEqual({ unread: true }); const unreadRes = await request.get( - `${WORKER_URL}/api/mails?limit=10&offset=0&flag=0&flag_state=set`, + `${WORKER_URL}/api/mails?limit=10&offset=0&read_status=unread`, { 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 }, + data: { ids: [results[0].id], flag: 'unread', action: 'clear' }, }); 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 }, + data: { ids: [results[0].id], flag: 'unread', action: 'clear' }, }); expect(updateRes.ok()).toBe(true); - expect((await updateRes.json()).changes).toBe(1); + const updateResult = await updateRes.json(); + expect(updateResult.changes).toBe(1); + expect(updateResult.results[0].mail_flags).toEqual({ unread: false }); 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); + expect((await updatedListRes.json()).results[0].mail_flags).toEqual({ unread: false }); const unreadAfterUpdateRes = await request.get( - `${WORKER_URL}/api/mails?limit=10&offset=0&flag=0&flag_state=set`, + `${WORKER_URL}/api/mails?limit=10&offset=0&read_status=unread`, { headers: { Authorization: `Bearer ${first.jwt}` } }, ); expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0); + + const toggleRes = await request.patch(`${WORKER_URL}/api/mails/flags`, { + headers: { Authorization: `Bearer ${first.jwt}` }, + data: { ids: [results[0].id], flag: 'unread', action: 'toggle' }, + }); + expect(toggleRes.ok()).toBe(true); + expect((await toggleRes.json()).results[0].mail_flags).toEqual({ unread: true }); } finally { await deleteAddress(request, first.jwt); await deleteAddress(request, second.jwt); } }); - test('rejects unsupported and overlapping flag masks', async ({ request }) => { + test('rejects unsupported flag names and actions', 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 }, + { ids: [1], flag: 'flagged', action: 'set' }, + { ids: [1], flag: 'unread', action: 'invalid' }, ]) { const res = await request.patch(`${WORKER_URL}/api/mails/flags`, { headers: { Authorization: `Bearer ${jwt}` }, diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue index 341a96e..498e163 100644 --- a/frontend/src/components/MailBox.vue +++ b/frontend/src/components/MailBox.vue @@ -8,7 +8,6 @@ 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"; @@ -107,7 +106,7 @@ const data = computed(() => { }) const isMailUnread = (mail) => { - return props.enableMailFlags && hasMailFlag(mail?.flags, MAIL_FLAGS.UNREAD) + return props.enableMailFlags && mail?.mail_flags?.unread === true } const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread)) @@ -117,36 +116,28 @@ const mailFlagFilterOptions = computed(() => [ { 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 - }) +const updateUnreadState = async (mails, action) => { + if (mails.length === 0) return true try { - await props.updateMailFlags( - changedMails.map(mail => mail.id), - unread ? MAIL_FLAGS.UNREAD : 0, - unread ? 0 : MAIL_FLAGS.UNREAD - ) + const response = await props.updateMailFlags(mails.map(mail => mail.id), 'unread', action) + 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.mail_flags = result.mail_flags + }) 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 markMailsRead = async (mails) => updateUnreadState(mails, 'clear') const toggleCurrentMailUnread = async () => { if (!curMail.value) return - await setMailsUnread([curMail.value], !isMailUnread(curMail.value)) + await updateUnreadState([curMail.value], 'toggle') } const openMail = async (mail) => { diff --git a/frontend/src/components/MailContentRenderer.vue b/frontend/src/components/MailContentRenderer.vue index c483335..b1d2cdf 100644 --- a/frontend/src/components/MailContentRenderer.vue +++ b/frontend/src/components/MailContentRenderer.vue @@ -8,7 +8,6 @@ import { getDownloadEmlUrl } from '../utils/email-parser'; import { blockRemoteContent } from '../utils/remote-content-policy'; import { utcToLocalDate } from '../utils'; import { useGlobalState } from '../store'; -import { MAIL_FLAGS, hasMailFlag } from '../utils/mail-flags'; const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState(); @@ -156,7 +155,7 @@ const handleSaveToS3 = async (filename, blob) => { - {{ hasMailFlag(mail.flags, MAIL_FLAGS.UNREAD) ? t('markRead') : t('markUnread') }} + {{ mail.mail_flags?.unread ? t('markRead') : t('markUnread') }} diff --git a/frontend/src/utils/mail-flags.js b/frontend/src/utils/mail-flags.js deleted file mode 100644 index 264e435..0000000 --- a/frontend/src/utils/mail-flags.js +++ /dev/null @@ -1,13 +0,0 @@ -export const MAIL_FLAGS = { - UNREAD: 1, -} - -export const hasMailFlag = (flags, flag) => { - return (Number(flags ?? 0) & flag) !== 0 -} - -export const getMailFlagFilterQuery = (filter) => { - if (filter === 'unread') return '&flag=0&flag_state=set' - if (filter === 'read') return '&flag=0&flag_state=unset' - return '' -} diff --git a/frontend/src/views/Index.vue b/frontend/src/views/Index.vue index 652894a..8c73242 100644 --- a/frontend/src/views/Index.vue +++ b/frontend/src/views/Index.vue @@ -6,7 +6,6 @@ import { useRoute } from 'vue-router' import { useGlobalState } from '../store' import { api } from '../api' import { useIsMobile } from '../utils/composables' -import { getMailFlagFilterQuery } from '../utils/mail-flags' import { FullscreenExitOutlined } from '@vicons/material' import AddressBar from './index/AddressBar.vue'; @@ -39,8 +38,9 @@ const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => { if (singleMail) return { results: [singleMail], count: 1 }; return { results: [], count: 0 }; } + const readStatusQuery = mailFlagFilter === 'all' ? '' : `&read_status=${mailFlagFilter}` return await api.fetch( - `/api/mails?limit=${limit}&offset=${offset}${getMailFlagFilterQuery(mailFlagFilter)}` + `/api/mails?limit=${limit}&offset=${offset}${readStatusQuery}` ); }; @@ -48,10 +48,10 @@ const deleteMail = async (curMailId) => { await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' }); }; -const updateMailFlags = async (ids, add, remove) => { - await api.fetch(`/api/mails/flags`, { +const updateMailFlags = async (ids, flag, action) => { + return await api.fetch(`/api/mails/flags`, { method: 'PATCH', - body: JSON.stringify({ ids, add, remove }) + body: JSON.stringify({ ids, flag, action }) }); }; diff --git a/frontend/src/views/index/SimpleIndex.vue b/frontend/src/views/index/SimpleIndex.vue index 2f3b322..a6d1944 100644 --- a/frontend/src/views/index/SimpleIndex.vue +++ b/frontend/src/views/index/SimpleIndex.vue @@ -18,7 +18,6 @@ import AccountSettings from './AccountSettings.vue' import { processItem } from '../../utils/email-parser' import MailContentRenderer from '../../components/MailContentRenderer.vue' import AddressSelect from '../../components/AddressSelect.vue' -import { MAIL_FLAGS, hasMailFlag } from '../../utils/mail-flags' const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState() const message = useMessage() @@ -51,12 +50,13 @@ 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.enableMailFlags && hasMailFlag(rawMail?.flags, MAIL_FLAGS.UNREAD)) { - rawMail.flags = Number(rawMail.flags ?? 0) & ~MAIL_FLAGS.UNREAD - await api.fetch(`/api/mails/flags`, { + if (openSettings.value.enableMailFlags && rawMail) { + const response = await api.fetch(`/api/mails/flags`, { method: 'PATCH', - body: JSON.stringify({ ids: [rawMail.id], add: 0, remove: MAIL_FLAGS.UNREAD }) + body: JSON.stringify({ ids: [rawMail.id], flag: 'unread', action: 'clear' }) }) + const updatedMail = response.results?.[0] + if (updatedMail) currentMail.value.mail_flags = updatedMail.mail_flags } } catch (error) { console.error('Failed to fetch mails:', error) @@ -66,23 +66,18 @@ const fetchMails = async () => { const toggleCurrentMailUnread = async () => { if (!currentMail.value || !openSettings.value.enableMailFlags) return - const wasUnread = hasMailFlag(currentMail.value.flags, MAIL_FLAGS.UNREAD) - currentMail.value.flags = wasUnread - ? Number(currentMail.value.flags ?? 0) & ~MAIL_FLAGS.UNREAD - : Number(currentMail.value.flags ?? 0) | MAIL_FLAGS.UNREAD try { - await api.fetch(`/api/mails/flags`, { + const response = await api.fetch(`/api/mails/flags`, { method: 'PATCH', body: JSON.stringify({ ids: [currentMail.value.id], - add: wasUnread ? 0 : MAIL_FLAGS.UNREAD, - remove: wasUnread ? MAIL_FLAGS.UNREAD : 0, + flag: 'unread', + action: 'toggle', }) }) + const updatedMail = response.results?.[0] + if (updatedMail) currentMail.value.mail_flags = updatedMail.mail_flags } catch (error) { - currentMail.value.flags = wasUnread - ? Number(currentMail.value.flags ?? 0) | MAIL_FLAGS.UNREAD - : Number(currentMail.value.flags ?? 0) & ~MAIL_FLAGS.UNREAD message.error(error.message || 'error') } } diff --git a/frontend/src/views/user/UserMailBox.vue b/frontend/src/views/user/UserMailBox.vue index 5b28223..c070ae0 100644 --- a/frontend/src/views/user/UserMailBox.vue +++ b/frontend/src/views/user/UserMailBox.vue @@ -5,7 +5,6 @@ import { useScopedI18n } from '@/i18n/app' import { api } from '../../api' import { useGlobalState } from '../../store' import MailBox from '../../components/MailBox.vue'; -import { getMailFlagFilterQuery } from '../../utils/mail-flags'; const message = useMessage() const { openSettings } = useGlobalState() @@ -26,7 +25,7 @@ const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => { `/user_api/mails` + `?limit=${limit}` + `&offset=${offset}` - + getMailFlagFilterQuery(mailFlagFilter) + + (mailFlagFilter === 'all' ? '' : `&read_status=${mailFlagFilter}`) + (addressFilter.value ? `&address=${addressFilter.value}` : '') ); } @@ -52,10 +51,10 @@ const deleteMail = async (curMailId) => { await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' }); }; -const updateMailFlags = async (ids, add, remove) => { - await api.fetch(`/user_api/mails/flags`, { +const updateMailFlags = async (ids, flag, action) => { + return await api.fetch(`/user_api/mails/flags`, { method: 'PATCH', - body: JSON.stringify({ ids, add, remove }) + body: JSON.stringify({ ids, flag, action }) }); }; diff --git a/vitepress-docs/docs/en/guide/feature/mail-api.md b/vitepress-docs/docs/en/guide/feature/mail-api.md index 400f2f5..b552a16 100644 --- a/vitepress-docs/docs/en/guide/feature/mail-api.md +++ b/vitepress-docs/docs/en/guide/feature/mail-api.md @@ -21,27 +21,27 @@ res = requests.get( ## Mail Flags API -After enabling `ENABLE_MAIL_FLAGS` and running the database migration, each mail response includes an integer `flags` bitmask. Bit 0 currently means `UNREAD`: `1` is unread, while `NULL` or `0` is treated as read. +After enabling `ENABLE_MAIL_FLAGS` and running the database migration, each mail response includes `mail_flags`, for example `{"unread": true}`. The backend owns bitmask mapping, historical `NULL` compatibility, and state calculation; clients do not need to know bit positions. -With an Address JWT, use `PATCH /api/mails/flags` to add or remove flags for up to 100 mail IDs. Only the `UNREAD` bit is currently mutable. +With an Address JWT, use `PATCH /api/mails/flags` to update up to 100 mail IDs. The supported flag is currently `unread`, and its action can be `set`, `clear`, or `toggle`. ```python requests.patch( "https:///api/mails/flags", headers={"Authorization": f"Bearer {your-JWT-password}"}, - json={"ids": [1, 2], "add": 0, "remove": 1} + json={"ids": [1, 2], "flag": "unread", "action": "clear"} ) ``` -With a User JWT, send the same body to `PATCH /user_api/mails/flags`. Only mail belonging to addresses bound to that user can be changed. Other flag bits are always preserved. +With a User JWT, send the same body to `PATCH /user_api/mails/flags`. Only mail belonging to addresses bound to that user can be changed. The response contains the updated `mail_flags`, and all unrelated bits are preserved by the server. -Mail-list endpoints accept generic flag filters: `flag` is the bit position (`0-30`), and `flag_state` is either `set` or `unset`. For example, list unread mail with: +Mail-list endpoints use the semantic `read_status` filter. For example, list unread mail with: ```text -GET /api/mails?limit=20&offset=0&flag=0&flag_state=set +GET /api/mails?limit=20&offset=0&read_status=unread ``` -Use `flag=0&flag_state=unset` for read mail. `/user_api/mails` accepts the same parameters, and future custom flags can use bits 10 through 19 directly. +Use `read_status=read` for read mail. `/user_api/mails` accepts the same parameter. Future system and custom flags will still be mapped from names to bits by the backend. ## 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 9534851..a453f0c 100644 --- a/vitepress-docs/docs/zh/guide/feature/mail-api.md +++ b/vitepress-docs/docs/zh/guide/feature/mail-api.md @@ -21,27 +21,27 @@ res = requests.get( ## 邮件 Flag API -启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应中的 `flags` 为整数位掩码。当前 bit 0 表示 `UNREAD`:值为 `1` 时未读,`NULL` 或 `0` 按已读处理。 +启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应会包含 `mail_flags`,例如 `{"unread": true}`。数据库位掩码、历史 `NULL` 兼容和状态计算全部由后端处理,客户端不需要了解具体 bit。 -地址 JWT 使用 `PATCH /api/mails/flags` 批量增删状态位。每次最多传入 100 个邮件 ID;当前仅允许修改 `UNREAD` 位。 +地址 JWT 使用 `PATCH /api/mails/flags` 批量操作状态。每次最多传入 100 个邮件 ID;当前支持 `unread`,操作可为 `set`、`clear` 或 `toggle`。 ```python requests.patch( "https://<你的worker地址>/api/mails/flags", headers={"Authorization": f"Bearer {你的JWT密码}"}, - json={"ids": [1, 2], "add": 0, "remove": 1} + json={"ids": [1, 2], "flag": "unread", "action": "clear"} ) ``` -用户 JWT 使用相同请求体访问 `PATCH /user_api/mails/flags`,只能修改该用户已绑定地址的邮件。服务端始终保留请求未涉及的其他 Flag 位。 +用户 JWT 使用相同请求体访问 `PATCH /user_api/mails/flags`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `mail_flags`;服务端始终保留请求未涉及的其他 Flag 位。 -邮件列表接口支持通用 Flag 查询参数:`flag` 为 bit 位置(`0-30`),`flag_state` 为 `set` 或 `unset`。例如查询未读邮件: +邮件列表使用语义化的 `read_status` 查询已读状态。例如查询未读邮件: ```text -GET /api/mails?limit=20&offset=0&flag=0&flag_state=set +GET /api/mails?limit=20&offset=0&read_status=unread ``` -查询已读邮件则使用 `flag=0&flag_state=unset`。`/user_api/mails` 支持相同参数,后续自定义 Flag 可以直接使用 bit 10~19。 +查询已读邮件使用 `read_status=read`。`/user_api/mails` 支持相同参数。后续扩展其他系统或自定义 Flag 时仍由后端负责名称到 bit 的映射。 ## admin 邮件 API diff --git a/worker/src/mail_flags.ts b/worker/src/mail_flags.ts index 5db5d94..6c02c13 100644 --- a/worker/src/mail_flags.ts +++ b/worker/src/mail_flags.ts @@ -9,7 +9,18 @@ export const MAIL_FLAGS = { export const CUSTOM_MAIL_FLAG_OFFSET = 10; export const CUSTOM_MAIL_FLAG_COUNT = 10; -export const MUTABLE_MAIL_FLAGS = MAIL_FLAGS.UNREAD; + +const MAIL_FLAG_MASKS = { + unread: MAIL_FLAGS.UNREAD, +} as const; + +type MailFlagName = keyof typeof MAIL_FLAG_MASKS; +type MailFlagAction = 'set' | 'clear' | 'toggle'; + +const isMailFlagName = (value: unknown): value is MailFlagName => { + return typeof value === 'string' + && Object.prototype.hasOwnProperty.call(MAIL_FLAG_MASKS, value); +}; export const getCustomMailFlag = (slot: number): number => { if (!Number.isInteger(slot) || slot < 0 || slot >= CUSTOM_MAIL_FLAG_COUNT) { @@ -23,11 +34,14 @@ export const serializeMailFlags = >( enabled: boolean, ): T => { const result = { ...row }; + const flags = Number(result.flags ?? 0); + delete result.flags; if (!enabled) { - delete result.flags; return result; } - result.flags = Number(result.flags ?? 0); + result.mail_flags = { + unread: (flags & MAIL_FLAGS.UNREAD) !== 0, + }; return result; }; @@ -67,8 +81,9 @@ export const insertRawMail = async ( export type MailFlagUpdate = { ids: number[]; - add: number; - remove: number; + flag: MailFlagName; + mask: number; + action: MailFlagAction; }; export type MailFlagFilter = { @@ -77,15 +92,22 @@ export type MailFlagFilter = { }; export const parseMailFlagFilter = ( - bitValue: string | undefined, + flagValue: string | undefined, stateValue: string | undefined, ): MailFlagFilter | undefined | null => { - if (bitValue === undefined && stateValue === undefined) return undefined; - if (!bitValue || !/^\d+$/.test(bitValue)) return null; - const bit = Number(bitValue); - if (!Number.isInteger(bit) || bit < 0 || bit > 30) return null; + if (flagValue === undefined && stateValue === undefined) return undefined; + if (!isMailFlagName(flagValue)) return null; if (stateValue !== 'set' && stateValue !== 'unset') return null; - return { mask: 1 << bit, state: stateValue }; + return { mask: MAIL_FLAG_MASKS[flagValue], state: stateValue }; +}; + +export const parseReadStatusFilter = ( + value: string | undefined, +): MailFlagFilter | undefined | null => { + if (value === undefined || value === 'all') return undefined; + if (value === 'unread') return { mask: MAIL_FLAGS.UNREAD, state: 'set' }; + if (value === 'read') return { mask: MAIL_FLAGS.UNREAD, state: 'unset' }; + return null; }; export const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | null => { @@ -97,14 +119,35 @@ export const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | null => { const ids = [...new Set(body.ids.map(Number))]; if (ids.some(id => !Number.isInteger(id) || id <= 0)) return null; - if (body.add !== undefined && typeof body.add !== 'number') return null; - if (body.remove !== undefined && typeof body.remove !== 'number') return null; - const add = Number(body.add ?? 0); - const remove = Number(body.remove ?? 0); - if (!Number.isInteger(add) || !Number.isInteger(remove) || add < 0 || remove < 0) return null; - if (add > MUTABLE_MAIL_FLAGS || remove > MUTABLE_MAIL_FLAGS) return null; - if (((add | remove) & ~MUTABLE_MAIL_FLAGS) !== 0 || (add & remove) !== 0) return null; - if (add === 0 && remove === 0) return null; + if (!isMailFlagName(body.flag)) return null; + if (body.action !== 'set' && body.action !== 'clear' && body.action !== 'toggle') return null; - return { ids, add, remove }; + const flag = body.flag; + return { ids, flag, mask: MAIL_FLAG_MASKS[flag], action: body.action }; +}; + +export const getMailFlagUpdateExpression = ( + update: MailFlagUpdate, + column = 'flags', +): { expression: string; params: number[]; condition?: string; conditionParams?: number[] } => { + if (update.action === 'set') { + return { + expression: `(COALESCE(${column}, 0) | ?)`, + params: [update.mask], + condition: `(COALESCE(${column}, 0) & ?) = 0`, + conditionParams: [update.mask], + }; + } + if (update.action === 'clear') { + 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], + }; }; diff --git a/worker/src/mails_api/mails_crud.ts b/worker/src/mails_api/mails_crud.ts index f646580..35e38d0 100644 --- a/worker/src/mails_api/mails_crud.ts +++ b/worker/src/mails_api/mails_crud.ts @@ -5,16 +5,27 @@ import { getBooleanValue } from '../utils'; import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common' import { resolveRawEmailRow } from '../gzip' import { getSendBalanceState } from './send_balance'; -import { parseMailFlagFilter, parseMailFlagUpdate, serializeMailFlags } from '../mail_flags'; +import { + getMailFlagUpdateExpression, + parseMailFlagFilter, + parseMailFlagUpdate, + parseReadStatusFilter, + serializeMailFlags, +} from '../mail_flags'; const listMails = async (c: Context) => { const { address } = c.get("jwtPayload") if (!address) { return c.json({ "error": "No address" }, 400) } - const { limit, offset, flag, flag_state } = c.req.query(); + const { limit, offset, flag, flag_state, read_status } = c.req.query(); if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address); - const flagFilter = parseMailFlagFilter(flag, flag_state); + if (read_status !== undefined && (flag !== undefined || flag_state !== undefined)) { + return c.json({ error: "Conflicting mail flag filters" }, 400); + } + const flagFilter = read_status === undefined + ? parseMailFlagFilter(flag, flag_state) + : parseReadStatusFilter(read_status); if (flagFilter === null) return c.json({ error: "Invalid mail flag filter" }, 400); if (flagFilter && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { return c.json({ error: "Mail flags are disabled" }, 403); @@ -68,12 +79,28 @@ const updateMailFlags = async (c: Context) => { const { address } = c.get("jwtPayload"); const placeholders = update.ids.map(() => '?').join(','); + const flagUpdate = getMailFlagUpdateExpression(update); + const condition = flagUpdate.condition ? ` AND ${flagUpdate.condition}` : ''; const result = await c.env.DB.prepare( `UPDATE raw_mails` - + ` SET flags = (COALESCE(flags, 0) | ?) & ~?` - + ` WHERE address = ? AND id IN (${placeholders})` - ).bind(update.add, update.remove, address, ...update.ids).run(); - return c.json({ success: result.success, changes: result.meta.changes ?? 0 }); + + ` SET flags = ${flagUpdate.expression}` + + ` WHERE address = ? AND id IN (${placeholders})${condition}` + ).bind( + ...flagUpdate.params, + address, + ...update.ids, + ...(flagUpdate.conditionParams ?? []), + ).run(); + if (!result.success) return c.json({ success: false, changes: 0, results: [] }, 500); + + const { results } = await c.env.DB.prepare( + `SELECT id, flags FROM raw_mails WHERE address = ? AND id IN (${placeholders})` + ).bind(address, ...update.ids).all(); + return c.json({ + success: true, + changes: result.meta.changes ?? 0, + results: results.map(row => serializeMailFlags(row, true)), + }); }; const getSettings = async (c: Context) => { diff --git a/worker/src/user_api/user_mail_api.ts b/worker/src/user_api/user_mail_api.ts index ab641e0..a6b7d38 100644 --- a/worker/src/user_api/user_mail_api.ts +++ b/worker/src/user_api/user_mail_api.ts @@ -2,19 +2,30 @@ import { Context } from "hono"; import i18n from "../i18n"; import { handleMailListQuery } from "../common"; import { getBooleanValue } from "../utils"; -import { parseMailFlagFilter, parseMailFlagUpdate } from "../mail_flags"; +import { + getMailFlagUpdateExpression, + parseMailFlagFilter, + parseMailFlagUpdate, + parseReadStatusFilter, + serializeMailFlags, +} from "../mail_flags"; export default { getMails: async (c: Context) => { const { user_id } = c.get("userPayload"); - const { address, limit, offset, flag, flag_state } = c.req.query(); + const { address, limit, offset, flag, flag_state, read_status } = c.req.query(); const filterQuerys = [`ua.user_id = ?`]; const filterParams = [String(user_id)]; if (address) { filterQuerys.push(`rm.address = ?`); filterParams.push(address); } - const flagFilter = parseMailFlagFilter(flag, flag_state); + if (read_status !== undefined && (flag !== undefined || flag_state !== undefined)) { + return c.json({ error: "Conflicting mail flag filters" }, 400); + } + const flagFilter = read_status === undefined + ? parseMailFlagFilter(flag, flag_state) + : parseReadStatusFilter(read_status); if (flagFilter === null) return c.json({ error: "Invalid mail flag filter" }, 400); if (flagFilter && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { return c.json({ error: "Mail flags are disabled" }, 403); @@ -61,16 +72,38 @@ export default { const { user_id } = c.get("userPayload"); const placeholders = update.ids.map(() => '?').join(','); + const flagUpdate = getMailFlagUpdateExpression(update); + const condition = flagUpdate.condition ? ` AND ${flagUpdate.condition}` : ''; const result = await c.env.DB.prepare( `UPDATE raw_mails` - + ` SET flags = (COALESCE(flags, 0) | ?) & ~?` + + ` SET flags = ${flagUpdate.expression}` + + ` WHERE id IN (${placeholders})` + + ` AND EXISTS (` + + `SELECT 1 FROM users_address ua` + + ` JOIN address a ON a.id = ua.address_id` + + ` WHERE ua.user_id = ? AND a.name = raw_mails.address` + + `)${condition}` + ).bind( + ...flagUpdate.params, + ...update.ids, + user_id, + ...(flagUpdate.conditionParams ?? []), + ).run(); + if (!result.success) return c.json({ success: false, changes: 0, results: [] }, 500); + + const { results } = await c.env.DB.prepare( + `SELECT id, flags FROM raw_mails` + ` WHERE id IN (${placeholders})` + ` AND EXISTS (` + `SELECT 1 FROM users_address ua` + ` JOIN address a ON a.id = ua.address_id` + ` WHERE ua.user_id = ? AND a.name = raw_mails.address` + `)` - ).bind(update.add, update.remove, ...update.ids, user_id).run(); - return c.json({ success: result.success, changes: result.meta.changes ?? 0 }); + ).bind(...update.ids, user_id).all(); + return c.json({ + success: true, + changes: result.meta.changes ?? 0, + results: results.map(row => serializeMailFlags(row, true)), + }); } }