- 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 生成
191 lines
8.0 KiB
TypeScript
191 lines
8.0 KiB
TypeScript
/** 邮件管线测试: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");
|
|
});
|
|
});
|