feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链
- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建) - 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选 - Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口 - 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除) - UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框 - 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react) - 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录 - 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
This commit is contained in:
@@ -6,11 +6,10 @@
|
||||
<meta name="description" content="WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<title>WmailHub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/assets/main.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="WmailHub icon">
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="#2f54eb"/>
|
||||
<path d="M16 39a9 9 0 0 1-1-18 17 17 0 0 1 32-1 10 10 0 0 1 1 19H16Z" fill="#ccdcff"/>
|
||||
<rect x="12" y="28" width="40" height="27" rx="5" fill="#fff"/>
|
||||
<path d="m14 32 16 12a3.5 3.5 0 0 0 4 0l16-12" fill="none" stroke="#2f54eb" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="m14 52 12-10m24 10L38 42" fill="none" stroke="#9bb4ff" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 606 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="WmailHub logo">
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#5578ff"/>
|
||||
<stop offset="55%" style="stop-color:#2f54eb"/>
|
||||
<stop offset="100%" style="stop-color:#1938bd"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="24" y="24" width="464" height="464" rx="108" fill="#2f54eb"/>
|
||||
<rect x="24" y="24" width="464" height="464" rx="108" fill="url(#background)" opacity=".2"/>
|
||||
<path d="M132 310a68 68 0 0 1-4-136 132 132 0 0 1 248-5 71 71 0 0 1 8 141H132Z" fill="#dbe7ff"/>
|
||||
<rect x="110" y="222" width="292" height="188" rx="30" fill="#fff"/>
|
||||
<path d="m124 246 119 89a22 22 0 0 0 26 0l119-89" fill="none" stroke="#2f54eb" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="m122 391 88-74m180 74-88-74" fill="none" stroke="#a5bbff" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1007 B |
@@ -1,45 +1,41 @@
|
||||
/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */
|
||||
/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 设置入口 */
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Avatar, Dropdown, Layout, Menu } from "antd";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Tooltip } from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
MailOutlined,
|
||||
CloudServerOutlined,
|
||||
GlobalOutlined,
|
||||
BellOutlined,
|
||||
KeyOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { health, logout, me } from "./api.ts";
|
||||
import { SettingsModal } from "./components/SettingsModal.tsx";
|
||||
|
||||
const { Sider, Header, Content } = Layout;
|
||||
|
||||
/** # 成员变量: 侧栏菜单项 **/
|
||||
const MENU_ITEMS = [
|
||||
{ key: "/", icon: <DashboardOutlined />, label: "概览" },
|
||||
{ key: "/nodes", icon: <CloudServerOutlined />, label: "节点 Nodes" },
|
||||
{ key: "/accounts", icon: <CloudServerOutlined />, label: "账号 Accounts" },
|
||||
{ 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: "收件地址与保留策略" },
|
||||
"/": { title: "概览", subtitle: "账号、域名与邮件总览" },
|
||||
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" },
|
||||
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
|
||||
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
|
||||
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
|
||||
"/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" },
|
||||
"/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" },
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源)
|
||||
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 设置入口;侧栏品牌带版本号
|
||||
*/
|
||||
export function App() {
|
||||
const navigate = useNavigate();
|
||||
@@ -47,6 +43,7 @@ export function App() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [version, setVersion] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -70,6 +67,8 @@ export function App() {
|
||||
<div className="brand">
|
||||
<img src="/logo.svg" alt="WmailHub" />
|
||||
<span className="name">WmailHub</span>
|
||||
{/* 版本号小一号、居上对齐(来源 package.json) */}
|
||||
{version && <span className="brand-version">v{version}</span>}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
@@ -81,12 +80,19 @@ export function App() {
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="app-header">
|
||||
<div>
|
||||
<div className="header-meta">
|
||||
<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>
|
||||
<Tooltip title="设置(全局 Webhook / Ingress / 密码 / API & MCP Keys)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="设置"
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
|
||||
<div>
|
||||
<Dropdown
|
||||
@@ -116,6 +122,7 @@ export function App() {
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+76
-73
@@ -12,7 +12,7 @@ export function setUnauthorizedHandler(fn: () => void): void {
|
||||
|
||||
/** # 函数
|
||||
* 通用请求封装
|
||||
* 参数: method/url/body(JSON)/query
|
||||
* 参数: method/url/body(JSON)
|
||||
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError
|
||||
*/
|
||||
export async function api<T = unknown>(
|
||||
@@ -68,7 +68,7 @@ export function qs(params: Record<string, string | number | undefined>): string
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
/* ---- 具体接口封装 ---- */
|
||||
/* ---- 会话 ---- */
|
||||
|
||||
/** 登录 */
|
||||
export const login = (username: string, password: string) =>
|
||||
@@ -83,16 +83,19 @@ export const me = () => api<{ user: { id: string; username: string } }>("/api/au
|
||||
/** 健康检查(含版本号) */
|
||||
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
|
||||
|
||||
/* ---- 概览 ---- */
|
||||
|
||||
/** Dashboard 统计 */
|
||||
export const stats = () =>
|
||||
api<{
|
||||
nodes_online: number;
|
||||
nodes_total: number;
|
||||
accounts_online: number;
|
||||
accounts_total: number;
|
||||
domains: number;
|
||||
domains_active: number;
|
||||
mailboxes: number;
|
||||
emails: number;
|
||||
emails_expired: number;
|
||||
webhooks: number;
|
||||
global_webhook: number;
|
||||
deliveries_pending: number;
|
||||
oauth_connections: number;
|
||||
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
|
||||
@@ -101,9 +104,9 @@ export const stats = () =>
|
||||
/** 手动触发 Cron */
|
||||
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
|
||||
|
||||
/* Nodes */
|
||||
/* ---- 账号(原节点) ---- */
|
||||
|
||||
export interface NodeRow {
|
||||
export interface AccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
cloudflare_account_id: string | null;
|
||||
@@ -114,62 +117,79 @@ export interface NodeRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes");
|
||||
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
|
||||
|
||||
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 };
|
||||
export interface AccountDetail {
|
||||
account: AccountRow & { 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 getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
|
||||
|
||||
export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE");
|
||||
export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
|
||||
api(`/api/accounts/${id}`, "PATCH", body);
|
||||
|
||||
export const rotateNode = (id: string) =>
|
||||
api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST");
|
||||
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
|
||||
|
||||
export const getNodeIngressSecret = (id: string) =>
|
||||
api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`);
|
||||
export const rotateAccount = (id: string) =>
|
||||
api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
|
||||
|
||||
/* OAuth */
|
||||
export const getAccountIngressSecret = (id: string) =>
|
||||
api<{ ingress_secret: string }>(`/api/accounts/${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)}`);
|
||||
/** 开始授权:nodeId 缺省 = 自动创建账号 */
|
||||
export const oauthStart = (nodeId?: string) =>
|
||||
api<{ url: string; state: string }>(
|
||||
`/api/oauth/cloudflare/start${nodeId ? `?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;
|
||||
setup_error: string | null;
|
||||
worker_id: string | null;
|
||||
zone_id: string | null;
|
||||
node_id: string | null;
|
||||
node_name: string | null;
|
||||
worker_id: string | null;
|
||||
webhook_url: string | null;
|
||||
webhook_headers: string | null;
|
||||
webhook_has_key: number;
|
||||
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 listDomainAccounts = () => api<{ items: { id: string; name: string }[] }>("/api/domains/filters/accounts");
|
||||
|
||||
/** 为域名配置 Email Workers(Worker + Email Routing + catch-all) */
|
||||
export const setupDomain = (id: string) =>
|
||||
api<{ ok: boolean; error?: string }>(`/api/domains/${id}/setup`, "POST");
|
||||
|
||||
/** 保存域名级 Webhook(url 空串清除;key undefined 保留原值) */
|
||||
export const putDomainWebhook = (
|
||||
id: string,
|
||||
body: { url: string; headers?: Record<string, string>; key?: string },
|
||||
) => api(`/api/domains/${id}/webhook`, "PUT", body);
|
||||
|
||||
export const deleteDomainWebhook = (id: string) => api(`/api/domains/${id}/webhook`, "DELETE");
|
||||
|
||||
export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE");
|
||||
|
||||
/* Mailboxes */
|
||||
/* ---- 邮箱 ---- */
|
||||
|
||||
export interface MailboxRow {
|
||||
id: string;
|
||||
@@ -179,6 +199,9 @@ export interface MailboxRow {
|
||||
retention_seconds: number | null;
|
||||
domain_id: string;
|
||||
domain_name: string;
|
||||
webhook_url: string | null;
|
||||
webhook_headers: string | null;
|
||||
webhook_has_key: number;
|
||||
email_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -192,9 +215,17 @@ export const createMailbox = (body: { domain_id: string; address: string; retent
|
||||
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
|
||||
api(`/api/mailboxes/${id}`, "PATCH", body);
|
||||
|
||||
/** 保存邮箱级 Webhook(url 空串清除;key undefined 保留原值) */
|
||||
export const putMailboxWebhook = (
|
||||
id: string,
|
||||
body: { url: string; headers?: Record<string, string>; key?: string },
|
||||
) => api(`/api/mailboxes/${id}/webhook`, "PUT", body);
|
||||
|
||||
export const deleteMailboxWebhook = (id: string) => api(`/api/mailboxes/${id}/webhook`, "DELETE");
|
||||
|
||||
export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE");
|
||||
|
||||
/* Emails */
|
||||
/* ---- 邮件 ---- */
|
||||
|
||||
export interface EmailRow {
|
||||
id: string;
|
||||
@@ -222,54 +253,26 @@ 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 interface SettingsInfo {
|
||||
username: string;
|
||||
default_ingress_url: string;
|
||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||
}
|
||||
|
||||
export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks");
|
||||
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
|
||||
export const createWebhook = (name: string, endpoint: string) =>
|
||||
api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint });
|
||||
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
|
||||
export const putSettings = (body: {
|
||||
default_ingress_url?: string;
|
||||
webhook?: { url: string; headers?: Record<string, string>; key?: string };
|
||||
}) => api("/api/settings", "PUT", body);
|
||||
|
||||
export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) =>
|
||||
api(`/api/webhooks/${id}`, "PATCH", body);
|
||||
export const changePassword = (old_password: string, new_password: string) =>
|
||||
api("/api/settings/password", "PUT", { old_password, new_password });
|
||||
|
||||
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 */
|
||||
/* ---- API/MCP Keys(设置模态窗口内使用) ---- */
|
||||
|
||||
export interface KeyRow {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 设置模态窗口(v1.1.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 默认 Node Ingress 地址 + 修改密码
|
||||
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
changePassword,
|
||||
createKey,
|
||||
deleteKey,
|
||||
getSettings,
|
||||
listKeys,
|
||||
putSettings,
|
||||
revokeKey,
|
||||
type KeyRow,
|
||||
type SettingsInfo,
|
||||
} from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 设置模态窗口组件
|
||||
*/
|
||||
export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [info, setInfo] = useState<SettingsInfo | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setInfo(await getSettings());
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "读取设置失败");
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [open, load]);
|
||||
|
||||
return (
|
||||
<Modal title="设置" open={open} onCancel={onClose} footer={null} width={760} destroyOnHidden>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "general", label: "基本设置", children: info ? <GeneralTab info={info} onSaved={load} /> : null },
|
||||
{ key: "api", label: "API Keys", children: <KeysTab kind="api" /> },
|
||||
{ key: "mcp", label: "MCP Keys", children: <KeysTab kind="mcp" /> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量: Webhook 配置表单值 **/
|
||||
interface WebhookFormValues {
|
||||
url: string;
|
||||
headers: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 基本设置:全局 Webhook + 默认 Node Ingress + 修改密码
|
||||
*/
|
||||
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [webhookForm] = Form.useForm<WebhookFormValues>();
|
||||
const [ingressForm] = Form.useForm<{ ingress: string }>();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm: string;
|
||||
}>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
webhookForm.setFieldsValue({
|
||||
url: info.webhook.url ?? "",
|
||||
headers: info.webhook.url ? JSON.stringify(info.webhook.headers ?? {}, null, 2) : "",
|
||||
});
|
||||
}, [info, webhookForm]);
|
||||
|
||||
/** 保存全局 Webhook 与默认 Ingress */
|
||||
const saveWebhook = async () => {
|
||||
const values = await webhookForm.validateFields();
|
||||
let headers: Record<string, string> = {};
|
||||
if (values.headers.trim()) {
|
||||
try {
|
||||
headers = JSON.parse(values.headers) as Record<string, string>;
|
||||
} catch {
|
||||
message.error("自定义请求头必须是合法 JSON 对象");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await putSettings({
|
||||
default_ingress_url: info.default_ingress_url,
|
||||
webhook: { url: values.url.trim(), headers, key: values.key },
|
||||
});
|
||||
message.success("全局 Webhook 已保存");
|
||||
webhookForm.setFieldValue("key", undefined);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存默认 Node Ingress 地址 */
|
||||
const saveIngress = async () => {
|
||||
const values = await ingressForm.validateFields();
|
||||
try {
|
||||
await putSettings({ default_ingress_url: values.ingress.trim() });
|
||||
message.success("默认 Node Ingress 地址已保存");
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
try {
|
||||
await changePassword(values.old_password, values.new_password);
|
||||
message.success("密码已更新");
|
||||
pwdForm.resetFields();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "修改失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Form form={webhookForm} layout="vertical">
|
||||
<Typography.Title level={5}>全局 Webhook</Typography.Title>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="收到邮件时按 邮箱 → 域名 → 全局 的顺序取第一个已配置的 Webhook 转发;此处为兜底的全局配置。留空 URL 表示停用全局 Webhook。"
|
||||
/>
|
||||
<Form.Item name="url" label="Webhook URL">
|
||||
<Input placeholder="https://hooks.example.com/wmail" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="headers"
|
||||
label="自定义请求头(JSON 对象,可选)"
|
||||
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>,头里的 Authorization 优先'
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="key"
|
||||
label="Key"
|
||||
extra={
|
||||
info.webhook.has_key
|
||||
? `已设置 key(****${info.webhook.key_last4}),用于签名(X-WmailHub-Signature)与默认 Authorization;留空保留现有,填空格清除`
|
||||
: "可选;默认以 Authorization: Bearer <key> 携带,并用于 Webhook 签名"
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={saving} onClick={() => void saveWebhook()}>
|
||||
保存 Webhook
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Form form={ingressForm} layout="vertical" initialValues={{ ingress: info.default_ingress_url }}>
|
||||
<Typography.Title level={5}>默认 Node Ingress 地址</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
|
||||
「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口
|
||||
(Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。
|
||||
</Typography.Text>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="ingress" noStyle rules={[{ required: true, message: "请输入地址" }]}>
|
||||
<Input placeholder="http://127.0.0.1:18788/ingress/email" />
|
||||
</Form.Item>
|
||||
<Button onClick={() => void saveIngress()}>保存</Button>
|
||||
</Space.Compact>
|
||||
</Form>
|
||||
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Typography.Title level={5}>修改密码</Typography.Title>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认新密码"
|
||||
dependencies={["new_password"]}
|
||||
rules={[
|
||||
{ required: true, message: "请再次输入" },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, v) =>
|
||||
!v || v === getFieldValue("new_password")
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("两次输入不一致")),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* API/MCP Key 管理表格
|
||||
*/
|
||||
function KeysTab({ kind }: { kind: "api" | "mcp" }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<KeyRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems((await listKeys()).items.filter((k) => k.kind === kind));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = items.filter((k) =>
|
||||
`${k.name} ${k.key_last4}`.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
/** 生成新 key:明文仅展示一次 */
|
||||
const create = async () => {
|
||||
try {
|
||||
const res = await createKey(kind);
|
||||
modal.info({
|
||||
title: `${kind === "api" ? "API" : "MCP"} Key 已生成(仅显示一次)`,
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
||||
{res.key}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">请立即保存;关闭后无法再次查看。</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "生成失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索名称 / 尾号"
|
||||
style={{ width: 220 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void create()}>
|
||||
生成 {kind === "api" ? "API" : "MCP"} Key
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name", ellipsis: true },
|
||||
{ title: "尾号", dataIndex: "key_last4", width: 80, render: (v: string) => <span className="mono">****{v}</span> },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "revoked_at",
|
||||
width: 90,
|
||||
render: (v: string | null) => (v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>),
|
||||
},
|
||||
{ title: "创建时间", dataIndex: "created_at", width: 165, render: (v: string) => new Date(v).toLocaleString() },
|
||||
{
|
||||
title: "最近使用",
|
||||
dataIndex: "last_used_at",
|
||||
width: 165,
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 130,
|
||||
render: (_: unknown, row: KeyRow) => (
|
||||
<Space>
|
||||
{!row.revoked_at && (
|
||||
<Popconfirm
|
||||
title="撤销后使用该 Key 的请求将被拒绝,确认?"
|
||||
onConfirm={async () => {
|
||||
await revokeKey(row.id);
|
||||
message.success("已撤销");
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Button size="small">撤销</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteKey(row.id);
|
||||
message.success("已删除");
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Webhook 配置表单(域名级/邮箱级共用;全局版本在设置模态窗口内)。
|
||||
* key 语义:留空保留现有;填空格清除;非空设置新 key。
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Input, Space, App } from "antd";
|
||||
import { putDomainWebhook, putMailboxWebhook } from "../api.ts";
|
||||
|
||||
/** # 成员变量: 表单当前值(由父组件传入) **/
|
||||
export interface WebhookValue {
|
||||
url: string | null;
|
||||
headers: string | null;
|
||||
hasKey: boolean;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解析 headers JSON 串为对象(失败返回 {})
|
||||
*/
|
||||
export function parseHeaders(s: string | null): Record<string, string> {
|
||||
if (!s) return {};
|
||||
try {
|
||||
const v = JSON.parse(s);
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, string>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Webhook 配置表单组件
|
||||
* 参数: scope - "domain" | "mailbox";targetId - 域名/邮箱 ID;value - 当前配置;onSaved - 保存后回调
|
||||
*/
|
||||
export function WebhookForm({
|
||||
scope,
|
||||
targetId,
|
||||
value,
|
||||
onSaved,
|
||||
}: {
|
||||
scope: "domain" | "mailbox";
|
||||
targetId: string;
|
||||
value: WebhookValue;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<{ url: string; headers: string; key?: string }>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
url: value.url ?? "",
|
||||
headers: value.url ? JSON.stringify(parseHeaders(value.headers), null, 2) : "",
|
||||
});
|
||||
}, [value, form, targetId]);
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
let headers: Record<string, string> = {};
|
||||
if (values.headers.trim()) {
|
||||
try {
|
||||
headers = JSON.parse(values.headers) as Record<string, string>;
|
||||
} catch {
|
||||
message.error("自定义请求头必须是合法 JSON 对象");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const key = values.key ?? undefined;
|
||||
// "填空格清除 key":把单个空格翻译成空串
|
||||
const normalizedKey = key !== undefined && key.trim() === "" && key.length > 0 ? "" : key;
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { url: values.url.trim(), headers, key: normalizedKey };
|
||||
if (scope === "domain") await putDomainWebhook(targetId, body);
|
||||
else await putMailboxWebhook(targetId, body);
|
||||
message.success("Webhook 已保存");
|
||||
form.setFieldValue("key", undefined);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (scope === "domain") await putDomainWebhook(targetId, { url: "" });
|
||||
else await putMailboxWebhook(targetId, { url: "" });
|
||||
message.success("已清除该级 Webhook(将回退到上一级配置)");
|
||||
form.resetFields();
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "清除失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="url" label="Webhook URL" extra="留空并保存 = 清除该级配置">
|
||||
<Input placeholder="https://hooks.example.com/wmail" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="headers"
|
||||
label="自定义请求头(JSON 对象,可选)"
|
||||
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>'
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="key"
|
||||
label="Key"
|
||||
extra={
|
||||
value.hasKey
|
||||
? "已设置 key(****尾号见保存后提示);留空保留,填空格清除"
|
||||
: "可选;默认以 Authorization: Bearer <key> 携带,并用于投递签名"
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
{value.url && (
|
||||
<Button danger onClick={() => void clear()}>
|
||||
清除该级配置
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */
|
||||
/** 前端入口:antd v6 + react-router(Vite 构建) */
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ConfigProvider, App as AntdApp } from "antd";
|
||||
@@ -10,12 +10,10 @@ 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 { AccountsPage } from "./pages/Accounts.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";
|
||||
|
||||
/** # 成员变量: 路由表 **/
|
||||
@@ -27,12 +25,10 @@ const router = createBrowserRouter([
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "nodes", element: <NodesPage /> },
|
||||
{ path: "accounts", element: <AccountsPage /> },
|
||||
{ path: "domains", element: <DomainsPage /> },
|
||||
{ path: "mailboxes", element: <MailboxesPage /> },
|
||||
{ path: "emails", element: <EmailsPage /> },
|
||||
{ path: "webhooks", element: <WebhooksPage /> },
|
||||
{ path: "keys", element: <KeysPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
/** 节点页:Add Node(OAuth 自动安装)、安装进度、凭据轮换 */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, 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 { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
createNode,
|
||||
deleteNode,
|
||||
getNode,
|
||||
getNodeIngressSecret,
|
||||
listNodes,
|
||||
deleteAccount,
|
||||
getAccount,
|
||||
getAccountIngressSecret,
|
||||
listAccounts,
|
||||
oauthMode,
|
||||
oauthStart,
|
||||
rotateNode,
|
||||
type NodeDetail,
|
||||
type NodeRow,
|
||||
rotateAccount,
|
||||
updateAccount,
|
||||
type AccountDetail,
|
||||
type AccountRow,
|
||||
} from "../api.ts";
|
||||
|
||||
/** # 成员变量: 节点状态 -> Tag 颜色 **/
|
||||
/** # 成员变量: 账号状态 -> Tag 颜色 **/
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending_oauth: "orange",
|
||||
provisioning: "processing",
|
||||
@@ -44,29 +41,26 @@ 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 创建",
|
||||
domains: "域名与 catch-all 邮箱同步",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* 节点页组件
|
||||
* 账号页组件
|
||||
*/
|
||||
export function NodesPage() {
|
||||
export function AccountsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [items, setItems] = useState<NodeRow[]>([]);
|
||||
const [items, setItems] = useState<AccountRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [detail, setDetail] = useState<NodeDetail | null>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [detail, setDetail] = useState<AccountDetail | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems((await listNodes()).items);
|
||||
setItems((await listAccounts()).items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -79,7 +73,7 @@ export function NodesPage() {
|
||||
if (provisionId) {
|
||||
void (async () => {
|
||||
try {
|
||||
setDetail(await getNode(provisionId));
|
||||
setDetail(await getAccount(provisionId));
|
||||
setDetailOpen(true);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -88,43 +82,53 @@ export function NodesPage() {
|
||||
}
|
||||
}, [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);
|
||||
};
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((row) =>
|
||||
`${row.name} ${row.status} ${row.cloudflare_account_id ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(q.trim().toLowerCase()),
|
||||
),
|
||||
[items, q],
|
||||
);
|
||||
|
||||
/** 发起 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=...
|
||||
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。
|
||||
* accountId 缺省 = 服务端自动创建账号。 */
|
||||
const startOAuth = async (accountId?: string) => {
|
||||
try {
|
||||
const mode = (await oauthMode()).mode;
|
||||
const { url } = await oauthStart(accountId);
|
||||
if (mode === "cloudflare") window.location.href = url;
|
||||
else navigate(url); // /oauth/sim/authorize?state=...
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "发起授权失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetail(await getNode(id));
|
||||
setDetail(await getAccount(id));
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
<span className="muted">
|
||||
点击「添加节点」后完成 Cloudflare OAuth,系统自动安装 Email Worker / Email Routing / Domain / Mailbox,
|
||||
全程无需在服务器执行任何安装命令。
|
||||
</span>
|
||||
<div className="filters">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索名称 / 状态 / Cloudflare 账户"
|
||||
style={{ width: 280 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span className="muted">
|
||||
点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
|
||||
</span>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
|
||||
添加节点
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
|
||||
添加账号
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -133,7 +137,7 @@ export function NodesPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
|
||||
columns={[
|
||||
@@ -141,21 +145,22 @@ export function NodesPage() {
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 120,
|
||||
width: 130,
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", render: (v: string | null) => v ?? "-" },
|
||||
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "最近心跳",
|
||||
dataIndex: "last_seen_at",
|
||||
width: 190,
|
||||
width: 170,
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 150,
|
||||
render: (_: unknown, row: NodeRow) => (
|
||||
render: (_: unknown, row: AccountRow) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -173,10 +178,10 @@ export function NodesPage() {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
modal.confirm({
|
||||
title: `删除节点 ${row.name}?`,
|
||||
content: "将撤销节点凭据并标记为 revoked。",
|
||||
title: `删除账号 ${row.name}?`,
|
||||
content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
|
||||
onOk: async () => {
|
||||
await deleteNode(row.id);
|
||||
await deleteAccount(row.id);
|
||||
message.success("已删除");
|
||||
void load();
|
||||
},
|
||||
@@ -192,14 +197,12 @@ export function NodesPage() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AddNodeModal open={addOpen} form={form} onOk={() => void submitAdd()} onCancel={() => setAddOpen(false)} />
|
||||
|
||||
<NodeDetailDrawer
|
||||
<AccountDetailDrawer
|
||||
detail={detail}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onReload={async () => {
|
||||
if (detail) setDetail(await getNode(detail.node.id));
|
||||
if (detail) setDetail(await getAccount(detail.account.id));
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
@@ -208,74 +211,35 @@ export function NodesPage() {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 添加节点弹窗
|
||||
* 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
|
||||
*/
|
||||
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({
|
||||
function AccountDetailDrawer({
|
||||
detail,
|
||||
open,
|
||||
onClose,
|
||||
onReload,
|
||||
}: {
|
||||
detail: NodeDetail | null;
|
||||
detail: AccountDetail | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [ingress, setIngress] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (detail) {
|
||||
setIngress(detail.account.ingress_url);
|
||||
setName(detail.account.name);
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
if (!detail) return null;
|
||||
const steps = detail.steps;
|
||||
|
||||
return (
|
||||
<Drawer title={`节点:${detail.node.name}`} size="large" open={open} onClose={onClose}>
|
||||
<Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Typography.Title level={5}>安装进度</Typography.Title>
|
||||
@@ -299,25 +263,49 @@ function NodeDetailDrawer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card size="small" title="已配置域名">
|
||||
<Card size="small" title="已同步域名">
|
||||
{detail.domains.length === 0 ? (
|
||||
<span className="muted">暂无</span>
|
||||
<span className="muted">暂无;OAuth 授权完成后自动同步</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>
|
||||
<span className="muted mono">
|
||||
{d.worker_id ?? "未配置 Email Workers"}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="凭据管理">
|
||||
<Space orientation="vertical" style={{ width: "100%" }}>
|
||||
<Card size="small" title="Ingress 与凭据">
|
||||
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
placeholder="账号名称"
|
||||
/>
|
||||
<Input
|
||||
value={ingress}
|
||||
onChange={(e) => setIngress(e.target.value)}
|
||||
placeholder="Node Ingress 地址"
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await updateAccount(detail.account.id, { name, ingress_url: ingress });
|
||||
message.success("已保存(新邮件将从新 Ingress 转发)");
|
||||
onReload();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await rotateNode(detail.node.id);
|
||||
const res = await rotateAccount(detail.account.id);
|
||||
modal.info({
|
||||
title: "新的 Enrollment Token(仅显示一次)",
|
||||
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
|
||||
@@ -329,21 +317,22 @@ function NodeDetailDrawer({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await getNodeIngressSecret(detail.node.id);
|
||||
setShowSecret(true);
|
||||
message.info(`Ingress Secret:${res.ingress_secret}`);
|
||||
const res = await getAccountIngressSecret(detail.account.id);
|
||||
modal.info({
|
||||
title: "Ingress Secret(Email Worker -> Node 共享密钥)",
|
||||
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
|
||||
});
|
||||
}}
|
||||
>
|
||||
查看 Ingress Secret
|
||||
</Button>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
enrollment 有效期至 {detail.node.enrollment_expires_at ?? "-"}
|
||||
enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
{showSecret && null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -49,12 +49,12 @@ export function DashboardPage() {
|
||||
<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}`} />
|
||||
<Statistic title="账号在线" value={data?.accounts_online ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="域名" value={data?.domains ?? 0} />
|
||||
<Statistic title="域名" value={data?.domains ?? 0} suffix={`/ ${data?.domains_active ?? 0} 已配置`} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
@@ -69,7 +69,7 @@ export function DashboardPage() {
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="Webhook" value={data?.webhooks ?? 0} />
|
||||
<Statistic title="全局 Webhook" value={data?.global_webhook ? "已配置" : "未配置"} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
/** 域名页 */
|
||||
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";
|
||||
/**
|
||||
* 域名页(v1.1.0):域名由 Cloudflare OAuth 自动同步(无手工添加);
|
||||
* 点击域名配置 Email Workers(Worker + Email Routing + catch-all)与域名级 Webhook;
|
||||
* 支持搜索与按账号筛选。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
||||
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
deleteDomain,
|
||||
listDomainAccounts,
|
||||
listDomains,
|
||||
setupDomain,
|
||||
type DomainRow,
|
||||
} from "../api.ts";
|
||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||
|
||||
/** # 成员变量: 域名状态 -> Tag **/
|
||||
function StatusTag({ row }: { row: DomainRow }) {
|
||||
if (row.status === "active") return <Tag color="success">已配置 Email Workers</Tag>;
|
||||
if (row.status === "error")
|
||||
return (
|
||||
<Tooltip title={row.setup_error ?? "配置失败"}>
|
||||
<Tag color="error">error</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
return <Tag color="default">pending</Tag>;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 域名页组件
|
||||
@@ -10,14 +33,18 @@ import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.
|
||||
export function DomainsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<DomainRow[]>([]);
|
||||
const [accountOptions, setAccountOptions] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [q, setQ] = useState("");
|
||||
const [accountId, setAccountId] = useState<string | undefined>();
|
||||
const [editing, setEditing] = useState<DomainRow | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems((await listDomains()).items);
|
||||
const [d, a] = await Promise.all([listDomains(), listDomainAccounts()]);
|
||||
setItems(d.items);
|
||||
setAccountOptions(a.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -27,90 +54,189 @@ export function DomainsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(row) =>
|
||||
(!accountId || row.node_id === accountId) &&
|
||||
row.name.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
),
|
||||
[items, q, accountId],
|
||||
);
|
||||
|
||||
/** 配置 Email Workers */
|
||||
const runSetup = async (row: DomainRow) => {
|
||||
try {
|
||||
const res = await setupDomain(row.id);
|
||||
if (res.ok) message.success(`已为 ${row.name} 部署 Email Worker 并开启 Email Routing`);
|
||||
else message.error(res.error ?? "配置失败");
|
||||
void load();
|
||||
setEditing((cur) => (cur?.id === row.id ? { ...cur, status: res.ok ? "active" : "error" } : cur));
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
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 className="filters">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索域名"
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按账号筛选"
|
||||
style={{ width: 200 }}
|
||||
value={accountId}
|
||||
onChange={setAccountId}
|
||||
options={accountOptions.map((a) => ({ value: a.id, label: a.name }))}
|
||||
/>
|
||||
<span className="muted">域名在 Cloudflare OAuth 授权后自动同步;点击域名行设置 Email Workers 与 Webhook。</span>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
</div>
|
||||
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
|
||||
columns={[
|
||||
{ title: "域名", dataIndex: "name" },
|
||||
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
|
||||
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: "节点", 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: "Webhook",
|
||||
width: 95,
|
||||
render: (_: unknown, row: DomainRow) =>
|
||||
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
|
||||
},
|
||||
{ title: "邮箱数", dataIndex: "mailbox_count", width: 80 },
|
||||
{
|
||||
title: "操作",
|
||||
width: 90,
|
||||
width: 200,
|
||||
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>
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
{row.status !== "active" ? (
|
||||
<Button size="small" type="primary" onClick={() => void runSetup(row)}>
|
||||
设置 Email Workers
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" onClick={() => void runSetup(row)}>
|
||||
重新配置
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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
|
||||
<Drawer
|
||||
title={
|
||||
editing ? (
|
||||
<span>
|
||||
<span className="mono">{editing.name}</span>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
|
||||
{editing.node_name ?? "-"}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
"域名"
|
||||
)
|
||||
}
|
||||
size="large"
|
||||
open={Boolean(editing)}
|
||||
onClose={() => setEditing(null)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="域名" rules={[{ required: true, message: "请输入域名" }]}>
|
||||
<Input placeholder="example.com" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
{editing && (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Email Workers"
|
||||
extra={
|
||||
editing.status !== "active" && (
|
||||
<Button size="small" type="primary" onClick={() => void runSetup(editing)}>
|
||||
设置 Email Workers
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: "100%" }}>
|
||||
<StatusTag row={editing} />
|
||||
<Typography.Text type="secondary">
|
||||
部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
|
||||
规则指向 Worker;邮件经由 Node 转发回 Console。
|
||||
</Typography.Text>
|
||||
{editing.status === "active" && editing.worker_id && (
|
||||
<Typography.Text className="mono">
|
||||
Worker:{editing.worker_id} · Zone:{editing.zone_id}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{editing.status === "error" && editing.setup_error && (
|
||||
<Typography.Text type="danger">{editing.setup_error}</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="域名级 Webhook">
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
|
||||
该域名下邮件未命中邮箱级 Webhook 时使用;再未配置则回退全局 Webhook(设置中配置)。
|
||||
</Typography.Text>
|
||||
<WebhookForm
|
||||
scope="domain"
|
||||
targetId={editing.id}
|
||||
value={{
|
||||
url: editing.webhook_url,
|
||||
headers: editing.webhook_headers,
|
||||
hasKey: Boolean(editing.webhook_has_key),
|
||||
}}
|
||||
onSaved={async () => {
|
||||
const fresh = (await listDomains()).items.find((d) => d.id === editing.id) ?? null;
|
||||
setEditing(fresh);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/** 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 : "生成失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
/** 邮箱页:创建/停用/删除/保留策略 */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
|
||||
import { useCallback, useEffect, useMemo, 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";
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
createMailbox,
|
||||
deleteMailbox,
|
||||
listDomains,
|
||||
listMailboxes,
|
||||
patchMailbox,
|
||||
type DomainRow,
|
||||
type MailboxRow,
|
||||
} from "../api.ts";
|
||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||
|
||||
/** # 函数
|
||||
* 邮箱页组件
|
||||
@@ -13,6 +22,8 @@ export function MailboxesPage() {
|
||||
const [domains, setDomains] = useState<DomainRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -30,6 +41,14 @@ export function MailboxesPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter((row) =>
|
||||
`${row.address} ${row.domain_name}`.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
),
|
||||
[items, q],
|
||||
);
|
||||
|
||||
const toggleStatus = async (row: MailboxRow) => {
|
||||
await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" });
|
||||
void load();
|
||||
@@ -38,7 +57,16 @@ export function MailboxesPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
|
||||
<div className="filters">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索地址 / 域名"
|
||||
style={{ width: 260 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
|
||||
@@ -50,7 +78,7 @@ export function MailboxesPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
@@ -62,47 +90,58 @@ export function MailboxesPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: "域名", dataIndex: "domain_name", width: 180 },
|
||||
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
width: 95,
|
||||
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "Webhook",
|
||||
width: 90,
|
||||
render: (_: unknown, row: MailboxRow) =>
|
||||
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
|
||||
},
|
||||
{
|
||||
title: "保留(秒)",
|
||||
dataIndex: "retention_seconds",
|
||||
width: 110,
|
||||
width: 105,
|
||||
render: (v: number | null) => v ?? <span className="muted">默认 86400</span>,
|
||||
},
|
||||
{ title: "邮件数", dataIndex: "email_count", width: 90 },
|
||||
{ title: "邮件数", dataIndex: "email_count", width: 85 },
|
||||
{
|
||||
title: "启用",
|
||||
width: 80,
|
||||
width: 75,
|
||||
render: (_: unknown, row: MailboxRow) => (
|
||||
<Switch checked={row.status === "active"} onChange={() => void toggleStatus(row)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 90,
|
||||
width: 150,
|
||||
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>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWebhookTarget(row)}>
|
||||
Webhook
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() =>
|
||||
modal.confirm({
|
||||
title: `删除邮箱 ${row.address}?`,
|
||||
onOk: async () => {
|
||||
await deleteMailbox(row.id);
|
||||
message.success("已删除");
|
||||
void load();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -152,6 +191,35 @@ export function MailboxesPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`邮箱级 Webhook:${webhookTarget?.address ?? ""}`}
|
||||
open={Boolean(webhookTarget)}
|
||||
onCancel={() => setWebhookTarget(null)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
该邮箱收到邮件时优先使用此 Webhook;未配置则回退域名级,再回退全局(设置中配置)。
|
||||
</p>
|
||||
{webhookTarget && (
|
||||
<WebhookForm
|
||||
scope="mailbox"
|
||||
targetId={webhookTarget.id}
|
||||
value={{
|
||||
url: webhookTarget.webhook_url,
|
||||
headers: webhookTarget.webhook_headers,
|
||||
hasKey: Boolean(webhookTarget.webhook_has_key),
|
||||
}}
|
||||
onSaved={async () => {
|
||||
const fresh = (await listMailboxes()).items.find((m) => m.id === webhookTarget.id) ?? null;
|
||||
setWebhookTarget(fresh);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,14 @@ export function OAuthSimPage() {
|
||||
const res = await oauthSimApprove(state);
|
||||
if (res.ok) {
|
||||
message.success("Cloudflare 授权与自动安装完成");
|
||||
navigate(`/nodes?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
|
||||
navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
|
||||
} else {
|
||||
message.error(res.error ?? "安装失败");
|
||||
navigate("/nodes", { replace: true });
|
||||
navigate("/accounts", { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "授权失败");
|
||||
navigate("/nodes", { replace: true });
|
||||
navigate("/accounts", { replace: true });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function OAuthSimPage() {
|
||||
|
||||
// state 缺失直接回节点页
|
||||
useEffect(() => {
|
||||
if (!state) navigate("/nodes", { replace: true });
|
||||
if (!state) navigate("/accounts", { replace: true });
|
||||
}, [state, navigate]);
|
||||
|
||||
return (
|
||||
@@ -44,7 +44,7 @@ export function OAuthSimPage() {
|
||||
<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 onClick={() => navigate("/accounts")}>拒绝</Button>
|
||||
<Button type="primary" loading={busy} onClick={() => void approve()}>
|
||||
同意授权
|
||||
</Button>
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,20 @@ body,
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* 版本号 tag:字体小一号、居上对齐 */
|
||||
.brand-version {
|
||||
align-self: flex-start;
|
||||
margin-top: 1px;
|
||||
padding: 0 6px;
|
||||
font-size: 10.5px;
|
||||
line-height: 16px;
|
||||
color: #2f54eb;
|
||||
background: rgba(47, 84, 235, 0.08);
|
||||
border: 1px solid rgba(47, 84, 235, 0.25);
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -47,15 +61,27 @@ body,
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
/* 标题/副标题紧凑排列:行高收紧、不换行、溢出省略 */
|
||||
.header-meta {
|
||||
min-width: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.app-header .title {
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header .subtitle {
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 56vw;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
|
||||
+5
-5
@@ -4,9 +4,9 @@
|
||||
* 路由分区:
|
||||
* /api/health 健康检查
|
||||
* /api/auth/* 用户登录/登出/会话(cookie)
|
||||
* /api/nodes|domains|mailboxes|emails 管理资源 CRUD(会话保护)
|
||||
* /api/webhooks/* Webhook 管理(会话保护)
|
||||
* /api/keys/* API/MCP Key 管理(会话保护)
|
||||
* /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护)
|
||||
* /api/settings/* 设置:全局 Webhook / 默认 Ingress / 改密码(会话保护)
|
||||
* /api/keys/* API/MCP Key 管理(会话保护;设置模态窗口内使用)
|
||||
* /api/oauth/* Cloudflare OAuth(会话保护)
|
||||
* /api/admin/* 版本/统计/Cron(会话保护)
|
||||
* /api/internal/* Node 内部协议(HMAC 签名)
|
||||
@@ -22,7 +22,7 @@ import { csrfOriginCheck } from "./middleware.ts";
|
||||
import { authRoutes } from "./auth.ts";
|
||||
import { internalRoutes } from "./routes/internal.ts";
|
||||
import { resourceRoutes } from "./routes/resources.ts";
|
||||
import { webhookRoutes } from "./routes/webhooks.ts";
|
||||
import { settingsRoutes } from "./routes/settings.ts";
|
||||
import { keyRoutes } from "./routes/keys.ts";
|
||||
import { oauthRoutes } from "./routes/oauth.ts";
|
||||
import { apiV1Routes } from "./routes/apiv1.ts";
|
||||
@@ -54,7 +54,7 @@ export function createApp(
|
||||
app.route("/api/auth", authRoutes());
|
||||
app.route("/api/internal", internalRoutes());
|
||||
app.route("/api/oauth", oauthRoutes());
|
||||
app.route("/api/webhooks", webhookRoutes());
|
||||
app.route("/api/settings", settingsRoutes());
|
||||
app.route("/api/keys", keyRoutes());
|
||||
app.route("/api/admin", adminRoutes());
|
||||
app.route("/api/v1/mcp", mcpRoutes());
|
||||
|
||||
@@ -104,3 +104,33 @@ export async function decryptSecret(env: Env, blob: string): Promise<string> {
|
||||
export function keyHash(key: string): Promise<string> {
|
||||
return sha256Hex(key);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 连接 token 的加密密钥(沿用历史派生方式,保持既有密文可解)
|
||||
*/
|
||||
async function getConnectionKey(env: Env): Promise<CryptoKey> {
|
||||
return deriveAesKey((await getSetting(env, "app_secret")) ?? "wmailhub");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 加密 Cloudflare OAuth access token 入库
|
||||
*/
|
||||
export async function encryptConnectionToken(env: Env, token: string): Promise<string> {
|
||||
return aesEncrypt(await getConnectionKey(env), token);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取最近一条 OAuth 连接的 access token 明文(供 Cloudflare API 调用)
|
||||
* 返回值: 无连接或解密失败返回 null
|
||||
*/
|
||||
export async function decryptConnectionToken(env: Env): Promise<string | null> {
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT encrypted_access_token FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
|
||||
).first<{ encrypted_access_token: string }>();
|
||||
if (!row) return null;
|
||||
try {
|
||||
return await aesDecrypt(await getConnectionKey(env), row.encrypted_access_token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,15 @@ const db = new Database(path.join(DATA_DIR, "wmailhub.local.sqlite"), { create:
|
||||
db.run("PRAGMA journal_mode = WAL;");
|
||||
const migrationsDir = path.join(ROOT, "..", "migrations");
|
||||
if (existsSync(migrationsDir)) {
|
||||
// 已应用的迁移记录在 _migrations 表(ALTER TABLE 之类无法重复执行)
|
||||
db.run("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
|
||||
for (const file of readdirSync(migrationsDir).sort()) {
|
||||
if (!file.endsWith(".sql")) continue;
|
||||
const applied = db.query("SELECT 1 FROM _migrations WHERE name = ?").get(file);
|
||||
if (applied) continue;
|
||||
const sql = await Bun.file(path.join(migrationsDir, file)).text();
|
||||
db.exec(sql);
|
||||
db.query("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(file, new Date().toISOString());
|
||||
console.log(`[console] migration applied: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,16 @@ export function adminRoutes(): App {
|
||||
(await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0;
|
||||
const now = nowIso();
|
||||
const stats = {
|
||||
nodes_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
|
||||
nodes_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
|
||||
accounts_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
|
||||
accounts_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
|
||||
domains: await count("SELECT COUNT(*) AS n FROM domains"),
|
||||
domains_active: await count(`SELECT COUNT(*) AS n FROM domains WHERE status = 'active'`),
|
||||
mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"),
|
||||
emails: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at > '${now}'`),
|
||||
emails_expired: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at <= '${now}'`),
|
||||
webhooks: await count("SELECT COUNT(*) AS n FROM webhooks"),
|
||||
global_webhook: await count(
|
||||
`SELECT COUNT(*) AS n FROM settings WHERE key = 'global_webhook' AND value != '{"url":"","headers":{}}' AND value != ''`,
|
||||
),
|
||||
deliveries_pending: await count(
|
||||
`SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`,
|
||||
),
|
||||
|
||||
@@ -111,13 +111,5 @@ export function apiV1Routes(): App {
|
||||
return c.json({ email, attachments: atts.results });
|
||||
});
|
||||
|
||||
/** Webhook 列表 */
|
||||
app.get("/webhooks", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
|
||||
*
|
||||
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox / list_webhooks
|
||||
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox
|
||||
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
|
||||
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
|
||||
* 返回普通 JSON);GET 返回 405(无 SSE 流)。
|
||||
@@ -72,11 +72,6 @@ const TOOLS = [
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_webhooks",
|
||||
description: "列出 Webhook(不含 secret)",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** # 函数
|
||||
@@ -239,12 +234,6 @@ async function dispatchTool(env: Env, name: string, args: Record<string, unknown
|
||||
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
|
||||
return { ok: true };
|
||||
}
|
||||
case "list_webhooks": {
|
||||
const rows = await env.DB.prepare(
|
||||
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
|
||||
).all();
|
||||
return { webhooks: rows.results };
|
||||
}
|
||||
default:
|
||||
throw new McpToolError(`unknown tool: ${name}`, -32601);
|
||||
}
|
||||
|
||||
+25
-12
@@ -12,10 +12,10 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { aesEncrypt, deriveAesKey, ids } from "../../../shared/src/index.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
|
||||
import { provisionNode } from "../services/provision.ts";
|
||||
import { getSetting } from "../db.ts";
|
||||
import { provisionNode, createNode } from "../services/provision.ts";
|
||||
import { getSetting, encryptConnectionToken } from "../db.ts";
|
||||
import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
@@ -23,6 +23,9 @@ type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
/** # 成员变量: OAuth state 有效期(秒) **/
|
||||
const STATE_TTL_SECONDS = 300;
|
||||
|
||||
/** # 成员变量: 自动创建账号时的默认 Node Ingress 地址(可在设置中修改) **/
|
||||
export const DEFAULT_INGRESS_URL = "http://127.0.0.1:18788/ingress/email";
|
||||
|
||||
/** # 函数
|
||||
* 是否使用真实 Cloudflare OAuth
|
||||
*/
|
||||
@@ -45,13 +48,24 @@ export function oauthRoutes(): App {
|
||||
});
|
||||
});
|
||||
|
||||
/** 开始授权:创建 state 并返回跳转 URL */
|
||||
/** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */
|
||||
app.get("/cloudflare/start", async (c) => {
|
||||
const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
|
||||
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status = 'pending_oauth' OR id = ?")
|
||||
.bind(nodeId, nodeId)
|
||||
.first<{ id: string }>();
|
||||
if (!node) return c.json({ error: "node not found" }, 404);
|
||||
let nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
|
||||
if (nodeId) {
|
||||
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
|
||||
.bind(nodeId)
|
||||
.first<{ id: string }>();
|
||||
if (!node) return c.json({ error: "account not found" }, 404);
|
||||
} else {
|
||||
// 添加账号:直接进入 OAuth,授权成功后自动落地账号(identity + 域名同步)
|
||||
const ingressUrl =
|
||||
(await getSetting(c.env, "default_ingress_url"))?.trim() || DEFAULT_INGRESS_URL;
|
||||
const created = await createNode(c.env, {
|
||||
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
||||
ingressUrl,
|
||||
});
|
||||
nodeId = created.nodeId;
|
||||
}
|
||||
|
||||
const state = "cfstate_" + ids.oauthState();
|
||||
await c.env.DB.prepare(
|
||||
@@ -92,7 +106,7 @@ export function oauthRoutes(): App {
|
||||
// 加密入库(不进前端/日志)
|
||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
||||
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
|
||||
const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
|
||||
const target = `/accounts?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
|
||||
return c.redirect(target, 302);
|
||||
} catch (err) {
|
||||
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
|
||||
@@ -143,8 +157,7 @@ async function consumeState(
|
||||
* OAuth access token 加密入库
|
||||
*/
|
||||
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
|
||||
const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub");
|
||||
const encrypted = await aesEncrypt(key, accessToken);
|
||||
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, ?, ?, ?)`,
|
||||
|
||||
+132
-75
@@ -1,28 +1,31 @@
|
||||
/**
|
||||
* Console 管理路由(Node / Domain / Mailbox / Email)。
|
||||
* 全部要求会话登录。
|
||||
* Console 管理路由(Account(账号) / Domain / Mailbox / Email)。
|
||||
* 全部要求会话登录。v1.1.0:
|
||||
* - 节点在管理面更名「账号」,路由 /api/accounts(内部 Node 协议 /api/internal/nodes 不变)
|
||||
* - 删除手工添加域名;域名由 Cloudflare OAuth 自动同步
|
||||
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
|
||||
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts";
|
||||
import { upsertMailbox, upsertDomain } from "../services/provision.ts";
|
||||
import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
|
||||
import { decryptSecret } from "../db.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { checkWebhookUrl } from "../../../shared/src/index.ts";
|
||||
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
|
||||
import { nowIso, pageParams, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 Node / Domain / Mailbox / Email 管理路由
|
||||
* 装配 Account / Domain / Mailbox / Email 管理路由
|
||||
*/
|
||||
export function resourceRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
installNodeRoutes(app);
|
||||
installAccountRoutes(app);
|
||||
installDomainRoutes(app);
|
||||
installMailboxRoutes(app);
|
||||
installEmailRoutes(app);
|
||||
@@ -30,11 +33,34 @@ export function resourceRoutes(): App {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node CRUD 与安装状态
|
||||
* 解析并校验 webhook 配置请求体
|
||||
* 返回值: { error } 或规整后的 { url, headers, key }(key 为 undefined 表示保留现有 key)
|
||||
*/
|
||||
function installNodeRoutes(app: App) {
|
||||
/** Node 列表(不含任何 secret) */
|
||||
app.get("/nodes", async (c) => {
|
||||
async function parseWebhookBody(
|
||||
c: { env: Env; req: { raw: Request } },
|
||||
): Promise<{ error: string } | { url: string; headers: Record<string, string>; key: string | undefined }> {
|
||||
const body = await readJson<{ url?: string; headers?: unknown; key?: string }>(c.req.raw);
|
||||
const url = (body.url ?? "").trim();
|
||||
let headers: Record<string, string>;
|
||||
try {
|
||||
headers = normalizeWebhookHeaders(body.headers);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : "invalid headers" };
|
||||
}
|
||||
if (url) {
|
||||
const unsafe = checkWebhookUrl(url, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
|
||||
if (unsafe) return { error: unsafe };
|
||||
}
|
||||
const rawKey = typeof body.key === "string" ? body.key : undefined;
|
||||
return { url, headers, key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Account(账号)管理与安装状态
|
||||
*/
|
||||
function installAccountRoutes(app: App) {
|
||||
/** 账号列表(不含任何 secret) */
|
||||
app.get("/accounts", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
|
||||
@@ -47,38 +73,17 @@ function installNodeRoutes(app: App) {
|
||||
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
||||
});
|
||||
|
||||
/** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */
|
||||
app.post("/nodes", async (c) => {
|
||||
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
|
||||
const name = (body.name ?? "").trim();
|
||||
if (!name) return c.json({ error: "name required" }, 400);
|
||||
const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim();
|
||||
try {
|
||||
new URL(ingressUrl);
|
||||
} catch {
|
||||
return c.json({ error: "invalid ingress_url" }, 400);
|
||||
}
|
||||
const created = await createNode(c.env, { name, ingressUrl });
|
||||
return c.json({
|
||||
ok: true,
|
||||
node_id: created.nodeId,
|
||||
enrollment_token: created.enrollmentToken,
|
||||
ingress_secret: created.ingressSecret,
|
||||
worker_name: emailWorkerNameFor(created.nodeId),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
/** Node 详情:含安装步骤状态与 OAuth 连接状态 */
|
||||
app.get("/nodes/:id", async (c) => {
|
||||
/** 账号详情:含安装步骤状态与 OAuth 连接状态 */
|
||||
app.get("/accounts/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const node = await c.env.DB.prepare(
|
||||
const account = await c.env.DB.prepare(
|
||||
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
|
||||
ingress_url, enrollment_expires_at, created_at, updated_at
|
||||
FROM nodes WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!node) return c.json({ error: "not found" }, 404);
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
const steps = await loadSteps(c.env, id);
|
||||
const oauth = await c.env.DB.prepare(
|
||||
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
|
||||
@@ -86,11 +91,38 @@ function installNodeRoutes(app: App) {
|
||||
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?")
|
||||
.bind(id)
|
||||
.all();
|
||||
return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results });
|
||||
return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results });
|
||||
});
|
||||
|
||||
/** 删除 Node(软删:撤销 credential + 标记 revoked) */
|
||||
app.delete("/nodes/:id", async (c) => {
|
||||
/** 更新账号:名称 / Node Ingress 地址(Email Worker 回源地址) */
|
||||
app.patch("/accounts/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
|
||||
const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
if (body.name !== undefined) {
|
||||
const name = body.name.trim();
|
||||
if (!name) return c.json({ error: "name required" }, 400);
|
||||
await c.env.DB.prepare("UPDATE nodes SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
|
||||
}
|
||||
if (body.ingress_url !== undefined) {
|
||||
const ingressUrl = body.ingress_url.trim();
|
||||
try {
|
||||
new URL(ingressUrl);
|
||||
} catch {
|
||||
return c.json({ error: "invalid ingress_url" }, 400);
|
||||
}
|
||||
await c.env.DB.prepare("UPDATE nodes SET ingress_url = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(ingressUrl, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 删除账号(软删:撤销 credential + 标记 revoked) */
|
||||
app.delete("/accounts/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
|
||||
@@ -104,12 +136,12 @@ function installNodeRoutes(app: App) {
|
||||
});
|
||||
|
||||
/** 重新生成 enrollment token(credential 轮换入口) */
|
||||
app.post("/nodes/:id/rotate", async (c) => {
|
||||
app.post("/accounts/:id/rotate", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
|
||||
const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!node) return c.json({ error: "not found" }, 404);
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
const token = "wenr_" + crypto.randomUUID().replace(/-/g, "");
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?",
|
||||
@@ -120,24 +152,26 @@ function installNodeRoutes(app: App) {
|
||||
});
|
||||
|
||||
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
|
||||
app.get("/nodes/:id/ingress-secret", async (c) => {
|
||||
app.get("/accounts/:id/ingress-secret", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
|
||||
const account = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ ingress_secret_enc: string }>();
|
||||
if (!node) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) });
|
||||
if (!account) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ingress_secret: await decryptSecret(c.env, account.ingress_secret_enc) });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Domain CRUD
|
||||
* Domain 查询 / Email Workers 配置 / Webhook 配置
|
||||
*/
|
||||
function installDomainRoutes(app: App) {
|
||||
app.get("/domains", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT d.*, n.name AS node_name,
|
||||
`SELECT d.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.node_id,
|
||||
d.webhook_url, d.webhook_headers, (d.webhook_key_enc IS NOT NULL) AS webhook_has_key,
|
||||
n.name AS node_name,
|
||||
(SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count
|
||||
FROM domains d LEFT JOIN nodes n ON n.id = d.node_id
|
||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
|
||||
@@ -148,19 +182,35 @@ function installDomainRoutes(app: App) {
|
||||
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
||||
});
|
||||
|
||||
/** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */
|
||||
app.post("/domains", async (c) => {
|
||||
const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw);
|
||||
const name = (body.name ?? "").trim().toLowerCase();
|
||||
if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400);
|
||||
const id = await upsertDomain(c.env, {
|
||||
cloudflareAccountId: body.cloudflare_account_id ?? "manual",
|
||||
nodeId: body.node_id ?? null as unknown as string,
|
||||
name,
|
||||
zoneId: "manual",
|
||||
workerId: null as unknown as string,
|
||||
});
|
||||
return c.json({ ok: true, id }, 201);
|
||||
/** 域名可用的账号(供筛选) */
|
||||
app.get("/domains/filters/accounts", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
"SELECT DISTINCT n.id, n.name FROM nodes n JOIN domains d ON d.node_id = n.id ORDER BY n.name",
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 为域名配置 Email Workers(部署 Worker + Email Routing + catch-all) */
|
||||
app.post("/domains/:id/setup", async (c) => {
|
||||
const result = await setupDomainEmail(c.env, c.req.param("id"));
|
||||
return c.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
/** 保存域名级 Webhook(url 为空表示清除) */
|
||||
app.put("/domains/:id/webhook", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const domain = await c.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(id).first();
|
||||
if (!domain) return c.json({ error: "not found" }, 404);
|
||||
const parsed = await parseWebhookBody(c);
|
||||
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
|
||||
await saveScopedWebhook(c.env, "domains", id, parsed);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 清除域名级 Webhook */
|
||||
app.delete("/domains/:id/webhook", async (c) => {
|
||||
await saveScopedWebhook(c.env, "domains", c.req.param("id"), { url: "" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/domains/:id", async (c) => {
|
||||
@@ -175,7 +225,7 @@ function installDomainRoutes(app: App) {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Mailbox CRUD
|
||||
* Mailbox CRUD 与邮箱级 Webhook 配置
|
||||
*/
|
||||
function installMailboxRoutes(app: App) {
|
||||
app.get("/mailboxes", async (c) => {
|
||||
@@ -183,7 +233,9 @@ function installMailboxRoutes(app: App) {
|
||||
const domainId = new URL(c.req.url).searchParams.get("domain_id");
|
||||
const where = domainId ? "WHERE m.domain_id = ?" : "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT m.*, d.name AS domain_name,
|
||||
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, m.domain_id,
|
||||
m.webhook_url, m.webhook_headers, (m.webhook_key_enc IS NOT NULL) AS webhook_has_key,
|
||||
d.name AS domain_name,
|
||||
(SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count
|
||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
||||
${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`,
|
||||
@@ -239,6 +291,23 @@ function installMailboxRoutes(app: App) {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 保存邮箱级 Webhook(url 为空表示清除) */
|
||||
app.put("/mailboxes/:id/webhook", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
|
||||
if (!mailbox) return c.json({ error: "not found" }, 404);
|
||||
const parsed = await parseWebhookBody(c);
|
||||
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
|
||||
await saveScopedWebhook(c.env, "mailboxes", id, parsed);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 清除邮箱级 Webhook */
|
||||
app.delete("/mailboxes/:id/webhook", async (c) => {
|
||||
await saveScopedWebhook(c.env, "mailboxes", c.req.param("id"), { url: "" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/mailboxes/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
||||
@@ -348,15 +417,3 @@ function installEmailRoutes(app: App) {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* webhook endpoint 的基本格式校验(创建时调用)
|
||||
*/
|
||||
export function validateEndpoint(endpoint: string): string | null {
|
||||
try {
|
||||
new URL(endpoint);
|
||||
} catch {
|
||||
return "invalid url";
|
||||
}
|
||||
return checkWebhookUrl(endpoint);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 设置路由(v1.1.0):
|
||||
* GET /api/settings 读取:全局 Webhook / 默认 Node Ingress 地址 / 当前用户
|
||||
* PUT /api/settings 保存:全局 Webhook + 默认 Node Ingress 地址
|
||||
* PUT /api/settings/password 修改登录密码
|
||||
* API/MCP Key 管理沿用 /api/keys(前端设置模态窗口内调用)。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { checkWebhookUrl, hashPassword, verifyPassword } from "../../../shared/src/index.ts";
|
||||
import { getSetting, setSetting } from "../db.ts";
|
||||
import {
|
||||
getGlobalWebhookKey,
|
||||
normalizeWebhookHeaders,
|
||||
saveGlobalWebhook,
|
||||
} from "../services/webhook_delivery.ts";
|
||||
import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配设置路由(会话保护)
|
||||
*/
|
||||
export function settingsRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
/** 读取设置(key 只回 last4,不回明文) */
|
||||
app.get("/", async (c) => {
|
||||
const userId = c.get("userId") ?? "";
|
||||
const user = await c.env.DB.prepare("SELECT username FROM users WHERE id = ?").bind(userId).first<{ username: string }>();
|
||||
const raw = await getSetting(c.env, "global_webhook");
|
||||
let webhook: Record<string, unknown> = { url: "", headers: {}, has_key: false, key_last4: "" };
|
||||
if (raw) {
|
||||
try {
|
||||
const cfg = JSON.parse(raw) as { url?: string; headers?: Record<string, string> };
|
||||
const key = await getGlobalWebhookKey(c.env);
|
||||
webhook = {
|
||||
url: cfg.url ?? "",
|
||||
headers: cfg.headers ?? {},
|
||||
has_key: Boolean(key),
|
||||
key_last4: key.slice(-4),
|
||||
};
|
||||
} catch {
|
||||
// settings 损坏时按未配置处理
|
||||
}
|
||||
}
|
||||
return c.json({
|
||||
username: user?.username ?? "",
|
||||
default_ingress_url: (await getSetting(c.env, "default_ingress_url")) ?? "",
|
||||
webhook,
|
||||
});
|
||||
});
|
||||
|
||||
/** 保存设置:全局 Webhook + 默认 Node Ingress 地址 */
|
||||
app.put("/", async (c) => {
|
||||
const body = await readJson<{
|
||||
default_ingress_url?: string;
|
||||
webhook?: { url?: string; headers?: unknown; key?: string };
|
||||
}>(c.req.raw);
|
||||
|
||||
if (body.default_ingress_url !== undefined) {
|
||||
const ingressUrl = body.default_ingress_url.trim();
|
||||
if (ingressUrl) {
|
||||
try {
|
||||
new URL(ingressUrl);
|
||||
} catch {
|
||||
return c.json({ error: "invalid default_ingress_url" }, 400);
|
||||
}
|
||||
await setSetting(c.env, "default_ingress_url", ingressUrl);
|
||||
} else {
|
||||
await c.env.DB.prepare("DELETE FROM settings WHERE key = 'default_ingress_url'").run();
|
||||
}
|
||||
}
|
||||
|
||||
if (body.webhook) {
|
||||
const url = (body.webhook.url ?? "").trim();
|
||||
let headers: Record<string, string>;
|
||||
try {
|
||||
headers = normalizeWebhookHeaders(body.webhook.headers);
|
||||
} catch (err) {
|
||||
return c.json({ error: err instanceof Error ? err.message : "invalid headers" }, 400);
|
||||
}
|
||||
if (url) {
|
||||
const unsafe = checkWebhookUrl(url, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
|
||||
if (unsafe) return c.json({ error: unsafe }, 400);
|
||||
}
|
||||
// key:undefined 保留现有;空白串(含 "")清除;非空设置新 key
|
||||
const rawKey = typeof body.webhook.key === "string" ? body.webhook.key : undefined;
|
||||
await saveGlobalWebhook(c.env, {
|
||||
url,
|
||||
headers,
|
||||
key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 修改登录密码 */
|
||||
app.put("/password", async (c) => {
|
||||
const body = await readJson<{ old_password?: string; new_password?: string }>(c.req.raw);
|
||||
const oldPassword = body.old_password ?? "";
|
||||
const newPassword = body.new_password ?? "";
|
||||
if (newPassword.length < 6) return c.json({ error: "新密码至少 6 位" }, 400);
|
||||
const userId = c.get("userId") ?? "";
|
||||
const user = await c.env.DB.prepare("SELECT id, password_hash FROM users WHERE id = ?")
|
||||
.bind(userId)
|
||||
.first<{ id: string; password_hash: string }>();
|
||||
if (!user) return c.json({ error: "user not found" }, 404);
|
||||
if (!(await verifyPassword(oldPassword, user.password_hash))) {
|
||||
return c.json({ error: "原密码不正确" }, 400);
|
||||
}
|
||||
await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(await hashPassword(newPassword), userId)
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Console Webhook 管理路由(docs/07):CRUD、secret、投递日志、手动测试/重投。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { ids, checkWebhookUrl } from "../../../shared/src/index.ts";
|
||||
import { attemptDelivery, buildWebhookPayload } from "../services/webhook_delivery.ts";
|
||||
import { pageParams, readJson, nowIso } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 Webhook 管理路由(会话保护)
|
||||
*/
|
||||
export function webhookRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
/** 列表(不含 secret 明文) */
|
||||
app.get("/", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT w.id, w.name, w.endpoint, w.enabled, w.created_at, w.updated_at,
|
||||
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id) AS delivery_count,
|
||||
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id AND d.status = 'success') AS success_count
|
||||
FROM webhooks w ORDER BY w.created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(limit, offset)
|
||||
.all();
|
||||
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM webhooks").first<{ n: number }>();
|
||||
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
||||
});
|
||||
|
||||
/** 创建:secret 生成后明文仅返回一次 */
|
||||
app.post("/", async (c) => {
|
||||
const body = await readJson<{ name?: string; endpoint?: string }>(c.req.raw);
|
||||
const name = (body.name ?? "").trim();
|
||||
const endpoint = (body.endpoint ?? "").trim();
|
||||
if (!name || !endpoint) return c.json({ error: "name and endpoint required" }, 400);
|
||||
const unsafe = checkWebhookUrl(endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
|
||||
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
|
||||
const id = ids.webhook();
|
||||
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const now = nowIso();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.bind(id, name, endpoint, secret, now, now)
|
||||
.run();
|
||||
return c.json({ ok: true, id, secret }, 201);
|
||||
});
|
||||
|
||||
/** 更新(name/endpoint/enabled);endpoint 变更时重新过 SSRF 校验 */
|
||||
app.patch("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await readJson<{ name?: string; endpoint?: string; enabled?: boolean }>(c.req.raw);
|
||||
const hook = await c.env.DB.prepare("SELECT id FROM webhooks WHERE id = ?").bind(id).first();
|
||||
if (!hook) return c.json({ error: "not found" }, 404);
|
||||
if (body.endpoint !== undefined) {
|
||||
const unsafe = checkWebhookUrl(body.endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
|
||||
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
|
||||
await c.env.DB.prepare("UPDATE webhooks SET endpoint = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.endpoint, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
await c.env.DB.prepare("UPDATE webhooks SET name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.name, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
await c.env.DB.prepare("UPDATE webhooks SET enabled = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.enabled ? 1 : 0, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 轮换 secret(明文仅返回一次) */
|
||||
app.post("/:id/rotate", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const res = await c.env.DB.prepare("UPDATE webhooks SET secret = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(secret, nowIso(), id)
|
||||
.run();
|
||||
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ok: true, secret });
|
||||
});
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE webhook_id = ?").bind(id).run();
|
||||
await c.env.DB.prepare("DELETE FROM webhooks WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 投递日志 */
|
||||
app.get("/:id/deliveries", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT d.*, e.subject, e.from_address
|
||||
FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id
|
||||
WHERE d.webhook_id = ? ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(id, limit, offset)
|
||||
.all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 手动测试投递(使用最近一封邮件,若无则发空壳测试事件) */
|
||||
app.post("/:id/test", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const hook = await c.env.DB.prepare("SELECT id, endpoint, secret, enabled FROM webhooks WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ id: string; endpoint: string; secret: string; enabled: number }>();
|
||||
if (!hook) return c.json({ error: "not found" }, 404);
|
||||
const lastEmail = await c.env.DB.prepare(
|
||||
"SELECT id, mailbox_id, from_address, to_addresses, subject, text_body, received_at FROM emails ORDER BY received_at DESC LIMIT 1",
|
||||
).first<{ id: string; mailbox_id: string | null; from_address: string | null; to_addresses: string; subject: string | null; text_body: string | null; received_at: string }>();
|
||||
const mailbox = lastEmail?.mailbox_id
|
||||
? (await c.env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?").bind(lastEmail.mailbox_id).first<{ address: string }>())?.address ?? null
|
||||
: null;
|
||||
const payload = lastEmail
|
||||
? buildWebhookPayload({ id: lastEmail.id, mailboxAddress: mailbox, fromAddress: lastEmail.from_address, toAddresses: lastEmail.to_addresses, subject: lastEmail.subject, textBody: lastEmail.text_body, receivedAt: lastEmail.received_at })
|
||||
: {
|
||||
event: "email.received",
|
||||
id: "test_" + crypto.randomUUID().slice(0, 8),
|
||||
mailbox: "test@example.com",
|
||||
from: "console@test.local",
|
||||
to: ["test@example.com"],
|
||||
subject: "WmailHub webhook test",
|
||||
text: "This is a manual test delivery.",
|
||||
received_at: nowIso(),
|
||||
};
|
||||
const result = await attemptDelivery(
|
||||
{ id: hook.id, endpoint: hook.endpoint, secret: hook.secret },
|
||||
"test",
|
||||
payload,
|
||||
undefined,
|
||||
c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1",
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/** 重投:把失败 delivery 重置为立即重试 */
|
||||
app.post("/deliveries/:deliveryId/redeliver", async (c) => {
|
||||
const deliveryId = c.req.param("deliveryId");
|
||||
const res = await c.env.DB.prepare(
|
||||
"UPDATE webhook_deliveries SET status = 'pending', next_retry_at = ?, last_error = NULL WHERE id = ?",
|
||||
)
|
||||
.bind(nowIso(), deliveryId)
|
||||
.run();
|
||||
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -118,8 +118,18 @@ export class RealCFApi implements CFApi {
|
||||
return this.api<CFAccount[]>(token, "/accounts");
|
||||
}
|
||||
|
||||
listZones(token: string, accountId: string) {
|
||||
return this.api<CFZone[]>(token, `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50`);
|
||||
async listZones(token: string, accountId: string) {
|
||||
// 分页拉全账号下的 Zone(每页 50,最多 20 页)
|
||||
const out: CFZone[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const rows = await this.api<CFZone[]>(
|
||||
token,
|
||||
`/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`,
|
||||
);
|
||||
out.push(...rows);
|
||||
if (rows.length < 50) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async putEmailWorker(opts: {
|
||||
@@ -199,13 +209,11 @@ export class SimCFApi implements CFApi {
|
||||
}
|
||||
|
||||
async listZones(_token: string, accountId: string): Promise<CFZone[]> {
|
||||
// sim 返回多个演示域名,覆盖「OAuth 后自动同步全部域名」的展示
|
||||
const zones = [
|
||||
{
|
||||
id: "sim-zone-example.test",
|
||||
name: "example.test",
|
||||
account_id: accountId,
|
||||
status: "active",
|
||||
},
|
||||
{ id: "sim-zone-example.test", name: "example.test", account_id: accountId, status: "active" },
|
||||
{ id: "sim-zone-demo.test", name: "demo.test", account_id: accountId, status: "active" },
|
||||
{ id: "sim-zone-mailbox.dev", name: "mailbox.dev", account_id: accountId, status: "active" },
|
||||
];
|
||||
for (const z of zones) await this.record("zone", z.id, z);
|
||||
return zones;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
import { enqueueDeliveryForEmail } from "./webhook_delivery.ts";
|
||||
|
||||
/** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/
|
||||
interface ParsedMail {
|
||||
@@ -191,15 +192,8 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
|
||||
.run();
|
||||
}
|
||||
|
||||
// Webhook delivery(邮件保存成功后创建;投递失败不影响入库)
|
||||
await createDeliveriesForEmail(env, emailId, {
|
||||
mailboxAddress: await mailboxAddress(env, mailboxId),
|
||||
from: fromAddress,
|
||||
to: toAddresses,
|
||||
subject: parsed?.subject ?? "(unparsable mail)",
|
||||
text: parsed?.text ?? null,
|
||||
receivedAt,
|
||||
});
|
||||
// Webhook 投递(按 邮箱->域名->全局 解析;未配置则不入队;投递失败不影响入库)
|
||||
await enqueueDeliveryForEmail(env, emailId, mailboxId);
|
||||
|
||||
return {
|
||||
id: emailId,
|
||||
@@ -245,37 +239,3 @@ export async function resolveMailbox(env: Env, addresses: string[]): Promise<str
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mailboxAddress(env: Env, mailboxId: string | null): Promise<string | null> {
|
||||
if (!mailboxId) return null;
|
||||
const row = await env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?")
|
||||
.bind(mailboxId)
|
||||
.first<{ address: string }>();
|
||||
return row?.address ?? null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为新邮件创建全部启用 webhook 的 delivery(pending,立即可投)
|
||||
*/
|
||||
export async function createDeliveriesForEmail(
|
||||
env: Env,
|
||||
emailId: string,
|
||||
ctx: {
|
||||
mailboxAddress: string | null;
|
||||
from?: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
text: string | null;
|
||||
receivedAt: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const hooks = await env.DB.prepare("SELECT id FROM webhooks WHERE enabled = 1").all<{ id: string }>();
|
||||
const now = nowIso();
|
||||
for (const h of hooks.results) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, next_retry_at, created_at) VALUES (?, ?, ?, 'pending', 0, ?, ?)",
|
||||
)
|
||||
.bind(ids.delivery(), h.id, emailId, now, now)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
/**
|
||||
* Node 自动安装管线(docs/02 流程):
|
||||
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
|
||||
* 创建 Domain/Mailbox -> Node Ready。
|
||||
* 账号(Node)自动安装管线(v1.1.0):
|
||||
* 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
|
||||
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
|
||||
* Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行,
|
||||
* 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { getCFApi, type CFApi } from "./cfapi.ts";
|
||||
import { encryptSecret, decryptSecret } from "../db.ts";
|
||||
import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
|
||||
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 安装步骤定义(顺序即展示顺序) **/
|
||||
/** # 成员变量: 账号安装步骤定义(顺序即展示顺序) **/
|
||||
export const PROVISION_STEPS = [
|
||||
{ key: "oauth", label: "Cloudflare OAuth 授权" },
|
||||
{ key: "identity", label: "Node identity 与 credential" },
|
||||
{ key: "discover", label: "Account / Zone 发现" },
|
||||
{ key: "email_worker", label: "Email Worker 创建/更新" },
|
||||
{ key: "email_routing", label: "Email Routing 配置" },
|
||||
{ key: "domains", label: "Domain 创建" },
|
||||
{ key: "mailboxes", label: "Mailbox 创建" },
|
||||
{ key: "domains", label: "域名与 catch-all 邮箱同步" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
@@ -38,14 +36,14 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
*/
|
||||
export async function createNode(
|
||||
@@ -70,7 +68,7 @@ export async function createNode(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptText(env, ingressSecret),
|
||||
await encryptSecret(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
@@ -117,7 +115,7 @@ export async function enrollNode(
|
||||
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"UPDATE nodes SET credential_id = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
|
||||
"UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
@@ -126,19 +124,11 @@ export async function enrollNode(
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptText(env, node.ingress_secret_enc),
|
||||
ingress_secret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
console_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function encryptText(env: Env, text: string): Promise<string> {
|
||||
return encryptSecret(env, text);
|
||||
}
|
||||
|
||||
async function decryptText(env: Env, blob: string): Promise<string> {
|
||||
return decryptSecret(env, blob);
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权完成后的完整安装流程参数 **/
|
||||
export interface ProvisionInput {
|
||||
nodeId: string;
|
||||
@@ -147,17 +137,15 @@ export interface ProvisionInput {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
|
||||
* 返回是否全部成功;失败时步骤状态里带 error。
|
||||
* OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。
|
||||
* 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。
|
||||
*/
|
||||
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, name, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
|
||||
)
|
||||
const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?")
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node) return { ok: false, error: "node not found" };
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!node) return { ok: false, error: "account not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
const mark = async (key: string, status: StepStatus, error?: string) => {
|
||||
@@ -175,7 +163,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
|
||||
// 2. Node identity/credential(在添加账号与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. Account/Zone 发现
|
||||
@@ -185,47 +173,25 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
const account = input.accountId
|
||||
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
|
||||
: accounts[0];
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, nowIso(), input.nodeId)
|
||||
// 自动创建的账号以 Cloudflare 账户名命名
|
||||
const newName = node.name.startsWith("Cloudflare 账号") ? account.name : node.name;
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, newName, nowIso(), input.nodeId)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, account.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 4. Email Worker(幂等上传)
|
||||
await mark("email_worker", "running");
|
||||
const workerName = emailWorkerNameFor(input.nodeId);
|
||||
const ingressSecret = await decryptText(env, node.ingress_secret_enc);
|
||||
await cf.putEmailWorker({
|
||||
accessToken: input.accessToken,
|
||||
accountId: account.id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret,
|
||||
});
|
||||
await mark("email_worker", "done");
|
||||
|
||||
// 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain
|
||||
await mark("email_routing", "running");
|
||||
// 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
await mark("domains", "running");
|
||||
for (const zone of zones) {
|
||||
await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id });
|
||||
await cf.setCatchAllRule({
|
||||
accessToken: input.accessToken,
|
||||
zoneId: zone.id,
|
||||
workerName,
|
||||
});
|
||||
await upsertDomain(env, {
|
||||
cloudflareAccountId: account.id,
|
||||
nodeId: input.nodeId,
|
||||
name: zone.name,
|
||||
zoneId: zone.id,
|
||||
workerId: workerName,
|
||||
workerId: null,
|
||||
});
|
||||
}
|
||||
await mark("email_routing", "done");
|
||||
await mark("domains", "done");
|
||||
|
||||
// 7. 每个 Domain 创建 catch-all Mailbox(*@domain)
|
||||
await mark("mailboxes", "running");
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
|
||||
.bind(input.nodeId)
|
||||
.all<{ id: string; name: string }>();
|
||||
@@ -236,7 +202,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
isCatchAll: true,
|
||||
});
|
||||
}
|
||||
await mark("mailboxes", "done");
|
||||
await mark("domains", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
@@ -258,6 +224,56 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为单个域名配置 Email Workers:部署账号级 Email Worker -> 开启 Email Routing ->
|
||||
* catch-all 规则指向 Worker -> 域名置 active。幂等,失败信息写入 domains.setup_error。
|
||||
*/
|
||||
export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const domain = await env.DB.prepare(
|
||||
"SELECT id, name, zone_id, node_id FROM domains WHERE id = ?",
|
||||
)
|
||||
.bind(domainId)
|
||||
.first<{ id: string; name: string; zone_id: string | null; node_id: string | null }>();
|
||||
if (!domain) return { ok: false, error: "domain not found" };
|
||||
if (!domain.node_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
|
||||
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, cloudflare_account_id, ingress_url, ingress_secret_enc FROM nodes WHERE id = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(domain.node_id)
|
||||
.first<{ id: string; cloudflare_account_id: string | null; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node?.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 cf: CFApi = getCFApi(env, env.DB);
|
||||
try {
|
||||
const workerName = emailWorkerNameFor(node.id);
|
||||
await cf.putEmailWorker({
|
||||
accessToken,
|
||||
accountId: node.cloudflare_account_id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
});
|
||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName });
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(workerName, nowIso(), domainId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await env.DB.prepare("UPDATE domains SET status = 'error', setup_error = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(message, nowIso(), domainId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
*/
|
||||
@@ -274,7 +290,7 @@ export async function loadSteps(env: Env, nodeId: string) {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)
|
||||
* upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
@@ -283,28 +299,35 @@ export async function upsertDomain(
|
||||
nodeId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string;
|
||||
workerId: string | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
"SELECT id, worker_id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
)
|
||||
.bind(d.cloudflareAccountId, d.name)
|
||||
.first<{ id: string }>();
|
||||
.first<{ id: string; worker_id: string | null }>();
|
||||
const now = nowIso();
|
||||
if (existing) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
|
||||
.run();
|
||||
// 已配置过 Worker 的域名不回退状态;仅当新配置带 worker 时更新
|
||||
if (existing.worker_id || !d.workerId) {
|
||||
await env.DB.prepare("UPDATE domains SET node_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(d.nodeId, d.zoneId, now, existing.id)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
|
||||
.run();
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const id = ids.domain();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,208 @@
|
||||
/**
|
||||
* Webhook 投递与重试(docs/06、docs/07):
|
||||
* Webhook 投递(docs/06、docs/07,v1.1.0 三级模型):
|
||||
* - 配置解析优先级:邮箱 -> 域名 -> 全局(settings),命中即止
|
||||
* - 每级配置:{ url, headers(自定义请求头 JSON), key }
|
||||
* key 默认以 `Authorization: Bearer <key>` 携带(headers 里显式给了 Authorization 则不覆盖),
|
||||
* 并用于投递签名:signature = HMAC-SHA256(key, `${timestamp}.${rawBody}`)
|
||||
* - payload 按文档 JSON 结构(event=email.received)
|
||||
* - 签名头:X-WmailHub-Event / Delivery / Timestamp / Signature
|
||||
* signature = HMAC-SHA256(webhook_secret, `${timestamp}.${rawBody}`)
|
||||
* - 2xx 成功;5xx/超时重试(指数退避);4xx 记失败并按策略重试到上限
|
||||
* - 2xx 成功;失败重试(指数退避);投递行在入队时固化目标配置,重试不跟随后续配置变更
|
||||
* - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时)
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import {
|
||||
hmacSha256Hex,
|
||||
aesEncrypt,
|
||||
aesDecrypt,
|
||||
assertWebhookUrlSafe,
|
||||
ids,
|
||||
type HostResolver,
|
||||
WEBHOOK_HEADERS,
|
||||
WEBHOOK_TIMEOUT_MS,
|
||||
WEBHOOK_MAX_ATTEMPTS,
|
||||
WEBHOOK_RETRY_BASE_MS,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 单级 Webhook 配置(解析结果) **/
|
||||
export interface WebhookConfig {
|
||||
/** 配置来源层级 **/
|
||||
targetType: "mailbox" | "domain" | "global";
|
||||
/** 目标 URL **/
|
||||
url: string;
|
||||
/** 自定义请求头 **/
|
||||
headers: Record<string, string>;
|
||||
/** key 明文(签名与 Authorization;可能为空串) **/
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: settings 表中全局 Webhook 的存储键 **/
|
||||
export const GLOBAL_WEBHOOK_SETTING = "global_webhook";
|
||||
|
||||
/** # 函数
|
||||
* 校验并规整自定义请求头:键名限 [A-Za-z0-9-],值转字符串;非法抛错
|
||||
* 参数: headers - 前端传入的对象
|
||||
* 返回值: 规整后的头(空对象返回 {})
|
||||
*/
|
||||
export function normalizeWebhookHeaders(headers: unknown): Record<string, string> {
|
||||
if (headers === undefined || headers === null) return {};
|
||||
if (typeof headers !== "object" || Array.isArray(headers)) throw new Error("headers must be an object");
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (!/^[A-Za-z0-9-]{1,64}$/.test(k)) throw new Error(`invalid header name: ${k}`);
|
||||
if (v === undefined || v === null) continue;
|
||||
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
||||
throw new Error(`invalid header value for: ${k}`);
|
||||
}
|
||||
out[k] = String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeParseHeaders(s: string | null): Record<string, string> {
|
||||
if (!s) return {};
|
||||
try {
|
||||
return normalizeWebhookHeaders(JSON.parse(s));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: 邮箱/域名行的 Webhook 列形态 **/
|
||||
interface WebhookColumns {
|
||||
webhook_url: string | null;
|
||||
webhook_headers: string | null;
|
||||
webhook_key_enc: string | null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 三级 Webhook 解析:邮箱 -> 域名 -> 全局(settings),命中第一级即返回
|
||||
* 参数: env - 运行环境;mailboxId - 邮件命中的邮箱(可为 null)
|
||||
* 返回值: 命中的配置;三级都未配置时 null(不投递)
|
||||
*/
|
||||
export async function resolveWebhookConfig(env: Env, mailboxId: string | null): Promise<WebhookConfig | null> {
|
||||
if (mailboxId) {
|
||||
const mb = await env.DB.prepare(
|
||||
"SELECT webhook_url, webhook_headers, webhook_key_enc, domain_id FROM mailboxes WHERE id = ?",
|
||||
)
|
||||
.bind(mailboxId)
|
||||
.first<WebhookColumns & { domain_id: string }>();
|
||||
if (mb?.webhook_url) {
|
||||
return {
|
||||
targetType: "mailbox",
|
||||
url: mb.webhook_url,
|
||||
headers: safeParseHeaders(mb.webhook_headers),
|
||||
key: mb.webhook_key_enc ? await decryptSecret(env, mb.webhook_key_enc) : "",
|
||||
};
|
||||
}
|
||||
if (mb?.domain_id) {
|
||||
const dom = await env.DB.prepare(
|
||||
"SELECT webhook_url, webhook_headers, webhook_key_enc FROM domains WHERE id = ?",
|
||||
)
|
||||
.bind(mb.domain_id)
|
||||
.first<WebhookColumns>();
|
||||
if (dom?.webhook_url) {
|
||||
return {
|
||||
targetType: "domain",
|
||||
url: dom.webhook_url,
|
||||
headers: safeParseHeaders(dom.webhook_headers),
|
||||
key: dom.webhook_key_enc ? await decryptSecret(env, dom.webhook_key_enc) : "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const raw = await getSetting(env, GLOBAL_WEBHOOK_SETTING);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cfg = JSON.parse(raw) as { url?: string; headers?: Record<string, string> };
|
||||
if (!cfg.url) return null;
|
||||
return { targetType: "global", url: cfg.url, headers: cfg.headers ?? {}, key: await getGlobalWebhookKey(env) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取全局 Webhook key(密文存 settings)
|
||||
*/
|
||||
export async function getGlobalWebhookKey(env: Env): Promise<string> {
|
||||
const enc = await getSetting(env, "global_webhook_key_enc");
|
||||
return enc ? await decryptSecret(env, enc) : "";
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 保存全局 Webhook 配置到 settings(url/headers 明文 JSON,key 加密)
|
||||
* 参数: cfg.url 为空串表示停用全局 Webhook;key 为 undefined 表示保留旧 key
|
||||
*/
|
||||
export async function saveGlobalWebhook(
|
||||
env: Env,
|
||||
cfg: { url: string; headers?: Record<string, string>; key?: string },
|
||||
): Promise<void> {
|
||||
await setSetting(
|
||||
env,
|
||||
GLOBAL_WEBHOOK_SETTING,
|
||||
JSON.stringify({ url: cfg.url, headers: cfg.headers ?? {} }),
|
||||
);
|
||||
if (cfg.key !== undefined) {
|
||||
if (cfg.key === "") {
|
||||
await env.DB.prepare("DELETE FROM settings WHERE key = 'global_webhook_key_enc'").run();
|
||||
} else {
|
||||
await setSetting(env, "global_webhook_key_enc", await encryptSecret(env, cfg.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 保存邮箱/域名级 Webhook 配置(url 为空串表示清除该级配置)
|
||||
*/
|
||||
export async function saveScopedWebhook(
|
||||
env: Env,
|
||||
table: "mailboxes" | "domains",
|
||||
id: string,
|
||||
cfg: { url: string; headers?: Record<string, string>; key?: string },
|
||||
): Promise<void> {
|
||||
if (!["mailboxes", "domains"].includes(table)) throw new Error("bad table");
|
||||
const headersJson = JSON.stringify(cfg.headers ?? {});
|
||||
const keyEnc = cfg.key !== undefined ? (cfg.key === "" ? null : await encryptSecret(env, cfg.key)) : undefined;
|
||||
if (!cfg.url) {
|
||||
// 清除该级配置
|
||||
await env.DB.prepare(
|
||||
`UPDATE ${table} SET webhook_url = NULL, webhook_headers = NULL, webhook_key_enc = NULL, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
if (keyEnc === undefined) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(cfg.url, headersJson, nowIso(), id)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, webhook_key_enc = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(cfg.url, headersJson, keyEnc, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* webhook key 入库密文(供入队时固化)
|
||||
*/
|
||||
async function encryptWebhookKey(env: Env, key: string): Promise<string | null> {
|
||||
return key ? aesEncrypt(await getAesKey(env), key) : null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解密投递行固化的 key
|
||||
*/
|
||||
async function decryptWebhookKey(env: Env, blob: string | null): Promise<string> {
|
||||
return blob ? await aesDecrypt(await getAesKey(env), blob) : "";
|
||||
}
|
||||
|
||||
/** # 成员变量: Webhook 投递 payload 结构(docs/07) **/
|
||||
export function buildWebhookPayload(email: {
|
||||
id: string;
|
||||
@@ -53,30 +237,43 @@ function safeParseArray(s: string | null): string[] {
|
||||
|
||||
/** # 函数
|
||||
* 执行一次投递;返回是否成功与错误信息(不修改库)
|
||||
* 参数: config - 固化在投递行上的目标配置
|
||||
*/
|
||||
export async function attemptDelivery(
|
||||
webhook: { id: string; endpoint: string; secret: string },
|
||||
config: { url: string; headers: Record<string, string>; key: string },
|
||||
deliveryId: string,
|
||||
payload: Record<string, unknown>,
|
||||
resolve?: HostResolver,
|
||||
allowPrivateHosts = false,
|
||||
): Promise<{ ok: boolean; status?: number; error?: string }> {
|
||||
const unsafe = await assertWebhookUrlSafe(webhook.endpoint, resolve, allowPrivateHosts);
|
||||
const unsafe = await assertWebhookUrlSafe(config.url, resolve, allowPrivateHosts);
|
||||
if (unsafe) return { ok: false, error: `ssrf guard: ${unsafe}` };
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const signature = await hmacSha256Hex(webhook.secret, `${timestamp}.${body}`);
|
||||
|
||||
// 组装请求头:标准头 -> key 的默认 Authorization -> 用户自定义头(可覆盖标准头,但不能去 Content-Type)
|
||||
// 未配置 key 时无法协商签名密钥,跳过签名头(仅保留事件标识)
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
[WEBHOOK_HEADERS.event]: "email.received",
|
||||
[WEBHOOK_HEADERS.delivery]: deliveryId,
|
||||
};
|
||||
if (config.key) {
|
||||
headers[WEBHOOK_HEADERS.timestamp] = timestamp;
|
||||
headers[WEBHOOK_HEADERS.signature] = await hmacSha256Hex(config.key, `${timestamp}.${body}`);
|
||||
}
|
||||
const customHasAuth = Object.keys(config.headers).some((k) => k.toLowerCase() === "authorization");
|
||||
if (config.key && !customHasAuth) headers["Authorization"] = `Bearer ${config.key}`;
|
||||
for (const [k, v] of Object.entries(config.headers)) {
|
||||
if (k.toLowerCase() === "content-type") continue;
|
||||
headers[k] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(webhook.endpoint, {
|
||||
const resp = await fetch(config.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[WEBHOOK_HEADERS.event]: "email.received",
|
||||
[WEBHOOK_HEADERS.delivery]: deliveryId,
|
||||
[WEBHOOK_HEADERS.timestamp]: timestamp,
|
||||
[WEBHOOK_HEADERS.signature]: signature,
|
||||
},
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
||||
});
|
||||
@@ -95,6 +292,23 @@ export function nextRetryIso(attemptCount: number): string {
|
||||
return new Date(Date.now() + delay).toISOString();
|
||||
}
|
||||
|
||||
/** # 成员变量: 投递循环的行结构 **/
|
||||
interface DueDeliveryRow {
|
||||
delivery_id: string;
|
||||
url: string;
|
||||
headers: string | null;
|
||||
key_enc: string | null;
|
||||
attempt_count: number;
|
||||
email_id: string;
|
||||
mailbox_id: string | null;
|
||||
from_address: string | null;
|
||||
to_addresses: string;
|
||||
subject: string | null;
|
||||
text_body: string | null;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Cron 驱动的投递循环:取到期 delivery 逐个投递并落状态。
|
||||
* 返回处理统计(供日志与 admin 接口)。
|
||||
@@ -107,11 +321,10 @@ export async function processDueDeliveries(
|
||||
): Promise<{ processed: number; success: number; failed: number; exhausted: number }> {
|
||||
const now = nowIso();
|
||||
const due = await env.DB.prepare(
|
||||
`SELECT d.id AS delivery_id, d.attempt_count, w.id AS webhook_id, w.endpoint, w.secret, w.enabled,
|
||||
`SELECT d.id AS delivery_id, d.url, d.headers, d.key_enc, d.attempt_count,
|
||||
e.id AS email_id, e.mailbox_id, e.from_address, e.to_addresses, e.subject, e.text_body,
|
||||
e.received_at, e.expires_at
|
||||
FROM webhook_deliveries d
|
||||
JOIN webhooks w ON w.id = d.webhook_id
|
||||
JOIN emails e ON e.id = d.email_id
|
||||
WHERE d.status IN ('pending', 'failed')
|
||||
AND d.attempt_count < ?
|
||||
@@ -120,22 +333,7 @@ export async function processDueDeliveries(
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(WEBHOOK_MAX_ATTEMPTS, now, limit)
|
||||
.all<{
|
||||
delivery_id: string;
|
||||
attempt_count: number;
|
||||
webhook_id: string;
|
||||
endpoint: string;
|
||||
secret: string;
|
||||
enabled: number;
|
||||
email_id: string;
|
||||
mailbox_id: string | null;
|
||||
from_address: string | null;
|
||||
to_addresses: string;
|
||||
subject: string | null;
|
||||
text_body: string | null;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
}>();
|
||||
.all<DueDeliveryRow>();
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
@@ -149,7 +347,6 @@ export async function processDueDeliveries(
|
||||
exhausted++;
|
||||
continue;
|
||||
}
|
||||
if (!row.enabled) continue;
|
||||
|
||||
const mailbox = row.mailbox_id
|
||||
? (
|
||||
@@ -168,7 +365,7 @@ export async function processDueDeliveries(
|
||||
receivedAt: row.received_at,
|
||||
});
|
||||
const result = await attemptDelivery(
|
||||
{ id: row.webhook_id, endpoint: row.endpoint, secret: row.secret },
|
||||
{ url: row.url, headers: safeParseHeaders(row.headers), key: await decryptWebhookKey(env, row.key_enc) },
|
||||
row.delivery_id,
|
||||
payload,
|
||||
resolve,
|
||||
@@ -200,3 +397,32 @@ export async function processDueDeliveries(
|
||||
}
|
||||
return { processed: due.results.length, success, failed, exhausted };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 入队:为邮件按三级解析结果创建一条投递记录(未配置任何 Webhook 则不入队)
|
||||
* 返回值: 命中的层级或 null
|
||||
*/
|
||||
export async function enqueueDeliveryForEmail(
|
||||
env: Env,
|
||||
emailId: string,
|
||||
mailboxId: string | null,
|
||||
): Promise<WebhookConfig["targetType"] | null> {
|
||||
const config = await resolveWebhookConfig(env, mailboxId);
|
||||
if (!config) return null;
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO webhook_deliveries (id, email_id, target_type, url, headers, key_enc, status, attempt_count, next_retry_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
ids.delivery(),
|
||||
emailId,
|
||||
config.targetType,
|
||||
config.url,
|
||||
JSON.stringify(config.headers),
|
||||
await encryptWebhookKey(env, config.key),
|
||||
nowIso(),
|
||||
nowIso(),
|
||||
)
|
||||
.run();
|
||||
return config.targetType;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user