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