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
+339
View File
@@ -0,0 +1,339 @@
/**
* Node 自动安装管线(docs/02 流程):
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
* 创建 Domain/Mailbox -> Node Ready。
*
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
*/
import type { Env } from "../env.ts";
import { getCFApi, type CFApi } from "./cfapi.ts";
import { encryptSecret, decryptSecret } from "../db.ts";
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: 安装步骤定义(顺序即展示顺序) **/
export const PROVISION_STEPS = [
{ key: "oauth", label: "Cloudflare OAuth 授权" },
{ key: "identity", label: "Node identity 与 credential" },
{ key: "discover", label: "Account / Zone 发现" },
{ key: "email_worker", label: "Email Worker 创建/更新" },
{ key: "email_routing", label: "Email Routing 配置" },
{ key: "domains", label: "Domain 创建" },
{ key: "mailboxes", label: "Mailbox 创建" },
] as const;
/** # 成员变量: 步骤状态 **/
export type StepStatus = "pending" | "running" | "done" | "error";
/** # 函数
* 初始化步骤状态 JSON
*/
export function initialSteps(): Record<string, { status: StepStatus; error?: string }> {
const out: Record<string, { status: StepStatus; error?: string }> = {};
for (const s of PROVISION_STEPS) out[s.key] = { status: "pending" };
return out;
}
/** # 函数
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
*/
export function emailWorkerNameFor(nodeId: string): string {
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
}
/** # 函数
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
*/
export async function createNode(
env: Env,
opts: { name: string; ingressUrl: string },
): Promise<{
nodeId: string;
enrollmentToken: string;
ingressSecret: string;
}> {
const nodeId = ids.node();
const now = nowIso();
const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, "");
const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version,
last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at,
provision_steps, created_at, updated_at)
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
nodeId,
opts.name,
opts.ingressUrl,
await encryptText(env, ingressSecret),
enrollmentToken,
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
JSON.stringify(initialSteps()),
now,
now,
)
.run();
return { nodeId, enrollmentToken, ingressSecret };
}
/** # 函数
* Node bootstrap enrollment:用一次性 token 换取正式身份。
* 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。
*/
export async function enrollNode(
env: Env,
enrollmentToken: string,
): Promise<{
node_id: string;
credential_id: string;
credential_secret: string;
ingress_secret: string;
console_url: string | null;
} | null> {
const node = await env.DB.prepare(
"SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'",
)
.bind(enrollmentToken)
.first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>();
if (!node) return null;
if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null;
// 轮换:撤销旧 credential,签发新 credential
await env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
)
.bind(nowIso(), node.id)
.run();
const credentialId = ids.credential();
const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)",
)
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
.run();
await env.DB.prepare(
"UPDATE nodes SET credential_id = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
)
.bind(credentialId, nowIso(), node.id)
.run();
return {
node_id: node.id,
credential_id: credentialId,
credential_secret: secret,
ingress_secret: await decryptText(env, node.ingress_secret_enc),
console_url: null,
};
}
async function encryptText(env: Env, text: string): Promise<string> {
return encryptSecret(env, text);
}
async function decryptText(env: Env, blob: string): Promise<string> {
return decryptSecret(env, blob);
}
/** # 成员变量: 授权完成后的完整安装流程参数 **/
export interface ProvisionInput {
nodeId: string;
accessToken: string;
accountId?: string;
}
/** # 函数
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
* 返回是否全部成功;失败时步骤状态里带 error。
*/
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
const cf: CFApi = getCFApi(env, env.DB);
const node = await env.DB.prepare(
"SELECT id, name, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
)
.bind(input.nodeId)
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
if (!node) return { ok: false, error: "node not found" };
const steps = await loadSteps(env, input.nodeId);
const mark = async (key: string, status: StepStatus, error?: string) => {
steps[key] = { status, error };
await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?")
.bind(JSON.stringify(steps), nowIso(), input.nodeId)
.run();
};
try {
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
.run();
// 1. OAuth 授权
await mark("oauth", "done");
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
await mark("identity", "done");
// 3. Account/Zone 发现
await mark("discover", "running");
const accounts = await cf.listAccounts(input.accessToken);
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
const account = input.accountId
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
: accounts[0];
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
.bind(account.id, nowIso(), input.nodeId)
.run();
const zones = await cf.listZones(input.accessToken, account.id);
await mark("discover", "done");
// 4. Email Worker(幂等上传)
await mark("email_worker", "running");
const workerName = emailWorkerNameFor(input.nodeId);
const ingressSecret = await decryptText(env, node.ingress_secret_enc);
await cf.putEmailWorker({
accessToken: input.accessToken,
accountId: account.id,
scriptName: workerName,
ingressUrl: node.ingress_url,
ingressSecret,
});
await mark("email_worker", "done");
// 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain
await mark("email_routing", "running");
for (const zone of zones) {
await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id });
await cf.setCatchAllRule({
accessToken: input.accessToken,
zoneId: zone.id,
workerName,
});
await upsertDomain(env, {
cloudflareAccountId: account.id,
nodeId: input.nodeId,
name: zone.name,
zoneId: zone.id,
workerId: workerName,
});
}
await mark("email_routing", "done");
await mark("domains", "done");
// 7. 每个 Domain 创建 catch-all Mailbox(*@domain)
await mark("mailboxes", "running");
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
.bind(input.nodeId)
.all<{ id: string; name: string }>();
for (const d of domainRows.results) {
await upsertMailbox(env, {
domainId: d.id,
address: `*@${d.name}`,
isCatchAll: true,
});
}
await mark("mailboxes", "done");
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
.run();
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// 标记第一个未完成步骤为 error
for (const s of PROVISION_STEPS) {
if (steps[s.key]?.status === "running") {
await mark(s.key, "error", message);
break;
}
}
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
.run();
return { ok: false, error: message };
}
}
/** # 函数
* 读取并解析 node 的步骤状态
*/
export async function loadSteps(env: Env, nodeId: string) {
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
.bind(nodeId)
.first<{ provision_steps: string }>();
if (!row?.provision_steps) return initialSteps();
try {
return JSON.parse(row.provision_steps) as Record<string, { status: StepStatus; error?: string }>;
} catch {
return initialSteps();
}
}
/** # 函数
* upsert Domain(按 account+name 幂等)
*/
export async function upsertDomain(
env: Env,
d: {
cloudflareAccountId: string;
nodeId: string;
name: string;
zoneId: string;
workerId: string;
},
): Promise<string> {
const existing = await env.DB.prepare(
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
)
.bind(d.cloudflareAccountId, d.name)
.first<{ id: string }>();
const now = nowIso();
if (existing) {
await env.DB.prepare(
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
)
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
.run();
return existing.id;
}
const id = ids.domain();
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)",
)
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
.run();
return id;
}
/** # 函数
* upsert Mailbox(按 address 幂等)
*/
export async function upsertMailbox(
env: Env,
m: { domainId: string; address: string; isCatchAll?: boolean; retentionSeconds?: number },
): Promise<string> {
const existing = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?")
.bind(m.address.toLowerCase())
.first<{ id: string }>();
const now = nowIso();
if (existing) return existing.id;
const id = ids.mailbox();
await env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, retention_seconds, is_catch_all, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?)",
)
.bind(
id,
m.domainId,
m.address.toLowerCase(),
m.retentionSeconds ?? DEFAULT_RETENTION_SECONDS,
m.isCatchAll ? 1 : 0,
now,
now,
)
.run();
return id;
}