fix(email): 开启 Email Routing 改用 PUT,并补上 zone-settings.write 权限
POST /zones/{id}/email/routing 不是有效路由,Cloudflare 返回
10405 Method not allowed for this authentication scheme。正确接口是
PUT /zones/{id}/email/routing(Apply Email Routing settings);
POST .../email/routing/enable 能用但官方已标 Deprecated。
该接口的 Accepted Permission 是 Zone Settings Write,而不是 Email Routing 权限族
—— 权限名和接口所属资源组对不上,Email Routing 四个权限全勾也不够。
- CF_OAUTH_SCOPES_DEFAULT / 探测候选集加 zone-settings.write;设置页文案同步
- cfapi 的 API 错误带上 Cloudflare 错误码(如 10405),便于区分路径错与权限错
- cf-mock 对非 PUT 的 /email/routing 返回 10405,旧写法会被打回
- 88/88 测试
This commit is contained in:
@@ -129,7 +129,7 @@ WmailHub/
|
|||||||
- **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback`
|
- **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback`
|
||||||
- **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验
|
- **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验
|
||||||
- **Promote to public**:私有 App 无法被授权
|
- **Promote to public**:私有 App 无法被授权
|
||||||
- **Scopes**(`zone.read` / `workers-scripts.write` / `email-routing-rule.write`):**点号分隔**,冒号写法(`account:read`)是 wrangler 的第一方简写,第三方 client 会被拒;这三项就够,账号 id 与账号名随 `GET /zones` 的每个 zone 一起返回,不需要账号级权限
|
- **Scopes**(`zone.read` / `workers-scripts.write` / `email-routing-rule.write` / `zone-settings.write`):**点号分隔**,冒号写法(`account:read`)是 wrangler 的第一方简写,第三方 client 会被拒;账号 id 与账号名随 `GET /zones` 的每个 zone 一起返回,不需要账号级权限;注意最后一项 Zone Settings Write 是**开启 Email Routing** 用的,不勾会在「设置 Email Workers」时报 `10405`
|
||||||
|
|
||||||
配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
|
配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
|
||||||
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
||||||
|
|||||||
@@ -335,7 +335,8 @@ export interface OAuthPreflightResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */
|
/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */
|
||||||
export const CF_OAUTH_SCOPES_DEFAULT = "zone.read workers-scripts.write email-routing-rule.write";
|
export const CF_OAUTH_SCOPES_DEFAULT =
|
||||||
|
"zone.read workers-scripts.write email-routing-rule.write zone-settings.write";
|
||||||
|
|
||||||
/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
|
/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
|
||||||
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
|
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
|
||||||
|
|||||||
@@ -390,11 +390,13 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
|||||||
<>
|
<>
|
||||||
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
|
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
|
||||||
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
|
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
|
||||||
直接告诉你哪些已授权、哪些还要补勾。三项就够:账号名随 zone 一起返回,不需要账号级权限。
|
直接告诉你哪些已授权、哪些还要补勾。四项分别对应:发现域名、上传 Email Worker、改 catch-all
|
||||||
|
规则、<b>开启 Email Routing</b>(最后这项在选择器里叫 Zone Settings Write,不归 Email
|
||||||
|
Routing 管,漏了会在「设置 Email Workers」时才报错)。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Input placeholder="account.read zone.read email_routing.write workers_scripts.write" />
|
<Input placeholder="zone.read workers-scripts.write email-routing-rule.write zone-settings.write" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button loading={checking} onClick={() => void runPreflight()}>
|
<Button loading={checking} onClick={() => void runPreflight()}>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
|
|||||||
* 该接口只要求 Zone Read,所以本应用完全用不到 Account Settings Read。
|
* 该接口只要求 Zone Read,所以本应用完全用不到 Account Settings Read。
|
||||||
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
|
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
|
||||||
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
|
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
|
||||||
|
* - **「开启 Email Routing」不归 Email Routing 权限管**:PUT /zones/{id}/email/routing
|
||||||
|
* 的 Accepted Permission 是 Zone Settings Write => `zone-settings.write`。
|
||||||
|
* 权限名和接口所属的资源组对不上是这里最大的坑,配少了会在「设置 Email Workers」时才报出来。
|
||||||
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
|
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
|
||||||
* 以上名称可用 /cloudflare/preflight 的 scope 探测功能现场核对(见 discoverAllowedScopes)。
|
* 以上名称可用 /cloudflare/preflight 的 scope 探测功能现场核对(见 discoverAllowedScopes)。
|
||||||
**/
|
**/
|
||||||
@@ -43,6 +46,7 @@ export const CF_OAUTH_SCOPES_DEFAULT = [
|
|||||||
"zone.read",
|
"zone.read",
|
||||||
"workers-scripts.write",
|
"workers-scripts.write",
|
||||||
"email-routing-rule.write",
|
"email-routing-rule.write",
|
||||||
|
"zone-settings.write",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选)
|
/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选)
|
||||||
@@ -51,6 +55,7 @@ export const CF_OAUTH_SCOPE_CANDIDATES = [
|
|||||||
"zone.read",
|
"zone.read",
|
||||||
"workers-scripts.write",
|
"workers-scripts.write",
|
||||||
"email-routing-rule.write",
|
"email-routing-rule.write",
|
||||||
|
"zone-settings.write",
|
||||||
"email-routing-account-rule.read",
|
"email-routing-account-rule.read",
|
||||||
"email-routing-address.write",
|
"email-routing-address.write",
|
||||||
"email-routing-suppression.write",
|
"email-routing-suppression.write",
|
||||||
@@ -478,8 +483,18 @@ export class RealCFApi implements CFApi {
|
|||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = (await resp.json()) as { success: boolean; errors?: { message: string }[]; result: T };
|
const data = (await resp.json()) as {
|
||||||
if (!data.success) throw new Error(`cloudflare api ${path}: ${data.errors?.[0]?.message ?? resp.status}`);
|
success: boolean;
|
||||||
|
errors?: { code?: number; message: string }[];
|
||||||
|
result: T;
|
||||||
|
};
|
||||||
|
if (!data.success) {
|
||||||
|
const first = data.errors?.[0];
|
||||||
|
// 带上 Cloudflare 的错误码(如 10405),只看 message 经常分不清是路径错还是权限错
|
||||||
|
throw new Error(
|
||||||
|
`cloudflare api ${path}: ${first?.code ? `[${first.code}] ` : ""}${first?.message ?? resp.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return data.result;
|
return data.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,12 +554,19 @@ export class RealCFApi implements CFApi {
|
|||||||
return { id: opts.scriptName };
|
return { id: opts.scriptName };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 开启 Zone 的 Email Routing(加并锁定 MX/SPF 记录)
|
||||||
|
* 注意:走 `PUT /zones/{id}/email/routing`(Apply Email Routing settings)。
|
||||||
|
* - `POST /zones/{id}/email/routing` 不是有效路由,Cloudflare 返回
|
||||||
|
* `10405 Method not allowed for this authentication scheme`(2026-09-30 线上踩过)
|
||||||
|
* - `POST .../email/routing/enable` 虽然能用,但官方已标 Deprecated
|
||||||
|
* 该接口的 Accepted Permission 是 **Zone Settings Write**(不是 Email Routing 那一类权限)。 */
|
||||||
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
||||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing`, {
|
const settings = await this.api<{ enabled?: boolean; status?: string }>(
|
||||||
method: "POST",
|
opts.accessToken,
|
||||||
body: JSON.stringify({ enabled: true, skip_wizard: true }),
|
`/zones/${opts.zoneId}/email/routing`,
|
||||||
});
|
{ method: "PUT", body: JSON.stringify({ enabled: true, skip_wizard: true }) },
|
||||||
return { enabled: true };
|
);
|
||||||
|
return { enabled: settings?.enabled ?? true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
||||||
|
|||||||
@@ -73,7 +73,20 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `zone.read` | Zone Read | 发现该账号下的域名,并顺带拿到账号 id 与账号名 |
|
| `zone.read` | Zone Read | 发现该账号下的域名,并顺带拿到账号 id 与账号名 |
|
||||||
| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 |
|
| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 |
|
||||||
| `email-routing-rule.write` | Email Routing Rules Edit | 开启 Email Routing + 配置 catch-all 规则 |
|
| `email-routing-rule.write` | Email Routing Rules Edit | 配置 catch-all 规则指向 Worker |
|
||||||
|
| `zone-settings.write` | Zone Settings Write | **开启 Email Routing**(见下) |
|
||||||
|
|
||||||
|
**「开启 Email Routing」不归 Email Routing 权限管**(2026-09-30 实测):`PUT /zones/{id}/email/routing`
|
||||||
|
的 Accepted Permission 是 **Zone Settings Write**。所以 Email Routing 那一族权限全勾上也不够,
|
||||||
|
漏掉 `zone-settings.write` 会一直等到点「设置 Email Workers」才报出来。
|
||||||
|
|
||||||
|
顺带两个接口层面的坑:
|
||||||
|
|
||||||
|
- 开启 Email Routing 用 **`PUT /zones/{id}/email/routing`**(Apply Email Routing settings)。
|
||||||
|
`POST` 到同一路径不是有效路由,返回 `10405 Method not allowed for this authentication scheme`;
|
||||||
|
`POST .../email/routing/enable` 能用但官方已标 Deprecated。
|
||||||
|
- catch-all 规则的请求体里,指向 Worker 的 action 是 `{ "type": "worker", "value": ["<脚本名>"] }`,
|
||||||
|
matchers 固定 `[{"type":"all"}]`。
|
||||||
|
|
||||||
两个容易踩的点:
|
两个容易踩的点:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.3.5",
|
"version": "2.3.6",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -147,6 +147,17 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
|||||||
}
|
}
|
||||||
if (url.startsWith(`${API_BASE}/zones`)) {
|
if (url.startsWith(`${API_BASE}/zones`)) {
|
||||||
if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
|
if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
|
||||||
|
if (url.endsWith("/email/routing")) {
|
||||||
|
// 开启 Email Routing 的正确方法是 PUT;POST 到同一路径不是有效路由,
|
||||||
|
// 真实 Cloudflare 返回 10405(2026-09-30 线上踩过),这里照抄以便锁住修复。
|
||||||
|
if (method !== "PUT") {
|
||||||
|
return json(
|
||||||
|
{ success: false, errors: [{ code: 10405, message: "Method not allowed for this authentication scheme" }], result: null },
|
||||||
|
405,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return json({ id: url.split("/").at(-2), enabled: true, skip_wizard: true, status: "ready" });
|
||||||
|
}
|
||||||
if (url.includes("/email/routing")) return json({ enabled: true });
|
if (url.includes("/email/routing")) return json({ enabled: true });
|
||||||
return json(zones);
|
return json(zones);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-6
@@ -111,7 +111,10 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
||||||
).json()) as { ok: boolean; error?: string };
|
).json()) as { ok: boolean; error?: string };
|
||||||
expect(setup.ok).toBe(true);
|
expect(setup.ok).toBe(true);
|
||||||
expect(cf.hit("POST", "/zones/zone-example/email/routing")).toBe(true);
|
// 开启 Email Routing 必须是 PUT(POST 到同一路径是 10405,桩会拒)
|
||||||
|
expect(cf.hit("PUT", "/zones/zone-example/email/routing")).toBe(true);
|
||||||
|
expect(cf.calls.some((c) => c.method === "POST" && c.path === "/zones/zone-example/email/routing")).toBe(false);
|
||||||
|
expect(cf.bodyOf("PUT", "/zones/zone-example/email/routing")).toContain('"enabled":true');
|
||||||
expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(true);
|
expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(true);
|
||||||
const after = (await (
|
const after = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/domains")
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
||||||
@@ -346,8 +349,8 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
).json()) as { url: string };
|
).json()) as { url: string };
|
||||||
const scope = new URL(start.url).searchParams.get("scope") ?? "";
|
const scope = new URL(start.url).searchParams.get("scope") ?? "";
|
||||||
// 默认只请求接入必需的三项(账号名权限可选,缺失时从 zone 反推)
|
// 默认请求接入必需的四项(最后一项 zone-settings.write 用于开启 Email Routing)
|
||||||
expect(scope).toBe("zone.read workers-scripts.write email-routing-rule.write");
|
expect(scope).toBe("zone.read workers-scripts.write email-routing-rule.write zone-settings.write");
|
||||||
expect(scope).not.toContain(":");
|
expect(scope).not.toContain(":");
|
||||||
|
|
||||||
// settings 里自定义 scope 后生效
|
// settings 里自定义 scope 后生效
|
||||||
@@ -439,8 +442,15 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(res.error).toBe("invalid_request");
|
expect(res.error).toBe("invalid_request");
|
||||||
expect(res.hint).toContain("Redirect URL");
|
expect(res.hint).toContain("Redirect URL");
|
||||||
|
|
||||||
// 默认 scope(三项)全被授权 => 直接通过
|
// 默认 scope(四项)全被授权 => 直接通过
|
||||||
installCloudflareMock({ scopesAllowed: ["zone.read", "workers-scripts.write", "email-routing-rule.write"] });
|
installCloudflareMock({
|
||||||
|
scopesAllowed: [
|
||||||
|
"zone.read",
|
||||||
|
"workers-scripts.write",
|
||||||
|
"email-routing-rule.write",
|
||||||
|
"zone-settings.write",
|
||||||
|
],
|
||||||
|
});
|
||||||
res = (await (
|
res = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
||||||
).json()) as PreflightBody;
|
).json()) as PreflightBody;
|
||||||
@@ -473,7 +483,12 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(res.scopes.allowed).toEqual(
|
expect(res.scopes.allowed).toEqual(
|
||||||
expect.arrayContaining(["zone.read", "workers-scripts.write", "email-routing-rule.write"]),
|
expect.arrayContaining([
|
||||||
|
"zone.read",
|
||||||
|
"workers-scripts.write",
|
||||||
|
"email-routing-rule.write",
|
||||||
|
"zone-settings.write",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// App 什么都没勾时:全部候选都算待勾选
|
// App 什么都没勾时:全部候选都算待勾选
|
||||||
|
|||||||
Reference in New Issue
Block a user