diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0de68..51cd1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- feat: |邮件状态| 新增可选 Mail Flags 功能,新邮件支持已读/未读状态、打开自动已读、手动切换状态、本页全部已读及按状态筛选;位运算和状态映射统一由后端处理,并为系统状态位及每地址自定义规则 Flag 预留扩展空间 +- feat: |邮件状态| 新增可选邮件状态功能,新邮件支持已读/未读、打开自动已读、手动切换状态、本页全部已读及按状态筛选;前端仅使用已读状态,底层 Flag 存储、计算与后续分组归属统一由后端处理 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API @@ -26,7 +26,7 @@ ### Testing -- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法 Flag 操作校验 +- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法状态操作校验 - test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复 - fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览 - fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 837b95b..0a444fa 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; bit operations and state mapping are handled by the backend, with reserved system/custom bits for future per-address rule assignment +- 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: |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-operation validation +- test: |E2E| Cover unread state on new mail, mailbox isolation, marking mail read, and invalid status-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 ccadb3d..58dafa1 100644 --- a/e2e/tests/api/mail-flags.spec.ts +++ b/e2e/tests/api/mail-flags.spec.ts @@ -6,7 +6,7 @@ import { seedTestMail, } from '../../fixtures/test-helpers'; -test.describe('Mail Flags', () => { +test.describe('Mail Read Status', () => { 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'); @@ -20,7 +20,7 @@ test.describe('Mail Flags', () => { const { results } = await listRes.json(); expect(results).toHaveLength(1); expect(results[0].flags).toBeUndefined(); - expect(results[0].mail_flags).toEqual({ unread: true }); + expect(results[0].unread).toBe(true); const unreadRes = await request.get( `${WORKER_URL}/api/mails?limit=10&offset=0&read_status=unread`, @@ -28,26 +28,26 @@ test.describe('Mail Flags', () => { ); expect((await unreadRes.json()).results).toHaveLength(1); - const deniedRes = await request.patch(`${WORKER_URL}/api/mails/flags`, { + const deniedRes = await request.patch(`${WORKER_URL}/api/mails/read-status`, { headers: { Authorization: `Bearer ${second.jwt}` }, - data: { ids: [results[0].id], flag: 'unread', action: 'clear' }, + data: { ids: [results[0].id], action: 'read' }, }); expect(deniedRes.ok()).toBe(true); expect((await deniedRes.json()).changes).toBe(0); - const updateRes = await request.patch(`${WORKER_URL}/api/mails/flags`, { + const updateRes = await request.patch(`${WORKER_URL}/api/mails/read-status`, { headers: { Authorization: `Bearer ${first.jwt}` }, - data: { ids: [results[0].id], flag: 'unread', action: 'clear' }, + data: { ids: [results[0].id], action: 'read' }, }); expect(updateRes.ok()).toBe(true); const updateResult = await updateRes.json(); expect(updateResult.changes).toBe(1); - expect(updateResult.results[0].mail_flags).toEqual({ unread: false }); + expect(updateResult.results[0].unread).toBe(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].mail_flags).toEqual({ unread: false }); + 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`, @@ -55,26 +55,26 @@ test.describe('Mail Flags', () => { ); expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0); - const toggleRes = await request.patch(`${WORKER_URL}/api/mails/flags`, { + const toggleRes = await request.patch(`${WORKER_URL}/api/mails/read-status`, { headers: { Authorization: `Bearer ${first.jwt}` }, - data: { ids: [results[0].id], flag: 'unread', action: 'toggle' }, + data: { ids: [results[0].id], action: 'toggle' }, }); expect(toggleRes.ok()).toBe(true); - expect((await toggleRes.json()).results[0].mail_flags).toEqual({ unread: true }); + expect((await toggleRes.json()).results[0].unread).toBe(true); } finally { await deleteAddress(request, first.jwt); await deleteAddress(request, second.jwt); } }); - test('rejects unsupported flag names and actions', async ({ request }) => { + test('rejects unsupported read-status actions', async ({ request }) => { const { jwt } = await createTestAddress(request, 'mail-flags-invalid'); try { for (const data of [ - { ids: [1], flag: 'flagged', action: 'set' }, - { ids: [1], flag: 'unread', action: 'invalid' }, + { ids: [1], action: 'invalid' }, + { ids: [1] }, ]) { - const res = await request.patch(`${WORKER_URL}/api/mails/flags`, { + const res = await request.patch(`${WORKER_URL}/api/mails/read-status`, { headers: { Authorization: `Bearer ${jwt}` }, data, }); diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue index 498e163..194e210 100644 --- a/frontend/src/components/MailBox.vue +++ b/frontend/src/components/MailBox.vue @@ -55,12 +55,12 @@ const props = defineProps({ default: false, required: false }, - enableMailFlags: { + enableReadStatus: { type: Boolean, default: false, required: false }, - updateMailFlags: { + updateMailReadStatus: { type: Function, default: () => { }, required: false @@ -68,7 +68,7 @@ const props = defineProps({ }) const localFilterKeyword = ref('') -const mailFlagFilter = ref('all') +const readStatusFilter = ref('all') const { isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate, @@ -106,11 +106,11 @@ const data = computed(() => { }) const isMailUnread = (mail) => { - return props.enableMailFlags && mail?.mail_flags?.unread === true + return props.enableReadStatus && mail?.unread === true } const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread)) -const mailFlagFilterOptions = computed(() => [ +const readStatusFilterOptions = computed(() => [ { label: t('allMail'), value: 'all' }, { label: t('unread'), value: 'unread' }, { label: t('read'), value: 'read' }, @@ -119,12 +119,12 @@ const mailFlagFilterOptions = computed(() => [ const updateUnreadState = async (mails, action) => { if (mails.length === 0) return true try { - const response = await props.updateMailFlags(mails.map(mail => mail.id), 'unread', action) + const response = await props.updateMailReadStatus(mails.map(mail => mail.id), 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 + if (result) mail.unread = result.unread }) return true } catch (error) { @@ -133,7 +133,7 @@ const updateUnreadState = async (mails, action) => { } } -const markMailsRead = async (mails) => updateUnreadState(mails, 'clear') +const markMailsRead = async (mails) => updateUnreadState(mails, 'read') const toggleCurrentMailUnread = async () => { if (!curMail.value) return @@ -148,7 +148,7 @@ const openMail = async (mail) => { const markCurrentPageRead = async () => { if (!await markMailsRead(rawData.value)) return message.success(t("success")) - if (mailFlagFilter.value === 'unread') await refresh() + if (readStatusFilter.value === 'unread') await refresh() } const canGoPrevMail = computed(() => { @@ -232,14 +232,14 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => { } }) -watch(mailFlagFilter, async () => { +watch(readStatusFilter, async () => { await backFirstPageAndRefresh() }) const refresh = async () => { try { const { results, count: totalCount } = await props.fetchMailData( - pageSize.value, (page.value - 1) * pageSize.value, mailFlagFilter.value + pageSize.value, (page.value - 1) * pageSize.value, readStatusFilter.value ); loading.value = true; rawData.value = await Promise.all(results.map(async (item) => { @@ -440,10 +440,10 @@ onBeforeUnmount(() => { {{ t('refresh') }} - + {{ t('markCurrentPageRead') }} - { style="overflow: auto; max-height: 100vh;"> @@ -600,7 +600,7 @@ onBeforeUnmount(() => { {{ t('refresh') }} - + {{ t('markCurrentPageRead') }} @@ -608,8 +608,8 @@ onBeforeUnmount(() => { -
- +
+
@@ -649,7 +649,7 @@ onBeforeUnmount(() => { diff --git a/frontend/src/components/MailContentRenderer.vue b/frontend/src/components/MailContentRenderer.vue index b1d2cdf..5922a58 100644 --- a/frontend/src/components/MailContentRenderer.vue +++ b/frontend/src/components/MailContentRenderer.vue @@ -34,7 +34,7 @@ const props = defineProps({ type: Boolean, default: false }, - enableMailFlags: { + enableReadStatus: { type: Boolean, default: false }, @@ -154,8 +154,8 @@ const handleSaveToS3 = async (filename, blob) => { {{ t('downloadMail') }} - - {{ mail.mail_flags?.unread ? t('markRead') : t('markUnread') }} + + {{ mail.unread ? t('markRead') : t('markUnread') }} diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 0825782..95e4b22 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, - enableMailFlags: false, + enableReadStatus: false, enableAutoReply: false, enableIndexAbout: false, /** @type {string[]} */ diff --git a/frontend/src/views/Index.vue b/frontend/src/views/Index.vue index 8c73242..2dcf866 100644 --- a/frontend/src/views/Index.vue +++ b/frontend/src/views/Index.vue @@ -32,13 +32,13 @@ const SendMail = defineAsyncComponent(() => { const { t } = useScopedI18n('views.Index') -const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => { +const fetchMailData = async (limit, offset, readStatus = 'all') => { 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 = mailFlagFilter === 'all' ? '' : `&read_status=${mailFlagFilter}` + const readStatusQuery = readStatus === 'all' ? '' : `&read_status=${readStatus}` return await api.fetch( `/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, flag, action) => { - return await api.fetch(`/api/mails/flags`, { +const updateMailReadStatus = async (ids, action) => { + return await api.fetch(`/api/mails/read-status`, { method: 'PATCH', - body: JSON.stringify({ ids, flag, action }) + body: JSON.stringify({ ids, action }) }); }; @@ -138,7 +138,7 @@ onMounted(() => { + :enableReadStatus="openSettings.enableReadStatus" :updateMailReadStatus="updateMailReadStatus" /> { 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 && rawMail) { - const response = await api.fetch(`/api/mails/flags`, { + if (openSettings.value.enableReadStatus && rawMail) { + const response = await api.fetch(`/api/mails/read-status`, { method: 'PATCH', - body: JSON.stringify({ ids: [rawMail.id], flag: 'unread', action: 'clear' }) + body: JSON.stringify({ ids: [rawMail.id], action: 'read' }) }) const updatedMail = response.results?.[0] - if (updatedMail) currentMail.value.mail_flags = updatedMail.mail_flags + if (updatedMail) currentMail.value.unread = updatedMail.unread } } catch (error) { console.error('Failed to fetch mails:', error) @@ -65,18 +65,17 @@ const fetchMails = async () => { } const toggleCurrentMailUnread = async () => { - if (!currentMail.value || !openSettings.value.enableMailFlags) return + if (!currentMail.value || !openSettings.value.enableReadStatus) return try { - const response = await api.fetch(`/api/mails/flags`, { + const response = await api.fetch(`/api/mails/read-status`, { method: 'PATCH', body: JSON.stringify({ ids: [currentMail.value.id], - flag: 'unread', action: 'toggle', }) }) const updatedMail = response.results?.[0] - if (updatedMail) currentMail.value.mail_flags = updatedMail.mail_flags + if (updatedMail) currentMail.value.unread = updatedMail.unread } catch (error) { message.error(error.message || 'error') } @@ -246,7 +245,7 @@ onBeforeUnmount(() => {
diff --git a/frontend/src/views/user/UserMailBox.vue b/frontend/src/views/user/UserMailBox.vue index c070ae0..645cabd 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, mailFlagFilter = 'all') => { +const fetchMailData = async (limit, offset, readStatus = 'all') => { return await api.fetch( `/user_api/mails` + `?limit=${limit}` + `&offset=${offset}` - + (mailFlagFilter === 'all' ? '' : `&read_status=${mailFlagFilter}`) + + (readStatus === 'all' ? '' : `&read_status=${readStatus}`) + (addressFilter.value ? `&address=${addressFilter.value}` : '') ); } @@ -51,10 +51,10 @@ const deleteMail = async (curMailId) => { await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' }); }; -const updateMailFlags = async (ids, flag, action) => { - return await api.fetch(`/user_api/mails/flags`, { +const updateMailReadStatus = async (ids, action) => { + return await api.fetch(`/user_api/mails/read-status`, { method: 'PATCH', - body: JSON.stringify({ ids, flag, action }) + body: JSON.stringify({ ids, action }) }); }; @@ -78,7 +78,7 @@ onMounted(() => {
+ :deleteMail="deleteMail" :showFilterInput="true" :enableReadStatus="openSettings.enableReadStatus" + :updateMailReadStatus="updateMailReadStatus" />
diff --git a/vitepress-docs/docs/en/guide/feature/mail-api.md b/vitepress-docs/docs/en/guide/feature/mail-api.md index b552a16..3341e4d 100644 --- a/vitepress-docs/docs/en/guide/feature/mail-api.md +++ b/vitepress-docs/docs/en/guide/feature/mail-api.md @@ -19,21 +19,21 @@ 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 Flags API +## Mail Read Status API -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. +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/flags` to update up to 100 mail IDs. The supported flag is currently `unread`, and its action can be `set`, `clear`, or `toggle`. +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`. ```python requests.patch( - "https:///api/mails/flags", + "https:///api/mails/read-status", headers={"Authorization": f"Bearer {your-JWT-password}"}, - json={"ids": [1, 2], "flag": "unread", "action": "clear"} + json={"ids": [1, 2], "action": "read"} ) ``` -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. +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. Mail-list endpoints use the semantic `read_status` filter. For example, list unread mail with: @@ -41,7 +41,7 @@ Mail-list endpoints use the semantic `read_status` filter. For example, list unr GET /api/mails?limit=20&offset=0&read_status=unread ``` -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. +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. ## 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 a453f0c..1665cda 100644 --- a/vitepress-docs/docs/zh/guide/feature/mail-api.md +++ b/vitepress-docs/docs/zh/guide/feature/mail-api.md @@ -19,21 +19,21 @@ res = requests.get( **注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。 -## 邮件 Flag API +## 邮件已读状态 API -启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应会包含 `mail_flags`,例如 `{"unread": true}`。数据库位掩码、历史 `NULL` 兼容和状态计算全部由后端处理,客户端不需要了解具体 bit。 +启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应会包含布尔字段 `unread`。数据库存储、历史 `NULL` 兼容和状态计算全部由后端处理。 -地址 JWT 使用 `PATCH /api/mails/flags` 批量操作状态。每次最多传入 100 个邮件 ID;当前支持 `unread`,操作可为 `set`、`clear` 或 `toggle`。 +地址 JWT 使用 `PATCH /api/mails/read-status` 批量操作状态。每次最多传入 100 个邮件 ID;`action` 可为 `read`、`unread` 或 `toggle`。 ```python requests.patch( - "https://<你的worker地址>/api/mails/flags", + "https://<你的worker地址>/api/mails/read-status", headers={"Authorization": f"Bearer {你的JWT密码}"}, - json={"ids": [1, 2], "flag": "unread", "action": "clear"} + json={"ids": [1, 2], "action": "read"} ) ``` -用户 JWT 使用相同请求体访问 `PATCH /user_api/mails/flags`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `mail_flags`;服务端始终保留请求未涉及的其他 Flag 位。 +用户 JWT 使用相同请求体访问 `PATCH /user_api/mails/read-status`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `unread` 状态。 邮件列表使用语义化的 `read_status` 查询已读状态。例如查询未读邮件: @@ -41,7 +41,7 @@ requests.patch( GET /api/mails?limit=20&offset=0&read_status=unread ``` -查询已读邮件使用 `read_status=read`。`/user_api/mails` 支持相同参数。后续扩展其他系统或自定义 Flag 时仍由后端负责名称到 bit 的映射。 +查询已读邮件使用 `read_status=read`,`/user_api/mails` 支持相同参数。后续邮件分组也由后端返回分组定义和邮件归属,客户端只负责展示。 ## admin 邮件 API diff --git a/worker/src/admin_api/admin_mail_api.ts b/worker/src/admin_api/admin_mail_api.ts index e1a846d..98e6645 100644 --- a/worker/src/admin_api/admin_mail_api.ts +++ b/worker/src/admin_api/admin_mail_api.ts @@ -1,7 +1,7 @@ import { Context } from "hono"; import { handleMailListQuery } from "../common"; import { resolveRawEmailRow } from "../gzip"; -import { serializeMailFlags } from "../mail_flags"; +import { serializeMailState } from "../mail_flags"; import { getBooleanValue } from "../utils"; export default { @@ -34,7 +34,7 @@ export default { ).bind(id).first(); if (!result) return c.json(null); const resolved = await resolveRawEmailRow(result); - return c.json(serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS))); + return c.json(serializeMailState(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS))); }, deleteMail: async (c: Context) => { const { id } = c.req.param(); diff --git a/worker/src/commom_api.ts b/worker/src/commom_api.ts index 51da126..ab0f58b 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), - "enableMailFlags": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS), + "enableReadStatus": 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/common.ts b/worker/src/common.ts index 63b6a77..744b2f4 100644 --- a/worker/src/common.ts +++ b/worker/src/common.ts @@ -7,7 +7,7 @@ import { unbindTelegramByAddress } from './telegram_api/common'; import { CONSTANTS } from './constants'; import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models'; import i18n from './i18n'; -import { serializeMailFlags } from './mail_flags'; +import { serializeMailState } from './mail_flags'; const DEFAULT_NAME_REGEX = /[^a-z0-9]/g; const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8; @@ -722,7 +722,7 @@ export const handleMailListQuery = async ( ...params, limit, offset ).all(); const resolvedResults = (await resolveRawEmailList(results)).map(row => - serializeMailFlags(row, getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) + serializeMailState(row, getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) ); const count = offset == 0 ? await c.env.DB.prepare( countQuery diff --git a/worker/src/mail_flags.ts b/worker/src/mail_flags.ts index 6c02c13..653df9f 100644 --- a/worker/src/mail_flags.ts +++ b/worker/src/mail_flags.ts @@ -10,17 +10,7 @@ export const MAIL_FLAGS = { export const CUSTOM_MAIL_FLAG_OFFSET = 10; export const CUSTOM_MAIL_FLAG_COUNT = 10; -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); -}; +type MailReadStatusAction = 'read' | 'unread' | 'toggle'; export const getCustomMailFlag = (slot: number): number => { if (!Number.isInteger(slot) || slot < 0 || slot >= CUSTOM_MAIL_FLAG_COUNT) { @@ -29,7 +19,7 @@ export const getCustomMailFlag = (slot: number): number => { return 1 << (CUSTOM_MAIL_FLAG_OFFSET + slot); }; -export const serializeMailFlags = >( +export const serializeMailState = >( row: T, enabled: boolean, ): T => { @@ -39,9 +29,7 @@ export const serializeMailFlags = >( if (!enabled) { return result; } - result.mail_flags = { - unread: (flags & MAIL_FLAGS.UNREAD) !== 0, - }; + result.unread = (flags & MAIL_FLAGS.UNREAD) !== 0; return result; }; @@ -79,38 +67,27 @@ export const insertRawMail = async ( ).bind(source, address, content, messageId, flags).run(); }; -export type MailFlagUpdate = { +export type MailReadStatusUpdate = { ids: number[]; - flag: MailFlagName; mask: number; - action: MailFlagAction; + action: MailReadStatusAction; }; -export type MailFlagFilter = { +export type MailReadStatusFilter = { mask: number; state: 'set' | 'unset'; }; -export const parseMailFlagFilter = ( - flagValue: string | undefined, - stateValue: string | undefined, -): MailFlagFilter | undefined | null => { - if (flagValue === undefined && stateValue === undefined) return undefined; - if (!isMailFlagName(flagValue)) return null; - if (stateValue !== 'set' && stateValue !== 'unset') return null; - return { mask: MAIL_FLAG_MASKS[flagValue], state: stateValue }; -}; - export const parseReadStatusFilter = ( value: string | undefined, -): MailFlagFilter | undefined | null => { +): MailReadStatusFilter | 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 => { +export const parseMailReadStatusUpdate = (value: unknown): MailReadStatusUpdate | 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; @@ -119,18 +96,16 @@ 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 (!isMailFlagName(body.flag)) return null; - if (body.action !== 'set' && body.action !== 'clear' && body.action !== 'toggle') return null; + if (body.action !== 'read' && body.action !== 'unread' && body.action !== 'toggle') return null; - const flag = body.flag; - return { ids, flag, mask: MAIL_FLAG_MASKS[flag], action: body.action }; + return { ids, mask: MAIL_FLAGS.UNREAD, action: body.action }; }; -export const getMailFlagUpdateExpression = ( - update: MailFlagUpdate, +export const getMailReadStatusUpdateExpression = ( + update: MailReadStatusUpdate, column = 'flags', ): { expression: string; params: number[]; condition?: string; conditionParams?: number[] } => { - if (update.action === 'set') { + if (update.action === 'unread') { return { expression: `(COALESCE(${column}, 0) | ?)`, params: [update.mask], @@ -138,7 +113,7 @@ export const getMailFlagUpdateExpression = ( conditionParams: [update.mask], }; } - if (update.action === 'clear') { + if (update.action === 'read') { return { expression: `(COALESCE(${column}, 0) & ~?)`, params: [update.mask], diff --git a/worker/src/mails_api/index.ts b/worker/src/mails_api/index.ts index 53bb889..33c0409 100644 --- a/worker/src/mails_api/index.ts +++ b/worker/src/mails_api/index.ts @@ -28,7 +28,7 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl) // mail crud api.get('/api/mails', mails_crud.listMails) api.get('/api/mail/:mail_id', mails_crud.getMail) -api.patch('/api/mails/flags', mails_crud.updateMailFlags) +api.patch('/api/mails/read-status', mails_crud.updateMailReadStatus) 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 35e38d0..f350cca 100644 --- a/worker/src/mails_api/mails_crud.ts +++ b/worker/src/mails_api/mails_crud.ts @@ -6,11 +6,10 @@ import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } fr import { resolveRawEmailRow } from '../gzip' import { getSendBalanceState } from './send_balance'; import { - getMailFlagUpdateExpression, - parseMailFlagFilter, - parseMailFlagUpdate, + getMailReadStatusUpdateExpression, + parseMailReadStatusUpdate, parseReadStatusFilter, - serializeMailFlags, + serializeMailState, } from '../mail_flags'; const listMails = async (c: Context) => { @@ -18,24 +17,19 @@ const listMails = async (c: Context) => { if (!address) { return c.json({ "error": "No address" }, 400) } - const { limit, offset, flag, flag_state, read_status } = c.req.query(); + const { limit, offset, read_status } = c.req.query(); if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address); - 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); + const readStatusFilter = parseReadStatusFilter(read_status); + if (readStatusFilter === null) return c.json({ error: "Invalid mail read status filter" }, 400); + if (readStatusFilter && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { + return c.json({ error: "Mail read status is disabled" }, 403); } const filters = [`address = ?`]; const params = [address]; - if (flagFilter) { - filters.push(`(COALESCE(flags, 0) & ?) ${flagFilter.state === 'set' ? '!=' : '='} 0`); - params.push(String(flagFilter.mask)); + if (readStatusFilter) { + filters.push(`(COALESCE(flags, 0) & ?) ${readStatusFilter.state === 'set' ? '!=' : '='} 0`); + params.push(String(readStatusFilter.mask)); } const whereClause = filters.join(' AND '); return await handleMailListQuery(c, @@ -53,7 +47,7 @@ const getMail = async (c: Context) => { ).bind(mail_id, address).first(); if (!result) return c.json(null); const resolved = await resolveRawEmailRow(result); - return c.json(serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS))); + return c.json(serializeMailState(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS))); }; const deleteMail = async (c: Context) => { @@ -70,26 +64,26 @@ const deleteMail = async (c: Context) => { return c.json({ success }); }; -const updateMailFlags = async (c: Context) => { +const updateMailReadStatus = async (c: Context) => { if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { - return c.json({ error: "Mail flags are disabled" }, 403); + return c.json({ error: "Mail read status is disabled" }, 403); } - const update = parseMailFlagUpdate(await c.req.json().catch(() => null)); - if (!update) return c.json({ error: "Invalid mail flags request" }, 400); + const update = parseMailReadStatusUpdate(await c.req.json().catch(() => null)); + if (!update) return c.json({ error: "Invalid mail read status request" }, 400); const { address } = c.get("jwtPayload"); const placeholders = update.ids.map(() => '?').join(','); - const flagUpdate = getMailFlagUpdateExpression(update); - const condition = flagUpdate.condition ? ` AND ${flagUpdate.condition}` : ''; + const statusUpdate = getMailReadStatusUpdateExpression(update); + const condition = statusUpdate.condition ? ` AND ${statusUpdate.condition}` : ''; const result = await c.env.DB.prepare( `UPDATE raw_mails` - + ` SET flags = ${flagUpdate.expression}` + + ` SET flags = ${statusUpdate.expression}` + ` WHERE address = ? AND id IN (${placeholders})${condition}` ).bind( - ...flagUpdate.params, + ...statusUpdate.params, address, ...update.ids, - ...(flagUpdate.conditionParams ?? []), + ...(statusUpdate.conditionParams ?? []), ).run(); if (!result.success) return c.json({ success: false, changes: 0, results: [] }, 500); @@ -99,7 +93,7 @@ const updateMailFlags = async (c: Context) => { return c.json({ success: true, changes: result.meta.changes ?? 0, - results: results.map(row => serializeMailFlags(row, true)), + results: results.map(row => serializeMailState(row, true)), }); }; @@ -177,6 +171,6 @@ const clearSentItems = async (c: Context) => { }; export default { - listMails, getMail, deleteMail, updateMailFlags, + listMails, getMail, deleteMail, updateMailReadStatus, getSettings, deleteAddress, clearInbox, clearSentItems }; diff --git a/worker/src/mails_api/parsed_mail_api.ts b/worker/src/mails_api/parsed_mail_api.ts index c071ea4..eb03d17 100644 --- a/worker/src/mails_api/parsed_mail_api.ts +++ b/worker/src/mails_api/parsed_mail_api.ts @@ -2,7 +2,7 @@ import { Context } from 'hono' import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common' import { resolveRawEmailRow } from '../gzip' -import { serializeMailFlags } from '../mail_flags'; +import { serializeMailState } from '../mail_flags'; import { getBooleanValue } from '../utils'; const toParsedMailRow = async (row: Record): Promise> => { @@ -48,7 +48,7 @@ const getParsedMail = async (c: Context) => { ).bind(mail_id, address).first(); if (!row) return c.json(null); const resolved = await resolveRawEmailRow(row); - const serialized = serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS)); + const serialized = serializeMailState(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS)); return c.json(await toParsedMailRow(serialized)); }; diff --git a/worker/src/user_api/index.ts b/worker/src/user_api/index.ts index dc6f5a4..bb99da9 100644 --- a/worker/src/user_api/index.ts +++ b/worker/src/user_api/index.ts @@ -16,7 +16,7 @@ api.get('/user_api/settings', settings.settings); // mail api api.get('/user_api/mails', user_mail_api.getMails); -api.patch('/user_api/mails/flags', user_mail_api.updateMailFlags); +api.patch('/user_api/mails/read-status', user_mail_api.updateMailReadStatus); 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 a6b7d38..b8598fe 100644 --- a/worker/src/user_api/user_mail_api.ts +++ b/worker/src/user_api/user_mail_api.ts @@ -3,36 +3,30 @@ import i18n from "../i18n"; import { handleMailListQuery } from "../common"; import { getBooleanValue } from "../utils"; import { - getMailFlagUpdateExpression, - parseMailFlagFilter, - parseMailFlagUpdate, + getMailReadStatusUpdateExpression, + parseMailReadStatusUpdate, parseReadStatusFilter, - serializeMailFlags, + serializeMailState, } from "../mail_flags"; export default { getMails: async (c: Context) => { const { user_id } = c.get("userPayload"); - const { address, limit, offset, flag, flag_state, read_status } = c.req.query(); + const { address, limit, offset, read_status } = c.req.query(); const filterQuerys = [`ua.user_id = ?`]; const filterParams = [String(user_id)]; if (address) { filterQuerys.push(`rm.address = ?`); filterParams.push(address); } - if (read_status !== undefined && (flag !== undefined || flag_state !== undefined)) { - return c.json({ error: "Conflicting mail flag filters" }, 400); + const readStatusFilter = parseReadStatusFilter(read_status); + if (readStatusFilter === null) return c.json({ error: "Invalid mail read status filter" }, 400); + if (readStatusFilter && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { + return c.json({ error: "Mail read status is disabled" }, 403); } - 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); - } - if (flagFilter) { - filterQuerys.push(`(COALESCE(rm.flags, 0) & ?) ${flagFilter.state === 'set' ? '!=' : '='} 0`); - filterParams.push(String(flagFilter.mask)); + if (readStatusFilter) { + filterQuerys.push(`(COALESCE(rm.flags, 0) & ?) ${readStatusFilter.state === 'set' ? '!=' : '='} 0`); + filterParams.push(String(readStatusFilter.mask)); } const fromQuery = ` FROM users_address ua` + ` JOIN address a ON a.id = ua.address_id` @@ -63,20 +57,20 @@ export default { success: success }) }, - updateMailFlags: async (c: Context) => { + updateMailReadStatus: async (c: Context) => { if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) { - return c.json({ error: "Mail flags are disabled" }, 403); + return c.json({ error: "Mail read status is disabled" }, 403); } - const update = parseMailFlagUpdate(await c.req.json().catch(() => null)); - if (!update) return c.json({ error: "Invalid mail flags request" }, 400); + const update = parseMailReadStatusUpdate(await c.req.json().catch(() => null)); + if (!update) return c.json({ error: "Invalid mail read status request" }, 400); const { user_id } = c.get("userPayload"); const placeholders = update.ids.map(() => '?').join(','); - const flagUpdate = getMailFlagUpdateExpression(update); - const condition = flagUpdate.condition ? ` AND ${flagUpdate.condition}` : ''; + const statusUpdate = getMailReadStatusUpdateExpression(update); + const condition = statusUpdate.condition ? ` AND ${statusUpdate.condition}` : ''; const result = await c.env.DB.prepare( `UPDATE raw_mails` - + ` SET flags = ${flagUpdate.expression}` + + ` SET flags = ${statusUpdate.expression}` + ` WHERE id IN (${placeholders})` + ` AND EXISTS (` + `SELECT 1 FROM users_address ua` @@ -84,10 +78,10 @@ export default { + ` WHERE ua.user_id = ? AND a.name = raw_mails.address` + `)${condition}` ).bind( - ...flagUpdate.params, + ...statusUpdate.params, ...update.ids, user_id, - ...(flagUpdate.conditionParams ?? []), + ...(statusUpdate.conditionParams ?? []), ).run(); if (!result.success) return c.json({ success: false, changes: 0, results: [] }, 500); @@ -103,7 +97,7 @@ export default { return c.json({ success: true, changes: result.meta.changes ?? 0, - results: results.map(row => serializeMailFlags(row, true)), + results: results.map(row => serializeMailState(row, true)), }); } }