v2.9.2: 开放 API 域名可见性修复——GET /api/v1/domains 与 MCP list_domains 只返回可用域名(active+已绑 zone),指定 domain_id 建邮箱同样校验可用,杜绝未接入域名被报成可用

This commit is contained in:
bigcode
2026-10-03 23:54:28 +08:00
parent 52d5827932
commit 1c7dba025b
6 changed files with 89 additions and 16 deletions
+2 -2
View File
@@ -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 已过期" },
+10 -5
View File
@@ -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",
+11 -5
View File
@@ -173,8 +173,10 @@ async function callTool(
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
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<string, unknown
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 = ?")
// 指定域名:校验存在且可用(pending/未装 Email Workers 的域名收不到件)
const row = await env.DB.prepare("SELECT id, name, status, zone_id FROM domains WHERE id = ?")
.bind(domainId)
.first<{ id: string; name: string }>();
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("@")) {
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+60 -2
View File
@@ -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);
});
});