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
+7 -4
View File
@@ -32,8 +32,11 @@ export const WEBHOOK_HEADERS = {
signature: "X-WmailHub-Signature",
} as const;
/** # 成员变量: Worker -> Node ingress 共享密钥请求头 **/
export const INGRESS_SECRET_HEADER = "X-WmailHub-Ingress-Secret";
/** # 成员变量: Email Worker 本地测试入口请求头(Worker secret 验证) **/
export const WORKER_TEST_SECRET_HEADER = "X-WmailHub-Test-Secret";
/** # 成员变量: Node 心跳间隔(毫秒) **/
export const NODE_HEARTBEAT_INTERVAL_MS = 60_000;
/** # 成员变量: Worker -> Console 信封传递请求头 **/
export const WORKER_ENVELOPE_HEADERS = {
recipient: "X-WmailHub-Recipient",
sender: "X-WmailHub-Sender",
} as const;
+146 -33
View File
@@ -1,37 +1,50 @@
/**
* 部署到 Cloudflare 的 Email Worker 脚本模板。
* 部署到 Cloudflare 的 Email Worker 脚本模板(docs/11)。
*
* Console 在自动安装 Node 时,通过 Cloudflare API 把该脚本上传为
* Email Worker,并用占位符替换注入 Node ingress 地址与 ingress secret。
* worker/(本地开发版)与该模板保持同一转发逻辑。
* Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时,通过 Cloudflare API
* 把该脚本上传为账号级 Email Worker,并以占位符注入三组配置:
* __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标)
* __WORKER_ID__ Worker 身份(与 WorkerCredential.worker_id 一致)
* __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥)
*
* 邮件流程:authorize(接受/拒绝/临时错误) -> accept 时 ingest 原始 MIME -> reject 时 setReject。
* worker/(本地开发版)与该模板保持同一逻辑。
*/
export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning)
// Cloudflare Email Routing -> Email Worker -> Node ingress
const INGRESS_URL = "__NODE_INGRESS_URL__";
const INGRESS_SECRET = "__NODE_INGRESS_SECRET__";
// Cloudflare Email Routing -> Email Worker -> WmailHub Console(docs/11)
const CONSOLE_URL = "__CONSOLE_URL__";
const WORKER_ID = "__WORKER_ID__";
const WORKER_SECRET = "__WORKER_SECRET__";
export default {
async email(message, env, ctx) {
const raw = await new Response(message.raw).arrayBuffer();
await forward(raw, message.from, message.to);
const result = await handleEmail(env, message);
if (result.action === "rejected") {
message.setReject(result.reason || "rejected by WmailHub");
return;
}
if (result.action === "temporary_error") {
// 不能伪造永久拒绝;抛出异常交给 Cloudflare 邮件机制处理后续重试
throw new Error(result.reason || "wmailhub console temporary error");
}
},
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, service: "wmailhub-email-worker" });
}
// 本地/联调测试入口:与 email handler 走同一条转发链路
// 本地/联调测试入口:与 email handler 走同一条两阶段链路
if (request.method === "POST" && url.pathname === "/__email_test") {
if (request.headers.get("X-WmailHub-Ingress-Secret") !== INGRESS_SECRET) {
if (request.headers.get("X-WmailHub-Test-Secret") !== WORKER_SECRET) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org";
const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com";
const raw = await request.arrayBuffer();
try {
await forward(raw, from, to);
return Response.json({ ok: true });
const result = await handleEmail(env, { from, to, raw: new Response(raw).body });
return Response.json(result, { status: result.action === "accepted" ? 200 : 502 });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
@@ -40,31 +53,131 @@ export default {
},
};
async function forward(raw, from, to) {
const resp = await fetch(INGRESS_URL, {
method: "POST",
headers: {
"Content-Type": "message/rfc822",
"X-WmailHub-Ingress-Secret": INGRESS_SECRET,
"X-WmailHub-Mail-From": from,
"X-WmailHub-Rcpt-To": to,
},
body: raw,
});
if (!resp.ok) {
throw new Error("node ingress responded " + resp.status);
async function hmac(secret, message) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function sha256Hex(data) {
const digest = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function randomNonce() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY_SHA256
async function signedHeaders(method, path, body) {
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomNonce();
const bh = await sha256Hex(body);
const signature = await hmac(WORKER_SECRET, [method, path, timestamp, nonce, bh].join("\n"));
return {
"Authorization": "WmailHub-Worker " + WORKER_ID,
"X-WmailHub-Timestamp": timestamp,
"X-WmailHub-Nonce": nonce,
"X-WmailHub-Body-SHA256": bh,
"X-WmailHub-Signature": signature,
"X-WmailHub-Protocol": "1",
};
}
// 第一阶段:询问 Console 是否接收该邮件(SMTP 决策)
async function authorize(env, recipient, sender) {
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender }));
const headers = await signedHeaders("POST", "/internal/email/authorize", body);
let resp;
try {
resp = await fetch(CONSOLE_URL + "/internal/email/authorize", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body,
});
} catch (err) {
// Console 不可达:临时错误,不能当作邮箱不存在
return { action: "temporary_error", reason: "console unreachable: " + err };
}
if (resp.status >= 500) return { action: "temporary_error", reason: "console responded " + resp.status };
if (!resp.ok) return { action: "temporary_error", reason: "console responded " + resp.status };
let data;
try {
data = await resp.json();
} catch {
return { action: "temporary_error", reason: "console returned non-json" };
}
if (data.decision === "accept") return { action: "accepted" };
if (data.decision === "reject") {
return { action: "rejected", reason: data.reason || data.code || "rejected by WmailHub", code: data.code };
}
return { action: "temporary_error", reason: "console decision: " + (data.decision || "unknown") };
}
// 第二阶段:把原始 MIME 交给 Console 入库(D1 + R2 + Webhook)
async function ingest(env, raw, recipient, sender) {
const body = new Uint8Array(raw);
const headers = await signedHeaders("POST", "/internal/email/ingest", body);
let resp;
try {
resp = await fetch(CONSOLE_URL + "/internal/email/ingest", {
method: "POST",
headers: {
...headers,
"Content-Type": "message/rfc822",
"X-WmailHub-Recipient": recipient,
"X-WmailHub-Sender": sender,
},
body,
});
} catch (err) {
console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", error: String(err) }));
return { action: "temporary_error", reason: "ingest unreachable: " + err };
}
if (!resp.ok) {
const text = await resp.text();
// ingest 失败不伪造业务拒绝;记录并按 Cloudflare 的重试行为处理
console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", status: resp.status, body: text.slice(0, 300) }));
return { action: "temporary_error", reason: "ingest responded " + resp.status };
}
let data = {};
try {
data = await resp.json();
} catch {
// ignore
}
return { action: "accepted", email_id: data.id, mailbox_id: data.mailbox_id };
}
// 完整两阶段处理(与部署模板共同逻辑)
async function handleEmail(env, message) {
const recipient = message.to;
const sender = message.from;
const decision = await authorize(env, recipient, sender);
if (decision.action !== "accepted") return decision;
const raw = await new Response(message.raw).arrayBuffer();
return await ingest(env, raw, recipient, sender);
}
`;
/** # 函数
* 用实际 Node ingress 配置渲染 Email Worker 脚本
* 参数: ingressUrl - Node 的 Email Worker ingress 完整 URL;ingressSecret - Worker -> Node 共享密钥
* 用实际 Console 地址与 Worker 凭据渲染 Email Worker 脚本
* 参数: consoleUrl - Console 对外地址;workerId - Worker 身份;workerSecret - credential secret
* 返回值: 可直接上传 Cloudflare 的脚本字符串
*/
export function renderEmailWorkerScript(ingressUrl: string, ingressSecret: string): string {
return EMAIL_WORKER_SCRIPT.replace("__NODE_INGRESS_URL__", ingressUrl).replace(
"__NODE_INGRESS_SECRET__",
ingressSecret,
);
export function renderEmailWorkerScript(
consoleUrl: string,
workerId: string,
workerSecret: string,
): string {
return EMAIL_WORKER_SCRIPT.replace("__CONSOLE_URL__", consoleUrl)
.replace("__WORKER_ID__", workerId)
.replace("__WORKER_SECRET__", workerSecret);
}
+2 -2
View File
@@ -14,7 +14,7 @@ function randomHex(bytes: number): string {
/** # 函数
* 生成带前缀的业务 ID,例如 node_a1b2c3d4e5f60718
* 参数: prefix - 业务前缀(node/dom/mbx/msg/att/whk/dlv/oauth/key/state/sim)
* 参数: prefix - 业务前缀(acct/dom/mbx/msg/att/dlv/oauth/key/state/sim)
*/
export function newId(prefix: string): string {
return `${prefix}_${randomHex(8)}`;
@@ -22,7 +22,7 @@ export function newId(prefix: string): string {
export const ids = {
user: () => newId("usr"),
node: () => newId("node"),
account: () => newId("acct"),
domain: () => newId("dom"),
mailbox: () => newId("mbx"),
email: () => newId("msg"),
+36 -30
View File
@@ -1,13 +1,19 @@
/**
* WmailHub Node <-> Console 通信协议(协议版本 1)
* WmailHub Worker <-> Console 内部协议(docs/04,协议版本 1)
*
* 签名规则(docs/04_NODE_PROTOCOL.md):
* Email Worker 调用 Console 的 /internal/email/authorize 与 /internal/email/ingest,
* 每个请求都必须签名:
* body_hash = SHA256(raw_body)
* canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash
* signature = HMAC-SHA256(credential_secret, canonical)
* signature = HMAC-SHA256(worker_credential_secret, canonical)
*
* 请求头:
* Authorization: WmailHub-Worker <worker_id>
* X-WmailHub-Timestamp / X-WmailHub-Nonce / X-WmailHub-Body-SHA256 /
* X-WmailHub-Signature / X-WmailHub-Protocol
*
* Console 端拒绝:timestamp 超窗(默认 5 分钟)、重复 nonce、错误 signature、
* 错误 body hash、已撤销 credential。
* 错误 body hash、已撤销 credential、已失效账号。
*/
import { hmacSha256Hex, sha256Hex, timingSafeEqualHex } from "./crypto.ts";
@@ -18,6 +24,10 @@ export const PROTOCOL_VERSION = "1";
/** # 成员变量: timestamp 允许窗口(秒),默认 5 分钟 **/
export const TIMESTAMP_WINDOW_SECONDS = 300;
/** # 成员变量: Console 内部邮件 API 路径(docs/11 §7/§9) **/
export const EMAIL_AUTHORIZE_PATH = "/internal/email/authorize";
export const EMAIL_INGEST_PATH = "/internal/email/ingest";
/** # 函数
* 计算请求体的 SHA-256 hex(body hash)
*/
@@ -54,13 +64,14 @@ export async function computeSignature(secret: string, c: SignatureComponents):
}
/** # 函数
* Node 端:为发往 Console 的请求生成全部签名请求头
* 参数: secret - node credential secret;nodeId - 节点 ID;method/url 同 HTTP 语义;body - 原始请求体
* Worker 端:为发往 Console 内部 API 的请求生成全部签名请求头
* 参数: secret - worker credential secret;workerId - Email Worker 身份(脚本名);
* method/url 同 HTTP 语义;body - 原始请求体
* 返回值: 可直接放入 fetch headers 的对象
*/
export async function signNodeRequest(
export async function signWorkerRequest(
secret: string,
nodeId: string,
workerId: string,
method: string,
url: string,
body: Uint8Array,
@@ -77,7 +88,7 @@ export async function signNodeRequest(
bodyHash: bh,
});
return {
"Authorization": `WmailHub-Node ${nodeId}`,
"Authorization": `WmailHub-Worker ${workerId}`,
"X-WmailHub-Timestamp": timestamp,
"X-WmailHub-Nonce": nonce,
"X-WmailHub-Body-SHA256": bh,
@@ -86,9 +97,9 @@ export async function signNodeRequest(
};
}
/** # 成员变量: verifyNodeRequest 的输入 **/
export interface VerifyNodeRequestInput {
/** 请求方声明的 credential secret(Console 从库中按 node_id 取出) **/
/** # 成员变量: verifyWorkerRequest 的输入 **/
export interface VerifyWorkerRequestInput {
/** 请求方声明的 credential secret(Console 从库中按 worker_id 取出) **/
secret: string;
/** HTTP 方法 **/
method: string;
@@ -96,26 +107,28 @@ export interface VerifyNodeRequestInput {
url: string;
/** 原始请求体 **/
body: Uint8Array;
/** 请求头(大小写不敏感,由调用方传入 Headers 或普通对象) **/
/** 请求头(大小写不敏感,由调用方传入 Headers) **/
headers: Headers;
/** 当前 unix 秒,测试可注入 **/
now?: number;
}
/** # 成员变量: verifyNodeRequest 的结果 **/
export type VerifyNodeResult =
| { ok: true; timestamp: number; nonce: string }
/** # 成员变量: verifyWorkerRequest 的结果 **/
export type VerifyWorkerResult =
| { ok: true; workerId: string; timestamp: number; nonce: string }
| { ok: false; status: 400 | 401; error: string };
/** # 函数
* Console 端:校验 Node 请求头签名完整性(不含 nonce 防重放与 credential 撤销检查,
* 这两项由调用方结合数据库完成:nonce 用唯一索引入库,credential 状态查询后拒绝)。
* Console 端:校验 Worker 请求头签名完整性(不含 nonce 防重放、credential/账号状态检查,
* 这三项由调用方结合数据库完成:nonce 用唯一索引入库,credential/账号状态查询后拒绝)。
*/
export async function verifyNodeRequest(input: VerifyNodeRequestInput): Promise<VerifyNodeResult> {
export async function verifyWorkerRequest(input: VerifyWorkerRequestInput): Promise<VerifyWorkerResult> {
const h = input.headers;
const auth = h.get("Authorization") ?? "";
const m = /^WmailHub-Node\s+(.+)$/.exec(auth);
if (!m) return { ok: false, status: 401, error: "missing node authorization" };
const m = /^WmailHub-Worker\s+(.+)$/.exec(auth);
if (!m) return { ok: false, status: 401, error: "missing worker authorization" };
const workerId = m[1].trim();
if (!workerId) return { ok: false, status: 401, error: "empty worker id" };
const timestamp = h.get("X-WmailHub-Timestamp") ?? "";
const nonce = h.get("X-WmailHub-Nonce") ?? "";
@@ -145,17 +158,10 @@ export async function verifyNodeRequest(input: VerifyNodeRequestInput): Promise<
nonce,
bodyHash: bodySha,
});
if (!timingSafeEqualEqual(expected, signature)) {
if (!timingSafeEqualHex(expected, signature)) {
return { ok: false, status: 401, error: "invalid signature" };
}
return { ok: true, timestamp: ts, nonce };
}
function timingSafeEqualEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
return { ok: true, workerId, timestamp: ts, nonce };
}
/** # 函数