feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)

- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
This commit is contained in:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Avatar, Dropdown, Layout, Menu } from "antd";
import {
DashboardOutlined,
MailOutlined,
CloudServerOutlined,
GlobalOutlined,
BellOutlined,
KeyOutlined,
UserOutlined,
LogoutOutlined,
} from "@ant-design/icons";
import { health, logout, me } from "./api.ts";
const { Sider, Header, Content } = Layout;
/** # 成员变量: 侧栏菜单项 **/
const MENU_ITEMS = [
{ key: "/", icon: <DashboardOutlined />, label: "概览" },
{ key: "/nodes", icon: <CloudServerOutlined />, label: "节点 Nodes" },
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
{ key: "/emails", icon: <MailOutlined />, label: "邮件 Emails" },
{ key: "/webhooks", icon: <BellOutlined />, label: "Webhooks" },
{ key: "/keys", icon: <KeyOutlined />, label: "API / MCP Keys" },
];
/** # 成员变量: 路由 -> 页面标题/副标题 **/
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
"/": { title: "概览", subtitle: "节点、邮件与投递总览" },
"/nodes": { title: "节点管理", subtitle: "通过 Cloudflare OAuth 自动安装的转发节点" },
"/domains": { title: "域名管理", subtitle: "接入 Email Routing 的收件域名" },
"/mailboxes": { title: "邮箱管理", subtitle: "收件地址与保留策略" },
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
"/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" },
"/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" },
};
/** # 函数
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源)
*/
export function App() {
const navigate = useNavigate();
const location = useLocation();
const [ready, setReady] = useState(false);
const [username, setUsername] = useState("");
const [version, setVersion] = useState("");
useEffect(() => {
void (async () => {
try {
const [healthInfo, meInfo] = await Promise.all([health(), me()]);
setVersion(healthInfo.version);
setUsername(meInfo.user.username);
setReady(true);
} catch {
navigate("/login", { replace: true });
}
})();
}, [navigate]);
if (!ready) return null;
const meta = PAGE_META[location.pathname] ?? { title: "WmailHub", subtitle: "" };
return (
<Layout className="app-layout">
<Sider width={228} className="app-sider" theme="light">
<div className="brand">
<img src="/logo.svg" alt="WmailHub" />
<span className="name">WmailHub</span>
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={MENU_ITEMS}
onClick={({ key }) => navigate(key)}
style={{ borderInlineEnd: "none" }}
/>
</Sider>
<Layout>
<Header className="app-header">
<div>
<div className="title">{meta.title}</div>
<div className="subtitle">{meta.subtitle}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span className="muted mono">v{version}</span>
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
<div>
<Dropdown
menu={{
items: [
{
key: "logout",
icon: <LogoutOutlined />,
label: "退出登录",
onClick: async () => {
await logout().catch(() => undefined);
window.location.href = "/login";
},
},
],
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<Avatar size={30} icon={<UserOutlined />} />
<span>{username}</span>
</div>
</Dropdown>
</div>
</div>
</Header>
<Content className="app-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+291
View File
@@ -0,0 +1,291 @@
/** Console API 客户端:统一 JSON 请求、错误处理、401 跳登录 */
/** # 成员变量: 未登录回调(由应用注册,跳转登录页) **/
let onUnauthorized: (() => void) | null = null;
/** # 函数
* 注册 401 处理器
*/
export function setUnauthorizedHandler(fn: () => void): void {
onUnauthorized = fn;
}
/** # 函数
* 通用请求封装
* 参数: method/url/body(JSON)/query
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError
*/
export async function api<T = unknown>(
url: string,
method: string = "GET",
body?: unknown,
): Promise<T> {
const resp = await fetch(url, {
method,
credentials: "include",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (resp.status === 401) {
onUnauthorized?.();
throw new ApiError(401, "未登录或会话已过期");
}
const text = await resp.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!resp.ok) {
const message =
(data as { error?: string } | null)?.error ?? `请求失败 (${resp.status})`;
throw new ApiError(resp.status, message, data);
}
return data as T;
}
/** # 成员变量: API 错误 **/
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public data?: unknown,
) {
super(message);
}
}
/** # 函数
* 分页查询串拼接
*/
export function qs(params: Record<string, string | number | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
/* ---- 具体接口封装 ---- */
/** 登录 */
export const login = (username: string, password: string) =>
api<{ ok: boolean; user: { id: string; username: string } }>("/api/auth/login", "POST", { username, password });
/** 登出 */
export const logout = () => api("/api/auth/logout", "POST");
/** 当前用户 */
export const me = () => api<{ user: { id: string; username: string } }>("/api/auth/me");
/** 健康检查(含版本号) */
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
/** Dashboard 统计 */
export const stats = () =>
api<{
nodes_online: number;
nodes_total: number;
domains: number;
mailboxes: number;
emails: number;
emails_expired: number;
webhooks: number;
deliveries_pending: number;
oauth_connections: number;
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
}>("/api/admin/stats");
/** 手动触发 Cron */
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
/* Nodes */
export interface NodeRow {
id: string;
name: string;
cloudflare_account_id: string | null;
status: string;
version: string | null;
last_seen_at: string | null;
ingress_url: string;
created_at: string;
}
export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes");
export const createNode = (name: string, ingress_url: string) =>
api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>(
"/api/nodes",
"POST",
{ name, ingress_url },
);
export interface NodeDetail {
node: NodeRow & { enrollment_expires_at: string | null };
steps: Record<string, { status: string; error?: string }>;
oauth: { id: string; account_id: string | null; created_at: string } | null;
domains: { id: string; name: string; status: string; worker_id: string | null }[];
}
export const getNode = (id: string) => api<NodeDetail>(`/api/nodes/${id}`);
export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE");
export const rotateNode = (id: string) =>
api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST");
export const getNodeIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`);
/* OAuth */
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
export const oauthStart = (nodeId: string) =>
api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`);
export const oauthSimApprove = (state: string) =>
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
/* Domains */
export interface DomainRow {
id: string;
name: string;
status: string;
node_id: string | null;
node_name: string | null;
worker_id: string | null;
mailbox_count: number;
created_at: string;
}
export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains");
export const createDomain = (name: string) => api<{ ok: boolean; id: string }>("/api/domains", "POST", { name });
export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE");
/* Mailboxes */
export interface MailboxRow {
id: string;
address: string;
status: string;
is_catch_all: number;
retention_seconds: number | null;
domain_id: string;
domain_name: string;
email_count: number;
created_at: string;
}
export const listMailboxes = (domainId?: string) =>
api<{ items: MailboxRow[]; total: number }>(`/api/mailboxes${qs({ domain_id: domainId })}`);
export const createMailbox = (body: { domain_id: string; address: string; retention_seconds?: number; catch_all?: boolean }) =>
api<{ ok: boolean; id: string; address: string }>("/api/mailboxes", "POST", body);
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
api(`/api/mailboxes/${id}`, "PATCH", body);
export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE");
/* Emails */
export interface EmailRow {
id: string;
mailbox_id: string | null;
mailbox_address: string | null;
message_id: string | null;
from_address: string | null;
to_addresses: string;
subject: string | null;
size: number;
received_at: string;
expires_at: string;
attachment_count: number;
}
export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) =>
api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
export interface EmailDetail {
email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string };
attachments: { id: string; filename: string; content_type: string; size: number; content_id: string | null }[];
}
export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
/* Webhooks */
export interface WebhookRow {
id: string;
name: string;
endpoint: string;
enabled: number;
delivery_count: number;
success_count: number;
created_at: string;
}
export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks");
export const createWebhook = (name: string, endpoint: string) =>
api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint });
export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) =>
api(`/api/webhooks/${id}`, "PATCH", body);
export const rotateWebhookSecret = (id: string) =>
api<{ ok: boolean; secret: string }>(`/api/webhooks/${id}/rotate`, "POST");
export const deleteWebhook = (id: string) => api(`/api/webhooks/${id}`, "DELETE");
export const testWebhook = (id: string) =>
api<{ ok: boolean; status?: number; error?: string }>(`/api/webhooks/${id}/test`, "POST");
export interface DeliveryRow {
id: string;
webhook_id: string;
email_id: string;
status: string;
attempt_count: number;
next_retry_at: string | null;
last_error: string | null;
delivered_at: string | null;
created_at: string;
subject: string | null;
}
export const listDeliveries = (webhookId: string) =>
api<{ items: DeliveryRow[] }>(`/api/webhooks/${webhookId}/deliveries`);
export const redeliver = (deliveryId: string) =>
api(`/api/webhooks/deliveries/${deliveryId}/redeliver`, "POST");
/* API/MCP Keys */
export interface KeyRow {
id: string;
kind: "api" | "mcp";
name: string;
key_last4: string;
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
}
export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys");
export const createKey = (kind: "api" | "mcp", name?: string) =>
api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name });
export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST");
export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");
+55
View File
@@ -0,0 +1,55 @@
/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */
import React from "react";
import { createRoot } from "react-dom/client";
import { ConfigProvider, App as AntdApp } from "antd";
import zhCN from "antd/locale/zh_CN";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { antdTheme } from "./theme.ts";
import "./styles.css";
import { App } from "./App.tsx";
import { LoginPage } from "./pages/Login.tsx";
import { OAuthSimPage } from "./pages/OAuthSim.tsx";
import { DashboardPage } from "./pages/Dashboard.tsx";
import { NodesPage } from "./pages/Nodes.tsx";
import { DomainsPage } from "./pages/Domains.tsx";
import { MailboxesPage } from "./pages/Mailboxes.tsx";
import { EmailsPage } from "./pages/Emails.tsx";
import { WebhooksPage } from "./pages/Webhooks.tsx";
import { KeysPage } from "./pages/Keys.tsx";
import { setUnauthorizedHandler } from "./api.ts";
/** # 成员变量: 路由表 **/
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/oauth/sim/authorize", element: <OAuthSimPage /> },
{
path: "/",
element: <App />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "nodes", element: <NodesPage /> },
{ path: "domains", element: <DomainsPage /> },
{ path: "mailboxes", element: <MailboxesPage /> },
{ path: "emails", element: <EmailsPage /> },
{ path: "webhooks", element: <WebhooksPage /> },
{ path: "keys", element: <KeysPage /> },
],
},
]);
/** 未登录统一跳登录页 */
setUnauthorizedHandler(() => {
if (!location.pathname.startsWith("/login")) {
location.href = "/login";
}
});
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN} theme={antdTheme}>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
</React.StrictMode>,
);
+115
View File
@@ -0,0 +1,115 @@
/** 概览页:统计卡 + 最近邮件 + 手动 Cron */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Col, Row, Statistic, Table, message } from "antd";
import { ReloadOutlined, ClearOutlined } from "@ant-design/icons";
import { health, runCron, stats } from "../api.ts";
/** # 成员变量: 最近邮件表格列 **/
const recentColumns = [
{ title: "主题", dataIndex: "subject", ellipsis: true },
{ title: "发件人", dataIndex: "from_address", width: 240, ellipsis: true },
{ title: "大小", dataIndex: "size", width: 100, render: (v: number) => fmtSize(v) },
{ title: "接收时间", dataIndex: "received_at", width: 190, render: (v: string) => new Date(v).toLocaleString() },
];
/** # 函数
* 字节数格式化
*/
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** # 函数
* 概览页组件
*/
export function DashboardPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof stats>> | null>(null);
const [version, setVersion] = useState("");
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [s, h] = await Promise.all([stats(), health()]);
setData(s);
setVersion(h.version);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="节点在线" value={data?.nodes_online ?? 0} suffix={`/ ${data?.nodes_total ?? 0}`} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="域名" value={data?.domains ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="邮箱" value={data?.mailboxes ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="邮件(未过期)" value={data?.emails ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="Webhook" value={data?.webhooks ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="待投递" value={data?.deliveries_pending ?? 0} />
</Card>
</Col>
</Row>
<Card
title="最近邮件"
style={{ marginTop: 20 }}
extra={
<span style={{ display: "flex", gap: 8 }}>
<Button
icon={<ClearOutlined />}
onClick={async () => {
const res = await runCron();
message.success(`清理完成:删除 ${res.stats.expiredEmails} 封过期邮件`);
void load();
}}
>
手动触发 Cron(TTL 清理)
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
</span>
}
>
<Table
rowKey="id"
size="middle"
loading={loading}
columns={recentColumns}
dataSource={data?.recent_emails ?? []}
pagination={false}
/>
<div className="muted" style={{ marginTop: 12 }}>
服务版本 v{version} · 邮件默认保留 1 天,过期由 Cron 自动清理
</div>
</Card>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
/** 域名页 */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Form, Input, Modal, Space, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.ts";
/** # 函数
* 域名页组件
*/
export function DomainsPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<DomainRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listDomains()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<span className="muted">OAuth 安装流程会自动创建域名;此处也可手工登记。</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
添加域名
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "域名", dataIndex: "name" },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{ title: "节点", dataIndex: "node_name", width: 160, render: (v: string | null) => v ?? "-" },
{ title: "Worker", dataIndex: "worker_id", width: 220, render: (v: string | null) => v ?? "-" },
{ title: "邮箱数", dataIndex: "mailbox_count", width: 90 },
{
title: "操作",
width: 90,
render: (_: unknown, row: DomainRow) => (
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
),
},
]}
/>
</Card>
<Modal
title="添加域名"
open={open}
onCancel={() => setOpen(false)}
onOk={async () => {
const values = await form.validateFields();
try {
await createDomain(values.name);
message.success("已添加");
setOpen(false);
form.resetFields();
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "添加失败");
}
}}
okText="添加"
cancelText="取消"
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="域名" rules={[{ required: true, message: "请输入域名" }]}>
<Input placeholder="example.com" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
/** 邮件页:搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Descriptions, Drawer, Input, Space, Table, Tag, Typography, App, Popconfirm, Modal } from "antd";
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { deleteEmail, getEmail, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
/** # 函数
* 字节数格式化
*/
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** # 成员变量: 表格列(分页在组件内配置) **/
const columns = [
{
title: "主题",
dataIndex: "subject",
ellipsis: true,
render: (v: string | null) => v ?? <span className="muted">(无主题)</span>,
},
{ title: "发件人", dataIndex: "from_address", width: 220, ellipsis: true },
{ title: "邮箱", dataIndex: "mailbox_address", width: 200, ellipsis: true, render: (v: string | null) => v ?? "-" },
{
title: "附件",
dataIndex: "attachment_count",
width: 70,
render: (v: number) => (v > 0 ? <Tag color="blue">{v}</Tag> : "-"),
},
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
{
title: "接收时间",
dataIndex: "received_at",
width: 175,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "过期时间",
dataIndex: "expires_at",
width: 175,
render: (v: string) => <span className="muted">{new Date(v).toLocaleString()}</span>,
},
];
/** # 函数
* 邮件页组件
*/
export function EmailsPage() {
const { message } = App.useApp();
const [items, setItems] = useState<EmailRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<EmailDetail | null>(null);
const [htmlPreview, setHtmlPreview] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await listEmails({ q, limit: pageSize, offset: (page - 1) * pageSize });
setItems(res.items);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [q, page, pageSize]);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<Space>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索主题 / 发件人"
style={{ width: 280 }}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
}}
/>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
onRow={(row) => ({
onClick: async () => {
setDetail(await getEmail(row.id));
setHtmlPreview(false);
},
style: { cursor: "pointer" },
})}
columns={columns}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
<Drawer
title={detail?.email.subject ?? "邮件详情"}
size="large"
open={Boolean(detail)}
onClose={() => setDetail(null)}
extra={
detail && (
<Space>
<Button size="small" onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
{htmlPreview ? "显示文本" : "HTML 预览"}
</Button>
<Button size="small" href={`/api/emails/${detail.email.id}/raw`} target="_blank">
下载 .eml
</Button>
<Popconfirm
title="删除这封邮件?"
onConfirm={async () => {
await deleteEmail(detail.email.id);
message.success("已删除");
setDetail(null);
void load();
}}
>
<Button size="small" danger>
删除
</Button>
</Popconfirm>
</Space>
)
}
>
{detail && (
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="发件人">{detail.email.from_address ?? "-"}</Descriptions.Item>
<Descriptions.Item label="收件人">
{safeArr(detail.email.to_addresses).join(", ") || "-"}
</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email.mailbox_address ?? "(未匹配)"}</Descriptions.Item>
<Descriptions.Item label="Message-ID">
<span className="mono">{detail.email.message_id ?? "-"}</span>
</Descriptions.Item>
<Descriptions.Item label="接收 / 过期">
{new Date(detail.email.received_at).toLocaleString()} →{" "}
<span className="muted">{new Date(detail.email.expires_at).toLocaleString()}</span>
</Descriptions.Item>
</Descriptions>
{detail.attachments.length > 0 && (
<div>
<Typography.Title level={5}>附件({detail.attachments.length})</Typography.Title>
<Space orientation="vertical">
{detail.attachments.map((att) => (
<Button
key={att.id}
size="small"
href={`/api/emails/${detail.email.id}/attachments/${att.id}`}
target="_blank"
>
{att.filename}({fmtSize(att.size)})
</Button>
))}
</Space>
</div>
)}
{htmlPreview && detail.email.html_body ? (
<Modal
open
title="HTML 预览(沙箱隔离)"
width={720}
footer={null}
onCancel={() => setHtmlPreview(false)}
getContainer={false}
>
<iframe
className="email-html-frame"
sandbox=""
title="email html preview"
srcDoc={detail.email.html_body}
/>
</Modal>
) : (
<Typography.Paragraph
style={{ whiteSpace: "pre-wrap", maxHeight: 420, overflow: "auto" }}
className="mono"
>
{detail.email.text_body ?? "(无文本正文)"}
</Typography.Paragraph>
)}
</Space>
)}
</Drawer>
</div>
);
}
/** # 函数
* JSON 数组字段安全解析
*/
function safeArr(s: string | null): string[] {
try {
const v = JSON.parse(s ?? "[]");
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}
+158
View File
@@ -0,0 +1,158 @@
/** API / MCP Key 管理页(wmh_ 前缀,总长 32,仅创建时可见) */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Dropdown, Space, Table, Tag, Typography, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createKey, deleteKey, listKeys, revokeKey, type KeyRow } from "../api.ts";
/** # 函数
* Keys 页组件
*/
export function KeysPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<KeyRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listKeys()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<span className="muted">
API Key 调用 /api/v1/*;MCP Key 调用 /api/v1/mcp(JSON-RPC)。均以 Authorization: Bearer wmh_...
传递;服务端只存 SHA-256,明文仅创建时展示一次。Key 总长 32 位(wmh_ + 28 位随机)。
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
{/* Dropdown 子元素必须是 DOM 元素(antd v6),外包 div */}
<div>
<Dropdown
menu={{
items: [
{ key: "api", label: "生成 API Key" },
{ key: "mcp", label: "生成 MCP Key" },
],
onClick: ({ key }) => void genKey(key as "api" | "mcp"),
}}
>
<Button type="primary" icon={<PlusOutlined />}>
生成 Key
</Button>
</Dropdown>
</div>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{
title: "类型",
dataIndex: "kind",
width: 90,
render: (v: string) => <Tag color={v === "api" ? "blue" : "purple"}>{v.toUpperCase()}</Tag>,
},
{ title: "名称", dataIndex: "name" },
{
title: "Key 尾缀",
dataIndex: "key_last4",
width: 110,
render: (v: string) => (
<span className="mono">
wmh_****{v}
</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
width: 175,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "最近使用",
dataIndex: "last_used_at",
width: 175,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "状态",
dataIndex: "revoked_at",
width: 90,
render: (v: string | null) =>
v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>,
},
{
title: "操作",
width: 160,
render: (_: unknown, row: KeyRow) => (
<Space>
{!row.revoked_at && (
<Button
size="small"
onClick={async () => {
await revokeKey(row.id);
message.success("已撤销");
void load();
}}
>
撤销
</Button>
)}
<Button
size="small"
danger
onClick={async () => {
await deleteKey(row.id);
message.success("已删除记录");
void load();
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
</div>
);
/** 生成 key 并展示一次 */
async function genKey(kind: "api" | "mcp") {
try {
const res = await createKey(kind);
modal.success({
title: `${kind.toUpperCase()} Key 已创建(仅显示一次)`,
width: 560,
content: (
<div style={{ marginTop: 12 }}>
<Typography.Paragraph type="warning">
请立即保存,关闭后无法再次查看。Key 格式:wmh_ 前缀 + 28 位随机串,总长 32。
</Typography.Paragraph>
<Typography.Paragraph copyable className="mono" style={{ fontSize: 15 }}>
{res.key}
</Typography.Paragraph>
</div>
),
});
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "生成失败");
}
}
}
+57
View File
@@ -0,0 +1,57 @@
/** 登录页:用户名密码(默认 admin/admin) */
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Card, Form, Input, App } from "antd";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { login } from "../api.ts";
/** # 函数
* 登录页组件
*/
export function LoginPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
return (
<div className="login-page">
<Card className="login-card">
<div className="login-brand">
<img src="/logo.svg" alt="WmailHub" />
<span className="name">WmailHub</span>
</div>
<div style={{ textAlign: "center", marginBottom: 20 }} className="muted">
Cloudflare Email Workers 邮件接收平台
</div>
<Form
layout="vertical"
onFinish={async (values) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success("登录成功");
navigate("/", { replace: true });
} catch (err) {
message.error(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
}}
>
<Form.Item name="username" rules={[{ required: true, message: "请输入用户名" }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" autoFocus />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" size="large" />
</Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
登录
</Button>
</Form>
<div style={{ marginTop: 14, textAlign: "center" }} className="muted">
默认账号 admin / admin,登录后请尽快修改
</div>
</Card>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
/** 邮箱页:创建/停用/删除/保留策略 */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createMailbox, deleteMailbox, listDomains, listMailboxes, patchMailbox, type DomainRow, type MailboxRow } from "../api.ts";
/** # 函数
* 邮箱页组件
*/
export function MailboxesPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<MailboxRow[]>([]);
const [domains, setDomains] = useState<DomainRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const [mb, dm] = await Promise.all([listMailboxes(), listDomains()]);
setItems(mb.items);
setDomains(dm.items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const toggleStatus = async (row: MailboxRow) => {
await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" });
void load();
};
return (
<div>
<div className="page-toolbar">
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
添加邮箱
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{
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>
),
},
{ title: "域名", dataIndex: "domain_name", width: 180 },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{
title: "保留(秒)",
dataIndex: "retention_seconds",
width: 110,
render: (v: number | null) => v ?? <span className="muted">默认 86400</span>,
},
{ title: "邮件数", dataIndex: "email_count", width: 90 },
{
title: "启用",
width: 80,
render: (_: unknown, row: MailboxRow) => (
<Switch checked={row.status === "active"} onChange={() => void toggleStatus(row)} />
),
},
{
title: "操作",
width: 90,
render: (_: unknown, row: MailboxRow) => (
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除邮箱 ${row.address}?`,
onOk: async () => {
await deleteMailbox(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
),
},
]}
/>
</Card>
<Modal
title="添加邮箱"
open={open}
onCancel={() => setOpen(false)}
okText="添加"
cancelText="取消"
destroyOnHidden
onOk={async () => {
const values = await form.validateFields();
try {
const res = await createMailbox({
domain_id: values.domain_id,
address: values.address,
retention_seconds: values.retention_seconds || undefined,
catch_all: values.catch_all,
});
message.success(`已创建 ${res.address}`);
setOpen(false);
form.resetFields();
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "创建失败");
}
}}
>
<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="选择域名"
/>
</Form.Item>
<Form.Item name="address" label="地址(可只填本地部分)" rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="hello" />
</Form.Item>
<Form.Item name="retention_seconds" label="保留秒数(留空用全局默认 1 天)">
<InputNumber min={60} style={{ width: "100%" }} placeholder="86400" />
</Form.Item>
<Form.Item name="catch_all" label="设为该域名 catch-all" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
/** 节点页:Add Node(OAuth 自动安装)、安装进度、凭据轮换 */
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Table,
Tag,
Timeline,
Typography,
App,
Alert,
} from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import {
createNode,
deleteNode,
getNode,
getNodeIngressSecret,
listNodes,
oauthMode,
oauthStart,
rotateNode,
type NodeDetail,
type NodeRow,
} from "../api.ts";
/** # 成员变量: 节点状态 -> Tag 颜色 **/
const STATUS_COLOR: Record<string, string> = {
pending_oauth: "orange",
provisioning: "processing",
online: "success",
offline: "default",
error: "error",
};
/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/
const STEP_LABEL: Record<string, string> = {
oauth: "Cloudflare OAuth 授权",
identity: "Node identity 与 credential",
discover: "Account / Zone 发现",
email_worker: "Email Worker 创建/更新",
email_routing: "Email Routing 配置",
domains: "Domain 创建",
mailboxes: "Mailbox 创建",
};
/** # 函数
* 节点页组件
*/
export function NodesPage() {
const { message, modal } = App.useApp();
const [params] = useSearchParams();
const [items, setItems] = useState<NodeRow[]>([]);
const [loading, setLoading] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [form] = Form.useForm();
const [detail, setDetail] = useState<NodeDetail | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listNodes()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
// OAuth 回调携带 ?provision=<id> 时自动打开详情
const provisionId = params.get("provision");
if (provisionId) {
void (async () => {
try {
setDetail(await getNode(provisionId));
setDetailOpen(true);
} catch {
// ignore
}
})();
}
}, [load, params]);
/** Add Node 提交:创建节点后进入 OAuth */
const submitAdd = async () => {
const values = await form.validateFields();
const created = await createNode(values.name, values.ingress_url || undefined);
setAddOpen(false);
form.resetFields();
message.success("节点已创建,开始 Cloudflare 授权");
await startOAuth(created.node_id);
};
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */
const startOAuth = async (nodeId: string) => {
const mode = (await oauthMode()).mode;
const { url } = await oauthStart(nodeId);
if (mode === "cloudflare") {
window.location.href = url;
} else {
window.location.href = url; // /oauth/sim/authorize?state=...
}
};
const openDetail = async (id: string) => {
setDetail(await getNode(id));
setDetailOpen(true);
};
return (
<div>
<div className="page-toolbar">
<span className="muted">
点击「添加节点」后完成 Cloudflare OAuth,系统自动安装 Email Worker / Email Routing / Domain / Mailbox,
全程无需在服务器执行任何安装命令。
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
添加节点
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
columns={[
{ title: "名称", dataIndex: "name" },
{
title: "状态",
dataIndex: "status",
width: 120,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", render: (v: string | null) => v ?? "-" },
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
{
title: "最近心跳",
dataIndex: "last_seen_at",
width: 190,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
width: 150,
render: (_: unknown, row: NodeRow) => (
<Space>
<Button
size="small"
disabled={row.status !== "pending_oauth"}
onClick={(e) => {
e.stopPropagation();
void startOAuth(row.id);
}}
>
授权
</Button>
<Button
size="small"
danger
onClick={(e) => {
e.stopPropagation();
modal.confirm({
title: `删除节点 ${row.name}?`,
content: "将撤销节点凭据并标记为 revoked。",
onOk: async () => {
await deleteNode(row.id);
message.success("已删除");
void load();
},
});
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
<AddNodeModal open={addOpen} form={form} onOk={() => void submitAdd()} onCancel={() => setAddOpen(false)} />
<NodeDetailDrawer
detail={detail}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onReload={async () => {
if (detail) setDetail(await getNode(detail.node.id));
void load();
}}
/>
</div>
);
}
/** # 函数
* 添加节点弹窗
*/
function AddNodeModal({
open,
form,
onOk,
onCancel,
}: {
open: boolean;
form: ReturnType<typeof Form.useForm>[0];
onOk: () => void;
onCancel: () => void;
}) {
return (
<Modal
title="添加节点"
open={open}
onOk={onOk}
onCancel={onCancel}
okText="创建并授权"
cancelText="取消"
destroyOnHidden
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="创建后将跳转 Cloudflare OAuth;授权完成即自动完成全部安装。"
/>
<Form form={form} layout="vertical">
<Form.Item name="name" label="节点名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="例如:Local Node / Node-JP" />
</Form.Item>
<Form.Item
name="ingress_url"
label="Node Ingress 地址"
initialValue="http://127.0.0.1:18788/ingress/email"
extra="Email Worker 回源 Node 的地址;生产环境填 Node 的公网 HTTPS 地址"
>
<Input />
</Form.Item>
</Form>
</Modal>
);
}
/** # 函数
* 节点详情抽屉:安装步骤时间线 + 域名 + enrollment/凭据管理
*/
function NodeDetailDrawer({
detail,
open,
onClose,
onReload,
}: {
detail: NodeDetail | null;
open: boolean;
onClose: () => void;
onReload: () => void;
}) {
const { message, modal } = App.useApp();
const [showSecret, setShowSecret] = useState(false);
if (!detail) return null;
const steps = detail.steps;
return (
<Drawer title={`节点:${detail.node.name}`} size="large" open={open} onClose={onClose}>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<div>
<Typography.Title level={5}>安装进度</Typography.Title>
<Timeline
items={Object.entries(STEP_LABEL).map(([key, label]) => {
const st = steps[key]?.status ?? "pending";
return {
color: st === "done" ? "green" : st === "error" ? "red" : st === "running" ? "blue" : "gray",
children: (
<span>
{label}{" "}
{st === "error" && steps[key]?.error ? (
<Typography.Text type="danger">{steps[key].error}</Typography.Text>
) : (
<Tag style={{ marginLeft: 6 }}>{st}</Tag>
)}
</span>
),
};
})}
/>
</div>
<Card size="small" title="已配置域名">
{detail.domains.length === 0 ? (
<span className="muted">暂无</span>
) : (
detail.domains.map((d) => (
<div key={d.id}>
<Tag color="blue">{d.name}</Tag>
<span className="muted mono">{d.worker_id ?? d.status}</span>
</div>
))
)}
</Card>
<Card size="small" title="凭据管理">
<Space orientation="vertical" style={{ width: "100%" }}>
<Space wrap>
<Button
onClick={async () => {
const res = await rotateNode(detail.node.id);
modal.info({
title: "新的 Enrollment Token(仅显示一次)",
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
});
onReload();
}}
>
轮换凭据(生成新 Enrollment Token)
</Button>
<Button
onClick={async () => {
const res = await getNodeIngressSecret(detail.node.id);
setShowSecret(true);
message.info(`Ingress Secret:${res.ingress_secret}`);
}}
>
查看 Ingress Secret
</Button>
</Space>
<Typography.Text type="secondary" className="mono">
enrollment 有效期至 {detail.node.enrollment_expires_at ?? "-"}
</Typography.Text>
</Space>
</Card>
</Space>
{showSecret && null}
</Drawer>
);
}
+55
View File
@@ -0,0 +1,55 @@
/** sim 模式的 Cloudflare 授权模拟页:确认后调用 approve,再跳回节点页 */
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, Card, Space, App } from "antd";
import { oauthSimApprove } from "../api.ts";
/** # 函数
* 模拟授权页(仅本地 sim 模式;真实部署直接跳 Cloudflare 授权页)
*/
export function OAuthSimPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { message } = App.useApp();
const state = params.get("state") ?? "";
const [busy, setBusy] = useState(false);
const approve = async () => {
setBusy(true);
try {
const res = await oauthSimApprove(state);
if (res.ok) {
message.success("Cloudflare 授权与自动安装完成");
navigate(`/nodes?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
} else {
message.error(res.error ?? "安装失败");
navigate("/nodes", { replace: true });
}
} catch (err) {
message.error(err instanceof Error ? err.message : "授权失败");
navigate("/nodes", { replace: true });
} finally {
setBusy(false);
}
};
// state 缺失直接回节点页
useEffect(() => {
if (!state) navigate("/nodes", { replace: true });
}, [state, navigate]);
return (
<div className="login-page">
<Card className="login-card" title="Cloudflare 授权(本地模拟)" styles={{ header: { textAlign: "center" } }}>
<p>sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。</p>
<p className="muted">真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。</p>
<Space style={{ width: "100%", justifyContent: "center", marginTop: 16 }}>
<Button onClick={() => navigate("/nodes")}>拒绝</Button>
<Button type="primary" loading={busy} onClick={() => void approve()}>
同意授权
</Button>
</Space>
</Card>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
/** Webhooks 页:CRUD、secret 展示、测试投递、投递日志 */
import { useCallback, useEffect, useState } from "react";
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
App,
} from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import {
createWebhook,
deleteWebhook,
listDeliveries,
listWebhooks,
patchWebhook,
redeliver,
rotateWebhookSecret,
testWebhook,
type DeliveryRow,
type WebhookRow,
} from "../api.ts";
/** # 成员变量: 投递状态颜色 **/
const DELIVERY_COLOR: Record<string, string> = {
pending: "processing",
success: "success",
failed: "warning",
exhausted: "error",
};
/** # 函数
* Webhooks 页组件
*/
export function WebhooksPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<WebhookRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [deliveries, setDeliveries] = useState<{ hook: WebhookRow; rows: DeliveryRow[] } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listWebhooks()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
/** 展示一次性 secret */
const showSecretModal = (title: string, secret: string) => {
modal.info({
title,
width: 560,
content: (
<div>
<Typography.Paragraph type="warning">secret 仅此一次显示,请立即保存。</Typography.Paragraph>
<Typography.Paragraph copyable className="mono">
{secret}
</Typography.Paragraph>
</div>
),
});
};
return (
<div>
<div className="page-toolbar">
<span className="muted">
{"邮件入库后推送 email.received 事件;签名头 X-WmailHub-Signature = HMAC-SHA256(secret, `${timestamp}.${body}`)。"}
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
添加 Webhook
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name" },
{ title: "Endpoint", dataIndex: "endpoint", ellipsis: true, className: "mono" },
{
title: "投递",
width: 140,
render: (_: unknown, row: WebhookRow) => (
<span>
{row.success_count}/{row.delivery_count}
</span>
),
},
{
title: "启用",
width: 80,
render: (_: unknown, row: WebhookRow) => (
<Switch
checked={row.enabled === 1}
onChange={async (v) => {
await patchWebhook(row.id, { enabled: v });
void load();
}}
/>
),
},
{
title: "操作",
width: 280,
render: (_: unknown, row: WebhookRow) => (
<Space wrap>
<Button
size="small"
onClick={async () => {
const res = await testWebhook(row.id);
if (res.ok) message.success("测试投递成功");
else message.error(`测试投递失败:${res.error ?? res.status}`);
void load();
}}
>
测试
</Button>
<Button
size="small"
onClick={async () => {
setDeliveries({ hook: row, rows: (await listDeliveries(row.id)).items });
}}
>
日志
</Button>
<Button
size="small"
onClick={async () => {
const res = await rotateWebhookSecret(row.id);
showSecretModal("新 Secret", res.secret);
}}
>
轮换 Secret
</Button>
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除 Webhook ${row.name}?`,
onOk: async () => {
await deleteWebhook(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
<Modal
title="添加 Webhook"
open={open}
onCancel={() => setOpen(false)}
okText="创建"
cancelText="取消"
destroyOnHidden
onOk={async () => {
const values = await form.validateFields();
try {
const res = await createWebhook(values.name, values.endpoint);
setOpen(false);
form.resetFields();
void load();
showSecretModal("Webhook Secret(仅显示一次)", res.secret);
} catch (err) {
message.error(err instanceof Error ? err.message : "创建失败");
}
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="CRM 通知" />
</Form.Item>
<Form.Item
name="endpoint"
label="Endpoint URL"
rules={[
{ required: true, message: "请输入 URL" },
{
validator: (_, v: string) =>
/^https?:\/\//.test(v ?? "") ? Promise.resolve() : Promise.reject(new Error("必须为 http(s) URL")),
},
]}
extra="禁止 localhost / 私网 / 内网地址(SSRF 防护)"
>
<Input placeholder="https://example.com/hooks/wmailhub" />
</Form.Item>
</Form>
</Modal>
<Drawer
title={`投递日志:${deliveries?.hook.name ?? ""}`}
size="large"
open={Boolean(deliveries)}
onClose={() => setDeliveries(null)}
>
<Table
rowKey="id"
size="small"
dataSource={deliveries?.rows ?? []}
pagination={false}
columns={[
{ title: "邮件", dataIndex: "subject", ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={DELIVERY_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "尝试", dataIndex: "attempt_count", width: 60 },
{
title: "错误",
dataIndex: "last_error",
ellipsis: true,
render: (v: string | null) => v ?? "-",
},
{
title: "操作",
width: 90,
render: (_: unknown, row: DeliveryRow) => (
<Button
size="small"
onClick={async () => {
await redeliver(row.id);
message.success("已加入重试队列");
}}
>
重投
</Button>
),
},
]}
/>
</Drawer>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
/** 全局样式:100vh 框架布局 + 少量辅助类(纯手写,不引入 Tailwind 避免与 antd 层叠冲突) */
html,
body,
#root {
height: 100%;
margin: 0;
}
* {
box-sizing: border-box;
}
.app-layout {
height: 100vh;
overflow: hidden;
}
.app-sider {
border-right: 1px solid rgba(5, 5, 5, 0.06);
}
.app-sider .brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px 12px;
}
.app-sider .brand img {
width: 34px;
height: 34px;
}
.app-sider .brand .name {
font-size: 18px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
letter-spacing: 0.2px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.app-header .title {
font-size: 20px;
font-weight: 650;
}
.app-header .subtitle {
font-size: 12.5px;
color: rgba(0, 0, 0, 0.45);
margin-top: 1px;
}
.app-content {
overflow: auto;
padding: 24px;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.page-toolbar .filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
}
.muted {
color: rgba(0, 0, 0, 0.45);
}
.email-html-frame {
width: 100%;
border: 1px solid rgba(5, 5, 5, 0.1);
border-radius: 8px;
background: #fff;
min-height: 320px;
}
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, #f0f5ff 0%, #ffffff 55%);
}
.login-card {
width: 380px;
box-shadow: 0 8px 40px rgba(47, 84, 235, 0.10);
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
margin-bottom: 8px;
}
.login-brand img {
width: 44px;
height: 44px;
}
.login-brand .name {
font-size: 26px;
font-weight: 750;
}
+25
View File
@@ -0,0 +1,25 @@
/** antd v6 主题配置:默认主题 + 少量 token 覆写(主色与品牌一致) */
import type { ThemeConfig } from "antd";
/** # 成员变量: 品牌主色 **/
export const BRAND_COLOR = "#2f54eb";
/** # 成员变量: antd 主题 */
export const antdTheme: ThemeConfig = {
token: {
colorPrimary: BRAND_COLOR,
borderRadius: 8,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
},
components: {
Layout: {
siderBg: "#ffffff",
headerBg: "#ffffff",
headerHeight: 64,
},
Card: {
headerHeight: 56,
},
},
};