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 一致) **/ /** # 成员变量: MCP 工具清单(与 /api/v1/mcp tools/list 一致) **/
const MCP_TOOLS = [ const MCP_TOOLS = [
{ name: "list_domains", params: "—", desc: "列出可用域名(创建邮箱的 domain_id 由此获取)" },
{ name: "list_mailboxes", params: "—", desc: "列出所有邮箱(域名、catch-all 标记、保留秒数)" }, { name: "list_mailboxes", params: "—", desc: "列出所有邮箱(域名、catch-all 标记、保留秒数)" },
{ name: "list_emails", params: "mailbox_id? / limit?", desc: "列出未过期邮件,limit 默认 20、最大 100" }, { name: "list_emails", params: "mailbox_id? / limit?", desc: "列出未过期邮件,limit 默认 20、最大 100" },
{ name: "get_email", params: "id", desc: "单封邮件详情(正文 + 附件元数据);过期邮件报错" }, { 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: "删除邮箱" }, { name: "delete_mailbox", params: "id", desc: "删除邮箱" },
]; ];
@@ -23,7 +24,7 @@ const API_ENDPOINTS = [
{ method: "GET", path: "/accounts", desc: "账号列表(不含已撤销)" }, { method: "GET", path: "/accounts", desc: "账号列表(不含已撤销)" },
{ method: "GET", path: "/domains", desc: "域名列表" }, { method: "GET", path: "/domains", desc: "域名列表" },
{ method: "GET", path: "/mailboxes", 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: "DELETE", path: "/mailboxes/:id", desc: "删除邮箱" },
{ method: "GET", path: "/emails", desc: "未过期邮件列表,query: mailbox_id? / limit / offset" }, { method: "GET", path: "/emails", desc: "未过期邮件列表,query: mailbox_id? / limit / offset" },
{ method: "GET", path: "/emails/:id", desc: "邮件详情 + 附件元数据;410 已过期" }, { method: "GET", path: "/emails/:id", desc: "邮件详情 + 附件元数据;410 已过期" },
+26 -6
View File
@@ -6,7 +6,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts"; import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts"; import { requireApiKey } from "../middleware.ts";
import { saveScopedWebhook } from "../services/webhook_delivery.ts";
import { upsertMailbox } from "../services/provision.ts"; import { upsertMailbox } from "../services/provision.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { nowIso, pageParams, readJson } from "../util.ts"; import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>; type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -44,25 +46,43 @@ export function apiV1Routes(): App {
return c.json({ items: rows.results }); return c.json({ items: rows.results });
}); });
/** 创建邮箱 */ /** 创建邮箱(domain_id 省略时随机分配已启用 Email Workers 的域名;可选 webhook URL) */
app.post("/mailboxes", async (c) => { app.post("/mailboxes", async (c) => {
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number }>(c.req.raw); const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; webhook?: string }>(c.req.raw);
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400); if (!body.address) return c.json({ error: "address required" }, 400);
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?") 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) .bind(body.domain_id)
.first<{ id: string; name: string }>(); .first<{ id: string; name: string }>();
if (!domain) return c.json({ error: "domain not found" }, 404); 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(); 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}`; if (!address.includes("@")) address = `${address}@${domain.name}`;
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400); 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(); 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); 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, { const id = await upsertMailbox(c.env, {
domainId: body.domain_id, domainId: domain.id,
address, address,
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined, 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);
}); });
/** 删除邮箱 */ /** 删除邮箱 */
+51 -14
View File
@@ -1,7 +1,7 @@
/** /**
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。 * /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);邮件强制检查未过期。 * 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器 * 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
* 返回普通 JSON);GET 返回 405(无 SSE 流)。 * 返回普通 JSON);GET 返回 405(无 SSE 流)。
@@ -10,8 +10,10 @@
import { Hono } from "hono"; import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts"; import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts"; import { requireApiKey } from "../middleware.ts";
import { saveScopedWebhook } from "../services/webhook_delivery.ts";
import { upsertMailbox } from "../services/provision.ts"; import { upsertMailbox } from "../services/provision.ts";
import { APP_VERSION } from "../version.ts"; import { APP_VERSION } from "../version.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { nowIso, readJson } from "../util.ts"; import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>; type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -21,6 +23,11 @@ const MCP_PROTOCOL_VERSION = "2025-06-18";
/** # 成员变量: MCP 工具定义(JSON Schema) **/ /** # 成员变量: MCP 工具定义(JSON Schema) **/
const TOOLS = [ const TOOLS = [
{
name: "list_domains",
description: "列出可用域名(创建邮箱的 domain_id 由此获取)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{ {
name: "list_mailboxes", name: "list_mailboxes",
description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)", description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)",
@@ -50,15 +57,16 @@ const TOOLS = [
}, },
{ {
name: "create_mailbox", name: "create_mailbox",
description: "在指定域名下创建邮箱", description: "创建邮箱;省略 domain_id 时随机分配一个已启用 Email Workers 的域名",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
domain_id: { type: "string", description: "域名 ID(dom_...)" }, domain_id: { type: "string", description: "可选域名 ID(dom_...);省略时随机分配" },
address: { type: "string", description: "邮箱地址或本地部分" }, address: { type: "string", description: "邮箱地址或本地部分;省略 domain_id 时必须为本地部分(不含 @)" },
retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" }, retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" },
webhook: { type: "string", description: "可选邮箱级 Webhook URL(该邮箱收到邮件时 POST)" },
}, },
required: ["domain_id", "address"], required: ["address"],
additionalProperties: false, additionalProperties: false,
}, },
}, },
@@ -164,6 +172,12 @@ async function callTool(
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> { async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) { 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": { case "list_mailboxes": {
const rows = await env.DB.prepare( 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 `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 }; return { email, attachments: atts.results };
} }
case "create_mailbox": { case "create_mailbox": {
const domainId = String(args.domain_id ?? ""); let domainId = String(args.domain_id ?? "");
const address = String(args.address ?? "").trim().toLowerCase(); const rawAddress = String(args.address ?? "").trim().toLowerCase();
if (!domainId || !address) throw new McpToolError("domain_id and address required", -32602); if (!rawAddress) throw new McpToolError("address required", -32602);
const domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?") let domain: { id: string; name: string } | null;
if (domainId) {
// 指定域名:校验存在
domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(domainId) .bind(domainId)
.first<{ id: string; name: string }>(); .first<{ id: string; name: string }>();
if (!domain) throw new McpToolError("domain not found", -32002); if (!domain) throw new McpToolError("domain not found", -32002);
let full = address; } else {
if (!full.includes("@")) full = `${full}@${domain.name}`; // 省略域名:随机分配一个已启用 Email Workers 的域名;此时地址必须是本地部分
else if (!full.endsWith(`@${domain.name}`)) throw new McpToolError(`address must be @${domain.name}`, -32602); 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(); const exists = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(full).first();
if (exists) throw new McpToolError("mailbox already exists", -32002); 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 retention = args.retention_seconds ? Number(args.retention_seconds) : undefined;
const id = await upsertMailbox(env, { domainId, address: full, retentionSeconds: retention }); const id = await upsertMailbox(env, { domainId: domain.id, address: full, retentionSeconds: retention });
return { id, address: full }; if (webhook) await saveScopedWebhook(env, "mailboxes", id, { url: webhook });
return { id, address: full, ...(webhook ? { webhook } : {}) };
} }
case "delete_mailbox": { case "delete_mailbox": {
const id = String(args.id ?? ""); const id = String(args.id ?? "");
+4 -2
View File
@@ -43,8 +43,10 @@ API 属于 Console,Bearer API Key(wmh_)认证。
## MCP ## MCP
MCP 允许 Agent 查询邮箱、获取邮件、创建/删除邮箱(list_mailboxes、list_emails、get_email、 MCP 允许 Agent 查询域名/邮箱、获取邮件、创建/删除邮箱(list_domains、list_mailboxes、list_emails、
create_mailbox、delete_mailbox),Bearer MCP Key(wmh_)认证。 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 位,设置模态窗口中生成/轮换/撤销), **单一 Key(v2.1.0)**:API 与 MCP 共用同一个 Key(wmh_ 开头 32 位,设置模态窗口中生成/轮换/撤销),
凭据存 settings 表(api_key_hash/api_key_last4/api_key_created_at),不再单独建 api_credentials 表; 凭据存 settings 表(api_key_hash/api_key_last4/api_key_created_at),不再单独建 api_credentials 表;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wmailhub", "name": "wmailhub",
"version": "2.7.0", "version": "2.8.0",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
"private": true, "private": true,
"type": "module", "type": "module",
+89 -1
View File
@@ -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 t = await makeTestEnv();
const cookie = await loginCookie(t); const cookie = await loginCookie(t);
const key = await createKey(t, cookie); 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 tools = await mcpCall(t, key, { jsonrpc: "2.0", id: 2, method: "tools/list" });
const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } }; const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } };
expect(toolsData.result.tools.map((x) => x.name)).toEqual([ expect(toolsData.result.tools.map((x) => x.name)).toEqual([
"list_domains",
"list_mailboxes", "list_mailboxes",
"list_emails", "list_emails",
"get_email", "get_email",
@@ -232,4 +233,91 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
); );
expect(get.status).toBe(405); 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();
});
}); });
+2 -2
View File
@@ -297,8 +297,8 @@ check(
docsText.includes("Authorization: Bearer wmh_"), docsText.includes("Authorization: Bearer wmh_"),
); );
check( check(
"文档页两张清单表(5 个 MCP 工具 + 7 个 API 端点)", "文档页两张清单表(6 个 MCP 工具 + 7 个 API 端点)",
(await page.locator(".ant-table-tbody tr").count()) >= 12, (await page.locator(".ant-table-tbody tr").count()) >= 13,
); );
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOT_DIR}/17-docs.png` }); await page.screenshot({ path: `${SHOT_DIR}/17-docs.png` });