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
+52 -13
View File
@@ -1,16 +1,16 @@
/** 邮件管线测试:plain/html/附件/多收件人/malformed/超大邮件/TTL/凭证撤销 */
/** 邮件管线测试(docs/11 两阶段):ingest 入库/附件/降级/限额/TTL/orphan/凭据撤销 */
import { describe, expect, test } from "bun:test";
import { ingestEmail, resolveMailbox } from "../console/src/services/email_ingest.ts";
import { runCron } from "../console/src/services/cleanup.ts";
import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
import { MAX_EMAIL_BYTES } from "../shared/src/index.ts";
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
import { makeTestEnv, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
describe("邮件接收管线", () => {
describe("邮件接收管线(POST /internal/email/ingest)", () => {
test("完整解析:subject/from/to/cc/text/html/附件/邮箱关联/expires_at", async () => {
const t = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env);
expect(resp.status).toBe(202);
const data = (await resp.json()) as { id: string; mailbox_id: string; expires_at: string };
expect(data.mailbox_id).toBe(t.mailboxId); // catch-all 匹配
@@ -25,6 +25,7 @@ describe("邮件接收管线", () => {
expect(JSON.parse(email.cc_addresses as string)).toEqual(["bob@example.net"]);
expect(email.text_body).toContain("plain body");
expect(email.html_body).toContain("html body");
expect(email.account_id).toBe(t.accountId);
// expires_at ≈ received_at + 86400s
const delta = (new Date(email.expires_at as string).getTime() - new Date(email.received_at as string).getTime()) / 1000;
expect(delta).toBe(86_400);
@@ -42,7 +43,7 @@ describe("邮件接收管线", () => {
test("malformed 邮件不拒收:降级入库,raw 可取回", async () => {
const t = await makeTestEnv();
const garbage = new TextEncoder().encode("\x00\x01not-a-mime-at-all\r\n###");
const resp = await t.app.fetch(await signedEmailRequest(t, garbage), t.env);
const resp = await t.app.fetch(await signedIngestRequest(t, garbage), t.env);
expect(resp.status).toBe(202);
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
expect(count?.n).toBe(1);
@@ -79,7 +80,7 @@ describe("邮件接收管线", () => {
raw: new TextEncoder().encode(mail),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
@@ -92,7 +93,7 @@ describe("邮件接收管线", () => {
test("超大邮件返回 413(资源耗尽防护)", async () => {
const t = await makeTestEnv();
const big = new Uint8Array(MAX_EMAIL_BYTES + 1);
const resp = await t.app.fetch(await signedEmailRequest(t, big), t.env);
const resp = await t.app.fetch(await signedIngestRequest(t, big), t.env);
expect(resp.status).toBe(413);
});
@@ -124,12 +125,40 @@ describe("邮件接收管线", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
const delta =
(new Date(result.expiresAt).getTime() - new Date(now).getTime()) / 1000;
expect(delta).toBeLessThan(400);
});
test("收件人已不可接收时 ingest 被拒(409,authorize 与 ingest 之间状态变化)", async () => {
const t = await makeTestEnv();
// 禁用 catch-all 邮箱 -> authorize 逻辑不再接受该收件人
await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?")
.bind(t.mailboxId)
.run();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env);
expect(resp.status).toBe(409);
const data = (await resp.json()) as { code: string };
expect(data.code).toBe("MAILBOX_DISABLED");
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
expect(count?.n).toBe(0);
});
test("域名归属其它账号的 ingest 请求被拒(DOMAIN_NOT_FOUND)", async () => {
const t = await makeTestEnv();
// 收件人域名不在 credential 所属账号下
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(
await signedIngestRequest(t, body, { recipient: "hello@other-domain.test" }),
t.env,
);
expect(resp.status).toBe(409);
const data = (await resp.json()) as { code: string };
expect(data.code).toBe("DOMAIN_NOT_FOUND");
});
});
describe("TTL 清理与 orphan", () => {
@@ -139,7 +168,7 @@ describe("TTL 清理与 orphan", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
// 人为把 expires_at 改到过去
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
@@ -175,16 +204,26 @@ describe("TTL 清理与 orphan", () => {
});
});
describe("credential 撤销", () => {
test("撤销后节点请求被 401,且不因重试恢复", async () => {
describe("Worker credential 撤销", () => {
test("撤销后 Worker 请求被 401,且不因重试恢复", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE node_credentials SET status='revoked', revoked_at=? WHERE id='cred1'")
await t.env.DB.prepare("UPDATE worker_credentials SET status='revoked', revoked_at=? WHERE id='wcred1'")
.bind(new Date().toISOString())
.run();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
expect(data.error).toContain("revoked");
});
test("账号 revoked 后 credential 立即失效", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked' WHERE id = ?")
.bind(t.accountId)
.run();
const req = await signedIngestRequest(t, new TextEncoder().encode(TEST_MAIL));
const resp = await t.app.fetch(req, t.env);
expect(resp.status).toBe(401);
});
});