- 账号化:节点更名账号,/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
295 lines
9.4 KiB
TypeScript
295 lines
9.4 KiB
TypeScript
/** Console API 客户端:统一 JSON 请求、错误处理、401 跳登录 */
|
|
|
|
/** # 成员变量: 未登录回调(由应用注册,跳转登录页) **/
|
|
let onUnauthorized: (() => void) | null = null;
|
|
|
|
/** # 函数
|
|
* 注册 401 处理器
|
|
*/
|
|
export function setUnauthorizedHandler(fn: () => void): void {
|
|
onUnauthorized = fn;
|
|
}
|
|
|
|
/** # 函数
|
|
* 通用请求封装
|
|
* 参数: method/url/body(JSON)
|
|
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError
|
|
*/
|
|
export async function api<T = unknown>(
|
|
url: string,
|
|
method: string = "GET",
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
const resp = await fetch(url, {
|
|
method,
|
|
credentials: "include",
|
|
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (resp.status === 401) {
|
|
onUnauthorized?.();
|
|
throw new ApiError(401, "未登录或会话已过期");
|
|
}
|
|
const text = await resp.text();
|
|
let data: unknown = null;
|
|
try {
|
|
data = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
data = { raw: text };
|
|
}
|
|
if (!resp.ok) {
|
|
const message =
|
|
(data as { error?: string } | null)?.error ?? `请求失败 (${resp.status})`;
|
|
throw new ApiError(resp.status, message, data);
|
|
}
|
|
return data as T;
|
|
}
|
|
|
|
/** # 成员变量: API 错误 **/
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
message: string,
|
|
public data?: unknown,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/** # 函数
|
|
* 分页查询串拼接
|
|
*/
|
|
export function qs(params: Record<string, string | number | undefined>): string {
|
|
const sp = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v !== undefined && v !== "") sp.set(k, String(v));
|
|
}
|
|
const s = sp.toString();
|
|
return s ? `?${s}` : "";
|
|
}
|
|
|
|
/* ---- 会话 ---- */
|
|
|
|
/** 登录 */
|
|
export const login = (username: string, password: string) =>
|
|
api<{ ok: boolean; user: { id: string; username: string } }>("/api/auth/login", "POST", { username, password });
|
|
|
|
/** 登出 */
|
|
export const logout = () => api("/api/auth/logout", "POST");
|
|
|
|
/** 当前用户 */
|
|
export const me = () => api<{ user: { id: string; username: string } }>("/api/auth/me");
|
|
|
|
/** 健康检查(含版本号) */
|
|
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
|
|
|
|
/* ---- 概览 ---- */
|
|
|
|
/** Dashboard 统计 */
|
|
export const stats = () =>
|
|
api<{
|
|
accounts_online: number;
|
|
accounts_total: number;
|
|
domains: number;
|
|
domains_active: number;
|
|
mailboxes: number;
|
|
emails: number;
|
|
emails_expired: number;
|
|
global_webhook: number;
|
|
deliveries_pending: number;
|
|
oauth_connections: number;
|
|
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
|
|
}>("/api/admin/stats");
|
|
|
|
/** 手动触发 Cron */
|
|
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
|
|
|
|
/* ---- 账号(原节点) ---- */
|
|
|
|
export interface AccountRow {
|
|
id: string;
|
|
name: string;
|
|
cloudflare_account_id: string | null;
|
|
status: string;
|
|
version: string | null;
|
|
last_seen_at: string | null;
|
|
ingress_url: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
|
|
|
|
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 getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
|
|
|
|
export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
|
|
api(`/api/accounts/${id}`, "PATCH", body);
|
|
|
|
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
|
|
|
|
export const rotateAccount = (id: string) =>
|
|
api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
|
|
|
|
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");
|
|
|
|
/** 开始授权: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 });
|
|
|
|
/* ---- 域名 ---- */
|
|
|
|
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;
|
|
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 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");
|
|
|
|
/* ---- 邮箱 ---- */
|
|
|
|
export interface MailboxRow {
|
|
id: string;
|
|
address: string;
|
|
status: string;
|
|
is_catch_all: number;
|
|
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;
|
|
}
|
|
|
|
export const listMailboxes = (domainId?: string) =>
|
|
api<{ items: MailboxRow[]; total: number }>(`/api/mailboxes${qs({ domain_id: domainId })}`);
|
|
|
|
export const createMailbox = (body: { domain_id: string; address: string; retention_seconds?: number; catch_all?: boolean }) =>
|
|
api<{ ok: boolean; id: string; address: string }>("/api/mailboxes", "POST", body);
|
|
|
|
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
|
|
api(`/api/mailboxes/${id}`, "PATCH", body);
|
|
|
|
/** 保存邮箱级 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");
|
|
|
|
/* ---- 邮件 ---- */
|
|
|
|
export interface EmailRow {
|
|
id: string;
|
|
mailbox_id: string | null;
|
|
mailbox_address: string | null;
|
|
message_id: string | null;
|
|
from_address: string | null;
|
|
to_addresses: string;
|
|
subject: string | null;
|
|
size: number;
|
|
received_at: string;
|
|
expires_at: string;
|
|
attachment_count: number;
|
|
}
|
|
|
|
export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) =>
|
|
api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
|
|
|
|
export interface EmailDetail {
|
|
email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string };
|
|
attachments: { id: string; filename: string; content_type: string; size: number; content_id: string | null }[];
|
|
}
|
|
|
|
export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
|
|
|
|
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
|
|
|
/* ---- 设置 ---- */
|
|
|
|
export interface SettingsInfo {
|
|
username: string;
|
|
default_ingress_url: string;
|
|
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
|
}
|
|
|
|
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
|
|
|
/** 保存设置;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 changePassword = (old_password: string, new_password: string) =>
|
|
api("/api/settings/password", "PUT", { old_password, new_password });
|
|
|
|
/* ---- API/MCP Keys(设置模态窗口内使用) ---- */
|
|
|
|
export interface KeyRow {
|
|
id: string;
|
|
kind: "api" | "mcp";
|
|
name: string;
|
|
key_last4: string;
|
|
created_at: string;
|
|
revoked_at: string | null;
|
|
last_used_at: string | null;
|
|
}
|
|
|
|
export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys");
|
|
|
|
export const createKey = (kind: "api" | "mcp", name?: string) =>
|
|
api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name });
|
|
|
|
export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST");
|
|
|
|
export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");
|