diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index a2db0db..235d132 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -247,10 +247,12 @@ export function AccountsPage() { e.stopPropagation(); modal.confirm({ title: `删除账号 ${row.name}?`, - content: "将撤销该账号的 Worker 凭据并标记为 revoked;Email Worker 将无法访问 Console。", + content: + "将撤销该账号的 Worker 凭据并标记为 revoked;同时删除 Cloudflare 上的 Email Worker 与各域名的 Email Routing 配置。", onOk: async () => { - await deleteAccount(row.id); - message.success("已删除"); + const r = (await deleteAccount(row.id)) as { warnings?: string[] }; + if (r.warnings?.length) message.warning(`已删除,但 Cloudflare 清理未完全成功:${r.warnings[0]}`); + else message.success("已删除(Cloudflare 侧资源已一并清理)"); void load(); }, }); diff --git a/console/frontend/src/pages/Domains.tsx b/console/frontend/src/pages/Domains.tsx index 47b6d88..c39cb91 100644 --- a/console/frontend/src/pages/Domains.tsx +++ b/console/frontend/src/pages/Domains.tsx @@ -222,11 +222,13 @@ export function DomainsPage() { onClick={() => modal.confirm({ title: `删除域名 ${row.name}?`, - content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。", + content: + "将同时关闭该域名在 Cloudflare 的 Email Routing;仅在域名无邮箱时可删,删除后重新 OAuth 授权会再次同步。", onOk: async () => { try { - await deleteDomain(row.id); - message.success("已删除"); + const r = (await deleteDomain(row.id)) as { warnings?: string[] }; + if (r.warnings?.length) message.warning(`已删除,但 Cloudflare 清理未完全成功:${r.warnings[0]}`); + else message.success("已删除(Cloudflare 侧 Email Routing 已关闭)"); void load(); } catch (err) { message.error(err instanceof Error ? err.message : "删除失败"); diff --git a/console/src/routes/resources.ts b/console/src/routes/resources.ts index 29f5153..66c9cf9 100644 --- a/console/src/routes/resources.ts +++ b/console/src/routes/resources.ts @@ -16,7 +16,7 @@ import { Hono } from "hono"; import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; -import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts"; +import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount, teardownAccountResources, teardownDomainRouting, type TeardownResult } from "../services/provision.ts"; import { getCFApi } from "../services/cfapi.ts"; import { logOperation, operatorName } from "../services/logs.ts"; import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts"; @@ -119,14 +119,20 @@ async function deleteEmailsByMailbox(env: Env, mailboxId: string): Promise { +async function revokeAccount( + env: Env, + id: string, +): Promise<{ deleted: boolean; name: string | null; cleanup: TeardownResult | null }> { const row = await env.DB.prepare("SELECT id, name FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'") .bind(id) .first<{ id: string; name: string }>(); - if (!row) return { deleted: false, name: null }; + if (!row) return { deleted: false, name: null, cleanup: null }; + // 先清 Cloudflare 侧资源(要读账号数据,须在标记 revoked 前做),失败仅告警 + const cleanup = await teardownAccountResources(env, id); await env.DB.prepare( "UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'", ) @@ -135,7 +141,8 @@ async function revokeAccount(env: Env, id: string): Promise<{ deleted: boolean; await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?") .bind(nowIso(), id) .run(); - return { deleted: true, name: row.name }; + const touched = cleanup.routingDisabled > 0 || cleanup.workersDeleted.length > 0 || cleanup.errors.length > 0; + return { deleted: true, name: row.name, cleanup: touched ? cleanup : null }; } /** # 函数 @@ -204,7 +211,7 @@ function installAccountRoutes(app: App) { return c.json({ ok: true }); }); - /** 删除账号(软删:撤销 Worker credential + 标记 revoked) */ + /** 删除账号(软删:撤销 Worker credential + 标记 revoked;并清理 Cloudflare 侧 Worker 与域名 Email Routing) */ app.delete("/accounts/:id", async (c) => { const id = c.req.param("id"); const r = await revokeAccount(c.env, id); @@ -214,23 +221,36 @@ function installAccountRoutes(app: App) { targetType: "account", targetId: id, targetName: r.name, + detail: r.cleanup + ? { + routing_disabled: r.cleanup.routingDisabled, + workers_deleted: r.cleanup.workersDeleted, + errors: r.cleanup.errors, + } + : undefined, operator: await operatorName(c.env, c.get("userId")), }); } - return c.json({ ok: true }); + return c.json({ ok: true, warnings: r.cleanup?.errors ?? [] }); }); - /** 批量删除账号(软删 + 撤销 Worker credential);域名数据保留 */ + /** 批量删除账号(软删 + 撤销 Worker credential + Cloudflare 侧清理);域名数据保留 */ app.post("/accounts/batch-delete", async (c) => { const ids = await readIds(c); const failed: { id: string; reason: string }[] = []; const deletedNames: string[] = []; + const warnings: string[] = []; + const cleanups: TeardownResult[] = []; let deleted = 0; for (const id of ids) { const r = await revokeAccount(c.env, id); if (r.deleted) { deleted++; if (r.name) deletedNames.push(r.name); + if (r.cleanup) { + cleanups.push(r.cleanup); + warnings.push(...r.cleanup.errors); + } } else { failed.push({ id, reason: "账号不存在或已删除" }); } @@ -240,11 +260,21 @@ function installAccountRoutes(app: App) { action: "account.batch_delete", targetType: "account", targetName: deletedNames.join("、"), - detail: { deleted, ids }, + detail: { + deleted, + ids, + ...(cleanups.length + ? { + routing_disabled: cleanups.reduce((n, x) => n + x.routingDisabled, 0), + workers_deleted: cleanups.flatMap((x) => x.workersDeleted), + errors: warnings, + } + : {}), + }, operator: await operatorName(c.env, c.get("userId")), }); } - return c.json({ ok: true, deleted, failed }); + return c.json({ ok: true, deleted, failed, warnings }); }); /** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见); @@ -468,6 +498,8 @@ function installDomainRoutes(app: App) { .bind(id) .first<{ n: number }>(); if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409); + // 清理 Cloudflare 侧 Email Routing(未配置过则跳过;失败仅告警,不阻断删除) + const teardown = await teardownDomainRouting(c.env, id); await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run(); if (domain) { await logOperation(c.env, { @@ -475,10 +507,11 @@ function installDomainRoutes(app: App) { targetType: "domain", targetId: id, targetName: domain.name, + detail: teardown.error ? { warnings: [teardown.error] } : undefined, operator: await operatorName(c.env, c.get("userId")), }); } - return c.json({ ok: true }); + return c.json({ ok: true, warnings: teardown.error ? [teardown.error] : [] }); }); /** 批量删除域名:级联删除该域名下所有邮箱(含 catch-all)及其邮件 @@ -488,13 +521,19 @@ function installDomainRoutes(app: App) { const ids = await readIds(c); const failed: { id: string; reason: string }[] = []; const deletedNames: string[] = []; + const warnings: string[] = []; let deleted = 0; let mailboxes = 0; let emails = 0; + let routingDisabled = 0; for (const id of ids) { const name = ( await c.env.DB.prepare("SELECT name FROM domains WHERE id = ?").bind(id).first<{ name: string }>() )?.name ?? null; + // 逐域名清理 Cloudflare 侧 Email Routing(要在本地行删除前做,清理函数要读行) + const teardown = await teardownDomainRouting(c.env, id); + if (teardown.error) warnings.push(teardown.error); + if (teardown.cleaned) routingDisabled++; const boxes = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE domain_id = ?") .bind(id) .all<{ id: string }>(); @@ -517,11 +556,17 @@ function installDomainRoutes(app: App) { action: "domain.batch_delete", targetType: "domain", targetName: deletedNames.join("、"), - detail: { deleted, mailboxes, emails, ids }, + detail: { + deleted, + mailboxes, + emails, + ids, + ...(routingDisabled > 0 || warnings.length ? { routing_disabled: routingDisabled, errors: warnings } : {}), + }, operator: await operatorName(c.env, c.get("userId")), }); } - return c.json({ ok: true, deleted, mailboxes, emails, failed }); + return c.json({ ok: true, deleted, mailboxes, emails, failed, warnings }); }); } diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index cf0d3d6..3785340 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -447,6 +447,10 @@ export interface CFApi { zoneId: string; workerName: string; }): Promise<{ ok: boolean }>; + /** 禁用 Zone 的 Email Routing(删除域名时的 Cloudflare 侧清理,只有 POST 真生效) */ + disableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ ok: boolean }>; + /** 删除账号级 Email Worker 脚本(删除账号时的 Cloudflare 侧清理) */ + deleteWorkerScript(opts: { accessToken: string; accountId: string; scriptName: string }): Promise<{ ok: boolean }>; } /** # 函数 @@ -718,4 +722,22 @@ export class RealCFApi implements CFApi { }); return { ok: true }; } + + /** 禁用 Zone 的 Email Routing(删除域名时的 Cloudflare 侧清理; + * 与 enable 同理只有 POST 真生效,PUT/PATCH 是假成功,失败抛 CFApiError) */ + async disableEmailRouting(opts: { accessToken: string; zoneId: string }) { + await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/disable`, { method: "POST" }); + return { ok: true }; + } + + /** 删除账号级 Email Worker 脚本(删除账号时的 Cloudflare 侧清理,失败抛 CFApiError; + * 脚本在 Cloudflare 侧已被手动删掉时这里会报错,由调用方降级为告警文案) */ + async deleteWorkerScript(opts: { accessToken: string; accountId: string; scriptName: string }) { + await this.api( + opts.accessToken, + `/accounts/${opts.accountId}/workers/scripts/${encodeURIComponent(opts.scriptName)}`, + { method: "DELETE" }, + ); + return { ok: true }; + } } diff --git a/console/src/services/provision.ts b/console/src/services/provision.ts index 5629572..4b6addb 100644 --- a/console/src/services/provision.ts +++ b/console/src/services/provision.ts @@ -1,10 +1,11 @@ /** * 账号接入管线(docs/11,Connect Cloudflare Account): * 添加账号(建 cloudflare_accounts 记录)-> Cloudflare OAuth -> 授权回调 -> - * Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱 -> + * Account/Zone 发现 -> 同步账号下全部域名(pending)-> * Email Worker 部署 + WorkerCredential 签发(账号级)。 * - * Email Routing/catch-all 规则不随 OAuth 自动执行,而是在域名列表中按域名单独触发 + * catch-all 邮箱不随接入自动创建,由用户手动添加;Email Routing/catch-all 规则 + * 不随 OAuth 自动执行,而是在域名列表中按域名单独触发 * (setupDomainEmail);一个账号只部署一个 Email Worker(幂等,重复执行不产生重复资源)。 * 账号级步骤状态写入 cloudflare_accounts.provision_steps。 */ @@ -19,7 +20,7 @@ import { nowIso } from "../util.ts"; export const PROVISION_STEPS = [ { key: "oauth", label: "Cloudflare OAuth 授权" }, { key: "discover", label: "Account / Zone 发现" }, - { key: "domains", label: "域名与 catch-all 邮箱同步" }, + { key: "domains", label: "域名同步" }, { key: "worker", label: "Email Worker 部署与 Worker 凭据" }, ] as const; @@ -163,7 +164,8 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise .run(); await mark("discover", "done"); - // 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱 + // 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」); + // catch-all 邮箱不再随同步自动创建,需要用户在邮箱页手动添加 await mark("domains", "running"); for (const zone of zones) { await upsertDomain(env, { @@ -174,16 +176,6 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise workerId: null, }); } - const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE account_id = ?") - .bind(input.accountId) - .all<{ id: string; name: string }>(); - for (const d of domainRows.results) { - await upsertMailbox(env, { - domainId: d.id, - address: `*@${d.name}`, - isCatchAll: true, - }); - } await mark("domains", "done"); // 4. 部署账号级 Email Worker + 签发 Worker credential(docs/11 §14) @@ -315,6 +307,86 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok } } +/** # 成员变量: Cloudflare 侧资源清理结果(删账号/域名时尽力而为) **/ +export interface TeardownResult { + /** 成功关闭 Email Routing 的域名数 **/ + routingDisabled: number; + /** 已删除的 Email Worker 脚本名 **/ + workersDeleted: string[]; + /** 清理失败文案(不阻断本地删除,最终回给前端展示) **/ + errors: string[]; +} + +/** # 函数 + * 删除域名时清理 Cloudflare 侧的 Email Routing(尽力而为): + * 只有配置过(status=active 且绑定 zone)才需要关;凭据缺失或 Cloudflare 报错 + * 都只返回错误文案,绝不阻断本地删除。 + * 返回值:cleaned = 实际执行了关闭;error = 失败原因(null = 无需清理或已成功) + */ +export async function teardownDomainRouting( + env: Env, + domainId: string, +): Promise<{ cleaned: boolean; error: string | null }> { + const domain = await env.DB.prepare("SELECT id, name, zone_id, account_id, status FROM domains WHERE id = ?") + .bind(domainId) + .first<{ id: string; name: string; zone_id: string | null; account_id: string | null; status: string }>(); + if (!domain?.zone_id || !domain.account_id || domain.status !== "active") return { cleaned: false, error: null }; + const accessToken = await decryptAccountToken(env, domain.account_id); + if (!accessToken) return { cleaned: false, error: `Cloudflare 凭据不可用,未能关闭 ${domain.name} 的 Email Routing` }; + const cf: CFApi = await getCFApi(env); + try { + await cf.disableEmailRouting({ accessToken, zoneId: domain.zone_id }); + return { cleaned: true, error: null }; + } catch (err) { + return { + cleaned: false, + error: `关闭 ${domain.name} 的 Email Routing 失败:${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +/** # 函数 + * 删除账号时清理 Cloudflare 侧资源:先逐个关闭其已配置域名的 Email Routing, + * 再删除账号级 Email Worker 脚本。全部尽力而为,失败累计进 errors。 + */ +export async function teardownAccountResources(env: Env, accountId: string): Promise { + const result: TeardownResult = { routingDisabled: 0, workersDeleted: [], errors: [] }; + const domains = await env.DB.prepare( + "SELECT id FROM domains WHERE account_id = ? AND status = 'active' AND zone_id IS NOT NULL", + ) + .bind(accountId) + .all<{ id: string }>(); + for (const d of domains.results) { + const r = await teardownDomainRouting(env, d.id); + if (r.error) result.errors.push(r.error); + else if (r.cleaned) result.routingDisabled++; + } + const account = await env.DB.prepare( + "SELECT cloudflare_account_id, worker_id FROM cloudflare_accounts WHERE id = ?", + ) + .bind(accountId) + .first<{ cloudflare_account_id: string | null; worker_id: string | null }>(); + if (account?.cloudflare_account_id && account.worker_id) { + const accessToken = await decryptAccountToken(env, accountId); + if (!accessToken) { + result.errors.push("Cloudflare 凭据不可用,未能删除 Email Worker"); + } else { + const cf: CFApi = await getCFApi(env); + try { + await cf.deleteWorkerScript({ + accessToken, + accountId: account.cloudflare_account_id, + scriptName: account.worker_id, + }); + result.workersDeleted.push(account.worker_id); + } catch (err) { + result.errors.push(`删除 Email Worker 失败:${err instanceof Error ? err.message : String(err)}`); + } + } + } + return result; +} + /** # 函数 * 读取并解析账号的步骤状态 */ diff --git a/package.json b/package.json index a0609c5..f063a76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.5.1", + "version": "2.6.0", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", diff --git a/tests/cf-mock.ts b/tests/cf-mock.ts index 8617a0d..0bac6d9 100644 --- a/tests/cf-mock.ts +++ b/tests/cf-mock.ts @@ -41,6 +41,8 @@ export interface CfMockOptions { emailRoutingStatusAfterEnable?: string; /** emailRoutingStatusAfterEnable=misconfigured 时状态对象里的 errors **/ routingErrors?: { code?: string; existing?: { content?: string }; missing?: { content?: string } }[]; + /** 删除清理(POST .../routing/disable、DELETE .../workers/scripts/*)返回失败,测告警降级路径 **/ + teardownError?: "disable" | "worker"; } /** # 成员变量: Cloudflare API 桩句柄 **/ @@ -169,6 +171,15 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { // PUT / PATCH .../email/routing -> 回 success:true 但 enabled 原封不动(纯回显) // 2026-09-30 线上正是因为信了 PUT 的返回值,把没开成 Email Routing 的域名标成了「已配置」, // 表现为域名显示配好了却收不到邮件、Dashboard 邮件路由里也找不到它。 + if (url.endsWith("/email/routing/disable")) { + // 关闭 Email Routing 与 enable 同理只有 POST 真生效 + if (method !== "POST") return jsonError([{ code: 10405, message: "Method not allowed" }], 405); + if (opts.teardownError === "disable") { + return jsonError([{ code: 10000, message: "email routing disable not permitted" }], 400); + } + state.emailRoutingEnabled = false; + return json({ id: url.split("/").at(-3), enabled: false, status: "disabled" }); + } if (url.endsWith("/email/routing/enable")) { if (method !== "POST") { return jsonError( @@ -199,7 +210,12 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { if (url.includes("/dns_records")) return json(opts.foreignMx ?? []); return json(zones); } - if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() }); + if (url.includes("/workers/scripts/")) { + if (method === "DELETE" && opts.teardownError === "worker") { + return jsonError([{ code: 10000, message: "workers.delete not permitted" }], 400); + } + return json({ id: url.split("/").pop() }); + } return json(null, 404); }) as typeof globalThis.fetch; diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index a401c14..a3226e2 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -103,9 +103,12 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { expect(synced.length).toBe(2); for (const d of synced) expect(d.status).toBe("pending"); - // catch-all 邮箱已随同步创建 - const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>(); - expect(mb.results.map((r) => r.address)).toContain("*@example.com"); + // 同步不再自动创建 catch-all 邮箱(v2.6.0 起,邮箱全部手动添加) + const mb = await t.env.DB.prepare( + `SELECT COUNT(*) AS n FROM mailboxes m JOIN domains d ON m.domain_id = d.id + WHERE d.name IN ('example.com', 'demo.com')`, + ).first<{ n: number }>(); + expect(mb?.n).toBe(0); // setup:为同步出的 example.com 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则) const example = domList.items.find((d) => d.name === "example.com" && d.status === "pending")!; diff --git a/tests/teardown.test.ts b/tests/teardown.test.ts new file mode 100644 index 0000000..68ea9b7 --- /dev/null +++ b/tests/teardown.test.ts @@ -0,0 +1,149 @@ +/** + * 删除账号/域名的 Cloudflare 侧清理测试(v2.6.0): + * - 删域名:关闭该域名的 Email Routing(与开启同理只有 POST 真生效) + * - 删账号:逐域名关闭 Email Routing + 删除账号级 Email Worker 脚本 + * - Cloudflare 清理失败只产生告警,不阻断本地删除 + * Cloudflare 侧由 tests/cf-mock.ts 在 fetch 层打桩,断言真实发出的 HTTP 调用。 + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { encryptConnectionToken } from "../console/src/db.ts"; +import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts"; +import { installCloudflareMock, type CfMock } from "./cf-mock"; + +/** # 成员变量: 当前 Cloudflare 桩(afterEach 恢复原始 fetch) **/ +let mock: CfMock | null = null; +afterEach(() => { + mock?.restore(); + mock = null; +}); + +/** # 函数 + * 把播种账号 acct_test1 / 域名 dom_test1 配成「已装 Email Workers」: + * 补 zone 绑定 + 一条未过期的 Cloudflare 凭据(decryptAccountToken 优先按账号取)。 + * 播种的 catch-all 邮箱一并删掉,否则单域名删除会被「有邮箱」409 挡住。 + */ +async function makeConfigured(t: Awaited>) { + const now = new Date().toISOString(); + await t.env.DB.prepare("UPDATE domains SET zone_id = 'zone-example' WHERE id = 'dom_test1'").run(); + await t.env.DB.prepare("DELETE FROM mailboxes WHERE domain_id = 'dom_test1'").run(); + await t.env.DB.prepare( + "INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, expires_at, created_at, updated_at) VALUES ('conn_tear', 'cloudflare', ?, ?, NULL, ?, ?)", + ).bind(t.accountId, await encryptConnectionToken(t.env, "cf-oauth-access-token-xyz"), now, now).run(); +} + +describe("删除账号/域名的 Cloudflare 侧清理", () => { + test("删除已配置域名:POST 关闭 Email Routing,本地行删除,无告警", async () => { + const t = await makeTestEnv(); + await makeConfigured(t); + const cf = installCloudflareMock(); + mock = cf; + const cookie = await loginCookie(t); + + const resp = await authedFetch(t, cookie, "http://localhost/api/domains/dom_test1", { method: "DELETE" }); + expect(resp.status).toBe(200); + const data = (await resp.json()) as { ok: boolean; warnings: string[] }; + expect(data.ok).toBe(true); + expect(data.warnings).toEqual([]); + expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true); + expect(await t.env.DB.prepare("SELECT id FROM domains WHERE id = 'dom_test1'").first()).toBeNull(); + const log = await t.env.DB.prepare("SELECT id FROM operation_logs WHERE action = 'domain.delete'").first(); + expect(log).not.toBeNull(); + }); + + test("未配置 Email Workers 的域名删除:不产生 Cloudflare 调用", async () => { + const t = await makeTestEnv(); + await makeConfigured(t); + await t.env.DB.prepare("UPDATE domains SET status = 'pending', zone_id = NULL WHERE id = 'dom_test1'").run(); + const cf = installCloudflareMock(); + mock = cf; + const cookie = await loginCookie(t); + + const resp = await authedFetch(t, cookie, "http://localhost/api/domains/dom_test1", { method: "DELETE" }); + expect(resp.status).toBe(200); + expect(cf.hit("POST", "/email/routing/disable")).toBe(false); + }); + + test("Cloudflare 关闭 Routing 失败:仍完成本地删除,响应带告警并记日志", async () => { + const t = await makeTestEnv(); + await makeConfigured(t); + const cf = installCloudflareMock({ teardownError: "disable" }); + mock = cf; + const cookie = await loginCookie(t); + + const resp = await authedFetch(t, cookie, "http://localhost/api/domains/dom_test1", { method: "DELETE" }); + expect(resp.status).toBe(200); + const data = (await resp.json()) as { ok: boolean; warnings: string[] }; + expect(data.ok).toBe(true); + expect(data.warnings.length).toBe(1); + expect(data.warnings[0]).toContain("Email Routing"); + expect(await t.env.DB.prepare("SELECT id FROM domains WHERE id = 'dom_test1'").first()).toBeNull(); + const log = await t.env.DB + .prepare("SELECT detail FROM operation_logs WHERE action = 'domain.delete'") + .first<{ detail: string }>(); + expect(log?.detail).toContain("Email Routing"); + }); + + test("批量删除域名:逐域名清理 Routing,级联删邮箱,无告警", async () => { + const t = await makeTestEnv(); + await makeConfigured(t); + // makeConfigured 删掉了播种邮箱,这里补一个以验证级联删除 + const now = new Date().toISOString(); + await t.env.DB.prepare( + "INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_tear', 'dom_test1', 'a@example.test', 'active', 0, ?, ?)", + ).bind(now, now).run(); + const cf = installCloudflareMock(); + mock = cf; + const cookie = await loginCookie(t); + + const resp = await authedFetch(t, cookie, "http://localhost/api/domains/batch-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: ["dom_test1"] }), + }); + const data = (await resp.json()) as { deleted: number; mailboxes: number; warnings: string[] }; + expect(data.deleted).toBe(1); + expect(data.mailboxes).toBe(1); + expect(data.warnings).toEqual([]); + expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true); + }); + + test("删除账号:关闭各域名 Email Routing + 删除 Email Worker 脚本", async () => { + const t = await makeTestEnv(); + await makeConfigured(t); + const cf = installCloudflareMock(); + mock = cf; + const cookie = await loginCookie(t); + + const resp = await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" }); + expect(resp.status).toBe(200); + const data = (await resp.json()) as { ok: boolean; warnings: string[] }; + expect(data.ok).toBe(true); + expect(data.warnings).toEqual([]); + expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true); + expect(cf.hit("DELETE", "/workers/scripts/wmailhub-email-acct-test1")).toBe(true); + // 软删生效:列表不再出现,操作日志带清理摘要 + const list = (await (await authedFetch(t, cookie, "http://localhost/api/accounts")).json()) as { total: number }; + expect(list.total).toBe(0); + const log = await t.env.DB + .prepare("SELECT detail FROM operation_logs WHERE action = 'account.delete'") + .first<{ detail: string }>(); + expect(log?.detail).toContain("wmailhub-email-acct-test1"); + }); + + test("删除账号但 Cloudflare 删 Worker 失败:告警不阻断,Routing 照常关闭", async () => { + const t = await makeTestEnv(); + await makeConfigured(t); + const cf = installCloudflareMock({ teardownError: "worker" }); + mock = cf; + const cookie = await loginCookie(t); + + const resp = await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" }); + expect(resp.status).toBe(200); + const data = (await resp.json()) as { ok: boolean; warnings: string[] }; + expect(data.ok).toBe(true); + expect(data.warnings.length).toBe(1); + expect(data.warnings[0]).toContain("Email Worker"); + expect(cf.hit("POST", "/zones/zone-example/email/routing/disable")).toBe(true); + expect(cf.hit("DELETE", "/workers/scripts/wmailhub-email-acct-test1")).toBe(true); + }); +});