v2.4.0 修 Email Routing 假成功 + 加 API Token 接入与邮箱/域名页交互改进

问题 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 的说明
This commit is contained in:
bigcode
2026-09-30 18:20:44 +08:00
parent e7db6762fd
commit ecce90dfab
20 changed files with 1107 additions and 143 deletions
+53 -8
View File
@@ -25,6 +25,26 @@ Console
用户不需要执行安装命令,也不手工创建 API Token。
## 两种接入方式(Console「添加账号」)
| 方式 | token 来源 | 适用场景 |
| --- | --- | --- |
| **Cloudflare OAuth 授权** | 用户在 Cloudflare 授权页同意后由 Console 兑换 | 默认推荐;本系统不保存任何 Cloudflare 密钥 |
| **API Token 授权** | 用户自己粘贴一个 Cloudflare API Token | 不方便走 OAuth 跳转;或已有现成 token |
两条路走的是**同一套**自动接入流程(发现域名 → 同步域名 → 部署 Email Worker → 签发 Worker 凭据),
区别只在 token 从哪来。API Token 方式会先拿 token 探一次 Cloudflare,并与用户填写的账户 ID 比对,
确认可用后才建账号 —— 填错当场报错,不会留下一个永远配不成功的僵尸账号。
对应接口:`POST /api/oauth/cloudflare/start`(OAuth)与 `POST /api/accounts/token`(API Token)。
### 每条连接绑定到具体账号(v2.4.0)
`oauth_connections.account_id` 记录这条连接属于哪个账号,`decryptAccountToken()` **按账号取 token**,
不再取"最近一条连接"。多账号接入后,拿 A 账号的 token 去配 B 账号的域名会失败(zone 根本不在那个
token 的可见范围内)。对早期 `account_id` 为空的数据保留回退:该账号没有绑定连接时回退到最近一条
**未过期**的连接(OAuth access token 一小时就过期,过期的不再被取用,避免拿一个死 token 去调 API)。
## 安全
- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。
@@ -67,7 +87,7 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
本应用默认只请求这三个(已在 Cloudflare 侧逐个验证可被接受):
本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受):
| scope id | 选择器里的名称 | 用途 |
| --- | --- | --- |
@@ -76,15 +96,40 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
| `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」不归 Email Routing 权限管**(2026-09-30 实测):它在选择器里对应
Zone Settings Write。Email Routing 那一族权限全勾上也不够。
顺带两个接口层面的坑:
### 开启 Email Routing:三个接口里只有一个真的干活(2026-09-30 线上踩坑)
Cloudflare 对同一个设置对象给了三个「看起来能改」的方法,实测行为:
| 调用 | 返回 | `enabled` 是否变化 |
| --- | --- | --- |
| `PUT /zones/{id}/email/routing` | `success: true` | **否**(纯回显) |
| `PATCH /zones/{id}/email/routing` | `success: true` | **否**(纯回显) |
| `POST /zones/{id}/email/routing/enable` | `success: true` | **是** |
前两个是**假成功**:照它们的返回值把域名标成「已配置 Email Workers」,而 Cloudflare 侧其实
`status: unconfigured`、连 MX 记录都没有 —— 表现为「域名显示配好了却收不到邮件,Dashboard 的
邮件路由里也找不到这个域名」。(`POST` 到 `/email/routing` 这个路径本身不是有效路由,
返回 `10405 Method not allowed for this authentication scheme`。)
因此 `console/src/services/cfapi.ts` 的 `enableEmailRouting()`:
1. 只调 `POST /zones/{id}/email/routing/enable`(文档标 Deprecated,但至今仍可用且是唯一有效入口);
2. **开启后回读 `GET /zones/{id}/email/routing` 校验 `enabled === true`**,不满足即判失败;
3. 失败原因写进 `domains.setup_error`、域名标 `error` —— 绝不谎报「已配置」。
`tests/cf-mock.ts` 复刻了这个「PUT/PATCH 回 success 但不改状态」的行为,锁住修复。
### 开启失败的最常见原因:2008 Non-Cloudflare MX records exist
域名上已有别的邮件服务的 MX 记录时 Cloudflare 拒绝接管,`/enable` 返回错误码 **2008**。
`describeMxConflict()` 会再读一次 `GET /zones/{id}/dns_records?type=MX`,把**非 Cloudflare 的那条
记录点名**写进中文错误提示。注意:删掉冲突 MX 意味着原邮件服务不再收信,不可逆,由用户自己决定。
顺带一个接口层面的坑:
- 开启 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"}]`。