diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index 76eda9c..cf0d3d6 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -395,6 +395,17 @@ export interface CFEmailRoutingSettings { enabled?: boolean; status?: string; skip_wizard?: boolean; + /** Cloudflare 侧的配置问题清单(如 spf.foreign / spf.missing;非 API 错误信封) **/ + errors?: { code?: string; existing?: CFRoutingDnsRecord; missing?: CFRoutingDnsRecord }[]; +} + +/** # 成员变量: Email Routing 指出的 DNS 记录(existing = 有冲突的,missing = 应补的) **/ +export interface CFRoutingDnsRecord { + id?: string; + name?: string; + content?: string; + type?: string; + ttl?: number; } /** # 成员变量: 域名的一条 MX 记录 **/ @@ -426,8 +437,8 @@ export interface CFApi { /** Worker credential secret(HMAC 签名密钥) **/ workerSecret: string; }): Promise<{ id: string }>; - /** 开启 Zone 的 Email Routing(幂等;开启后会回读校验真的生效) */ - enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>; + /** 开启 Zone 的 Email Routing(加并锁定 MX/SPF 记录;开启后会回读校验真的生效) */ + enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean; status?: string }>; /** 读取 Zone 的 Email Routing 设置 */ getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }): Promise; /** 配置 Zone catch-all 规则指向 Email Worker(幂等) */ @@ -628,8 +639,7 @@ export class RealCFApi implements CFApi { * * 开启后**回读校验** enabled:接口说成功不算数,状态对象变成 ready 才算开成。 */ - async enableEmailRouting(opts: { accessToken: string; zoneId: string }) { - try { + async enableEmailRouting(opts: { accessToken: string; zoneId: string }) { try { await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/enable`, { method: "POST", body: JSON.stringify({ skip_wizard: true }), @@ -653,7 +663,33 @@ export class RealCFApi implements CFApi { `(enabled=false);请到 Cloudflare Dashboard 检查该域名的邮件路由状态`, ); } - return { enabled: true }; + // enabled 只是开关;真正能收信还要 status 不是 misconfigured(如 SPF 指向别的邮件服务)。 + // unlocked 是刚解锁待复核的过渡态,实测邮件照常投递,不视为失败。 + const status = settings.status ?? ""; + if (status.startsWith("misconfigured")) { + throw new CFApiError(this.describeStatusErrors(settings.errors), undefined, `/zones/${opts.zoneId}/email/routing`); + } + return { enabled: true, status: status || "ready" }; + } + + /** 把 Email Routing 状态对象里的 errors(spf.foreign / spf.missing 等)翻译成可执行的提示 */ + private describeStatusErrors(errors?: CFEmailRoutingSettings["errors"]): string { + const parts: string[] = []; + for (const e of errors ?? []) { + if (e.code === "spf.foreign" && e.existing) { + parts.push(`现有 SPF 记录(${e.existing.content ?? ""})指向其它邮件服务`); + } else if (e.code === "spf.missing" && e.missing) { + parts.push( + `缺少 Cloudflare 的 SPF 记录,请在 DNS 里把 @ 的 TXT 记录内容替换为:${e.missing.content ?? "v=spf1 include:_spf.mx.cloudflare.net ~all"}`, + ); + } else if (e.code) { + parts.push(`存在配置问题(${e.code})`); + } + } + return ( + "Email Routing 已开启但状态是 misconfigured,邮件不会正常投递。" + + (parts.length ? `原因:${parts.join(";")}。` : "请到 Cloudflare Dashboard → Email → Email Routing 查看具体原因。") + ); } /** 把 2008 翻译成可执行的提示:点名冲突的 MX 记录,并说明删掉的后果 */ diff --git a/package.json b/package.json index 166d5b2..daa0666 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.4.0", + "version": "2.4.1", "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 9b5ecb0..8617a0d 100644 --- a/tests/cf-mock.ts +++ b/tests/cf-mock.ts @@ -37,6 +37,10 @@ export interface CfMockOptions { emailRoutingBlocked?: boolean; /** emailRoutingBlocked 时 /dns_records 返回的 MX 列表(用于验证中文提示点名了冲突记录) **/ foreignMx?: { id?: string; name: string; content: string; priority?: number }[]; + /** 开启成功后回读到的状态(默认 ready;可传 misconfigured 模拟 SPF 等配置冲突) **/ + emailRoutingStatusAfterEnable?: string; + /** emailRoutingStatusAfterEnable=misconfigured 时状态对象里的 errors **/ + routingErrors?: { code?: string; existing?: { content?: string }; missing?: { content?: string } }[]; } /** # 成员变量: Cloudflare API 桩句柄 **/ @@ -180,13 +184,16 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { } if (url.endsWith("/email/routing")) { // 回显型接口:无论 PUT 还是 PATCH,一律 success 但绝不改 enabled + const enabled = state.emailRoutingEnabled; + const misconfigured = Boolean(opts.emailRoutingStatusAfterEnable?.startsWith("misconfigured")); return json({ id: url.split("/").at(-2), name: zones[0]?.name ?? "example.com", - enabled: state.emailRoutingEnabled, + enabled, skip_wizard: true, - status: state.emailRoutingEnabled ? "ready" : "unconfigured", - }); + status: enabled ? (opts.emailRoutingStatusAfterEnable ?? "ready") : "unconfigured", + ...(misconfigured ? { errors: opts.routingErrors ?? [] } : {}), + } as unknown); } // 开启被 2008 挡下时,补一次 MX 查询,验证中文提示里确实点名了冲突记录 if (url.includes("/dns_records")) return json(opts.foreignMx ?? []); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index aafd0fe..8ff7f1f 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -615,6 +615,50 @@ describe("Email Routing 开启失败与回读校验(2026-09-30 线上 bug 的回 expect(setup.ok).toBe(false); expect(setup.error).toContain("enabled=false"); }); + + test("开启成功但 status=misconfigured(SPF 冲突):判失败并翻译 errors,不谎报已配置", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + await connectAccount(t, cookie, { + emailRoutingStatusAfterEnable: "misconfigured", + routingErrors: [ + { code: "spf.foreign", existing: { content: "v=spf1 mx -all" } }, + { code: "spf.missing", missing: { content: "v=spf1 include:_spf.mx.cloudflare.net ~all" } }, + ], + }); + const domList = (await ( + await authedFetch(t, cookie, "http://localhost/api/domains") + ).json()) as { items: { id: string; name: string }[] }; + const example = domList.items.find((d) => d.name === "example.com")!; + + const setup = (await ( + await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" }) + ).json()) as { ok: boolean; error?: string }; + // enabled=true 但 misconfigured 照样不能标已配置 —— 这也是"显示配好了却收不到邮件"的一种 + expect(setup.ok).toBe(false); + expect(setup.error).toContain("misconfigured"); + expect(setup.error).toContain("SPF"); + expect(setup.error).toContain("v=spf1 include:_spf.mx.cloudflare.net ~all"); + + const after = (await ( + await authedFetch(t, cookie, "http://localhost/api/domains") + ).json()) as { items: { id: string; status: string }[] }; + expect(after.items.find((d) => d.id === example.id)!.status).toBe("error"); + }); + + test("开启成功但 status=unlocked(过渡态):视为成功,邮件照常投递", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + await connectAccount(t, cookie, { emailRoutingStatusAfterEnable: "unlocked" }); + const domList = (await ( + await authedFetch(t, cookie, "http://localhost/api/domains") + ).json()) as { items: { id: string; name: string }[] }; + const example = domList.items.find((d) => d.name === "example.com")!; + const setup = (await ( + await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" }) + ).json()) as { ok: boolean }; + expect(setup.ok).toBe(true); + }); }); describe("API Token 方式添加账号(与 OAuth 并列的第二种接入方式)", () => {