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
+251
View File
@@ -0,0 +1,251 @@
/**
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
*
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox / list_webhooks
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
* 返回普通 JSON);GET 返回 405(无 SSE 流)。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts";
import { upsertMailbox } from "../services/provision.ts";
import { APP_VERSION } from "../version.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: MCP 协议版本 **/
const MCP_PROTOCOL_VERSION = "2025-06-18";
/** # 成员变量: MCP 工具定义(JSON Schema) **/
const TOOLS = [
{
name: "list_mailboxes",
description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "list_emails",
description: "列出邮件(仅未过期),可按 mailbox_id 过滤",
inputSchema: {
type: "object",
properties: {
mailbox_id: { type: "string", description: "可选,按邮箱过滤" },
limit: { type: "number", description: "可选,默认 20,最大 100" },
},
additionalProperties: false,
},
},
{
name: "get_email",
description: "获取单封邮件详情(正文与附件元数据);过期邮件返回 410 语义错误",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "邮件 ID(msg_...)" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "create_mailbox",
description: "在指定域名下创建邮箱",
inputSchema: {
type: "object",
properties: {
domain_id: { type: "string", description: "域名 ID(dom_...)" },
address: { type: "string", description: "邮箱地址或本地部分" },
retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" },
},
required: ["domain_id", "address"],
additionalProperties: false,
},
},
{
name: "delete_mailbox",
description: "删除邮箱",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "邮箱 ID(mbx_...)" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "list_webhooks",
description: "列出 Webhook(不含 secret)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
] as const;
/** # 函数
* 装配 MCP 路由
*/
export function mcpRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireApiKey("mcp"));
app.post("/", async (c) => {
const rpc = await readJson<JsonRpcRequest>(c.req.raw);
if (typeof rpc.jsonrpc !== "string" || typeof rpc.method !== "string") {
return c.json(jsonRpcError(null, -32600, "invalid request"), 400);
}
const id = rpc.id ?? null;
try {
switch (rpc.method) {
case "initialize":
return c.json({
jsonrpc: "2.0",
id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "wmailhub", version: APP_VERSION },
},
});
case "notifications/initialized":
return new Response(null, { status: 202 });
case "ping":
return c.json({ jsonrpc: "2.0", id, result: {} });
case "tools/list":
return c.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
case "tools/call": {
const params = (rpc.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
const result = await callTool(c.env, params.name ?? "", params.arguments ?? {});
return c.json({ jsonrpc: "2.0", id, result });
}
default:
return c.json(jsonRpcError(id, -32601, `method not found: ${rpc.method}`), 200);
}
} catch (err) {
const message = err instanceof McpToolError ? err.message : `internal error: ${err instanceof Error ? err.message : String(err)}`;
const code = err instanceof McpToolError ? err.rpcCode : -32603;
return c.json(jsonRpcError(id, code, message), 200);
}
});
app.get("/", (c) => c.json({ error: "POST JSON-RPC requests only" }, 405));
return app;
}
/** # 成员变量: JSON-RPC 请求结构 **/
interface JsonRpcRequest {
jsonrpc: string;
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
}
/** # 函数
* 构造 JSON-RPC error 响应
*/
function jsonRpcError(id: string | number | null, code: number, message: string) {
return { jsonrpc: "2.0", id, error: { code, message } };
}
/** # 成员变量: 工具层错误(映射到 JSON-RPC error code) **/
class McpToolError extends Error {
constructor(
message: string,
public rpcCode: number,
) {
super(message);
}
}
/** # 函数
* 工具调用分发
*/
async function callTool(
env: Env,
name: string,
args: Record<string, unknown>,
): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> {
const data = await dispatchTool(env, name, args);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case "list_mailboxes": {
const rows = await env.DB.prepare(
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, d.name AS domain_name
FROM mailboxes m JOIN domains d ON d.id = m.domain_id ORDER BY m.created_at DESC`,
).all();
return { mailboxes: rows.results };
}
case "list_emails": {
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const mailboxId = typeof args.mailbox_id === "string" ? args.mailbox_id : null;
const conds = ["e.expires_at > ?"];
const binds: unknown[] = [nowIso()];
if (mailboxId) {
conds.push("e.mailbox_id = ?");
binds.push(mailboxId);
}
const rows = await env.DB.prepare(
`SELECT e.id, e.mailbox_id, m.address AS mailbox_address, e.from_address, e.subject,
e.size, e.received_at, e.expires_at
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
WHERE ${conds.join(" AND ")} ORDER BY e.received_at DESC LIMIT ?`,
)
.bind(...binds, limit)
.all();
return { emails: rows.results };
}
case "get_email": {
const id = String(args.id ?? "");
if (!id) throw new McpToolError("id required", -32602);
const email = await 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) throw new McpToolError("email not found", -32002);
if ((email as { expires_at: string }).expires_at <= nowIso()) {
throw new McpToolError("email expired", -32002);
}
const atts = await env.DB.prepare(
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
)
.bind(id)
.all();
return { email, attachments: atts.results };
}
case "create_mailbox": {
const domainId = String(args.domain_id ?? "");
const address = String(args.address ?? "").trim().toLowerCase();
if (!domainId || !address) throw new McpToolError("domain_id and address required", -32602);
const domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(domainId)
.first<{ id: string; name: string }>();
if (!domain) throw new McpToolError("domain not found", -32002);
let full = address;
if (!full.includes("@")) full = `${full}@${domain.name}`;
else if (!full.endsWith(`@${domain.name}`)) throw new McpToolError(`address must be @${domain.name}`, -32602);
const exists = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(full).first();
if (exists) throw new McpToolError("mailbox already exists", -32002);
const retention = args.retention_seconds ? Number(args.retention_seconds) : undefined;
const id = await upsertMailbox(env, { domainId, address: full, retentionSeconds: retention });
return { id, address: full };
}
case "delete_mailbox": {
const id = String(args.id ?? "");
if (!id) throw new McpToolError("id required", -32602);
const res = await env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
return { ok: true };
}
case "list_webhooks": {
const rows = await env.DB.prepare(
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
).all();
return { webhooks: rows.results };
}
default:
throw new McpToolError(`unknown tool: ${name}`, -32601);
}
}