v2.8.0: MCP 新增 list_domains 工具;create_mailbox/MCP 与 REST 支持 webhook 参数、省略 domain_id 时随机分配已启用 Email Workers 的域名;修复 webhook 校验后置导致的半截创建(先校验再建邮箱);/docs 页与 docs/07 同步
This commit is contained in:
@@ -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 已过期" },
|
||||
|
||||
@@ -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
@@ -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 ?? "");
|
||||
|
||||
Reference in New Issue
Block a user