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,36 @@
|
||||
/**
|
||||
* 前端构建:bun build 打包 React SPA → console/public/
|
||||
* 产物:index.html + assets/main.js + logo/favicon。
|
||||
*/
|
||||
|
||||
import { cpSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const FRONTEND = path.join(ROOT, "console/frontend");
|
||||
const PUBLIC = path.join(ROOT, "console/public");
|
||||
|
||||
mkdirSync(path.join(PUBLIC, "assets"), { recursive: true });
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(FRONTEND, "src/main.tsx")],
|
||||
outdir: path.join(PUBLIC, "assets"),
|
||||
naming: "[name].[ext]",
|
||||
target: "browser",
|
||||
minify: true,
|
||||
splitting: false,
|
||||
sourcemap: process.env.WMAILHUB_DEV ? "linked" : "none",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) console.error(log);
|
||||
console.error("[build] frontend build failed");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
cpSync(path.join(FRONTEND, "index.html"), path.join(PUBLIC, "index.html"));
|
||||
cpSync(path.join(ROOT, "assets/logo.svg"), path.join(PUBLIC, "logo.svg"));
|
||||
cpSync(path.join(ROOT, "assets/favicon.svg"), path.join(PUBLIC, "favicon.svg"));
|
||||
cpSync(path.join(ROOT, "assets/favicon.ico"), path.join(PUBLIC, "favicon.ico"));
|
||||
|
||||
console.log("[build] frontend -> console/public OK");
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 本地开发一键启动:console + node + email worker。
|
||||
* bun run dev # 常规启动(复用既有数据)
|
||||
* bun run dev -- --reset # 清空本地数据后全新安装
|
||||
*
|
||||
* 流程:
|
||||
* 1. 构建前端
|
||||
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
|
||||
* 3. 确保存在本地 Node(无则创建)并完成 sim 模式 OAuth 自动安装(幂等)
|
||||
* 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘)
|
||||
* 5. 启动 Email Worker(:18789,转发到 Node)
|
||||
* 6. Ctrl-C 一起退出
|
||||
*/
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
const CONSOLE_PORT = 18787;
|
||||
const NODE_PORT = 18788;
|
||||
const WORKER_PORT = 18789;
|
||||
const BASE = `http://127.0.0.1:${CONSOLE_PORT}`;
|
||||
|
||||
const reset = process.argv.includes("--reset");
|
||||
if (reset) {
|
||||
await Bun.$`rm -rf ${ROOT}console/data ${ROOT}node/config.json`.quiet();
|
||||
console.log("[dev] 本地数据已重置");
|
||||
}
|
||||
|
||||
/** 前端构建(产物已存在时可跳过:SKIP_BUILD=1) */
|
||||
if (!process.env.SKIP_BUILD) {
|
||||
console.log("[dev] 构建前端...");
|
||||
await Bun.$`bun run build`.cwd(ROOT);
|
||||
}
|
||||
|
||||
interface Child {
|
||||
name: string;
|
||||
proc: Bun.Subprocess<"ignore", "inherit", "inherit">;
|
||||
}
|
||||
|
||||
const children: Child[] = [];
|
||||
|
||||
/** 启动子进程并登记 */
|
||||
function spawn(name: string, args: string[], cwd: string, env?: Record<string, string>) {
|
||||
const proc = Bun.spawn(["bun", ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "ignore",
|
||||
});
|
||||
children.push({ name, proc });
|
||||
return proc;
|
||||
}
|
||||
|
||||
/** 等待 HTTP 就绪 */
|
||||
async function waitReady(url: string, label: string, tries = 60): Promise<void> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) return;
|
||||
} catch {
|
||||
// not ready
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`${label} 未就绪:${url}`);
|
||||
}
|
||||
|
||||
// 1. Console
|
||||
spawn("console", ["console/src/local/main.ts"], ROOT);
|
||||
await waitReady(`${BASE}/api/health`, "console");
|
||||
console.log("[dev] console ready");
|
||||
|
||||
// 2. 登录拿会话
|
||||
const loginResp = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "admin" }),
|
||||
});
|
||||
if (!loginResp.ok) throw new Error("admin 登录失败");
|
||||
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
|
||||
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
|
||||
|
||||
// 3. 确保本地 Node 存在
|
||||
const nodesResp = await fetch(`${BASE}/api/nodes`, { headers: authHeaders });
|
||||
const existing = ((await nodesResp.json()) as { items: { id: string; name: string }[] }).items;
|
||||
let nodeId: string;
|
||||
let ingressSecret: string;
|
||||
let enrollmentToken: string | null = null;
|
||||
|
||||
if (existing.length > 0) {
|
||||
nodeId = existing[0].id;
|
||||
ingressSecret = ((await (await fetch(`${BASE}/api/nodes/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
|
||||
// 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment
|
||||
const rotated = (await (
|
||||
await fetch(`${BASE}/api/nodes/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
|
||||
).json()) as { enrollment_token: string };
|
||||
enrollmentToken = rotated.enrollment_token;
|
||||
console.log(`[dev] 复用既有节点 ${nodeId}`);
|
||||
} else {
|
||||
const created = (await (
|
||||
await fetch(`${BASE}/api/nodes`, {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({ name: "Local Node", ingress_url: `http://127.0.0.1:${NODE_PORT}/ingress/email` }),
|
||||
})
|
||||
).json()) as { node_id: string; enrollment_token: string; ingress_secret: string };
|
||||
nodeId = created.node_id;
|
||||
ingressSecret = created.ingress_secret;
|
||||
enrollmentToken = created.enrollment_token;
|
||||
console.log(`[dev] 已创建节点 ${nodeId}`);
|
||||
}
|
||||
|
||||
// 4. sim OAuth 自动安装(幂等;节点已 online 时跳过)
|
||||
const nodeDetail = (await (await fetch(`${BASE}/api/nodes/${nodeId}`, { headers: authHeaders })).json()) as {
|
||||
node: { status: string };
|
||||
steps: Record<string, { status: string }>;
|
||||
};
|
||||
if (nodeDetail.node.status === "pending_oauth") {
|
||||
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start?node_id=${nodeId}`, { headers: authHeaders });
|
||||
const { state } = (await startResp.json()) as { state: string };
|
||||
const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
const approveRes = (await approve.json()) as { ok: boolean; error?: string };
|
||||
if (!approveRes.ok) throw new Error(`自动安装失败:${approveRes.error}`);
|
||||
console.log("[dev] Cloudflare OAuth(sim)自动安装完成:Email Worker / Routing / Domain / Mailbox 就绪");
|
||||
} else {
|
||||
console.log(`[dev] 节点已安装(status=${nodeDetail.node.status}),跳过安装`);
|
||||
}
|
||||
|
||||
// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份)
|
||||
const nodeEnv: Record<string, string> = {
|
||||
WMAILHUB_CONSOLE_URL: BASE,
|
||||
WMAILHUB_INGRESS_SECRET: ingressSecret,
|
||||
WMAILHUB_PORT: String(NODE_PORT),
|
||||
};
|
||||
if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken;
|
||||
spawn("node", ["node/src/main.ts"], ROOT, nodeEnv);
|
||||
await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node");
|
||||
console.log("[dev] node ready");
|
||||
|
||||
// 6. Email Worker
|
||||
spawn("worker", ["worker/src/local.ts"], ROOT, {
|
||||
NODE_INGRESS_URL: `http://127.0.0.1:${NODE_PORT}/ingress/email`,
|
||||
NODE_INGRESS_SECRET: ingressSecret,
|
||||
WMAILHUB_WORKER_PORT: String(WORKER_PORT),
|
||||
});
|
||||
await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker");
|
||||
console.log("[dev] email worker ready");
|
||||
|
||||
console.log("");
|
||||
console.log("======== WmailHub 本地环境已就绪 ========");
|
||||
console.log(` Console : ${BASE} (admin / admin)`);
|
||||
console.log(` Node : http://127.0.0.1:${NODE_PORT}/__health`);
|
||||
console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health`);
|
||||
console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\");
|
||||
console.log(" -H 'X-WmailHub-Ingress-Secret: <secret>' \\");
|
||||
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
|
||||
console.log("==========================================");
|
||||
|
||||
/** 退出时统一收割子进程 */
|
||||
function shutdown() {
|
||||
for (const { name, proc } of children) {
|
||||
proc.kill();
|
||||
console.log(`[dev] ${name} stopped`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
// 任一子进程退出则整体退出
|
||||
for (const { name, proc } of children) {
|
||||
void proc.exited.then((code) => {
|
||||
console.error(`[dev] ${name} exited with ${code}`);
|
||||
shutdown();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user