mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-27 19:20:33 +08:00
Compare commits
14 Commits
main
...
feat/mail-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f14a3763 | ||
|
|
41fe6f7a54 | ||
|
|
07fcab70cc | ||
|
|
f12163490e | ||
|
|
523f9b8af6 | ||
|
|
fe73bbc54c | ||
|
|
b809a5489f | ||
|
|
cc75977b6e | ||
|
|
c6196b58de | ||
|
|
04138ebcdb | ||
|
|
b5d8f64320 | ||
|
|
b0e801995b | ||
|
|
de8920ccfd | ||
|
|
6fe31df05a |
@@ -10,6 +10,7 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |邮件状态| 新增基于独立稀疏关联表的可选邮件状态功能,不修改原邮件表且历史邮件默认已读;新邮件支持已读/未读、打开自动已读、手动切换、本页全部已读及索引化状态筛选,后端统一返回系统状态枚举与自定义分组
|
||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法状态操作校验
|
||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Mail State| Add optional mail states backed by a separate sparse relation table, leaving the raw-mail table unchanged and treating historical mail as read; support automatic read-on-open, manual toggling, mark-current-page-read, indexed state filters, and backend-owned system and custom state definitions
|
||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |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
|
||||
|
||||
9
db/2026-08-25-mail-flags.sql
Normal file
9
db/2026-08-25-mail-flags.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail
|
||||
ON mail_flags(address_id, flag, mail_id DESC);
|
||||
@@ -15,6 +15,16 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail
|
||||
ON mail_flags(address_id, flag, mail_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
|
||||
@@ -18,6 +18,7 @@ JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_MAIL_FLAGS = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
NO_LIMIT_SEND_ROLE = "case-role"
|
||||
|
||||
395
e2e/tests/api/mail-flags.spec.ts
Normal file
395
e2e/tests/api/mail-flags.spec.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_ENV_OFF,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const addressHeaders = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||
|
||||
async function listAddressMails(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
state = 'all',
|
||||
baseUrl = WORKER_URL,
|
||||
) {
|
||||
const response = await request.get(
|
||||
`${baseUrl}/api/mails?limit=100&offset=0&mail_state=${state}`,
|
||||
{ headers: addressHeaders(jwt) },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function updateAddressMailState(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
ids: number[],
|
||||
state: string,
|
||||
) {
|
||||
const response = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: addressHeaders(jwt),
|
||||
data: { ids, state },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function getStoredFlags(request: APIRequestContext, mailId: number) {
|
||||
const response = await request.get(`${WORKER_URL}/admin/test/mail_flags?mail_id=${mailId}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()).results as { mail_id: number; address_id: number; flag: number }[];
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
try {
|
||||
const statesRes = await request.get(`${WORKER_URL}/api/mail-states`, {
|
||||
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||
});
|
||||
expect(statesRes.ok()).toBe(true);
|
||||
expect((await statesRes.json()).results.map((state: { value: string }) => state.value))
|
||||
.toEqual(['all', 'unread', 'read']);
|
||||
|
||||
await seedTestMail(request, first.address, { subject: 'Unread mail' });
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].flags).toBeUndefined();
|
||||
expect(results[0].unread).toBe(true);
|
||||
expect(await getStoredFlags(request, results[0].id)).toEqual([{
|
||||
mail_id: results[0].id,
|
||||
address_id: first.address_id,
|
||||
flag: 0,
|
||||
}]);
|
||||
|
||||
const detailRes = await request.get(`${WORKER_URL}/api/mail/${results[0].id}`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
expect((await detailRes.json()).unread).toBe(true);
|
||||
|
||||
const parsedDetailRes = await request.get(`${WORKER_URL}/api/parsed_mail/${results[0].id}`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
});
|
||||
expect(parsedDetailRes.ok()).toBe(true);
|
||||
expect((await parsedDetailRes.json()).unread).toBe(true);
|
||||
|
||||
const unreadRes = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${first.jwt}` } },
|
||||
);
|
||||
expect((await unreadRes.json()).results).toHaveLength(1);
|
||||
|
||||
const deniedRes = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: { Authorization: `Bearer ${second.jwt}` },
|
||||
data: { ids: [results[0].id], state: 'read' },
|
||||
});
|
||||
expect(deniedRes.ok()).toBe(true);
|
||||
expect((await deniedRes.json()).changes).toBe(0);
|
||||
|
||||
const updateRes = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||
data: { ids: [results[0].id], state: 'read' },
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
const updateResult = await updateRes.json();
|
||||
expect(updateResult.changes).toBe(1);
|
||||
expect(updateResult.results[0].unread).toBe(false);
|
||||
expect(await getStoredFlags(request, results[0].id)).toEqual([]);
|
||||
|
||||
const updatedListRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||
});
|
||||
expect((await updatedListRes.json()).results[0].unread).toBe(false);
|
||||
|
||||
const unreadAfterUpdateRes = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${first.jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0);
|
||||
|
||||
const unreadStateRes = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||
data: { ids: [results[0].id], state: 'unread' },
|
||||
});
|
||||
expect(unreadStateRes.ok()).toBe(true);
|
||||
expect((await unreadStateRes.json()).results[0].unread).toBe(true);
|
||||
expect(await getStoredFlags(request, results[0].id)).toHaveLength(1);
|
||||
} finally {
|
||||
await deleteAddress(request, first.jwt);
|
||||
await deleteAddress(request, second.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports batch and idempotent updates with accurate filters and cleanup', async ({ request }) => {
|
||||
const mailbox = await createTestAddress(request, 'mail-flags-batch');
|
||||
try {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await seedTestMail(request, mailbox.address, { subject: `Flag batch ${index}` });
|
||||
}
|
||||
|
||||
const initial = await listAddressMails(request, mailbox.jwt);
|
||||
const ids = initial.results.map((mail: { id: number }) => mail.id);
|
||||
expect(initial.count).toBe(3);
|
||||
expect(initial.results.every((mail: { unread: boolean }) => mail.unread)).toBe(true);
|
||||
|
||||
const firstUpdate = await updateAddressMailState(request, mailbox.jwt, ids.slice(0, 2), 'read');
|
||||
expect(firstUpdate.changes).toBe(2);
|
||||
expect(firstUpdate.results).toHaveLength(2);
|
||||
expect(firstUpdate.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true);
|
||||
|
||||
const duplicateUpdate = await updateAddressMailState(request, mailbox.jwt, ids.slice(0, 2), 'read');
|
||||
expect(duplicateUpdate.changes).toBe(0);
|
||||
|
||||
const read = await listAddressMails(request, mailbox.jwt, 'read');
|
||||
expect(read.count).toBe(2);
|
||||
expect(read.results.map((mail: { id: number }) => mail.id).sort()).toEqual(ids.slice(0, 2).sort());
|
||||
expect(read.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true);
|
||||
|
||||
const unread = await listAddressMails(request, mailbox.jwt, 'unread');
|
||||
expect(unread.count).toBe(1);
|
||||
expect(unread.results[0].id).toBe(ids[2]);
|
||||
expect(unread.results[0].unread).toBe(true);
|
||||
|
||||
const duplicateUnread = await updateAddressMailState(request, mailbox.jwt, [ids[2]], 'unread');
|
||||
expect(duplicateUnread.changes).toBe(0);
|
||||
|
||||
const deleteRes = await request.delete(`${WORKER_URL}/api/mails/${ids[2]}`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
expect(await getStoredFlags(request, ids[2])).toEqual([]);
|
||||
expect((await updateAddressMailState(request, mailbox.jwt, [ids[2]], 'unread')).changes).toBe(0);
|
||||
} finally {
|
||||
await deleteAddress(request, mailbox.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unsupported mail states', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'mail-flags-invalid');
|
||||
try {
|
||||
for (const data of [
|
||||
{ ids: [1], state: 'invalid' },
|
||||
{ ids: [1] },
|
||||
{ ids: [], state: 'read' },
|
||||
{ ids: [0], state: 'read' },
|
||||
{ ids: [1.5], state: 'read' },
|
||||
{ ids: ['1'], state: 'read' },
|
||||
{ ids: Array.from({ length: 101 }, (_, index) => index + 1), state: 'read' },
|
||||
]) {
|
||||
const res = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data,
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
}
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('mail without a flag relation is read by default', async ({ request }) => {
|
||||
const mailbox = await createTestAddress(request, 'mail-flags-history');
|
||||
try {
|
||||
const seedRes = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address: mailbox.address,
|
||||
raw: [
|
||||
`From: sender@example.com`,
|
||||
`To: ${mailbox.address}`,
|
||||
`Subject: Historical mail`,
|
||||
`Message-ID: <historical-mail@test>`,
|
||||
``,
|
||||
`Historical body`,
|
||||
].join('\r\n'),
|
||||
},
|
||||
});
|
||||
expect(seedRes.ok()).toBe(true);
|
||||
|
||||
const list = async (state: string) => {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=${state}`,
|
||||
{ headers: { Authorization: `Bearer ${mailbox.jwt}` } },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const historical = (await list('all')).results[0];
|
||||
expect(historical.unread).toBe(false);
|
||||
expect(await getStoredFlags(request, historical.id)).toEqual([]);
|
||||
expect((await list('read')).results).toHaveLength(1);
|
||||
expect((await list('unread')).results).toHaveLength(0);
|
||||
} finally {
|
||||
await deleteAddress(request, mailbox.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('user APIs query and mutate flags only for bound addresses', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||
let originalSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalSettings = await settingsRes.json();
|
||||
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { ...originalSettings, enable: true, enableMailVerify: false, maxAddressCount: 0 },
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
const email = `mail-flags-user-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('mail-flags-password');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
userId = payload.user_id;
|
||||
|
||||
addresses.push(
|
||||
await createTestAddress(request, 'mf-user-a'),
|
||||
await createTestAddress(request, 'mf-user-b'),
|
||||
);
|
||||
outsider = await createTestAddress(request, 'mf-outsider');
|
||||
|
||||
for (const mailbox of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
...addressHeaders(mailbox.jwt),
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
await seedTestMail(request, mailbox.address, { subject: `Bound ${mailbox.address}` });
|
||||
}
|
||||
await seedTestMail(request, outsider.address, { subject: 'Outsider unread' });
|
||||
|
||||
const statesRes = await request.get(`${WORKER_URL}/user_api/mail-states`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(statesRes.ok()).toBe(true);
|
||||
expect((await statesRes.json()).results.map((state: { value: string }) => state.value))
|
||||
.toEqual(['all', 'unread', 'read']);
|
||||
|
||||
const userList = async (state: string, address?: string) => {
|
||||
const addressQuery = address ? `&address=${encodeURIComponent(address)}` : '';
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&mail_state=${state}${addressQuery}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const unread = await userList('unread');
|
||||
expect(unread.count).toBe(2);
|
||||
expect(new Set(unread.results.map((mail: { address: string }) => mail.address)))
|
||||
.toEqual(new Set(addresses.map(mailbox => mailbox.address)));
|
||||
expect((await userList('unread', addresses[0].address)).results).toHaveLength(1);
|
||||
expect((await userList('unread', outsider.address)).results).toHaveLength(0);
|
||||
|
||||
const firstMail = unread.results.find(
|
||||
(mail: { address: string }) => mail.address === addresses[0].address,
|
||||
);
|
||||
const updateRes = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [firstMail.id], state: 'read' },
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
expect((await updateRes.json()).results[0].unread).toBe(false);
|
||||
expect((await userList('read')).results.map((mail: { id: number }) => mail.id))
|
||||
.toContain(firstMail.id);
|
||||
|
||||
const outsiderMail = (await listAddressMails(request, outsider.jwt)).results[0];
|
||||
const deniedRes = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [outsiderMail.id], state: 'read' },
|
||||
});
|
||||
expect(deniedRes.ok()).toBe(true);
|
||||
expect((await deniedRes.json()).changes).toBe(0);
|
||||
expect((await listAddressMails(request, outsider.jwt, 'unread')).results).toHaveLength(1);
|
||||
} finally {
|
||||
await Promise.allSettled(
|
||||
[...addresses, outsider].filter((mailbox): mailbox is NonNullable<typeof mailbox> => mailbox !== undefined)
|
||||
.map((mailbox) => deleteAddress(request, mailbox.jwt)),
|
||||
);
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
if (originalSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, { data: originalSettings });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled feature keeps legacy mail responses and rejects state APIs', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
const createRes = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, {
|
||||
data: { name: `mail-flags-off-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const mailbox = await createRes.json();
|
||||
|
||||
try {
|
||||
const raw = [
|
||||
`From: sender@example.com`,
|
||||
`To: ${mailbox.address}`,
|
||||
`Subject: Flags disabled`,
|
||||
`Message-ID: <flags-disabled-${Date.now()}@test>`,
|
||||
``,
|
||||
`Disabled body`,
|
||||
].join('\r\n');
|
||||
const receiveRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@example.com', to: mailbox.address, raw },
|
||||
});
|
||||
expect(receiveRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const list = await listRes.json();
|
||||
expect(list.results).toHaveLength(1);
|
||||
expect(list.results[0]).not.toHaveProperty('unread');
|
||||
expect(list.results[0]).not.toHaveProperty('flags');
|
||||
|
||||
const statesRes = await request.get(`${WORKER_URL_ENV_OFF}/api/mail-states`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
expect(statesRes.status()).toBe(403);
|
||||
|
||||
const filterRes = await request.get(
|
||||
`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: addressHeaders(mailbox.jwt) },
|
||||
);
|
||||
expect(filterRes.status()).toBe(403);
|
||||
|
||||
const updateRes = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/state`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
data: { ids: [list.results[0].id], state: 'read' },
|
||||
});
|
||||
expect(updateRes.status()).toBe(403);
|
||||
} finally {
|
||||
await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${mailbox.address_id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
91
e2e/tests/browser/mail-flags.spec.ts
Normal file
91
e2e/tests/browser/mail-flags.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail state browser flow', () => {
|
||||
test('opens, toggles, filters and marks the current page read', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-flags-browser');
|
||||
jwt = mailbox.jwt;
|
||||
const subjects = [`Unread A ${Date.now()}`, `Unread B ${Date.now()}`];
|
||||
for (const subject of subjects) {
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
await expect(page.getByText('Unread', { exact: true })).toHaveCount(2);
|
||||
|
||||
const openStateResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByText(subjects[0], { exact: true }).click();
|
||||
expect((await openStateResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toBeVisible();
|
||||
|
||||
const unreadAfterOpen = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterOpen.json()).results).toHaveLength(1);
|
||||
|
||||
const toggleResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||
expect((await toggleResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Read' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to List' }).click();
|
||||
const pageReadResponse = page.waitForResponse((response) => {
|
||||
if (new URL(response.url()).pathname !== '/api/mails/state') return false;
|
||||
if (response.request().method() !== 'PATCH') return false;
|
||||
const body = response.request().postDataJSON();
|
||||
return body.state === 'read' && body.ids.length === 2;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark This Page as Read' }).click();
|
||||
expect((await pageReadResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark This Page as Read' })).toBeHidden();
|
||||
|
||||
const unreadAfterPage = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterPage.json()).results).toHaveLength(0);
|
||||
|
||||
const unreadFilterResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && url.searchParams.get('mail_state') === 'unread';
|
||||
});
|
||||
const stateSelect = page.locator('.n-select').filter({ hasText: 'All Mail' }).first();
|
||||
await stateSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: /^Unread$/ }).click();
|
||||
expect((await unreadFilterResponse).ok()).toBe(true);
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,9 +55,26 @@ const props = defineProps({
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
enableMailStates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
updateMailState: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
fetchMailStates: {
|
||||
type: Function,
|
||||
default: () => ({ results: [] }),
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const localFilterKeyword = ref('')
|
||||
const mailStateFilter = ref(null)
|
||||
const mailStates = ref([])
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
||||
@@ -94,6 +111,57 @@ const data = computed(() => {
|
||||
});
|
||||
})
|
||||
|
||||
const isMailUnread = (mail) => {
|
||||
return props.enableMailStates && mail?.unread === true
|
||||
}
|
||||
|
||||
const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread))
|
||||
const mailStateFilterOptions = computed(() => mailStates.value.map(state => ({
|
||||
label: state.label || t(state.label_key),
|
||||
value: state.value,
|
||||
})))
|
||||
|
||||
const getReadStateValue = (unread) => {
|
||||
return mailStates.value.find(state => state.unread === unread)?.value
|
||||
}
|
||||
|
||||
const updateUnreadState = async (mails, state) => {
|
||||
if (mails.length === 0 || !state) return true
|
||||
try {
|
||||
const response = await props.updateMailState(mails.map(mail => mail.id), state)
|
||||
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.unread = result.unread
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const markMailsRead = async (mails) => {
|
||||
return await updateUnreadState(mails.filter(isMailUnread), getReadStateValue(false))
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!curMail.value) return
|
||||
await updateUnreadState([curMail.value], getReadStateValue(!curMail.value.unread))
|
||||
}
|
||||
|
||||
const openMail = async (mail) => {
|
||||
curMail.value = mail
|
||||
await markMailsRead([mail])
|
||||
}
|
||||
|
||||
const markCurrentPageRead = async () => {
|
||||
if (!await markMailsRead(rawData.value)) return
|
||||
message.success(t("success"))
|
||||
if (mailStateFilter.value === getReadStateValue(true)) await backFirstPageAndRefresh()
|
||||
}
|
||||
|
||||
const canGoPrevMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
@@ -111,12 +179,12 @@ const prevMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
curMail.value = data.value[currentIndex - 1]
|
||||
await openMail(data.value[currentIndex - 1])
|
||||
} else if (page.value > 1) {
|
||||
page.value--
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[data.value.length - 1]
|
||||
await openMail(data.value[data.value.length - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,12 +194,12 @@ const nextMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex < data.value.length - 1) {
|
||||
curMail.value = data.value[currentIndex + 1]
|
||||
await openMail(data.value[currentIndex + 1])
|
||||
} else if (count.value > page.value * pageSize.value) {
|
||||
page.value++
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[0]
|
||||
await openMail(data.value[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,22 +243,25 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(mailStateFilter, async (_value, oldValue) => {
|
||||
if (oldValue === null) return
|
||||
await backFirstPageAndRefresh()
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await props.fetchMailData(
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
pageSize.value, (page.value - 1) * pageSize.value, mailStateFilter.value
|
||||
);
|
||||
loading.value = true;
|
||||
rawData.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (page.value === 1) count.value = totalCount;
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
await openMail(data.value[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -215,7 +286,7 @@ const clickRow = async (row) => {
|
||||
curMail.value = null;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
await openMail(row);
|
||||
};
|
||||
|
||||
|
||||
@@ -329,6 +400,16 @@ const multiActionDownload = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.enableMailStates) {
|
||||
try {
|
||||
const { results = [] } = await props.fetchMailStates()
|
||||
mailStates.value = results
|
||||
mailStateFilter.value = results.find(state => state.default)?.value ?? results[0]?.value ?? null
|
||||
} catch (error) {
|
||||
mailStates.value = []
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
@@ -381,6 +462,11 @@ onBeforeUnmount(() => {
|
||||
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-button v-if="enableMailStates && currentPageHasUnread" @click="markCurrentPageRead" tertiary>
|
||||
{{ t('markCurrentPageRead') }}
|
||||
</n-button>
|
||||
<n-select v-if="enableMailStates" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
|
||||
style="width: 120px" />
|
||||
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
|
||||
clearable />
|
||||
@@ -397,12 +483,15 @@ onBeforeUnmount(() => {
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -461,6 +550,7 @@ onBeforeUnmount(() => {
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:enableMailStates="enableMailStates" :onToggleUnread="toggleCurrentMailUnread"
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
@@ -475,7 +565,7 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="mail-list-scroll">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
@@ -487,6 +577,9 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="mail-list-meta">
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -529,16 +622,26 @@ onBeforeUnmount(() => {
|
||||
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-button v-if="enableMailStates && currentPageHasUnread" @click="markCurrentPageRead" tertiary size="small">
|
||||
{{ t('markCurrentPageRead') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
|
||||
<n-input v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||
</div>
|
||||
<div v-if="enableMailStates" style="padding: 0 10px; margin-bottom: 10px;">
|
||||
<n-select v-model:value="mailStateFilter" :options="mailStateFilterOptions" size="small" />
|
||||
</div>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="{ 'mail-list-unread': isMailUnread(row) }">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -568,6 +671,7 @@ onBeforeUnmount(() => {
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||
:enableMailStates="enableMailStates" :onToggleUnread="toggleCurrentMailUnread"
|
||||
:onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
@@ -676,6 +780,10 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-unread :deep(.n-thing-header__title) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -34,6 +34,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableMailStates: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 回调函数 props
|
||||
onDelete: {
|
||||
type: Function,
|
||||
@@ -50,6 +54,10 @@ const props = defineProps({
|
||||
onSaveToS3: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onToggleUnread: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,6 +154,10 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="enableMailStates" size="small" tertiary type="info" @click="onToggleUnread">
|
||||
{{ mail.unread ? t('markRead') : t('markUnread') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
|
||||
@@ -34,6 +34,10 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"components.MailBox": {
|
||||
"allMail": {
|
||||
"en": "All Mail",
|
||||
"zh": "全部邮件"
|
||||
},
|
||||
"attachments": {
|
||||
"en": "Show Attachments",
|
||||
"zh": "查看附件"
|
||||
@@ -74,6 +78,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Filter current page",
|
||||
"zh": "过滤当前页"
|
||||
},
|
||||
"markCurrentPageRead": {
|
||||
"en": "Mark This Page as Read",
|
||||
"zh": "本页全部已读"
|
||||
},
|
||||
"multiAction": {
|
||||
"en": "Multi Action",
|
||||
"zh": "多选"
|
||||
@@ -94,6 +102,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Query",
|
||||
"zh": "查询"
|
||||
},
|
||||
"read": {
|
||||
"en": "Read",
|
||||
"zh": "已读"
|
||||
},
|
||||
"refresh": {
|
||||
"en": "Refresh",
|
||||
"zh": "刷新"
|
||||
@@ -129,6 +141,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"unselectAll": {
|
||||
"en": "Unselect All",
|
||||
"zh": "取消全选"
|
||||
},
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"zh": "未读"
|
||||
}
|
||||
},
|
||||
"components.AiExtractInfo": {
|
||||
@@ -194,6 +210,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Load Images",
|
||||
"zh": "加载图片"
|
||||
},
|
||||
"markRead": {
|
||||
"en": "Mark as Read",
|
||||
"zh": "标记已读"
|
||||
},
|
||||
"markUnread": {
|
||||
"en": "Mark as Unread",
|
||||
"zh": "标记未读"
|
||||
},
|
||||
"remoteImagesBlocked": {
|
||||
"en": "{count} remote resources blocked to protect your privacy",
|
||||
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableMailStates: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
|
||||
@@ -32,19 +32,33 @@ const SendMail = defineAsyncComponent(() => {
|
||||
|
||||
const { t } = useScopedI18n('views.Index')
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
const fetchMailData = async (limit, offset, mailState) => {
|
||||
if (mailIdQuery.value > 0) {
|
||||
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
||||
if (singleMail) return { results: [singleMail], count: 1 };
|
||||
return { results: [], count: 0 };
|
||||
}
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
const mailStateQuery = mailState ? `&mail_state=${encodeURIComponent(mailState)}` : ''
|
||||
return await api.fetch(
|
||||
`/api/mails?limit=${limit}&offset=${offset}${mailStateQuery}`
|
||||
);
|
||||
};
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailState = async (ids, state) => {
|
||||
return await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, state })
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMailStates = async () => {
|
||||
return await api.fetch(`/api/mail-states`)
|
||||
}
|
||||
|
||||
const deleteSenboxMail = async (curMailId) => {
|
||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
@@ -127,7 +141,9 @@ onMounted(() => {
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
|
||||
:enableMailStates="openSettings.enableMailStates" :updateMailState="updateMailState"
|
||||
:fetchMailStates="fetchMailStates" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
|
||||
@@ -26,12 +26,17 @@ const message = useMessage()
|
||||
const currentPage = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const currentMail = ref(null)
|
||||
const mailStates = ref([])
|
||||
const showAccountSettingsCard = ref(false)
|
||||
const currentAutoRefreshInterval = ref(60)
|
||||
const timer = ref(null)
|
||||
|
||||
const { t } = useScopedI18n('views.index.SimpleIndex')
|
||||
|
||||
const getReadStateValue = (unread) => {
|
||||
return mailStates.value.find(state => state.unread === unread)?.value
|
||||
}
|
||||
|
||||
// 复制地址
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
@@ -50,12 +55,41 @@ 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.enableMailStates && rawMail?.unread) {
|
||||
const state = getReadStateValue(false)
|
||||
if (!state) return
|
||||
const response = await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids: [rawMail.id], state })
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.unread = updatedMail.unread
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mails:', error)
|
||||
message.error('获取邮件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!currentMail.value || !openSettings.value.enableMailStates) return
|
||||
try {
|
||||
const state = getReadStateValue(!currentMail.value.unread)
|
||||
if (!state) return
|
||||
const response = await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [currentMail.value.id],
|
||||
state,
|
||||
})
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.unread = updatedMail.unread
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除邮件
|
||||
const deleteMail = async () => {
|
||||
if (!currentMail.value) return;
|
||||
@@ -106,6 +140,15 @@ watch(currentPage, () => {
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings()
|
||||
if (openSettings.value.enableMailStates) {
|
||||
try {
|
||||
const { results = [] } = await api.fetch(`/api/mail-states`)
|
||||
mailStates.value = results
|
||||
} catch (error) {
|
||||
mailStates.value = []
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
await fetchMails()
|
||||
|
||||
// 启动自动刷新
|
||||
@@ -220,6 +263,7 @@ onBeforeUnmount(() => {
|
||||
<div style="margin-top: 16px;">
|
||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||
:enableMailStates="openSettings.enableMailStates" :onToggleUnread="toggleCurrentMailUnread"
|
||||
:onDelete="deleteMail" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,12 @@ const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
const fetchMailData = async (limit, offset, mailState) => {
|
||||
return await api.fetch(
|
||||
`/user_api/mails`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (mailState ? `&mail_state=${encodeURIComponent(mailState)}` : '')
|
||||
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
|
||||
);
|
||||
}
|
||||
@@ -50,6 +51,17 @@ const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailState = async (ids, state) => {
|
||||
return await api.fetch(`/user_api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, state })
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMailStates = async () => {
|
||||
return await api.fetch(`/user_api/mail-states`)
|
||||
}
|
||||
|
||||
watch(addressFilter, async (newValue) => {
|
||||
queryMail();
|
||||
});
|
||||
@@ -70,6 +82,7 @@ onMounted(() => {
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" :showFilterInput="true" />
|
||||
:deleteMail="deleteMail" :showFilterInput="true" :enableMailStates="openSettings.enableMailStates"
|
||||
:updateMailState="updateMailState" :fetchMailStates="fetchMailStates" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@ offset = 0
|
||||
res = requests.get(
|
||||
f"https://<your-worker-address>/api/mails?limit={limit}&offset={offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {your-JWT-password}",
|
||||
"Authorization": "Bearer <your-JWT-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
@@ -19,6 +19,32 @@ 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 State API
|
||||
|
||||
After enabling `ENABLE_MAIL_FLAGS` and running the database migration, each mail response includes the boolean field `unread`. Mail states live in a separate sparse relation table without changing `raw_mails`; historical mail without a state record is read by default, and the backend owns all state calculation.
|
||||
|
||||
With an Address JWT, use `GET /api/mail-states` to retrieve the currently available system states. This endpoint is the extension point for future address-specific custom states. The frontend uses each returned `value` directly for filtering and moving, and displays its `label_key` or `label`.
|
||||
|
||||
Use `PATCH /api/mails/state` to move the state of up to 100 mail IDs:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<your-worker-address>/api/mails/state",
|
||||
headers={"Authorization": "Bearer <your-JWT-password>"},
|
||||
json={"ids": [1, 2], "state": "read"}
|
||||
)
|
||||
```
|
||||
|
||||
With a User JWT, use `GET /user_api/mail-states` and `PATCH /user_api/mails/state`. Only mail belonging to addresses bound to that user can be changed. The response contains the updated `unread` state.
|
||||
|
||||
Mail-list endpoints accept a state `value` returned by the backend. For example, list unread mail with:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread
|
||||
```
|
||||
|
||||
`/user_api/mails` accepts the same parameter. Future custom groups only require the backend to append custom states and resolve their values; clients do not add another enum.
|
||||
|
||||
## Admin Mail API
|
||||
|
||||
Supports `address` filter
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
||||
| `ENABLE_MAIL_FLAGS` | Text/JSON | Enables per-message state in the web inbox. New mail starts unread and can be opened to mark read, toggled manually, filtered by state, or marked read for the current page. State is stored in a separate sparse relation table without changing `raw_mails`; historical mail without a relation is read by default. **Run the database migration first so the `mail_flags` table exists.** | `true` |
|
||||
| `CLEANUP_BATCH_SIZE` | Number | Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to `3000`, valid range `1-5000`. Smaller values reduce per-run D1 pressure; larger values clear backlogs faster | `3000` |
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -10,7 +10,7 @@ offset = 0
|
||||
res = requests.get(
|
||||
f"https://<你的worker地址>/api/mails?limit={limit}&offset={offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
"Authorization": "Bearer <你的JWT密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
@@ -19,6 +19,32 @@ res = requests.get(
|
||||
|
||||
**注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。
|
||||
|
||||
## 邮件状态 API
|
||||
|
||||
启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应会包含布尔字段 `unread`。邮件状态保存在独立的稀疏关联表中,不修改 `raw_mails`;没有状态记录的历史邮件默认已读,状态计算全部由后端处理。
|
||||
|
||||
地址 JWT 使用 `GET /api/mail-states` 获取当前可用的系统状态;该接口也是未来扩展地址自定义状态的入口。前端直接使用其中的 `value` 作为筛选和移动参数,并使用 `label_key` 或 `label` 显示名称。
|
||||
|
||||
使用 `PATCH /api/mails/state` 批量移动邮件状态,每次最多传入 100 个邮件 ID:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<你的worker地址>/api/mails/state",
|
||||
headers={"Authorization": "Bearer <你的JWT密码>"},
|
||||
json={"ids": [1, 2], "state": "read"}
|
||||
)
|
||||
```
|
||||
|
||||
用户 JWT 使用 `GET /user_api/mail-states` 和 `PATCH /user_api/mails/state`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `unread` 状态。
|
||||
|
||||
邮件列表使用后端返回的状态 `value` 查询。例如查询未读邮件:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread
|
||||
```
|
||||
|
||||
`/user_api/mails` 支持相同参数。后续增加自定义分组时,只需由后端将自定义状态拼接到状态列表并解析其 `value`,客户端无需增加枚举。
|
||||
|
||||
## admin 邮件 API
|
||||
|
||||
支持 `address` 过滤
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
||||
| `ENABLE_MAIL_FLAGS` | 文本/JSON | 启用网页邮件状态功能。新邮件默认未读,支持打开自动已读、手动切换、按状态筛选及本页全部已读。状态存储在独立的稀疏关联表中,不修改 `raw_mails`,无关联记录的历史邮件默认已读。**启用前必须先执行数据库迁移,确保 `mail_flags` 表已创建。** | `true` |
|
||||
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
import i18n from '../i18n'
|
||||
import { getBooleanValue } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { deleteRawMails, prepareRawMailDeleteStatements } from '../mail_flags'
|
||||
|
||||
const listAddresses = async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
@@ -74,10 +75,12 @@ const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
// deleted first and the address row last, so the name subqueries still
|
||||
// resolve and a failed statement rolls back the whole deletion
|
||||
const results = await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id),
|
||||
...prepareRawMailDeleteStatements(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (select name from address where id = ?)`,
|
||||
[id],
|
||||
),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
@@ -107,10 +110,12 @@ const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: mailSuccess } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (select name from address where id = ?)`,
|
||||
[id],
|
||||
);
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import { resolveRawEmailRow } from "../gzip";
|
||||
import { deleteRawMails, serializeMailState } from "../mail_flags";
|
||||
|
||||
export default {
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
@@ -31,13 +32,15 @@ export default {
|
||||
`SELECT * FROM raw_mails WHERE id = ?`
|
||||
).bind(id).first();
|
||||
if (!result) return c.json(null);
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
return c.json(await serializeMailState(
|
||||
c.env.DB,
|
||||
await resolveRawEmailRow(result),
|
||||
c.env,
|
||||
));
|
||||
},
|
||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE id = ? `
|
||||
).bind(id).run();
|
||||
const { success } = await deleteRawMails(c.env.DB, c.env, `id = ?`, [id]);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getJsonSetting, saveSetting } from '../utils';
|
||||
import { CleanupSettings, CustomSqlCleanup } from '../models';
|
||||
import i18n from '../i18n';
|
||||
import { LocaleMessages } from '../i18n/type';
|
||||
import { cleanupOrphanMailFlags } from '../mail_flags';
|
||||
|
||||
// SQL validation error types
|
||||
type SqlValidationError = 'empty' | 'too_long' | 'not_delete' | 'multiple_statements' | 'has_comments';
|
||||
@@ -84,6 +85,7 @@ export const executeCustomSqlCleanup = async (
|
||||
console.log(`Executing custom SQL cleanup [${customSql.name}]: ${sql}`);
|
||||
const result = await c.env.DB.prepare(sql).run();
|
||||
const rowsAffected = result.meta?.changes ?? 0;
|
||||
await cleanupOrphanMailFlags(c.env.DB, c.env);
|
||||
console.log(`Custom SQL cleanup [${customSql.name}] completed, rows affected: ${rowsAffected}`);
|
||||
return { success: true, rowsAffected };
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,6 +20,15 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
@@ -197,6 +206,18 @@ export default {
|
||||
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;`);
|
||||
}
|
||||
}
|
||||
if (version && version <= "v0.0.7") {
|
||||
await c.env.DB.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail
|
||||
ON mail_flags(address_id, flag, mail_id DESC);
|
||||
`);
|
||||
}
|
||||
if (version != CONSTANTS.DB_VERSION) {
|
||||
// remove all \r and \n characters from the query string
|
||||
// split by ; and join with a ;\n
|
||||
|
||||
@@ -78,4 +78,18 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default { seedMail, receiveMail };
|
||||
const getMailFlags = async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
||||
return c.text("Not available", 404);
|
||||
}
|
||||
const mailId = Number(c.req.query('mail_id'));
|
||||
if (!Number.isInteger(mailId) || mailId <= 0) {
|
||||
return c.text("Invalid mail_id", 400);
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT mail_id, address_id, flag FROM mail_flags WHERE mail_id = ? ORDER BY flag`
|
||||
).bind(mailId).all();
|
||||
return c.json({ results });
|
||||
};
|
||||
|
||||
export default { seedMail, receiveMail, getMailFlags };
|
||||
|
||||
@@ -112,3 +112,4 @@ api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings
|
||||
// E2E test endpoints
|
||||
api.post('/admin/test/seed_mail', e2e_test_api.seedMail)
|
||||
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail)
|
||||
api.get('/admin/test/mail_flags', e2e_test_api.getMailFlags)
|
||||
|
||||
@@ -40,6 +40,7 @@ export default {
|
||||
"ENABLE_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
|
||||
"DISABLE_ANONYMOUS_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
||||
"ENABLE_USER_DELETE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||
"ENABLE_MAIL_FLAGS": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS),
|
||||
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||
"COPYRIGHT": c.env.COPYRIGHT,
|
||||
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
|
||||
|
||||
@@ -20,6 +20,7 @@ api.get('/open_api/settings', async (c) => {
|
||||
) || {};
|
||||
const smtpProxyConfig = smtpImapProxyConfig.smtp || {};
|
||||
const imapProxyConfig = smtpImapProxyConfig.imap || {};
|
||||
const enableMailStates = utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS);
|
||||
|
||||
return c.json({
|
||||
"title": c.env.TITLE,
|
||||
@@ -39,6 +40,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),
|
||||
...(enableMailStates ? { "enableMailStates": true } : {}),
|
||||
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
|
||||
"copyright": c.env.COPYRIGHT,
|
||||
|
||||
@@ -7,6 +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 { deleteRawMails, serializeMailStates } from './mail_flags';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
@@ -527,20 +528,25 @@ export const cleanup = async (
|
||||
)
|
||||
break;
|
||||
case "mails":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE id IN (
|
||||
await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`id IN (
|
||||
SELECT id FROM raw_mails
|
||||
WHERE created_at < datetime('now', ?)
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?
|
||||
)`
|
||||
).bind(`-${cleanDays} day`, cleanupBatchSize).run();
|
||||
LIMIT ?)`,
|
||||
[`-${cleanDays} day`, cleanupBatchSize],
|
||||
);
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE address NOT IN
|
||||
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address NOT IN (select name from address)`
|
||||
+ ` AND created_at < datetime('now', '-${cleanDays} day')`,
|
||||
[],
|
||||
);
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
@@ -569,10 +575,12 @@ const batchDeleteAddressWithData = async (
|
||||
c: Context<HonoCustomType>,
|
||||
addressQueryCondition: string,
|
||||
): Promise<boolean> => {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN ( ` +
|
||||
`SELECT name FROM address WHERE ${addressQueryCondition})`
|
||||
).run();
|
||||
await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (SELECT name FROM address WHERE ${addressQueryCondition})`,
|
||||
[],
|
||||
);
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address IN ( ` +
|
||||
`SELECT name FROM address WHERE ${addressQueryCondition})`
|
||||
@@ -626,9 +634,12 @@ export const deleteAddressWithData = async (
|
||||
// unbind telegram
|
||||
await unbindTelegramByAddress(c, address);
|
||||
// delete address and related data
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? `
|
||||
).bind(address).run();
|
||||
const { success: mailSuccess } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address = ?`,
|
||||
[address],
|
||||
);
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address = ? `
|
||||
).bind(address).run();
|
||||
@@ -704,7 +715,7 @@ export const hideObjectFields = <T extends Record<string, unknown>>(
|
||||
*/
|
||||
export const handleMailListQuery = async (
|
||||
c: Context<HonoCustomType>,
|
||||
query: string, countQuery: string, params: string[],
|
||||
query: string, countQuery: string, params: (string | number)[],
|
||||
limit: string | number | undefined | null,
|
||||
offset: string | number | undefined | null,
|
||||
orderBy?: string
|
||||
@@ -721,10 +732,11 @@ export const handleMailListQuery = async (
|
||||
...params, limit, offset
|
||||
).all();
|
||||
const resolvedResults = await resolveRawEmailList(results);
|
||||
const serializedResults = await serializeMailStates(c.env.DB, resolvedResults, c.env);
|
||||
const count = offset == 0 ? await c.env.DB.prepare(
|
||||
countQuery
|
||||
).bind(...params).first("count") : 0;
|
||||
return c.json({ results: resolvedResults, count });
|
||||
return c.json({ results: serializedResults, count });
|
||||
}
|
||||
|
||||
export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): Promise<{
|
||||
|
||||
@@ -3,7 +3,7 @@ export const CONSTANTS = {
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
DB_VERSION: "v0.0.7",
|
||||
DB_VERSION: "v0.0.8",
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { forwardEmail } from "./forward";
|
||||
import { EmailRuleSettings } from "../models";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { compressText } from "../gzip";
|
||||
import { updateInitialMailFlags } from "../mail_flags";
|
||||
|
||||
|
||||
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
||||
@@ -68,6 +69,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
// save email
|
||||
try {
|
||||
let success = false;
|
||||
let insertResult: D1Result | null = null;
|
||||
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
|
||||
let compressed: ArrayBuffer | null = null;
|
||||
try {
|
||||
@@ -77,42 +79,55 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
}
|
||||
if (compressed) {
|
||||
try {
|
||||
({ success } = await env.DB.prepare(
|
||||
insertResult = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, compressed, message_id
|
||||
).run());
|
||||
).run();
|
||||
({ success } = insertResult);
|
||||
} catch (dbError) {
|
||||
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
|
||||
const errMsg = String(dbError);
|
||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||
({ success } = await env.DB.prepare(
|
||||
insertResult = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
||||
).run());
|
||||
).run();
|
||||
({ success } = insertResult);
|
||||
} else {
|
||||
throw dbError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
({ success } = await env.DB.prepare(
|
||||
insertResult = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
||||
).run());
|
||||
).run();
|
||||
({ success } = insertResult);
|
||||
}
|
||||
} else {
|
||||
({ success } = await env.DB.prepare(
|
||||
insertResult = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
||||
).run());
|
||||
).run();
|
||||
({ success } = insertResult);
|
||||
}
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${toAddress}`);
|
||||
console.error(`Failed save message from ${message.from} to ${toAddress}`);
|
||||
} else {
|
||||
await updateInitialMailFlags(
|
||||
env.DB,
|
||||
getBooleanValue(env.ENABLE_MAIL_FLAGS),
|
||||
insertResult?.meta.last_row_id ?? 0,
|
||||
env,
|
||||
toAddress,
|
||||
parsedEmailContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
335
worker/src/mail_flags.ts
Normal file
335
worker/src/mail_flags.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
export enum MailFlag {
|
||||
UNREAD = 0,
|
||||
ANSWERED = 1,
|
||||
FLAGGED = 2,
|
||||
DELETED = 3,
|
||||
DRAFT = 4,
|
||||
JUNK = 5,
|
||||
}
|
||||
|
||||
export const CUSTOM_MAIL_FLAG_OFFSET = 10;
|
||||
export const CUSTOM_MAIL_FLAG_COUNT = 10;
|
||||
|
||||
export enum MailState {
|
||||
ALL = 'all',
|
||||
UNREAD = 'unread',
|
||||
READ = 'read',
|
||||
}
|
||||
|
||||
export type MailStateOption = {
|
||||
value: string;
|
||||
label_key?: string;
|
||||
label?: string;
|
||||
unread?: boolean;
|
||||
default?: boolean;
|
||||
};
|
||||
|
||||
type MailStateMutation = {
|
||||
add?: number;
|
||||
remove?: number[];
|
||||
};
|
||||
|
||||
export type MailStateDefinition = MailStateOption & {
|
||||
filter?: { flag: number; present: boolean };
|
||||
mutation?: MailStateMutation;
|
||||
};
|
||||
|
||||
const SYSTEM_MAIL_STATES: MailStateDefinition[] = [
|
||||
{ value: MailState.ALL, label_key: 'allMail', default: true },
|
||||
{
|
||||
value: MailState.UNREAD,
|
||||
label_key: 'unread',
|
||||
unread: true,
|
||||
filter: { flag: MailFlag.UNREAD, present: true },
|
||||
mutation: { add: MailFlag.UNREAD },
|
||||
},
|
||||
{
|
||||
value: MailState.READ,
|
||||
label_key: 'read',
|
||||
unread: false,
|
||||
filter: { flag: MailFlag.UNREAD, present: false },
|
||||
mutation: { remove: [MailFlag.UNREAD] },
|
||||
},
|
||||
];
|
||||
|
||||
export const getMailStateOptions = (
|
||||
customStates: MailStateDefinition[] = [],
|
||||
): MailStateOption[] => {
|
||||
return [...SYSTEM_MAIL_STATES, ...customStates].map(state => {
|
||||
const { filter: _filter, mutation: _mutation, ...option } = state;
|
||||
return option;
|
||||
});
|
||||
};
|
||||
|
||||
const getMailStateDefinition = (
|
||||
value: unknown,
|
||||
customStates: MailStateDefinition[] = [],
|
||||
) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return [...SYSTEM_MAIL_STATES, ...customStates].find(state => state.value === value);
|
||||
};
|
||||
|
||||
export const getCustomMailFlag = (slot: number): number => {
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot >= CUSTOM_MAIL_FLAG_COUNT) {
|
||||
throw new Error('Invalid custom mail flag slot');
|
||||
}
|
||||
return CUSTOM_MAIL_FLAG_OFFSET + slot;
|
||||
};
|
||||
|
||||
export type CustomMailStateConfig = {
|
||||
slot: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const CUSTOM_MAIL_FLAGS = Array.from(
|
||||
{ length: CUSTOM_MAIL_FLAG_COUNT },
|
||||
(_, slot) => getCustomMailFlag(slot),
|
||||
);
|
||||
|
||||
export const createCustomMailStateDefinitions = (
|
||||
configs: CustomMailStateConfig[],
|
||||
): MailStateDefinition[] => {
|
||||
return configs.map(config => {
|
||||
const flag = getCustomMailFlag(config.slot);
|
||||
return {
|
||||
value: `custom:${config.slot}`,
|
||||
label: config.name,
|
||||
filter: { flag, present: true },
|
||||
mutation: { add: flag, remove: CUSTOM_MAIL_FLAGS.filter(value => value !== flag) },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const withoutLegacyFlags = <T extends Record<string, unknown>>(row: T): T => {
|
||||
const result = { ...row };
|
||||
delete result.flags;
|
||||
return result;
|
||||
};
|
||||
|
||||
const isMailFlagsEnabled = (env: Bindings): boolean => {
|
||||
return env.ENABLE_MAIL_FLAGS === true || env.ENABLE_MAIL_FLAGS === 'true';
|
||||
};
|
||||
|
||||
export const serializeMailStates = async <T extends Record<string, unknown>>(
|
||||
db: D1Database,
|
||||
rows: T[],
|
||||
env: Bindings,
|
||||
): Promise<T[]> => {
|
||||
const results = rows.map(withoutLegacyFlags);
|
||||
if (!isMailFlagsEnabled(env) || results.length === 0) return results;
|
||||
|
||||
const needsUnread = (row: T) => ![true, false, 0, 1].includes(row.unread as boolean | number);
|
||||
const ids = [...new Set(results.filter(needsUnread).map(row => Number(row.id)))]
|
||||
.filter(id => Number.isInteger(id) && id > 0);
|
||||
if (ids.length === 0) {
|
||||
return results.map(row => ({ ...row, unread: Boolean(row.unread) }));
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const { results: flagRows } = await db.prepare(
|
||||
`SELECT mf.mail_id FROM mail_flags mf`
|
||||
+ ` JOIN raw_mails rm ON rm.id = mf.mail_id`
|
||||
+ ` JOIN address a ON a.id = mf.address_id AND a.name = rm.address`
|
||||
+ ` WHERE mf.flag = ? AND mf.mail_id IN (${placeholders})`
|
||||
).bind(MailFlag.UNREAD, ...ids).all<{ mail_id: number }>();
|
||||
const unreadIds = new Set(flagRows.map(row => Number(row.mail_id)));
|
||||
|
||||
return results.map(row => ({
|
||||
...row,
|
||||
unread: needsUnread(row) ? unreadIds.has(Number(row.id)) : Boolean(row.unread),
|
||||
}));
|
||||
};
|
||||
|
||||
export const serializeMailState = async <T extends Record<string, unknown>>(
|
||||
db: D1Database,
|
||||
row: T,
|
||||
env: Bindings,
|
||||
): Promise<T> => {
|
||||
const [result] = await serializeMailStates(db, [row], env);
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveInitialMailFlags = async (
|
||||
_env: Bindings,
|
||||
_address: string,
|
||||
_parsedEmailContext: ParsedEmailContext,
|
||||
): Promise<number[]> => {
|
||||
return [MailFlag.UNREAD];
|
||||
};
|
||||
|
||||
export const updateInitialMailFlags = async (
|
||||
db: D1Database,
|
||||
enabled: boolean,
|
||||
mailId: number,
|
||||
env: Bindings,
|
||||
address: string,
|
||||
parsedEmailContext: ParsedEmailContext,
|
||||
) => {
|
||||
if (!enabled || !Number.isInteger(mailId) || mailId <= 0) return;
|
||||
|
||||
const flags = await resolveInitialMailFlags(env, address, parsedEmailContext);
|
||||
if (flags.length === 0) return;
|
||||
|
||||
const selects = flags.map(() => 'SELECT ?, id, ? FROM address WHERE name = ?').join(' UNION ALL ');
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO mail_flags (mail_id, address_id, flag) ${selects}`
|
||||
).bind(...flags.flatMap(flag => [mailId, flag, address])).run();
|
||||
};
|
||||
|
||||
export type MailStateQuery = {
|
||||
join: string;
|
||||
clause?: string;
|
||||
orderBy?: string;
|
||||
unread?: boolean;
|
||||
params: number[];
|
||||
};
|
||||
|
||||
export const getMailStateQuery = (
|
||||
value: string | undefined,
|
||||
mailAlias: string,
|
||||
addressIdColumn: string,
|
||||
customStates: MailStateDefinition[] = [],
|
||||
): MailStateQuery | undefined | null => {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
const definition = getMailStateDefinition(value, customStates);
|
||||
if (!definition) return null;
|
||||
if (!definition.filter) return undefined;
|
||||
|
||||
const { flag, present } = definition.filter;
|
||||
const joinType = present ? 'JOIN' : 'LEFT JOIN';
|
||||
return {
|
||||
join: ` ${joinType} mail_flags mail_state_flags`
|
||||
+ ` ON mail_state_flags.mail_id = ${mailAlias}.id`
|
||||
+ ` AND mail_state_flags.address_id = ${addressIdColumn}`
|
||||
+ ` AND mail_state_flags.flag = ?`,
|
||||
clause: present ? undefined : 'mail_state_flags.mail_id IS NULL',
|
||||
orderBy: present ? 'mail_state_flags.mail_id desc' : undefined,
|
||||
unread: definition.unread,
|
||||
params: [flag],
|
||||
};
|
||||
};
|
||||
|
||||
type MailStateUpdate = {
|
||||
ids: number[];
|
||||
mutation: MailStateMutation;
|
||||
unread?: boolean;
|
||||
};
|
||||
|
||||
const parseMailStateUpdate = (
|
||||
value: unknown,
|
||||
customStates: MailStateDefinition[] = [],
|
||||
): MailStateUpdate | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const body = value as Record<string, unknown>;
|
||||
if (!Array.isArray(body.ids) || body.ids.length === 0 || body.ids.length > 100) return null;
|
||||
if (body.ids.some(id => typeof id !== 'number')) return null;
|
||||
|
||||
const ids = [...new Set(body.ids.map(Number))];
|
||||
if (ids.some(id => !Number.isInteger(id) || id <= 0)) return null;
|
||||
|
||||
const definition = getMailStateDefinition(body.state, customStates);
|
||||
if (!definition?.mutation) return null;
|
||||
return { ids, mutation: definition.mutation, unread: definition.unread };
|
||||
};
|
||||
|
||||
type MailScope = {
|
||||
clause: string;
|
||||
params: (string | number)[];
|
||||
};
|
||||
|
||||
export const applyMailStateUpdate = async (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
scope: MailScope,
|
||||
value: unknown,
|
||||
customStates: MailStateDefinition[] = [],
|
||||
) => {
|
||||
const update = parseMailStateUpdate(value, customStates);
|
||||
if (!update) return null;
|
||||
|
||||
const idPlaceholders = update.ids.map(() => '?').join(',');
|
||||
const targetWhere = `rm.id IN (${idPlaceholders}) AND (${scope.clause})`;
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
|
||||
if (update.mutation.remove?.length) {
|
||||
const flagPlaceholders = update.mutation.remove.map(() => '?').join(',');
|
||||
statements.push(db.prepare(
|
||||
`DELETE FROM mail_flags WHERE flag IN (${flagPlaceholders})`
|
||||
+ ` AND mail_id IN (`
|
||||
+ `SELECT rm.id FROM raw_mails rm JOIN address a ON a.name = rm.address`
|
||||
+ ` WHERE ${targetWhere})`
|
||||
).bind(...update.mutation.remove, ...update.ids, ...scope.params));
|
||||
}
|
||||
|
||||
if (update.mutation.add !== undefined) {
|
||||
statements.push(db.prepare(
|
||||
`INSERT OR IGNORE INTO mail_flags (mail_id, address_id, flag)`
|
||||
+ ` SELECT rm.id, a.id, ?`
|
||||
+ ` FROM raw_mails rm JOIN address a ON a.name = rm.address`
|
||||
+ ` WHERE ${targetWhere}`
|
||||
).bind(update.mutation.add, ...update.ids, ...scope.params));
|
||||
}
|
||||
|
||||
const mutationResults = await db.batch(statements);
|
||||
if (mutationResults.some(result => !result.success)) {
|
||||
return { success: false, changes: 0, results: [] };
|
||||
}
|
||||
|
||||
const unreadSelect = update.unread === undefined ? '' : `, ${update.unread ? 1 : 0} AS unread`;
|
||||
const { results } = await db.prepare(
|
||||
`SELECT rm.id${unreadSelect} FROM raw_mails rm JOIN address a ON a.name = rm.address`
|
||||
+ ` WHERE ${targetWhere}`
|
||||
).bind(...update.ids, ...scope.params).all();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: mutationResults.reduce((total, result) => total + (result.meta.changes ?? 0), 0),
|
||||
results: await serializeMailStates(db, results, env),
|
||||
};
|
||||
};
|
||||
|
||||
export const prepareRawMailDeleteStatements = (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
whereClause: string,
|
||||
params: (string | number)[],
|
||||
): D1PreparedStatement[] => {
|
||||
const deleteMail = db.prepare(
|
||||
`DELETE FROM raw_mails WHERE ${whereClause}`
|
||||
).bind(...params);
|
||||
if (!isMailFlagsEnabled(env)) return [deleteMail];
|
||||
|
||||
const deleteFlags = db.prepare(
|
||||
`DELETE FROM mail_flags WHERE mail_id IN (`
|
||||
+ `SELECT id FROM raw_mails WHERE ${whereClause})`
|
||||
).bind(...params);
|
||||
return [deleteFlags, deleteMail];
|
||||
};
|
||||
|
||||
export const deleteRawMails = async (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
whereClause: string,
|
||||
params: (string | number)[],
|
||||
): Promise<D1Result> => {
|
||||
const results = await db.batch(prepareRawMailDeleteStatements(db, env, whereClause, params));
|
||||
return results[results.length - 1];
|
||||
};
|
||||
|
||||
export const cleanupOrphanMailFlags = async (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
limit = 1000,
|
||||
): Promise<number> => {
|
||||
if (!isMailFlagsEnabled(env)) return 0;
|
||||
|
||||
const result = await db.prepare(
|
||||
`DELETE FROM mail_flags WHERE (mail_id, flag) IN (`
|
||||
+ `SELECT mf.mail_id, mf.flag FROM mail_flags mf`
|
||||
+ ` LEFT JOIN raw_mails rm ON rm.id = mf.mail_id`
|
||||
+ ` WHERE rm.id IS NULL LIMIT ?)`
|
||||
).bind(limit).run();
|
||||
return result.meta.changes ?? 0;
|
||||
};
|
||||
@@ -27,7 +27,9 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
|
||||
|
||||
// mail crud
|
||||
api.get('/api/mails', mails_crud.listMails)
|
||||
api.get('/api/mail-states', mails_crud.getMailStates)
|
||||
api.get('/api/mail/:mail_id', mails_crud.getMail)
|
||||
api.patch('/api/mails/state', mails_crud.updateMailState)
|
||||
api.delete('/api/mails/:id', mails_crud.deleteMail)
|
||||
|
||||
// parsed mail (server-side parsed subject/text/html/attachments)
|
||||
|
||||
@@ -5,18 +5,48 @@ import { getBooleanValue } from '../utils';
|
||||
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
import { getSendBalanceState } from './send_balance';
|
||||
import {
|
||||
getMailStateQuery,
|
||||
getMailStateOptions,
|
||||
applyMailStateUpdate,
|
||||
serializeMailState,
|
||||
deleteRawMails,
|
||||
} 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 } = c.req.query();
|
||||
const { limit, offset, mail_state } = c.req.query();
|
||||
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
||||
const stateQuery = getMailStateQuery(mail_state, 'rm', 'a.id');
|
||||
if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400);
|
||||
if (stateQuery && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||
return c.json({ error: "Mail states are disabled" }, 403);
|
||||
}
|
||||
|
||||
if (!stateQuery) {
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails WHERE address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails WHERE address = ?`,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
|
||||
const filters = [`rm.address = ?`];
|
||||
if (stateQuery.clause) filters.push(stateQuery.clause);
|
||||
const fromQuery = ` FROM raw_mails rm`
|
||||
+ ` JOIN address a ON a.name = rm.address`
|
||||
+ stateQuery.join
|
||||
+ ` WHERE ${filters.join(' AND ')}`;
|
||||
const unreadSelect = stateQuery.unread === undefined
|
||||
? ''
|
||||
: `, ${stateQuery.unread ? 1 : 0} AS unread`;
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails where address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ?`,
|
||||
[address], limit, offset
|
||||
`SELECT rm.*${unreadSelect}${fromQuery}`,
|
||||
`SELECT count(*) as count${fromQuery}`,
|
||||
[...stateQuery.params, address], limit, offset, stateQuery.orderBy ?? 'rm.id desc'
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +57,11 @@ const getMail = async (c: Context<HonoCustomType>) => {
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
if (!result) return c.json(null);
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
return c.json(await serializeMailState(
|
||||
c.env.DB,
|
||||
await resolveRawEmailRow(result),
|
||||
c.env,
|
||||
));
|
||||
};
|
||||
|
||||
const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||
@@ -38,12 +72,38 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { id } = c.req.param();
|
||||
// TODO: add toLowerCase() to handle old data
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? and id = ? `
|
||||
).bind(address.toLowerCase(), id).run();
|
||||
const { success } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address = ? and id = ?`,
|
||||
[address.toLowerCase(), id],
|
||||
);
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
const updateMailState = async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||
return c.json({ error: "Mail states are disabled" }, 403);
|
||||
}
|
||||
const { address } = c.get("jwtPayload");
|
||||
const result = await applyMailStateUpdate(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
{ clause: 'rm.address = ?', params: [address] },
|
||||
await c.req.json().catch(() => null),
|
||||
);
|
||||
if (!result) return c.json({ error: "Invalid mail state request" }, 400);
|
||||
if (!result.success) return c.json(result, 500);
|
||||
return c.json(result);
|
||||
};
|
||||
|
||||
const getMailStates = (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||
return c.json({ error: "Mail states are disabled" }, 403);
|
||||
}
|
||||
return c.json({ results: getMailStateOptions() });
|
||||
};
|
||||
|
||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
@@ -93,9 +153,12 @@ const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ?`
|
||||
).bind(address).run();
|
||||
const { success } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address = ?`,
|
||||
[address],
|
||||
);
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedClearInboxMsg, 500)
|
||||
}
|
||||
@@ -117,4 +180,7 @@ const clearSentItems = async (c: Context<HonoCustomType>) => {
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
|
||||
export default {
|
||||
listMails, getMail, deleteMail, updateMailState, getMailStates,
|
||||
getSettings, deleteAddress, clearInbox, clearSentItems
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Context } from 'hono'
|
||||
|
||||
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
import { serializeMailState } from '../mail_flags';
|
||||
|
||||
const toParsedMailRow = async (row: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||
const raw = typeof row.raw === 'string' ? row.raw : '';
|
||||
@@ -45,8 +46,12 @@ const getParsedMail = async (c: Context<HonoCustomType>) => {
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
if (!row) return c.json(null);
|
||||
const resolved = await resolveRawEmailRow(row);
|
||||
return c.json(await toParsedMailRow(resolved as Record<string, unknown>));
|
||||
const resolved = await serializeMailState(
|
||||
c.env.DB,
|
||||
await resolveRawEmailRow(row),
|
||||
c.env,
|
||||
);
|
||||
return c.json(await toParsedMailRow(resolved));
|
||||
};
|
||||
|
||||
export default { listParsedMails, getParsedMail };
|
||||
|
||||
@@ -213,6 +213,7 @@ export type RawMailRow = {
|
||||
raw?: string;
|
||||
raw_blob?: unknown;
|
||||
metadata?: string;
|
||||
unread?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
||||
import { resolveRawEmailRow } from "../gzip";
|
||||
import { TelegramSettings } from "./settings";
|
||||
import i18n from "../i18n";
|
||||
import { serializeMailState } from "../mail_flags";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const TG_AUTH_TIMEOUT = 300;
|
||||
@@ -145,7 +146,7 @@ async function getMail(c: Context<HonoCustomType>): Promise<Response> {
|
||||
if (!result) {
|
||||
return c.text("Mail not found", 404);
|
||||
}
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
return c.json(await serializeMailState(c.env.DB, await resolveRawEmailRow(result), c.env));
|
||||
}
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
@@ -168,7 +169,7 @@ async function getMail(c: Context<HonoCustomType>): Promise<Response> {
|
||||
return c.text(msgs.TgNoPermissionViewMailMsg, 403);
|
||||
}
|
||||
}
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
return c.json(await serializeMailState(c.env.DB, await resolveRawEmailRow(result), c.env));
|
||||
}
|
||||
catch (e) {
|
||||
return c.text((e as Error).message, 400);
|
||||
|
||||
1
worker/src/types.d.ts
vendored
1
worker/src/types.d.ts
vendored
@@ -117,6 +117,7 @@ type Bindings = {
|
||||
|
||||
// gzip compression for raw_mails
|
||||
ENABLE_MAIL_GZIP: string | boolean | undefined
|
||||
ENABLE_MAIL_FLAGS: string | boolean | undefined
|
||||
CLEANUP_BATCH_SIZE: string | number | undefined
|
||||
|
||||
// E2E testing
|
||||
|
||||
@@ -16,6 +16,8 @@ api.get('/user_api/settings', settings.settings);
|
||||
|
||||
// mail api
|
||||
api.get('/user_api/mails', user_mail_api.getMails);
|
||||
api.get('/user_api/mail-states', user_mail_api.getMailStates);
|
||||
api.patch('/user_api/mails/state', user_mail_api.updateMailState);
|
||||
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
|
||||
|
||||
// send mail api
|
||||
|
||||
@@ -2,25 +2,47 @@ import { Context } from "hono";
|
||||
import i18n from "../i18n";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import {
|
||||
getMailStateQuery,
|
||||
getMailStateOptions,
|
||||
applyMailStateUpdate,
|
||||
deleteRawMails,
|
||||
} from "../mail_flags";
|
||||
|
||||
export default {
|
||||
getMailStates: (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||
return c.json({ error: "Mail states are disabled" }, 403);
|
||||
}
|
||||
return c.json({ results: getMailStateOptions() });
|
||||
},
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address, limit, offset } = c.req.query();
|
||||
const { address, limit, offset, mail_state } = c.req.query();
|
||||
const filterQuerys = [`ua.user_id = ?`];
|
||||
const filterParams = [String(user_id)];
|
||||
if (address) {
|
||||
filterQuerys.push(`rm.address = ?`);
|
||||
filterParams.push(address);
|
||||
}
|
||||
const stateQuery = getMailStateQuery(mail_state, 'rm', 'a.id');
|
||||
if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400);
|
||||
if (stateQuery && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||
return c.json({ error: "Mail states are disabled" }, 403);
|
||||
}
|
||||
if (stateQuery?.clause) filterQuerys.push(stateQuery.clause);
|
||||
const fromQuery = ` FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` JOIN raw_mails rm ON rm.address = a.name`
|
||||
+ (stateQuery?.join ?? '')
|
||||
+ ` WHERE ${filterQuerys.join(" AND ")}`;
|
||||
const unreadSelect = stateQuery?.unread === undefined
|
||||
? ''
|
||||
: `, ${stateQuery.unread ? 1 : 0} AS unread`;
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT rm.*${fromQuery}`,
|
||||
`SELECT rm.*${unreadSelect}${fromQuery}`,
|
||||
`SELECT count(*) as count${fromQuery}`,
|
||||
filterParams, limit, offset, 'rm.id desc'
|
||||
[...(stateQuery?.params ?? []), ...filterParams], limit, offset, 'rm.id desc'
|
||||
);
|
||||
},
|
||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||
@@ -30,16 +52,39 @@ export default {
|
||||
}
|
||||
const { id } = c.req.param();
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE id = ?`
|
||||
const { success } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`id = ?`
|
||||
+ ` 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(id, user_id).run();
|
||||
+ `)`,
|
||||
[id, user_id],
|
||||
);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
},
|
||||
updateMailState: async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||
return c.json({ error: "Mail states are disabled" }, 403);
|
||||
}
|
||||
const { user_id } = c.get("userPayload");
|
||||
const result = await applyMailStateUpdate(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
{
|
||||
clause: `a.id IN (`
|
||||
+ `SELECT address_id FROM users_address WHERE user_id = ?`
|
||||
+ `)`,
|
||||
params: [user_id],
|
||||
},
|
||||
await c.req.json().catch(() => null),
|
||||
);
|
||||
if (!result) return c.json({ error: "Invalid mail state request" }, 400);
|
||||
if (!result.success) return c.json(result, 500);
|
||||
return c.json(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createMimeMessage } from "mimetext";
|
||||
import { UserSettings, RoleAddressConfig } from "./models";
|
||||
import { CONSTANTS } from "./constants";
|
||||
import { compressText } from "./gzip";
|
||||
import { updateInitialMailFlags } from "./mail_flags";
|
||||
|
||||
export const getJsonObjectValue = <T = any>(
|
||||
value: string | any
|
||||
@@ -371,7 +372,9 @@ export const sendAdminInternalMail = async (
|
||||
});
|
||||
const message_id = Math.random().toString(36).substring(2, 15);
|
||||
const rawText = msg.asRaw();
|
||||
const parsedEmailContext: ParsedEmailContext = { rawEmail: rawText };
|
||||
let success = false;
|
||||
let insertResult: D1Result | null = null;
|
||||
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
|
||||
let compressed: ArrayBuffer | null = null;
|
||||
try {
|
||||
@@ -381,32 +384,45 @@ export const sendAdminInternalMail = async (
|
||||
}
|
||||
if (compressed) {
|
||||
try {
|
||||
({ success } = await c.env.DB.prepare(
|
||||
insertResult = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, compressed, message_id).run());
|
||||
).bind("admin@internal", toMail, compressed, message_id).run();
|
||||
({ success } = insertResult);
|
||||
} catch (dbError) {
|
||||
const errMsg = String(dbError);
|
||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||
({ success } = await c.env.DB.prepare(
|
||||
insertResult = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
||||
).bind("admin@internal", toMail, rawText, message_id).run();
|
||||
({ success } = insertResult);
|
||||
} else {
|
||||
throw dbError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
({ success } = await c.env.DB.prepare(
|
||||
insertResult = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
||||
).bind("admin@internal", toMail, rawText, message_id).run();
|
||||
({ success } = insertResult);
|
||||
}
|
||||
} else {
|
||||
({ success } = await c.env.DB.prepare(
|
||||
insertResult = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
||||
).bind("admin@internal", toMail, rawText, message_id).run();
|
||||
({ success } = insertResult);
|
||||
}
|
||||
if (!success) {
|
||||
console.log(`Failed save message from admin@internal to ${toMail}`);
|
||||
} else {
|
||||
await updateInitialMailFlags(
|
||||
c.env.DB,
|
||||
getBooleanValue(c.env.ENABLE_MAIL_FLAGS),
|
||||
insertResult?.meta.last_row_id ?? 0,
|
||||
c.env,
|
||||
toMail,
|
||||
parsedEmailContext,
|
||||
);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
|
||||
@@ -77,6 +77,8 @@ ENABLE_USER_CREATE_EMAIL = true
|
||||
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
|
||||
# Allow users to delete messages
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# Enable per-message mail flags such as unread state. Run the database migration before enabling.
|
||||
# ENABLE_MAIL_FLAGS = true
|
||||
# Allow automatic replies to emails
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# Allow webhook
|
||||
|
||||
Reference in New Issue
Block a user