feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)

- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传)
- Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效
- 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id
- 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署
- 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在
- 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程
- 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0
- 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通
- docs 00-10 同步改写;README 更新
This commit is contained in:
bigcode
2026-09-30 13:10:26 +08:00
parent 421cfb2f04
commit fec96e49cc
56 changed files with 2584 additions and 1416 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ const MENU_ITEMS = [
/** # 成员变量: 路由 -> 页面标题/副标题 **/
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
"/": { title: "概览", subtitle: "账号、域名与邮件总览" },
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" },
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" },
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
@@ -85,7 +85,7 @@ export function App() {
<div className="subtitle">{meta.subtitle}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<Tooltip title="设置(全局 Webhook / Ingress / 密码 / API & MCP Keys)">
<Tooltip title="设置(全局 Webhook / 密码 / API & MCP Keys)">
<Button
type="text"
icon={<SettingOutlined />}
+20 -20
View File
@@ -88,8 +88,8 @@ export const health = () => api<{ ok: boolean; name: string; version: string }>(
/** Dashboard 统计 */
export const stats = () =>
api<{
accounts_online: number;
accounts_total: number;
accounts_active: number;
domains: number;
domains_active: number;
mailboxes: number;
@@ -98,59 +98,61 @@ export const stats = () =>
global_webhook: number;
deliveries_pending: number;
oauth_connections: number;
worker_credentials: number;
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
}>("/api/admin/stats");
/** 手动触发 Cron */
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
/* ---- 账号(原节点) ---- */
/* ---- 账号(Cloudflare 账户) ---- */
export interface AccountRow {
id: string;
name: string;
cloudflare_account_id: string | null;
worker_id: string | null;
status: string;
version: string | null;
last_seen_at: string | null;
ingress_url: string;
created_at: string;
}
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
export interface AccountDetail {
account: AccountRow & { enrollment_expires_at: string | null };
account: AccountRow;
steps: Record<string, { status: string; error?: string }>;
oauth: { id: string; account_id: string | null; created_at: string } | null;
domains: { id: string; name: string; status: string; worker_id: string | null }[];
credential: { id: string; worker_id: string; secret_last4: string; status: string; created_at: string } | null;
}
export const getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
export const updateAccount = (id: string, body: { name?: string }) =>
api(`/api/accounts/${id}`, "PATCH", body);
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
export const rotateAccount = (id: string) =>
api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
`/api/accounts/${id}/rotate`,
"POST",
);
export const getAccountIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/accounts/${id}/ingress-secret`);
/** 本地/运维:查看账号当前 Worker credential(secret 用于部署/联调) */
export const getAccountWorkerSecret = (id: string) =>
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
/* ---- OAuth ---- */
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
/** 开始授权:nodeId 缺省 = 自动创建账号 */
export const oauthStart = (nodeId?: string) =>
api<{ url: string; state: string }>(
`/api/oauth/cloudflare/start${nodeId ? `?node_id=${encodeURIComponent(nodeId)}` : ""}`,
);
/** 开始授权(自动创建账号记录;授权后自动接入) */
export const oauthStart = () => api<{ url: string; state: string }>("/api/oauth/cloudflare/start");
export const oauthSimApprove = (state: string) =>
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
api<{ ok: boolean; error?: string; account_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
/* ---- 域名 ---- */
@@ -161,8 +163,8 @@ export interface DomainRow {
setup_error: string | null;
worker_id: string | null;
zone_id: string | null;
node_id: string | null;
node_name: string | null;
account_id: string | null;
account_name: string | null;
webhook_url: string | null;
webhook_headers: string | null;
webhook_has_key: number;
@@ -257,7 +259,6 @@ export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
export interface SettingsInfo {
username: string;
default_ingress_url: string;
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
}
@@ -265,7 +266,6 @@ export const getSettings = () => api<SettingsInfo>("/api/settings");
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
export const putSettings = (body: {
default_ingress_url?: string;
webhook?: { url: string; headers?: Record<string, string>; key?: string };
}) => api("/api/settings", "PUT", body);
@@ -1,6 +1,6 @@
/**
* 设置模态窗口(v1.1.0):
* - 基本设置:全局 Webhook(url/自定义头/key)+ 默认 Node Ingress 地址 + 修改密码
* 设置模态窗口(v2.0.0):
* - 基本设置:全局 Webhook(url/自定义头/key)+ 修改密码
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
*/
import { useCallback, useEffect, useState } from "react";
@@ -71,12 +71,11 @@ interface WebhookFormValues {
}
/** # 函数
* 基本设置:全局 Webhook + 默认 Node Ingress + 修改密码
* 基本设置:全局 Webhook + 修改密码
*/
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
const { message } = App.useApp();
const [webhookForm] = Form.useForm<WebhookFormValues>();
const [ingressForm] = Form.useForm<{ ingress: string }>();
const [pwdForm] = Form.useForm<{
old_password: string;
new_password: string;
@@ -91,7 +90,7 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
});
}, [info, webhookForm]);
/** 保存全局 Webhook 与默认 Ingress */
/** 保存全局 Webhook */
const saveWebhook = async () => {
const values = await webhookForm.validateFields();
let headers: Record<string, string> = {};
@@ -106,7 +105,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
setSaving(true);
try {
await putSettings({
default_ingress_url: info.default_ingress_url,
webhook: { url: values.url.trim(), headers, key: values.key },
});
message.success("全局 Webhook 已保存");
@@ -119,18 +117,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
}
};
/** 保存默认 Node Ingress 地址 */
const saveIngress = async () => {
const values = await ingressForm.validateFields();
try {
await putSettings({ default_ingress_url: values.ingress.trim() });
message.success("默认 Node Ingress 地址已保存");
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
}
};
/** 修改密码 */
const savePassword = async () => {
const values = await pwdForm.validateFields();
@@ -179,20 +165,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
</Button>
</Form>
<Form form={ingressForm} layout="vertical" initialValues={{ ingress: info.default_ingress_url }}>
<Typography.Title level={5}>默认 Node Ingress 地址</Typography.Title>
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口
(Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。
</Typography.Text>
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="ingress" noStyle rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="http://127.0.0.1:18788/ingress/email" />
</Form.Item>
<Button onClick={() => void saveIngress()}>保存</Button>
</Space.Compact>
</Form>
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
<Typography.Title level={5}>修改密码</Typography.Title>
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
+45 -61
View File
@@ -1,4 +1,4 @@
/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */
/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
@@ -17,7 +17,6 @@ import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"
import {
deleteAccount,
getAccount,
getAccountIngressSecret,
listAccounts,
oauthMode,
oauthStart,
@@ -31,17 +30,16 @@ import {
const STATUS_COLOR: Record<string, string> = {
pending_oauth: "orange",
provisioning: "processing",
online: "success",
offline: "default",
active: "success",
error: "error",
};
/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/
/** # 成员变量: 接入步骤中文标签(与后端 PROVISION_STEPS 对应) **/
const STEP_LABEL: Record<string, string> = {
oauth: "Cloudflare OAuth 授权",
identity: "Node identity 与 credential",
discover: "Account / Zone 发现",
domains: "域名与 catch-all 邮箱同步",
worker: "Email Worker 部署与 Worker 凭据",
};
/** # 函数
@@ -92,12 +90,11 @@ export function AccountsPage() {
[items, q],
);
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。
* accountId 缺省 = 服务端自动创建账号。 */
const startOAuth = async (accountId?: string) => {
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */
const startOAuth = async () => {
try {
const mode = (await oauthMode()).mode;
const { url } = await oauthStart(accountId);
const { url } = await oauthStart();
if (mode === "cloudflare") window.location.href = url;
else navigate(url); // /oauth/sim/authorize?state=...
} catch (err) {
@@ -122,7 +119,8 @@ export function AccountsPage() {
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">
点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
并签发 Worker 凭据(Email Worker 直连 Console)。
</span>
</div>
<Space>
@@ -149,29 +147,23 @@ export function AccountsPage() {
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
{
title: "最近心跳",
dataIndex: "last_seen_at",
title: "Email Worker",
dataIndex: "worker_id",
ellipsis: true,
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未部署</span>),
},
{
title: "接入时间",
dataIndex: "created_at",
width: 170,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "操作",
width: 150,
width: 100,
render: (_: unknown, row: AccountRow) => (
<Space>
<Button
size="small"
disabled={row.status !== "pending_oauth"}
onClick={(e) => {
e.stopPropagation();
void startOAuth(row.id);
}}
>
授权
</Button>
<Button
size="small"
danger
@@ -179,7 +171,7 @@ export function AccountsPage() {
e.stopPropagation();
modal.confirm({
title: `删除账号 ${row.name}?`,
content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
content: "将撤销该账号的 Worker 凭据并标记为 revoked;Email Worker 将无法访问 Console。",
onOk: async () => {
await deleteAccount(row.id);
message.success("已删除");
@@ -211,7 +203,7 @@ export function AccountsPage() {
}
/** # 函数
* 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
*/
function AccountDetailDrawer({
detail,
@@ -225,14 +217,10 @@ function AccountDetailDrawer({
onReload: () => void;
}) {
const { message, modal } = App.useApp();
const [ingress, setIngress] = useState("");
const [name, setName] = useState("");
useEffect(() => {
if (detail) {
setIngress(detail.account.ingress_url);
setName(detail.account.name);
}
if (detail) setName(detail.account.name);
}, [detail]);
if (!detail) return null;
@@ -242,7 +230,7 @@ function AccountDetailDrawer({
<Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<div>
<Typography.Title level={5}>安装进度</Typography.Title>
<Typography.Title level={5}>接入进度</Typography.Title>
<Timeline
items={Object.entries(STEP_LABEL).map(([key, label]) => {
const st = steps[key]?.status ?? "pending";
@@ -278,58 +266,54 @@ function AccountDetailDrawer({
)}
</Card>
<Card size="small" title="Ingress 与凭据">
<Card size="small" title="Email Worker 与凭据">
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
<Space.Compact style={{ width: "100%" }}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
style={{ width: 180 }}
placeholder="账号名称"
/>
<Input
value={ingress}
onChange={(e) => setIngress(e.target.value)}
placeholder="Node Ingress 地址"
/>
<Button
onClick={async () => {
await updateAccount(detail.account.id, { name, ingress_url: ingress });
message.success("已保存(新邮件将从新 Ingress 转发)");
await updateAccount(detail.account.id, { name });
message.success("已保存");
onReload();
}}
>
保存
</Button>
</Space.Compact>
<Typography.Text type="secondary" className="mono">
Worker ID:{detail.account.worker_id ?? "-"}
{detail.credential ? ` · 凭据 ****${detail.credential.secret_last4}(有效)` : ""}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: "block" }}>
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
</Typography.Text>
<Space wrap>
<Button
onClick={async () => {
const res = await rotateAccount(detail.account.id);
modal.info({
title: "新的 Enrollment Token(仅显示一次)",
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
title: "新的 Worker Secret(仅显示一次)",
content: (
<div>
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
{res.secret}
</Typography.Paragraph>
<Typography.Text type="secondary">
已自动重新部署域名:{res.redeployed.length ? res.redeployed.join("、") : "无(尚无已配置域名)"}
</Typography.Text>
</div>
),
});
onReload();
}}
>
轮换凭据(生成新 Enrollment Token)
</Button>
<Button
onClick={async () => {
const res = await getAccountIngressSecret(detail.account.id);
modal.info({
title: "Ingress Secret(Email Worker -> Node 共享密钥)",
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
});
}}
>
查看 Ingress Secret
轮换 Worker 凭据
</Button>
</Space>
<Typography.Text type="secondary" className="mono">
enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
</Typography.Text>
</Space>
</Card>
</Space>
+1 -1
View File
@@ -49,7 +49,7 @@ export function DashboardPage() {
<Row gutter={[16, 16]}>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="账号在线" value={data?.accounts_online ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
<Statistic title="Cloudflare 账号" value={data?.accounts_active ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
+3 -3
View File
@@ -58,7 +58,7 @@ export function DomainsPage() {
() =>
items.filter(
(row) =>
(!accountId || row.node_id === accountId) &&
(!accountId || row.account_id === accountId) &&
row.name.toLowerCase().includes(q.trim().toLowerCase()),
),
[items, q, accountId],
@@ -110,7 +110,7 @@ export function DomainsPage() {
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
columns={[
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
{ title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
{
title: "Email Worker",
@@ -174,7 +174,7 @@ export function DomainsPage() {
<span>
<span className="mono">{editing.name}</span>
<Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
{editing.node_name ?? "-"}
{editing.account_name ?? "-"}
</Typography.Text>
</span>
) : (
+1 -1
View File
@@ -20,7 +20,7 @@ export function OAuthSimPage() {
const res = await oauthSimApprove(state);
if (res.ok) {
message.success("Cloudflare 授权与自动安装完成");
navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
navigate(`/accounts?provision=${encodeURIComponent(res.account_id)}`, { replace: true });
} else {
message.error(res.error ?? "安装失败");
navigate("/accounts", { replace: true });