feat: add single-mail read status (#1125)

This commit is contained in:
Dream Hunter
2026-08-31 13:11:07 +08:00
committed by GitHub
parent f92b059aac
commit 70206c61ef
32 changed files with 496 additions and 128 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| 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
+1
View File
@@ -0,0 +1 @@
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
+1
View File
@@ -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
);
+3 -2
View File
@@ -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()) {
+1
View File
@@ -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"
+108
View File
@@ -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);
}
});
});
+79
View File
@@ -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();
}
}
});
+4 -2
View File
@@ -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,
+58 -9
View File
@@ -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(() => {
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -461,6 +492,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
@@ -475,7 +507,7 @@ onBeforeUnmount(() => {
<div v-else class="mail-list-scroll">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -536,7 +568,8 @@ onBeforeUnmount(() => {
</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': enableMailReadStatus && row.is_unread === 1 }">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
@@ -567,6 +600,7 @@ onBeforeUnmount(() => {
<n-card :bordered="false" embedded style="overflow: auto;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
@@ -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;
@@ -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') }}
</n-button>
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" :loading="readStatusUpdating"
@click="handleUpdateMailReadStatus">
{{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
:href="getDownloadEmlUrl(mail.raw)">
<template #icon>
+12
View File
@@ -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}秒后刷新"
+1
View File
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableMailReadStatus: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
+10 -1
View File
@@ -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(() => {
</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" :updateMailReadStatus="updateMailReadStatus" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
+39 -1
View File
@@ -56,6 +56,24 @@ const fetchMails = async () => {
}
}
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(() => {
<n-empty :description="t('noMails')" />
</div>
<div v-else>
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
<h3 v-if="currentMail.subject"
:class="{ 'mail-title-unread': openSettings.enableMailReadStatus && currentMail.is_unread === 1 }">
{{ currentMail.subject }}
</h3>
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" />
</div>
</div>
@@ -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;
}
</style>
@@ -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`.
@@ -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.
@@ -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` 认证。
@@ -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。
+1 -1
View File
@@ -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<HonoCustomType>) => {
+9
View File
@@ -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
+1
View File
@@ -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),
+1
View File
@@ -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,
+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',
+5 -45
View File
@@ -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}`);
+99
View File
@@ -0,0 +1,99 @@
import { Context } from "hono";
import { createMimeMessage } from "mimetext";
import { compressText } from "../gzip";
import { getBooleanValue } from "../utils";
let rawMailTableColumns: Set<string> | undefined;
const getRawMailTableColumns = async (
env: Bindings, requiredColumns: string[]
): Promise<Set<string>> => {
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<D1Result> => {
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<string>();
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<HonoCustomType>, toMail: string, subject: string, text: string
): Promise<boolean> => {
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;
}
};
+1
View File
@@ -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)
+18 -1
View File
@@ -20,6 +20,23 @@ const listMails = async (c: Context<HonoCustomType>) => {
);
};
const updateMailReadStatus = async (c: Context<HonoCustomType>) => {
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<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
@@ -117,4 +134,4 @@ const clearSentItems = async (c: Context<HonoCustomType>) => {
return c.json({ success });
};
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
export default { listMails, getMail, updateMailReadStatus, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
+1
View File
@@ -213,6 +213,7 @@ export type RawMailRow = {
raw?: string;
raw_blob?: unknown;
metadata?: string;
is_unread?: number | null;
created_at?: string;
}
+1
View File
@@ -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
-65
View File
@@ -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 = <T = any>(
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<HonoCustomType>, toMail: string, subject: string, text: string
): Promise<boolean> => {
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<HonoCustomType>): 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,
+2
View File
@@ -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