feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)

- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传)
- Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效
- 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id
- 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署
- 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在
- 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程
- 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0
- 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通
- docs 00-10 同步改写;README 更新
This commit is contained in:
bigcode
2026-09-30 13:10:26 +08:00
parent 421cfb2f04
commit fec96e49cc
56 changed files with 2584 additions and 1416 deletions
+175 -151
View File
@@ -1,12 +1,12 @@
/**
* 账号(Node)自动安装管线(v1.1.0):
* 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。
* 账号接入管线(docs/11,Connect Cloudflare Account):
* 添加账号(建 cloudflare_accounts 记录)-> Cloudflare OAuth -> 授权回调 ->
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱 ->
* Email Worker 部署 + WorkerCredential 签发(账号级)。
*
* Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行,
* 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。
*
* 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。
* Email Routing/catch-all 规则不随 OAuth 自动执行,而是在域名列表中按域名单独触发
* (setupDomainEmail);一个账号只部署一个 Email Worker(幂等,重复执行不产生重复资源)。
* 账号级步骤状态写入 cloudflare_accounts.provision_steps。
*/
import type { Env } from "../env.ts";
@@ -15,12 +15,12 @@ import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: 账号安装步骤定义(顺序即展示顺序) **/
/** # 成员变量: 账号接入步骤定义(顺序即展示顺序) **/
export const PROVISION_STEPS = [
{ key: "oauth", label: "Cloudflare OAuth 授权" },
{ key: "identity", label: "Node identity 与 credential" },
{ key: "discover", label: "Account / Zone 发现" },
{ key: "domains", label: "域名与 catch-all 邮箱同步" },
{ key: "worker", label: "Email Worker 部署与 Worker 凭据" },
] as const;
/** # 成员变量: 步骤状态 **/
@@ -36,164 +36,131 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
}
/** # 函数
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名;同时作为 Worker -> Console 的身份 ID)
*/
export function emailWorkerNameFor(nodeId: string): string {
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
export function emailWorkerNameFor(accountId: string): string {
return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
}
/** # 函数
* 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
* 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。
*/
export async function createNode(
export async function createAccount(
env: Env,
opts: { name: string; ingressUrl: string },
): Promise<{
nodeId: string;
enrollmentToken: string;
ingressSecret: string;
}> {
const nodeId = ids.node();
opts: { name: string },
): Promise<{ accountId: string }> {
const accountId = ids.account();
const now = nowIso();
const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, "");
const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version,
last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at,
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status,
provision_steps, created_at, updated_at)
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`,
)
.bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now)
.run();
return { accountId };
}
/** # 函数
* 确保账号存在可用的 Worker credential:
* 已有 active credential 直接复用(不泄露 secret);否则签发全新 credential。
* 返回 secret 明文仅供部署注入(本地测试/重新部署 Worker 时使用)。
*/
export async function ensureWorkerCredential(
env: Env,
accountId: string,
workerId: string,
): Promise<{ credentialId: string; secret: string | null }> {
const now = nowIso();
const existing = await env.DB.prepare(
"SELECT id FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active'",
)
.bind(accountId, workerId)
.first<{ id: string }>();
if (existing) return { credentialId: existing.id, secret: null };
const secret = "whs_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const credentialId = ids.credential();
await env.DB.prepare(
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'active', ?)",
)
.bind(
nodeId,
opts.name,
opts.ingressUrl,
await encryptSecret(env, ingressSecret),
enrollmentToken,
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
JSON.stringify(initialSteps()),
now,
credentialId,
accountId,
workerId,
await sha256Hex(secret),
await encryptSecret(env, secret),
secret.slice(-4),
now,
)
.run();
return { nodeId, enrollmentToken, ingressSecret };
return { credentialId, secret };
}
/** # 函数
* Node bootstrap enrollment:用一次性 token 换取正式身份。
* 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。
*/
export async function enrollNode(
env: Env,
enrollmentToken: string,
): Promise<{
node_id: string;
credential_id: string;
credential_secret: string;
ingress_secret: string;
console_url: string | null;
} | null> {
const node = await env.DB.prepare(
"SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'",
)
.bind(enrollmentToken)
.first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>();
if (!node) return null;
if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null;
// 轮换:撤销旧 credential,签发新 credential
await env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
)
.bind(nowIso(), node.id)
.run();
const credentialId = ids.credential();
const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)",
)
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
.run();
await env.DB.prepare(
"UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?",
)
.bind(credentialId, nowIso(), node.id)
.run();
return {
node_id: node.id,
credential_id: credentialId,
credential_secret: secret,
ingress_secret: await decryptSecret(env, node.ingress_secret_enc),
console_url: null,
};
}
/** # 成员变量: 授权完成后的完整安装流程参数 **/
/** # 成员变量: 授权完成后的完整接入流程参数 **/
export interface ProvisionInput {
nodeId: string;
accountId: string;
accessToken: string;
accountId?: string;
cfAccountId?: string;
}
/** # 函数
* OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。
* 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。
* OAuth 授权成功后的自动接入主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱 +
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
*/
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
const cf: CFApi = getCFApi(env, env.DB);
const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?")
.bind(input.nodeId)
.first<{ id: string; name: string }>();
if (!node) return { ok: false, error: "account not found" };
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
.bind(input.accountId)
.first<{ id: string; name: string; status: string }>();
if (!account || account.status === "revoked") return { ok: false, error: "account not found" };
const steps = await loadSteps(env, input.nodeId);
const steps = await loadSteps(env, input.accountId);
const mark = async (key: string, status: StepStatus, error?: string) => {
steps[key] = { status, error };
await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?")
.bind(JSON.stringify(steps), nowIso(), input.nodeId)
await env.DB.prepare("UPDATE cloudflare_accounts SET provision_steps = ?, updated_at = ? WHERE id = ?")
.bind(JSON.stringify(steps), nowIso(), input.accountId)
.run();
};
try {
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'provisioning', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.accountId)
.run();
// 1. OAuth 授权
await mark("oauth", "done");
// 2. Node identity/credential(在添加账号与 enrollment 时完成)
await mark("identity", "done");
// 3. Account/Zone 发现
// 2. Account/Zone 发现
await mark("discover", "running");
const accounts = await cf.listAccounts(input.accessToken);
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
const account = input.accountId
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
const cfAccount = input.cfAccountId
? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0]
: accounts[0];
// 自动创建的账号以 Cloudflare 账户名命名
const newName = node.name.startsWith("Cloudflare 账号") ? account.name : node.name;
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?")
.bind(account.id, newName, nowIso(), input.nodeId)
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
await env.DB.prepare(
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
)
.bind(cfAccount.id, newName, nowIso(), input.accountId)
.run();
const zones = await cf.listZones(input.accessToken, account.id);
const zones = await cf.listZones(input.accessToken, cfAccount.id);
await mark("discover", "done");
// 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
await mark("domains", "running");
for (const zone of zones) {
await upsertDomain(env, {
cloudflareAccountId: account.id,
nodeId: input.nodeId,
cloudflareAccountId: cfAccount.id,
accountId: input.accountId,
name: zone.name,
zoneId: zone.id,
workerId: null,
});
}
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
.bind(input.nodeId)
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, {
@@ -204,8 +171,13 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
}
await mark("domains", "done");
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
// 4. 部署账号级 Email Worker + 签发 Worker credential(docs/11 §14)
await mark("worker", "running");
await deployAccountWorker(env, input.accountId, input.accessToken);
await mark("worker", "done");
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'active', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.accountId)
.run();
return { ok: true };
} catch (err) {
@@ -217,52 +189,104 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
break;
}
}
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'error', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.accountId)
.run();
return { ok: false, error: message };
}
}
/** # 函数
* 为单个域名配置 Email Workers:部署账号级 Email Worker -> 开启 Email Routing ->
* 部署(或更新)账号级 Email Worker:
* 确保 Worker credential -> 渲染脚本(Console 地址 + Worker ID + secret)-> 上传 Cloudflare。
* credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。
*/
export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise<string> {
const account = await env.DB.prepare("SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ?")
.bind(accountId)
.first<{ id: string; cloudflare_account_id: string | null }>();
if (!account?.cloudflare_account_id) throw new Error("account not connected via OAuth");
const workerId = await getOrCreateWorkerId(env, accountId);
const { secret } = await ensureWorkerCredential(env, accountId, workerId);
if (!secret) {
// 已有 credential:重新上传脚本时需要原 secret,先解密
const row = await env.DB.prepare(
"SELECT secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
)
.bind(accountId, workerId)
.first<{ secret_enc: string }>();
const decrypted = row ? await decryptSecret(env, row.secret_enc) : null;
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, decrypted ?? "");
return workerId;
}
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, secret);
return workerId;
}
/** # 函数
* 账号的 worker_id(幂等:无则生成并写入 cloudflare_accounts.worker_id)
*/
async function getOrCreateWorkerId(env: Env, accountId: string): Promise<string> {
const row = await env.DB.prepare("SELECT worker_id FROM cloudflare_accounts WHERE id = ?")
.bind(accountId)
.first<{ worker_id: string | null }>();
if (row?.worker_id) return row.worker_id;
const workerId = emailWorkerNameFor(accountId);
await env.DB.prepare("UPDATE cloudflare_accounts SET worker_id = ?, updated_at = ? WHERE id = ?")
.bind(workerId, nowIso(), accountId)
.run();
return workerId;
}
/** # 函数
* 上传 Email Worker 脚本(Console 地址取 PUBLIC_BASE_URL,与 OAuth redirect 同源)
*/
async function putWorkerScript(
env: Env,
accessToken: string,
cfAccountId: string,
workerId: string,
workerSecret: string,
): Promise<void> {
const cf: CFApi = getCFApi(env, env.DB);
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
}
/** # 函数
* 为单个域名配置 Email Workers:确保账号级 Worker 部署 -> 开启 Email Routing ->
* catch-all 规则指向 Worker -> 域名置 active。幂等,失败信息写入 domains.setup_error。
*/
export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok: boolean; error?: string }> {
const domain = await env.DB.prepare(
"SELECT id, name, zone_id, node_id FROM domains WHERE id = ?",
"SELECT id, name, zone_id, account_id FROM domains WHERE id = ?",
)
.bind(domainId)
.first<{ id: string; name: string; zone_id: string | null; node_id: string | null }>();
.first<{ id: string; name: string; zone_id: string | null; account_id: string | null }>();
if (!domain) return { ok: false, error: "domain not found" };
if (!domain.node_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
if (!domain.account_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
const node = await env.DB.prepare(
"SELECT id, cloudflare_account_id, ingress_url, ingress_secret_enc FROM nodes WHERE id = ? AND status != 'revoked'",
const account = await env.DB.prepare(
"SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
)
.bind(domain.node_id)
.first<{ id: string; cloudflare_account_id: string | null; ingress_url: string; ingress_secret_enc: string }>();
if (!node?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
.bind(domain.account_id)
.first<{ id: string; cloudflare_account_id: string | null }>();
if (!account?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
const accessToken = await decryptConnectionToken(env);
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
const cf: CFApi = getCFApi(env, env.DB);
try {
const workerName = emailWorkerNameFor(node.id);
await cf.putEmailWorker({
accessToken,
accountId: node.cloudflare_account_id,
scriptName: workerName,
ingressUrl: node.ingress_url,
ingressSecret: await decryptSecret(env, node.ingress_secret_enc),
});
const workerId = await deployAccountWorker(env, account.id, accessToken);
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName });
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId });
await env.DB.prepare(
"UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?",
)
.bind(workerName, nowIso(), domainId)
.bind(workerId, nowIso(), domainId)
.run();
return { ok: true };
} catch (err) {
@@ -275,11 +299,11 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
}
/** # 函数
* 读取并解析 node 的步骤状态
* 读取并解析账号的步骤状态
*/
export async function loadSteps(env: Env, nodeId: string) {
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
.bind(nodeId)
export async function loadSteps(env: Env, accountId: string) {
const row = await env.DB.prepare("SELECT provision_steps FROM cloudflare_accounts WHERE id = ?")
.bind(accountId)
.first<{ provision_steps: string }>();
if (!row?.provision_steps) return initialSteps();
try {
@@ -290,13 +314,13 @@ export async function loadSteps(env: Env, nodeId: string) {
}
/** # 函数
* upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
* upsert Domain(按 cf account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
*/
export async function upsertDomain(
env: Env,
d: {
cloudflareAccountId: string;
nodeId: string;
accountId: string;
name: string;
zoneId: string;
workerId: string | null;
@@ -311,23 +335,23 @@ export async function upsertDomain(
if (existing) {
// 已配置过 Worker 的域名不回退状态;仅当新配置带 worker 时更新
if (existing.worker_id || !d.workerId) {
await env.DB.prepare("UPDATE domains SET node_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
.bind(d.nodeId, d.zoneId, now, existing.id)
await env.DB.prepare("UPDATE domains SET account_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
.bind(d.accountId, d.zoneId, now, existing.id)
.run();
} else {
await env.DB.prepare(
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
"UPDATE domains SET account_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
)
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
.bind(d.accountId, d.zoneId, d.workerId, now, existing.id)
.run();
}
return existing.id;
}
const id = ids.domain();
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
.bind(id, d.cloudflareAccountId, d.accountId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
.run();
return id;
}