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
+30 -10
View File
@@ -11,7 +11,7 @@
import type { Env } from "../env.ts";
import { getCFApi, type CFApi } from "./cfapi.ts";
import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
import { encryptSecret, decryptSecret, decryptAccountToken } from "../db.ts";
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
@@ -35,28 +35,46 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
return out;
}
/** # 成员变量: Email Worker 的统一脚本名
*
* 所有接入的 Cloudflare 账号共用这一个名字(Worker 本身是 **账号级** 资源,
* 同账号下只有一个,靠名字区分毫无意义)。好处是在 Cloudflare Dashboard 的
* Workers 列表里一眼就能找到,不必在一堆 wmailhub-email-acct-xxxx 里猜。
* 部署走 PUT,同名即覆盖更新,重复执行不会产生第二个 Worker。
* (历史上曾按账号 id 拼名,改名后旧脚本会残留在 Cloudflare,可手动删除。)
**/
export const EMAIL_WORKER_NAME = "wmailhub-email";
/** # 函数
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名;同时作为 Worker -> Console 的身份 ID)
* 账号的 Email Worker 脚本名(统一名;保留函数形式以兼容既有调用点)
*/
export function emailWorkerNameFor(accountId: string): string {
return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
export function emailWorkerNameFor(_accountId: string): string {
return EMAIL_WORKER_NAME;
}
/** # 函数
* 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。
* 添加账号:创建 cloudflare_accounts 记录(等待接入)。
* 参数: opts.name - 展示名;opts.cloudflareAccountId - 已知的 Cloudflare 账户 ID(API Token 方式可先填)
*/
export async function createAccount(
env: Env,
opts: { name: string },
opts: { name: string; cloudflareAccountId?: string | null },
): Promise<{ accountId: string }> {
const accountId = ids.account();
const now = nowIso();
await env.DB.prepare(
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
provision_steps, created_at, updated_at)
VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`,
VALUES (?, ?, ?, NULL, 'pending_oauth', ?, ?, ?)`,
)
.bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now)
.bind(
accountId,
opts.name,
opts.cloudflareAccountId ?? null,
JSON.stringify(initialSteps()),
now,
now,
)
.run();
return { accountId };
}
@@ -272,12 +290,14 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
.first<{ id: string; cloudflare_account_id: string | null }>();
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
const accessToken = await decryptConnectionToken(env);
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
const accessToken = await decryptAccountToken(env, account.id);
if (!accessToken) return { ok: false, error: "no active Cloudflare connection for this account" };
const cf: CFApi = await getCFApi(env);
try {
const workerId = await deployAccountWorker(env, account.id, accessToken);
// 开启 Email Routing 会失败于「已存在非 Cloudflare MX 记录」等真实原因,
// 失败信息已翻译成中文写进 domains.setup_error,不会静默标成已配置。
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
await env.DB.prepare(