v2.4.1 回读校验收紧到 status:SPF 冲突(misconfigured)不再谎报已配置

实测 adx9.com 全链路时发现的新缺口:/enable 成功后 enabled=true,但 zone 的
Email Routing status 可能仍是 misconfigured(SPF 指向旧邮件服务)——
照 v2.4.0 的校验(enabled===true)照样会把域名标成「已配置」,而邮件根本不会投递,
与 MX 假成功是同一类问题。

- cfapi: 回读校验增加 status 判定:
  - ready / unlocked(过渡态,实测邮件照常投递)视为成功
  - misconfigured* 判失败,并把状态对象里的 errors 翻译成中文
    (spf.foreign = 现有 SPF 指向其它邮件服务;spf.missing = 给出应替换的 SPF 内容)
- cf-mock: 读回的状态随 emailRoutingStatusAfterEnable 可配置
- oauth.test 新增 2 例:misconfigured 判失败(不标 active)、unlocked 视为成功

线上排查记录(2026-09-30,已手工修复):
  adx9.com 删除自建 MX(10 mx.adx9.com)→ POST /email/routing/enable 成功但
  status=misconfigured/locked(errors: spf.foreign/spf.missing)→ PATCH
  /email/routing/dns(unlock)→ 替换 SPF TXT 为 v=spf1 include:_spf.mx.cloudflare.net
  ~all → Graph API 发测试邮件 → Console 收到(msg_7eb6afeba6179293),全链路打通。
This commit is contained in:
bigcode
2026-09-30 22:18:13 +08:00
parent ecce90dfab
commit 8c5ed7fc8b
4 changed files with 96 additions and 9 deletions
+41 -5
View File
@@ -395,6 +395,17 @@ export interface CFEmailRoutingSettings {
enabled?: boolean; enabled?: boolean;
status?: string; status?: string;
skip_wizard?: boolean; 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 记录 **/ /** # 成员变量: 域名的一条 MX 记录 **/
@@ -426,8 +437,8 @@ export interface CFApi {
/** Worker credential secret(HMAC 签名密钥) **/ /** Worker credential secret(HMAC 签名密钥) **/
workerSecret: string; workerSecret: string;
}): Promise<{ id: string }>; }): Promise<{ id: string }>;
/** 开启 Zone 的 Email Routing(幂等;开启后会回读校验真的生效) */ /** 开启 Zone 的 Email Routing(加并锁定 MX/SPF 记录;开启后会回读校验真的生效) */
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>; enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean; status?: string }>;
/** 读取 Zone 的 Email Routing 设置 */ /** 读取 Zone 的 Email Routing 设置 */
getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }): Promise<CFEmailRoutingSettings>; getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }): Promise<CFEmailRoutingSettings>;
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */ /** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
@@ -628,8 +639,7 @@ export class RealCFApi implements CFApi {
* *
* 开启后**回读校验** enabled:接口说成功不算数,状态对象变成 ready 才算开成。 * 开启后**回读校验** enabled:接口说成功不算数,状态对象变成 ready 才算开成。
*/ */
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) { async enableEmailRouting(opts: { accessToken: string; zoneId: string }) { try {
try {
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/enable`, { await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/enable`, {
method: "POST", method: "POST",
body: JSON.stringify({ skip_wizard: true }), body: JSON.stringify({ skip_wizard: true }),
@@ -653,7 +663,33 @@ export class RealCFApi implements CFApi {
`(enabled=false);请到 Cloudflare Dashboard 检查该域名的邮件路由状态`, `(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 记录,并说明删掉的后果 */ /** 把 2008 翻译成可执行的提示:点名冲突的 MX 记录,并说明删掉的后果 */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wmailhub", "name": "wmailhub",
"version": "2.4.0", "version": "2.4.1",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
"private": true, "private": true,
"type": "module", "type": "module",
+10 -3
View File
@@ -37,6 +37,10 @@ export interface CfMockOptions {
emailRoutingBlocked?: boolean; emailRoutingBlocked?: boolean;
/** emailRoutingBlocked 时 /dns_records 返回的 MX 列表(用于验证中文提示点名了冲突记录) **/ /** emailRoutingBlocked 时 /dns_records 返回的 MX 列表(用于验证中文提示点名了冲突记录) **/
foreignMx?: { id?: string; name: string; content: string; priority?: number }[]; 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 桩句柄 **/ /** # 成员变量: Cloudflare API 桩句柄 **/
@@ -180,13 +184,16 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
} }
if (url.endsWith("/email/routing")) { if (url.endsWith("/email/routing")) {
// 回显型接口:无论 PUT 还是 PATCH,一律 success 但绝不改 enabled // 回显型接口:无论 PUT 还是 PATCH,一律 success 但绝不改 enabled
const enabled = state.emailRoutingEnabled;
const misconfigured = Boolean(opts.emailRoutingStatusAfterEnable?.startsWith("misconfigured"));
return json({ return json({
id: url.split("/").at(-2), id: url.split("/").at(-2),
name: zones[0]?.name ?? "example.com", name: zones[0]?.name ?? "example.com",
enabled: state.emailRoutingEnabled, enabled,
skip_wizard: true, skip_wizard: true,
status: state.emailRoutingEnabled ? "ready" : "unconfigured", status: enabled ? (opts.emailRoutingStatusAfterEnable ?? "ready") : "unconfigured",
}); ...(misconfigured ? { errors: opts.routingErrors ?? [] } : {}),
} as unknown);
} }
// 开启被 2008 挡下时,补一次 MX 查询,验证中文提示里确实点名了冲突记录 // 开启被 2008 挡下时,补一次 MX 查询,验证中文提示里确实点名了冲突记录
if (url.includes("/dns_records")) return json(opts.foreignMx ?? []); if (url.includes("/dns_records")) return json(opts.foreignMx ?? []);
+44
View File
@@ -615,6 +615,50 @@ describe("Email Routing 开启失败与回读校验(2026-09-30 线上 bug 的回
expect(setup.ok).toBe(false); expect(setup.ok).toBe(false);
expect(setup.error).toContain("enabled=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 并列的第二种接入方式)", () => { describe("API Token 方式添加账号(与 OAuth 并列的第二种接入方式)", () => {