feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)

- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
This commit is contained in:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
/** 邮件管线测试:plain/html/附件/多收件人/malformed/超大邮件/TTL/凭证撤销 */
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";
describe("邮件接收管线", () => {
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);
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 匹配
const email = (await t.env.DB.prepare("SELECT * FROM emails WHERE id = ?").bind(data.id).first()) as Record<
string,
unknown
>;
expect(email.subject).toBe("Test Mail");
expect(email.from_address).toBe("alice@example.org");
expect(JSON.parse(email.to_addresses as string)).toEqual(["hello@example.test"]);
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");
// 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);
const atts = await t.env.DB.prepare("SELECT * FROM email_attachments WHERE email_id = ?").bind(data.id).all();
expect(atts.results.length).toBe(1);
expect(atts.results[0].filename).toBe("a.txt");
// raw 已入 R2
const obj = await t.env.R2.get(email.raw_storage_key as string);
expect(obj).not.toBeNull();
const attObj = await t.env.R2.get(atts.results[0].storage_key as string);
expect(await attObj?.text()).toContain("attachment-content");
});
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);
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);
const email = await t.env.DB.prepare("SELECT * FROM emails").first<Record<string, unknown>>();
expect(email?.subject).toBe("(unparsable mail)");
const obj = await t.env.R2.get(email?.raw_storage_key as string);
expect(obj).not.toBeNull();
});
test("超大附件被跳过,邮件本身照常入库(限额防护)", async () => {
const t = await makeTestEnv();
const { MAX_ATTACHMENT_BYTES } = await import("../shared/src/index.ts");
const bigB64 = Buffer.from(new Uint8Array(MAX_ATTACHMENT_BYTES + 1)).toString("base64");
const mail = [
"From: a@b.c",
"To: hello@example.test",
"Subject: big att",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=\"BB\"",
"",
"--BB",
"Content-Type: application/octet-stream; name=\"big.bin\"",
"Content-Disposition: attachment; filename=\"big.bin\"",
"Content-Transfer-Encoding: base64",
"",
bigB64,
"--BB",
"Content-Type: text/plain",
"",
"body",
"--BB--",
].join("\r\n");
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(mail),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
.all();
expect(atts.results.length).toBe(0); // 超限附件跳过
const row = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(result.id).first();
expect(row).not.toBeNull(); // 邮件入库
});
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);
expect(resp.status).toBe(413);
});
test("精确地址匹配优先于 catch-all", 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_exact', 'dom_test1', 'hello@example.test', 'active', 0, ?, ?)",
)
.bind(now, now)
.run();
const hit = await resolveMailbox(t.env, ["hello@example.test"]);
expect(hit).toBe("mbx_exact");
const other = await resolveMailbox(t.env, ["nobody@example.test"]);
expect(other).toBe(t.mailboxId); // catch-all
const miss = await resolveMailbox(t.env, ["x@other.example"]);
expect(miss).toBeNull();
});
test("mailbox 自定义 retention 覆盖默认 1 天", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"UPDATE mailboxes SET retention_seconds = 300 WHERE id = ?",
)
.bind(t.mailboxId)
.run();
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const delta =
(new Date(result.expiresAt).getTime() - new Date(now).getTime()) / 1000;
expect(delta).toBeLessThan(400);
});
});
describe("TTL 清理与 orphan", () => {
test("过期邮件被清理(R2 + DB),未过期保留", async () => {
const t = await makeTestEnv();
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
// 人为把 expires_at 改到过去
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
.bind(result.id)
.run();
const stats = await runCron(t.env, () => processDueDeliveries(t.env));
expect(stats.expiredEmails).toBe(1);
const row = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(result.id).first();
expect(row).toBeNull();
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
.all();
expect(atts.results.length).toBe(0);
const obj = await t.env.R2.get(result.id ? `emails/2000/01/01/${result.id}/raw.eml` : "nonexist");
expect(obj).toBeNull();
});
test("orphan 附件与投递记录被清理", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO email_attachments (id, email_id, filename, size) VALUES ('att_orphan', 'msg_gone', 'x.txt', 1)",
)
.run();
await t.env.DB.prepare(
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('dlv_orphan', 'whk_gone', 'msg_gone', 'pending', 0, ?)",
)
.bind(now)
.run();
const stats = await runCron(t.env, () => processDueDeliveries(t.env));
expect(stats.orphanAttachments).toBe(1);
expect(stats.orphanDeliveries).toBe(1);
});
});
describe("credential 撤销", () => {
test("撤销后节点请求被 401,且不因重试恢复", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE node_credentials SET status='revoked', revoked_at=? WHERE id='cred1'")
.bind(new Date().toISOString())
.run();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
expect(data.error).toContain("revoked");
});
});