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
+27 -49
View File
@@ -1,26 +1,25 @@
/**
* 本地开发一键启动:console + node + email worker。
* 本地开发一键启动:console + email worker(docs/11,Node 已移除)。
* bun run dev # 常规启动(复用既有数据)
* bun run dev -- --reset # 清空本地数据后全新安装
* bun run dev -- --reset # 清空本地数据后全新接入
*
* 流程:
* 1. 构建前端(vite build)
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
* 3. 确保存在本地账号(无则 OAuth sim 自动创建账号 + 同步域名),并逐域名配置 Email Workers
* 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘)
* 5. 启动 Email Worker(:18789,转发到 Node)
* 6. Ctrl-C 一起退出
* 3. 确保存在账号(无则 OAuth sim 自动创建账号),并逐域名配置 Email Workers
* (域名 setup 会部署账号级 Email Worker + 签发 WorkerCredential)
* 4. 启动 Email Worker(:18789,authorize/ingest 直连 Console)
* 5. Ctrl-C 一起退出
*/
const ROOT = new URL("..", import.meta.url).pathname;
const CONSOLE_PORT = 18787;
const NODE_PORT = 18788;
const WORKER_PORT = 18789;
const BASE = `http://127.0.0.1:${CONSOLE_PORT}`;
const reset = process.argv.includes("--reset");
if (reset) {
await Bun.$`rm -rf ${ROOT}console/data ${ROOT}node/config.json`.quiet();
await Bun.$`rm -rf ${ROOT}console/data`.quiet();
console.log("[dev] 本地数据已重置");
}
@@ -79,24 +78,16 @@ if (!loginResp.ok) throw new Error("admin 登录失败");
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
// 3. 确保本地账号存在(无则走 OAuth 自动创建);拿到 ingress secret 与 enrollment token
// 3. 确保账号存在(无则走 OAuth 自动创建)
const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders });
const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items;
let nodeId: string;
let ingressSecret: string;
let enrollmentToken: string | null = null;
let accountId: string;
if (existing.length > 0) {
nodeId = existing[0].id;
ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
// 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment
const rotated = (await (
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token;
console.log(`[dev] 复用既有账号 ${nodeId}`);
accountId = existing[0].id;
console.log(`[dev] 复用既有账号 ${accountId}`);
} else {
// 添加账号:OAuth start 不带 node_id,服务端自动创建账号
// 添加账号:OAuth start 自动创建账号,授权后自动接入
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders });
const { state } = (await startResp.json()) as { state: string };
const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, {
@@ -104,21 +95,16 @@ if (existing.length > 0) {
headers: authHeaders,
body: JSON.stringify({ state }),
});
const approveRes = (await approve.json()) as { ok: boolean; error?: string; node_id: string };
const approveRes = (await approve.json()) as { ok: boolean; error?: string; account_id: string };
if (!approveRes.ok) throw new Error(`自动创建账号失败:${approveRes.error}`);
nodeId = approveRes.node_id;
ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
const rotated = (await (
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token;
console.log(`[dev] OAuth(sim)已创建账号 ${nodeId} 并同步域名`);
accountId = approveRes.account_id;
console.log(`[dev] OAuth(sim)已创建账号 ${accountId} 并同步域名`);
}
// 4. 域名同步后逐域名配置 Email Workers(sim 模式记录;幂等)
// 4. 域名同步后逐域名配置 Email Workers(部署 Worker + Routing + catch-all;幂等)
const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders });
const domains = ((await domainsResp.json()) as { items: { id: string; name: string; status: string }[] }).items;
for (const d of domains.filter((x) => x.status !== "active")) {
const domains = ((await domainsResp.json()) as { items: { id: string; name: string; account_id: string | null; status: string }[] }).items;
for (const d of domains.filter((x) => x.account_id === accountId && x.status !== "active")) {
const setup = (await (
await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders })
).json()) as { ok: boolean; error?: string };
@@ -126,21 +112,14 @@ for (const d of domains.filter((x) => x.status !== "active")) {
console.log(`[dev] 域名 ${d.name} Email Workers 已配置`);
}
// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份)
const nodeEnv: Record<string, string> = {
WMAILHUB_CONSOLE_URL: BASE,
WMAILHUB_INGRESS_SECRET: ingressSecret,
WMAILHUB_PORT: String(NODE_PORT),
};
if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken;
spawn("node", ["node/src/main.ts"], ROOT, nodeEnv);
await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node");
console.log("[dev] node ready");
// 6. Email Worker
// 5. Email Worker(authorize/ingest 直连 Console;凭据来自 WorkerCredential)
const secretResp = (await (
await fetch(`${BASE}/api/accounts/${accountId}/worker-secret`, { headers: authHeaders })
).json()) as { worker_id: string; secret: string };
spawn("worker", ["worker/src/local.ts"], ROOT, {
NODE_INGRESS_URL: `http://127.0.0.1:${NODE_PORT}/ingress/email`,
NODE_INGRESS_SECRET: ingressSecret,
CONSOLE_URL: BASE,
WORKER_ID: secretResp.worker_id,
WORKER_SECRET: secretResp.secret,
WMAILHUB_WORKER_PORT: String(WORKER_PORT),
});
await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker");
@@ -149,10 +128,9 @@ console.log("[dev] email worker ready");
console.log("");
console.log("======== WmailHub 本地环境已就绪 ========");
console.log(` Console : ${BASE} (admin / admin)`);
console.log(` Node : http://127.0.0.1:${NODE_PORT}/__health`);
console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health`);
console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health (worker_id=${secretResp.worker_id})`);
console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\");
console.log(" -H 'X-WmailHub-Ingress-Secret: <secret>' \\");
console.log(" -H 'X-WmailHub-Test-Secret: <worker secret>' \\");
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
console.log("==========================================");