v2.4.0 修 Email Routing 假成功 + 加 API Token 接入与邮箱/域名页交互改进

问题 6(收不到邮件、Dashboard 邮件路由里看不到域名)的根因:
  上一版把「开启 Email Routing」改成了 PUT /zones/{id}/email/routing,
  但实测该接口(以及 PATCH)是**纯回显** —— 返回 success:true 但 enabled 纹丝不动,
  域名在 Cloudflare 侧仍是 unconfigured、连 MX 记录都没有。Console 却照它的返回值
  把域名标成「已配置 Email Workers」,于是「显示配好了却收不到邮件」。
  实测只有 POST /zones/{id}/email/routing/enable 真的开启(线上对 adx9.com 与
  galaxyemail.com 逐一验证)。

后端
- cfapi: 改用 /enable,并在开启后回读 GET 校验 enabled===true,不满足即判失败;
  新增 CFApiError(带 Cloudflare 错误码)与 getEmailRoutingSettings
- cfapi: 错误码 2008(Non-Cloudflare MX records exist)翻译成中文并点名冲突的 MX 记录
- provision: Email Worker 统一命名 wmailhub-email(原先按账号 id 拼名),
  部署走 PUT,同名即覆盖更新
- db: 新增 decryptAccountToken —— 按账号取 token,不再取「最近一条连接」,
  并跳过已过期的 OAuth token;oauth_connections.account_id 真正写入
- resources: 新增 POST /api/accounts/token(API Token 方式添加账号):
  先校验 token 与账户 ID,确认可用才建账号并自动接入

前端
- 账号:「添加账号」改为模态窗口,OAuth / API Token 两个 tab,各自带说明
- 域名: 删除「Email Worker」列;「设置 Email Workers / 重新配置」先弹确认框
  (说明会改 MX 与路由),执行期间按钮 loading 且互相禁用;error 状态改成可读的 Alert + 重试
- 邮箱: 删除「状态」列(已由「启用」开关承担);地址列加复制按钮(带弹跳+变绿动画);
  「添加邮箱」窗口的域名只列已装 Email Workers 的、支持搜索、默认选中最近使用的域名;
  地址框可一键从 namedb 取随机用户名;勾选 catch-all 后地址框禁用
- 收件箱: 域名筛选只显示已装 Email Workers 的域名

测试
- cf-mock 复刻「PUT/PATCH 回 success 但不改 enabled」与 2008,锁住本次修复
- oauth.test 新增 4 例:2008 冲突、回读校验、API Token 接入、Token/账户 ID 校验失败
- ui-smoke 覆盖新增的添加账号/添加邮箱/确认框等交互(28/28 通过)

文档: docs/02 补「三种开启接口只有 /enable 有效」的实测表、2008 处理、
两种接入方式与按账号取 token 的说明
This commit is contained in:
bigcode
2026-09-30 18:20:44 +08:00
parent e7db6762fd
commit ecce90dfab
20 changed files with 1107 additions and 143 deletions
+25
View File
@@ -140,6 +140,15 @@ export const updateAccount = (id: string, body: { name?: string }) =>
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
/** 用 Cloudflare API Token 添加账号(与 OAuth 并列的第二种接入方式)。
* 后端会先用 token 探一次 Cloudflare 确认可用、且与填写的账户 ID 一致,再建账号并自动接入。 */
export const createAccountByToken = (body: {
name: string;
cloudflare_account_id?: string;
token: string;
}) =>
api<{ ok: boolean; account_id: string; error?: string }>("/api/accounts/token", "POST", body);
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
export const rotateAccount = (id: string) =>
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
@@ -355,3 +364,19 @@ export const createApiKey = () =>
/** 撤销并删除 Key */
export const deleteApiKey = () => api("/api/settings/api-key", "DELETE");
/* ---- 随机邮箱名 ---- */
/** # 成员变量: 随机用户名来源(namedb,返回 { username, first, last, gender }) **/
const NAMEDB_URL = "https://namedb.adx9.com/api/username";
/** 从 namedb 拉一个随机用户名(用于邮箱地址的本地部分)。
* 返回值: 用户名字符串;失败时抛错,由调用方决定提示文案。 */
export async function fetchRandomUsername(): Promise<string> {
const resp = await fetch(NAMEDB_URL);
if (!resp.ok) throw new Error(`随机用户名服务返回 ${resp.status}`);
const data = (await resp.json()) as { username?: string };
const name = (data.username ?? "").trim();
if (!name) throw new Error("随机用户名服务没有返回可用名字");
return name;
}
+187 -10
View File
@@ -2,20 +2,26 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Button,
Card,
Drawer,
Form,
Input,
Modal,
Radio,
Space,
Table,
Tabs,
Tag,
Timeline,
Typography,
App,
} from "antd";
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from "@ant-design/icons";
import {
batchDeleteAccounts,
createAccountByToken,
deleteAccount,
getAccount,
listAccounts,
@@ -111,7 +117,48 @@ export function AccountsPage() {
[items, q],
);
/** 发起 OAuth:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
const openDetail = async (id: string) => {
setDetail(await getAccount(id));
setDetailOpen(true);
};
/** 添加账号:OAuth 与 API Token 两种方式都收在同一个模态窗口里 */
const [addOpen, setAddOpen] = useState(false);
/** 当前选中的授权方式 **/
const [addMode, setAddMode] = useState<"oauth" | "token">("oauth");
/** API Token 提交的 loading(要防重复点击,一次接入要走好几次 Cloudflare 调用) **/
const [adding, setAdding] = useState(false);
const [tokenForm] = Form.useForm();
/** API Token 授权:校验 + 建账号 + 自动接入,一条龙 */
const submitToken = async () => {
const values = await tokenForm.validateFields();
setAdding(true);
try {
const res = await createAccountByToken({
name: values.name,
cloudflare_account_id: values.cloudflare_account_id?.trim() || undefined,
token: values.token.trim(),
});
if (!res.ok) {
message.error(res.error ?? "接入失败");
return;
}
message.success(`已接入账号 ${values.name},正在同步域名`);
setAddOpen(false);
tokenForm.resetFields();
void load();
// 直接打开详情看接入步骤,和 OAuth 回调回来的体验一致
setDetail(await getAccount(res.account_id));
setDetailOpen(true);
} catch (err) {
message.error(err instanceof Error ? err.message : "接入失败");
} finally {
setAdding(false);
}
};
/** OAuth 授权:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
const startOAuth = async () => {
try {
const { url } = await oauthStart();
@@ -121,11 +168,6 @@ export function AccountsPage() {
}
};
const openDetail = async (id: string) => {
setDetail(await getAccount(id));
setDetailOpen(true);
};
return (
<div>
<div className="page-toolbar">
@@ -138,8 +180,8 @@ export function AccountsPage() {
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
并签发 Worker 凭据(Email Worker 直连 Console)。
「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、
部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
</span>
</div>
<Space>
@@ -164,7 +206,7 @@ export function AccountsPage() {
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
添加账号
</Button>
</Space>
@@ -237,6 +279,21 @@ export function AccountsPage() {
/>
</Card>
<AddAccountModal
open={addOpen}
mode={addMode}
loading={adding}
form={tokenForm}
onModeChange={setAddMode}
onCancel={() => {
if (adding) return;
setAddOpen(false);
tokenForm.resetFields();
}}
onOAuth={() => void startOAuth()}
onTokenSubmit={() => void submitToken()}
/>
<AccountDetailDrawer
detail={detail}
open={detailOpen}
@@ -250,6 +307,126 @@ export function AccountsPage() {
);
}
/** # 成员变量: 添加账号模态窗口的属性 **/
interface AddAccountModalProps {
open: boolean;
mode: "oauth" | "token";
loading: boolean;
form: ReturnType<typeof Form.useForm>[0];
onModeChange: (mode: "oauth" | "token") => void;
onCancel: () => void;
onOAuth: () => void;
onTokenSubmit: () => void;
}
/** # 函数
* 「添加账号」模态窗口:Cloudflare OAuth 与 API Token 两种授权方式,各自带说明。
* 两种方式最终都走同一套自动接入流程(发现域名 -> 部署 Email Worker -> 签发 Worker 凭据),
* 区别只在于 token 从哪来:OAuth 走用户点授权,API Token 由用户自己粘贴。
*/
function AddAccountModal(props: AddAccountModalProps) {
const { open, mode, loading, form, onModeChange, onCancel, onOAuth, onTokenSubmit } = props;
return (
<Modal
title="添加 Cloudflare 账号"
open={open}
onCancel={onCancel}
maskClosable={!loading}
closable={!loading}
width={620}
footer={null}
destroyOnHidden
>
<Tabs
activeKey={mode}
onChange={(k) => onModeChange(k as "oauth" | "token")}
items={[
{
key: "oauth",
label: (
<span>
<SafetyOutlined /> Cloudflare OAuth 授权
</span>
),
children: (
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
<Alert
type="info"
showIcon
message="推荐:不需要在本机保存任何密钥"
description={
<span>
点击下方按钮跳转到 Cloudflare 官方授权页,用你的 Cloudflare 账号登录并同意授权,
完成后自动跳回本控制台。Console 会用拿到的授权去发现该账号下的域名、
部署 Email Worker 并签发 Worker 凭据,全程无需在本系统里保存 Cloudflare 密钥。
<br />
前提:「设置 → Cloudflare 接入」里已填好 Client ID / Secret,且 OAuth App
已勾选本应用需要的权限(scope 用点号分隔)。
</span>
}
/>
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
前往 Cloudflare 授权
</Button>
</Space>
),
},
{
key: "token",
label: (
<span>
<ApiOutlined /> API Token 授权
</span>
),
children: (
<Form form={form} layout="vertical" requiredMark="optional">
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="适合不方便走 OAuth 跳转的场景"
description={
<span>
直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍
(并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。
Token 加密入库、不回显、不写日志。
</span>
}
/>
<Form.Item
name="name"
label="账户名称"
rules={[{ required: true, message: "请输入账户名称" }]}
>
<Input placeholder="我的主账号" />
</Form.Item>
<Form.Item
name="cloudflare_account_id"
label="账户 ID"
extra="Cloudflare 账户 ID(账户主页右侧的 Account ID)。可留空,留空则以 Token 实际所属账户为准;填错会被校验拦下。"
>
<Input placeholder="bf8cd983e6dfd171c1881b00b512ed5a" className="mono" />
</Form.Item>
<Form.Item
name="token"
label="API Token"
rules={[{ required: true, message: "请输入 API Token" }]}
extra="到 Cloudflare → My Profile → API Tokens 生成。所需权限与 OAuth 方式一致:Zone Read、Workers Scripts Edit、Email Routing Rules Edit、Zone Settings Write。"
>
<Input.Password placeholder="粘贴 Cloudflare API Token" autoComplete="new-password" />
</Form.Item>
<Button type="primary" icon={<ApiOutlined />} onClick={onTokenSubmit} loading={loading} block>
{loading ? "正在校验并接入…" : "用 API Token 接入"}
</Button>
</Form>
),
},
]}
/>
</Modal>
);
}
/** # 函数
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
*/
+103 -46
View File
@@ -4,7 +4,7 @@
* 支持搜索与按账号筛选。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App, Alert } from "antd";
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
batchDeleteDomains,
@@ -41,6 +41,8 @@ export function DomainsPage() {
const [accountId, setAccountId] = useState<string | undefined>();
const [editing, setEditing] = useState<DomainRow | null>(null);
const [deleting, setDeleting] = useState(false);
/** 正在配置 Email Workers 的域名 id(用于按钮 loading + 禁用,防重复点击) */
const [settingId, setSettingId] = useState<string | null>(null);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -70,6 +72,7 @@ export function DomainsPage() {
/** 配置 Email Workers */
const runSetup = async (row: DomainRow) => {
setSettingId(row.id);
try {
const res = await setupDomain(row.id);
if (res.ok) message.success(`已为 ${row.name} 部署 Email Worker 并开启 Email Routing`);
@@ -78,9 +81,38 @@ export function DomainsPage() {
setEditing((cur) => (cur?.id === row.id ? { ...cur, status: res.ok ? "active" : "error" } : cur));
} catch (err) {
message.error(err instanceof Error ? err.message : "配置失败");
} finally {
setSettingId(null);
}
};
/** 配置前的确认弹窗:会改 Cloudflare 侧的 DNS/MX 与路由规则,先说清影响再执行 */
const confirmSetup = (row: DomainRow) => {
const isReconfig = row.status === "active";
modal.confirm({
title: isReconfig ? `重新配置 ${row.name} 的 Email Workers?` : `为 ${row.name} 配置 Email Workers?`,
icon: <CloudUploadOutlined />,
content: (
<div>
<p style={{ marginBottom: 6 }}>将在 Cloudflare 上执行:</p>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>部署(或覆盖更新)Email Worker 脚本 wmailhub-email</li>
<li>开启该域名的 Email Routing(会写入 Cloudflare 的 MX / SPF 记录)</li>
<li>把 catch-all 规则指向该 Worker,之后所有邮件都会进本系统</li>
</ul>
{row.status === "error" && row.setup_error ? (
<p className="muted" style={{ marginTop: 8, marginBottom: 0 }}>
上次失败原因:{row.setup_error}
</p>
) : null}
</div>
),
okText: "开始配置",
cancelText: "取消",
onOk: () => runSetup(row),
});
};
return (
<div>
<div className="page-toolbar">
@@ -146,13 +178,6 @@ export function DomainsPage() {
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
{
title: "Email Worker",
dataIndex: "worker_id",
width: 210,
ellipsis: true,
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未配置</span>),
},
{
title: "Webhook",
width: 95,
@@ -163,41 +188,57 @@ export function DomainsPage() {
{
title: "操作",
width: 260,
render: (_: unknown, row: DomainRow) => (
<Space onClick={(e) => e.stopPropagation()}>
{row.status !== "active" ? (
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => void runSetup(row)}>
设置 Email Workers
render: (_: unknown, row: DomainRow) => {
const busy = settingId === row.id;
return (
<Space onClick={(e) => e.stopPropagation()}>
{row.status !== "active" ? (
<Button
size="small"
type="primary"
icon={<CloudUploadOutlined />}
loading={busy}
disabled={settingId !== null}
onClick={() => confirmSetup(row)}
>
设置 Email Workers
</Button>
) : (
<Button
size="small"
icon={<ReloadOutlined />}
loading={busy}
disabled={settingId !== null}
onClick={() => confirmSetup(row)}
>
重新配置
</Button>
)}
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。",
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
) : (
<Button size="small" icon={<ReloadOutlined />} onClick={() => void runSetup(row)}>
重新配置
</Button>
)}
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。",
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
</Space>
),
</Space>
);
},
},
]}
/>
@@ -227,7 +268,13 @@ export function DomainsPage() {
title="Email Workers"
extra={
editing.status !== "active" && (
<Button size="small" type="primary" onClick={() => void runSetup(editing)}>
<Button
size="small"
type="primary"
loading={settingId === editing.id}
disabled={settingId !== null}
onClick={() => confirmSetup(editing)}
>
设置 Email Workers
</Button>
)
@@ -236,8 +283,8 @@ export function DomainsPage() {
<Space orientation="vertical" style={{ width: "100%" }}>
<StatusTag row={editing} />
<Typography.Text type="secondary">
部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
规则指向 Worker;邮件经由 Node 转发回 Console。
部署统一命名的 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
规则指向 Worker;邮件经由 Worker 转发回 Console。
</Typography.Text>
{editing.status === "active" && editing.worker_id && (
<Typography.Text className="mono">
@@ -245,7 +292,17 @@ export function DomainsPage() {
</Typography.Text>
)}
{editing.status === "error" && editing.setup_error && (
<Typography.Text type="danger">{editing.setup_error}</Typography.Text>
<Alert
type="error"
showIcon
message="配置未完成"
description={editing.setup_error}
action={
<Button size="small" onClick={() => confirmSetup(editing)}>
重试
</Button>
}
/>
)}
</Space>
</Card>
+4 -1
View File
@@ -109,7 +109,10 @@ export function InboxPage() {
useEffect(() => {
void (async () => {
try {
setDomains((await listDomains()).items.map((d) => ({ id: d.id, name: d.name })));
// 只列已安装 Email Workers 的域名:没配 Email Routing 的域名在 Cloudflare
// 侧压根收不到邮件,按它筛选只会得到一个永远为空的列表,反而误导人。
const all = (await listDomains()).items;
setDomains(all.filter((d) => d.status === "active" && d.worker_id).map((d) => ({ id: d.id, name: d.name })));
} catch {
// 域名选项加载失败不阻塞页面
}
+119 -16
View File
@@ -1,10 +1,20 @@
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App, Tooltip } from "antd";
import {
ApiOutlined,
CheckOutlined,
CopyOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import {
createMailbox,
deleteMailbox,
fetchRandomUsername,
listDomains,
listMailboxes,
patchMailbox,
@@ -15,6 +25,15 @@ import { WebhookForm } from "../components/WebhookForm.tsx";
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
import { batchDeleteMailboxes } from "../api.ts";
/** # 函数
* 判断域名是否"已安装 Email Workers"。
* 只有配置完成的域名才能真正收到邮件(未配置的域名在 Cloudflare 侧根本没有 Email Routing),
* 所以建邮箱时只让用户从这些域名里选,避免建出一个永远收不到信的地址。
*/
function hasEmailWorkers(d: DomainRow): boolean {
return d.status === "active" && Boolean(d.worker_id);
}
/** # 函数
* 邮箱页组件
*/
@@ -28,6 +47,10 @@ export function MailboxesPage() {
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
const [form] = Form.useForm();
const [deleting, setDeleting] = useState(false);
/** 正在拉取随机用户名的 loading **/
const [fetchingName, setFetchingName] = useState(false);
/** 刚复制完的邮箱 id(用于播放复制成功的动画) **/
const [copiedId, setCopiedId] = useState<string | null>(null);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -45,6 +68,47 @@ export function MailboxesPage() {
void load();
}, [load]);
/** # 成员变量: 可选域名(仅已安装 Email Workers 的) **/
const readyDomains = useMemo(() => domains.filter(hasEmailWorkers), [domains]);
/** # 成员变量: 最近使用过的域名(最新创建的邮箱所属域名;没有则取第一个可用域名) **/
const recentDomainId = useMemo(() => {
const newest = items[0];
if (newest && readyDomains.some((d) => d.id === newest.domain_id)) return newest.domain_id;
return readyDomains[0]?.id;
}, [items, readyDomains]);
/** 打开「添加邮箱」:默认落在最近用过的域名上 */
const openCreate = () => {
form.resetFields();
if (recentDomainId) form.setFieldsValue({ domain_id: recentDomainId, catch_all: false });
setOpen(true);
};
/** 拉一个随机用户名填进地址框(可反复点,直到碰到喜欢的) */
const rollUsername = async () => {
setFetchingName(true);
try {
form.setFieldValue("address", await fetchRandomUsername());
} catch (err) {
message.error(err instanceof Error ? err.message : "获取随机用户名失败");
} finally {
setFetchingName(false);
}
};
/** 复制邮箱地址:成功时按钮播放一下动画并短暂变成对勾 */
const copyAddress = async (row: MailboxRow) => {
try {
await navigator.clipboard.writeText(row.address);
setCopiedId(row.id);
setTimeout(() => setCopiedId((cur) => (cur === row.id ? null : cur)), 1000);
} catch {
// 剪贴板不可用(非 https / 权限被拒)时退回选中,让用户自己复制
message.warning("浏览器拒绝了剪贴板访问,请手动复制");
}
};
const filtered = useMemo(
() =>
items.filter((row) =>
@@ -93,7 +157,7 @@ export function MailboxesPage() {
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={readyDomains.length === 0}>
添加邮箱
</Button>
</Space>
@@ -111,18 +175,22 @@ export function MailboxesPage() {
title: "地址",
dataIndex: "address",
render: (v: string, row: MailboxRow) => (
<span>
<span className="mono">{v}</span> {row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span className="mono">{v}</span>
{row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
<Tooltip title="复制邮箱地址">
<Button
type="text"
size="small"
className={`copy-btn${copiedId === row.id ? " copied" : ""}`}
icon={copiedId === row.id ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => void copyAddress(row)}
/>
</Tooltip>
</span>
),
},
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 95,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{
title: "保留(秒)",
dataIndex: "retention_seconds",
@@ -181,7 +249,8 @@ export function MailboxesPage() {
try {
const res = await createMailbox({
domain_id: values.domain_id,
address: values.address,
// 勾了 catch-all 时后端会忽略地址、直接建 *@域名
address: values.catch_all ? "catch-all" : values.address,
retention_seconds: values.retention_seconds || undefined,
catch_all: values.catch_all,
});
@@ -197,12 +266,46 @@ export function MailboxesPage() {
<Form form={form} layout="vertical">
<Form.Item name="domain_id" label="域名" rules={[{ required: true, message: "请选择域名" }]}>
<Select
options={domains.map((d) => ({ value: d.id, label: d.name }))}
placeholder="选择域名"
showSearch
optionFilterProp="label"
options={readyDomains.map((d) => ({ value: d.id, label: d.name }))}
placeholder="选择域名(只列出已安装 Email Workers 的域名)"
notFoundContent="没有可用域名:请先到「域名」页为某个域名设置 Email Workers"
/>
</Form.Item>
<Form.Item name="address" label="地址(可只填本地部分)" rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="hello" />
<Form.Item noStyle shouldUpdate={(prev, next) => prev.catch_all !== next.catch_all}>
{({ getFieldValue }) => {
const isCatchAll = Boolean(getFieldValue("catch_all"));
return (
<Form.Item
name="address"
label="地址(邮箱名)"
extra={
isCatchAll
? "已设为 catch-all:该域名下所有邮件都会进入这个邮箱,无需再填邮箱名"
: "只填 @ 前面的部分,域名会自动补全;可点「随机」从名字库取一个"
}
rules={isCatchAll ? [] : [{ required: true, message: "请输入邮箱名" }]}
>
<Input
placeholder="hello"
disabled={isCatchAll}
suffix={
<Tooltip title="从名字库随机取一个">
<Button
type="text"
size="small"
icon={<ThunderboltOutlined />}
loading={fetchingName}
disabled={isCatchAll}
onClick={() => void rollUsername()}
/>
</Tooltip>
}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="retention_seconds" label="保留秒数(留空用全局默认 1 天)">
<InputNumber min={60} style={{ width: "100%" }} placeholder="86400" />
+24
View File
@@ -193,3 +193,27 @@ body,
font-size: 26px;
font-weight: 750;
}
/* ---- 复制按钮:点一下的反馈动画 ---- */
.copy-btn {
color: var(--wmh-muted, #8c8c8c);
transition: transform 0.18s ease, color 0.18s ease, background 0.18s ease;
}
.copy-btn:hover {
color: #2f54eb;
background: rgba(47, 84, 235, 0.10);
}
/* 复制成功:轻微弹一下 + 变绿,1 秒后由 JS 换回默认图标 */
.copy-btn.copied {
color: #52c41a;
animation: wmh-copy-pop 0.45s ease;
}
@keyframes wmh-copy-pop {
0% { transform: scale(1); }
35% { transform: scale(1.35); }
70% { transform: scale(0.92); }
100% { transform: scale(1); }
}