v2.6.0: 接入不再自动建 catch-all 邮箱(手动添加);删除账号/域名时清理 Cloudflare 侧资源(关闭 Email Routing + 删除 Email Worker,尽力而为+告警)

This commit is contained in:
bigcode
2026-10-01 02:19:30 +08:00
parent 830ca85ebd
commit c4b5e65159
9 changed files with 350 additions and 39 deletions
+86 -14
View File
@@ -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;
}
/** # 函数
* 读取并解析账号的步骤状态
*/