feat: add extensible mail flags

This commit is contained in:
dreamhunter2333
2026-08-28 02:44:30 +08:00
parent f92b059aac
commit 13b6ab051c
39 changed files with 1682 additions and 92 deletions
+1
View File
@@ -10,6 +10,7 @@
### Features
- feat: |邮件状态| 新增基于独立稀疏关联表的可选邮件状态功能,不修改原邮件表且历史邮件默认已读、未星标;已读状态与低写入量的星标功能使用独立开关,支持索引化组合筛选
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
+1
View File
@@ -10,6 +10,7 @@
### Features
- feat: |Mail State| Add optional mail states backed by a separate sparse relation table without changing the raw-mail table; read status and low-write Flagged mail use independent switches and support indexed combined filters
- 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: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
- 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
+8
View File
@@ -0,0 +1,8 @@
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);
+9
View File
@@ -15,6 +15,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,
+2
View File
@@ -18,6 +18,8 @@ JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
ENABLE_MAIL_READ_STATUS = true
ENABLE_MAIL_FLAGGED = true
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
NO_LIMIT_SEND_ROLE = "case-role"
+1
View File
@@ -20,6 +20,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
ENABLE_MAIL_GZIP = true
ENABLE_MAIL_READ_STATUS = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -24,6 +24,7 @@ DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
ENABLE_MAIL_FLAGGED = true
SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
"""
+361
View File
@@ -0,0 +1,361 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_ENV_OFF,
WORKER_GZIP_URL,
WORKER_URL_SEND_MAIL_DOMAIN,
createTestAddress,
deleteAddress,
hashPassword,
seedTestMail,
} from '../../fixtures/test-helpers';
const addressHeaders = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
async function createAddressAt(
request: APIRequestContext,
baseUrl: string,
name: string,
domain: string,
) {
const response = await request.post(`${baseUrl}/api/new_address`, {
data: { name: `${name}${Date.now()}`, domain },
});
expect(response.ok()).toBe(true);
return await response.json();
}
async function receiveMailAt(
request: APIRequestContext,
baseUrl: string,
address: string,
) {
const raw = [
'From: sender@example.com',
`To: ${address}`,
`Subject: Split flags ${Date.now()}`,
'',
'Split flags body',
].join('\r\n');
const response = await request.post(`${baseUrl}/admin/test/receive_mail`, {
data: { from: 'sender@example.com', to: address, raw },
});
expect(response.ok()).toBe(true);
}
async function listMails(
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 updateState(
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 updateFlagged(
request: APIRequestContext,
jwt: string,
ids: number[],
flagged: boolean,
) {
const response = await request.patch(`${WORKER_URL}/api/mails/flagged`, {
headers: addressHeaders(jwt),
data: { ids, flagged },
});
expect(response.ok()).toBe(true);
return await response.json();
}
test.describe('Mail states', () => {
test('read status and Flagged switches are independent', async ({ request }) => {
test.skip(
!WORKER_GZIP_URL || !WORKER_URL_SEND_MAIL_DOMAIN,
'Mixed feature workers are not configured',
);
const readOnly = await createAddressAt(
request, WORKER_GZIP_URL, 'mail-read-only', 'test.example.com',
);
const flaggedOnly = await createAddressAt(
request, WORKER_URL_SEND_MAIL_DOMAIN, 'mail-flagged-only', 'TEST.EXAMPLE.COM',
);
try {
await receiveMailAt(request, WORKER_GZIP_URL, readOnly.address);
await receiveMailAt(request, WORKER_URL_SEND_MAIL_DOMAIN, flaggedOnly.address);
const readSettings = await (await request.get(
`${WORKER_GZIP_URL}/open_api/settings`,
)).json();
expect(readSettings.enableMailReadStatus).toBe(true);
expect(readSettings).not.toHaveProperty('enableMailFlagged');
const flaggedSettings = await (await request.get(
`${WORKER_URL_SEND_MAIL_DOMAIN}/open_api/settings`,
)).json();
expect(flaggedSettings).not.toHaveProperty('enableMailReadStatus');
expect(flaggedSettings.enableMailFlagged).toBe(true);
const readList = await request.get(
`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`,
{ headers: addressHeaders(readOnly.jwt) },
);
const readMail = (await readList.json()).results[0];
expect(readMail.unread).toBe(true);
expect(readMail).not.toHaveProperty('flagged');
expect((await request.get(`${WORKER_GZIP_URL}/api/mail-states`, {
headers: addressHeaders(readOnly.jwt),
})).ok()).toBe(true);
expect((await request.patch(`${WORKER_GZIP_URL}/api/mails/flagged`, {
headers: addressHeaders(readOnly.jwt),
data: { ids: [readMail.id], flagged: true },
})).status()).toBe(403);
const flaggedList = await request.get(
`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails?limit=10&offset=0`,
{ headers: addressHeaders(flaggedOnly.jwt) },
);
const flaggedMail = (await flaggedList.json()).results[0];
expect(flaggedMail).not.toHaveProperty('unread');
expect(flaggedMail.flagged).toBe(false);
expect((await request.get(`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mail-states`, {
headers: addressHeaders(flaggedOnly.jwt),
})).status()).toBe(403);
const addedStar = await request.patch(
`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails/flagged`,
{
headers: addressHeaders(flaggedOnly.jwt),
data: { ids: [flaggedMail.id], flagged: true },
},
);
expect((await addedStar.json()).results).toEqual([{ id: flaggedMail.id, flagged: true }]);
} finally {
await Promise.allSettled([
request.delete(`${WORKER_GZIP_URL}/admin/delete_address/${readOnly.address_id}`),
request.delete(
`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/delete_address/${flaggedOnly.address_id}`,
),
]);
}
});
test('supports unread lifecycle, historical mail, filtering and mailbox isolation', async ({ request }) => {
const first = await createTestAddress(request, 'mail-state-first');
const second = await createTestAddress(request, 'mail-state-second');
try {
const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: { address: first.address, raw: 'Historical mail' },
});
expect(historical.ok()).toBe(true);
await seedTestMail(request, first.address, { subject: 'Unread one' });
await seedTestMail(request, first.address, { subject: 'Unread two' });
await seedTestMail(request, second.address, { subject: 'Other mailbox' });
const states = await request.get(`${WORKER_URL}/api/mail-states`, {
headers: addressHeaders(first.jwt),
});
expect(states.ok()).toBe(true);
expect((await states.json()).results.map((state: { value: string }) => state.value))
.toEqual(['all', 'unread', 'read']);
const initial = await listMails(request, first.jwt);
expect(initial.count).toBe(3);
expect(initial.results.filter((mail: { unread: boolean }) => mail.unread)).toHaveLength(2);
expect(initial.results.filter((mail: { unread: boolean }) => !mail.unread)).toHaveLength(1);
expect(initial.results.every((mail: { flagged: boolean }) => !mail.flagged)).toBe(true);
const unreadIds = initial.results
.filter((mail: { unread: boolean }) => mail.unread)
.map((mail: { id: number }) => mail.id);
const denied = await updateState(request, second.jwt, [unreadIds[0]], 'read');
expect(denied.changes).toBe(0);
const deniedStar = await updateFlagged(request, second.jwt, [unreadIds[0]], true);
expect(deniedStar.changes).toBe(0);
const addedStar = await updateFlagged(request, first.jwt, [unreadIds[0]], true);
expect(addedStar.results).toEqual([{ id: unreadIds[0], flagged: true }]);
const markedRead = await updateState(request, first.jwt, unreadIds, 'read');
expect(markedRead.changes).toBe(2);
expect(markedRead.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true);
expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(0);
expect((await listMails(request, first.jwt, 'read')).results).toHaveLength(3);
const flagged = await request.get(
`${WORKER_URL}/api/mails?limit=100&offset=0&mail_state=read&flagged=true`,
{ headers: addressHeaders(first.jwt) },
);
const flaggedMails = (await flagged.json()).results;
expect(flaggedMails).toHaveLength(1);
expect(flaggedMails[0]).toMatchObject({ id: unreadIds[0], unread: false, flagged: true });
const markedUnread = await updateState(request, first.jwt, [unreadIds[0]], 'unread');
expect(markedUnread.results).toEqual([{ id: unreadIds[0], unread: true }]);
expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(1);
const detail = await request.get(`${WORKER_URL}/api/mail/${unreadIds[0]}`, {
headers: addressHeaders(first.jwt),
});
expect(await detail.json()).toMatchObject({ unread: true, flagged: true });
await updateFlagged(request, first.jwt, [unreadIds[0]], false);
const noFlagged = await request.get(
`${WORKER_URL}/api/mails?limit=100&offset=0&flagged=true`,
{ headers: addressHeaders(first.jwt) },
);
expect((await noFlagged.json()).results).toHaveLength(0);
const invalid = await request.patch(`${WORKER_URL}/api/mails/state`, {
headers: addressHeaders(first.jwt),
data: { ids: [unreadIds[0]], state: 'unknown' },
});
expect(invalid.status()).toBe(400);
} finally {
await deleteAddress(request, first.jwt);
await deleteAddress(request, second.jwt);
}
});
test('user APIs only expose and update bound-address mail', async ({ request }) => {
let originalSettings: Record<string, unknown> | undefined;
let userId: number | undefined;
const mailboxes: Awaited<ReturnType<typeof createTestAddress>>[] = [];
try {
const settings = await request.get(`${WORKER_URL}/admin/user_settings`);
originalSettings = await settings.json();
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: { ...originalSettings, enable: true, enableMailVerify: false, maxAddressCount: 0 },
});
const email = `mail-state-user-${Date.now()}@test.example.com`;
const password = hashPassword('mail-state-password');
expect((await request.post(`${WORKER_URL}/user_api/register`, {
data: { email, password },
})).ok()).toBe(true);
const login = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
const { jwt: userJwt } = await login.json();
userId = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString()).user_id;
const bound = await createTestAddress(request, 'mail-state-bound');
const outsider = await createTestAddress(request, 'mail-state-outsider');
mailboxes.push(bound, outsider);
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: { ...addressHeaders(bound.jwt), 'x-user-token': userJwt },
});
expect(bind.ok()).toBe(true);
await seedTestMail(request, bound.address, { subject: 'Bound unread' });
await seedTestMail(request, outsider.address, { subject: 'Outsider unread' });
const userList = await request.get(
`${WORKER_URL}/user_api/mails?limit=20&offset=0&mail_state=unread`,
{ headers: { 'x-user-token': userJwt } },
);
const userMails = await userList.json();
expect(userMails.results).toHaveLength(1);
expect(userMails.results[0].address).toBe(bound.address);
const outsiderMail = (await listMails(request, outsider.jwt)).results[0];
const denied = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
headers: { 'x-user-token': userJwt },
data: { ids: [outsiderMail.id], state: 'read' },
});
expect((await denied.json()).changes).toBe(0);
const update = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
headers: { 'x-user-token': userJwt },
data: { ids: [userMails.results[0].id], state: 'read' },
});
expect((await update.json()).results[0].unread).toBe(false);
const addStar = await request.patch(`${WORKER_URL}/user_api/mails/flagged`, {
headers: { 'x-user-token': userJwt },
data: { ids: [userMails.results[0].id], flagged: true },
});
expect((await addStar.json()).results[0].flagged).toBe(true);
const flagged = await request.get(
`${WORKER_URL}/user_api/mails?limit=20&offset=0&flagged=true`,
{ headers: { 'x-user-token': userJwt } },
);
expect((await flagged.json()).results).toHaveLength(1);
} finally {
await Promise.allSettled(mailboxes.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 existing responses unchanged', async ({ request }) => {
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
const created = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, {
data: { name: `mail-state-off-${Date.now()}`, domain: 'test.example.com' },
});
const mailbox = await created.json();
try {
const raw = [
'From: sender@example.com',
`To: ${mailbox.address}`,
'Subject: States disabled',
'',
'Disabled body',
].join('\r\n');
await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
data: { from: 'sender@example.com', to: mailbox.address, raw },
});
const list = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
headers: addressHeaders(mailbox.jwt),
});
const body = await list.json();
expect(body.results).toHaveLength(1);
expect(body.results[0]).not.toHaveProperty('unread');
expect(body.results[0]).not.toHaveProperty('flagged');
const states = await request.get(`${WORKER_URL_ENV_OFF}/api/mail-states`, {
headers: addressHeaders(mailbox.jwt),
});
expect(states.status()).toBe(403);
const update = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/state`, {
headers: addressHeaders(mailbox.jwt),
data: { ids: [body.results[0].id], state: 'read' },
});
expect(update.status()).toBe(403);
const disabledFlagUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/flagged`, {
headers: addressHeaders(mailbox.jwt),
data: { ids: [body.results[0].id], flagged: true },
});
expect(disabledFlagUpdate.status()).toBe(403);
} finally {
await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${mailbox.address_id}`);
}
});
});
+160
View File
@@ -0,0 +1,160 @@
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('does not mark the initial desktop preview as read before a click', async ({ page }) => {
const request = await apiRequest.newContext();
let jwt: string | undefined;
try {
const mailbox = await createTestAddress(request, 'mail-preview-unread');
jwt = mailbox.jwt;
const subject = `Preview unread ${Date.now()}`;
await seedTestMail(request, mailbox.address, { subject });
await page.goto(`${FRONTEND_URL}/en/`);
await page.evaluate(() => localStorage.setItem('mailListView', 'false'));
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
await expect(page.getByText(subject, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
await page.waitForLoadState('networkidle');
const beforeClick = await request.get(
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
{ headers: { Authorization: `Bearer ${jwt}` } },
);
expect((await beforeClick.json()).results).toHaveLength(1);
const readResponse = page.waitForResponse((response) => {
return new URL(response.url()).pathname === '/api/mails/state'
&& response.request().method() === 'PATCH';
});
await page.getByText(subject, { exact: true }).first().click();
expect((await readResponse).ok()).toBe(true);
const afterClick = await request.get(
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
{ headers: { Authorization: `Bearer ${jwt}` } },
);
expect((await afterClick.json()).results).toHaveLength(0);
} finally {
try {
if (jwt) await deleteAddress(request, jwt);
} finally {
await request.dispose();
}
}
});
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 flaggedResponse = page.waitForResponse((response) => {
return new URL(response.url()).pathname === '/api/mails/flagged'
&& response.request().method() === 'PATCH';
});
await page.locator('.mail-content-renderer').getByRole('button', { name: 'Add Star' }).click();
expect((await flaggedResponse).ok()).toBe(true);
await expect(
page.locator('.mail-content-renderer').getByRole('button', { name: 'Remove Star' }),
).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 flaggedFilterResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return url.pathname === '/api/mails' && url.searchParams.get('flagged') === 'true';
});
await page.getByRole('checkbox', { name: 'Flagged' }).check();
expect((await flaggedFilterResponse).ok()).toBe(true);
await expect(page.getByText(subjects[0], { exact: true })).toBeVisible();
await expect(page.getByText(subjects[1], { exact: true })).toHaveCount(0);
const allMailResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return url.pathname === '/api/mails' && !url.searchParams.has('flagged');
});
await page.getByRole('checkbox', { name: 'Flagged' }).uncheck();
expect((await allMailResponse).ok()).toBe(true);
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();
}
}
});
});
+2
View File
@@ -99,6 +99,8 @@ const getOpenSettings = async (message, notification) => {
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableMailReadStatus: res["enableMailReadStatus"] === true,
enableMailFlagged: res["enableMailFlagged"] === true,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
+203 -17
View File
@@ -3,7 +3,10 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
import {
CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound,
StarBorderRound, StarRound
} from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
@@ -55,9 +58,37 @@ const props = defineProps({
default: false,
required: false
},
enableMailReadStatus: {
type: Boolean,
default: false,
required: false
},
enableMailFlagged: {
type: Boolean,
default: false,
required: false
},
updateMailState: {
type: Function,
default: () => { },
required: false
},
updateMailFlagged: {
type: Function,
default: () => { },
required: false
},
fetchMailStates: {
type: Function,
default: () => ({ results: [] }),
required: false
},
})
const localFilterKeyword = ref('')
const mailStateFilter = ref(null)
const flaggedOnly = ref(false)
const mailStates = ref([])
const {
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
@@ -94,6 +125,73 @@ const data = computed(() => {
});
})
const isMailUnread = (mail) => {
return props.enableMailReadStatus && 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 toggleMailFlagged = async (mail) => {
if (!mail) return
try {
const response = await props.updateMailFlagged([mail.id], !mail.flagged)
const result = response?.results?.[0]
if (result) mail.flagged = result.flagged
if (flaggedOnly.value && !mail.flagged) await backFirstPageAndRefresh()
} catch (error) {
message.error(error.message || "error")
}
}
const toggleCurrentMailFlagged = async () => {
await toggleMailFlagged(curMail.value)
}
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 +209,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 +224,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])
}
}
}
@@ -165,6 +263,22 @@ const setupAutoRefresh = async (autoRefresh) => {
}
}
const loadMailStates = async () => {
if (!props.enableMailReadStatus) {
mailStates.value = []
mailStateFilter.value = null
return
}
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")
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
}, { immediate: true })
@@ -175,19 +289,32 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
}
})
watch(mailStateFilter, async (_value, oldValue) => {
if (oldValue === null) return
await backFirstPageAndRefresh()
})
watch(flaggedOnly, async () => {
await backFirstPageAndRefresh()
})
watch(() => props.enableMailReadStatus, async (enabled, oldValue) => {
if (enabled === oldValue) return
await loadMailStates()
})
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,
flaggedOnly.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];
@@ -215,7 +342,7 @@ const clickRow = async (row) => {
curMail.value = null;
return;
}
curMail.value = row;
await openMail(row);
};
@@ -329,6 +456,7 @@ const multiActionDownload = async () => {
}
onMounted(async () => {
await loadMailStates()
await refresh();
});
@@ -381,6 +509,14 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
<n-button v-if="enableMailReadStatus && currentPageHasUnread" @click="markCurrentPageRead" tertiary>
{{ t('markCurrentPageRead') }}
</n-button>
<n-select v-if="enableMailReadStatus" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
style="width: 120px" />
<n-checkbox v-if="enableMailFlagged" v-model:checked="flaggedOnly">
{{ t('flagged') }}
</n-checkbox>
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
clearable />
@@ -397,12 +533,21 @@ 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)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
<template #prefix>
<n-checkbox v-if="multiActionMode" v-model:checked="row.checked" />
<n-button v-else-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
<template #icon>
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
</template>
</n-button>
</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 +606,9 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :enableMailFlagged="enableMailFlagged"
:onToggleUnread="toggleCurrentMailUnread"
:onToggleFlagged="toggleCurrentMailFlagged"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
@@ -475,9 +623,15 @@ 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)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
<template #prefix>
<n-checkbox v-if="multiActionMode" v-model:checked="row.checked" />
<n-button v-else-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
<template #icon>
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
</template>
</n-button>
</template>
<n-thing class="mail-list-thing">
<template #header>
@@ -487,6 +641,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 +686,38 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
{{ t('refresh') }}
</n-button>
<n-button v-if="enableMailReadStatus && 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="enableMailReadStatus || enableMailFlagged" style="padding: 0 10px; margin-bottom: 10px;">
<n-select v-if="enableMailReadStatus" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
size="small" />
<n-checkbox v-if="enableMailFlagged" v-model:checked="flaggedOnly" style="margin-top: 8px;">
{{ t('flagged') }}
</n-checkbox>
</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) }">
<template #prefix>
<n-button v-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
<template #icon>
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
</template>
</n-button>
</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>
@@ -568,6 +747,9 @@ onBeforeUnmount(() => {
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:enableMailReadStatus="enableMailReadStatus" :enableMailFlagged="enableMailFlagged"
:onToggleUnread="toggleCurrentMailUnread"
:onToggleFlagged="toggleCurrentMailFlagged"
:onSaveToS3="saveToS3Proxy" />
</n-card>
</n-drawer-content>
@@ -676,6 +858,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,14 @@ const props = defineProps({
type: Boolean,
default: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
enableMailFlagged: {
type: Boolean,
default: false
},
// 回调函数 props
onDelete: {
type: Function,
@@ -50,6 +58,14 @@ const props = defineProps({
onSaveToS3: {
type: Function,
default: () => { }
},
onToggleUnread: {
type: Function,
default: () => { }
},
onToggleFlagged: {
type: Function,
default: () => { }
}
});
@@ -146,6 +162,14 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('downloadMail') }}
</n-button>
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" @click="onToggleUnread">
{{ mail.unread ? t('markRead') : t('markUnread') }}
</n-button>
<n-button v-if="enableMailFlagged" size="small" tertiary type="warning" @click="onToggleFlagged">
{{ mail.flagged ? t('removeFlagged') : t('addFlagged') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
<template #icon>
<n-icon :component="ReplyFilled" />
+44
View File
@@ -34,6 +34,10 @@ export const MESSAGE_REGISTRY = {
}
},
"components.MailBox": {
"allMail": {
"en": "All Mail",
"zh": "全部邮件"
},
"attachments": {
"en": "Show Attachments",
"zh": "查看附件"
@@ -70,10 +74,18 @@ export const MESSAGE_REGISTRY = {
"en": "Forward",
"zh": "转发"
},
"flagged": {
"en": "Flagged",
"zh": "星标邮件"
},
"keywordQueryTip": {
"en": "Filter current page",
"zh": "过滤当前页"
},
"markCurrentPageRead": {
"en": "Mark This Page as Read",
"zh": "本页全部已读"
},
"multiAction": {
"en": "Multi Action",
"zh": "多选"
@@ -94,6 +106,10 @@ export const MESSAGE_REGISTRY = {
"en": "Query",
"zh": "查询"
},
"read": {
"en": "Read",
"zh": "已读"
},
"refresh": {
"en": "Refresh",
"zh": "刷新"
@@ -129,6 +145,18 @@ export const MESSAGE_REGISTRY = {
"unselectAll": {
"en": "Unselect All",
"zh": "取消全选"
},
"unread": {
"en": "Unread",
"zh": "未读"
},
"addFlagged": {
"en": "Add Star",
"zh": "添加星标"
},
"removeFlagged": {
"en": "Remove Star",
"zh": "取消星标"
}
},
"components.AiExtractInfo": {
@@ -170,6 +198,10 @@ export const MESSAGE_REGISTRY = {
"en": "View Attachments",
"zh": "查看附件"
},
"addFlagged": {
"en": "Add Star",
"zh": "添加星标"
},
"delete": {
"en": "Delete",
"zh": "删除"
@@ -194,10 +226,22 @@ 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} 项外部资源以保护隐私"
},
"removeFlagged": {
"en": "Remove Star",
"zh": "取消星标"
},
"reply": {
"en": "Reply",
"zh": "回复"
+2
View File
@@ -24,6 +24,8 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableMailReadStatus: false,
enableMailFlagged: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
+28 -3
View File
@@ -32,19 +32,41 @@ const SendMail = defineAsyncComponent(() => {
const { t } = useScopedI18n('views.Index')
const fetchMailData = async (limit, offset) => {
const fetchMailData = async (limit, offset, mailState, flaggedOnly) => {
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)}` : ''
const flaggedQuery = flaggedOnly ? '&flagged=true' : ''
return await api.fetch(
`/api/mails?limit=${limit}&offset=${offset}${mailStateQuery}${flaggedQuery}`
);
};
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 updateMailFlagged = async (ids, flagged) => {
return await api.fetch(`/api/mails/flagged`, {
method: 'PATCH',
body: JSON.stringify({ ids, flagged })
});
};
const fetchMailStates = async () => {
return await api.fetch(`/api/mail-states`)
}
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
@@ -127,7 +149,10 @@ 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"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:enableMailFlagged="openSettings.enableMailFlagged" :updateMailState="updateMailState"
:updateMailFlagged="updateMailFlagged" :fetchMailStates="fetchMailStates" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
+64
View File
@@ -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,58 @@ 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.enableMailReadStatus && 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.enableMailReadStatus) 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 toggleCurrentMailFlagged = async () => {
if (!currentMail.value || !openSettings.value.enableMailFlagged) return
try {
const response = await api.fetch(`/api/mails/flagged`, {
method: 'PATCH',
body: JSON.stringify({
ids: [currentMail.value.id],
flagged: !currentMail.value.flagged,
})
})
const updatedMail = response.results?.[0]
if (updatedMail) currentMail.value.flagged = updatedMail.flagged
} catch (error) {
message.error(error.message || 'error')
}
}
// 删除邮件
const deleteMail = async () => {
if (!currentMail.value) return;
@@ -106,6 +157,15 @@ watch(currentPage, () => {
onMounted(async () => {
await api.getSettings()
if (openSettings.value.enableMailReadStatus) {
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 +280,10 @@ onBeforeUnmount(() => {
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:enableMailFlagged="openSettings.enableMailFlagged"
:onToggleUnread="toggleCurrentMailUnread"
:onToggleFlagged="toggleCurrentMailFlagged"
:onDelete="deleteMail" />
</div>
</div>
+26 -2
View File
@@ -20,11 +20,13 @@ const queryMail = () => {
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
const fetchMailData = async (limit, offset, mailState, flaggedOnly) => {
return await api.fetch(
`/user_api/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (mailState ? `&mail_state=${encodeURIComponent(mailState)}` : '')
+ (flaggedOnly ? '&flagged=true' : '')
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
);
}
@@ -50,6 +52,24 @@ 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 updateMailFlagged = async (ids, flagged) => {
return await api.fetch(`/user_api/mails/flagged`, {
method: 'PATCH',
body: JSON.stringify({ ids, flagged })
});
};
const fetchMailStates = async () => {
return await api.fetch(`/user_api/mail-states`)
}
watch(addressFilter, async (newValue) => {
queryMail();
});
@@ -70,6 +90,10 @@ 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"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:enableMailFlagged="openSettings.enableMailFlagged"
:updateMailState="updateMailState" :updateMailFlagged="updateMailFlagged"
:fetchMailStates="fetchMailStates" />
</div>
</template>
@@ -19,6 +19,52 @@ 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 running the database migration, `ENABLE_MAIL_READ_STATUS` and `ENABLE_MAIL_FLAGGED` can be enabled independently. The former adds `unread` to mail responses and the latter adds `flagged`. State lives in a separate sparse relation table without changing `raw_mails`; historical mail without a state record is read and unstarred by default, and the backend handles state calculation and updates.
Read status is the high-write feature: it inserts one unread row for every new mail and deletes that row when the mail becomes read. Enabling only Flagged performs none of those writes; the database changes only when a user adds or removes a star.
With an Address JWT, use `GET /api/mail-states` to retrieve the available read states. The frontend uses each returned `value` directly for filtering and updates, and displays its `label_key`.
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.
Flagged is independent of read state. Use `PATCH /api/mails/flagged` to add or remove stars:
```python
requests.patch(
"https://<your-worker-address>/api/mails/flagged",
headers={"Authorization": "Bearer <your-JWT-password>"},
json={"ids": [1, 2], "flagged": True}
)
```
The User JWT equivalent is `PATCH /user_api/mails/flagged`.
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
```
Use `flagged=true` to list starred mail. It can be combined with `mail_state`:
```text
GET /api/mails?limit=20&offset=0&mail_state=unread&flagged=true
```
`/user_api/mails` accepts the same parameter.
## Admin Mail API
Supports `address` filter
@@ -102,6 +102,8 @@
| `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_READ_STATUS` | Text/JSON | Enables web read/unread state. It writes one unread row for every new mail and deletes that row when read, so this is the high-write feature. Historical mail without a row is read. **Run the database migration before enabling.** | `true` |
| `ENABLE_MAIL_FLAGGED` | Text/JSON | Independently enables Flagged/starred mail. It writes only when a user adds or removes a star and does not add per-new-mail writes. Historical mail is unstarred. **Run the database migration before enabling.** | `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]
@@ -19,6 +19,52 @@ res = requests.get(
**注意**`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject``text``html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm``postal-mime`)。
## 邮件状态 API
完成数据库迁移后,可分别启用 `ENABLE_MAIL_READ_STATUS``ENABLE_MAIL_FLAGGED`。前者让邮件响应包含 `unread`,后者包含 `flagged`;两个开关互不依赖。邮件状态保存在独立的稀疏关联表中,不修改 `raw_mails`;没有状态记录的历史邮件默认已读且未星标,状态计算和更新全部由后端处理。
已读状态是高写入量功能:启用后每封新邮件会新增一条未读记录,邮件变为已读时再删除。仅启用星标不会执行这些写入,只有用户添加或取消星标时才修改数据库。
地址 JWT 使用 `GET /api/mail-states` 获取当前可用的已读状态。前端直接使用其中的 `value` 作为筛选和更新参数,并使用 `label_key` 显示名称。
使用 `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` 状态。
星标与已读状态相互独立。使用 `PATCH /api/mails/flagged` 添加或取消星标:
```python
requests.patch(
"https://<你的worker地址>/api/mails/flagged",
headers={"Authorization": "Bearer <你的JWT密码>"},
json={"ids": [1, 2], "flagged": True}
)
```
用户 JWT 对应接口为 `PATCH /user_api/mails/flagged`
邮件列表使用后端返回的状态 `value` 查询。例如查询未读邮件:
```text
GET /api/mails?limit=20&offset=0&mail_state=unread
```
使用 `flagged=true` 查询星标邮件,并可与 `mail_state` 组合:
```text
GET /api/mails?limit=20&offset=0&mail_state=unread&flagged=true
```
`/user_api/mails` 支持相同参数。
## admin 邮件 API
支持 `address` 过滤
@@ -97,6 +97,8 @@
| `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_READ_STATUS` | 文本/JSON | 启用网页已读/未读功能。每封新邮件写入一条未读记录,变为已读时删除,因此属于高写入量功能。无记录的历史邮件默认已读。**启用前必须先执行数据库迁移。** | `true` |
| `ENABLE_MAIL_FLAGGED` | 文本/JSON | 独立启用星标邮件功能。只有添加或取消星标时才写数据库,不会产生逐封新邮件写入;历史邮件默认未星标。**启用前必须先执行数据库迁移。** | `true` |
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
> [!NOTE]
+13 -8
View File
@@ -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)
}
+2 -3
View File
@@ -1,6 +1,7 @@
import { Context } from "hono";
import { handleMailListQuery } from "../common";
import { resolveRawEmailRow } from "../gzip";
import { deleteRawMails } from "../mail_flags";
export default {
getMails: async (c: Context<HonoCustomType>) => {
@@ -35,9 +36,7 @@ export default {
},
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
})
+2
View File
@@ -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) {
+9
View File
@@ -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,
+2
View File
@@ -40,6 +40,8 @@ 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_READ_STATUS": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS),
"ENABLE_MAIL_FLAGGED": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGGED),
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"COPYRIGHT": c.env.COPYRIGHT,
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
+4
View File
@@ -20,6 +20,8 @@ api.get('/open_api/settings', async (c) => {
) || {};
const smtpProxyConfig = smtpImapProxyConfig.smtp || {};
const imapProxyConfig = smtpImapProxyConfig.imap || {};
const enableMailReadStatus = utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS);
const enableMailFlagged = utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGGED);
return c.json({
"title": c.env.TITLE,
@@ -39,6 +41,8 @@ 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),
...(enableMailReadStatus ? { "enableMailReadStatus": true } : {}),
...(enableMailFlagged ? { "enableMailFlagged": true } : {}),
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
+30 -18
View File
@@ -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<{
+1 -1
View File
@@ -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',
+18 -10
View File
@@ -12,6 +12,7 @@ import { forwardEmail } from "./forward";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
import { compressText } from "../gzip";
import { initializeMailFlagsAfterInsert } from "../mail_flags";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
@@ -67,7 +68,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
const message_id = message.headers.get("Message-ID");
// 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 +78,49 @@ 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();
} 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();
} 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();
}
} 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();
}
if (!success) {
if (!insertResult?.success) {
message.setReject(`Failed save message to ${toAddress}`);
console.error(`Failed save message from ${message.from} to ${toAddress}`);
} else {
await initializeMailFlagsAfterInsert(
env.DB,
env,
insertResult?.meta.last_row_id ?? 0,
toAddress,
);
}
}
catch (error) {
+343
View File
@@ -0,0 +1,343 @@
export enum MailFlag {
UNREAD = 0,
FLAGGED = 1,
}
export enum MailState {
ALL = 'all',
UNREAD = 'unread',
READ = 'read',
}
export type MailStateOption = {
value: string;
label_key: string;
unread?: boolean;
default?: boolean;
};
type MailStateDefinition = MailStateOption & {
filter?: { flag: MailFlag; present: boolean };
};
const 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 },
},
{
value: MailState.READ,
label_key: 'read',
unread: false,
filter: { flag: MailFlag.UNREAD, present: false },
},
];
export const getMailStateOptions = (): MailStateOption[] => {
return MAIL_STATES.map(({ filter: _filter, ...option }) => option);
};
const getMailStateDefinition = (value: unknown): MailStateDefinition | undefined => {
if (typeof value !== 'string') return undefined;
return MAIL_STATES.find(state => state.value === value);
};
const isEnabled = (value: boolean | string | undefined): boolean => {
return value === true || value === 'true';
};
export const isMailReadStatusEnabled = (env: Bindings): boolean => {
return isEnabled(env.ENABLE_MAIL_READ_STATUS);
};
export const isMailFlaggedEnabled = (env: Bindings): boolean => {
return isEnabled(env.ENABLE_MAIL_FLAGGED);
};
const isAnyMailFlagEnabled = (env: Bindings): boolean => {
return isMailReadStatusEnabled(env) || isMailFlaggedEnabled(env);
};
export const serializeMailStates = async <T extends Record<string, unknown>>(
db: D1Database,
rows: T[],
env: Bindings,
): Promise<T[]> => {
const readStatusEnabled = isMailReadStatusEnabled(env);
const flaggedEnabled = isMailFlaggedEnabled(env);
if ((!readStatusEnabled && !flaggedEnabled) || rows.length === 0) return rows;
const hasBoolean = (row: T, field: string) => {
return [true, false, 0, 1].includes(row[field] as boolean | number);
};
const ids = [...new Set(rows
.filter(row => (readStatusEnabled && !hasBoolean(row, 'unread'))
|| (flaggedEnabled && !hasBoolean(row, 'flagged')))
.map(row => Number(row.id)))]
.filter(id => Number.isInteger(id) && id > 0);
if (ids.length === 0) {
return rows.map(row => ({
...row,
...(readStatusEnabled ? { unread: Boolean(row.unread) } : {}),
...(flaggedEnabled ? { flagged: Boolean(row.flagged) } : {}),
}));
}
const flags = [
...(readStatusEnabled ? [MailFlag.UNREAD] : []),
...(flaggedEnabled ? [MailFlag.FLAGGED] : []),
];
const flagPlaceholders = flags.map(() => '?').join(',');
const placeholders = ids.map(() => '?').join(',');
const { results } = await db.prepare(
`SELECT mf.mail_id, mf.flag, a.name AS address FROM mail_flags mf`
+ ` JOIN address a ON a.id = mf.address_id`
+ ` WHERE mf.flag IN (${flagPlaceholders}) AND mf.mail_id IN (${placeholders})`
).bind(...flags, ...ids).all<{
mail_id: number;
flag: number;
address: string;
}>();
const mailKey = (id: unknown, address: unknown) => `${Number(id)}\0${String(address)}`;
const unreadKeys = new Set(results
.filter(row => row.flag === MailFlag.UNREAD)
.map(row => mailKey(row.mail_id, row.address)));
const flaggedKeys = new Set(results
.filter(row => row.flag === MailFlag.FLAGGED)
.map(row => mailKey(row.mail_id, row.address)));
return rows.map(row => ({
...row,
...(readStatusEnabled ? {
unread: hasBoolean(row, 'unread')
? Boolean(row.unread)
: unreadKeys.has(mailKey(row.id, row.address)),
} : {}),
...(flaggedEnabled ? {
flagged: hasBoolean(row, 'flagged')
? Boolean(row.flagged)
: flaggedKeys.has(mailKey(row.id, row.address)),
} : {}),
}));
};
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;
};
export const initializeMailFlagsAfterInsert = async (
db: D1Database,
env: Bindings,
mailId: number,
address: string,
): Promise<void> => {
if (!isMailReadStatusEnabled(env) || !Number.isInteger(mailId) || mailId <= 0) return;
try {
await db.prepare(
`INSERT OR IGNORE INTO mail_flags (mail_id, address_id, flag)`
+ ` SELECT ?, id, ? FROM address WHERE name = ?`
).bind(mailId, MailFlag.UNREAD, address).run();
} catch (error) {
console.error(`Failed to initialize mail flags for mail ${mailId}`, error);
}
};
export type MailStateQuery = {
join: string;
clause?: string;
orderBy?: string;
unread?: boolean;
flagged?: boolean;
params: number[];
};
export const getMailFlaggedQuery = (
value: string | undefined,
mailAlias: string,
addressIdColumn: string,
): MailStateQuery | undefined | null => {
if (value === undefined) return undefined;
if (value !== 'true' && value !== 'false') return null;
const present = value === 'true';
return {
join: ` ${present ? 'JOIN' : 'LEFT JOIN'} mail_flags mail_flagged_flags`
+ ` ON mail_flagged_flags.mail_id = ${mailAlias}.id`
+ ` AND mail_flagged_flags.address_id = ${addressIdColumn}`
+ ` AND mail_flagged_flags.flag = ?`,
clause: present ? undefined : 'mail_flagged_flags.mail_id IS NULL',
orderBy: present ? 'mail_flagged_flags.mail_id desc' : undefined,
flagged: present,
params: [MailFlag.FLAGGED],
};
};
export const getMailStateQuery = (
value: string | undefined,
mailAlias: string,
addressIdColumn: string,
): MailStateQuery | undefined | null => {
if (value === undefined) return undefined;
const definition = getMailStateDefinition(value);
if (!definition) return null;
if (!definition.filter) return undefined;
const { flag, present } = definition.filter;
return {
join: ` ${present ? 'JOIN' : 'LEFT JOIN'} 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 MailFlagUpdate = {
ids: number[];
body: Record<string, unknown>;
};
const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | 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;
return { ids, body };
};
type MailScope = {
clause: string;
params: (string | number)[];
};
const applyMailFlagUpdate = async (
db: D1Database,
scope: MailScope,
ids: number[],
flag: MailFlag,
present: boolean,
resultField: 'unread' | 'flagged',
) => {
const placeholders = ids.map(() => '?').join(',');
const targetWhere = `rm.id IN (${placeholders}) AND (${scope.clause})`;
const mutation = present
? 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(flag, ...ids, ...scope.params)
: db.prepare(
`DELETE FROM mail_flags WHERE flag = ? AND mail_id IN (`
+ `SELECT rm.id FROM raw_mails rm JOIN address a ON a.name = rm.address`
+ ` WHERE ${targetWhere})`
).bind(flag, ...ids, ...scope.params);
const mutationResult = await mutation.run();
if (!mutationResult.success) {
return { success: false, changes: 0, results: [] };
}
const { results } = await db.prepare(
`SELECT rm.id FROM raw_mails rm JOIN address a ON a.name = rm.address`
+ ` WHERE ${targetWhere}`
).bind(...ids, ...scope.params).all<{ id: number }>();
return {
success: true,
changes: mutationResult.meta.changes ?? 0,
results: results.map(row => ({ id: row.id, [resultField]: present })),
};
};
export const applyMailStateUpdate = async (
db: D1Database,
scope: MailScope,
value: unknown,
) => {
const update = parseMailFlagUpdate(value);
if (!update) return null;
const definition = getMailStateDefinition(update.body.state);
if (definition?.unread === undefined) return null;
return await applyMailFlagUpdate(
db, scope, update.ids, MailFlag.UNREAD, definition.unread, 'unread'
);
};
export const applyMailFlaggedUpdate = async (
db: D1Database,
scope: MailScope,
value: unknown,
) => {
const update = parseMailFlagUpdate(value);
if (!update || typeof update.body.flagged !== 'boolean') return null;
return await applyMailFlagUpdate(
db, scope, update.ids, MailFlag.FLAGGED, update.body.flagged, 'flagged'
);
};
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 (!isAnyMailFlagEnabled(env)) return [deleteMail];
return [
db.prepare(
`DELETE FROM mail_flags WHERE mail_id IN (`
+ `SELECT id FROM raw_mails WHERE ${whereClause})`
).bind(...params),
deleteMail,
];
};
export const deleteRawMails = async (
db: D1Database,
env: Bindings,
whereClause: string,
params: (string | number)[],
): Promise<D1Result> => {
const statements = prepareRawMailDeleteStatements(db, env, whereClause, params);
if (statements.length === 1) return await statements[0].run();
const results = await db.batch(statements);
return results[results.length - 1];
};
export const cleanupOrphanMailFlags = async (
db: D1Database,
env: Bindings,
limit = 1000,
): Promise<number> => {
if (!isAnyMailFlagEnabled(env) || !Number.isInteger(limit) || limit <= 0) 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`
+ ` LEFT JOIN address a ON a.id = mf.address_id AND a.name = rm.address`
+ ` WHERE rm.id IS NULL OR a.id IS NULL LIMIT ?)`
).bind(limit).run();
return result.meta.changes ?? 0;
};
+3
View File
@@ -27,7 +27,10 @@ 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.patch('/api/mails/flagged', mails_crud.updateMailFlagged)
api.delete('/api/mails/:id', mails_crud.deleteMail)
// parsed mail (server-side parsed subject/text/html/attachments)
+107 -12
View File
@@ -5,18 +5,63 @@ import { getBooleanValue } from '../utils';
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
import { getSendBalanceState } from './send_balance';
import {
getMailStateQuery,
getMailFlaggedQuery,
getMailStateOptions,
applyMailStateUpdate,
applyMailFlaggedUpdate,
serializeMailState,
deleteRawMails,
isMailReadStatusEnabled,
isMailFlaggedEnabled,
} 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, flagged } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
const stateQuery = getMailStateQuery(mail_state, 'rm', 'a.id');
const flaggedQuery = getMailFlaggedQuery(flagged, 'rm', 'a.id');
if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400);
if (flaggedQuery === null) return c.json({ error: "Invalid flagged filter" }, 400);
if (stateQuery && !isMailReadStatusEnabled(c.env)) {
return c.json({ error: "Mail read status is disabled" }, 403);
}
if (flaggedQuery && !isMailFlaggedEnabled(c.env)) {
return c.json({ error: "Flagged mail is disabled" }, 403);
}
if (!stateQuery && !flaggedQuery) {
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);
if (flaggedQuery?.clause) filters.push(flaggedQuery.clause);
const fromQuery = ` FROM raw_mails rm`
+ ` JOIN address a ON a.name = rm.address`
+ (stateQuery?.join ?? '')
+ (flaggedQuery?.join ?? '')
+ ` WHERE ${filters.join(' AND ')}`;
const unreadSelect = stateQuery?.unread === undefined
? ''
: `, ${stateQuery.unread ? 1 : 0} AS unread`;
const flaggedSelect = flaggedQuery?.flagged === undefined
? ''
: `, ${flaggedQuery.flagged ? 1 : 0} AS flagged`;
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}${flaggedSelect}${fromQuery}`,
`SELECT count(*) as count${fromQuery}`,
[...(stateQuery?.params ?? []), ...(flaggedQuery?.params ?? []), address], limit, offset,
flaggedQuery?.orderBy ?? stateQuery?.orderBy ?? 'rm.id desc'
);
};
@@ -27,7 +72,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 +87,52 @@ 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 (!isMailReadStatusEnabled(c.env)) {
return c.json({ error: "Mail read status is disabled" }, 403);
}
const { address } = c.get("jwtPayload");
const result = await applyMailStateUpdate(
c.env.DB,
{ 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 updateMailFlagged = async (c: Context<HonoCustomType>) => {
if (!isMailFlaggedEnabled(c.env)) {
return c.json({ error: "Flagged mail is disabled" }, 403);
}
const { address } = c.get("jwtPayload");
const result = await applyMailFlaggedUpdate(
c.env.DB,
{ clause: 'rm.address = ?', params: [address] },
await c.req.json().catch(() => null),
);
if (!result) return c.json({ error: "Invalid flagged request" }, 400);
if (!result.success) return c.json(result, 500);
return c.json(result);
};
const getMailStates = (c: Context<HonoCustomType>) => {
if (!isMailReadStatusEnabled(c.env)) {
return c.json({ error: "Mail read status is 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 +182,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 +209,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, updateMailFlagged, getMailStates,
getSettings, deleteAddress, clearInbox, clearSentItems
};
+2
View File
@@ -213,6 +213,8 @@ export type RawMailRow = {
raw?: string;
raw_blob?: unknown;
metadata?: string;
unread?: boolean;
flagged?: boolean;
created_at?: string;
}
+2
View File
@@ -117,6 +117,8 @@ type Bindings = {
// gzip compression for raw_mails
ENABLE_MAIL_GZIP: string | boolean | undefined
ENABLE_MAIL_READ_STATUS: string | boolean | undefined
ENABLE_MAIL_FLAGGED: string | boolean | undefined
CLEANUP_BATCH_SIZE: string | number | undefined
// E2E testing
+3
View File
@@ -16,6 +16,9 @@ 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.patch('/user_api/mails/flagged', user_mail_api.updateMailFlagged);
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
// send mail api
+85 -7
View File
@@ -2,25 +2,62 @@ import { Context } from "hono";
import i18n from "../i18n";
import { handleMailListQuery } from "../common";
import { getBooleanValue } from "../utils";
import {
getMailStateQuery,
getMailFlaggedQuery,
getMailStateOptions,
applyMailStateUpdate,
applyMailFlaggedUpdate,
deleteRawMails,
isMailReadStatusEnabled,
isMailFlaggedEnabled,
} from "../mail_flags";
export default {
getMailStates: (c: Context<HonoCustomType>) => {
if (!isMailReadStatusEnabled(c.env)) {
return c.json({ error: "Mail read status is 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, flagged } = 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');
const flaggedQuery = getMailFlaggedQuery(flagged, 'rm', 'a.id');
if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400);
if (flaggedQuery === null) return c.json({ error: "Invalid flagged filter" }, 400);
if (stateQuery && !isMailReadStatusEnabled(c.env)) {
return c.json({ error: "Mail read status is disabled" }, 403);
}
if (flaggedQuery && !isMailFlaggedEnabled(c.env)) {
return c.json({ error: "Flagged mail is disabled" }, 403);
}
if (stateQuery?.clause) filterQuerys.push(stateQuery.clause);
if (flaggedQuery?.clause) filterQuerys.push(flaggedQuery.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 ?? '')
+ (flaggedQuery?.join ?? '')
+ ` WHERE ${filterQuerys.join(" AND ")}`;
const unreadSelect = stateQuery?.unread === undefined
? ''
: `, ${stateQuery.unread ? 1 : 0} AS unread`;
const flaggedSelect = flaggedQuery?.flagged === undefined
? ''
: `, ${flaggedQuery.flagged ? 1 : 0} AS flagged`;
return await handleMailListQuery(c,
`SELECT rm.*${fromQuery}`,
`SELECT rm.*${unreadSelect}${flaggedSelect}${fromQuery}`,
`SELECT count(*) as count${fromQuery}`,
filterParams, limit, offset, 'rm.id desc'
[...(stateQuery?.params ?? []), ...(flaggedQuery?.params ?? []), ...filterParams],
limit, offset, flaggedQuery?.orderBy ?? stateQuery?.orderBy ?? 'rm.id desc'
);
},
deleteMail: async (c: Context<HonoCustomType>) => {
@@ -30,16 +67,57 @@ 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 (!isMailReadStatusEnabled(c.env)) {
return c.json({ error: "Mail read status is disabled" }, 403);
}
const { user_id } = c.get("userPayload");
const result = await applyMailStateUpdate(
c.env.DB,
{
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);
},
updateMailFlagged: async (c: Context<HonoCustomType>) => {
if (!isMailFlaggedEnabled(c.env)) {
return c.json({ error: "Flagged mail is disabled" }, 403);
}
const { user_id } = c.get("userPayload");
const result = await applyMailFlaggedUpdate(
c.env.DB,
{
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 flagged request" }, 400);
if (!result.success) return c.json(result, 500);
return c.json(result);
}
}
+19 -11
View File
@@ -3,6 +3,7 @@ import { createMimeMessage } from "mimetext";
import { UserSettings, RoleAddressConfig } from "./models";
import { CONSTANTS } from "./constants";
import { compressText } from "./gzip";
import { initializeMailFlagsAfterInsert } from "./mail_flags";
export const getJsonObjectValue = <T = any>(
value: string | any
@@ -371,7 +372,7 @@ export const sendAdminInternalMail = async (
});
const message_id = Math.random().toString(36).substring(2, 15);
const rawText = msg.asRaw();
let success = false;
let insertResult: D1Result | null = null;
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
let compressed: ArrayBuffer | null = null;
try {
@@ -381,34 +382,41 @@ 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();
} 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();
} 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();
}
} 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();
}
if (!success) {
if (!insertResult?.success) {
console.log(`Failed save message from admin@internal to ${toMail}`);
} else {
await initializeMailFlagsAfterInsert(
c.env.DB,
c.env,
insertResult?.meta.last_row_id ?? 0,
toMail,
);
}
return success;
return insertResult?.success ?? false;
} catch (error) {
console.log("sendAdminInternalMail error", error);
return false;
+4
View File
@@ -77,6 +77,10 @@ ENABLE_USER_CREATE_EMAIL = true
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Enable per-message read status. This adds one write for each new mail and another when it is read.
# ENABLE_MAIL_READ_STATUS = true
# Enable low-write Flagged/starred mail independently from read status.
# ENABLE_MAIL_FLAGGED = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Allow webhook