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
+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>