Files
wmailhub/console/frontend/src/api.ts
T
BigCode d4c1334143 feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
2026-09-30 03:34:37 +08:00

292 lines
8.8 KiB
TypeScript

/** Console API 客户端:统一 JSON 请求、错误处理、401 跳登录 */
/** # 成员变量: 未登录回调(由应用注册,跳转登录页) **/
let onUnauthorized: (() => void) | null = null;
/** # 函数
* 注册 401 处理器
*/
export function setUnauthorizedHandler(fn: () => void): void {
onUnauthorized = fn;
}
/** # 函数
* 通用请求封装
* 参数: method/url/body(JSON)/query
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError
*/
export async function api<T = unknown>(
url: string,
method: string = "GET",
body?: unknown,
): Promise<T> {
const resp = await fetch(url, {
method,
credentials: "include",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (resp.status === 401) {
onUnauthorized?.();
throw new ApiError(401, "未登录或会话已过期");
}
const text = await resp.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!resp.ok) {
const message =
(data as { error?: string } | null)?.error ?? `请求失败 (${resp.status})`;
throw new ApiError(resp.status, message, data);
}
return data as T;
}
/** # 成员变量: API 错误 **/
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public data?: unknown,
) {
super(message);
}
}
/** # 函数
* 分页查询串拼接
*/
export function qs(params: Record<string, string | number | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
/* ---- 具体接口封装 ---- */
/** 登录 */
export const login = (username: string, password: string) =>
api<{ ok: boolean; user: { id: string; username: string } }>("/api/auth/login", "POST", { username, password });
/** 登出 */
export const logout = () => api("/api/auth/logout", "POST");
/** 当前用户 */
export const me = () => api<{ user: { id: string; username: string } }>("/api/auth/me");
/** 健康检查(含版本号) */
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
/** Dashboard 统计 */
export const stats = () =>
api<{
nodes_online: number;
nodes_total: number;
domains: number;
mailboxes: number;
emails: number;
emails_expired: number;
webhooks: number;
deliveries_pending: number;
oauth_connections: number;
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
}>("/api/admin/stats");
/** 手动触发 Cron */
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
/* Nodes */
export interface NodeRow {
id: string;
name: string;
cloudflare_account_id: string | null;
status: string;
version: string | null;
last_seen_at: string | null;
ingress_url: string;
created_at: string;
}
export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes");
export const createNode = (name: string, ingress_url: string) =>
api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>(
"/api/nodes",
"POST",
{ name, ingress_url },
);
export interface NodeDetail {
node: NodeRow & { enrollment_expires_at: string | null };
steps: Record<string, { status: string; error?: string }>;
oauth: { id: string; account_id: string | null; created_at: string } | null;
domains: { id: string; name: string; status: string; worker_id: string | null }[];
}
export const getNode = (id: string) => api<NodeDetail>(`/api/nodes/${id}`);
export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE");
export const rotateNode = (id: string) =>
api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST");
export const getNodeIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`);
/* OAuth */
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
export const oauthStart = (nodeId: string) =>
api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`);
export const oauthSimApprove = (state: string) =>
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
/* Domains */
export interface DomainRow {
id: string;
name: string;
status: string;
node_id: string | null;
node_name: string | null;
worker_id: string | null;
mailbox_count: number;
created_at: string;
}
export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains");
export const createDomain = (name: string) => api<{ ok: boolean; id: string }>("/api/domains", "POST", { name });
export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE");
/* Mailboxes */
export interface MailboxRow {
id: string;
address: string;
status: string;
is_catch_all: number;
retention_seconds: number | null;
domain_id: string;
domain_name: string;
email_count: number;
created_at: string;
}
export const listMailboxes = (domainId?: string) =>
api<{ items: MailboxRow[]; total: number }>(`/api/mailboxes${qs({ domain_id: domainId })}`);
export const createMailbox = (body: { domain_id: string; address: string; retention_seconds?: number; catch_all?: boolean }) =>
api<{ ok: boolean; id: string; address: string }>("/api/mailboxes", "POST", body);
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
api(`/api/mailboxes/${id}`, "PATCH", body);
export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE");
/* Emails */
export interface EmailRow {
id: string;
mailbox_id: string | null;
mailbox_address: string | null;
message_id: string | null;
from_address: string | null;
to_addresses: string;
subject: string | null;
size: number;
received_at: string;
expires_at: string;
attachment_count: number;
}
export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) =>
api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
export interface EmailDetail {
email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string };
attachments: { id: string; filename: string; content_type: string; size: number; content_id: string | null }[];
}
export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
/* Webhooks */
export interface WebhookRow {
id: string;
name: string;
endpoint: string;
enabled: number;
delivery_count: number;
success_count: number;
created_at: string;
}
export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks");
export const createWebhook = (name: string, endpoint: string) =>
api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint });
export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) =>
api(`/api/webhooks/${id}`, "PATCH", body);
export const rotateWebhookSecret = (id: string) =>
api<{ ok: boolean; secret: string }>(`/api/webhooks/${id}/rotate`, "POST");
export const deleteWebhook = (id: string) => api(`/api/webhooks/${id}`, "DELETE");
export const testWebhook = (id: string) =>
api<{ ok: boolean; status?: number; error?: string }>(`/api/webhooks/${id}/test`, "POST");
export interface DeliveryRow {
id: string;
webhook_id: string;
email_id: string;
status: string;
attempt_count: number;
next_retry_at: string | null;
last_error: string | null;
delivered_at: string | null;
created_at: string;
subject: string | null;
}
export const listDeliveries = (webhookId: string) =>
api<{ items: DeliveryRow[] }>(`/api/webhooks/${webhookId}/deliveries`);
export const redeliver = (deliveryId: string) =>
api(`/api/webhooks/deliveries/${deliveryId}/redeliver`, "POST");
/* API/MCP Keys */
export interface KeyRow {
id: string;
kind: "api" | "mcp";
name: string;
key_last4: string;
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
}
export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys");
export const createKey = (kind: "api" | "mcp", name?: string) =>
api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name });
export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST");
export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");