From 6e40f555f3b4d7d5d50a4f6cfaf26906a1cda632 Mon Sep 17 00:00:00 2001 From: bigcode Date: Thu, 1 Oct 2026 02:54:27 +0800 Subject: [PATCH] =?UTF-8?q?v2.8.0:=20MCP=20=E6=96=B0=E5=A2=9E=20list=5Fdom?= =?UTF-8?q?ains=20=E5=B7=A5=E5=85=B7;create=5Fmailbox/MCP=20=E4=B8=8E=20RE?= =?UTF-8?q?ST=20=E6=94=AF=E6=8C=81=20webhook=20=E5=8F=82=E6=95=B0=E3=80=81?= =?UTF-8?q?=E7=9C=81=E7=95=A5=20domain=5Fid=20=E6=97=B6=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=88=86=E9=85=8D=E5=B7=B2=E5=90=AF=E7=94=A8=20Email=20Workers?= =?UTF-8?q?=20=E7=9A=84=E5=9F=9F=E5=90=8D;=E4=BF=AE=E5=A4=8D=20webhook=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=90=8E=E7=BD=AE=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=8D=8A=E6=88=AA=E5=88=9B=E5=BB=BA(=E5=85=88=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=86=8D=E5=BB=BA=E9=82=AE=E7=AE=B1);/docs=20?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=20docs/07=20=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/frontend/src/pages/Docs.tsx | 5 +- console/src/routes/apiv1.ts | 38 +++++++++--- console/src/routes/mcp.ts | 71 +++++++++++++++++------ docs/07_WEBHOOK_API_MCP.md | 6 +- package.json | 2 +- tests/api-mcp.test.ts | 90 ++++++++++++++++++++++++++++- tests/ui-smoke.ts | 4 +- 7 files changed, 182 insertions(+), 34 deletions(-) diff --git a/console/frontend/src/pages/Docs.tsx b/console/frontend/src/pages/Docs.tsx index 9992535..77df9fa 100644 --- a/console/frontend/src/pages/Docs.tsx +++ b/console/frontend/src/pages/Docs.tsx @@ -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 已过期" }, diff --git a/console/src/routes/apiv1.ts b/console/src/routes/apiv1.ts index 6e6b367..d3dc2cd 100644 --- a/console/src/routes/apiv1.ts +++ b/console/src/routes/apiv1.ts @@ -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); }); /** 删除邮箱 */ diff --git a/console/src/routes/mcp.ts b/console/src/routes/mcp.ts index d2748c9..ec7ef4c 100644 --- a/console/src/routes/mcp.ts +++ b/console/src/routes/mcp.ts @@ -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): Promise { 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(); - 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 ?? ""); diff --git a/docs/07_WEBHOOK_API_MCP.md b/docs/07_WEBHOOK_API_MCP.md index c24b31e..279949c 100644 --- a/docs/07_WEBHOOK_API_MCP.md +++ b/docs/07_WEBHOOK_API_MCP.md @@ -43,8 +43,10 @@ API 属于 Console,Bearer API Key(wmh_)认证。 ## MCP -MCP 允许 Agent 查询邮箱、获取邮件、创建/删除邮箱(list_mailboxes、list_emails、get_email、 -create_mailbox、delete_mailbox),Bearer MCP Key(wmh_)认证。 +MCP 允许 Agent 查询域名/邮箱、获取邮件、创建/删除邮箱(list_domains、list_mailboxes、list_emails、 +get_email、create_mailbox、delete_mailbox),Bearer MCP Key(wmh_)认证。 +create_mailbox(v2.8.0 起)的 domain_id 可省略,省略时随机分配一个已启用 Email Workers 的域名 +(此时 address 必须为本地部分);另支持可选 webhook 参数,创建时直接配置邮箱级 Webhook URL。 **单一 Key(v2.1.0)**:API 与 MCP 共用同一个 Key(wmh_ 开头 32 位,设置模态窗口中生成/轮换/撤销), 凭据存 settings 表(api_key_hash/api_key_last4/api_key_created_at),不再单独建 api_credentials 表; diff --git a/package.json b/package.json index 00d6351..85c2b40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.7.0", + "version": "2.8.0", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", diff --git a/tests/api-mcp.test.ts b/tests/api-mcp.test.ts index cbc0376..d7d2fd5 100644 --- a/tests/api-mcp.test.ts +++ b/tests/api-mcp.test.ts @@ -144,7 +144,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { ); } - test("initialize + tools/list 返回五个工具与 serverInfo", async () => { + test("initialize + tools/list 返回六个工具与 serverInfo", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const key = await createKey(t, cookie); @@ -156,6 +156,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { const tools = await mcpCall(t, key, { jsonrpc: "2.0", id: 2, method: "tools/list" }); const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } }; expect(toolsData.result.tools.map((x) => x.name)).toEqual([ + "list_domains", "list_mailboxes", "list_emails", "get_email", @@ -232,4 +233,91 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { ); expect(get.status).toBe(405); }); + + test("list_domains 返回域名;create_mailbox 省略 domain_id 随机分配 + webhook 入库", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + const key = await createKey(t, cookie); + // 种子域名无 zone_id(未启用 Email Workers);再补一个已启用的域名 + const now = new Date().toISOString(); + await t.env.DB.prepare( + "INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, zone_id, created_at, updated_at) VALUES (?, 'cfacct-1', ?, 'ready.test', 'active', 'zone-ready', ?, ?)", + ).bind("dom_ready", t.accountId, now, now).run(); + + const domains = await mcpCall(t, key, { jsonrpc: "2.0", id: 11, method: "tools/call", params: { name: "list_domains", arguments: {} } }); + const domainsData = JSON.parse(((await domains.json()) as { result: { content: { text: string }[] } }).result.content[0].text) as { + domains: { id: string; name: string }[]; + }; + expect(domainsData.domains.map((d) => d.name).sort()).toEqual(["example.test", "ready.test"]); + + // 省略 domain_id:必须落在已启用 Email Workers 的域名上,且 webhook 参数生效 + const created = await mcpCall(t, key, { + jsonrpc: "2.0", + id: 12, + method: "tools/call", + params: { name: "create_mailbox", arguments: { address: "lucky", webhook: "https://hooks.example.com/wmh" } }, + }); + const createdData = JSON.parse(((await created.json()) as { result: { content: { text: string }[] } }).result.content[0].text) as { + address: string; + }; + expect(createdData.address).toBe("lucky@ready.test"); + const row = await t.env.DB.prepare("SELECT webhook_url FROM mailboxes WHERE address = ?").bind("lucky@ready.test").first<{ webhook_url: string }>(); + expect(row?.webhook_url).toBe("https://hooks.example.com/wmh"); + + // 带完整地址且省略 domain_id → 明确报错 + const badAddr = await mcpCall(t, key, { + jsonrpc: "2.0", + id: 13, + method: "tools/call", + params: { name: "create_mailbox", arguments: { address: "x@example.test" } }, + }); + expect(((await badAddr.json()) as { error: { message: string } }).error.message).toContain("local part"); + + // 无已启用域名可用 → no available domain + await t.env.DB.prepare("UPDATE domains SET zone_id = NULL WHERE id = 'dom_ready'").run(); + const none = await mcpCall(t, key, { + jsonrpc: "2.0", + id: 14, + method: "tools/call", + params: { name: "create_mailbox", arguments: { address: "lucky2" } }, + }); + expect(((await none.json()) as { error: { message: string } }).error.message).toContain("no available domain"); + }); + + test("webhook 校验失败必须整单拒绝,不留半截邮箱(API 与 MCP)", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + const key = await createKey(t, cookie); + await t.env.DB.prepare("UPDATE domains SET zone_id = 'zone-ready' WHERE id = ?").bind(t.domainId).run(); + + const restBad = await t.app.fetch( + new Request("http://localhost/api/v1/mailboxes", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ address: "halfbad", webhook: "ftp://example.com/hook" }), + }), + t.env, + ); + expect(restBad.status).toBe(400); + const restRow = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind("halfbad@example.test").first(); + expect(restRow).toBeNull(); + + const mcpBad = await t.app.fetch( + new Request("http://localhost/api/v1/mcp", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 20, + method: "tools/call", + params: { name: "create_mailbox", arguments: { address: "mcpbad", webhook: "ftp://example.com/hook" } }, + }), + }), + t.env, + ); + const mcpData = (await mcpBad.json()) as { error: { code: number } }; + expect(mcpData.error.code).toBe(-32602); + const mcpRow = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind("mcpbad@example.test").first(); + expect(mcpRow).toBeNull(); + }); }); diff --git a/tests/ui-smoke.ts b/tests/ui-smoke.ts index f563ca7..48ca0b8 100644 --- a/tests/ui-smoke.ts +++ b/tests/ui-smoke.ts @@ -297,8 +297,8 @@ check( docsText.includes("Authorization: Bearer wmh_"), ); check( - "文档页两张清单表(5 个 MCP 工具 + 7 个 API 端点)", - (await page.locator(".ant-table-tbody tr").count()) >= 12, + "文档页两张清单表(6 个 MCP 工具 + 7 个 API 端点)", + (await page.locator(".ant-table-tbody tr").count()) >= 13, ); await page.waitForTimeout(500); await page.screenshot({ path: `${SHOT_DIR}/17-docs.png` });