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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user