diff --git a/console/frontend/src/pages/Docs.tsx b/console/frontend/src/pages/Docs.tsx index 77df9fa..bb32c2e 100644 --- a/console/frontend/src/pages/Docs.tsx +++ b/console/frontend/src/pages/Docs.tsx @@ -22,9 +22,9 @@ const MCP_TOOLS = [ /** # 成员变量: REST API 端点清单(与 routes/apiv1.ts 一致) **/ const API_ENDPOINTS = [ { method: "GET", path: "/accounts", desc: "账号列表(不含已撤销)" }, - { method: "GET", path: "/domains", desc: "域名列表" }, + { method: "GET", path: "/domains", desc: "可用域名列表(仅 status=active 且已绑定 zone,即 Email Workers 就绪)" }, { method: "GET", path: "/mailboxes", desc: "邮箱列表" }, - { method: "POST", path: "/mailboxes", desc: "创建邮箱,body: { address, domain_id?, retention_seconds?, webhook? };省略 domain_id 随机分配;409 已存在/无可用域名" }, + { method: "POST", path: "/mailboxes", desc: "创建邮箱,body: { address, domain_id?, retention_seconds?, webhook? };省略 domain_id 随机分配;指定域名须可用(400 domain not available);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 d3dc2cd..a7f2fdb 100644 --- a/console/src/routes/apiv1.ts +++ b/console/src/routes/apiv1.ts @@ -29,10 +29,11 @@ export function apiV1Routes(): App { return c.json({ items: rows.results }); }); - /** 域名列表 */ + /** 域名列表(只返回可用域名:status='active' 且已绑定 zone,即 Email Workers 就绪;与随机分配建邮箱同一判据) */ app.get("/domains", async (c) => { const rows = await c.env.DB.prepare( - "SELECT id, name, status, account_id, created_at FROM domains ORDER BY created_at DESC", + `SELECT id, name, status, account_id, created_at FROM domains + WHERE status = 'active' AND zone_id IS NOT NULL ORDER BY created_at DESC`, ).all(); return c.json({ items: rows.results }); }); @@ -50,12 +51,16 @@ export function apiV1Routes(): App { app.post("/mailboxes", async (c) => { 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; + let domain: { id: string; name: string; status?: string; zone_id?: string | null } | null; if (body.domain_id) { - domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?") + domain = await c.env.DB.prepare("SELECT id, name, status, zone_id FROM domains WHERE id = ?") .bind(body.domain_id) - .first<{ id: string; name: string }>(); + .first<{ id: string; name: string; status: string; zone_id: string | null }>(); if (!domain) return c.json({ error: "domain not found" }, 404); + // 指定域名同样必须可用(未装 Email Workers 的 pending 域名收不到件,不可用于建邮箱) + if (domain.status !== "active" || !domain.zone_id) { + return c.json({ error: "domain not available (Email Workers not enabled)" }, 400); + } } 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", diff --git a/console/src/routes/mcp.ts b/console/src/routes/mcp.ts index ec7ef4c..dee6448 100644 --- a/console/src/routes/mcp.ts +++ b/console/src/routes/mcp.ts @@ -173,8 +173,10 @@ async function callTool( async function dispatchTool(env: Env, name: string, args: Record): Promise { switch (name) { case "list_domains": { + // 只返回可用域名(active + 已绑定 zone,Email Workers 就绪),与随机分配建邮箱同一判据 const rows = await env.DB.prepare( - "SELECT id, name, status, account_id, created_at FROM domains ORDER BY created_at DESC", + `SELECT id, name, status, account_id, created_at FROM domains + WHERE status = 'active' AND zone_id IS NOT NULL ORDER BY created_at DESC`, ).all(); return { domains: rows.results }; } @@ -230,11 +232,15 @@ async function dispatchTool(env: Env, name: string, args: Record(); - if (!domain) throw new McpToolError("domain not found", -32002); + .first<{ id: string; name: string; status: string; zone_id: string | null }>(); + if (!row) throw new McpToolError("domain not found", -32002); + if (row.status !== "active" || !row.zone_id) { + throw new McpToolError("domain not available (Email Workers not enabled)", -32002); + } + domain = row; } else { // 省略域名:随机分配一个已启用 Email Workers 的域名;此时地址必须是本地部分 if (rawAddress.includes("@")) { diff --git a/docs/API.md b/docs/API.md index 66fe9b8..96d0660 100644 --- a/docs/API.md +++ b/docs/API.md @@ -164,6 +164,9 @@ Webhook 生效顺序:**邮箱 → 域名 → 全局**,取第一个已配置的 `{ items: [{ id, name, status, account_id, created_at }] }` +**只返回可用域名(v2.9.2 起)**:`status = 'active'` 且已绑定 zone(即 Email Workers 就绪)的域名; +未完成接入的域名(pending/error)不会出现在列表里,也不能用于创建邮箱。 + ### GET /api/v1/mailboxes `{ items: [{ id, address, status, is_catch_all, retention_seconds, domain_id, domain_name }] }` @@ -175,8 +178,9 @@ Webhook 生效顺序:**邮箱 → 域名 → 全局**,取第一个已配置的 ``` - `domain_id` **可省略(v2.8.0 起)**:随机分配一个已启用 Email Workers 的域名;此时 `address` 必须为本地部分(不带 `@`)。 +- `domain_id` 指定时该域名同样必须可用(v2.9.2 起):未接入的域名返回 `400 domain not available (Email Workers not enabled)`。 - `webhook` **可选(v2.8.0 起)**:创建时直接配置邮箱级 Webhook(先校验再建邮箱,校验失败不留半截数据;SSRF 规则同 Console——拒绝私网/localhost/元数据地址)。 -- 返回 `201 { ok: true, id, address, webhook? }`;错误:`400 address required` / `404 domain not found` / `409 mailbox already exists` / `409 no available domain (none has Email Workers enabled)`。 +- 返回 `201 { ok: true, id, address, webhook? }`;错误:`400 address required` / `404 domain not found` / `400 domain not available (Email Workers not enabled)` / `409 mailbox already exists` / `409 no available domain (none has Email Workers enabled)`。 ### DELETE /api/v1/mailboxes/:id diff --git a/package.json b/package.json index 19c0eff..7a23f01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.9.1", + "version": "2.9.2", "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 d7d2fd5..3325436 100644 --- a/tests/api-mcp.test.ts +++ b/tests/api-mcp.test.ts @@ -80,6 +80,8 @@ describe("Console API v1(Bearer wmh_ Key)", () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const key = await createKey(t, cookie); + // v2.9.2 起指定域名建邮箱要求域名可用(已绑 zone),先给种子域名绑上 + await t.env.DB.prepare("UPDATE domains SET zone_id = 'zone-v1' WHERE id = ?").bind(t.domainId).run(); const create = await t.app.fetch( new Request("http://localhost/api/v1/mailboxes", { method: "POST", @@ -169,6 +171,8 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const key = await createKey(t, cookie); + // v2.9.2 起指定域名建邮箱要求域名可用(已绑 zone),先给种子域名绑上 + await t.env.DB.prepare("UPDATE domains SET zone_id = 'zone-mcp' WHERE id = ?").bind(t.domainId).run(); const email = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", @@ -238,7 +242,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const key = await createKey(t, cookie); - // 种子域名无 zone_id(未启用 Email Workers);再补一个已启用的域名 + // 种子域名无 zone_id(未启用 Email Workers,v2.9.2 起不进可用列表);再补一个已启用的域名 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', ?, ?)", @@ -248,7 +252,8 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { 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"]); + // 未启用 Email Workers 的域名不可见 + expect(domainsData.domains.map((d) => d.name)).toEqual(["ready.test"]); // 省略 domain_id:必须落在已启用 Email Workers 的域名上,且 webhook 参数生效 const created = await mcpCall(t, key, { @@ -320,4 +325,57 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { const mcpRow = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind("mcpbad@example.test").first(); expect(mcpRow).toBeNull(); }); + + test("域名可见性与建邮箱可用校验:未启用 Email Workers 的域名不可见、不可指定(v2.9.2)", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + const key = await createKey(t, cookie); + + // 种子域名 active 但未绑 zone → GET /api/v1/domains 不返回它 + const rest = await t.app.fetch( + new Request("http://localhost/api/v1/domains", { headers: { Authorization: `Bearer ${key}` } }), + t.env, + ); + expect(rest.status).toBe(200); + expect(((await rest.json()) as { items: { name: string }[] }).items.map((d) => d.name)).not.toContain("example.test"); + + // 显式指定未启用域名建邮箱:REST 400 / MCP -32002 + 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({ domain_id: t.domainId, address: "pendingbox" }), + }), + t.env, + ); + expect(restBad.status).toBe(400); + expect(((await restBad.json()) as { error: string }).error).toContain("not available"); + + const mcpBad = await mcpCall(t, key, { + jsonrpc: "2.0", + id: 30, + method: "tools/call", + params: { name: "create_mailbox", arguments: { domain_id: t.domainId, address: "pendingmcp" } }, + }); + expect(((await mcpBad.json()) as { error: { message: string } }).error.message).toContain("not available"); + const left = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE address = 'pendingbox@example.test'").first(); + expect(left).toBeNull(); + + // 绑上 zone 后变为可用:列表可见、创建成功 + await t.env.DB.prepare("UPDATE domains SET zone_id = 'zone-ok' WHERE id = ?").bind(t.domainId).run(); + const rest2 = await t.app.fetch( + new Request("http://localhost/api/v1/domains", { headers: { Authorization: `Bearer ${key}` } }), + t.env, + ); + expect(((await rest2.json()) as { items: { name: string }[] }).items.map((d) => d.name)).toContain("example.test"); + const ok = await t.app.fetch( + new Request("http://localhost/api/v1/mailboxes", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ domain_id: t.domainId, address: "pendingbox" }), + }), + t.env, + ); + expect(ok.status).toBe(201); + }); });