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
+2 -2
View File
@@ -104,7 +104,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
.bind(email.id)
@@ -175,7 +175,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
const created = await mcpCall(t, mcpKey, {
+256
View File
@@ -0,0 +1,256 @@
/** authorize 决策矩阵测试(docs/11 §3/§7):accept/reject/temporary_error 语义与拒绝码 */
import { describe, expect, test } from "bun:test";
import { handleEmailEvent, default as workerMod, type EmailMessage } from "../worker/src/index.ts";
import type { WorkerEnv } from "../worker/src/env.ts";
import { makeTestEnv, signedAuthorizeRequest, TEST_MAIL } from "./helpers.ts";
import { sha256Hex } from "../shared/src/index.ts";
import { encryptSecret } from "../console/src/db.ts";
/** # 成员变量: 决策结果(decision/code) **/
interface Decision {
decision: "accept" | "reject" | "temporary_error";
code?: string;
reason?: string;
}
/** # 函数
* 调 authorize 接口并解析决策
*/
async function authorize(
t: Awaited<ReturnType<typeof makeTestEnv>>,
recipient: string,
sender = "sender@example.net",
): Promise<{ status: number; body: Decision }> {
const resp = await t.app.fetch(await signedAuthorizeRequest(t, recipient, sender), t.env);
return { status: resp.status, body: (await resp.json()) as Decision };
}
describe("POST /internal/email/authorize 决策矩阵", () => {
test("存在的 catch-all 域名 -> accept(catch-all 下任意未知地址也接收)", async () => {
const t = await makeTestEnv();
const r = await authorize(t, "hello@example.test");
expect(r.status).toBe(200);
expect(r.body.decision).toBe("accept");
const unknown = await authorize(t, "anyone@example.test");
expect(unknown.body.decision).toBe("accept");
});
test("不存在邮箱且无有效 catch-all -> reject MAILBOX_NOT_FOUND", async () => {
const t = await makeTestEnv();
// 删除 catch-all,只留精确地址(禁用的 catch-all 属于 MAILBOX_DISABLED 语义)
await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run();
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_exact2', 'dom_test1', 'only@example.test', 'active', 0, ?, ?)",
)
.bind(now, now)
.run();
const r = await authorize(t, "unknown@example.test");
expect(r.status).toBe(200);
expect(r.body.decision).toBe("reject");
expect(r.body.code).toBe("MAILBOX_NOT_FOUND");
expect(r.body.reason).toBe("Mailbox does not exist");
});
test("禁用邮箱 -> reject MAILBOX_DISABLED", 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_disabled', 'dom_test1', 'off@example.test', 'disabled', 0, ?, ?)",
)
.bind(now, now)
.run();
const r = await authorize(t, "off@example.test");
expect(r.body.decision).toBe("reject");
expect(r.body.code).toBe("MAILBOX_DISABLED");
});
test("禁用 catch-all 邮箱 -> reject MAILBOX_DISABLED(不再回退)", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?").bind(t.mailboxId).run();
const r = await authorize(t, "anyone@example.test");
expect(r.body.decision).toBe("reject");
expect(r.body.code).toBe("MAILBOX_DISABLED");
});
test("pending 域名 -> reject DOMAIN_DISABLED(Email Workers 未启用)", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE domains SET status = 'pending' WHERE id = ?").bind(t.domainId).run();
const r = await authorize(t, "hello@example.test");
expect(r.body.decision).toBe("reject");
expect(r.body.code).toBe("DOMAIN_DISABLED");
});
test("不存在的域名 -> reject DOMAIN_NOT_FOUND", async () => {
const t = await makeTestEnv();
const r = await authorize(t, "hello@no-such-domain.test");
expect(r.body.decision).toBe("reject");
expect(r.body.code).toBe("DOMAIN_NOT_FOUND");
});
test("Worker 归属校验:credential 所属账号与收件域名必须一致", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
// 另一个账号持有同名域名 example.test;默认 credential 不应跨账号匹配
await t.env.DB.prepare(
"INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) VALUES ('acct_other', 'other-account', 'cfacct-2', 'wmailhub-email-acct-other', 'active', '{}', ?, ?)",
)
.bind(now, now)
.run();
// 默认 credential 收件人域名不存在于本账号(先移除种子域名) -> DOMAIN_NOT_FOUND
await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run();
await t.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(t.domainId).run();
const r = await authorize(t, "hello@example.test");
expect(r.body.decision).toBe("reject");
expect(r.body.code).toBe("DOMAIN_NOT_FOUND");
// other 账号的 credential + 它自己的域名 -> accept
await t.env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_other', 'cfacct-2', 'acct_other', 'example.test', 'active', ?, ?)",
)
.bind(now, now)
.run();
await t.env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_other', 'dom_other', '*@example.test', 'active', 1, ?, ?)",
)
.bind(now, now)
.run();
const otherSecret = "whs_other_secret_for_signing";
await t.env.DB.prepare(
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred_other', 'acct_other', 'wmailhub-email-acct-other', ?, ?, ?, 'active', ?)",
)
.bind(await sha256Hex(otherSecret), await encryptSecret(t.env, otherSecret), otherSecret.slice(-4), now)
.run();
const resp = await t.app.fetch(
await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", {
workerId: "wmailhub-email-acct-other",
secret: otherSecret,
}),
t.env,
);
expect(resp.status).toBe(200);
expect(((await resp.json()) as Decision).decision).toBe("accept");
});
test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => {
const t = await makeTestEnv();
for (const bad of ["no-at-sign", "@example.test", "a@"]) {
const r = await authorize(t, bad);
expect(r.status).toBe(200);
expect(r.body.decision).toBe("reject");
}
});
});
/** # 成员变量: fake console 行为配置 **/
interface FakeConsoleOptions {
/** authorize 阶段响应(缺省 accept) **/
authorize?: (req: Request) => Response | Promise<Response>;
/** ingest 阶段响应(缺省 202 成功) **/
ingest?: (req: Request) => Response | Promise<Response>;
/** 模拟 authorize 阶段网络异常(不可达/超时) **/
authorizeThrows?: string;
}
/** # 函数
* 构造 WorkerEnv:fetchImpl 按路径分发到 fake Console
*/
function makeWorkerEnv(opts: FakeConsoleOptions): WorkerEnv {
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const req = input instanceof Request ? input : new Request(input, init);
const pathname = new URL(req.url).pathname;
if (pathname === "/internal/email/authorize") {
if (opts.authorizeThrows) throw new Error(opts.authorizeThrows);
return await (opts.authorize ?? (() => Response.json({ decision: "accept" })))(req);
}
if (pathname === "/internal/email/ingest") {
return await (opts.ingest ?? (() => Response.json({ ok: true, id: "msg_x", mailbox_id: "mbx_x" }, { status: 202 })))(req);
}
throw new Error(`unexpected fetch ${pathname}`);
}) as typeof fetch;
return {
CONSOLE_URL: "http://fake-console",
WORKER_ID: "wmailhub-email-test",
WORKER_SECRET: "whs_fake_secret",
fetchImpl,
};
}
describe("Email Worker 两阶段行为(临时错误语义)", () => {
const MAIL_BYTES = new TextEncoder().encode(TEST_MAIL);
function message(): EmailMessage {
return { from: "sender@example.net", to: "test@example.com", raw: MAIL_BYTES };
}
test("accept -> ingest 原文提交成功(信封头 + message/rfc822)", async () => {
let ingestBody: Uint8Array | null = null;
const wenv = makeWorkerEnv({
ingest: async (req) => {
expect(req.headers.get("X-WmailHub-Recipient")).toBe("test@example.com");
expect(req.headers.get("X-WmailHub-Sender")).toBe("sender@example.net");
expect(req.headers.get("Content-Type")).toBe("message/rfc822");
ingestBody = new Uint8Array(await req.arrayBuffer());
return Response.json({ ok: true, id: "msg_1", mailbox_id: "mbx_1" }, { status: 202 });
},
});
const result = await handleEmailEvent(wenv, message());
expect(result.action).toBe("accepted");
expect(result.email_id).toBe("msg_1");
// 原始 MIME 原样提交(未转 JSON 字符串)
expect(new TextDecoder().decode(ingestBody ?? new Uint8Array())).toContain("plain body");
});
test("reject -> 不调用 ingest,返回拒绝码(setReject 由 email() 执行)", async () => {
const wenv = makeWorkerEnv({
authorize: () => Response.json({ decision: "reject", code: "MAILBOX_NOT_FOUND", reason: "Mailbox does not exist" }),
});
const result = await handleEmailEvent(wenv, message());
expect(result.action).toBe("rejected");
expect(result.code).toBe("MAILBOX_NOT_FOUND");
});
test("Console 5xx -> temporary_error(不能变成 mailbox not found)", async () => {
const wenv = makeWorkerEnv({
authorize: () => new Response("boom", { status: 500 }),
});
const result = await handleEmailEvent(wenv, message());
expect(result.action).toBe("temporary_error");
expect(result.reason).toContain("500");
});
test("Console 网络不可达/超时 -> temporary_error", async () => {
const wenv = makeWorkerEnv({ authorizeThrows: "connect timeout" });
const result = await handleEmailEvent(wenv, message());
expect(result.action).toBe("temporary_error");
expect(result.reason).toContain("timeout");
});
test("ingest 失败 -> 不伪造业务拒绝(temporary_error)", async () => {
const wenv = makeWorkerEnv({
ingest: () => new Response("storage down", { status: 500 }),
});
const result = await handleEmailEvent(wenv, message());
expect(result.action).toBe("temporary_error");
expect(result.reason).toContain("ingest");
});
test("默认 email() handler:reject 调 setReject;temporary 抛异常", async () => {
// reject -> setReject(SMTP 永久拒绝)
const wenv1 = makeWorkerEnv({
authorize: () =>
Response.json({
decision: "reject",
code: "DOMAIN_DISABLED",
reason: "Receiving for this domain is not enabled",
}),
});
const reasons: string[] = [];
await workerMod.email({ ...message(), setReject: (r) => reasons.push(r) }, wenv1);
expect(reasons).toEqual(["Receiving for this domain is not enabled"]);
// temporary_error -> 抛异常交 Cloudflare 重试
const wenv2 = makeWorkerEnv({ authorizeThrows: "console down" });
await expect(workerMod.email(message(), wenv2)).rejects.toThrow("console down");
});
});
+7 -1
View File
@@ -3,6 +3,12 @@ const KEY = "wmh_e2e_key_12345";
Bun.serve({
port: 18790,
async fetch(req) {
if (req.method === "GET" && new URL(req.url).pathname === "/__stats") {
return Response.json({ ok: true, note: "see stdout for deliveries" });
}
if (req.method !== "POST" || new URL(req.url).pathname !== "/hook") {
return Response.json({ error: "not found" }, { status: 404 });
}
const body = await req.text();
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = req.headers.get("X-WmailHub-Signature") ?? "";
@@ -17,7 +23,7 @@ Bun.serve({
event: payload.event,
subject: payload.subject,
mailbox: payload.mailbox,
custom_header: req.headers.get("X-E2E"),
custom_header: req.headers.get("X-Custom"),
}),
);
return Response.json({ ok: true });
+89 -41
View File
@@ -12,7 +12,7 @@ import { LocalD1 } from "../console/src/local/d1.ts";
import { LocalR2 } from "../console/src/local/r2.ts";
import type { Env } from "../console/src/env.ts";
import { encryptSecret } from "../console/src/db.ts";
import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts";
import { hashPassword, sha256Hex, signWorkerRequest } from "../shared/src/index.ts";
import { ids } from "../shared/src/index.ts";
/** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/
@@ -54,17 +54,19 @@ export interface TestEnv {
env: Env;
app: ReturnType<typeof createApp>;
db: Database;
/** 默认节点 */
nodeId: string;
/** 默认节点 credential secret(明文,用于签名) */
nodeSecret: string;
/** 默认账号(cloudflare_accounts) */
accountId: string;
/** Email Worker 身份(worker_id,即部署脚本名) */
workerId: string;
/** 默认 worker credential secret(明文,用于签名) */
workerSecret: string;
/** catch-all 域名 example.test 对应的 mailbox id */
mailboxId: string;
domainId: string;
}
/** # 函数
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
* 构造隔离测试环境并播种:admin 用户、账号、worker credential、domain、catch-all mailbox
*/
export async function makeTestEnv(): Promise<TestEnv> {
const db = new Database(":memory:", { strict: true });
@@ -72,6 +74,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
const env: Env = {
DB: new LocalD1(db),
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
PUBLIC_BASE_URL: "http://localhost",
WMAILHUB_SIM_CF: "1",
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
};
@@ -82,25 +85,26 @@ export async function makeTestEnv(): Promise<TestEnv> {
.bind(ids.user(), await hashPassword("admin"), now)
.run();
const nodeId = "node_test1";
const nodeSecret = "wns_test_secret_for_signing";
const accountId = "acct_test1";
const workerId = "wmailhub-email-acct-test1";
const workerSecret = "whs_test_secret_for_signing";
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, ingress_url, ingress_secret_enc, provision_steps, created_at, updated_at)
VALUES (?, 'test-node', 'acct-1', 'cred1', 'online', 'http://127.0.0.1:18788/ingress/email', 'enc', '{}', ?, ?)`,
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at)
VALUES (?, 'test-account', 'cfacct-1', ?, 'active', '{}', ?, ?)`,
)
.bind(nodeId, now, now)
.bind(accountId, workerId, now, now)
.run();
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred1', ?, ?, ?, ?, ?, 'active', ?)",
)
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now)
.bind(accountId, workerId, await sha256Hex(workerSecret), await encryptSecret(env, workerSecret), workerSecret.slice(-4), now)
.run();
const domainId = "dom_test1";
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, created_at, updated_at) VALUES (?, 'acct-1', ?, 'example.test', 'active', ?, ?)",
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES (?, 'cfacct-1', ?, 'example.test', 'active', ?, ?)",
)
.bind(domainId, nodeId, now, now)
.bind(domainId, accountId, now, now)
.run();
const mailboxId = "mbx_test1";
await env.DB.prepare(
@@ -109,7 +113,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
.bind(mailboxId, domainId, now, now)
.run();
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
return { env, app, db, accountId, workerId, workerSecret, mailboxId, domainId };
}
/** # 函数
@@ -130,42 +134,86 @@ export async function loginCookie(test: TestEnv): Promise<string> {
return `wmh_session=${m[1]}`;
}
/** # 成员变量: 签名请求构造的篡改选项 **/
export interface SignOverrides {
/** 换用错误 secret(签名必然不匹配) **/
secret?: string;
/** 声明其它 worker_id **/
workerId?: string;
/** 篡改 Authorization */
tamperSignature?: boolean;
/** 发送与签名不一致的 body(body hash mismatch) **/
tamperBody?: Uint8Array;
/** 覆盖 timestamp(超窗测试) **/
timestamp?: string;
/** 覆盖 nonce(replay 测试) **/
reuseNonce?: string;
/** 覆盖协议版本 */
protocol?: string;
}
/** # 函数
* 构造已签名的 Node 邮件转发请求
* 构造已签名的 authorize 请求(docs/11:recipient/sender JSON)
*/
export async function signedEmailRequest(
export async function signedAuthorizeRequest(
test: TestEnv,
recipient: string,
sender = "alice@example.org",
overrides: SignOverrides = {},
): Promise<Request> {
const url = "http://localhost/internal/email/authorize";
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender, message_id: "<t-1@example.org>" }));
const headers = await signWorkerRequest(
overrides.secret ?? test.workerSecret,
overrides.workerId ?? test.workerId,
"POST",
url,
body,
);
applyOverrides(headers, overrides);
const payload = overrides.tamperBody ?? body;
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
}
/** # 函数
* 构造已签名的 ingest 请求(raw MIME + 信封头)
*/
export async function signedIngestRequest(
test: TestEnv,
body: Uint8Array,
overrides: {
secret?: string;
nodeId?: string;
tamperSignature?: boolean;
tamperBody?: Uint8Array;
timestamp?: string;
reuseNonce?: string;
} = {},
overrides: SignOverrides & { recipient?: string; sender?: string } = {},
): Promise<Request> {
const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`;
// 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求
const headers = await signNodeRequest(
overrides.secret ?? test.nodeSecret,
overrides.nodeId ?? test.nodeId,
const url = "http://localhost/internal/email/ingest";
const headers = await signWorkerRequest(
overrides.secret ?? test.workerSecret,
overrides.workerId ?? test.workerId,
"POST",
url,
body,
);
headers["Content-Type"] = "message/rfc822";
headers["X-WmailHub-Mail-From"] = "alice@example.org";
headers["X-WmailHub-Rcpt-To"] = "hello@example.test";
if (overrides.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
if (overrides.timestamp) headers["X-WmailHub-Timestamp"] = overrides.timestamp;
if (overrides.reuseNonce) headers["X-WmailHub-Nonce"] = overrides.reuseNonce;
headers["X-WmailHub-Recipient"] = overrides.recipient ?? "hello@example.test";
headers["X-WmailHub-Sender"] = overrides.sender ?? "alice@example.org";
applyOverrides(headers, overrides);
const payload = overrides.tamperBody ?? body;
const ab = payload.buffer.slice(
payload.byteOffset,
payload.byteOffset + payload.byteLength,
) as ArrayBuffer;
return new Request(url, { method: "POST", headers, body: ab });
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
}
/** # 函数
* 应用测试篡改覆盖(签名/body/timestamp/nonce/协议版本)
*/
function applyOverrides(headers: Record<string, string>, o: SignOverrides) {
if (o.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
if (o.timestamp) headers["X-WmailHub-Timestamp"] = o.timestamp;
if (o.reuseNonce) headers["X-WmailHub-Nonce"] = o.reuseNonce;
if (o.protocol) headers["X-WmailHub-Protocol"] = o.protocol;
}
/** # 函数
* Uint8Array -> ArrayBuffer(Request body 需要)
*/
function toArrayBuffer(payload: Uint8Array): ArrayBuffer {
return payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer;
}
/** # 函数
+57 -45
View File
@@ -1,13 +1,13 @@
/** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */
/** Cloudflare OAuth(本地 sim 模式)测试:Connect Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("Cloudflare OAuth(sim 模式)", () => {
test("start 免 node_id 自动建账号;approve 同步全部域名;setup 配置 Email Workers", async () => {
describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => {
test("start 自动建账号;approve 完成自动接入并同步域名;setup 配置 Email Workers", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 添加账号:OAuth start 不带 node_id(服务端自动创建账号)
// Connect Cloudflare Account:start 自动创建账号记录
const start = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { url: string; state: string };
@@ -20,31 +20,43 @@ describe("Cloudflare OAuth(sim 模式)", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }),
})
).json()) as { ok: boolean; error?: string; node_id: string };
).json()) as { ok: boolean; error?: string; account_id: string };
expect(approve.ok).toBe(true);
// 安装结果:发现 + 域名同步完成
// 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`)
await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.account_id}`)
).json()) as {
account: { status: string; cloudflare_account_id: string | null; name: string };
account: { status: string; cloudflare_account_id: string | null; name: string; worker_id: string | null };
steps: Record<string, { status: string }>;
domains: { name: string }[];
credential: { worker_id: string; secret_last4: string; status: string } | null;
};
expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线
expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念)
expect(detail.account.cloudflare_account_id).toBe("sim-account-0001");
expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名
for (const key of ["oauth", "identity", "discover", "domains"]) {
for (const key of ["oauth", "discover", "domains", "worker"]) {
expect(detail.steps[key].status).toBe("done");
}
// OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发)
expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发(库中不存明文 secret)
expect(detail.credential).not.toBeNull();
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
const credRow = await t.env.DB.prepare(
"SELECT secret_hash, secret_enc, secret_last4 FROM worker_credentials WHERE cloudflare_account_id = ?",
)
.bind(approve.account_id)
.first<{ secret_hash: string; secret_enc: string; secret_last4: string }>();
expect(credRow?.secret_enc).toBeTruthy();
expect(credRow!.secret_last4.length).toBe(4);
// OAuth 只同步域名,Email Routing 按域名手动触发
expect(detail.domains.map((d) => d.name)).toEqual(
expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]),
);
const domList = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string }[] };
// 种子环境自带一个 acct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] };
// 种子环境自带一个 cfacct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].includes(d.name));
expect(synced.length).toBe(2);
for (const d of synced) expect(d.status).toBe("pending");
@@ -53,7 +65,7 @@ describe("Cloudflare OAuth(sim 模式)", () => {
const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>();
expect(mb.results.map((r) => r.address)).toContain("*@example.test");
// setup:为同步出的 example.test 配置 Email Workers(Worker + Routing + catch-all 规则)
// setup:为同步出的 example.test 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则)
const example = domList.items.find((d) => d.name === "example.test" && d.status === "pending")!;
const setup = (await (
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
@@ -116,50 +128,50 @@ describe("Cloudflare OAuth(sim 模式)", () => {
expect(cb.status).toBe(400);
});
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
test("rotate:Worker credential 轮换,secret 仅本次可见且已配置域名自动重部署", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 自动创建账号后,用 rotate 取得 enrollment token
// 自动接入一个账号(approve 走完 worker 步骤:部署 + 签发凭据)
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
const accounts = (await (
await authedFetch(t, cookie, "http://localhost/api/accounts")
).json()) as { items: { id: string }[] };
const accountId = accounts.items[0].id;
const start = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { state: string };
const approve = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }),
})
).json()) as { ok: boolean; account_id: string };
const accountId = approve.account_id;
const rotated = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
).json()) as { enrollment_token: string };
expect(rotated.enrollment_token.startsWith("wenr_")).toBe(true);
).json()) as { ok: boolean; worker_id: string; secret: string; redeployed: string[] };
expect(rotated.ok).toBe(true);
expect(rotated.secret.startsWith("whs_")).toBe(true);
const enroll = (await (
await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: rotated.enrollment_token }),
}),
t.env,
)
).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
expect(enroll.node_id).toBe(accountId);
expect(enroll.credential_secret.startsWith("wns_")).toBe(true);
// 库中不存明文 secret
const cred = await t.env.DB.prepare(
"SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'",
// 库中不存明文 secret;旧 credential 已撤销,新 credential 生效
const creds = await t.env.DB.prepare(
"SELECT status FROM worker_credentials WHERE cloudflare_account_id = ?",
)
.bind(accountId)
.first<{ secret_hash: string; secret_enc: string }>();
expect(cred?.secret_hash).not.toBe(enroll.credential_secret);
expect(cred?.secret_enc).not.toContain(enroll.credential_secret);
// 无效 token 被拒
const badEnroll = await t.app.fetch(
.all<{ status: string }>();
expect(creds.results.filter((r) => r.status === "active").length).toBe(1);
expect(creds.results.filter((r) => r.status === "revoked").length).toBe(1);
});
test("旧 Node 协议接口已移除(/api/internal/enroll 不再可用)", async () => {
const t = await makeTestEnv();
// 该路径已无路由;管理面中间件按未授权拒绝,不会返回 enrollment 结果
const resp = await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: "wenr_invalid" }),
body: JSON.stringify({ enrollment_token: "wenr_x" }),
}),
t.env,
);
expect(badEnroll.status).toBe(401);
expect(resp.status).toBe(401);
});
});
+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);
});
});
+38 -51
View File
@@ -1,13 +1,15 @@
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式(Worker-Console 协议) */
import { describe, expect, test } from "bun:test";
import {
canonicalString,
computeSignature,
generateWmailKey,
isValidWmailKey,
verifyNodeRequest,
sha256Hex,
verifyWorkerRequest,
signWorkerRequest,
} from "../shared/src/index.ts";
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
import { makeTestEnv, signedAuthorizeRequest, signedIngestRequest, TEST_MAIL } from "./helpers.ts";
describe("wmh_ Key 规范", () => {
test("生成的 Key 以 wmh_ 开头且总长 32", () => {
@@ -29,41 +31,35 @@ describe("wmh_ Key 规范", () => {
});
});
describe("HMAC 请求签名", () => {
describe("Worker-Console HMAC 请求签名", () => {
test("canonical 串格式符合 docs/04", () => {
const c = canonicalString({
method: "POST",
path: "/api/x",
path: "/internal/email/authorize",
timestamp: "123",
nonce: "n",
bodyHash: "h",
});
expect(c).toBe("POST\n/api/x\n123\nn\nh");
expect(c).toBe("POST\n/internal/email/authorize\n123\nn\nh");
});
test("正确签名可通过校验", async () => {
test("正确签名可通过校验(Authorization: WmailHub-Worker <worker_id>)", async () => {
const body = new TextEncoder().encode("hello");
const headers = new Headers({
"Authorization": "WmailHub-Node node_1",
"Authorization": "WmailHub-Worker wmailhub-email-x1",
"X-WmailHub-Timestamp": String(Math.floor(Date.now() / 1000)),
"X-WmailHub-Nonce": "nonce-1",
"X-WmailHub-Body-SHA256": await (async () => {
const { sha256Hex } = await import("../shared/src/index.ts");
return sha256Hex(body);
})(),
"X-WmailHub-Body-SHA256": await sha256Hex(body),
"X-WmailHub-Signature": await computeSignature("s", {
method: "POST",
path: "/p",
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: "nonce-1",
bodyHash: await (async () => {
const { sha256Hex } = await import("../shared/src/index.ts");
return sha256Hex(body);
})(),
bodyHash: await sha256Hex(body),
}),
"X-WmailHub-Protocol": "1",
});
const result = await verifyNodeRequest({
const result = await verifyWorkerRequest({
secret: "s",
method: "POST",
url: "http://x/p",
@@ -71,30 +67,36 @@ describe("HMAC 请求签名", () => {
headers,
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.workerId).toBe("wmailhub-email-x1");
});
test("错误 secret 签名被拒(伪造 Node 请求)", async () => {
test("错误 secret 签名被拒(伪造 Worker 请求)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" });
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { secret: "whs_wrong_secret" });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("body 篡改被拒", async () => {
test("未知 worker_id 被拒(公网匿名调用 /internal/email/*)", async () => {
const test = await makeTestEnv();
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { workerId: "wmailhub-email-unknown" });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("body 篡改被拒(authorize 与 ingest 都校验 body hash)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const tampered = new TextEncoder().encode(TEST_MAIL + "injected");
const req = await signedEmailRequest(test, body, { tamperBody: tampered });
const req = await signedIngestRequest(test, body, { tamperBody: tampered });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("过期 timestamp 超窗被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const old = String(Math.floor(Date.now() / 1000) - 3600); // 1 小时前
const req = await signedEmailRequest(test, body, { timestamp: old });
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { timestamp: old });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
@@ -103,39 +105,24 @@ describe("HMAC 请求签名", () => {
test("nonce 重放被拒(replay attack)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req1 = await signedEmailRequest(test, body);
const resp1 = await test.app.fetch(req1, test.env);
expect(resp1.status).toBe(202);
// 取第一次请求的 nonce 重放(重新签名同一 nonce)
const url = `http://localhost/api/internal/nodes/${test.nodeId}/emails`;
const { signNodeRequest } = await import("../shared/src/index.ts");
const headers = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
headers["Content-Type"] = "message/rfc822";
const resp2a = await test.app.fetch(new Request(url, { method: "POST", headers, body }), test.env);
expect(resp2a.status).toBe(202);
// 抓第二次请求的完整头,原样重发构成 replay(同 nonce 同签名)
const headers3 = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
headers3["Content-Type"] = "message/rfc822";
const replayReq = () => new Request(url, { method: "POST", headers: headers3, body });
// 同一组签名头发两次:第一次 nonce 入库通过,第二次构成 replay
const url = "http://localhost/internal/email/authorize";
const body = new TextEncoder().encode(JSON.stringify({ recipient: "hello@example.test", sender: "a@b.c" }));
const headers = await signWorkerRequest(test.workerSecret, test.workerId, "POST", url, body);
headers["Content-Type"] = "application/json";
const replayReq = () => new Request(url, { method: "POST", headers, body: body.slice() });
const respFirst = await test.app.fetch(replayReq(), test.env);
expect(respFirst.status).toBe(202);
const resp3 = await test.app.fetch(replayReq(), test.env);
expect(resp3.status).toBe(401);
const data = (await resp3.json()) as { error: string };
expect(respFirst.status).toBe(200);
const respReplay = await test.app.fetch(replayReq(), test.env);
expect(respReplay.status).toBe(401);
const data = (await respReplay.json()) as { error: string };
expect(data.error).toContain("replay");
});
test("错误协议版本被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req = await signedEmailRequest(test, body);
const headers = new Headers(req.headers);
headers.set("X-WmailHub-Protocol", "9");
const resp = await test.app.fetch(
new Request(req.url, { method: "POST", headers, body }),
test.env,
);
const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { protocol: "9" });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(400);
});
});
+1 -24
View File
@@ -1,4 +1,4 @@
/** 设置测试:全局 Webhook 读写与 key 语义、默认 Ingress、修改密码 */
/** 设置测试:全局 Webhook 读写与 key 语义、修改密码 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
@@ -52,29 +52,6 @@ describe("设置(/api/settings)", () => {
expect(info.webhook.url).toBe("");
});
test("默认 Node Ingress 地址保存与读取", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_ingress_url: "https://node.example.com/ingress/email" }),
});
expect(save.status).toBe(200);
const info = (await (
await authedFetch(t, cookie, "http://localhost/api/settings")
).json()) as { default_ingress_url: string };
expect(info.default_ingress_url).toBe("https://node.example.com/ingress/email");
// 非法地址被拒
const bad = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_ingress_url: "not-a-url" }),
});
expect(bad.status).toBe(400);
});
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
+2 -2
View File
@@ -80,8 +80,8 @@ await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
const drawerText = await page.locator(".ant-drawer-title").textContent();
check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("WmailHub")));
const drawerText = (await page.locator(".ant-drawer-title").textContent()) ?? "";
check("邮件详情抽屉打开(标题=邮件主题)", drawerText.trim().length > 0, drawerText.trim());
await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
await page.keyboard.press("Escape");
+6 -6
View File
@@ -84,7 +84,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
const dlv = await t.env.DB.prepare("SELECT id, target_type FROM webhook_deliveries").first<{
id: string;
@@ -129,7 +129,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(mailboxRecv.requests.length).toBe(1);
@@ -145,7 +145,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(domainRecv.requests.length).toBe(1);
@@ -157,7 +157,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(globalRecv.requests.length).toBe(1);
@@ -171,7 +171,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM webhook_deliveries").first<{ n: number }>();
expect(count?.n).toBe(0);
@@ -200,7 +200,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
accountId: t.accountId,
});
const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts");
const row0 = await t.env.DB.prepare(