问题 6(收不到邮件、Dashboard 邮件路由里看不到域名)的根因:
上一版把「开启 Email Routing」改成了 PUT /zones/{id}/email/routing,
但实测该接口(以及 PATCH)是**纯回显** —— 返回 success:true 但 enabled 纹丝不动,
域名在 Cloudflare 侧仍是 unconfigured、连 MX 记录都没有。Console 却照它的返回值
把域名标成「已配置 Email Workers」,于是「显示配好了却收不到邮件」。
实测只有 POST /zones/{id}/email/routing/enable 真的开启(线上对 adx9.com 与
galaxyemail.com 逐一验证)。
后端
- cfapi: 改用 /enable,并在开启后回读 GET 校验 enabled===true,不满足即判失败;
新增 CFApiError(带 Cloudflare 错误码)与 getEmailRoutingSettings
- cfapi: 错误码 2008(Non-Cloudflare MX records exist)翻译成中文并点名冲突的 MX 记录
- provision: Email Worker 统一命名 wmailhub-email(原先按账号 id 拼名),
部署走 PUT,同名即覆盖更新
- db: 新增 decryptAccountToken —— 按账号取 token,不再取「最近一条连接」,
并跳过已过期的 OAuth token;oauth_connections.account_id 真正写入
- resources: 新增 POST /api/accounts/token(API Token 方式添加账号):
先校验 token 与账户 ID,确认可用才建账号并自动接入
前端
- 账号:「添加账号」改为模态窗口,OAuth / API Token 两个 tab,各自带说明
- 域名: 删除「Email Worker」列;「设置 Email Workers / 重新配置」先弹确认框
(说明会改 MX 与路由),执行期间按钮 loading 且互相禁用;error 状态改成可读的 Alert + 重试
- 邮箱: 删除「状态」列(已由「启用」开关承担);地址列加复制按钮(带弹跳+变绿动画);
「添加邮箱」窗口的域名只列已装 Email Workers 的、支持搜索、默认选中最近使用的域名;
地址框可一键从 namedb 取随机用户名;勾选 catch-all 后地址框禁用
- 收件箱: 域名筛选只显示已装 Email Workers 的域名
测试
- cf-mock 复刻「PUT/PATCH 回 success 但不改 enabled」与 2008,锁住本次修复
- oauth.test 新增 4 例:2008 冲突、回读校验、API Token 接入、Token/账户 ID 校验失败
- ui-smoke 覆盖新增的添加账号/添加邮箱/确认框等交互(28/28 通过)
文档: docs/02 补「三种开启接口只有 /enable 有效」的实测表、2008 处理、
两种接入方式与按账号取 token 的说明
210 lines
9.8 KiB
TypeScript
210 lines
9.8 KiB
TypeScript
/**
|
|
* Cloudflare API 抓包桩(仅测试用):
|
|
* 拦截 globalThis.fetch,对 dash.cloudflare.com / api.cloudflare.com 返回固定响应,
|
|
* 并记录真实发出的 HTTP 调用。生产代码里没有任何模拟实现——接入链路跑的始终是
|
|
* RealCFApi,测试只是在传输层把它要访问的 Cloudflare 换成本地桩。
|
|
*/
|
|
|
|
/** # 成员变量: 记录到的 Cloudflare API 调用 **/
|
|
export interface CfCall {
|
|
/** 请求方法 **/
|
|
method: string;
|
|
/** 完整 URL **/
|
|
url: string;
|
|
/** 路径(去掉 base) **/
|
|
path: string;
|
|
/** Authorization 头(Cloudflare API 是 OAuth Bearer token;token 端点是 Basic) **/
|
|
authorization: string | null;
|
|
/** 请求体文本(URL 编码或 JSON);multipart 上传记 null **/
|
|
body: string | null;
|
|
}
|
|
|
|
/** # 成员变量: 桩的返回数据配置 **/
|
|
export interface CfMockOptions {
|
|
/** /zones 返回的域名列表;每个 zone 自带 account(与真实 Cloudflare 响应一致) **/
|
|
zones?: { id: string; name: string; account_id: string; status: string; account?: { id: string; name: string } }[];
|
|
/** OAuth token 端点签发的 access token **/
|
|
accessToken?: string;
|
|
/** token 端点 HTTP 状态(测失败分支用) **/
|
|
tokenStatus?: number;
|
|
/** 授权端点(dash.cloudflare.com/oauth2/auth)的固定 Location 跳转目标;不传则按 scopesAllowed 判定 **/
|
|
authorizeRedirect?: string;
|
|
/** 授权端点允许的 scope 集合;其余一律 invalid_scope 并点名第一个被拒的(不传 = 全拒) **/
|
|
scopesAllowed?: string[];
|
|
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
|
authorizeError?: string;
|
|
/** Email Routing 开启被拒(模拟 2008:域名上已有非 Cloudflare 的 MX 记录) **/
|
|
emailRoutingBlocked?: boolean;
|
|
/** emailRoutingBlocked 时 /dns_records 返回的 MX 列表(用于验证中文提示点名了冲突记录) **/
|
|
foreignMx?: { id?: string; name: string; content: string; priority?: number }[];
|
|
}
|
|
|
|
/** # 成员变量: Cloudflare API 桩句柄 **/
|
|
export interface CfMock {
|
|
/** 已记录的调用 **/
|
|
calls: CfCall[];
|
|
/** 恢复原始 fetch **/
|
|
restore(): void;
|
|
/** 是否发生过指定 method + 路径子串的调用 **/
|
|
hit(method: string, pathPart: string): boolean;
|
|
/** 取出第一条匹配调用的 body **/
|
|
bodyOf(method: string, pathPart: string): string | null;
|
|
}
|
|
|
|
const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
|
const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
|
const REDIRECT_URI = "http://localhost/api/oauth/cloudflare/callback";
|
|
const API_BASE = "https://api.cloudflare.com/client/v4";
|
|
|
|
/** # 函数
|
|
* 安装 Cloudflare API 桩:接管 globalThis.fetch,返回记录与恢复句柄
|
|
* 参数: opts.zones 决定发现到的资源,tokenStatus 可模拟 token 兑换失败
|
|
*/
|
|
export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
|
const account = { id: "cf-acct-real-1", name: "Real Cloudflare Account" };
|
|
// zone 自带 account(真实 Cloudflare 就在 zone 上附带),账号名因此无需账号级读权限
|
|
const zones = opts.zones ?? [
|
|
{ id: "zone-example", name: "example.com", account_id: account.id, status: "active", account },
|
|
{ id: "zone-demo", name: "demo.com", account_id: account.id, status: "active", account },
|
|
];
|
|
const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz";
|
|
const calls: CfCall[] = [];
|
|
const original = globalThis.fetch;
|
|
// Email Routing 的真实开关状态:PUT/PATCH 不会改它,只有 POST .../enable 会
|
|
const state = { emailRoutingEnabled: false };
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
const headers = new Headers(
|
|
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
);
|
|
const rawBody = init?.body;
|
|
const body =
|
|
typeof rawBody === "string"
|
|
? rawBody
|
|
: rawBody instanceof URLSearchParams
|
|
? rawBody.toString()
|
|
: null;
|
|
|
|
calls.push({
|
|
method,
|
|
url,
|
|
path: url.startsWith(API_BASE) ? url.slice(API_BASE.length) : url,
|
|
authorization: headers.get("authorization"),
|
|
// multipart 上传(Email Worker 脚本)读不出文本,只记方法与路径
|
|
body: rawBody instanceof FormData ? null : body,
|
|
});
|
|
|
|
const json = (result: unknown, status = 200) =>
|
|
new Response(JSON.stringify({ success: true, errors: [], messages: [], result }), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
/** Cloudflare 风格的失败信封(success:false + errors 数组) */
|
|
const jsonError = (errors: { code: number; message: string }[], status: number) =>
|
|
new Response(JSON.stringify({ success: false, errors, messages: [], result: null }), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
if (url.startsWith(CF_AUTHORIZE_URL)) {
|
|
if (opts.authorizeError) {
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: `https://dash.cloudflare.com/oauth/error?error=${opts.authorizeError}&state=wmh_preflight` },
|
|
});
|
|
}
|
|
if (opts.authorizeRedirect) {
|
|
return new Response(null, { status: 302, headers: { Location: opts.authorizeRedirect } });
|
|
}
|
|
// 默认行为:只有 scopesAllowed 里的 scope 才放行,否则点名第一个被拒的
|
|
const allowed = opts.scopesAllowed ?? [];
|
|
const asked = (new URL(url).searchParams.get("scope") ?? "").split(" ").filter(Boolean);
|
|
const rejected = asked.find((sc) => !allowed.includes(sc));
|
|
if (rejected) {
|
|
const desc = `The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope '${rejected}'.`;
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
Location: `${REDIRECT_URI}?error=invalid_scope&error_description=${encodeURIComponent(desc)}&state=wmh_preflight`,
|
|
},
|
|
});
|
|
}
|
|
return new Response(null, { status: 302, headers: { Location: "https://dash.cloudflare.com/login" } });
|
|
}
|
|
if (url === TOKEN_URL) {
|
|
// 复刻 Cloudflare 的客户端认证要求:只接受 HTTP Basic(client_secret_basic)。
|
|
// 把 client_id/secret 放进 POST body(client_secret_post)会被 401 invalid_client 拒绝,
|
|
// 且错误描述会点名 "method 'client_secret_post' was requested"(2026-09-30 线上踩过)。
|
|
const auth = headers.get("authorization") ?? "";
|
|
if (!auth.startsWith("Basic ")) {
|
|
const desc =
|
|
"Client authentication failed (e.g., unknown client, no client authentication included, " +
|
|
"or unsupported authentication method). The OAuth 2.0 Client supports client authentication " +
|
|
"method 'client_secret_basic', but method 'client_secret_post' was requested.";
|
|
return new Response(JSON.stringify({ error: "invalid_client", error_description: desc }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if ((opts.tokenStatus ?? 200) !== 200) {
|
|
return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus });
|
|
}
|
|
// OAuth token 端点是裸 JSON(不是 Cloudflare API 的 {success,result} 信封)
|
|
return new Response(JSON.stringify({ access_token: accessToken, expires_in: 3600, token_type: "bearer" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (url.startsWith(`${API_BASE}/zones`)) {
|
|
if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
|
|
// 开启 Email Routing 只有一个接口真的干活,这里照抄 Cloudflare 的真实行为:
|
|
// POST .../email/routing/enable -> 真的把 enabled 翻成 true
|
|
// PUT / PATCH .../email/routing -> 回 success:true 但 enabled 原封不动(纯回显)
|
|
// 2026-09-30 线上正是因为信了 PUT 的返回值,把没开成 Email Routing 的域名标成了「已配置」,
|
|
// 表现为域名显示配好了却收不到邮件、Dashboard 邮件路由里也找不到它。
|
|
if (url.endsWith("/email/routing/enable")) {
|
|
if (method !== "POST") {
|
|
return jsonError(
|
|
[{ code: 10405, message: "Method not allowed for this authentication scheme" }],
|
|
405,
|
|
);
|
|
}
|
|
if (opts.emailRoutingBlocked) {
|
|
return jsonError([{ code: 2008, message: "Non-Cloudflare MX records exist" }], 400);
|
|
}
|
|
state.emailRoutingEnabled = true;
|
|
return json({ id: url.split("/").at(-3), enabled: true, skip_wizard: true, status: "ready" });
|
|
}
|
|
if (url.endsWith("/email/routing")) {
|
|
// 回显型接口:无论 PUT 还是 PATCH,一律 success 但绝不改 enabled
|
|
return json({
|
|
id: url.split("/").at(-2),
|
|
name: zones[0]?.name ?? "example.com",
|
|
enabled: state.emailRoutingEnabled,
|
|
skip_wizard: true,
|
|
status: state.emailRoutingEnabled ? "ready" : "unconfigured",
|
|
});
|
|
}
|
|
// 开启被 2008 挡下时,补一次 MX 查询,验证中文提示里确实点名了冲突记录
|
|
if (url.includes("/dns_records")) return json(opts.foreignMx ?? []);
|
|
return json(zones);
|
|
}
|
|
if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() });
|
|
return json(null, 404);
|
|
}) as typeof globalThis.fetch;
|
|
|
|
return {
|
|
calls,
|
|
restore: () => {
|
|
globalThis.fetch = original;
|
|
},
|
|
hit: (method, pathPart) =>
|
|
calls.some((c) => c.method === method.toUpperCase() && c.path.includes(pathPart)),
|
|
bodyOf: (method, pathPart) =>
|
|
calls.find((c) => c.method === method.toUpperCase() && c.path.includes(pathPart))?.body ?? null,
|
|
};
|
|
}
|