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
+362
View File
@@ -0,0 +1,362 @@
/**
* Console 管理路由(Node / Domain / Mailbox / Email)。
* 全部要求会话登录。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts";
import { upsertMailbox, upsertDomain } from "../services/provision.ts";
import { decryptSecret } from "../db.ts";
import { ids } from "../../../shared/src/index.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 Node / Domain / Mailbox / Email 管理路由
*/
export function resourceRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
installNodeRoutes(app);
installDomainRoutes(app);
installMailboxRoutes(app);
installEmailRoutes(app);
return app;
}
/** # 函数
* Node CRUD 与安装状态
*/
function installNodeRoutes(app: App) {
/** Node 列表(不含任何 secret) */
app.get("/nodes", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, created_at, updated_at
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */
app.post("/nodes", async (c) => {
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
const name = (body.name ?? "").trim();
if (!name) return c.json({ error: "name required" }, 400);
const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim();
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid ingress_url" }, 400);
}
const created = await createNode(c.env, { name, ingressUrl });
return c.json({
ok: true,
node_id: created.nodeId,
enrollment_token: created.enrollmentToken,
ingress_secret: created.ingressSecret,
worker_name: emailWorkerNameFor(created.nodeId),
}, 201);
});
/** Node 详情:含安装步骤状态与 OAuth 连接状态 */
app.get("/nodes/:id", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, enrollment_expires_at, created_at, updated_at
FROM nodes WHERE id = ?`,
)
.bind(id)
.first();
if (!node) return c.json({ error: "not found" }, 404);
const steps = await loadSteps(c.env, id);
const oauth = await c.env.DB.prepare(
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
).first();
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?")
.bind(id)
.all();
return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results });
});
/** 删除 Node(软删:撤销 credential + 标记 revoked) */
app.delete("/nodes/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
)
.bind(nowIso(), id)
.run();
await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?")
.bind(nowIso(), id)
.run();
return c.json({ ok: true });
});
/** 重新生成 enrollment token(credential 轮换入口) */
app.post("/nodes/:id/rotate", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(id)
.first();
if (!node) return c.json({ error: "not found" }, 404);
const token = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await c.env.DB.prepare(
"UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?",
)
.bind(token, new Date(Date.now() + 24 * 3600 * 1000).toISOString(), nowIso(), id)
.run();
return c.json({ ok: true, enrollment_token: token });
});
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
app.get("/nodes/:id/ingress-secret", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
.bind(id)
.first<{ ingress_secret_enc: string }>();
if (!node) return c.json({ error: "not found" }, 404);
return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) });
});
}
/** # 函数
* Domain CRUD
*/
function installDomainRoutes(app: App) {
app.get("/domains", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT d.*, n.name AS node_name,
(SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count
FROM domains d LEFT JOIN nodes n ON n.id = d.node_id
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM domains").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */
app.post("/domains", async (c) => {
const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw);
const name = (body.name ?? "").trim().toLowerCase();
if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400);
const id = await upsertDomain(c.env, {
cloudflareAccountId: body.cloudflare_account_id ?? "manual",
nodeId: body.node_id ?? null as unknown as string,
name,
zoneId: "manual",
workerId: null as unknown as string,
});
return c.json({ ok: true, id }, 201);
});
app.delete("/domains/:id", async (c) => {
const id = c.req.param("id");
const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?")
.bind(id)
.first<{ n: number }>();
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
}
/** # 函数
* Mailbox CRUD
*/
function installMailboxRoutes(app: App) {
app.get("/mailboxes", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const domainId = new URL(c.req.url).searchParams.get("domain_id");
const where = domainId ? "WHERE m.domain_id = ?" : "";
const rows = await c.env.DB.prepare(
`SELECT m.*, d.name AS domain_name,
(SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`,
);
const bound = domainId ? rows.bind(domainId, limit, offset) : rows.bind(limit, offset);
const res = await bound.all();
const totalRows = domainId
? await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?").bind(domainId).first<{ n: number }>()
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes").first<{ n: number }>();
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
});
app.post("/mailboxes", async (c) => {
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; catch_all?: boolean }>(c.req.raw);
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400);
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(body.domain_id)
.first<{ id: string; name: string }>();
if (!domain) return c.json({ error: "domain not found" }, 404);
let address = body.address.trim().toLowerCase();
if (body.catch_all) address = `*@${domain.name}`;
else if (!address.includes("@")) address = `${address}@${domain.name}`;
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400);
const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first();
if (exists) return c.json({ error: "mailbox already exists" }, 409);
const id = await upsertMailbox(c.env, {
domainId: body.domain_id,
address,
isCatchAll: Boolean(body.catch_all),
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
});
return c.json({ ok: true, id, address }, 201);
});
app.patch("/mailboxes/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ status?: string; retention_seconds?: number | null }>(c.req.raw);
if (body.status && !["active", "disabled"].includes(body.status)) {
return c.json({ error: "invalid status" }, 400);
}
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
if (!mailbox) return c.json({ error: "not found" }, 404);
if (body.status) {
await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?")
.bind(body.status, nowIso(), id)
.run();
}
if (body.retention_seconds !== undefined) {
await c.env.DB.prepare("UPDATE mailboxes SET retention_seconds = ?, updated_at = ? WHERE id = ?")
.bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id)
.run();
}
return c.json({ ok: true });
});
app.delete("/mailboxes/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
}
/** # 函数
* Email 查询 / 详情 / 附件 / 手动删除
*/
function installEmailRoutes(app: App) {
app.get("/emails", async (c) => {
const url = new URL(c.req.url);
const { limit, offset } = pageParams(url);
const mailboxId = url.searchParams.get("mailbox_id");
const q = url.searchParams.get("q");
const conds: string[] = [];
const binds: unknown[] = [];
if (mailboxId) {
conds.push("mailbox_id = ?");
binds.push(mailboxId);
}
if (q) {
conds.push("(subject LIKE ? OR from_address LIKE ?)");
binds.push(`%${q}%`, `%${q}%`);
}
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
const rows = await c.env.DB.prepare(
`SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size,
e.received_at, e.expires_at, m.address AS mailbox_address,
(SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
);
const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all();
const totalRows = binds.length
? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM emails ${where}`).bind(...binds).first<{ n: number }>()
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
});
app.get("/emails/:id", async (c) => {
const id = c.req.param("id");
const email = await c.env.DB.prepare(
`SELECT e.*, m.address AS mailbox_address FROM emails e
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
)
.bind(id)
.first();
if (!email) return c.json({ error: "not found" }, 404);
const atts = await c.env.DB.prepare(
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
)
.bind(id)
.all();
return c.json({ email, attachments: atts.results });
});
/** 附件下载 */
app.get("/emails/:id/attachments/:attId", async (c) => {
const attId = c.req.param("attId");
const att = await c.env.DB.prepare("SELECT filename, content_type, storage_key FROM email_attachments WHERE id = ? AND email_id = ?")
.bind(attId, c.req.param("id"))
.first<{ filename: string; content_type: string; storage_key: string }>();
if (!att) return c.json({ error: "not found" }, 404);
const obj = await c.env.R2.get(att.storage_key);
if (!obj) return c.json({ error: "object missing" }, 404);
const buf = await obj.arrayBuffer();
return new Response(buf, {
headers: {
"Content-Type": att.content_type || "application/octet-stream",
"Content-Disposition": `attachment; filename="${encodeURIComponent(att.filename)}"`,
},
});
});
/** raw MIME 下载(.eml) */
app.get("/emails/:id/raw", async (c) => {
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
.bind(c.req.param("id"))
.first<{ raw_storage_key: string }>();
if (!email) return c.json({ error: "not found" }, 404);
const obj = await c.env.R2.get(email.raw_storage_key);
if (!obj) return c.json({ error: "object missing" }, 404);
return new Response(await obj.arrayBuffer(), {
headers: {
"Content-Type": "message/rfc822",
"Content-Disposition": `attachment; filename="${c.req.param("id")}.eml"`,
},
});
});
app.delete("/emails/:id", async (c) => {
const id = c.req.param("id");
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
.bind(id)
.first<{ raw_storage_key: string }>();
if (!email) return c.json({ error: "not found" }, 404);
const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
.bind(id)
.all<{ storage_key: string | null }>();
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[];
if (keys.length) await c.env.R2.delete(keys);
await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
}
/** # 函数
* webhook endpoint 的基本格式校验(创建时调用)
*/
export function validateEndpoint(endpoint: string): string | null {
try {
new URL(endpoint);
} catch {
return "invalid url";
}
return checkWebhookUrl(endpoint);
}