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:
@@ -140,6 +140,15 @@ export const updateAccount = (id: string, body: { name?: string }) =>
|
|||||||
|
|
||||||
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
|
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 仅本次返回可见;已配置域名自动重新部署 */
|
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
|
||||||
export const rotateAccount = (id: string) =>
|
export const rotateAccount = (id: string) =>
|
||||||
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
|
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
|
||||||
@@ -355,3 +364,19 @@ export const createApiKey = () =>
|
|||||||
|
|
||||||
/** 撤销并删除 Key */
|
/** 撤销并删除 Key */
|
||||||
export const deleteApiKey = () => api("/api/settings/api-key", "DELETE");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,20 +2,26 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
|
Radio,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Timeline,
|
Timeline,
|
||||||
Typography,
|
Typography,
|
||||||
App,
|
App,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
batchDeleteAccounts,
|
batchDeleteAccounts,
|
||||||
|
createAccountByToken,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
listAccounts,
|
listAccounts,
|
||||||
@@ -111,7 +117,48 @@ export function AccountsPage() {
|
|||||||
[items, q],
|
[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 () => {
|
const startOAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const { url } = await oauthStart();
|
const { url } = await oauthStart();
|
||||||
@@ -121,11 +168,6 @@ export function AccountsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (id: string) => {
|
|
||||||
setDetail(await getAccount(id));
|
|
||||||
setDetailOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-toolbar">
|
<div className="page-toolbar">
|
||||||
@@ -138,8 +180,8 @@ export function AccountsPage() {
|
|||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
|
「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、
|
||||||
并签发 Worker 凭据(Email Worker 直连 Console)。
|
部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
@@ -164,7 +206,7 @@ export function AccountsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
|
||||||
添加账号
|
添加账号
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -237,6 +279,21 @@ export function AccountsPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</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
|
<AccountDetailDrawer
|
||||||
detail={detail}
|
detail={detail}
|
||||||
open={detailOpen}
|
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 凭据管理
|
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 支持搜索与按账号筛选。
|
* 支持搜索与按账号筛选。
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
batchDeleteDomains,
|
batchDeleteDomains,
|
||||||
@@ -41,6 +41,8 @@ export function DomainsPage() {
|
|||||||
const [accountId, setAccountId] = useState<string | undefined>();
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
const [editing, setEditing] = useState<DomainRow | null>(null);
|
const [editing, setEditing] = useState<DomainRow | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
/** 正在配置 Email Workers 的域名 id(用于按钮 loading + 禁用,防重复点击) */
|
||||||
|
const [settingId, setSettingId] = useState<string | null>(null);
|
||||||
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -70,6 +72,7 @@ export function DomainsPage() {
|
|||||||
|
|
||||||
/** 配置 Email Workers */
|
/** 配置 Email Workers */
|
||||||
const runSetup = async (row: DomainRow) => {
|
const runSetup = async (row: DomainRow) => {
|
||||||
|
setSettingId(row.id);
|
||||||
try {
|
try {
|
||||||
const res = await setupDomain(row.id);
|
const res = await setupDomain(row.id);
|
||||||
if (res.ok) message.success(`已为 ${row.name} 部署 Email Worker 并开启 Email Routing`);
|
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));
|
setEditing((cur) => (cur?.id === row.id ? { ...cur, status: res.ok ? "active" : "error" } : cur));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err instanceof Error ? err.message : "配置失败");
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-toolbar">
|
<div className="page-toolbar">
|
||||||
@@ -146,13 +178,6 @@ export function DomainsPage() {
|
|||||||
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
{ 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: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||||
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
|
{ 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",
|
title: "Webhook",
|
||||||
width: 95,
|
width: 95,
|
||||||
@@ -163,14 +188,29 @@ export function DomainsPage() {
|
|||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (_: unknown, row: DomainRow) => (
|
render: (_: unknown, row: DomainRow) => {
|
||||||
|
const busy = settingId === row.id;
|
||||||
|
return (
|
||||||
<Space onClick={(e) => e.stopPropagation()}>
|
<Space onClick={(e) => e.stopPropagation()}>
|
||||||
{row.status !== "active" ? (
|
{row.status !== "active" ? (
|
||||||
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => void runSetup(row)}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<CloudUploadOutlined />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={settingId !== null}
|
||||||
|
onClick={() => confirmSetup(row)}
|
||||||
|
>
|
||||||
设置 Email Workers
|
设置 Email Workers
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => void runSetup(row)}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={settingId !== null}
|
||||||
|
onClick={() => confirmSetup(row)}
|
||||||
|
>
|
||||||
重新配置
|
重新配置
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +237,8 @@ export function DomainsPage() {
|
|||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -227,7 +268,13 @@ export function DomainsPage() {
|
|||||||
title="Email Workers"
|
title="Email Workers"
|
||||||
extra={
|
extra={
|
||||||
editing.status !== "active" && (
|
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
|
设置 Email Workers
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -236,8 +283,8 @@ export function DomainsPage() {
|
|||||||
<Space orientation="vertical" style={{ width: "100%" }}>
|
<Space orientation="vertical" style={{ width: "100%" }}>
|
||||||
<StatusTag row={editing} />
|
<StatusTag row={editing} />
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
|
部署统一命名的 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
|
||||||
规则指向 Worker;邮件经由 Node 转发回 Console。
|
规则指向 Worker;邮件经由 Worker 转发回 Console。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{editing.status === "active" && editing.worker_id && (
|
{editing.status === "active" && editing.worker_id && (
|
||||||
<Typography.Text className="mono">
|
<Typography.Text className="mono">
|
||||||
@@ -245,7 +292,17 @@ export function DomainsPage() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
{editing.status === "error" && editing.setup_error && (
|
{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>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -109,7 +109,10 @@ export function InboxPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// 域名选项加载失败不阻塞页面
|
// 域名选项加载失败不阻塞页面
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
|
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
|
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App, Tooltip } from "antd";
|
||||||
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
createMailbox,
|
createMailbox,
|
||||||
deleteMailbox,
|
deleteMailbox,
|
||||||
|
fetchRandomUsername,
|
||||||
listDomains,
|
listDomains,
|
||||||
listMailboxes,
|
listMailboxes,
|
||||||
patchMailbox,
|
patchMailbox,
|
||||||
@@ -15,6 +25,15 @@ import { WebhookForm } from "../components/WebhookForm.tsx";
|
|||||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||||
import { batchDeleteMailboxes } from "../api.ts";
|
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 [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [deleting, setDeleting] = useState(false);
|
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 { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -45,6 +68,47 @@ export function MailboxesPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [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(
|
const filtered = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.filter((row) =>
|
items.filter((row) =>
|
||||||
@@ -93,7 +157,7 @@ export function MailboxesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
<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>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -111,18 +175,22 @@ export function MailboxesPage() {
|
|||||||
title: "地址",
|
title: "地址",
|
||||||
dataIndex: "address",
|
dataIndex: "address",
|
||||||
render: (v: string, row: MailboxRow) => (
|
render: (v: string, row: MailboxRow) => (
|
||||||
<span>
|
<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}
|
<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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
|
{ 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: "保留(秒)",
|
title: "保留(秒)",
|
||||||
dataIndex: "retention_seconds",
|
dataIndex: "retention_seconds",
|
||||||
@@ -181,7 +249,8 @@ export function MailboxesPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await createMailbox({
|
const res = await createMailbox({
|
||||||
domain_id: values.domain_id,
|
domain_id: values.domain_id,
|
||||||
address: values.address,
|
// 勾了 catch-all 时后端会忽略地址、直接建 *@域名
|
||||||
|
address: values.catch_all ? "catch-all" : values.address,
|
||||||
retention_seconds: values.retention_seconds || undefined,
|
retention_seconds: values.retention_seconds || undefined,
|
||||||
catch_all: values.catch_all,
|
catch_all: values.catch_all,
|
||||||
});
|
});
|
||||||
@@ -197,12 +266,46 @@ export function MailboxesPage() {
|
|||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="domain_id" label="域名" rules={[{ required: true, message: "请选择域名" }]}>
|
<Form.Item name="domain_id" label="域名" rules={[{ required: true, message: "请选择域名" }]}>
|
||||||
<Select
|
<Select
|
||||||
options={domains.map((d) => ({ value: d.id, label: d.name }))}
|
showSearch
|
||||||
placeholder="选择域名"
|
optionFilterProp="label"
|
||||||
|
options={readyDomains.map((d) => ({ value: d.id, label: d.name }))}
|
||||||
|
placeholder="选择域名(只列出已安装 Email Workers 的域名)"
|
||||||
|
notFoundContent="没有可用域名:请先到「域名」页为某个域名设置 Email Workers"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="address" label="地址(可只填本地部分)" rules={[{ required: true, message: "请输入地址" }]}>
|
<Form.Item noStyle shouldUpdate={(prev, next) => prev.catch_all !== next.catch_all}>
|
||||||
<Input placeholder="hello" />
|
{({ 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>
|
||||||
<Form.Item name="retention_seconds" label="保留秒数(留空用全局默认 1 天)">
|
<Form.Item name="retention_seconds" label="保留秒数(留空用全局默认 1 天)">
|
||||||
<InputNumber min={60} style={{ width: "100%" }} placeholder="86400" />
|
<InputNumber min={60} style={{ width: "100%" }} placeholder="86400" />
|
||||||
|
|||||||
@@ -193,3 +193,27 @@ body,
|
|||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 750;
|
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
@@ -106,7 +106,7 @@ export function keyHash(key: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* OAuth 连接 token 的加密密钥(沿用历史派生方式,保持既有密文可解)
|
* OAuth access token 的加密密钥(沿用历史派生方式,保持既有密文可解)
|
||||||
*/
|
*/
|
||||||
async function getConnectionKey(env: Env): Promise<CryptoKey> {
|
async function getConnectionKey(env: Env): Promise<CryptoKey> {
|
||||||
return deriveAesKey((await getSetting(env, "app_secret")) ?? "wmailhub");
|
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);
|
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 调用)
|
* 读取最近一条 OAuth 连接的 access token 明文(供 Cloudflare API 调用)
|
||||||
* 返回值: 无连接或解密失败返回 null
|
* 返回值: 无连接或解密失败返回 null
|
||||||
@@ -134,3 +168,43 @@ export async function decryptConnectionToken(env: Env): Promise<string | null> {
|
|||||||
return 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
preflightOAuth,
|
preflightOAuth,
|
||||||
} from "../services/cfapi.ts";
|
} from "../services/cfapi.ts";
|
||||||
import { provisionAccount, createAccount } from "../services/provision.ts";
|
import { provisionAccount, createAccount } from "../services/provision.ts";
|
||||||
import { encryptConnectionToken } from "../db.ts";
|
import { saveConnection } from "../db.ts";
|
||||||
import { nowIso } from "../util.ts";
|
import { nowIso } from "../util.ts";
|
||||||
|
|
||||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||||
@@ -120,8 +120,13 @@ export function oauthRoutes(): App {
|
|||||||
try {
|
try {
|
||||||
const redirectUri = oauthRedirectUri(c.env, url.origin);
|
const redirectUri = oauthRedirectUri(c.env, url.origin);
|
||||||
const token = await cf.exchangeToken(code, redirectUri);
|
const token = await cf.exchangeToken(code, redirectUri);
|
||||||
// 加密入库(不进前端/日志)
|
// 加密入库(不进前端/日志);绑定到本次授权的账号,多账号时不会串用 token
|
||||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
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 result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
|
||||||
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
|
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
|
||||||
return c.redirect(target, 302);
|
return c.redirect(target, 302);
|
||||||
@@ -157,16 +162,3 @@ async function consumeState(
|
|||||||
if (row.expires_at < nowIso()) return null;
|
if (row.expires_at < nowIso()) return null;
|
||||||
return { accountId: row.account_id };
|
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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Env, AppVars } from "../env.ts";
|
import type { Env, AppVars } from "../env.ts";
|
||||||
import { requireSession } from "../middleware.ts";
|
import { requireSession } from "../middleware.ts";
|
||||||
import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
|
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts";
|
||||||
import { encryptSecret, keyHash, decryptConnectionToken, decryptSecret } from "../db.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 { checkWebhookUrl, ids } from "../../../shared/src/index.ts";
|
||||||
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
|
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
|
||||||
import { nowIso, pageParams, readJson } from "../util.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) return c.json({ error: "not found" }, 404);
|
||||||
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
|
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
|
||||||
|
|
||||||
const accessToken = await decryptConnectionToken(c.env);
|
const accessToken = await decryptAccountToken(c.env, id);
|
||||||
if (!accessToken) return c.json({ error: "no active Cloudflare OAuth connection" }, 400);
|
if (!accessToken) return c.json({ error: "no active Cloudflare connection for this account" }, 400);
|
||||||
|
|
||||||
// 轮换:撤销旧 credential,签发新 credential
|
// 轮换:撤销旧 credential,签发新 credential
|
||||||
await c.env.DB.prepare(
|
await c.env.DB.prepare(
|
||||||
@@ -271,6 +272,61 @@ function installAccountRoutes(app: App) {
|
|||||||
if (!cred) return c.json({ error: "not found" }, 404);
|
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) });
|
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) {
|
function installDomainRoutes(app: App) {
|
||||||
app.get("/domains", async (c) => {
|
app.get("/domains", async (c) => {
|
||||||
|
|||||||
+117
-16
@@ -36,11 +36,18 @@ 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)。
|
||||||
|
*
|
||||||
|
* 【开启 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 = [
|
export const CF_OAUTH_SCOPES_DEFAULT = [
|
||||||
"zone.read",
|
"zone.read",
|
||||||
@@ -367,6 +374,40 @@ export interface CFDiscovery {
|
|||||||
zones: CFZone[];
|
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 客户端接口(自动安装所需操作) **/
|
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||||
export interface CFApi {
|
export interface CFApi {
|
||||||
/** 用 code 换 access token */
|
/** 用 code 换 access token */
|
||||||
@@ -385,8 +426,10 @@ export interface CFApi {
|
|||||||
/** Worker credential secret(HMAC 签名密钥) **/
|
/** Worker credential secret(HMAC 签名密钥) **/
|
||||||
workerSecret: string;
|
workerSecret: string;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
/** 开启 Zone 的 Email Routing(幂等) */
|
/** 开启 Zone 的 Email Routing(幂等;开启后会回读校验真的生效) */
|
||||||
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
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(幂等) */
|
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
|
||||||
setCatchAllRule(opts: {
|
setCatchAllRule(opts: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -490,9 +533,11 @@ export class RealCFApi implements CFApi {
|
|||||||
};
|
};
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
const first = data.errors?.[0];
|
const first = data.errors?.[0];
|
||||||
// 带上 Cloudflare 的错误码(如 10405),只看 message 经常分不清是路径错还是权限错
|
// 带上 Cloudflare 的错误码(如 10405、2008),只看 message 经常分不清是路径错、权限错还是配置冲突
|
||||||
throw new Error(
|
throw new CFApiError(
|
||||||
`cloudflare api ${path}: ${first?.code ? `[${first.code}] ` : ""}${first?.message ?? resp.status}`,
|
`cloudflare api ${path}: ${first?.code ? `[${first.code}] ` : ""}${first?.message ?? resp.status}`,
|
||||||
|
first?.code,
|
||||||
|
path,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return data.result;
|
return data.result;
|
||||||
@@ -554,19 +599,75 @@ export class RealCFApi implements CFApi {
|
|||||||
return { id: opts.scriptName };
|
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 记录)
|
/** 开启 Zone 的 Email Routing(加并锁定 MX/SPF 记录)
|
||||||
* 注意:走 `PUT /zones/{id}/email/routing`(Apply Email Routing settings)。
|
*
|
||||||
* - `POST /zones/{id}/email/routing` 不是有效路由,Cloudflare 返回
|
* 必须走 `POST /zones/{id}/email/routing/enable`:
|
||||||
* `10405 Method not allowed for this authentication scheme`(2026-09-30 线上踩过)
|
* - `PUT`/`PATCH /zones/{id}/email/routing` 会返回 success:true 但**什么都不做**,
|
||||||
* - `POST .../email/routing/enable` 虽然能用,但官方已标 Deprecated
|
* enabled 保持 false(2026-09-30 线上对 adx9.com / galaxyemail.com 实测);
|
||||||
* 该接口的 Accepted Permission 是 **Zone Settings Write**(不是 Email Routing 那一类权限)。 */
|
* 照着它的返回值把域名标成「已配置」,就是「显示配好了却收不到邮件」的根因。
|
||||||
|
* - `/enable` 虽然在文档里标了 Deprecated,但至今仍然可用,并且是唯一真正生效的入口。
|
||||||
|
*
|
||||||
|
* 开启后**回读校验** enabled:接口说成功不算数,状态对象变成 ready 才算开成。
|
||||||
|
*/
|
||||||
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
||||||
const settings = await this.api<{ enabled?: boolean; status?: string }>(
|
try {
|
||||||
opts.accessToken,
|
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/enable`, {
|
||||||
`/zones/${opts.zoneId}/email/routing`,
|
method: "POST",
|
||||||
{ method: "PUT", body: JSON.stringify({ enabled: true, skip_wizard: true }) },
|
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 }) {
|
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import type { Env } from "../env.ts";
|
import type { Env } from "../env.ts";
|
||||||
import { getCFApi, type CFApi } from "./cfapi.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 { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
|
||||||
import { nowIso } from "../util.ts";
|
import { nowIso } from "../util.ts";
|
||||||
|
|
||||||
@@ -35,28 +35,46 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
|
|||||||
return out;
|
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 {
|
export function emailWorkerNameFor(_accountId: string): string {
|
||||||
return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
return EMAIL_WORKER_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。
|
* 添加账号:创建 cloudflare_accounts 记录(等待接入)。
|
||||||
|
* 参数: opts.name - 展示名;opts.cloudflareAccountId - 已知的 Cloudflare 账户 ID(API Token 方式可先填)
|
||||||
*/
|
*/
|
||||||
export async function createAccount(
|
export async function createAccount(
|
||||||
env: Env,
|
env: Env,
|
||||||
opts: { name: string },
|
opts: { name: string; cloudflareAccountId?: string | null },
|
||||||
): Promise<{ accountId: string }> {
|
): Promise<{ accountId: string }> {
|
||||||
const accountId = ids.account();
|
const accountId = ids.account();
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
|
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
|
||||||
provision_steps, created_at, updated_at)
|
provision_steps, created_at, updated_at)
|
||||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`,
|
VALUES (?, ?, ?, NULL, 'pending_oauth', ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
opts.name,
|
||||||
|
opts.cloudflareAccountId ?? null,
|
||||||
|
JSON.stringify(initialSteps()),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
)
|
)
|
||||||
.bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now)
|
|
||||||
.run();
|
.run();
|
||||||
return { accountId };
|
return { accountId };
|
||||||
}
|
}
|
||||||
@@ -272,12 +290,14 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
|
|||||||
.first<{ id: string; cloudflare_account_id: string | null }>();
|
.first<{ id: string; cloudflare_account_id: string | null }>();
|
||||||
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
|
||||||
|
|
||||||
const accessToken = await decryptConnectionToken(env);
|
const accessToken = await decryptAccountToken(env, account.id);
|
||||||
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
if (!accessToken) return { ok: false, error: "no active Cloudflare connection for this account" };
|
||||||
|
|
||||||
const cf: CFApi = await getCFApi(env);
|
const cf: CFApi = await getCFApi(env);
|
||||||
try {
|
try {
|
||||||
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
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.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
|
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ Console
|
|||||||
|
|
||||||
用户不需要执行安装命令,也不手工创建 API Token。
|
用户不需要执行安装命令,也不手工创建 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 加密存储,不进入前端和日志。
|
- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。
|
||||||
@@ -67,7 +87,7 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
|
|||||||
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
|
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
|
||||||
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
|
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
|
||||||
|
|
||||||
本应用默认只请求这三个(已在 Cloudflare 侧逐个验证可被接受):
|
本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受):
|
||||||
|
|
||||||
| scope id | 选择器里的名称 | 用途 |
|
| scope id | 选择器里的名称 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -76,15 +96,40 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
|
|||||||
| `email-routing-rule.write` | Email Routing Rules Edit | 配置 catch-all 规则指向 Worker |
|
| `email-routing-rule.write` | Email Routing Rules Edit | 配置 catch-all 规则指向 Worker |
|
||||||
| `zone-settings.write` | Zone Settings Write | **开启 Email Routing**(见下) |
|
| `zone-settings.write` | Zone Settings Write | **开启 Email Routing**(见下) |
|
||||||
|
|
||||||
**「开启 Email Routing」不归 Email Routing 权限管**(2026-09-30 实测):`PUT /zones/{id}/email/routing`
|
**「开启 Email Routing」不归 Email Routing 权限管**(2026-09-30 实测):它在选择器里对应
|
||||||
的 Accepted Permission 是 **Zone Settings Write**。所以 Email Routing 那一族权限全勾上也不够,
|
Zone Settings Write。Email Routing 那一族权限全勾上也不够。
|
||||||
漏掉 `zone-settings.write` 会一直等到点「设置 Email Workers」才报出来。
|
|
||||||
|
|
||||||
顺带两个接口层面的坑:
|
### 开启 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": ["<脚本名>"] }`,
|
- catch-all 规则的请求体里,指向 Worker 的 action 是 `{ "type": "worker", "value": ["<脚本名>"] }`,
|
||||||
matchers 固定 `[{"type":"all"}]`。
|
matchers 固定 `[{"type":"all"}]`。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.3.6",
|
"version": "2.4.0",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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
@@ -33,6 +33,10 @@ export interface CfMockOptions {
|
|||||||
scopesAllowed?: string[];
|
scopesAllowed?: string[];
|
||||||
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
||||||
authorizeError?: string;
|
authorizeError?: string;
|
||||||
|
/** Email Routing 开启被拒(模拟 2008:域名上已有非 Cloudflare 的 MX 记录) **/
|
||||||
|
emailRoutingBlocked?: boolean;
|
||||||
|
/** emailRoutingBlocked 时 /dns_records 返回的 MX 列表(用于验证中文提示点名了冲突记录) **/
|
||||||
|
foreignMx?: { id?: string; name: string; content: string; priority?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: Cloudflare API 桩句柄 **/
|
/** # 成员变量: Cloudflare API 桩句柄 **/
|
||||||
@@ -66,6 +70,8 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
|||||||
const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz";
|
const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz";
|
||||||
const calls: CfCall[] = [];
|
const calls: CfCall[] = [];
|
||||||
const original = globalThis.fetch;
|
const original = globalThis.fetch;
|
||||||
|
// Email Routing 的真实开关状态:PUT/PATCH 不会改它,只有 POST .../enable 会
|
||||||
|
const state = { emailRoutingEnabled: false };
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
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" },
|
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 (url.startsWith(CF_AUTHORIZE_URL)) {
|
||||||
if (opts.authorizeError) {
|
if (opts.authorizeError) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
@@ -147,18 +160,36 @@ 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 只有一个接口真的干活,这里照抄 Cloudflare 的真实行为:
|
||||||
// 开启 Email Routing 的正确方法是 PUT;POST 到同一路径不是有效路由,
|
// POST .../email/routing/enable -> 真的把 enabled 翻成 true
|
||||||
// 真实 Cloudflare 返回 10405(2026-09-30 线上踩过),这里照抄以便锁住修复。
|
// PUT / PATCH .../email/routing -> 回 success:true 但 enabled 原封不动(纯回显)
|
||||||
if (method !== "PUT") {
|
// 2026-09-30 线上正是因为信了 PUT 的返回值,把没开成 Email Routing 的域名标成了「已配置」,
|
||||||
return json(
|
// 表现为域名显示配好了却收不到邮件、Dashboard 邮件路由里也找不到它。
|
||||||
{ success: false, errors: [{ code: 10405, message: "Method not allowed for this authentication scheme" }], result: null },
|
if (url.endsWith("/email/routing/enable")) {
|
||||||
|
if (method !== "POST") {
|
||||||
|
return jsonError(
|
||||||
|
[{ code: 10405, message: "Method not allowed for this authentication scheme" }],
|
||||||
405,
|
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);
|
||||||
}
|
}
|
||||||
if (url.includes("/email/routing")) return json({ enabled: true });
|
state.emailRoutingEnabled = true;
|
||||||
|
return json({ id: url.split("/").at(-3), enabled: true, skip_wizard: true, status: "ready" });
|
||||||
|
}
|
||||||
|
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);
|
return json(zones);
|
||||||
}
|
}
|
||||||
if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() });
|
if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() });
|
||||||
|
|||||||
+166
-6
@@ -16,12 +16,14 @@ afterEach(() => {
|
|||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 走完 start -> callback,返回账号 id 与桩(不携带会话 cookie 调 callback,模拟跨站重定向)
|
* 走完 start -> callback,返回账号 id 与桩(不携带会话 cookie 调 callback,模拟跨站重定向)
|
||||||
|
* 参数: opts - 传给 Cloudflare 桩的配置(如模拟 Email Routing 开启失败)
|
||||||
*/
|
*/
|
||||||
async function connectAccount(
|
async function connectAccount(
|
||||||
t: Awaited<ReturnType<typeof makeTestEnv>>,
|
t: Awaited<ReturnType<typeof makeTestEnv>>,
|
||||||
cookie: string,
|
cookie: string,
|
||||||
|
opts: Parameters<typeof installCloudflareMock>[0] = {},
|
||||||
): Promise<{ accountId: string; cf: CfMock; resp: Response }> {
|
): Promise<{ accountId: string; cf: CfMock; resp: Response }> {
|
||||||
const cf = installCloudflareMock();
|
const cf = installCloudflareMock(opts);
|
||||||
mock = cf;
|
mock = cf;
|
||||||
const start = (await (
|
const start = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
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"]) {
|
for (const key of ["oauth", "discover", "domains", "worker"]) {
|
||||||
expect(detail.steps[key].status).toBe("done");
|
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).not.toBeNull();
|
||||||
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
|
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
|
||||||
const credRow = await t.env.DB.prepare(
|
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" })
|
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);
|
||||||
// 开启 Email Routing 必须是 PUT(POST 到同一路径是 10405,桩会拒)
|
// 开启 Email Routing 必须走 POST .../email/routing/enable。
|
||||||
expect(cf.hit("PUT", "/zones/zone-example/email/routing")).toBe(true);
|
// 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.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);
|
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")
|
||||||
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
|
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
|
||||||
const exampleAfter = after.items.find((d) => d.id === example.id)!;
|
const exampleAfter = after.items.find((d) => d.id === example.id)!;
|
||||||
expect(exampleAfter.status).toBe("active");
|
expect(exampleAfter.status).toBe("active");
|
||||||
expect(exampleAfter.worker_id).toContain("wmailhub-email-");
|
expect(exampleAfter.worker_id).toBe("wmailhub-email");
|
||||||
|
|
||||||
// OAuth token 已加密入库(不含明文 token)
|
// OAuth token 已加密入库(不含明文 token)
|
||||||
const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{
|
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);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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` });
|
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();
|
await browser.close();
|
||||||
|
|
||||||
const failed = results.filter((r) => !r.ok);
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
|||||||
Reference in New Issue
Block a user