v2.9.2: 开放 API 域名可见性修复——GET /api/v1/domains 与 MCP list_domains 只返回可用域名(active+已绑 zone),指定 domain_id 建邮箱同样校验可用,杜绝未接入域名被报成可用
This commit is contained in:
@@ -22,9 +22,9 @@ const MCP_TOOLS = [
|
|||||||
/** # 成员变量: REST API 端点清单(与 routes/apiv1.ts 一致) **/
|
/** # 成员变量: REST API 端点清单(与 routes/apiv1.ts 一致) **/
|
||||||
const API_ENDPOINTS = [
|
const API_ENDPOINTS = [
|
||||||
{ method: "GET", path: "/accounts", desc: "账号列表(不含已撤销)" },
|
{ 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: "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: "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 已过期" },
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ export function apiV1Routes(): App {
|
|||||||
return c.json({ items: rows.results });
|
return c.json({ items: rows.results });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 域名列表 */
|
/** 域名列表(只返回可用域名:status='active' 且已绑定 zone,即 Email Workers 就绪;与随机分配建邮箱同一判据) */
|
||||||
app.get("/domains", async (c) => {
|
app.get("/domains", async (c) => {
|
||||||
const rows = await c.env.DB.prepare(
|
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();
|
).all();
|
||||||
return c.json({ items: rows.results });
|
return c.json({ items: rows.results });
|
||||||
});
|
});
|
||||||
@@ -50,12 +51,16 @@ export function apiV1Routes(): App {
|
|||||||
app.post("/mailboxes", async (c) => {
|
app.post("/mailboxes", async (c) => {
|
||||||
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; webhook?: string }>(c.req.raw);
|
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);
|
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) {
|
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)
|
.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);
|
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 {
|
} else {
|
||||||
domain = await c.env.DB.prepare(
|
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",
|
"SELECT id, name FROM domains WHERE status = 'active' AND zone_id IS NOT NULL ORDER BY RANDOM() LIMIT 1",
|
||||||
|
|||||||
@@ -173,8 +173,10 @@ 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": {
|
case "list_domains": {
|
||||||
|
// 只返回可用域名(active + 已绑定 zone,Email Workers 就绪),与随机分配建邮箱同一判据
|
||||||
const rows = await env.DB.prepare(
|
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();
|
).all();
|
||||||
return { domains: rows.results };
|
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);
|
if (!rawAddress) throw new McpToolError("address required", -32602);
|
||||||
let domain: { id: string; name: string } | null;
|
let domain: { id: string; name: string } | null;
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
// 指定域名:校验存在
|
// 指定域名:校验存在且可用(pending/未装 Email Workers 的域名收不到件)
|
||||||
domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
const row = await env.DB.prepare("SELECT id, name, status, zone_id FROM domains WHERE id = ?")
|
||||||
.bind(domainId)
|
.bind(domainId)
|
||||||
.first<{ id: string; name: string }>();
|
.first<{ id: string; name: string; status: string; zone_id: string | null }>();
|
||||||
if (!domain) throw new McpToolError("domain not found", -32002);
|
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 {
|
} else {
|
||||||
// 省略域名:随机分配一个已启用 Email Workers 的域名;此时地址必须是本地部分
|
// 省略域名:随机分配一个已启用 Email Workers 的域名;此时地址必须是本地部分
|
||||||
if (rawAddress.includes("@")) {
|
if (rawAddress.includes("@")) {
|
||||||
|
|||||||
+5
-1
@@ -164,6 +164,9 @@ Webhook 生效顺序:**邮箱 → 域名 → 全局**,取第一个已配置的
|
|||||||
|
|
||||||
`{ items: [{ id, name, status, account_id, created_at }] }`
|
`{ items: [{ id, name, status, account_id, created_at }] }`
|
||||||
|
|
||||||
|
**只返回可用域名(v2.9.2 起)**:`status = 'active'` 且已绑定 zone(即 Email Workers 就绪)的域名;
|
||||||
|
未完成接入的域名(pending/error)不会出现在列表里,也不能用于创建邮箱。
|
||||||
|
|
||||||
### GET /api/v1/mailboxes
|
### GET /api/v1/mailboxes
|
||||||
|
|
||||||
`{ items: [{ id, address, status, is_catch_all, retention_seconds, domain_id, domain_name }] }`
|
`{ 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.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/元数据地址)。
|
- `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
|
### DELETE /api/v1/mailboxes/:id
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.9.1",
|
"version": "2.9.2",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+60
-2
@@ -80,6 +80,8 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
|||||||
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);
|
||||||
|
// 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(
|
const create = await t.app.fetch(
|
||||||
new Request("http://localhost/api/v1/mailboxes", {
|
new Request("http://localhost/api/v1/mailboxes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -169,6 +171,8 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
|||||||
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);
|
||||||
|
// 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, {
|
const email = await ingestEmail(t.env, {
|
||||||
raw: new TextEncoder().encode(TEST_MAIL),
|
raw: new TextEncoder().encode(TEST_MAIL),
|
||||||
mailFrom: "a@b.c",
|
mailFrom: "a@b.c",
|
||||||
@@ -238,7 +242,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
|||||||
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);
|
||||||
// 种子域名无 zone_id(未启用 Email Workers);再补一个已启用的域名
|
// 种子域名无 zone_id(未启用 Email Workers,v2.9.2 起不进可用列表);再补一个已启用的域名
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await t.env.DB.prepare(
|
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', ?, ?)",
|
"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 {
|
const domainsData = JSON.parse(((await domains.json()) as { result: { content: { text: string }[] } }).result.content[0].text) as {
|
||||||
domains: { id: string; name: string }[];
|
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 参数生效
|
// 省略 domain_id:必须落在已启用 Email Workers 的域名上,且 webhook 参数生效
|
||||||
const created = await mcpCall(t, key, {
|
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();
|
const mcpRow = await t.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind("mcpbad@example.test").first();
|
||||||
expect(mcpRow).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user