- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建) - 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选 - Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口 - 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除) - UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框 - 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react) - 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录 - 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
241 lines
8.7 KiB
TypeScript
241 lines
8.7 KiB
TypeScript
/**
|
|
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
|
|
*
|
|
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox
|
|
* 权限:基于 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,
|
|
},
|
|
},
|
|
] 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 };
|
|
}
|
|
default:
|
|
throw new McpToolError(`unknown tool: ${name}`, -32601);
|
|
}
|
|
}
|