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
+12 -6
View File
@@ -1,7 +1,13 @@
/** Email Worker 环境变量(wrangler vars 注入) */
export interface ForwarderEnv {
/** Node ingress 完整 URL */
NODE_INGRESS_URL: string;
/** Worker -> Node 共享密钥 */
NODE_INGRESS_SECRET: string;
/** Email Worker 环境变量(docs/11;wrangler vars 或本地进程 env 注入) **/
/** # 成员变量: Email Worker 运行环境绑定 **/
export interface WorkerEnv {
/** Console 对外地址(Worker -> Console 的目标) **/
CONSOLE_URL: string;
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
WORKER_ID: string;
/** Worker credential secret(HMAC 签名密钥) **/
WORKER_SECRET: string;
/** 测试注入:替换 fetch 实现(单测用;缺省用全局 fetch) **/
fetchImpl?: typeof fetch;
}
+154 -38
View File
@@ -1,72 +1,188 @@
/**
* WmailHub Email Worker(部署入口):
* Cloudflare Email Routing -> email() handler -> Node ingress。
* 只做转发,不存业务数据(docs/01);持有 Node ingress secret(docs/08)。
* WmailHub Email Worker(docs/11):
* Cloudflare Email Routing -> email() handler -> Console /internal/email/authorize + /ingest
* 只做接收转发链路的"SMTP 决策 + 原文提交",不存业务数据;
* 业务判断全部由 Console 决定:
* accept -> ingest 原始 MIME
* reject -> message.setReject()(SMTP 永久拒绝)
* temporary_error -> 抛异常,交给 Cloudflare 邮件机制处理重试(绝不伪装成邮箱不存在)
*
* 生产部署:Console provisioning 自动上传脚本;或 `bunx wrangler deploy`(worker/)。
* 生产部署:Console 自动上传账号级 Worker 脚本(shared/src/email-worker-template.ts);
* 本地联调:`bun worker/src/local.ts` 起 HTTP 服务,POST /__email_test 模拟邮件事件。
*/
import type { ForwarderEnv } from "./env.ts";
import type { WorkerEnv } from "./env.ts";
import {
signWorkerRequest,
EMAIL_AUTHORIZE_PATH,
EMAIL_INGEST_PATH,
WORKER_TEST_SECRET_HEADER,
WORKER_ENVELOPE_HEADERS,
PROTOCOL_VERSION,
} from "../../shared/src/index.ts";
/** # 成员变量: Cloudflare Email Message 最小接口 **/
/** # 成员变量: Cloudflare Email Message 最小接口(setReject 仅真实事件提供) **/
export interface EmailMessage {
from: string;
to: string;
raw: ReadableStream;
raw: ReadableStream | ArrayBuffer | Uint8Array;
/** Cloudflare Email Worker 提供的 SMTP 拒绝方法;本地模拟时记录调用 */
setReject?: (reason: string) => void;
}
/** # 成员变量: 两阶段处理结果 **/
export interface HandleEmailResult {
/** accepted: 已 ingest;rejected: 已拒绝(Worker 应 setReject);temporary_error: 临时失败 */
action: "accepted" | "rejected" | "temporary_error";
/** reject 原因 / 临时错误说明(不含内部敏感信息) **/
reason?: string;
/** reject 的业务状态码(DOMAIN_NOT_FOUND 等) **/
code?: string;
/** ingest 成功时的邮件 id / mailbox id **/
email_id?: string;
mailbox_id?: string | null;
}
/** # 函数
* 转发 raw MIME 到 Node ingress
* 第一阶段:询问 Console 是否接收该邮件(SMTP 决策)
* Console 超时/网络失败/5xx/无法解析 -> temporary_error(不能解释成邮箱不存在)
*/
export async function forward(env: ForwarderEnv, raw: ArrayBuffer, from: string, to: string): Promise<Response> {
const resp = await fetch(env.NODE_INGRESS_URL, {
method: "POST",
headers: {
"Content-Type": "message/rfc822",
"X-WmailHub-Ingress-Secret": env.NODE_INGRESS_SECRET,
"X-WmailHub-Mail-From": from,
"X-WmailHub-Rcpt-To": to,
},
body: raw,
});
return resp;
}
/** # 函数
* 处理 Email Worker 邮件事件(与测试入口共用)
*/
export async function handleEmailEvent(env: ForwarderEnv, message: EmailMessage): Promise<void> {
const raw = await new Response(message.raw).arrayBuffer();
const resp = await forward(env, raw, message.from, message.to);
if (!resp.ok) {
throw new Error(`node ingress responded ${resp.status}`);
export async function authorizeEmail(env: WorkerEnv, recipient: string, sender: string): Promise<HandleEmailResult> {
const doFetch = env.fetchImpl ?? fetch;
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender }));
const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, body);
let resp: Response;
try {
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: body as unknown as BodyInit,
});
} catch (err) {
return { action: "temporary_error", reason: `console unreachable: ${err instanceof Error ? err.message : String(err)}` };
}
if (!resp.ok) {
// 5xx/4xx 都不能当作邮箱不存在:统一按临时错误处理(docs/11 §15)
return { action: "temporary_error", reason: `console responded ${resp.status}` };
}
let data: { decision?: string; code?: string; reason?: string };
try {
data = (await resp.json()) as { decision?: string; code?: string; reason?: string };
} 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"}` };
}
/** # 函数
* 第二阶段:把 raw MIME 提交给 Console 入库(D1 + R2 + Webhook)
* 失败不伪造业务拒绝:记录后按 temporary 处理(由 Cloudflare 重试机制兜底)。
*/
export async function ingestEmail(
env: WorkerEnv,
raw: ArrayBuffer | Uint8Array,
recipient: string,
sender: string,
): Promise<HandleEmailResult> {
const doFetch = env.fetchImpl ?? fetch;
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, bytes);
let resp: Response;
try {
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, {
method: "POST",
headers: {
...headers,
"Content-Type": "message/rfc822",
[WORKER_ENVELOPE_HEADERS.recipient]: recipient,
[WORKER_ENVELOPE_HEADERS.sender]: sender,
},
body: bytes as unknown as BodyInit,
});
} catch (err) {
console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", stage: "fetch", error: String(err) }));
return { action: "temporary_error", reason: `ingest unreachable: ${err instanceof Error ? err.message : String(err)}` };
}
if (!resp.ok) {
const text = await resp.text();
// ingest 失败不伪造业务拒绝(docs/11 §15);记录后交 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: { id?: string; mailbox_id?: string | null } = {};
try {
data = (await resp.json()) as { id?: string; mailbox_id?: string | null };
} catch {
// 非 JSON 视为成功(2xx)
}
return { action: "accepted", email_id: data.id, mailbox_id: data.mailbox_id };
}
/** # 函数
* 处理 Email Worker 邮件事件:完整两阶段(authorize -> ingest)
*/
export async function handleEmailEvent(env: WorkerEnv, message: EmailMessage): Promise<HandleEmailResult> {
const decision = await authorizeEmail(env, message.to, message.from);
if (decision.action !== "accepted") return decision;
let raw: ArrayBuffer;
if (message.raw instanceof ArrayBuffer) raw = message.raw;
else if (message.raw instanceof Uint8Array) raw = message.raw.buffer.slice(message.raw.byteOffset, message.raw.byteOffset + message.raw.byteLength) as ArrayBuffer;
else raw = await new Response(message.raw).arrayBuffer();
const ingestResult = await ingestEmail(env, raw, message.to, message.from);
return ingestResult;
}
/** # 成员变量: Workers 导出(email + fetch) **/
export default {
async email(message: EmailMessage, env: ForwarderEnv): Promise<void> {
await handleEmailEvent(env, message);
async email(message: EmailMessage, env: WorkerEnv): Promise<void> {
const result = await handleEmailEvent(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: Request, env: ForwarderEnv): Promise<Response> {
async fetch(request: Request, env: WorkerEnv): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, service: "wmailhub-email-worker" });
return Response.json({ ok: true, service: "wmailhub-email-worker", protocol: PROTOCOL_VERSION });
}
if (request.method === "POST" && url.pathname === "/__email_test") {
if (request.headers.get("X-WmailHub-Ingress-Secret") !== env.NODE_INGRESS_SECRET) {
// 本地/联调测试入口:与 email handler 走同一条两阶段链路
if (request.headers.get(WORKER_TEST_SECRET_HEADER) !== env.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();
const rejectReasons: string[] = [];
const message: EmailMessage = {
from,
to,
raw,
setReject: (reason) => rejectReasons.push(reason),
};
try {
const resp = await forward(env, raw, from, to);
return Response.json({ ok: resp.ok, status: resp.status }, { status: resp.ok ? 200 : 502 });
const result = await handleEmailEvent(env, message);
if (rejectReasons.length > 0) {
// 模拟 SMTP 永久拒绝(生产由 message.setReject 完成)
return Response.json({ action: "rejected", reason: rejectReasons[0] }, { status: 200 });
}
return Response.json(result, { status: result.action === "accepted" ? 200 : 502 });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
return Response.json({ action: "temporary_error", error: String(err) }, { status: 502 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
+11 -9
View File
@@ -1,21 +1,22 @@
/**
* Email Worker 本地联调服务:把部署入口的 fetch handler 跑在 Bun 上。
* Email Worker 本地联调服务:把部署入口的 fetch handler 跑在 Bun 上(docs/11)。
* bun worker/src/local.ts
* 测试:POST http://127.0.0.1:18789/__email_test
* headers: X-WmailHub-Ingress-Secret / X-WmailHub-Test-From / X-WmailHub-Test-To
* headers: X-WmailHub-Test-Secret / X-WmailHub-Test-From / X-WmailHub-Test-To
* body: raw MIME
*/
import worker from "./index.ts";
import type { ForwarderEnv } from "./env.ts";
import type { WorkerEnv } from "./env.ts";
const env: ForwarderEnv = {
NODE_INGRESS_URL: process.env.NODE_INGRESS_URL ?? "http://127.0.0.1:18788/ingress/email",
NODE_INGRESS_SECRET: process.env.NODE_INGRESS_SECRET ?? "",
const env: WorkerEnv = {
CONSOLE_URL: process.env.CONSOLE_URL ?? "http://127.0.0.1:18787",
WORKER_ID: process.env.WORKER_ID ?? "",
WORKER_SECRET: process.env.WORKER_SECRET ?? "",
};
if (!env.NODE_INGRESS_SECRET) {
console.error("missing NODE_INGRESS_SECRET env");
if (!env.WORKER_ID || !env.WORKER_SECRET) {
console.error("missing WORKER_ID / WORKER_SECRET env");
process.exit(1);
}
@@ -32,7 +33,8 @@ console.log(
lvl: "info",
msg: "email worker started",
port: PORT,
node_ingress: env.NODE_INGRESS_URL,
worker_id: env.WORKER_ID,
console_url: env.CONSOLE_URL,
}),
);