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
+25
View File
@@ -140,6 +140,15 @@ export const updateAccount = (id: string, body: { name?: string }) =>
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
/** 用 Cloudflare API Token 添加账号(与 OAuth 并列的第二种接入方式)。
* 后端会先用 token 探一次 Cloudflare 确认可用、且与填写的账户 ID 一致,再建账号并自动接入。 */
export const createAccountByToken = (body: {
name: string;
cloudflare_account_id?: string;
token: string;
}) =>
api<{ ok: boolean; account_id: string; error?: string }>("/api/accounts/token", "POST", body);
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
export const rotateAccount = (id: string) =>
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
@@ -355,3 +364,19 @@ export const createApiKey = () =>
/** 撤销并删除 Key */
export const deleteApiKey = () => api("/api/settings/api-key", "DELETE");
/* ---- 随机邮箱名 ---- */
/** # 成员变量: 随机用户名来源(namedb,返回 { username, first, last, gender }) **/
const NAMEDB_URL = "https://namedb.adx9.com/api/username";
/** 从 namedb 拉一个随机用户名(用于邮箱地址的本地部分)。
* 返回值: 用户名字符串;失败时抛错,由调用方决定提示文案。 */
export async function fetchRandomUsername(): Promise<string> {
const resp = await fetch(NAMEDB_URL);
if (!resp.ok) throw new Error(`随机用户名服务返回 ${resp.status}`);
const data = (await resp.json()) as { username?: string };
const name = (data.username ?? "").trim();
if (!name) throw new Error("随机用户名服务没有返回可用名字");
return name;
}
+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 凭据管理
*/
+103 -46
View File
@@ -4,7 +4,7 @@
* 支持搜索与按账号筛选。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App, Alert } from "antd";
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
batchDeleteDomains,
@@ -41,6 +41,8 @@ export function DomainsPage() {
const [accountId, setAccountId] = useState<string | undefined>();
const [editing, setEditing] = useState<DomainRow | null>(null);
const [deleting, setDeleting] = useState(false);
/** 正在配置 Email Workers 的域名 id(用于按钮 loading + 禁用,防重复点击) */
const [settingId, setSettingId] = useState<string | null>(null);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -70,6 +72,7 @@ export function DomainsPage() {
/** 配置 Email Workers */
const runSetup = async (row: DomainRow) => {
setSettingId(row.id);
try {
const res = await setupDomain(row.id);
if (res.ok) message.success(`已为 ${row.name} 部署 Email Worker 并开启 Email Routing`);
@@ -78,9 +81,38 @@ export function DomainsPage() {
setEditing((cur) => (cur?.id === row.id ? { ...cur, status: res.ok ? "active" : "error" } : cur));
} catch (err) {
message.error(err instanceof Error ? err.message : "配置失败");
} finally {
setSettingId(null);
}
};
/** 配置前的确认弹窗:会改 Cloudflare 侧的 DNS/MX 与路由规则,先说清影响再执行 */
const confirmSetup = (row: DomainRow) => {
const isReconfig = row.status === "active";
modal.confirm({
title: isReconfig ? `重新配置 ${row.name} 的 Email Workers?` : `为 ${row.name} 配置 Email Workers?`,
icon: <CloudUploadOutlined />,
content: (
<div>
<p style={{ marginBottom: 6 }}>将在 Cloudflare 上执行:</p>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>部署(或覆盖更新)Email Worker 脚本 wmailhub-email</li>
<li>开启该域名的 Email Routing(会写入 Cloudflare 的 MX / SPF 记录)</li>
<li>把 catch-all 规则指向该 Worker,之后所有邮件都会进本系统</li>
</ul>
{row.status === "error" && row.setup_error ? (
<p className="muted" style={{ marginTop: 8, marginBottom: 0 }}>
上次失败原因:{row.setup_error}
</p>
) : null}
</div>
),
okText: "开始配置",
cancelText: "取消",
onOk: () => runSetup(row),
});
};
return (
<div>
<div className="page-toolbar">
@@ -146,13 +178,6 @@ export function DomainsPage() {
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
{
title: "Email Worker",
dataIndex: "worker_id",
width: 210,
ellipsis: true,
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未配置</span>),
},
{
title: "Webhook",
width: 95,
@@ -163,41 +188,57 @@ export function DomainsPage() {
{
title: "操作",
width: 260,
render: (_: unknown, row: DomainRow) => (
<Space onClick={(e) => e.stopPropagation()}>
{row.status !== "active" ? (
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => void runSetup(row)}>
设置 Email Workers
render: (_: unknown, row: DomainRow) => {
const busy = settingId === row.id;
return (
<Space onClick={(e) => e.stopPropagation()}>
{row.status !== "active" ? (
<Button
size="small"
type="primary"
icon={<CloudUploadOutlined />}
loading={busy}
disabled={settingId !== null}
onClick={() => confirmSetup(row)}
>
设置 Email Workers
</Button>
) : (
<Button
size="small"
icon={<ReloadOutlined />}
loading={busy}
disabled={settingId !== null}
onClick={() => confirmSetup(row)}
>
重新配置
</Button>
)}
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。",
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
) : (
<Button size="small" icon={<ReloadOutlined />} onClick={() => void runSetup(row)}>
重新配置
</Button>
)}
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。",
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
</Space>
),
</Space>
);
},
},
]}
/>
@@ -227,7 +268,13 @@ export function DomainsPage() {
title="Email Workers"
extra={
editing.status !== "active" && (
<Button size="small" type="primary" onClick={() => void runSetup(editing)}>
<Button
size="small"
type="primary"
loading={settingId === editing.id}
disabled={settingId !== null}
onClick={() => confirmSetup(editing)}
>
设置 Email Workers
</Button>
)
@@ -236,8 +283,8 @@ export function DomainsPage() {
<Space orientation="vertical" style={{ width: "100%" }}>
<StatusTag row={editing} />
<Typography.Text type="secondary">
部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
规则指向 Worker;邮件经由 Node 转发回 Console。
部署统一命名的 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
规则指向 Worker;邮件经由 Worker 转发回 Console。
</Typography.Text>
{editing.status === "active" && editing.worker_id && (
<Typography.Text className="mono">
@@ -245,7 +292,17 @@ export function DomainsPage() {
</Typography.Text>
)}
{editing.status === "error" && editing.setup_error && (
<Typography.Text type="danger">{editing.setup_error}</Typography.Text>
<Alert
type="error"
showIcon
message="配置未完成"
description={editing.setup_error}
action={
<Button size="small" onClick={() => confirmSetup(editing)}>
重试
</Button>
}
/>
)}
</Space>
</Card>
+4 -1
View File
@@ -109,7 +109,10 @@ export function InboxPage() {
useEffect(() => {
void (async () => {
try {
setDomains((await listDomains()).items.map((d) => ({ id: d.id, name: d.name })));
// 只列已安装 Email Workers 的域名:没配 Email Routing 的域名在 Cloudflare
// 侧压根收不到邮件,按它筛选只会得到一个永远为空的列表,反而误导人。
const all = (await listDomains()).items;
setDomains(all.filter((d) => d.status === "active" && d.worker_id).map((d) => ({ id: d.id, name: d.name })));
} catch {
// 域名选项加载失败不阻塞页面
}
+119 -16
View File
@@ -1,10 +1,20 @@
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App, Tooltip } from "antd";
import {
ApiOutlined,
CheckOutlined,
CopyOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import {
createMailbox,
deleteMailbox,
fetchRandomUsername,
listDomains,
listMailboxes,
patchMailbox,
@@ -15,6 +25,15 @@ import { WebhookForm } from "../components/WebhookForm.tsx";
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
import { batchDeleteMailboxes } from "../api.ts";
/** # 函数
* 判断域名是否"已安装 Email Workers"。
* 只有配置完成的域名才能真正收到邮件(未配置的域名在 Cloudflare 侧根本没有 Email Routing),
* 所以建邮箱时只让用户从这些域名里选,避免建出一个永远收不到信的地址。
*/
function hasEmailWorkers(d: DomainRow): boolean {
return d.status === "active" && Boolean(d.worker_id);
}
/** # 函数
* 邮箱页组件
*/
@@ -28,6 +47,10 @@ export function MailboxesPage() {
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
const [form] = Form.useForm();
const [deleting, setDeleting] = useState(false);
/** 正在拉取随机用户名的 loading **/
const [fetchingName, setFetchingName] = useState(false);
/** 刚复制完的邮箱 id(用于播放复制成功的动画) **/
const [copiedId, setCopiedId] = useState<string | null>(null);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -45,6 +68,47 @@ export function MailboxesPage() {
void load();
}, [load]);
/** # 成员变量: 可选域名(仅已安装 Email Workers 的) **/
const readyDomains = useMemo(() => domains.filter(hasEmailWorkers), [domains]);
/** # 成员变量: 最近使用过的域名(最新创建的邮箱所属域名;没有则取第一个可用域名) **/
const recentDomainId = useMemo(() => {
const newest = items[0];
if (newest && readyDomains.some((d) => d.id === newest.domain_id)) return newest.domain_id;
return readyDomains[0]?.id;
}, [items, readyDomains]);
/** 打开「添加邮箱」:默认落在最近用过的域名上 */
const openCreate = () => {
form.resetFields();
if (recentDomainId) form.setFieldsValue({ domain_id: recentDomainId, catch_all: false });
setOpen(true);
};
/** 拉一个随机用户名填进地址框(可反复点,直到碰到喜欢的) */
const rollUsername = async () => {
setFetchingName(true);
try {
form.setFieldValue("address", await fetchRandomUsername());
} catch (err) {
message.error(err instanceof Error ? err.message : "获取随机用户名失败");
} finally {
setFetchingName(false);
}
};
/** 复制邮箱地址:成功时按钮播放一下动画并短暂变成对勾 */
const copyAddress = async (row: MailboxRow) => {
try {
await navigator.clipboard.writeText(row.address);
setCopiedId(row.id);
setTimeout(() => setCopiedId((cur) => (cur === row.id ? null : cur)), 1000);
} catch {
// 剪贴板不可用(非 https / 权限被拒)时退回选中,让用户自己复制
message.warning("浏览器拒绝了剪贴板访问,请手动复制");
}
};
const filtered = useMemo(
() =>
items.filter((row) =>
@@ -93,7 +157,7 @@ export function MailboxesPage() {
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={readyDomains.length === 0}>
添加邮箱
</Button>
</Space>
@@ -111,18 +175,22 @@ export function MailboxesPage() {
title: "地址",
dataIndex: "address",
render: (v: string, row: MailboxRow) => (
<span>
<span className="mono">{v}</span> {row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span className="mono">{v}</span>
{row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
<Tooltip title="复制邮箱地址">
<Button
type="text"
size="small"
className={`copy-btn${copiedId === row.id ? " copied" : ""}`}
icon={copiedId === row.id ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => void copyAddress(row)}
/>
</Tooltip>
</span>
),
},
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 95,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{
title: "保留(秒)",
dataIndex: "retention_seconds",
@@ -181,7 +249,8 @@ export function MailboxesPage() {
try {
const res = await createMailbox({
domain_id: values.domain_id,
address: values.address,
// 勾了 catch-all 时后端会忽略地址、直接建 *@域名
address: values.catch_all ? "catch-all" : values.address,
retention_seconds: values.retention_seconds || undefined,
catch_all: values.catch_all,
});
@@ -197,12 +266,46 @@ export function MailboxesPage() {
<Form form={form} layout="vertical">
<Form.Item name="domain_id" label="域名" rules={[{ required: true, message: "请选择域名" }]}>
<Select
options={domains.map((d) => ({ value: d.id, label: d.name }))}
placeholder="选择域名"
showSearch
optionFilterProp="label"
options={readyDomains.map((d) => ({ value: d.id, label: d.name }))}
placeholder="选择域名(只列出已安装 Email Workers 的域名)"
notFoundContent="没有可用域名:请先到「域名」页为某个域名设置 Email Workers"
/>
</Form.Item>
<Form.Item name="address" label="地址(可只填本地部分)" rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="hello" />
<Form.Item noStyle shouldUpdate={(prev, next) => prev.catch_all !== next.catch_all}>
{({ getFieldValue }) => {
const isCatchAll = Boolean(getFieldValue("catch_all"));
return (
<Form.Item
name="address"
label="地址(邮箱名)"
extra={
isCatchAll
? "已设为 catch-all:该域名下所有邮件都会进入这个邮箱,无需再填邮箱名"
: "只填 @ 前面的部分,域名会自动补全;可点「随机」从名字库取一个"
}
rules={isCatchAll ? [] : [{ required: true, message: "请输入邮箱名" }]}
>
<Input
placeholder="hello"
disabled={isCatchAll}
suffix={
<Tooltip title="从名字库随机取一个">
<Button
type="text"
size="small"
icon={<ThunderboltOutlined />}
loading={fetchingName}
disabled={isCatchAll}
onClick={() => void rollUsername()}
/>
</Tooltip>
}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="retention_seconds" label="保留秒数(留空用全局默认 1 天)">
<InputNumber min={60} style={{ width: "100%" }} placeholder="86400" />
+24
View File
@@ -193,3 +193,27 @@ body,
font-size: 26px;
font-weight: 750;
}
/* ---- 复制按钮:点一下的反馈动画 ---- */
.copy-btn {
color: var(--wmh-muted, #8c8c8c);
transition: transform 0.18s ease, color 0.18s ease, background 0.18s ease;
}
.copy-btn:hover {
color: #2f54eb;
background: rgba(47, 84, 235, 0.10);
}
/* 复制成功:轻微弹一下 + 变绿,1 秒后由 JS 换回默认图标 */
.copy-btn.copied {
color: #52c41a;
animation: wmh-copy-pop 0.45s ease;
}
@keyframes wmh-copy-pop {
0% { transform: scale(1); }
35% { transform: scale(1.35); }
70% { transform: scale(0.92); }
100% { transform: scale(1); }
}
+75 -1
View File
@@ -106,7 +106,7 @@ export function keyHash(key: string): Promise<string> {
}
/** # 函数
* OAuth 连接 token 的加密密钥(沿用历史派生方式,保持既有密文可解)
* OAuth access token 的加密密钥(沿用历史派生方式,保持既有密文可解)
*/
async function getConnectionKey(env: Env): Promise<CryptoKey> {
return deriveAesKey((await getSetting(env, "app_secret")) ?? "wmailhub");
@@ -119,6 +119,40 @@ export async function encryptConnectionToken(env: Env, token: string): Promise<s
return aesEncrypt(await getConnectionKey(env), token);
}
/** # 成员变量: 保存一条 Cloudflare 连接的参数 **/
export interface ConnectionInput {
/** 连接方式:cloudflare = OAuth 授权;cloudflare-token = 手工填写的 API Token **/
provider: "cloudflare" | "cloudflare-token";
/** 绑定的本地账号 id **/
accountId: string;
/** token 明文(入库前加密) **/
accessToken: string;
/** 有效期秒数;API Token 长期有效传 null **/
expiresIn: number | null;
}
/** # 函数
* 保存一条 Cloudflare 连接(token AES-GCM 加密入库,不进前端与日志)。
* 绑定 account_id:多账号接入后,"用哪个 token 配哪个域名"才不会串。
*/
export async function saveConnection(env: Env, input: ConnectionInput): Promise<void> {
const now = new Date().toISOString();
await env.DB.prepare(
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?)`,
)
.bind(
ids.oauth(),
input.provider,
input.accountId,
await encryptConnectionToken(env, input.accessToken),
input.expiresIn === null ? null : new Date(Date.now() + input.expiresIn * 1000).toISOString(),
now,
now,
)
.run();
}
/** # 函数
* 读取最近一条 OAuth 连接的 access token 明文(供 Cloudflare API 调用)
* 返回值: 无连接或解密失败返回 null
@@ -134,3 +168,43 @@ export async function decryptConnectionToken(env: Env): Promise<string | null> {
return null;
}
}
/** # 函数
* 读取**指定账号**的 Cloudflare access token 明文。
* 必须按账号取:多账号接入后,拿"最新一条连接"去配别的账号的域名,会拿错 token
* 去动不属于该账号的 zone(症状是该域名反复配置失败)。
* 兼容:该账号没有绑定连接时(早期数据 account_id 为空)回退到最近一条**未过期**的连接。
* 参数: env - 运行环境;accountId - 本地 cloudflare_accounts.id
* 返回值: token 明文;无可用连接或解密失败返回 null
*/
export async function decryptAccountToken(env: Env, accountId: string): Promise<string | null> {
const now = new Date().toISOString();
// expires_at 为空 = API Token,长期有效,不过滤
const row = await env.DB.prepare(
`SELECT encrypted_access_token FROM oauth_connections
WHERE account_id = ? AND encrypted_access_token != ''
AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT 1`,
)
.bind(accountId, now)
.first<{ encrypted_access_token: string }>();
if (row) {
try {
return await aesDecrypt(await getConnectionKey(env), row.encrypted_access_token);
} catch {
return null;
}
}
const fallback = await env.DB.prepare(
`SELECT encrypted_access_token FROM oauth_connections
WHERE expires_at IS NULL OR expires_at > ? ORDER BY created_at DESC LIMIT 1`,
)
.bind(now)
.first<{ encrypted_access_token: string }>();
if (!fallback) return null;
try {
return await aesDecrypt(await getConnectionKey(env), fallback.encrypted_access_token);
} catch {
return null;
}
}
+8 -16
View File
@@ -29,7 +29,7 @@ import {
preflightOAuth,
} from "../services/cfapi.ts";
import { provisionAccount, createAccount } from "../services/provision.ts";
import { encryptConnectionToken } from "../db.ts";
import { saveConnection } from "../db.ts";
import { nowIso } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -120,8 +120,13 @@ export function oauthRoutes(): App {
try {
const redirectUri = oauthRedirectUri(c.env, url.origin);
const token = await cf.exchangeToken(code, redirectUri);
// 加密入库(不进前端/日志)
await saveConnection(c.env, token.accessToken, token.expiresIn);
// 加密入库(不进前端/日志);绑定到本次授权的账号,多账号时不会串用 token
await saveConnection(c.env, {
provider: "cloudflare",
accountId: verdict.accountId,
accessToken: token.accessToken,
expiresIn: token.expiresIn,
});
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
return c.redirect(target, 302);
@@ -157,16 +162,3 @@ async function consumeState(
if (row.expires_at < nowIso()) return null;
return { accountId: row.account_id };
}
/** # 函数
* OAuth access token 加密入库
*/
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
const encrypted = await encryptConnectionToken(env, accessToken);
await env.DB.prepare(
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
)
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
.run();
}
+60 -4
View File
@@ -14,8 +14,9 @@
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
import { encryptSecret, keyHash, decryptConnectionToken, decryptSecret } from "../db.ts";
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts";
import { getCFApi } from "../services/cfapi.ts";
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
import { checkWebhookUrl, ids } from "../../../shared/src/index.ts";
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
@@ -222,8 +223,8 @@ function installAccountRoutes(app: App) {
if (!account) return c.json({ error: "not found" }, 404);
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
const accessToken = await decryptConnectionToken(c.env);
if (!accessToken) return c.json({ error: "no active Cloudflare OAuth connection" }, 400);
const accessToken = await decryptAccountToken(c.env, id);
if (!accessToken) return c.json({ error: "no active Cloudflare connection for this account" }, 400);
// 轮换:撤销旧 credential,签发新 credential
await c.env.DB.prepare(
@@ -271,6 +272,61 @@ function installAccountRoutes(app: App) {
if (!cred) return c.json({ error: "not found" }, 404);
return c.json({ worker_id: cred.worker_id, secret: await decryptSecret(c.env, cred.secret_enc) });
});
/** API Token 方式添加账号(与 Cloudflare OAuth 并列的第二种接入方式)。
*
* 流程:先用 token 探一次 Cloudflare(列出可见域名)确认凭据可用、且与填写的账户 ID 一致,
* 再建账号 + 加密保存 token + 走与 OAuth 完全相同的自动接入流程。
* 这样"token 填错"会在提交当场报错,而不是留下一个永远配不成功的僵尸账号。 */
app.post("/accounts/token", async (c) => {
const body = await readJson<{ name?: string; cloudflare_account_id?: string; token?: string }>(
c.req.raw,
);
const name = (body.name ?? "").trim();
const token = (body.token ?? "").trim();
const claimedAccountId = (body.cloudflare_account_id ?? "").trim();
if (!name) return c.json({ error: "账户名称不能为空" }, 400);
if (!token) return c.json({ error: "API Token 不能为空" }, 400);
const cf = await getCFApi(c.env);
let discovered: { account: { id: string; name: string }; zones: unknown[] };
try {
discovered = await cf.discover(token);
} catch (err) {
// token 无效 / 权限不足 / 一个域名都看不到,都在这里挡下
return c.json(
{ error: `API Token 校验失败:${err instanceof Error ? err.message : String(err)}` },
400,
);
}
if (claimedAccountId && claimedAccountId !== discovered.account.id) {
return c.json(
{
error:
`填写的账户 ID 与该 Token 实际所属账户不一致` +
`(填写 ${claimedAccountId},Token 实际属于 ${discovered.account.id} / ${discovered.account.name})`,
},
400,
);
}
const created = await createAccount(c.env, {
name,
cloudflareAccountId: discovered.account.id,
});
await saveConnection(c.env, {
provider: "cloudflare-token",
accountId: created.accountId,
accessToken: token,
expiresIn: null,
});
const result = await provisionAccount(c.env, {
accountId: created.accountId,
accessToken: token,
cfAccountId: discovered.account.id,
});
return c.json({ ok: result.ok, account_id: created.accountId, error: result.error }, result.ok ? 200 : 400);
});
}
function installDomainRoutes(app: App) {
app.get("/domains", async (c) => {
+117 -16
View File
@@ -36,11 +36,18 @@ export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
* 该接口只要求 Zone Read,所以本应用完全用不到 Account Settings Read。
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
* `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 不会自动生效。
* 以上名称可用 /cloudflare/preflight 的 scope 探测功能现场核对(见 discoverAllowedScopes)。
*
* 【开启 Email Routing 的坑(2026-09-30 线上实测,踩了两次)】
* Cloudflare 对同一个设置对象给了三个「看起来能改」的方法,但只有一个真的干活:
* PUT /zones/{id}/email/routing -> success:true,但 enabled 原封不动(纯回显)
* PATCH /zones/{id}/email/routing -> 同上,也是回显
* POST /zones/{id}/email/routing/enable -> 唯一真正开启的接口(文档标 Deprecated 但仍可用)
* 踩坑后果:前两个接口返回成功,Console 就把域名标成「已配置 Email Workers」,
* 而 Cloudflare 侧 Email Routing 仍是 unconfigured、连 MX 记录都没有 ——
* 表现为「域名显示配好了却收不到邮件,Dashboard 邮件路由里也找不到这个域名」。
* 因此 enableEmailRouting 开启后必须回读校验 enabled,不能只看响应里的 success。
**/
export const CF_OAUTH_SCOPES_DEFAULT = [
"zone.read",
@@ -367,6 +374,40 @@ export interface CFDiscovery {
zones: CFZone[];
}
/** # 成员变量: Cloudflare API 错误(带 Cloudflare 原始错误码,便于分支处理) **/
export class CFApiError extends Error {
constructor(
message: string,
/** Cloudflare 错误码(如 2008 = 存在非 Cloudflare 的 MX 记录) **/
readonly code?: number,
/** 出错的 API 路径 **/
readonly path?: string,
) {
super(message);
this.name = "CFApiError";
}
}
/** # 成员变量: Zone 的 Email Routing 设置 **/
export interface CFEmailRoutingSettings {
id?: string;
name?: string;
enabled?: boolean;
status?: string;
skip_wizard?: boolean;
}
/** # 成员变量: 域名的一条 MX 记录 **/
export interface CFMxRecord {
id?: string;
name: string;
content: string;
priority?: number;
}
/** # 成员变量: Cloudflare Email Routing 自己的 MX 主机名后缀 **/
const CF_MX_SUFFIX = ".mx.cloudflare.net";
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
export interface CFApi {
/** 用 code 换 access token */
@@ -385,8 +426,10 @@ export interface CFApi {
/** Worker credential secret(HMAC 签名密钥) **/
workerSecret: string;
}): Promise<{ id: string }>;
/** 开启 Zone 的 Email Routing(幂等) */
/** 开启 Zone 的 Email Routing(幂等;开启后会回读校验真的生效) */
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
/** 读取 Zone 的 Email Routing 设置 */
getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }): Promise<CFEmailRoutingSettings>;
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
setCatchAllRule(opts: {
accessToken: string;
@@ -490,9 +533,11 @@ export class RealCFApi implements CFApi {
};
if (!data.success) {
const first = data.errors?.[0];
// 带上 Cloudflare 的错误码(如 10405),只看 message 经常分不清是路径错还是权限错
throw new Error(
// 带上 Cloudflare 的错误码(如 10405、2008),只看 message 经常分不清是路径错、权限错还是配置冲突
throw new CFApiError(
`cloudflare api ${path}: ${first?.code ? `[${first.code}] ` : ""}${first?.message ?? resp.status}`,
first?.code,
path,
);
}
return data.result;
@@ -554,19 +599,75 @@ export class RealCFApi implements CFApi {
return { id: opts.scriptName };
}
/** 读取 Zone 的 Email Routing 设置(enabled/status 是判断"到底有没有开成"的唯一依据) */
async getEmailRoutingSettings(opts: { accessToken: string; zoneId: string }) {
return await this.api<CFEmailRoutingSettings>(opts.accessToken, `/zones/${opts.zoneId}/email/routing`);
}
/** 列出 Zone 的 MX 记录(用于把"存在非 Cloudflare MX"这个阻塞原因说清楚) */
private async listMxRecords(accessToken: string, zoneId: string): Promise<CFMxRecord[]> {
try {
const rows = await this.api<CFMxRecord[]>(
accessToken,
`/zones/${zoneId}/dns_records?type=MX&per_page=100`,
);
return rows ?? [];
} catch {
// 读不到 DNS(缺 Zone Read 之类)不影响主流程,降级成不带明细的提示
return [];
}
}
/** 开启 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 那一类权限)。 */
*
* 必须走 `POST /zones/{id}/email/routing/enable`:
* - `PUT`/`PATCH /zones/{id}/email/routing` 会返回 success:true 但**什么都不做**,
* enabled 保持 false(2026-09-30 线上对 adx9.com / galaxyemail.com 实测);
* 照着它的返回值把域名标成「已配置」,就是「显示配好了却收不到邮件」的根因。
* - `/enable` 虽然在文档里标了 Deprecated,但至今仍然可用,并且是唯一真正生效的入口。
*
* 开启后**回读校验** enabled:接口说成功不算数,状态对象变成 ready 才算开成。
*/
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
const settings = await this.api<{ enabled?: boolean; status?: string }>(
opts.accessToken,
`/zones/${opts.zoneId}/email/routing`,
{ method: "PUT", body: JSON.stringify({ enabled: true, skip_wizard: true }) },
try {
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/enable`, {
method: "POST",
body: JSON.stringify({ skip_wizard: true }),
});
} catch (err) {
// 2008:域名上已有别的邮件服务的 MX 记录,Cloudflare 拒绝接管。
// 这是最常见的"开不成功"原因,把冲突的记录点名,用户才知道该删哪条。
if (err instanceof CFApiError && err.code === 2008) {
throw new CFApiError(
await this.describeMxConflict(opts.accessToken, opts.zoneId),
2008,
err.path,
);
}
throw err;
}
const settings = await this.getEmailRoutingSettings(opts);
if (!settings?.enabled) {
throw new Error(
`cloudflare 报告 Email Routing 已开启,但回读状态仍是 ${settings?.status ?? "unknown"}` +
`(enabled=false);请到 Cloudflare Dashboard 检查该域名的邮件路由状态`,
);
}
return { enabled: true };
}
/** 把 2008 翻译成可执行的提示:点名冲突的 MX 记录,并说明删掉的后果 */
private async describeMxConflict(accessToken: string, zoneId: string): Promise<string> {
const records = await this.listMxRecords(accessToken, zoneId);
const foreign = records.filter((r) => !r.content.toLowerCase().endsWith(CF_MX_SUFFIX));
const detail = foreign.length
? foreign.map((r) => `${r.name}(优先级 ${r.priority ?? "-"} → ${r.content})`).join("、")
: "未能读出具体记录";
return (
`该域名存在非 Cloudflare 的 MX 记录,Cloudflare 拒绝开启 Email Routing(错误 2008)。` +
`冲突记录:${detail}。请先在 Cloudflare → Email → Email Routing 里处理掉这些记录` +
`(通常是删除它们,或改用 Cloudflare 托管);注意删除后原有邮件服务将不再收信。`
);
return { enabled: settings?.enabled ?? true };
}
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
+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(
+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"}]`。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wmailhub",
"version": "2.3.6",
"version": "2.4.0",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
"private": true,
"type": "module",
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+39 -8
View File
@@ -33,6 +33,10 @@ export interface CfMockOptions {
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 桩句柄 **/
@@ -66,6 +70,8 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
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;
@@ -96,6 +102,13 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
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, {
@@ -147,18 +160,36 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
}
if (url.startsWith(`${API_BASE}/zones`)) {
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 },
// 开启 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,
);
}
return json({ id: url.split("/").at(-2), enabled: true, skip_wizard: true, status: "ready" });
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.includes("/email/routing")) return json({ enabled: true });
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() });
+166 -6
View File
@@ -16,12 +16,14 @@ afterEach(() => {
/** # 函数
* 走完 start -> callback,返回账号 id 与桩(不携带会话 cookie 调 callback,模拟跨站重定向)
* 参数: opts - 传给 Cloudflare 桩的配置(如模拟 Email Routing 开启失败)
*/
async function connectAccount(
t: Awaited<ReturnType<typeof makeTestEnv>>,
cookie: string,
opts: Parameters<typeof installCloudflareMock>[0] = {},
): Promise<{ accountId: string; cf: CfMock; resp: Response }> {
const cf = installCloudflareMock();
const cf = installCloudflareMock(opts);
mock = cf;
const start = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
@@ -63,7 +65,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
for (const key of ["oauth", "discover", "domains", "worker"]) {
expect(detail.steps[key].status).toBe("done");
}
expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发
expect(detail.account.worker_id).toBe("wmailhub-email"); // 统一 Worker 名(已存在则覆盖更新)
expect(detail.credential).not.toBeNull();
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
const credRow = await t.env.DB.prepare(
@@ -111,17 +113,28 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
).json()) as { ok: boolean; error?: string };
expect(setup.ok).toBe(true);
// 开启 Email Routing 必须是 PUT(POST 到同一路径是 10405,桩会拒)
expect(cf.hit("PUT", "/zones/zone-example/email/routing")).toBe(true);
// 开启 Email Routing 必须走 POST .../email/routing/enable。
// PUT/PATCH /email/routing 会回 success 但 enabled 不动(桩已复刻这个行为),
// 照它的返回值判断就会把没开成 Email Routing 的域名标成「已配置」——
// 这正是 2026-09-30 线上「域名显示配好了却收不到邮件」的根因。
expect(cf.hit("POST", "/zones/zone-example/email/routing/enable")).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.calls.some(
(c) =>
(c.method === "PUT" || c.method === "PATCH") &&
c.path === "/zones/zone-example/email/routing",
),
).toBe(false);
// 开启后回读状态做校验:接口说成功不算数,enabled 为 true 才算开成
expect(cf.hit("GET", "/zones/zone-example/email/routing")).toBe(true);
expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(true);
const after = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
const exampleAfter = after.items.find((d) => d.id === example.id)!;
expect(exampleAfter.status).toBe("active");
expect(exampleAfter.worker_id).toContain("wmailhub-email-");
expect(exampleAfter.worker_id).toBe("wmailhub-email");
// OAuth token 已加密入库(不含明文 token)
const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{
@@ -537,3 +550,150 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
expect(resp.status).toBe(401);
});
});
describe("Email Routing 开启失败与回读校验(2026-09-30 线上 bug 的回归锁)", () => {
test("域名上已有非 Cloudflare MX(2008):标 error 并点名冲突记录,不谎报已配置", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const { accountId, cf } = await connectAccount(t, cookie, {
emailRoutingBlocked: true,
foreignMx: [{ name: "example.com", content: "mx.example.com", priority: 10 }],
});
const domList = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string }[] };
const example = domList.items.find((d) => d.name === "example.com")!;
const setup = (await (
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
).json()) as { ok: boolean; error?: string };
// 关键:开不成就不能标 active —— 这正是线上"显示已配置却收不到邮件"的成因
expect(setup.ok).toBe(false);
const after = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; status: string; setup_error: string | null }[] };
const row = after.items.find((d) => d.id === example.id)!;
expect(row.status).toBe("error");
// 错误信息要能指导用户行动:说清是 MX 冲突,并点名是哪条记录
expect(row.setup_error).toContain("非 Cloudflare 的 MX 记录");
expect(row.setup_error).toContain("mx.example.com");
// catch-all 规则不该在 Routing 没开成的情况下被改写
expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(false);
// 账号仍要有 Worker 凭据(脚本已部署,只是 Routing 没开成)
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`)
).json()) as { credential: unknown };
expect(detail.credential).not.toBeNull();
});
test("开启成功但回读 enabled=false:判失败而不是标记已配置", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
await connectAccount(t, cookie);
const domList = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string }[] };
const example = domList.items.find((d) => d.name === "example.com")!;
// 让桩的 /enable 变成"回 success 但不改状态":直接把 fetch 换成半吊子实现
const base = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith("/email/routing/enable")) {
return new Response(JSON.stringify({ success: true, errors: [], result: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return base(input as RequestInfo, init);
}) as typeof globalThis.fetch;
const setup = (await (
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
).json()) as { ok: boolean; error?: string };
expect(setup.ok).toBe(false);
expect(setup.error).toContain("enabled=false");
});
});
describe("API Token 方式添加账号(与 OAuth 并列的第二种接入方式)", () => {
test("填 Token 即可接入:token 加密入库并绑定到该账号,域名同步完成", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const cf = installCloudflareMock({ accessToken: "cf-user-api-token-abc" });
mock = cf;
const resp = await authedFetch(t, cookie, "http://localhost/api/accounts/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "我的主账号",
cloudflare_account_id: "cf-acct-real-1",
token: "cf-user-api-token-abc",
}),
});
const body = (await resp.json()) as { ok: boolean; account_id: string; error?: string };
expect(resp.status).toBe(200);
expect(body.ok).toBe(true);
// 账号建好且按 Cloudflare 实际账户命名
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${body.account_id}`)
).json()) as {
account: { name: string; cloudflare_account_id: string; worker_id: string; status: string };
domains: { name: string; status: string }[];
};
expect(detail.account.status).toBe("active");
expect(detail.account.cloudflare_account_id).toBe("cf-acct-real-1");
expect(detail.account.worker_id).toBe("wmailhub-email");
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"]));
// 同步出的域名默认 pending,要逐个「设置 Email Workers」
expect(detail.domains.every((d) => d.status === "pending")).toBe(true);
// token 加密入库,不落明文;provider 标成 cloudflare-token
const conn = await t.env.DB.prepare(
"SELECT provider, account_id, encrypted_access_token, expires_at FROM oauth_connections WHERE account_id = ?",
)
.bind(body.account_id)
.first<{ provider: string; account_id: string; encrypted_access_token: string; expires_at: string | null }>();
expect(conn?.provider).toBe("cloudflare-token");
expect(conn!.encrypted_access_token).not.toContain("cf-user-api-token-abc");
expect(conn!.expires_at).toBeNull(); // API Token 长期有效
});
test("Token 无效 / 账户 ID 对不上:当场报错,不留下僵尸账号", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const cf = installCloudflareMock({ zones: [] }); // 一个域名都看不到 = token 没权限
mock = cf;
const before = ((await (await authedFetch(t, cookie, "http://localhost/api/accounts")).json()) as {
total: number;
}).total;
const resp = await authedFetch(t, cookie, "http://localhost/api/accounts/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "坏 Token", token: "bad-token" }),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error: string };
expect(body.error).toContain("API Token 校验失败");
const after = ((await (await authedFetch(t, cookie, "http://localhost/api/accounts")).json()) as {
total: number;
}).total;
expect(after).toBe(before); // 没建出账号
// 账户 ID 与 token 实际所属账户不一致 -> 明确指出两个 ID
installCloudflareMock();
const mismatch = await authedFetch(t, cookie, "http://localhost/api/accounts/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "对不上", cloudflare_account_id: "cf-acct-wrong", token: "tok" }),
});
expect(mismatch.status).toBe(400);
const mBody = (await mismatch.json()) as { error: string };
expect(mBody.error).toContain("cf-acct-wrong");
expect(mBody.error).toContain("cf-acct-real-1");
});
});
+96
View File
@@ -143,6 +143,102 @@ for (const path of ["/accounts", "/domains", "/mailboxes", "/inboxs"]) {
if (path === "/mailboxes") await page.screenshot({ path: `${SHOT_DIR}/11-batch-selected.png` });
}
// 6b. 添加账号模态窗口:OAuth / API Token 两种授权方式 + 各自说明
await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table", { timeout: 8000 });
await page.click("button:has-text('添加账号')");
await page.waitForSelector(".ant-modal:has-text('添加 Cloudflare 账号')", { timeout: 8000 });
const addTabs = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
check(
"添加账号模态窗口含 OAuth / API Token 两种授权方式",
addTabs.some((t) => t.includes("OAuth")) && addTabs.some((t) => t.includes("API Token")),
addTabs.join(" | "),
);
check(
"OAuth 方式带说明(不需在本系统保存密钥)",
(await page.locator(".ant-modal :text('不需要在本机保存任何密钥')").count()) > 0,
);
await page.screenshot({ path: `${SHOT_DIR}/12a-add-account-oauth.png` });
await page.click(".ant-modal .ant-tabs-tab:has-text('API Token')");
await page.waitForSelector(".ant-modal :text('账户名称')", { timeout: 8000 });
check(
"API Token 方式含 账户名称 / 账户 ID / API Token 三个字段",
(await page.locator(".ant-modal :text('账户 ID')").count()) > 0 &&
(await page.locator(".ant-modal input[type=password]").count()) > 0,
);
check(
"API Token 方式带说明(会先校验再接入)",
(await page.locator(".ant-modal :text('确认可用后才建账号')").count()) > 0,
);
await page.screenshot({ path: `${SHOT_DIR}/12b-add-account-token.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
// 6c. 添加邮箱模态窗口:域名只列已装 Email Workers 的、随机用户名、catch-all 开关禁用地址
await page.goto(`${BASE}/mailboxes`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table", { timeout: 8000 });
check(
"邮箱列表不再有「状态」列(已由「启用」开关承担)",
(await page.locator(".ant-table-thead th:has-text('状态')").count()) === 0,
);
check(
"地址列带复制按钮",
(await page.locator(".ant-table-tbody button.copy-btn").count()) > 0,
);
await page.click("button:has-text('添加邮箱')");
await page.waitForSelector(".ant-modal:has-text('添加邮箱')", { timeout: 8000 });
await page.locator(".ant-modal .ant-select").first().click();
await page.waitForTimeout(600);
const domainOptions = await page.locator(".ant-select-dropdown .ant-select-item-option-content").allInnerTexts();
check("添加邮箱的域名下拉有可选项", domainOptions.length > 0, domainOptions.join(" | "));
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
// 随机用户名:点一下应把名字库返回的名字填进地址框
await page.click(".ant-modal button:has(.anticon-thunderbolt)");
await page.waitForFunction(
() => [...document.querySelectorAll(".ant-modal input")].some((i) => (i as HTMLInputElement).value?.length),
undefined,
{ timeout: 15000 },
);
const addressInput = page.locator(".ant-modal input").nth(1);
const filled = (await addressInput.inputValue()).trim();
check("随机用户名可从名字库取到并填入地址框", filled.length > 0, filled);
// 勾上 catch-all 后地址输入框应被禁用
await page.locator(".ant-modal .ant-switch").first().click();
await page.waitForTimeout(600);
check("勾选 catch-all 后地址输入框被禁用", await addressInput.isDisabled());
await page.screenshot({ path: `${SHOT_DIR}/13-add-mailbox.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
// 6d. 「设置 Email Workers」点击后先弹确认框,不直接发请求
await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-tbody button", { timeout: 8000 });
check(
"域名列表不再有「Email Worker」列(统一命名,状态列已足够)",
(await page.locator(".ant-table-thead th:has-text('Email Worker')").count()) === 0,
);
const setupBtn = page
.locator(".ant-table-tbody button")
.filter({ hasText: /设置 Email Workers|重新配置/ })
.first();
if ((await setupBtn.count()) > 0) {
await setupBtn.click();
await page.waitForSelector(".ant-modal-confirm, .ant-popconfirm, .ant-popover", { timeout: 8000 });
const confirmText =
(await page.locator(".ant-modal-confirm, .ant-popconfirm, .ant-popover").first().innerText().catch(() => "")) ?? "";
check(
"点「设置 Email Workers」先弹确认框(说明会改 MX 与路由)",
confirmText.includes("Email Routing"),
confirmText.split("\n")[0],
);
await page.waitForTimeout(600); // 等确认框进场动画,截图才有内容
await page.screenshot({ path: `${SHOT_DIR}/14-setup-confirm.png` });
await page.keyboard.press("Escape");
} else {
check("点「设置 Email Workers」先弹确认框(说明会改 MX 与路由)", false, "找不到配置按钮");
}
await browser.close();
const failed = results.filter((r) => !r.ok);