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:
@@ -0,0 +1,256 @@
|
||||
/** authorize 决策矩阵测试(docs/11 §3/§7):accept/reject/temporary_error 语义与拒绝码 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { handleEmailEvent, default as workerMod, type EmailMessage } from "../worker/src/index.ts";
|
||||
import type { WorkerEnv } from "../worker/src/env.ts";
|
||||
import { makeTestEnv, signedAuthorizeRequest, TEST_MAIL } from "./helpers.ts";
|
||||
import { sha256Hex } from "../shared/src/index.ts";
|
||||
import { encryptSecret } from "../console/src/db.ts";
|
||||
|
||||
/** # 成员变量: 决策结果(decision/code) **/
|
||||
interface Decision {
|
||||
decision: "accept" | "reject" | "temporary_error";
|
||||
code?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 调 authorize 接口并解析决策
|
||||
*/
|
||||
async function authorize(
|
||||
t: Awaited<ReturnType<typeof makeTestEnv>>,
|
||||
recipient: string,
|
||||
sender = "sender@example.net",
|
||||
): Promise<{ status: number; body: Decision }> {
|
||||
const resp = await t.app.fetch(await signedAuthorizeRequest(t, recipient, sender), t.env);
|
||||
return { status: resp.status, body: (await resp.json()) as Decision };
|
||||
}
|
||||
|
||||
describe("POST /internal/email/authorize 决策矩阵", () => {
|
||||
test("存在的 catch-all 域名 -> accept(catch-all 下任意未知地址也接收)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const r = await authorize(t, "hello@example.test");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.decision).toBe("accept");
|
||||
const unknown = await authorize(t, "anyone@example.test");
|
||||
expect(unknown.body.decision).toBe("accept");
|
||||
});
|
||||
|
||||
test("不存在邮箱且无有效 catch-all -> reject MAILBOX_NOT_FOUND", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// 删除 catch-all,只留精确地址(禁用的 catch-all 属于 MAILBOX_DISABLED 语义)
|
||||
await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run();
|
||||
const now = new Date().toISOString();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_exact2', 'dom_test1', 'only@example.test', 'active', 0, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const r = await authorize(t, "unknown@example.test");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("MAILBOX_NOT_FOUND");
|
||||
expect(r.body.reason).toBe("Mailbox does not exist");
|
||||
});
|
||||
|
||||
test("禁用邮箱 -> reject MAILBOX_DISABLED", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const now = new Date().toISOString();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_disabled', 'dom_test1', 'off@example.test', 'disabled', 0, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const r = await authorize(t, "off@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("MAILBOX_DISABLED");
|
||||
});
|
||||
|
||||
test("禁用 catch-all 邮箱 -> reject MAILBOX_DISABLED(不再回退)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?").bind(t.mailboxId).run();
|
||||
const r = await authorize(t, "anyone@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("MAILBOX_DISABLED");
|
||||
});
|
||||
|
||||
test("pending 域名 -> reject DOMAIN_DISABLED(Email Workers 未启用)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
await t.env.DB.prepare("UPDATE domains SET status = 'pending' WHERE id = ?").bind(t.domainId).run();
|
||||
const r = await authorize(t, "hello@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("DOMAIN_DISABLED");
|
||||
});
|
||||
|
||||
test("不存在的域名 -> reject DOMAIN_NOT_FOUND", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const r = await authorize(t, "hello@no-such-domain.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("DOMAIN_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Worker 归属校验:credential 所属账号与收件域名必须一致", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const now = new Date().toISOString();
|
||||
// 另一个账号持有同名域名 example.test;默认 credential 不应跨账号匹配
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) VALUES ('acct_other', 'other-account', 'cfacct-2', 'wmailhub-email-acct-other', 'active', '{}', ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
// 默认 credential 收件人域名不存在于本账号(先移除种子域名) -> DOMAIN_NOT_FOUND
|
||||
await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run();
|
||||
await t.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(t.domainId).run();
|
||||
const r = await authorize(t, "hello@example.test");
|
||||
expect(r.body.decision).toBe("reject");
|
||||
expect(r.body.code).toBe("DOMAIN_NOT_FOUND");
|
||||
|
||||
// other 账号的 credential + 它自己的域名 -> accept
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_other', 'cfacct-2', 'acct_other', 'example.test', 'active', ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_other', 'dom_other', '*@example.test', 'active', 1, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const otherSecret = "whs_other_secret_for_signing";
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred_other', 'acct_other', 'wmailhub-email-acct-other', ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(await sha256Hex(otherSecret), await encryptSecret(t.env, otherSecret), otherSecret.slice(-4), now)
|
||||
.run();
|
||||
const resp = await t.app.fetch(
|
||||
await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", {
|
||||
workerId: "wmailhub-email-acct-other",
|
||||
secret: otherSecret,
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(resp.status).toBe(200);
|
||||
expect(((await resp.json()) as Decision).decision).toBe("accept");
|
||||
});
|
||||
|
||||
test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
for (const bad of ["no-at-sign", "@example.test", "a@"]) {
|
||||
const r = await authorize(t, bad);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.decision).toBe("reject");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** # 成员变量: fake console 行为配置 **/
|
||||
interface FakeConsoleOptions {
|
||||
/** authorize 阶段响应(缺省 accept) **/
|
||||
authorize?: (req: Request) => Response | Promise<Response>;
|
||||
/** ingest 阶段响应(缺省 202 成功) **/
|
||||
ingest?: (req: Request) => Response | Promise<Response>;
|
||||
/** 模拟 authorize 阶段网络异常(不可达/超时) **/
|
||||
authorizeThrows?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造 WorkerEnv:fetchImpl 按路径分发到 fake Console
|
||||
*/
|
||||
function makeWorkerEnv(opts: FakeConsoleOptions): WorkerEnv {
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init);
|
||||
const pathname = new URL(req.url).pathname;
|
||||
if (pathname === "/internal/email/authorize") {
|
||||
if (opts.authorizeThrows) throw new Error(opts.authorizeThrows);
|
||||
return await (opts.authorize ?? (() => Response.json({ decision: "accept" })))(req);
|
||||
}
|
||||
if (pathname === "/internal/email/ingest") {
|
||||
return await (opts.ingest ?? (() => Response.json({ ok: true, id: "msg_x", mailbox_id: "mbx_x" }, { status: 202 })))(req);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${pathname}`);
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
CONSOLE_URL: "http://fake-console",
|
||||
WORKER_ID: "wmailhub-email-test",
|
||||
WORKER_SECRET: "whs_fake_secret",
|
||||
fetchImpl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Email Worker 两阶段行为(临时错误语义)", () => {
|
||||
const MAIL_BYTES = new TextEncoder().encode(TEST_MAIL);
|
||||
|
||||
function message(): EmailMessage {
|
||||
return { from: "sender@example.net", to: "test@example.com", raw: MAIL_BYTES };
|
||||
}
|
||||
|
||||
test("accept -> ingest 原文提交成功(信封头 + message/rfc822)", async () => {
|
||||
let ingestBody: Uint8Array | null = null;
|
||||
const wenv = makeWorkerEnv({
|
||||
ingest: async (req) => {
|
||||
expect(req.headers.get("X-WmailHub-Recipient")).toBe("test@example.com");
|
||||
expect(req.headers.get("X-WmailHub-Sender")).toBe("sender@example.net");
|
||||
expect(req.headers.get("Content-Type")).toBe("message/rfc822");
|
||||
ingestBody = new Uint8Array(await req.arrayBuffer());
|
||||
return Response.json({ ok: true, id: "msg_1", mailbox_id: "mbx_1" }, { status: 202 });
|
||||
},
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("accepted");
|
||||
expect(result.email_id).toBe("msg_1");
|
||||
// 原始 MIME 原样提交(未转 JSON 字符串)
|
||||
expect(new TextDecoder().decode(ingestBody ?? new Uint8Array())).toContain("plain body");
|
||||
});
|
||||
|
||||
test("reject -> 不调用 ingest,返回拒绝码(setReject 由 email() 执行)", async () => {
|
||||
const wenv = makeWorkerEnv({
|
||||
authorize: () => Response.json({ decision: "reject", code: "MAILBOX_NOT_FOUND", reason: "Mailbox does not exist" }),
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("rejected");
|
||||
expect(result.code).toBe("MAILBOX_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("Console 5xx -> temporary_error(不能变成 mailbox not found)", async () => {
|
||||
const wenv = makeWorkerEnv({
|
||||
authorize: () => new Response("boom", { status: 500 }),
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("temporary_error");
|
||||
expect(result.reason).toContain("500");
|
||||
});
|
||||
|
||||
test("Console 网络不可达/超时 -> temporary_error", async () => {
|
||||
const wenv = makeWorkerEnv({ authorizeThrows: "connect timeout" });
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("temporary_error");
|
||||
expect(result.reason).toContain("timeout");
|
||||
});
|
||||
|
||||
test("ingest 失败 -> 不伪造业务拒绝(temporary_error)", async () => {
|
||||
const wenv = makeWorkerEnv({
|
||||
ingest: () => new Response("storage down", { status: 500 }),
|
||||
});
|
||||
const result = await handleEmailEvent(wenv, message());
|
||||
expect(result.action).toBe("temporary_error");
|
||||
expect(result.reason).toContain("ingest");
|
||||
});
|
||||
|
||||
test("默认 email() handler:reject 调 setReject;temporary 抛异常", async () => {
|
||||
// reject -> setReject(SMTP 永久拒绝)
|
||||
const wenv1 = makeWorkerEnv({
|
||||
authorize: () =>
|
||||
Response.json({
|
||||
decision: "reject",
|
||||
code: "DOMAIN_DISABLED",
|
||||
reason: "Receiving for this domain is not enabled",
|
||||
}),
|
||||
});
|
||||
const reasons: string[] = [];
|
||||
await workerMod.email({ ...message(), setReject: (r) => reasons.push(r) }, wenv1);
|
||||
expect(reasons).toEqual(["Receiving for this domain is not enabled"]);
|
||||
|
||||
// temporary_error -> 抛异常交 Cloudflare 重试
|
||||
const wenv2 = makeWorkerEnv({ authorizeThrows: "console down" });
|
||||
await expect(workerMod.email(message(), wenv2)).rejects.toThrow("console down");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user