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:
@@ -0,0 +1,172 @@
|
||||
/** Webhook 测试:SSRF 校验、投递签名、重试与退避、失败状态、手动测试投递 */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ingestEmail } from "../console/src/services/email_ingest.ts";
|
||||
import { attemptDelivery, processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
|
||||
import { hmacSha256Hex } from "../shared/src/index.ts";
|
||||
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
/** # 函数
|
||||
* 起一个本地 HTTP 接收器,记录请求并可控返回状态
|
||||
*/
|
||||
function startReceiver(mode: "ok" | "fail500"): { url: string; requests: { headers: Headers; body: string }[]; stop: () => void } {
|
||||
const requests: { headers: Headers; body: string }[] = [];
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (req) => {
|
||||
requests.push({ headers: req.headers, body: await req.text() });
|
||||
if (mode === "fail500") return new Response("no", { status: 500 });
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
});
|
||||
return { url: `http://127.0.0.1:${server.port}/hook`, requests, stop: () => server.stop(true) };
|
||||
}
|
||||
|
||||
describe("Webhook", () => {
|
||||
test("创建 webhook 时拒绝私网/localhost endpoint(SSRF)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
// SSRF 防护在"未开启本地联调开关"时生效(生产默认态)
|
||||
delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS;
|
||||
const cookie = await loginCookie(t);
|
||||
for (const endpoint of [
|
||||
"http://127.0.0.1:8080/hook",
|
||||
"http://localhost/hook",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://10.0.0.5/hook",
|
||||
"http://[::1]/hook",
|
||||
"ftp://example.com/x",
|
||||
]) {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "x", endpoint }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test("允许公网 endpoint 创建成功", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "ok", endpoint: "https://hooks.example.com/wmail" }),
|
||||
});
|
||||
expect(resp.status).toBe(201);
|
||||
const data = (await resp.json()) as { secret: string };
|
||||
expect(data.secret.startsWith("whsec_")).toBe(true);
|
||||
});
|
||||
|
||||
test("投递成功:签名头正确且 2xx 落 success", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const recv = startReceiver("ok");
|
||||
const now = new Date().toISOString();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk1', 'w', ?, 'whsec_test', 1, ?, ?)",
|
||||
)
|
||||
.bind(recv.url, now, now)
|
||||
.run();
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
});
|
||||
const dlv = await t.env.DB.prepare("SELECT id FROM webhook_deliveries").first<{ id: string }>();
|
||||
expect(dlv).not.toBeNull();
|
||||
const stats = await processDueDeliveries(t.env, undefined, 20, true);
|
||||
expect(stats.success).toBe(1);
|
||||
const row = await t.env.DB.prepare("SELECT status, delivered_at FROM webhook_deliveries").first<{
|
||||
status: string;
|
||||
delivered_at: string | null;
|
||||
}>();
|
||||
expect(row?.status).toBe("success");
|
||||
|
||||
// 验证签名
|
||||
const req = recv.requests[0];
|
||||
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
|
||||
const sig = await hmacSha256Hex("whsec_test", String(ts).concat(".", req.body));
|
||||
expect(req.headers.get("X-WmailHub-Signature") ?? null).toBe(sig);
|
||||
expect(req.headers.get("X-WmailHub-Event") ?? null).toBe("email.received");
|
||||
expect(req.headers.get("X-WmailHub-Delivery") ?? null).toBe(dlv?.id ?? null);
|
||||
const payload = JSON.parse(req.body) as Record<string, unknown>;
|
||||
expect(payload.event).toBe("email.received");
|
||||
expect(payload.id).toBe(email.id);
|
||||
expect(payload.mailbox).toBe("*@example.test");
|
||||
recv.stop();
|
||||
});
|
||||
|
||||
test("5xx 失败进入重试队列,退避时间递增,超过上限 exhausted", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const now = new Date().toISOString();
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d1', 'whk_gone', 'msg_gone', 'pending', 0, ?)",
|
||||
)
|
||||
.bind(now)
|
||||
.run();
|
||||
// msg_gone 不存在(触发 email expired 分支前先看 join):join 不到直接跳过 — 改用真实邮件
|
||||
await t.env.DB.prepare("DELETE FROM webhook_deliveries WHERE id='d1'").run();
|
||||
// 建真实 webhook 行(endpoint 不可达),投递 join 需要
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk_gone', 'gone', 'http://127.0.0.1:1/hook', 's', 1, ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
nodeId: t.nodeId,
|
||||
});
|
||||
await t.env.DB.prepare(
|
||||
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d2', 'whk_gone', ?, 'pending', 0, ?)",
|
||||
)
|
||||
.bind(email.id, now)
|
||||
.run();
|
||||
// 直连 attemptDelivery 到一个 5xx 地址(fetch 会失败)
|
||||
const result = await attemptDelivery(
|
||||
{ id: "whk_gone", endpoint: "http://127.0.0.1:1/hook", secret: "s" },
|
||||
"d2",
|
||||
{ event: "email.received" },
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
// 状态机:连续失败到上限 → exhausted(每轮把 next_retry_at 拉回现在模拟时间流逝)
|
||||
const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts");
|
||||
let attempts = 0;
|
||||
for (let i = 0; i < WEBHOOK_MAX_ATTEMPTS + 1; i++) {
|
||||
const stats = await processDueDeliveries(t.env, undefined, 20, true);
|
||||
if (stats.processed === 0) break;
|
||||
attempts++;
|
||||
await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id='d2'")
|
||||
.bind(new Date().toISOString())
|
||||
.run();
|
||||
}
|
||||
const row = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id='d2'").first<{
|
||||
status: string;
|
||||
attempt_count: number;
|
||||
}>();
|
||||
// 目标不可达(端口 1),每次都失败,最终 exhausted
|
||||
expect(row?.status).toBe("exhausted");
|
||||
expect(row?.attempt_count).toBe(attempts);
|
||||
});
|
||||
|
||||
test("手动 test 投递走同一条签名链路", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const recv = startReceiver("ok");
|
||||
const cookie = await loginCookie(t);
|
||||
const create = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "t", endpoint: recv.url }),
|
||||
});
|
||||
const { id } = (await create.json()) as { id: string };
|
||||
const testResp = await authedFetch(t, cookie, `http://localhost/api/webhooks/${id}/test`, { method: "POST" });
|
||||
const data = (await testResp.json()) as { ok: boolean };
|
||||
expect(data.ok).toBe(true);
|
||||
expect(recv.requests.length).toBeGreaterThanOrEqual(1);
|
||||
recv.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user