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:
+12
-6
@@ -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
@@ -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
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user