问题 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 的说明
383 lines
13 KiB
TypeScript
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;
|
|
}
|