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");
|
||||
|
||||
/** 用 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;
|
||||
}
|
||||
|
||||
@@ -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 凭据管理
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
// 域名选项加载失败不阻塞页面
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user