v2.6.0: 接入不再自动建 catch-all 邮箱(手动添加);删除账号/域名时清理 Cloudflare 侧资源(关闭 Email Routing + 删除 Email Worker,尽力而为+告警)
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts";
|
||||
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount, teardownAccountResources, teardownDomainRouting, type TeardownResult } from "../services/provision.ts";
|
||||
import { getCFApi } from "../services/cfapi.ts";
|
||||
import { logOperation, operatorName } from "../services/logs.ts";
|
||||
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
|
||||
@@ -119,14 +119,20 @@ async function deleteEmailsByMailbox(env: Env, mailboxId: string): Promise<numbe
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 软删账号:撤销 Worker credential + 标记 revoked(与 DELETE /accounts/:id 同一语义)
|
||||
* 返回值: { deleted } 为账号存在且未撤销时 true;name 用于操作日志展示
|
||||
* 软删账号:撤销 Worker credential + 标记 revoked(与 DELETE /accounts/:id 同一语义);
|
||||
* 同时尽力清理 Cloudflare 侧资源(各域名 Email Routing + 账号级 Email Worker 脚本),
|
||||
* 清理失败不阻断本地删除,错误文案随返回值带给前端。
|
||||
*/
|
||||
async function revokeAccount(env: Env, id: string): Promise<{ deleted: boolean; name: string | null }> {
|
||||
async function revokeAccount(
|
||||
env: Env,
|
||||
id: string,
|
||||
): Promise<{ deleted: boolean; name: string | null; cleanup: TeardownResult | null }> {
|
||||
const row = await env.DB.prepare("SELECT id, name FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
|
||||
.bind(id)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!row) return { deleted: false, name: null };
|
||||
if (!row) return { deleted: false, name: null, cleanup: null };
|
||||
// 先清 Cloudflare 侧资源(要读账号数据,须在标记 revoked 前做),失败仅告警
|
||||
const cleanup = await teardownAccountResources(env, id);
|
||||
await env.DB.prepare(
|
||||
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
|
||||
)
|
||||
@@ -135,7 +141,8 @@ async function revokeAccount(env: Env, id: string): Promise<{ deleted: boolean;
|
||||
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
return { deleted: true, name: row.name };
|
||||
const touched = cleanup.routingDisabled > 0 || cleanup.workersDeleted.length > 0 || cleanup.errors.length > 0;
|
||||
return { deleted: true, name: row.name, cleanup: touched ? cleanup : null };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -204,7 +211,7 @@ function installAccountRoutes(app: App) {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
|
||||
/** 删除账号(软删:撤销 Worker credential + 标记 revoked;并清理 Cloudflare 侧 Worker 与域名 Email Routing) */
|
||||
app.delete("/accounts/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const r = await revokeAccount(c.env, id);
|
||||
@@ -214,23 +221,36 @@ function installAccountRoutes(app: App) {
|
||||
targetType: "account",
|
||||
targetId: id,
|
||||
targetName: r.name,
|
||||
detail: r.cleanup
|
||||
? {
|
||||
routing_disabled: r.cleanup.routingDisabled,
|
||||
workers_deleted: r.cleanup.workersDeleted,
|
||||
errors: r.cleanup.errors,
|
||||
}
|
||||
: undefined,
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
return c.json({ ok: true, warnings: r.cleanup?.errors ?? [] });
|
||||
});
|
||||
|
||||
/** 批量删除账号(软删 + 撤销 Worker credential);域名数据保留 */
|
||||
/** 批量删除账号(软删 + 撤销 Worker credential + Cloudflare 侧清理);域名数据保留 */
|
||||
app.post("/accounts/batch-delete", async (c) => {
|
||||
const ids = await readIds(c);
|
||||
const failed: { id: string; reason: string }[] = [];
|
||||
const deletedNames: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const cleanups: TeardownResult[] = [];
|
||||
let deleted = 0;
|
||||
for (const id of ids) {
|
||||
const r = await revokeAccount(c.env, id);
|
||||
if (r.deleted) {
|
||||
deleted++;
|
||||
if (r.name) deletedNames.push(r.name);
|
||||
if (r.cleanup) {
|
||||
cleanups.push(r.cleanup);
|
||||
warnings.push(...r.cleanup.errors);
|
||||
}
|
||||
} else {
|
||||
failed.push({ id, reason: "账号不存在或已删除" });
|
||||
}
|
||||
@@ -240,11 +260,21 @@ function installAccountRoutes(app: App) {
|
||||
action: "account.batch_delete",
|
||||
targetType: "account",
|
||||
targetName: deletedNames.join("、"),
|
||||
detail: { deleted, ids },
|
||||
detail: {
|
||||
deleted,
|
||||
ids,
|
||||
...(cleanups.length
|
||||
? {
|
||||
routing_disabled: cleanups.reduce((n, x) => n + x.routingDisabled, 0),
|
||||
workers_deleted: cleanups.flatMap((x) => x.workersDeleted),
|
||||
errors: warnings,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, deleted, failed });
|
||||
return c.json({ ok: true, deleted, failed, warnings });
|
||||
});
|
||||
|
||||
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
|
||||
@@ -468,6 +498,8 @@ function installDomainRoutes(app: App) {
|
||||
.bind(id)
|
||||
.first<{ n: number }>();
|
||||
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
|
||||
// 清理 Cloudflare 侧 Email Routing(未配置过则跳过;失败仅告警,不阻断删除)
|
||||
const teardown = await teardownDomainRouting(c.env, id);
|
||||
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
||||
if (domain) {
|
||||
await logOperation(c.env, {
|
||||
@@ -475,10 +507,11 @@ function installDomainRoutes(app: App) {
|
||||
targetType: "domain",
|
||||
targetId: id,
|
||||
targetName: domain.name,
|
||||
detail: teardown.error ? { warnings: [teardown.error] } : undefined,
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
return c.json({ ok: true, warnings: teardown.error ? [teardown.error] : [] });
|
||||
});
|
||||
|
||||
/** 批量删除域名:级联删除该域名下所有邮箱(含 catch-all)及其邮件
|
||||
@@ -488,13 +521,19 @@ function installDomainRoutes(app: App) {
|
||||
const ids = await readIds(c);
|
||||
const failed: { id: string; reason: string }[] = [];
|
||||
const deletedNames: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let deleted = 0;
|
||||
let mailboxes = 0;
|
||||
let emails = 0;
|
||||
let routingDisabled = 0;
|
||||
for (const id of ids) {
|
||||
const name = (
|
||||
await c.env.DB.prepare("SELECT name FROM domains WHERE id = ?").bind(id).first<{ name: string }>()
|
||||
)?.name ?? null;
|
||||
// 逐域名清理 Cloudflare 侧 Email Routing(要在本地行删除前做,清理函数要读行)
|
||||
const teardown = await teardownDomainRouting(c.env, id);
|
||||
if (teardown.error) warnings.push(teardown.error);
|
||||
if (teardown.cleaned) routingDisabled++;
|
||||
const boxes = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE domain_id = ?")
|
||||
.bind(id)
|
||||
.all<{ id: string }>();
|
||||
@@ -517,11 +556,17 @@ function installDomainRoutes(app: App) {
|
||||
action: "domain.batch_delete",
|
||||
targetType: "domain",
|
||||
targetName: deletedNames.join("、"),
|
||||
detail: { deleted, mailboxes, emails, ids },
|
||||
detail: {
|
||||
deleted,
|
||||
mailboxes,
|
||||
emails,
|
||||
ids,
|
||||
...(routingDisabled > 0 || warnings.length ? { routing_disabled: routingDisabled, errors: warnings } : {}),
|
||||
},
|
||||
operator: await operatorName(c.env, c.get("userId")),
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, deleted, mailboxes, emails, failed });
|
||||
return c.json({ ok: true, deleted, mailboxes, emails, failed, warnings });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -447,6 +447,10 @@ export interface CFApi {
|
||||
zoneId: string;
|
||||
workerName: string;
|
||||
}): Promise<{ ok: boolean }>;
|
||||
/** 禁用 Zone 的 Email Routing(删除域名时的 Cloudflare 侧清理,只有 POST 真生效) */
|
||||
disableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ ok: boolean }>;
|
||||
/** 删除账号级 Email Worker 脚本(删除账号时的 Cloudflare 侧清理) */
|
||||
deleteWorkerScript(opts: { accessToken: string; accountId: string; scriptName: string }): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -718,4 +722,22 @@ export class RealCFApi implements CFApi {
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 禁用 Zone 的 Email Routing(删除域名时的 Cloudflare 侧清理;
|
||||
* 与 enable 同理只有 POST 真生效,PUT/PATCH 是假成功,失败抛 CFApiError) */
|
||||
async disableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/disable`, { method: "POST" });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 删除账号级 Email Worker 脚本(删除账号时的 Cloudflare 侧清理,失败抛 CFApiError;
|
||||
* 脚本在 Cloudflare 侧已被手动删掉时这里会报错,由调用方降级为告警文案) */
|
||||
async deleteWorkerScript(opts: { accessToken: string; accountId: string; scriptName: string }) {
|
||||
await this.api(
|
||||
opts.accessToken,
|
||||
`/accounts/${opts.accountId}/workers/scripts/${encodeURIComponent(opts.scriptName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 账号接入管线(docs/11,Connect Cloudflare Account):
|
||||
* 添加账号(建 cloudflare_accounts 记录)-> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)->
|
||||
* Email Worker 部署 + WorkerCredential 签发(账号级)。
|
||||
*
|
||||
* Email Routing/catch-all 规则不随 OAuth 自动执行,而是在域名列表中按域名单独触发
|
||||
* catch-all 邮箱不随接入自动创建,由用户手动添加;Email Routing/catch-all 规则
|
||||
* 不随 OAuth 自动执行,而是在域名列表中按域名单独触发
|
||||
* (setupDomainEmail);一个账号只部署一个 Email Worker(幂等,重复执行不产生重复资源)。
|
||||
* 账号级步骤状态写入 cloudflare_accounts.provision_steps。
|
||||
*/
|
||||
@@ -19,7 +20,7 @@ import { nowIso } from "../util.ts";
|
||||
export const PROVISION_STEPS = [
|
||||
{ key: "oauth", label: "Cloudflare OAuth 授权" },
|
||||
{ key: "discover", label: "Account / Zone 发现" },
|
||||
{ key: "domains", label: "域名与 catch-all 邮箱同步" },
|
||||
{ key: "domains", label: "域名同步" },
|
||||
{ key: "worker", label: "Email Worker 部署与 Worker 凭据" },
|
||||
] as const;
|
||||
|
||||
@@ -163,7 +164,8 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
|
||||
.run();
|
||||
await mark("discover", "done");
|
||||
|
||||
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」);
|
||||
// catch-all 邮箱不再随同步自动创建,需要用户在邮箱页手动添加
|
||||
await mark("domains", "running");
|
||||
for (const zone of zones) {
|
||||
await upsertDomain(env, {
|
||||
@@ -174,16 +176,6 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
|
||||
workerId: null,
|
||||
});
|
||||
}
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE account_id = ?")
|
||||
.bind(input.accountId)
|
||||
.all<{ id: string; name: string }>();
|
||||
for (const d of domainRows.results) {
|
||||
await upsertMailbox(env, {
|
||||
domainId: d.id,
|
||||
address: `*@${d.name}`,
|
||||
isCatchAll: true,
|
||||
});
|
||||
}
|
||||
await mark("domains", "done");
|
||||
|
||||
// 4. 部署账号级 Email Worker + 签发 Worker credential(docs/11 §14)
|
||||
@@ -315,6 +307,86 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare 侧资源清理结果(删账号/域名时尽力而为) **/
|
||||
export interface TeardownResult {
|
||||
/** 成功关闭 Email Routing 的域名数 **/
|
||||
routingDisabled: number;
|
||||
/** 已删除的 Email Worker 脚本名 **/
|
||||
workersDeleted: string[];
|
||||
/** 清理失败文案(不阻断本地删除,最终回给前端展示) **/
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 删除域名时清理 Cloudflare 侧的 Email Routing(尽力而为):
|
||||
* 只有配置过(status=active 且绑定 zone)才需要关;凭据缺失或 Cloudflare 报错
|
||||
* 都只返回错误文案,绝不阻断本地删除。
|
||||
* 返回值:cleaned = 实际执行了关闭;error = 失败原因(null = 无需清理或已成功)
|
||||
*/
|
||||
export async function teardownDomainRouting(
|
||||
env: Env,
|
||||
domainId: string,
|
||||
): Promise<{ cleaned: boolean; error: string | null }> {
|
||||
const domain = await env.DB.prepare("SELECT id, name, zone_id, account_id, status FROM domains WHERE id = ?")
|
||||
.bind(domainId)
|
||||
.first<{ id: string; name: string; zone_id: string | null; account_id: string | null; status: string }>();
|
||||
if (!domain?.zone_id || !domain.account_id || domain.status !== "active") return { cleaned: false, error: null };
|
||||
const accessToken = await decryptAccountToken(env, domain.account_id);
|
||||
if (!accessToken) return { cleaned: false, error: `Cloudflare 凭据不可用,未能关闭 ${domain.name} 的 Email Routing` };
|
||||
const cf: CFApi = await getCFApi(env);
|
||||
try {
|
||||
await cf.disableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||
return { cleaned: true, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
cleaned: false,
|
||||
error: `关闭 ${domain.name} 的 Email Routing 失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 删除账号时清理 Cloudflare 侧资源:先逐个关闭其已配置域名的 Email Routing,
|
||||
* 再删除账号级 Email Worker 脚本。全部尽力而为,失败累计进 errors。
|
||||
*/
|
||||
export async function teardownAccountResources(env: Env, accountId: string): Promise<TeardownResult> {
|
||||
const result: TeardownResult = { routingDisabled: 0, workersDeleted: [], errors: [] };
|
||||
const domains = await env.DB.prepare(
|
||||
"SELECT id FROM domains WHERE account_id = ? AND status = 'active' AND zone_id IS NOT NULL",
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<{ id: string }>();
|
||||
for (const d of domains.results) {
|
||||
const r = await teardownDomainRouting(env, d.id);
|
||||
if (r.error) result.errors.push(r.error);
|
||||
else if (r.cleaned) result.routingDisabled++;
|
||||
}
|
||||
const account = await env.DB.prepare(
|
||||
"SELECT cloudflare_account_id, worker_id FROM cloudflare_accounts WHERE id = ?",
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ cloudflare_account_id: string | null; worker_id: string | null }>();
|
||||
if (account?.cloudflare_account_id && account.worker_id) {
|
||||
const accessToken = await decryptAccountToken(env, accountId);
|
||||
if (!accessToken) {
|
||||
result.errors.push("Cloudflare 凭据不可用,未能删除 Email Worker");
|
||||
} else {
|
||||
const cf: CFApi = await getCFApi(env);
|
||||
try {
|
||||
await cf.deleteWorkerScript({
|
||||
accessToken,
|
||||
accountId: account.cloudflare_account_id,
|
||||
scriptName: account.worker_id,
|
||||
});
|
||||
result.workersDeleted.push(account.worker_id);
|
||||
} catch (err) {
|
||||
result.errors.push(`删除 Email Worker 失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析账号的步骤状态
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user