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
+187 -10
View File
@@ -2,20 +2,26 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Button,
Card,
Drawer,
Form,
Input,
Modal,
Radio,
Space,
Table,
Tabs,
Tag,
Timeline,
Typography,
App,
} from "antd";
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from "@ant-design/icons";
import {
batchDeleteAccounts,
createAccountByToken,
deleteAccount,
getAccount,
listAccounts,
@@ -111,7 +117,48 @@ export function AccountsPage() {
[items, q],
);
/** 发起 OAuth:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
const openDetail = async (id: string) => {
setDetail(await getAccount(id));
setDetailOpen(true);
};
/** 添加账号:OAuth 与 API Token 两种方式都收在同一个模态窗口里 */
const [addOpen, setAddOpen] = useState(false);
/** 当前选中的授权方式 **/
const [addMode, setAddMode] = useState<"oauth" | "token">("oauth");
/** API Token 提交的 loading(要防重复点击,一次接入要走好几次 Cloudflare 调用) **/
const [adding, setAdding] = useState(false);
const [tokenForm] = Form.useForm();
/** API Token 授权:校验 + 建账号 + 自动接入,一条龙 */
const submitToken = async () => {
const values = await tokenForm.validateFields();
setAdding(true);
try {
const res = await createAccountByToken({
name: values.name,
cloudflare_account_id: values.cloudflare_account_id?.trim() || undefined,
token: values.token.trim(),
});
if (!res.ok) {
message.error(res.error ?? "接入失败");
return;
}
message.success(`已接入账号 ${values.name},正在同步域名`);
setAddOpen(false);
tokenForm.resetFields();
void load();
// 直接打开详情看接入步骤,和 OAuth 回调回来的体验一致
setDetail(await getAccount(res.account_id));
setDetailOpen(true);
} catch (err) {
message.error(err instanceof Error ? err.message : "接入失败");
} finally {
setAdding(false);
}
};
/** OAuth 授权:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
const startOAuth = async () => {
try {
const { url } = await oauthStart();
@@ -121,11 +168,6 @@ export function AccountsPage() {
}
};
const openDetail = async (id: string) => {
setDetail(await getAccount(id));
setDetailOpen(true);
};
return (
<div>
<div className="page-toolbar">
@@ -138,8 +180,8 @@ export function AccountsPage() {
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
并签发 Worker 凭据(Email Worker 直连 Console)。
「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、
部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
</span>
</div>
<Space>
@@ -164,7 +206,7 @@ export function AccountsPage() {
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
添加账号
</Button>
</Space>
@@ -237,6 +279,21 @@ export function AccountsPage() {
/>
</Card>
<AddAccountModal
open={addOpen}
mode={addMode}
loading={adding}
form={tokenForm}
onModeChange={setAddMode}
onCancel={() => {
if (adding) return;
setAddOpen(false);
tokenForm.resetFields();
}}
onOAuth={() => void startOAuth()}
onTokenSubmit={() => void submitToken()}
/>
<AccountDetailDrawer
detail={detail}
open={detailOpen}
@@ -250,6 +307,126 @@ export function AccountsPage() {
);
}
/** # 成员变量: 添加账号模态窗口的属性 **/
interface AddAccountModalProps {
open: boolean;
mode: "oauth" | "token";
loading: boolean;
form: ReturnType<typeof Form.useForm>[0];
onModeChange: (mode: "oauth" | "token") => void;
onCancel: () => void;
onOAuth: () => void;
onTokenSubmit: () => void;
}
/** # 函数
* 「添加账号」模态窗口:Cloudflare OAuth 与 API Token 两种授权方式,各自带说明。
* 两种方式最终都走同一套自动接入流程(发现域名 -> 部署 Email Worker -> 签发 Worker 凭据),
* 区别只在于 token 从哪来:OAuth 走用户点授权,API Token 由用户自己粘贴。
*/
function AddAccountModal(props: AddAccountModalProps) {
const { open, mode, loading, form, onModeChange, onCancel, onOAuth, onTokenSubmit } = props;
return (
<Modal
title="添加 Cloudflare 账号"
open={open}
onCancel={onCancel}
maskClosable={!loading}
closable={!loading}
width={620}
footer={null}
destroyOnHidden
>
<Tabs
activeKey={mode}
onChange={(k) => onModeChange(k as "oauth" | "token")}
items={[
{
key: "oauth",
label: (
<span>
<SafetyOutlined /> Cloudflare OAuth 授权
</span>
),
children: (
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
<Alert
type="info"
showIcon
message="推荐:不需要在本机保存任何密钥"
description={
<span>
点击下方按钮跳转到 Cloudflare 官方授权页,用你的 Cloudflare 账号登录并同意授权,
完成后自动跳回本控制台。Console 会用拿到的授权去发现该账号下的域名、
部署 Email Worker 并签发 Worker 凭据,全程无需在本系统里保存 Cloudflare 密钥。
<br />
前提:「设置 → Cloudflare 接入」里已填好 Client ID / Secret,且 OAuth App
已勾选本应用需要的权限(scope 用点号分隔)。
</span>
}
/>
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
前往 Cloudflare 授权
</Button>
</Space>
),
},
{
key: "token",
label: (
<span>
<ApiOutlined /> API Token 授权
</span>
),
children: (
<Form form={form} layout="vertical" requiredMark="optional">
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="适合不方便走 OAuth 跳转的场景"
description={
<span>
直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍
(并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。
Token 加密入库、不回显、不写日志。
</span>
}
/>
<Form.Item
name="name"
label="账户名称"
rules={[{ required: true, message: "请输入账户名称" }]}
>
<Input placeholder="我的主账号" />
</Form.Item>
<Form.Item
name="cloudflare_account_id"
label="账户 ID"
extra="Cloudflare 账户 ID(账户主页右侧的 Account ID)。可留空,留空则以 Token 实际所属账户为准;填错会被校验拦下。"
>
<Input placeholder="bf8cd983e6dfd171c1881b00b512ed5a" className="mono" />
</Form.Item>
<Form.Item
name="token"
label="API Token"
rules={[{ required: true, message: "请输入 API Token" }]}
extra="到 Cloudflare → My Profile → API Tokens 生成。所需权限与 OAuth 方式一致:Zone Read、Workers Scripts Edit、Email Routing Rules Edit、Zone Settings Write。"
>
<Input.Password placeholder="粘贴 Cloudflare API Token" autoComplete="new-password" />
</Form.Item>
<Button type="primary" icon={<ApiOutlined />} onClick={onTokenSubmit} loading={loading} block>
{loading ? "正在校验并接入…" : "用 API Token 接入"}
</Button>
</Form>
),
},
]}
/>
</Modal>
);
}
/** # 函数
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
*/