From ecce90dfabd355a1b2141f8ef377d3c292f28593 Mon Sep 17 00:00:00 2001 From: bigcode Date: Wed, 30 Sep 2026 18:20:44 +0800 Subject: [PATCH] =?UTF-8?q?v2.4.0=20=E4=BF=AE=20Email=20Routing=20?= =?UTF-8?q?=E5=81=87=E6=88=90=E5=8A=9F=20+=20=E5=8A=A0=20API=20Token=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E4=B8=8E=E9=82=AE=E7=AE=B1/=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E9=A1=B5=E4=BA=A4=E4=BA=92=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 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 的说明 --- console/frontend/src/api.ts | 25 +++ console/frontend/src/pages/Accounts.tsx | 197 +++++++++++++++++++++-- console/frontend/src/pages/Domains.tsx | 149 +++++++++++------ console/frontend/src/pages/Inbox.tsx | 5 +- console/frontend/src/pages/Mailboxes.tsx | 135 ++++++++++++++-- console/frontend/src/styles.css | 24 +++ console/src/db.ts | 76 ++++++++- console/src/routes/oauth.ts | 24 +-- console/src/routes/resources.ts | 64 +++++++- console/src/services/cfapi.ts | 133 +++++++++++++-- console/src/services/provision.ts | 40 +++-- docs/02_CLOUDFLARE_OAUTH.md | 61 ++++++- package.json | 2 +- shots/05-mailboxes.png | Bin 0 -> 71298 bytes shots/12b-add-account-token.png | Bin 0 -> 128067 bytes shots/13-add-mailbox.png | Bin 0 -> 89552 bytes shots/14-setup-confirm.png | Bin 0 -> 72684 bytes tests/cf-mock.ts | 47 +++++- tests/oauth.test.ts | 172 +++++++++++++++++++- tests/ui-smoke.ts | 96 +++++++++++ 20 files changed, 1107 insertions(+), 143 deletions(-) create mode 100644 shots/05-mailboxes.png create mode 100644 shots/12b-add-account-token.png create mode 100644 shots/13-add-mailbox.png create mode 100644 shots/14-setup-confirm.png diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index 0b34534..2baedc8 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -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 { + 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; +} diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index a80f077..dd966c5 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -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 (
@@ -138,8 +180,8 @@ export function AccountsPage() { onChange={(e) => setQ(e.target.value)} /> - 点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker - 并签发 Worker 凭据(Email Worker 直连 Console)。 + 「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、 + 部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
@@ -164,7 +206,7 @@ export function AccountsPage() { }} /> @@ -237,6 +279,21 @@ export function AccountsPage() { /> + { + if (adding) return; + setAddOpen(false); + tokenForm.resetFields(); + }} + onOAuth={() => void startOAuth()} + onTokenSubmit={() => void submitToken()} + /> + [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 ( + + onModeChange(k as "oauth" | "token")} + items={[ + { + key: "oauth", + label: ( + + Cloudflare OAuth 授权 + + ), + children: ( + + + 点击下方按钮跳转到 Cloudflare 官方授权页,用你的 Cloudflare 账号登录并同意授权, + 完成后自动跳回本控制台。Console 会用拿到的授权去发现该账号下的域名、 + 部署 Email Worker 并签发 Worker 凭据,全程无需在本系统里保存 Cloudflare 密钥。 +
+ 前提:「设置 → Cloudflare 接入」里已填好 Client ID / Secret,且 OAuth App + 已勾选本应用需要的权限(scope 用点号分隔)。 + + } + /> + +
+ ), + }, + { + key: "token", + label: ( + + API Token 授权 + + ), + children: ( +
+ + 直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍 + (并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。 + Token 加密入库、不回显、不写日志。 + + } + /> + + + + + + + + + + + + ), + }, + ]} + /> +
+ ); +} + /** # 函数 * 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理 */ diff --git a/console/frontend/src/pages/Domains.tsx b/console/frontend/src/pages/Domains.tsx index 685a094..2cbd169 100644 --- a/console/frontend/src/pages/Domains.tsx +++ b/console/frontend/src/pages/Domains.tsx @@ -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(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(false); + /** 正在配置 Email Workers 的域名 id(用于按钮 loading + 禁用,防重复点击) */ + const [settingId, setSettingId] = useState(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: , + content: ( +
+

将在 Cloudflare 上执行:

+
    +
  • 部署(或覆盖更新)Email Worker 脚本 wmailhub-email
  • +
  • 开启该域名的 Email Routing(会写入 Cloudflare 的 MX / SPF 记录)
  • +
  • 把 catch-all 规则指向该 Worker,之后所有邮件都会进本系统
  • +
+ {row.status === "error" && row.setup_error ? ( +

+ 上次失败原因:{row.setup_error} +

+ ) : null} +
+ ), + okText: "开始配置", + cancelText: "取消", + onOk: () => runSetup(row), + }); + }; + return (
@@ -146,13 +178,6 @@ export function DomainsPage() { { title: "域名", dataIndex: "name", render: (v: string) => {v} }, { title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" }, { title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => }, - { - title: "Email Worker", - dataIndex: "worker_id", - width: 210, - ellipsis: true, - render: (v: string | null) => (v ? {v} : 未配置), - }, { title: "Webhook", width: 95, @@ -163,41 +188,57 @@ export function DomainsPage() { { title: "操作", width: 260, - render: (_: unknown, row: DomainRow) => ( - e.stopPropagation()}> - {row.status !== "active" ? ( - + ) : ( + + )} + - ) : ( - - )} - - - ), + + ); + }, }, ]} /> @@ -227,7 +268,13 @@ export function DomainsPage() { title="Email Workers" extra={ editing.status !== "active" && ( - ) @@ -236,8 +283,8 @@ export function DomainsPage() { - 部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all - 规则指向 Worker;邮件经由 Node 转发回 Console。 + 部署统一命名的 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all + 规则指向 Worker;邮件经由 Worker 转发回 Console。 {editing.status === "active" && editing.worker_id && ( @@ -245,7 +292,17 @@ export function DomainsPage() { )} {editing.status === "error" && editing.setup_error && ( - {editing.setup_error} + confirmSetup(editing)}> + 重试 + + } + /> )} diff --git a/console/frontend/src/pages/Inbox.tsx b/console/frontend/src/pages/Inbox.tsx index cd7024f..919342a 100644 --- a/console/frontend/src/pages/Inbox.tsx +++ b/console/frontend/src/pages/Inbox.tsx @@ -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 { // 域名选项加载失败不阻塞页面 } diff --git a/console/frontend/src/pages/Mailboxes.tsx b/console/frontend/src/pages/Mailboxes.tsx index ffbd244..c3eba1b 100644 --- a/console/frontend/src/pages/Mailboxes.tsx +++ b/console/frontend/src/pages/Mailboxes.tsx @@ -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(null); const [form] = Form.useForm(); const [deleting, setDeleting] = useState(false); + /** 正在拉取随机用户名的 loading **/ + const [fetchingName, setFetchingName] = useState(false); + /** 刚复制完的邮箱 id(用于播放复制成功的动画) **/ + const [copiedId, setCopiedId] = useState(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() { }} /> @@ -111,18 +175,22 @@ export function MailboxesPage() { title: "地址", dataIndex: "address", render: (v: string, row: MailboxRow) => ( - - {v} {row.is_catch_all ? catch-all : null} + + {v} + {row.is_catch_all ? catch-all : null} + +