refactor: move mail flag logic to backend

This commit is contained in:
dreamhunter2333
2026-08-25 20:47:09 +08:00
parent 6fe31df05a
commit de8920ccfd
14 changed files with 206 additions and 122 deletions
+2 -2
View File
@@ -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 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
+2 -2
View File
@@ -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
+20 -10
View File
@@ -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}` },
+12 -21
View File
@@ -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) => {
@@ -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) => {
</n-button>
<n-button v-if="enableMailFlags" size="small" tertiary type="info" @click="onToggleUnread">
{{ hasMailFlag(mail.flags, MAIL_FLAGS.UNREAD) ? t('markRead') : t('markUnread') }}
{{ mail.mail_flags?.unread ? t('markRead') : t('markUnread') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
-13
View File
@@ -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 ''
}
+5 -5
View File
@@ -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 })
});
};
+10 -15
View File
@@ -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')
}
}
+4 -5
View File
@@ -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 })
});
};
@@ -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://<your-worker-address>/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
@@ -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 1019
查询已读邮件使用 `read_status=read``/user_api/mails` 支持相同参数后续扩展其他系统或自定义 Flag 时仍由后端负责名称到 bit 的映射
## admin 邮件 API
+63 -20
View File
@@ -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 = <T extends Record<string, unknown>>(
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],
};
};
+34 -7
View File
@@ -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<HonoCustomType>) => {
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<HonoCustomType>) => {
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<HonoCustomType>) => {
+39 -6
View File
@@ -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<HonoCustomType>) => {
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)),
});
}
}