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 @@
|
||||
/**
|
||||
* 测试基建:内存 D1 + 临时目录 R2 + Console app + 种子数据。
|
||||
* 每个 test 文件独立建 env,互不干扰。
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createApp } from "../console/src/app.ts";
|
||||
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 { ids } from "../shared/src/index.ts";
|
||||
|
||||
/** # 成员变量: 迁移 SQL(一次读取,多处使用) **/
|
||||
export const MIGRATION_SQL = await Bun.file(new URL("../migrations/0001_init.sql", import.meta.url)).text();
|
||||
|
||||
/** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/
|
||||
export const TEST_MAIL = `From: Alice <alice@example.org>\r
|
||||
To: hello@example.test\r
|
||||
Cc: bob@example.net\r
|
||||
Subject: Test Mail\r
|
||||
Message-ID: <t-1@example.org>\r
|
||||
MIME-Version: 1.0\r
|
||||
Content-Type: multipart/mixed; boundary="BB"\r
|
||||
\r
|
||||
--BB\r
|
||||
Content-Type: text/plain\r
|
||||
\r
|
||||
plain body\r
|
||||
--BB\r
|
||||
Content-Type: text/html\r
|
||||
\r
|
||||
<b>html body</b>\r
|
||||
--BB\r
|
||||
Content-Type: text/plain; name="a.txt"\r
|
||||
Content-Disposition: attachment; filename="a.txt"\r
|
||||
\r
|
||||
attachment-content\r
|
||||
--BB--\r
|
||||
`;
|
||||
|
||||
/** # 成员变量: 测试环境(内存库 + 临时 R2 目录) **/
|
||||
export interface TestEnv {
|
||||
env: Env;
|
||||
app: ReturnType<typeof createApp>;
|
||||
db: Database;
|
||||
/** 默认节点 */
|
||||
nodeId: string;
|
||||
/** 默认节点 credential secret(明文,用于签名) */
|
||||
nodeSecret: string;
|
||||
/** catch-all 域名 example.test 对应的 mailbox id */
|
||||
mailboxId: string;
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
|
||||
*/
|
||||
export async function makeTestEnv(): Promise<TestEnv> {
|
||||
const db = new Database(":memory:", { strict: true });
|
||||
db.exec(MIGRATION_SQL);
|
||||
const env: Env = {
|
||||
DB: new LocalD1(db),
|
||||
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
|
||||
WMAILHUB_SIM_CF: "1",
|
||||
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
|
||||
};
|
||||
const app = createApp();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, 'admin', ?, ?)")
|
||||
.bind(ids.user(), await hashPassword("admin"), now)
|
||||
.run();
|
||||
|
||||
const nodeId = "node_test1";
|
||||
const nodeSecret = "wns_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', '{}', ?, ?)`,
|
||||
)
|
||||
.bind(nodeId, now, now)
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), 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', ?, ?)",
|
||||
)
|
||||
.bind(domainId, nodeId, now, now)
|
||||
.run();
|
||||
const mailboxId = "mbx_test1";
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES (?, ?, '*@example.test', 'active', 1, ?, ?)",
|
||||
)
|
||||
.bind(mailboxId, domainId, now, now)
|
||||
.run();
|
||||
|
||||
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造登录会话 cookie(走真实登录接口)
|
||||
*/
|
||||
export async function loginCookie(test: TestEnv): Promise<string> {
|
||||
const resp = await test.app.fetch(
|
||||
new Request("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "admin" }),
|
||||
}),
|
||||
test.env,
|
||||
);
|
||||
const setCookie = resp.headers.get("Set-Cookie") ?? "";
|
||||
const m = /wmh_session=([^;]+)/.exec(setCookie);
|
||||
if (!m) throw new Error("login failed: no session cookie");
|
||||
return `wmh_session=${m[1]}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造已签名的 Node 邮件转发请求
|
||||
*/
|
||||
export async function signedEmailRequest(
|
||||
test: TestEnv,
|
||||
body: Uint8Array,
|
||||
overrides: {
|
||||
secret?: string;
|
||||
nodeId?: string;
|
||||
tamperSignature?: boolean;
|
||||
tamperBody?: Uint8Array;
|
||||
timestamp?: string;
|
||||
reuseNonce?: 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,
|
||||
"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;
|
||||
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 });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 会话 GET/POST 便捷封装
|
||||
*/
|
||||
export function authedFetch(test: TestEnv, cookie: string, url: string, init?: RequestInit) {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Cookie", cookie);
|
||||
return test.app.fetch(new Request(url, { ...init, headers }), test.env);
|
||||
}
|
||||
Reference in New Issue
Block a user