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
+7
View File
@@ -0,0 +1,7 @@
/** Email Worker 环境变量(wrangler vars 注入) */
export interface ForwarderEnv {
/** Node ingress 完整 URL */
NODE_INGRESS_URL: string;
/** Worker -> Node 共享密钥 */
NODE_INGRESS_SECRET: string;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* WmailHub Email Worker(部署入口):
* Cloudflare Email Routing -> email() handler -> Node ingress。
* 只做转发,不存业务数据(docs/01);持有 Node ingress secret(docs/08)。
*
* 生产部署:Console provisioning 自动上传脚本;或 `bunx wrangler deploy`(worker/)。
* 本地联调:`bun worker/src/local.ts` 起 HTTP 服务,POST /__email_test 模拟邮件事件。
*/
import type { ForwarderEnv } from "./env.ts";
/** # 成员变量: Cloudflare Email Message 最小接口 **/
export interface EmailMessage {
from: string;
to: string;
raw: ReadableStream;
}
/** # 函数
* 转发 raw MIME 到 Node ingress
*/
export async function forward(env: ForwarderEnv, raw: ArrayBuffer, from: string, to: string): Promise<Response> {
const resp = await fetch(env.NODE_INGRESS_URL, {
method: "POST",
headers: {
"Content-Type": "message/rfc822",
"X-WmailHub-Ingress-Secret": env.NODE_INGRESS_SECRET,
"X-WmailHub-Mail-From": from,
"X-WmailHub-Rcpt-To": to,
},
body: raw,
});
return resp;
}
/** # 函数
* 处理 Email Worker 邮件事件(与测试入口共用)
*/
export async function handleEmailEvent(env: ForwarderEnv, message: EmailMessage): Promise<void> {
const raw = await new Response(message.raw).arrayBuffer();
const resp = await forward(env, raw, message.from, message.to);
if (!resp.ok) {
throw new Error(`node ingress responded ${resp.status}`);
}
}
/** # 成员变量: Workers 导出(email + fetch) **/
export default {
async email(message: EmailMessage, env: ForwarderEnv): Promise<void> {
await handleEmailEvent(env, message);
},
async fetch(request: Request, env: ForwarderEnv): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, service: "wmailhub-email-worker" });
}
if (request.method === "POST" && url.pathname === "/__email_test") {
if (request.headers.get("X-WmailHub-Ingress-Secret") !== env.NODE_INGRESS_SECRET) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org";
const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com";
const raw = await request.arrayBuffer();
try {
const resp = await forward(env, raw, from, to);
return Response.json({ ok: resp.ok, status: resp.status }, { status: resp.ok ? 200 : 502 });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Email Worker 本地联调服务:把部署入口的 fetch handler 跑在 Bun 上。
* bun worker/src/local.ts
* 测试:POST http://127.0.0.1:18789/__email_test
* headers: X-WmailHub-Ingress-Secret / X-WmailHub-Test-From / X-WmailHub-Test-To
* body: raw MIME
*/
import worker from "./index.ts";
import type { ForwarderEnv } from "./env.ts";
const env: ForwarderEnv = {
NODE_INGRESS_URL: process.env.NODE_INGRESS_URL ?? "http://127.0.0.1:18788/ingress/email",
NODE_INGRESS_SECRET: process.env.NODE_INGRESS_SECRET ?? "",
};
if (!env.NODE_INGRESS_SECRET) {
console.error("missing NODE_INGRESS_SECRET env");
process.exit(1);
}
const PORT = Number(process.env.WMAILHUB_WORKER_PORT ?? 18789);
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
fetch: (req) => worker.fetch(req, env),
});
console.log(
JSON.stringify({
t: new Date().toISOString(),
lvl: "info",
msg: "email worker started",
port: PORT,
node_ingress: env.NODE_INGRESS_URL,
}),
);
process.on("SIGINT", () => {
server.stop(true);
process.exit(0);
});
process.on("SIGTERM", () => {
server.stop(true);
process.exit(0);
});