v2.8.0: MCP 新增 list_domains 工具;create_mailbox/MCP 与 REST 支持 webhook 参数、省略 domain_id 时随机分配已启用 Email Workers 的域名;修复 webhook 校验后置导致的半截创建(先校验再建邮箱);/docs 页与 docs/07 同步

This commit is contained in:
bigcode
2026-10-01 02:54:27 +08:00
parent fa0730c6dd
commit 6e40f555f3
7 changed files with 182 additions and 34 deletions
+3 -2
View File
@@ -11,10 +11,11 @@ const MCP_URL = `${window.location.origin}/api/v1/mcp`;
/** # 成员变量: MCP 工具清单(与 /api/v1/mcp tools/list 一致) **/
const MCP_TOOLS = [
{ name: "list_domains", params: "—", desc: "列出可用域名(创建邮箱的 domain_id 由此获取)" },
{ name: "list_mailboxes", params: "—", desc: "列出所有邮箱(域名、catch-all 标记、保留秒数)" },
{ name: "list_emails", params: "mailbox_id? / limit?", desc: "列出未过期邮件,limit 默认 20、最大 100" },
{ name: "get_email", params: "id", desc: "单封邮件详情(正文 + 附件元数据);过期邮件报错" },
{ name: "create_mailbox", params: "domain_id / address / retention_seconds?", desc: "创建邮箱;address 可传本地部分或完整地址,保留默认 86400 秒" },
{ name: "create_mailbox", params: "domain_id? / address / retention_seconds? / webhook?", desc: "创建邮箱;省略 domain_id 时随机分配一个已启用 Email Workers 的域名(address 须为本地部分)" },
{ name: "delete_mailbox", params: "id", desc: "删除邮箱" },
];
@@ -23,7 +24,7 @@ const API_ENDPOINTS = [
{ method: "GET", path: "/accounts", desc: "账号列表(不含已撤销)" },
{ method: "GET", path: "/domains", desc: "域名列表" },
{ method: "GET", path: "/mailboxes", desc: "邮箱列表" },
{ method: "POST", path: "/mailboxes", desc: "创建邮箱,body: { domain_id, address, retention_seconds? };409 已存在" },
{ method: "POST", path: "/mailboxes", desc: "创建邮箱,body: { address, domain_id?, retention_seconds?, webhook? };省略 domain_id 随机分配;409 已存在/无可用域名" },
{ method: "DELETE", path: "/mailboxes/:id", desc: "删除邮箱" },
{ method: "GET", path: "/emails", desc: "未过期邮件列表,query: mailbox_id? / limit / offset" },
{ method: "GET", path: "/emails/:id", desc: "邮件详情 + 附件元数据;410 已过期" },
+29 -9
View File
@@ -6,7 +6,9 @@
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts";
import { saveScopedWebhook } from "../services/webhook_delivery.ts";
import { upsertMailbox } from "../services/provision.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -44,25 +46,43 @@ export function apiV1Routes(): App {
return c.json({ items: rows.results });
});
/** 创建邮箱 */
/** 创建邮箱(domain_id 省略时随机分配已启用 Email Workers 的域名;可选 webhook URL) */
app.post("/mailboxes", async (c) => {
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number }>(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);
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; webhook?: string }>(c.req.raw);
if (!body.address) return c.json({ error: "address required" }, 400);
let domain: { id: string; name: string } | null;
if (body.domain_id) {
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);
} else {
domain = await c.env.DB.prepare(
"SELECT id, name FROM domains WHERE status = 'active' AND zone_id IS NOT NULL ORDER BY RANDOM() LIMIT 1",
).first<{ id: string; name: string }>();
if (!domain) return c.json({ error: "no available domain (none has Email Workers enabled)" }, 409);
}
let address = body.address.trim().toLowerCase();
if (!body.domain_id && address.includes("@")) {
return c.json({ error: "address must be a local part (no @) when domain_id is omitted" }, 400);
}
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);
// 可选邮箱级 Webhook:先校验再建邮箱,避免校验失败留下半截创建
const webhook = typeof body.webhook === "string" ? body.webhook.trim() : "";
if (webhook) {
const unsafe = checkWebhookUrl(webhook, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: unsafe }, 400);
}
const id = await upsertMailbox(c.env, {
domainId: body.domain_id,
domainId: domain.id,
address,
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
});
return c.json({ ok: true, id, address }, 201);
if (webhook) await saveScopedWebhook(c.env, "mailboxes", id, { url: webhook });
return c.json({ ok: true, id, address, ...(webhook ? { webhook } : {}) }, 201);
});
/** 删除邮箱 */
+54 -17
View File
@@ -1,7 +1,7 @@
/**
* /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_domains / 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 流)。
@@ -10,8 +10,10 @@
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts";
import { saveScopedWebhook } from "../services/webhook_delivery.ts";
import { upsertMailbox } from "../services/provision.ts";
import { APP_VERSION } from "../version.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -21,6 +23,11 @@ const MCP_PROTOCOL_VERSION = "2025-06-18";
/** # 成员变量: MCP 工具定义(JSON Schema) **/
const TOOLS = [
{
name: "list_domains",
description: "列出可用域名(创建邮箱的 domain_id 由此获取)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "list_mailboxes",
description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)",
@@ -50,15 +57,16 @@ const TOOLS = [
},
{
name: "create_mailbox",
description: "在指定域名下创建邮箱",
description: "创建邮箱;省略 domain_id 时随机分配一个已启用 Email Workers 的域名",
inputSchema: {
type: "object",
properties: {
domain_id: { type: "string", description: "域名 ID(dom_...)" },
address: { type: "string", description: "邮箱地址或本地部分" },
domain_id: { type: "string", description: "可选域名 ID(dom_...);省略时随机分配" },
address: { type: "string", description: "邮箱地址或本地部分;省略 domain_id 时必须为本地部分(不含 @)" },
retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" },
webhook: { type: "string", description: "可选邮箱级 Webhook URL(该邮箱收到邮件时 POST)" },
},
required: ["domain_id", "address"],
required: ["address"],
additionalProperties: false,
},
},
@@ -164,6 +172,12 @@ async function callTool(
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case "list_domains": {
const rows = await env.DB.prepare(
"SELECT id, name, status, account_id, created_at FROM domains ORDER BY created_at DESC",
).all();
return { domains: rows.results };
}
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
@@ -211,21 +225,44 @@ async function dispatchTool(env: Env, name: string, args: Record<string, unknown
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);
let domainId = String(args.domain_id ?? "");
const rawAddress = String(args.address ?? "").trim().toLowerCase();
if (!rawAddress) throw new McpToolError("address required", -32602);
let domain: { id: string; name: string } | null;
if (domainId) {
// 指定域名:校验存在
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);
} else {
// 省略域名:随机分配一个已启用 Email Workers 的域名;此时地址必须是本地部分
if (rawAddress.includes("@")) {
throw new McpToolError("address must be a local part (no @) when domain_id is omitted", -32602);
}
domain = await env.DB.prepare(
"SELECT id, name FROM domains WHERE status = 'active' AND zone_id IS NOT NULL ORDER BY RANDOM() LIMIT 1",
).first<{ id: string; name: string }>();
if (!domain) throw new McpToolError("no available domain (none has Email Workers enabled)", -32002);
}
const full = rawAddress.includes("@")
? rawAddress
: `${rawAddress}@${domain.name}`;
if (full.endsWith(`@${domain.name}`) === false) {
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);
// 可选邮箱级 Webhook:先校验再建邮箱,避免校验失败留下半截创建
const webhook = typeof args.webhook === "string" ? args.webhook.trim() : "";
if (webhook) {
const unsafe = checkWebhookUrl(webhook, env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) throw new McpToolError(unsafe, -32602);
}
const retention = args.retention_seconds ? Number(args.retention_seconds) : undefined;
const id = await upsertMailbox(env, { domainId, address: full, retentionSeconds: retention });
return { id, address: full };
const id = await upsertMailbox(env, { domainId: domain.id, address: full, retentionSeconds: retention });
if (webhook) await saveScopedWebhook(env, "mailboxes", id, { url: webhook });
return { id, address: full, ...(webhook ? { webhook } : {}) };
}
case "delete_mailbox": {
const id = String(args.id ?? "");