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 一致) **/
|
/** # 成员变量: 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 已过期" },
|
||||||
|
|||||||
@@ -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;
|
||||||
.bind(body.domain_id)
|
if (body.domain_id) {
|
||||||
.first<{ id: string; name: string }>();
|
domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
||||||
if (!domain) return c.json({ error: "domain not found" }, 404);
|
.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();
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 删除邮箱 */
|
/** 删除邮箱 */
|
||||||
|
|||||||
+54
-17
@@ -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;
|
||||||
.bind(domainId)
|
if (domainId) {
|
||||||
.first<{ id: string; name: string }>();
|
// 指定域名:校验存在
|
||||||
if (!domain) throw new McpToolError("domain not found", -32002);
|
domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
||||||
let full = address;
|
.bind(domainId)
|
||||||
if (!full.includes("@")) full = `${full}@${domain.name}`;
|
.first<{ id: string; name: string }>();
|
||||||
else if (!full.endsWith(`@${domain.name}`)) throw new McpToolError(`address must be @${domain.name}`, -32602);
|
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();
|
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 ?? "");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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` });
|
||||||
|
|||||||
Reference in New Issue
Block a user