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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
Reference in New Issue
Block a user