diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62e9681..e16fe56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
### Features
+- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126)
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md
index 5b0be5a..3b01e15 100644
--- a/CHANGELOG_EN.md
+++ b/CHANGELOG_EN.md
@@ -10,6 +10,7 @@
### Features
+- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching
- 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
diff --git a/db/2026-08-30-mail-read-status.sql b/db/2026-08-30-mail-read-status.sql
new file mode 100644
index 0000000..362cc16
--- /dev/null
+++ b/db/2026-08-30-mail-read-status.sql
@@ -0,0 +1 @@
+ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
diff --git a/db/schema.sql b/db/schema.sql
index 321d9bf..3afc8e5 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
+ is_unread INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts
index d6fb53f..96723e2 100644
--- a/e2e/fixtures/test-helpers.ts
+++ b/e2e/fixtures/test-helpers.ts
@@ -27,10 +27,11 @@ export function hashPassword(password: string): string {
export async function createTestAddress(
ctx: APIRequestContext,
name: string,
- domain: string = TEST_DOMAIN
+ domain: string = TEST_DOMAIN,
+ workerUrl: string = WORKER_URL,
): Promise<{ jwt: string; address: string; address_id: number }> {
const uniqueName = `${name}${Date.now()}`;
- const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
+ const res = await ctx.post(`${workerUrl}/api/new_address`, {
data: { name: uniqueName, domain },
});
if (!res.ok()) {
diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e
index 0756436..0059410 100644
--- a/e2e/fixtures/wrangler.toml.e2e
+++ b/e2e/fixtures/wrangler.toml.e2e
@@ -18,6 +18,7 @@ JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
+ENABLE_MAIL_READ_STATUS = true
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
NO_LIMIT_SEND_ROLE = "case-role"
diff --git a/e2e/tests/api/mail-read.spec.ts b/e2e/tests/api/mail-read.spec.ts
new file mode 100644
index 0000000..a1d1311
--- /dev/null
+++ b/e2e/tests/api/mail-read.spec.ts
@@ -0,0 +1,108 @@
+import { expect, test } from '@playwright/test';
+
+import {
+ WORKER_URL,
+ WORKER_URL_ENV_OFF,
+ createTestAddress,
+ deleteAddress,
+ seedTestMail,
+} from '../../fixtures/test-helpers';
+
+const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
+
+test.describe('Mail read status', () => {
+ test('keeps historical mail read and switches one new mail state', async ({ request }) => {
+ const mailbox = await createTestAddress(request, 'mail-read');
+ try {
+ const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
+ data: {
+ address: mailbox.address,
+ source: 'sender@test.example.com',
+ raw: 'From: sender@test.example.com\r\nSubject: Historical\r\n\r\nHistorical',
+ },
+ });
+ expect(historical.ok()).toBe(true);
+ await seedTestMail(request, mailbox.address, { subject: 'New unread mail' });
+
+ const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: headers(mailbox.jwt),
+ });
+ const mails = (await list.json()).results;
+ expect(mails.find((mail: any) => mail.raw.includes('Historical')).is_unread).toBeNull();
+ const unreadMail = mails.find((mail: any) => mail.raw.includes('New unread mail'));
+ expect(unreadMail.is_unread).toBe(1);
+
+ const markRead = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
+ headers: headers(mailbox.jwt),
+ data: { isUnread: false },
+ });
+ expect((await markRead.json()).success).toBe(true);
+
+ const updated = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: headers(mailbox.jwt),
+ });
+ const updatedMail = (await updated.json()).results.find((mail: any) => mail.id === unreadMail.id);
+ expect(updatedMail.is_unread).toBe(0);
+
+ const markUnread = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
+ headers: headers(mailbox.jwt),
+ data: { isUnread: true },
+ });
+ expect((await markUnread.json()).success).toBe(true);
+
+ const restored = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: headers(mailbox.jwt),
+ });
+ const restoredMail = (await restored.json()).results.find((mail: any) => mail.id === unreadMail.id);
+ expect(restoredMail.is_unread).toBe(1);
+
+ const invalid = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
+ headers: headers(mailbox.jwt),
+ data: { isUnread: 'yes' },
+ });
+ expect(invalid.status()).toBe(400);
+ } finally {
+ await deleteAddress(request, mailbox.jwt);
+ }
+ });
+
+ test('scopes the update and keeps disabled instances unchanged', async ({ request }) => {
+ const first = await createTestAddress(request, 'mail-read-first');
+ const second = await createTestAddress(request, 'mail-read-second');
+ try {
+ await seedTestMail(request, second.address, { subject: 'Second mail' });
+ const secondList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: headers(second.jwt),
+ });
+ const secondMail = (await secondList.json()).results[0];
+
+ await request.patch(`${WORKER_URL}/api/mails/${secondMail.id}/read`, {
+ headers: headers(first.jwt),
+ data: { isUnread: false },
+ });
+ const unchanged = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: headers(second.jwt),
+ });
+ expect((await unchanged.json()).results[0].is_unread).toBe(1);
+
+ const disabledMailbox = await createTestAddress(
+ request,
+ 'mail-read-disabled',
+ 'test.example.com',
+ WORKER_URL_ENV_OFF,
+ );
+ const oldList = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
+ headers: headers(disabledMailbox.jwt),
+ });
+ expect(oldList.ok()).toBe(true);
+ const disabledUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/1/read`, {
+ headers: headers(disabledMailbox.jwt),
+ data: { isUnread: false },
+ });
+ expect(disabledUpdate.status()).toBe(403);
+ } finally {
+ await deleteAddress(request, first.jwt);
+ await deleteAddress(request, second.jwt);
+ }
+ });
+});
diff --git a/e2e/tests/browser/mail-read.spec.ts b/e2e/tests/browser/mail-read.spec.ts
new file mode 100644
index 0000000..6f8372a
--- /dev/null
+++ b/e2e/tests/browser/mail-read.spec.ts
@@ -0,0 +1,79 @@
+import { expect, request as apiRequest, test } from '@playwright/test';
+
+import {
+ FRONTEND_URL,
+ WORKER_URL,
+ createTestAddress,
+ deleteAddress,
+ seedTestMail,
+} from '../../fixtures/test-helpers';
+
+test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => {
+ const request = await apiRequest.newContext();
+ let jwt: string | undefined;
+ try {
+ const mailbox = await createTestAddress(request, 'mail-read-browser');
+ jwt = mailbox.jwt;
+ const subject = `Unread browser mail ${Date.now()}`;
+ 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}`);
+ await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
+
+ await page.reload();
+ await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
+ const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject));
+ expect(mail.is_unread).toBe(1);
+ await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible();
+
+ await page.route('**/api/mails/*/read', async route => {
+ await new Promise(resolve => setTimeout(resolve, 300));
+ await route.continue();
+ });
+ const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response =>
+ /\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname)
+ && response.request().method() === 'PATCH'
+ && response.request().postDataJSON().isUnread === isUnread
+ );
+ const readResponse = waitForReadUpdate(false);
+ await page.getByText(subject, { exact: true }).click();
+ expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
+ expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
+ await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
+ expect((await readResponse).ok()).toBe(true);
+ await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
+
+ const unreadResponse = waitForReadUpdate(true);
+ await page.getByRole('button', { name: 'Mark as Unread' }).click();
+ expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1);
+ expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
+ await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/);
+ expect((await unreadResponse).ok()).toBe(true);
+ await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/);
+
+ const manualReadResponse = waitForReadUpdate(false);
+ await page.getByRole('button', { name: 'Mark as Read' }).click();
+ expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
+ expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
+ await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/);
+ expect((await manualReadResponse).ok()).toBe(true);
+ await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
+
+ const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id);
+ expect(updatedMail.is_unread).toBe(0);
+ } finally {
+ try {
+ if (jwt) await deleteAddress(request, jwt);
+ } finally {
+ await request.dispose();
+ }
+ }
+});
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index ee82686..9eeee9b 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -21,7 +21,8 @@ const instance = axios.create({
});
const apiFetch = async (path, options = {}) => {
- loading.value = true;
+ const showLoading = options.showLoading !== false;
+ if (showLoading) loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
@@ -67,7 +68,7 @@ const apiFetch = async (path, options = {}) => {
}
throw error;
} finally {
- loading.value = false;
+ if (showLoading) loading.value = false;
}
}
@@ -99,6 +100,7 @@ const getOpenSettings = async (message, notification) => {
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
+ enableMailReadStatus: res["enableMailReadStatus"] === true,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue
index ce16d66..0777efa 100644
--- a/frontend/src/components/MailBox.vue
+++ b/frontend/src/components/MailBox.vue
@@ -55,6 +55,14 @@ const props = defineProps({
default: false,
required: false
},
+ enableMailReadStatus: {
+ type: Boolean,
+ default: false
+ },
+ updateMailReadStatus: {
+ type: Function,
+ default: () => { }
+ },
})
const localFilterKeyword = ref('')
@@ -94,6 +102,29 @@ const data = computed(() => {
});
})
+const openMail = (mail) => {
+ curMail.value = mail
+ if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return
+ mail.is_unread = 0
+ void props.updateMailReadStatus(mail.id, false).catch(() => {
+ mail.is_unread = 1
+ })
+}
+
+const toggleCurrentMailUnread = async () => {
+ if (!curMail.value || !props.enableMailReadStatus) return
+ const mail = curMail.value
+ const previousValue = mail.is_unread
+ const isUnread = previousValue !== 1
+ mail.is_unread = isUnread ? 1 : 0
+ try {
+ await props.updateMailReadStatus(mail.id, isUnread)
+ message.success(t("success"))
+ } catch {
+ mail.is_unread = previousValue
+ }
+}
+
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -111,12 +142,12 @@ const prevMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
- curMail.value = data.value[currentIndex - 1]
+ 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]
+ openMail(data.value[data.value.length - 1])
}
}
}
@@ -126,12 +157,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]
+ 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]
+ openMail(data.value[0])
}
}
}
@@ -205,7 +236,7 @@ const backFirstPageAndRefresh = async () => {
await refresh();
}
-const clickRow = async (row) => {
+const clickRow = (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
curMail.value = row;
@@ -215,7 +246,7 @@ const clickRow = async (row) => {
curMail.value = null;
return;
}
- curMail.value = row;
+ openMail(row);
};
@@ -397,7 +428,7 @@ onBeforeUnmount(() => {
clickRow(row)"
- :class="mailItemClass(row)">
+ :class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
@@ -461,6 +492,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
@@ -475,7 +507,7 @@ onBeforeUnmount(() => {
clickRow(row)"
- :class="mailItemClass(row)">
+ :class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
@@ -536,7 +568,8 @@ onBeforeUnmount(() => {
- clickRow(row)">
+ clickRow(row)"
+ :class="{ 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }">
@@ -567,6 +600,7 @@ onBeforeUnmount(() => {
@@ -676,6 +710,21 @@ onBeforeUnmount(() => {
min-width: 0;
}
+.mail-list-unread :deep(.n-thing-header__title) {
+ font-weight: 700;
+}
+
+.mail-list-unread :deep(.n-thing-header__title)::before {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 8px;
+ border-radius: 50%;
+ background: #2080f0;
+ content: '';
+ vertical-align: middle;
+}
+
pre {
white-space: pre-wrap;
word-wrap: break-word;
diff --git a/frontend/src/components/MailContentRenderer.vue b/frontend/src/components/MailContentRenderer.vue
index 31accea..a39bfc9 100644
--- a/frontend/src/components/MailContentRenderer.vue
+++ b/frontend/src/components/MailContentRenderer.vue
@@ -34,6 +34,10 @@ const props = defineProps({
type: Boolean,
default: false
},
+ enableMailReadStatus: {
+ type: Boolean,
+ default: false
+ },
// 回调函数 props
onDelete: {
type: Function,
@@ -50,6 +54,10 @@ const props = defineProps({
onSaveToS3: {
type: Function,
default: () => { }
+ },
+ onUpdateMailReadStatus: {
+ type: Function,
+ default: () => { }
}
});
@@ -57,6 +65,7 @@ const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
+const readStatusUpdating = ref(false);
const showFullscreen = ref(false);
// Per-mail consent, deliberately independent of the global setting: it only
@@ -96,6 +105,15 @@ const handleForward = () => {
props.onForward();
};
+const handleUpdateMailReadStatus = async () => {
+ readStatusUpdating.value = true;
+ try {
+ await props.onUpdateMailReadStatus();
+ } finally {
+ readStatusUpdating.value = false;
+ }
+};
+
const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true;
@@ -138,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('attachments') }}
+
+ {{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
+
+
diff --git a/frontend/src/i18n/message-registry.ts b/frontend/src/i18n/message-registry.ts
index 5b90fd8..d2f205f 100644
--- a/frontend/src/i18n/message-registry.ts
+++ b/frontend/src/i18n/message-registry.ts
@@ -190,6 +190,14 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen",
"zh": "全屏"
},
+ "markAsRead": {
+ "en": "Mark as Read",
+ "zh": "标为已读"
+ },
+ "markAsUnread": {
+ "en": "Mark as Unread",
+ "zh": "标为未读"
+ },
"loadRemoteImages": {
"en": "Load Images",
"zh": "加载图片"
@@ -1198,6 +1206,10 @@ export const MESSAGE_REGISTRY = {
"en": "Previous",
"zh": "上一页"
},
+ "readStatusUpdated": {
+ "en": "Read status updated",
+ "zh": "已读状态已更新"
+ },
"refreshAfter": {
"en": "Refresh After {msg} Seconds",
"zh": "{msg}秒后刷新"
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
index c61b195..518ff89 100644
--- a/frontend/src/store/index.js
+++ b/frontend/src/store/index.js
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
+ enableMailReadStatus: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
diff --git a/frontend/src/views/Index.vue b/frontend/src/views/Index.vue
index 00aebe1..13d020f 100644
--- a/frontend/src/views/Index.vue
+++ b/frontend/src/views/Index.vue
@@ -45,6 +45,14 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
+const updateMailReadStatus = async (id, isUnread) => {
+ await api.fetch(`/api/mails/${id}/read`, {
+ method: 'PATCH',
+ body: JSON.stringify({ isUnread }),
+ showLoading: false
+ })
+}
+
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
@@ -127,7 +135,8 @@ onMounted(() => {
+ :fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
+ :enableMailReadStatus="openSettings.enableMailReadStatus" :updateMailReadStatus="updateMailReadStatus" />
{
}
}
+const toggleCurrentMailUnread = async () => {
+ if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
+ const mail = currentMail.value
+ const previousValue = mail.is_unread
+ const isUnread = previousValue !== 1
+ mail.is_unread = isUnread ? 1 : 0
+ try {
+ await api.fetch(`/api/mails/${mail.id}/read`, {
+ method: 'PATCH',
+ body: JSON.stringify({ isUnread }),
+ showLoading: false
+ })
+ message.success(t('readStatusUpdated'))
+ } catch {
+ mail.is_unread = previousValue
+ }
+}
+
// 删除邮件
const deleteMail = async () => {
if (!currentMail.value) return;
@@ -216,10 +234,15 @@ onBeforeUnmount(() => {
-
{{ currentMail.subject }}
+
+ {{ currentMail.subject }}
+
@@ -246,4 +269,19 @@ onBeforeUnmount(() => {
margin-top: 20px;
width: 100%;
}
+
+.mail-title-unread {
+ font-weight: 700;
+}
+
+.mail-title-unread::before {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 8px;
+ border-radius: 50%;
+ background: #2080f0;
+ content: '';
+ vertical-align: middle;
+}
diff --git a/vitepress-docs/docs/en/guide/feature/mail-api.md b/vitepress-docs/docs/en/guide/feature/mail-api.md
index 8204dcd..1c9f9c8 100644
--- a/vitepress-docs/docs/en/guide/feature/mail-api.md
+++ b/vitepress-docs/docs/en/guide/feature/mail-api.md
@@ -49,6 +49,12 @@ print(response.json())
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
+## Mail Read Status API
+
+Enable `ENABLE_MAIL_READ_STATUS` and upgrade the database first. Historical mail has `is_unread = NULL` and is treated as read; new mail has `is_unread = 1`. Opening unread mail from the web mail list marks it as read, and its detail view can switch the state manually. Refreshing the page does not mark it as read:
+
+- `PATCH /api/mails/:id/read`: set one mail belonging to the current address; use `{ "isUnread": true }` for unread or `{ "isUnread": false }` for read
+
## Admin Get Mail API
Fetch a single mail by mail ID without a mailbox JWT. Authenticate with `x-admin-auth`.
diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md
index 9fc89e8..903e030 100644
--- a/vitepress-docs/docs/en/guide/worker-vars.md
+++ b/vitepress-docs/docs/en/guide/worker-vars.md
@@ -12,6 +12,7 @@
| `ADMIN_PASSWORDS` | JSON | Admin console passwords, console access disabled if not configured | `["123", "456"]` |
| `ENABLE_USER_CREATE_EMAIL` | Text/JSON | Whether to allow users to create mailboxes, disabled if not configured | `true` |
| `ENABLE_USER_DELETE_EMAIL` | Text/JSON | Whether to allow users to delete emails, disabled if not configured | `true` |
+| `ENABLE_MAIL_READ_STATUS` | Text/JSON | Enables read/unread mail state. Upgrade the database schema before enabling | `true` |
> [!IMPORTANT] `DOMAINS` and `DEFAULT_DOMAINS` must already be set up in Cloudflare
> Every domain you put here (including `DEFAULT_DOMAINS`, `USER_ROLES.domains`, `RANDOM_SUBDOMAIN_DOMAINS` further below) **must already have Cloudflare Email Routing enabled and its email DNS records provisioned**. After the Worker is deployed, bind the domain's Catch-all rule to that Worker; otherwise inbound mail will never reach the Worker.
diff --git a/vitepress-docs/docs/zh/guide/feature/mail-api.md b/vitepress-docs/docs/zh/guide/feature/mail-api.md
index 675a0fc..3ffe4f2 100644
--- a/vitepress-docs/docs/zh/guide/feature/mail-api.md
+++ b/vitepress-docs/docs/zh/guide/feature/mail-api.md
@@ -49,6 +49,12 @@ print(response.json())
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
+## 邮件已读状态 API
+
+启用 `ENABLE_MAIL_READ_STATUS` 并升级数据库后可使用。历史邮件的 `is_unread` 为 `NULL`,视为已读;新邮件为 `1`。用户在网页邮件列表中点击未读邮件后会将其设为已读,也可以在邮件详情中手动切换状态;刷新页面不会自动标记:
+
+- `PATCH /api/mails/:id/read`:设置当前地址下单封邮件的状态,请求体为 `{ "isUnread": true }`(未读)或 `{ "isUnread": false }`(已读)
+
## admin 获取单封邮件 API
无需邮箱 JWT,通过邮件 ID 获取单封邮件,并使用 `x-admin-auth` 认证。
diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md
index b909020..6b3ec25 100644
--- a/vitepress-docs/docs/zh/guide/worker-vars.md
+++ b/vitepress-docs/docs/zh/guide/worker-vars.md
@@ -12,6 +12,7 @@
| `ADMIN_PASSWORDS` | JSON | admin 控制台密码, 不配置则不允许访问控制台 | `["123", "456"]` |
| `ENABLE_USER_CREATE_EMAIL` | 文本/JSON | 是否允许用户创建邮箱, 不配置则不允许 | `true` |
| `ENABLE_USER_DELETE_EMAIL` | 文本/JSON | 是否允许用户删除邮件, 不配置则不允许 | `true` |
+| `ENABLE_MAIL_READ_STATUS` | 文本/JSON | 启用邮件已读/未读状态。启用前需要升级数据库 Schema | `true` |
> [!IMPORTANT] DOMAINS 与 DEFAULT_DOMAINS 必须先在 Cloudflare 配置好
> 这里填写的所有域名(包括下文「邮箱相关变量」里的 `DEFAULT_DOMAINS`、`USER_ROLES.domains`、`RANDOM_SUBDOMAIN_DOMAINS` 等)必须是你**已经在 Cloudflare Email Routing 中启用并完成邮件 DNS 记录下发**的域名。Worker 部署完成后,还需要把该域名的 Catch-all 规则绑定到这个 Worker,否则邮件无法投递到 Worker。
diff --git a/worker/src/admin_api/address_sender_api.ts b/worker/src/admin_api/address_sender_api.ts
index e994643..b47db74 100644
--- a/worker/src/admin_api/address_sender_api.ts
+++ b/worker/src/admin_api/address_sender_api.ts
@@ -1,7 +1,7 @@
import { Context } from 'hono'
import i18n from '../i18n'
-import { sendAdminInternalMail } from '../utils'
+import { sendAdminInternalMail } from '../email/storage'
import { handleListQuery } from '../common'
const list = async (c: Context) => {
diff --git a/worker/src/admin_api/db_api.ts b/worker/src/admin_api/db_api.ts
index 2add400..c2a2de6 100644
--- a/worker/src/admin_api/db_api.ts
+++ b/worker/src/admin_api/db_api.ts
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
+ is_unread INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -197,6 +198,14 @@ export default {
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;`);
}
}
+ if (version && version <= "v0.0.7") {
+ const tableInfo = await c.env.DB.prepare(`PRAGMA table_info(raw_mails)`).all();
+ if (!tableInfo.results?.some((col: any) => col.name === 'is_unread')) {
+ await c.env.DB.exec(
+ `ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;`
+ );
+ }
+ }
if (version != CONSTANTS.DB_VERSION) {
// remove all \r and \n characters from the query string
// split by ; and join with a ;\n
diff --git a/worker/src/admin_api/worker_config.ts b/worker/src/admin_api/worker_config.ts
index cdba70b..e91b7d8 100644
--- a/worker/src/admin_api/worker_config.ts
+++ b/worker/src/admin_api/worker_config.ts
@@ -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_READ_STATUS": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS),
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"COPYRIGHT": c.env.COPYRIGHT,
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
diff --git a/worker/src/commom_api.ts b/worker/src/commom_api.ts
index c8a42c9..a62a11d 100644
--- a/worker/src/commom_api.ts
+++ b/worker/src/commom_api.ts
@@ -39,6 +39,7 @@ api.get('/open_api/settings', async (c) => {
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME),
"enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
+ "enableMailReadStatus": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS),
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
diff --git a/worker/src/constants.ts b/worker/src/constants.ts
index 50a2a9a..ce1cf57 100644
--- a/worker/src/constants.ts
+++ b/worker/src/constants.ts
@@ -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',
diff --git a/worker/src/email/index.ts b/worker/src/email/index.ts
index e83f6f8..73bdec6 100644
--- a/worker/src/email/index.ts
+++ b/worker/src/email/index.ts
@@ -1,6 +1,6 @@
import { Context } from "hono";
-import { getBooleanValue, getJsonSetting, normalizeAddressDomain } from "../utils";
+import { getJsonSetting, normalizeAddressDomain } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
@@ -11,7 +11,7 @@ import { extractEmailInfo } from "./ai_extract";
import { forwardEmail } from "./forward";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
-import { compressText } from "../gzip";
+import { storeRawMail } from "./storage";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
@@ -67,49 +67,9 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
const message_id = message.headers.get("Message-ID");
// save email
try {
- let success = false;
- if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
- let compressed: ArrayBuffer | null = null;
- try {
- compressed = await compressText(parsedEmailContext.rawEmail);
- } catch (gzipError) {
- console.error("gzip compression failed, falling back to plaintext", gzipError);
- }
- if (compressed) {
- try {
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, compressed, message_id
- ).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(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, parsedEmailContext.rawEmail, message_id
- ).run());
- } else {
- throw dbError;
- }
- }
- } else {
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, parsedEmailContext.rawEmail, message_id
- ).run());
- }
- } else {
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, parsedEmailContext.rawEmail, message_id
- ).run());
- }
+ const { success } = await storeRawMail(
+ env, message.from, toAddress, message_id, parsedEmailContext.rawEmail
+ );
if (!success) {
message.setReject(`Failed save message to ${toAddress}`);
console.error(`Failed save message from ${message.from} to ${toAddress}`);
diff --git a/worker/src/email/storage.ts b/worker/src/email/storage.ts
new file mode 100644
index 0000000..fd6cc5b
--- /dev/null
+++ b/worker/src/email/storage.ts
@@ -0,0 +1,99 @@
+import { Context } from "hono";
+import { createMimeMessage } from "mimetext";
+
+import { compressText } from "../gzip";
+import { getBooleanValue } from "../utils";
+
+let rawMailTableColumns: Set | undefined;
+
+const getRawMailTableColumns = async (
+ env: Bindings, requiredColumns: string[]
+): Promise> => {
+ const cachedColumns = rawMailTableColumns;
+ if (cachedColumns && requiredColumns.every(column => cachedColumns.has(column))) {
+ return cachedColumns;
+ }
+ const tableInfo = await env.DB.prepare(`PRAGMA table_info(raw_mails)`).all<{ name: string }>();
+ const columns = new Set(tableInfo.results.map(column => column.name));
+ if (requiredColumns.every(column => columns.has(column))) {
+ rawMailTableColumns = columns;
+ }
+ return columns;
+}
+
+export const storeRawMail = async (
+ env: Bindings,
+ source: string,
+ address: string,
+ messageId: string | null,
+ raw: string,
+): Promise => {
+ const gzipEnabled = getBooleanValue(env.ENABLE_MAIL_GZIP);
+ const readStatusEnabled = getBooleanValue(env.ENABLE_MAIL_READ_STATUS);
+ const requiredColumns: string[] = [];
+ if (gzipEnabled) requiredColumns.push('raw_blob');
+ if (readStatusEnabled) requiredColumns.push('is_unread');
+
+ let tableColumns = new Set();
+ if (requiredColumns.length > 0) {
+ tableColumns = await getRawMailTableColumns(env, requiredColumns);
+ }
+
+ let rawBlob: ArrayBuffer | undefined;
+ if (gzipEnabled && tableColumns.has('raw_blob')) {
+ try {
+ rawBlob = await compressText(raw);
+ } catch (error) {
+ console.error("gzip compression failed, falling back to plaintext", error);
+ }
+ }
+
+ const storeUnreadStatus = readStatusEnabled && tableColumns.has('is_unread');
+ if (rawBlob) {
+ if (!storeUnreadStatus) {
+ return env.DB.prepare(
+ `INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
+ ).bind(source, address, rawBlob, messageId).run();
+ }
+ return env.DB.prepare(
+ `INSERT INTO raw_mails (source, address, raw_blob, message_id, is_unread) VALUES (?, ?, ?, ?, 1)`
+ ).bind(source, address, rawBlob, messageId).run();
+ }
+ if (!storeUnreadStatus) {
+ return env.DB.prepare(
+ `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
+ ).bind(source, address, raw, messageId).run();
+ }
+ return env.DB.prepare(
+ `INSERT INTO raw_mails (source, address, raw, message_id, is_unread) VALUES (?, ?, ?, ?, 1)`
+ ).bind(source, address, raw, messageId).run();
+}
+
+export const sendAdminInternalMail = async (
+ c: Context, toMail: string, subject: string, text: string
+): Promise => {
+ try {
+ const msg = createMimeMessage();
+ msg.setSender({
+ name: "Admin",
+ addr: "admin@internal"
+ });
+ msg.setRecipient(toMail);
+ msg.setSubject(subject);
+ msg.addMessage({
+ contentType: 'text/plain',
+ data: text
+ });
+ const messageId = Math.random().toString(36).substring(2, 15);
+ const { success } = await storeRawMail(
+ c.env, "admin@internal", toMail, messageId, msg.asRaw()
+ );
+ if (!success) {
+ console.log(`Failed save message from admin@internal to ${toMail}`);
+ }
+ return success;
+ } catch (error) {
+ console.log("sendAdminInternalMail error", error);
+ return false;
+ }
+};
diff --git a/worker/src/mails_api/index.ts b/worker/src/mails_api/index.ts
index 5373864..e1a2ce0 100644
--- a/worker/src/mails_api/index.ts
+++ b/worker/src/mails_api/index.ts
@@ -29,6 +29,7 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
api.get('/api/mails', mails_crud.listMails)
api.get('/api/mail/:mail_id', mails_crud.getMail)
api.delete('/api/mails/:id', mails_crud.deleteMail)
+api.patch('/api/mails/:id/read', mails_crud.updateMailReadStatus)
// parsed mail (server-side parsed subject/text/html/attachments)
api.get('/api/parsed_mails', parsed_mail_api.listParsedMails)
diff --git a/worker/src/mails_api/mails_crud.ts b/worker/src/mails_api/mails_crud.ts
index 34f6525..7c409ce 100644
--- a/worker/src/mails_api/mails_crud.ts
+++ b/worker/src/mails_api/mails_crud.ts
@@ -20,6 +20,23 @@ const listMails = async (c: Context) => {
);
};
+const updateMailReadStatus = async (c: Context) => {
+ if (!getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS)) {
+ return c.json({ error: 'Mail read status is disabled' }, 403);
+ }
+ const { address } = c.get("jwtPayload");
+ const { id } = c.req.param();
+ const { isUnread } = await c.req.json<{ isUnread?: boolean }>().catch(() => ({ isUnread: undefined }));
+ if (typeof isUnread !== 'boolean') {
+ return c.json({ error: 'isUnread must be a boolean' }, 400);
+ }
+ const value = isUnread ? 1 : 0;
+ const { success } = await c.env.DB.prepare(
+ `UPDATE raw_mails SET is_unread = ? WHERE id = ? AND address = ? AND COALESCE(is_unread, 0) != ?`
+ ).bind(value, id, address, value).run();
+ return c.json({ success });
+};
+
const getMail = async (c: Context) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
@@ -117,4 +134,4 @@ const clearSentItems = async (c: Context) => {
return c.json({ success });
};
-export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
+export default { listMails, getMail, updateMailReadStatus, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
diff --git a/worker/src/models/index.ts b/worker/src/models/index.ts
index f544393..5eaf67f 100644
--- a/worker/src/models/index.ts
+++ b/worker/src/models/index.ts
@@ -213,6 +213,7 @@ export type RawMailRow = {
raw?: string;
raw_blob?: unknown;
metadata?: string;
+ is_unread?: number | null;
created_at?: string;
}
diff --git a/worker/src/types.d.ts b/worker/src/types.d.ts
index ce194e8..0488d5e 100644
--- a/worker/src/types.d.ts
+++ b/worker/src/types.d.ts
@@ -117,6 +117,7 @@ type Bindings = {
// gzip compression for raw_mails
ENABLE_MAIL_GZIP: string | boolean | undefined
+ ENABLE_MAIL_READ_STATUS: string | boolean | undefined
CLEANUP_BATCH_SIZE: string | number | undefined
// E2E testing
diff --git a/worker/src/utils.ts b/worker/src/utils.ts
index 0c701d1..79644de 100644
--- a/worker/src/utils.ts
+++ b/worker/src/utils.ts
@@ -1,8 +1,6 @@
import { Context } from "hono";
-import { createMimeMessage } from "mimetext";
import { UserSettings, RoleAddressConfig } from "./models";
import { CONSTANTS } from "./constants";
-import { compressText } from "./gzip";
export const getJsonObjectValue = (
value: string | any
@@ -353,68 +351,6 @@ export const getEnvStringList = (value: string | string[] | undefined): string[]
return value.filter((item) => item.length > 0);
}
-export const sendAdminInternalMail = async (
- c: Context, toMail: string, subject: string, text: string
-): Promise => {
- try {
-
- const msg = createMimeMessage();
- msg.setSender({
- name: "Admin",
- addr: "admin@internal"
- });
- msg.setRecipient(toMail);
- msg.setSubject(subject);
- msg.addMessage({
- contentType: 'text/plain',
- data: text
- });
- const message_id = Math.random().toString(36).substring(2, 15);
- const rawText = msg.asRaw();
- let success = false;
- if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
- let compressed: ArrayBuffer | null = null;
- try {
- compressed = await compressText(rawText);
- } catch (gzipError) {
- console.error("gzip compression failed, falling back to plaintext", gzipError);
- }
- if (compressed) {
- try {
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
- ).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(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, rawText, message_id).run());
- } else {
- throw dbError;
- }
- }
- } else {
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, rawText, message_id).run());
- }
- } else {
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, rawText, message_id).run());
- }
- if (!success) {
- console.log(`Failed save message from admin@internal to ${toMail}`);
- }
- return success;
- } catch (error) {
- console.log("sendAdminInternalMail error", error);
- return false;
- }
-};
-
export const isGlobalTurnstileEnabled = (c: Context): boolean => {
return getBooleanValue(c.env.ENABLE_GLOBAL_TURNSTILE_CHECK)
&& !!c.env.CF_TURNSTILE_SITE_KEY
@@ -525,7 +461,6 @@ export default {
getAdminPasswords,
checkIsAdmin,
getEnvStringList,
- sendAdminInternalMail,
isGlobalTurnstileEnabled,
checkCfTurnstile,
checkUserPassword,
diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template
index f57e44d..83e6a8e 100644
--- a/worker/wrangler.toml.template
+++ b/worker/wrangler.toml.template
@@ -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
+# Track read and unread mail. Run the database migration before enabling.
+# ENABLE_MAIL_READ_STATUS = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Allow webhook