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

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

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

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

文档: docs/02 补「三种开启接口只有 /enable 有效」的实测表、2008 处理、
两种接入方式与按账号取 token 的说明
2026-09-30 18:20:44 +08:00

383 lines
13 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_total: number;
accounts_active: number;
domains: number;
domains_active: number;
mailboxes: number;
emails: number;
emails_expired: number;
global_webhook: number;
deliveries_pending: number;
oauth_connections: number;
worker_credentials: number;
recent_emails: {
id: string;
subject: string;
from_address: string;
to_addresses: 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");
/* ---- 账号(Cloudflare 账户) ---- */
export interface AccountRow {
id: string;
name: string;
cloudflare_account_id: string | null;
worker_id: string | null;
status: string;
created_at: string;
}
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
export interface AccountDetail {
account: AccountRow;
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 }[];
credential: { id: string; worker_id: string; secret_last4: string; status: string; created_at: string } | null;
}
export const getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
export const updateAccount = (id: string, body: { name?: string }) =>
api(`/api/accounts/${id}`, "PATCH", body);
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
/** 用 Cloudflare API Token 添加账号(与 OAuth 并列的第二种接入方式)。
* 后端会先用 token 探一次 Cloudflare 确认可用、且与填写的账户 ID 一致,再建账号并自动接入。 */
export const createAccountByToken = (body: {
name: string;
cloudflare_account_id?: string;
token: string;
}) =>
api<{ ok: boolean; account_id: string; error?: string }>("/api/accounts/token", "POST", body);
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
export const rotateAccount = (id: string) =>
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
`/api/accounts/${id}/rotate`,
"POST",
);
/** 本地/运维:查看账号当前 Worker credential(secret 用于部署/联调) */
export const getAccountWorkerSecret = (id: string) =>
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
/* ---- OAuth ---- */
/** OAuth 是否已配置(cloudflare = 可用;unconfigured = 缺少 client id/secret) */
export const oauthMode = () =>
api<{ mode: "cloudflare" | "unconfigured"; authorize_url_base: string | null }>("/api/oauth/mode");
/** 开始授权(自动创建账号记录;授权后自动接入) */
export const oauthStart = () => api<{ url: string; state: string }>("/api/oauth/cloudflare/start");
/* ---- 域名 ---- */
export interface DomainRow {
id: string;
name: string;
status: string;
setup_error: string | null;
worker_id: string | null;
zone_id: string | null;
account_id: string | null;
account_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: {
domain_id?: string;
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 BatchDeleteResult {
ok: boolean;
/** 成功删除的主记录数 **/
deleted: number;
/** 级联删除的邮箱数(域名批量删除时有值) **/
mailboxes?: number;
/** 级联删除的邮件数(域名/邮箱批量删除时有值) **/
emails?: number;
/** 删除失败的 id 及原因(不存在/已删除) **/
failed: { id: string; reason: string }[];
}
export const batchDeleteAccounts = (ids: string[]) =>
api<BatchDeleteResult>("/api/accounts/batch-delete", "POST", { ids });
export const batchDeleteDomains = (ids: string[]) =>
api<BatchDeleteResult>("/api/domains/batch-delete", "POST", { ids });
export const batchDeleteMailboxes = (ids: string[]) =>
api<BatchDeleteResult>("/api/mailboxes/batch-delete", "POST", { ids });
export const batchDeleteEmails = (ids: string[]) =>
api<BatchDeleteResult>("/api/emails/batch-delete", "POST", { ids });
/* ---- 设置 ---- */
export interface SettingsInfo {
username: string;
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
api_key: { has_key: boolean; key_last4: string; created_at: string | null };
/** Cloudflare OAuth 客户端凭据元信息(不含明文 secret) */
cf_oauth: {
client_id: string;
has_secret: boolean;
secret_last4: string;
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
source: "settings" | "env" | "none";
/** 实际请求的 scope(点号分隔,空格分隔多个) */
scope: string;
};
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
redirect_uri: string;
}
export const getSettings = () => api<SettingsInfo>("/api/settings");
/** 保存设置
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */
export const putSettings = (body: {
webhook?: { url: string; headers: Record<string, string>; key?: string };
cf_oauth?: { client_id: string; secret?: string; scope?: string };
}) => api("/api/settings", "PUT", body);
/** # 成员变量: 授权配置自检结果 **/
export interface OAuthPreflightResult {
ok: boolean;
location: string;
error: string | null;
description: string | null;
hint: string | null;
used: { client_id: string; redirect_uri: string; scope: string };
/** scope 探测:allowed=App 已授权、denied=还需在选择器里补勾 */
scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null };
}
/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */
export const CF_OAUTH_SCOPES_DEFAULT =
"zone.read workers-scripts.write email-routing-rule.write zone-settings.write";
/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底不受影响) */
export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE");
export const changePassword = (old_password: string, new_password: string) =>
api("/api/settings/password", "PUT", { old_password, new_password });
/* ---- API/MCP Key(单一 Key,设置模态窗口内使用;API 与 MCP 共用) ---- */
/** 生成(轮换)Key:明文仅本次返回 */
export const createApiKey = () =>
api<{ ok: boolean; key: string; created_at: string }>("/api/settings/api-key", "POST");
/** 撤销并删除 Key */
export const deleteApiKey = () => api("/api/settings/api-key", "DELETE");
/* ---- 随机邮箱名 ---- */
/** # 成员变量: 随机用户名来源(namedb,返回 { username, first, last, gender }) **/
const NAMEDB_URL = "https://namedb.adx9.com/api/username";
/** 从 namedb 拉一个随机用户名(用于邮箱地址的本地部分)。
* 返回值: 用户名字符串;失败时抛错,由调用方决定提示文案。 */
export async function fetchRandomUsername(): Promise<string> {
const resp = await fetch(NAMEDB_URL);
if (!resp.ok) throw new Error(`随机用户名服务返回 ${resp.status}`);
const data = (await resp.json()) as { username?: string };
const name = (data.username ?? "").trim();
if (!name) throw new Error("随机用户名服务没有返回可用名字");
return name;
}