feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链
- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建) - 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选 - Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口 - 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除) - UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框 - 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react) - 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录 - 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
This commit is contained in:
@@ -118,8 +118,18 @@ export class RealCFApi implements CFApi {
|
||||
return this.api<CFAccount[]>(token, "/accounts");
|
||||
}
|
||||
|
||||
listZones(token: string, accountId: string) {
|
||||
return this.api<CFZone[]>(token, `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50`);
|
||||
async listZones(token: string, accountId: string) {
|
||||
// 分页拉全账号下的 Zone(每页 50,最多 20 页)
|
||||
const out: CFZone[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const rows = await this.api<CFZone[]>(
|
||||
token,
|
||||
`/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`,
|
||||
);
|
||||
out.push(...rows);
|
||||
if (rows.length < 50) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async putEmailWorker(opts: {
|
||||
@@ -199,13 +209,11 @@ export class SimCFApi implements CFApi {
|
||||
}
|
||||
|
||||
async listZones(_token: string, accountId: string): Promise<CFZone[]> {
|
||||
// sim 返回多个演示域名,覆盖「OAuth 后自动同步全部域名」的展示
|
||||
const zones = [
|
||||
{
|
||||
id: "sim-zone-example.test",
|
||||
name: "example.test",
|
||||
account_id: accountId,
|
||||
status: "active",
|
||||
},
|
||||
{ id: "sim-zone-example.test", name: "example.test", account_id: accountId, status: "active" },
|
||||
{ id: "sim-zone-demo.test", name: "demo.test", account_id: accountId, status: "active" },
|
||||
{ id: "sim-zone-mailbox.dev", name: "mailbox.dev", account_id: accountId, status: "active" },
|
||||
];
|
||||
for (const z of zones) await this.record("zone", z.id, z);
|
||||
return zones;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
import { enqueueDeliveryForEmail } from "./webhook_delivery.ts";
|
||||
|
||||
/** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/
|
||||
interface ParsedMail {
|
||||
@@ -191,15 +192,8 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
|
||||
.run();
|
||||
}
|
||||
|
||||
// Webhook delivery(邮件保存成功后创建;投递失败不影响入库)
|
||||
await createDeliveriesForEmail(env, emailId, {
|
||||
mailboxAddress: await mailboxAddress(env, mailboxId),
|
||||
from: fromAddress,
|
||||
to: toAddresses,
|
||||
subject: parsed?.subject ?? "(unparsable mail)",
|
||||
text: parsed?.text ?? null,
|
||||
receivedAt,
|
||||
});
|
||||
// Webhook 投递(按 邮箱->域名->全局 解析;未配置则不入队;投递失败不影响入库)
|
||||
await enqueueDeliveryForEmail(env, emailId, mailboxId);
|
||||
|
||||
return {
|
||||
id: emailId,
|
||||
@@ -245,37 +239,3 @@ export async function resolveMailbox(env: Env, addresses: string[]): Promise<str
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mailboxAddress(env: Env, mailboxId: string | null): Promise<string | null> {
|
||||
if (!mailboxId) return null;
|
||||
const row = await env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?")
|
||||
.bind(mailboxId)
|
||||
.first<{ address: string }>();
|
||||
return row?.address ?? null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为新邮件创建全部启用 webhook 的 delivery(pending,立即可投)
|
||||
*/
|
||||
export async function createDeliveriesForEmail(
|
||||
env: Env,
|
||||
emailId: string,
|
||||
ctx: {
|
||||
mailboxAddress: string | null;
|
||||
from?: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
text: string | null;
|
||||
receivedAt: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const hooks = await env.DB.prepare("SELECT id FROM webhooks WHERE enabled = 1").all<{ id: string }>();
|
||||
const now = nowIso();
|
||||
for (const h of hooks.results) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, next_retry_at, created_at) VALUES (?, ?, ?, 'pending', 0, ?, ?)",
|
||||
)
|
||||
.bind(ids.delivery(), h.id, emailId, now, now)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
/**
|
||||
* Node 自动安装管线(docs/02 流程):
|
||||
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
|
||||
* 创建 Domain/Mailbox -> Node Ready。
|
||||
* 账号(Node)自动安装管线(v1.1.0):
|
||||
* 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
|
||||
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
|
||||
* Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行,
|
||||
* 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { getCFApi, type CFApi } from "./cfapi.ts";
|
||||
import { encryptSecret, decryptSecret } from "../db.ts";
|
||||
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: "email_worker", label: "Email Worker 创建/更新" },
|
||||
{ key: "email_routing", label: "Email Routing 配置" },
|
||||
{ key: "domains", label: "Domain 创建" },
|
||||
{ key: "mailboxes", label: "Mailbox 创建" },
|
||||
{ key: "domains", label: "域名与 catch-all 邮箱同步" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
@@ -38,14 +36,14 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
*/
|
||||
export async function createNode(
|
||||
@@ -70,7 +68,7 @@ export async function createNode(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptText(env, ingressSecret),
|
||||
await encryptSecret(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
@@ -117,7 +115,7 @@ export async function enrollNode(
|
||||
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"UPDATE nodes SET credential_id = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
|
||||
"UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
@@ -126,19 +124,11 @@ export async function enrollNode(
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptText(env, node.ingress_secret_enc),
|
||||
ingress_secret: await decryptSecret(env, node.ingress_secret_enc),
|
||||
console_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function encryptText(env: Env, text: string): Promise<string> {
|
||||
return encryptSecret(env, text);
|
||||
}
|
||||
|
||||
async function decryptText(env: Env, blob: string): Promise<string> {
|
||||
return decryptSecret(env, blob);
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权完成后的完整安装流程参数 **/
|
||||
export interface ProvisionInput {
|
||||
nodeId: string;
|
||||
@@ -147,17 +137,15 @@ export interface ProvisionInput {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
|
||||
* 返回是否全部成功;失败时步骤状态里带 error。
|
||||
* OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。
|
||||
* 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。
|
||||
*/
|
||||
export async function provisionNode(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, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
|
||||
)
|
||||
const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?")
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node) return { ok: false, error: "node not found" };
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!node) return { ok: false, error: "account not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
const mark = async (key: string, status: StepStatus, error?: string) => {
|
||||
@@ -175,7 +163,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
|
||||
// 2. Node identity/credential(在添加账号与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. Account/Zone 发现
|
||||
@@ -185,47 +173,25 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
const account = input.accountId
|
||||
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
|
||||
: accounts[0];
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, nowIso(), input.nodeId)
|
||||
// 自动创建的账号以 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)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, account.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 4. Email Worker(幂等上传)
|
||||
await mark("email_worker", "running");
|
||||
const workerName = emailWorkerNameFor(input.nodeId);
|
||||
const ingressSecret = await decryptText(env, node.ingress_secret_enc);
|
||||
await cf.putEmailWorker({
|
||||
accessToken: input.accessToken,
|
||||
accountId: account.id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret,
|
||||
});
|
||||
await mark("email_worker", "done");
|
||||
|
||||
// 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain
|
||||
await mark("email_routing", "running");
|
||||
// 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
await mark("domains", "running");
|
||||
for (const zone of zones) {
|
||||
await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id });
|
||||
await cf.setCatchAllRule({
|
||||
accessToken: input.accessToken,
|
||||
zoneId: zone.id,
|
||||
workerName,
|
||||
});
|
||||
await upsertDomain(env, {
|
||||
cloudflareAccountId: account.id,
|
||||
nodeId: input.nodeId,
|
||||
name: zone.name,
|
||||
zoneId: zone.id,
|
||||
workerId: workerName,
|
||||
workerId: null,
|
||||
});
|
||||
}
|
||||
await mark("email_routing", "done");
|
||||
await mark("domains", "done");
|
||||
|
||||
// 7. 每个 Domain 创建 catch-all Mailbox(*@domain)
|
||||
await mark("mailboxes", "running");
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
|
||||
.bind(input.nodeId)
|
||||
.all<{ id: string; name: string }>();
|
||||
@@ -236,7 +202,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
isCatchAll: true,
|
||||
});
|
||||
}
|
||||
await mark("mailboxes", "done");
|
||||
await mark("domains", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
@@ -258,6 +224,56 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 为单个域名配置 Email Workers:部署账号级 Email 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 = ?",
|
||||
)
|
||||
.bind(domainId)
|
||||
.first<{ id: string; name: string; zone_id: string | null; node_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" };
|
||||
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, cloudflare_account_id, ingress_url, ingress_secret_enc FROM nodes 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" };
|
||||
|
||||
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),
|
||||
});
|
||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName });
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(workerName, nowIso(), domainId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await env.DB.prepare("UPDATE domains SET status = 'error', setup_error = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(message, nowIso(), domainId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
*/
|
||||
@@ -274,7 +290,7 @@ export async function loadSteps(env: Env, nodeId: string) {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)
|
||||
* upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
@@ -283,28 +299,35 @@ export async function upsertDomain(
|
||||
nodeId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string;
|
||||
workerId: string | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
"SELECT id, worker_id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
)
|
||||
.bind(d.cloudflareAccountId, d.name)
|
||||
.first<{ id: string }>();
|
||||
.first<{ id: string; worker_id: string | null }>();
|
||||
const now = nowIso();
|
||||
if (existing) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
|
||||
.run();
|
||||
// 已配置过 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)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, 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 (?, ?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,208 @@
|
||||
/**
|
||||
* Webhook 投递与重试(docs/06、docs/07):
|
||||
* Webhook 投递(docs/06、docs/07,v1.1.0 三级模型):
|
||||
* - 配置解析优先级:邮箱 -> 域名 -> 全局(settings),命中即止
|
||||
* - 每级配置:{ url, headers(自定义请求头 JSON), key }
|
||||
* key 默认以 `Authorization: Bearer <key>` 携带(headers 里显式给了 Authorization 则不覆盖),
|
||||
* 并用于投递签名:signature = HMAC-SHA256(key, `${timestamp}.${rawBody}`)
|
||||
* - payload 按文档 JSON 结构(event=email.received)
|
||||
* - 签名头:X-WmailHub-Event / Delivery / Timestamp / Signature
|
||||
* signature = HMAC-SHA256(webhook_secret, `${timestamp}.${rawBody}`)
|
||||
* - 2xx 成功;5xx/超时重试(指数退避);4xx 记失败并按策略重试到上限
|
||||
* - 2xx 成功;失败重试(指数退避);投递行在入队时固化目标配置,重试不跟随后续配置变更
|
||||
* - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时)
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import {
|
||||
hmacSha256Hex,
|
||||
aesEncrypt,
|
||||
aesDecrypt,
|
||||
assertWebhookUrlSafe,
|
||||
ids,
|
||||
type HostResolver,
|
||||
WEBHOOK_HEADERS,
|
||||
WEBHOOK_TIMEOUT_MS,
|
||||
WEBHOOK_MAX_ATTEMPTS,
|
||||
WEBHOOK_RETRY_BASE_MS,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 单级 Webhook 配置(解析结果) **/
|
||||
export interface WebhookConfig {
|
||||
/** 配置来源层级 **/
|
||||
targetType: "mailbox" | "domain" | "global";
|
||||
/** 目标 URL **/
|
||||
url: string;
|
||||
/** 自定义请求头 **/
|
||||
headers: Record<string, string>;
|
||||
/** key 明文(签名与 Authorization;可能为空串) **/
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: settings 表中全局 Webhook 的存储键 **/
|
||||
export const GLOBAL_WEBHOOK_SETTING = "global_webhook";
|
||||
|
||||
/** # 函数
|
||||
* 校验并规整自定义请求头:键名限 [A-Za-z0-9-],值转字符串;非法抛错
|
||||
* 参数: headers - 前端传入的对象
|
||||
* 返回值: 规整后的头(空对象返回 {})
|
||||
*/
|
||||
export function normalizeWebhookHeaders(headers: unknown): Record<string, string> {
|
||||
if (headers === undefined || headers === null) return {};
|
||||
if (typeof headers !== "object" || Array.isArray(headers)) throw new Error("headers must be an object");
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (!/^[A-Za-z0-9-]{1,64}$/.test(k)) throw new Error(`invalid header name: ${k}`);
|
||||
if (v === undefined || v === null) continue;
|
||||
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
||||
throw new Error(`invalid header value for: ${k}`);
|
||||
}
|
||||
out[k] = String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeParseHeaders(s: string | null): Record<string, string> {
|
||||
if (!s) return {};
|
||||
try {
|
||||
return normalizeWebhookHeaders(JSON.parse(s));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: 邮箱/域名行的 Webhook 列形态 **/
|
||||
interface WebhookColumns {
|
||||
webhook_url: string | null;
|
||||
webhook_headers: string | null;
|
||||
webhook_key_enc: string | null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 三级 Webhook 解析:邮箱 -> 域名 -> 全局(settings),命中第一级即返回
|
||||
* 参数: env - 运行环境;mailboxId - 邮件命中的邮箱(可为 null)
|
||||
* 返回值: 命中的配置;三级都未配置时 null(不投递)
|
||||
*/
|
||||
export async function resolveWebhookConfig(env: Env, mailboxId: string | null): Promise<WebhookConfig | null> {
|
||||
if (mailboxId) {
|
||||
const mb = await env.DB.prepare(
|
||||
"SELECT webhook_url, webhook_headers, webhook_key_enc, domain_id FROM mailboxes WHERE id = ?",
|
||||
)
|
||||
.bind(mailboxId)
|
||||
.first<WebhookColumns & { domain_id: string }>();
|
||||
if (mb?.webhook_url) {
|
||||
return {
|
||||
targetType: "mailbox",
|
||||
url: mb.webhook_url,
|
||||
headers: safeParseHeaders(mb.webhook_headers),
|
||||
key: mb.webhook_key_enc ? await decryptSecret(env, mb.webhook_key_enc) : "",
|
||||
};
|
||||
}
|
||||
if (mb?.domain_id) {
|
||||
const dom = await env.DB.prepare(
|
||||
"SELECT webhook_url, webhook_headers, webhook_key_enc FROM domains WHERE id = ?",
|
||||
)
|
||||
.bind(mb.domain_id)
|
||||
.first<WebhookColumns>();
|
||||
if (dom?.webhook_url) {
|
||||
return {
|
||||
targetType: "domain",
|
||||
url: dom.webhook_url,
|
||||
headers: safeParseHeaders(dom.webhook_headers),
|
||||
key: dom.webhook_key_enc ? await decryptSecret(env, dom.webhook_key_enc) : "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const raw = await getSetting(env, GLOBAL_WEBHOOK_SETTING);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const cfg = JSON.parse(raw) as { url?: string; headers?: Record<string, string> };
|
||||
if (!cfg.url) return null;
|
||||
return { targetType: "global", url: cfg.url, headers: cfg.headers ?? {}, key: await getGlobalWebhookKey(env) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取全局 Webhook key(密文存 settings)
|
||||
*/
|
||||
export async function getGlobalWebhookKey(env: Env): Promise<string> {
|
||||
const enc = await getSetting(env, "global_webhook_key_enc");
|
||||
return enc ? await decryptSecret(env, enc) : "";
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 保存全局 Webhook 配置到 settings(url/headers 明文 JSON,key 加密)
|
||||
* 参数: cfg.url 为空串表示停用全局 Webhook;key 为 undefined 表示保留旧 key
|
||||
*/
|
||||
export async function saveGlobalWebhook(
|
||||
env: Env,
|
||||
cfg: { url: string; headers?: Record<string, string>; key?: string },
|
||||
): Promise<void> {
|
||||
await setSetting(
|
||||
env,
|
||||
GLOBAL_WEBHOOK_SETTING,
|
||||
JSON.stringify({ url: cfg.url, headers: cfg.headers ?? {} }),
|
||||
);
|
||||
if (cfg.key !== undefined) {
|
||||
if (cfg.key === "") {
|
||||
await env.DB.prepare("DELETE FROM settings WHERE key = 'global_webhook_key_enc'").run();
|
||||
} else {
|
||||
await setSetting(env, "global_webhook_key_enc", await encryptSecret(env, cfg.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 保存邮箱/域名级 Webhook 配置(url 为空串表示清除该级配置)
|
||||
*/
|
||||
export async function saveScopedWebhook(
|
||||
env: Env,
|
||||
table: "mailboxes" | "domains",
|
||||
id: string,
|
||||
cfg: { url: string; headers?: Record<string, string>; key?: string },
|
||||
): Promise<void> {
|
||||
if (!["mailboxes", "domains"].includes(table)) throw new Error("bad table");
|
||||
const headersJson = JSON.stringify(cfg.headers ?? {});
|
||||
const keyEnc = cfg.key !== undefined ? (cfg.key === "" ? null : await encryptSecret(env, cfg.key)) : undefined;
|
||||
if (!cfg.url) {
|
||||
// 清除该级配置
|
||||
await env.DB.prepare(
|
||||
`UPDATE ${table} SET webhook_url = NULL, webhook_headers = NULL, webhook_key_enc = NULL, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
if (keyEnc === undefined) {
|
||||
await env.DB.prepare(
|
||||
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(cfg.url, headersJson, nowIso(), id)
|
||||
.run();
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, webhook_key_enc = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(cfg.url, headersJson, keyEnc, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* webhook key 入库密文(供入队时固化)
|
||||
*/
|
||||
async function encryptWebhookKey(env: Env, key: string): Promise<string | null> {
|
||||
return key ? aesEncrypt(await getAesKey(env), key) : null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解密投递行固化的 key
|
||||
*/
|
||||
async function decryptWebhookKey(env: Env, blob: string | null): Promise<string> {
|
||||
return blob ? await aesDecrypt(await getAesKey(env), blob) : "";
|
||||
}
|
||||
|
||||
/** # 成员变量: Webhook 投递 payload 结构(docs/07) **/
|
||||
export function buildWebhookPayload(email: {
|
||||
id: string;
|
||||
@@ -53,30 +237,43 @@ function safeParseArray(s: string | null): string[] {
|
||||
|
||||
/** # 函数
|
||||
* 执行一次投递;返回是否成功与错误信息(不修改库)
|
||||
* 参数: config - 固化在投递行上的目标配置
|
||||
*/
|
||||
export async function attemptDelivery(
|
||||
webhook: { id: string; endpoint: string; secret: string },
|
||||
config: { url: string; headers: Record<string, string>; key: string },
|
||||
deliveryId: string,
|
||||
payload: Record<string, unknown>,
|
||||
resolve?: HostResolver,
|
||||
allowPrivateHosts = false,
|
||||
): Promise<{ ok: boolean; status?: number; error?: string }> {
|
||||
const unsafe = await assertWebhookUrlSafe(webhook.endpoint, resolve, allowPrivateHosts);
|
||||
const unsafe = await assertWebhookUrlSafe(config.url, resolve, allowPrivateHosts);
|
||||
if (unsafe) return { ok: false, error: `ssrf guard: ${unsafe}` };
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const signature = await hmacSha256Hex(webhook.secret, `${timestamp}.${body}`);
|
||||
|
||||
// 组装请求头:标准头 -> key 的默认 Authorization -> 用户自定义头(可覆盖标准头,但不能去 Content-Type)
|
||||
// 未配置 key 时无法协商签名密钥,跳过签名头(仅保留事件标识)
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
[WEBHOOK_HEADERS.event]: "email.received",
|
||||
[WEBHOOK_HEADERS.delivery]: deliveryId,
|
||||
};
|
||||
if (config.key) {
|
||||
headers[WEBHOOK_HEADERS.timestamp] = timestamp;
|
||||
headers[WEBHOOK_HEADERS.signature] = await hmacSha256Hex(config.key, `${timestamp}.${body}`);
|
||||
}
|
||||
const customHasAuth = Object.keys(config.headers).some((k) => k.toLowerCase() === "authorization");
|
||||
if (config.key && !customHasAuth) headers["Authorization"] = `Bearer ${config.key}`;
|
||||
for (const [k, v] of Object.entries(config.headers)) {
|
||||
if (k.toLowerCase() === "content-type") continue;
|
||||
headers[k] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(webhook.endpoint, {
|
||||
const resp = await fetch(config.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[WEBHOOK_HEADERS.event]: "email.received",
|
||||
[WEBHOOK_HEADERS.delivery]: deliveryId,
|
||||
[WEBHOOK_HEADERS.timestamp]: timestamp,
|
||||
[WEBHOOK_HEADERS.signature]: signature,
|
||||
},
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
||||
});
|
||||
@@ -95,6 +292,23 @@ export function nextRetryIso(attemptCount: number): string {
|
||||
return new Date(Date.now() + delay).toISOString();
|
||||
}
|
||||
|
||||
/** # 成员变量: 投递循环的行结构 **/
|
||||
interface DueDeliveryRow {
|
||||
delivery_id: string;
|
||||
url: string;
|
||||
headers: string | null;
|
||||
key_enc: string | null;
|
||||
attempt_count: number;
|
||||
email_id: string;
|
||||
mailbox_id: string | null;
|
||||
from_address: string | null;
|
||||
to_addresses: string;
|
||||
subject: string | null;
|
||||
text_body: string | null;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Cron 驱动的投递循环:取到期 delivery 逐个投递并落状态。
|
||||
* 返回处理统计(供日志与 admin 接口)。
|
||||
@@ -107,11 +321,10 @@ export async function processDueDeliveries(
|
||||
): Promise<{ processed: number; success: number; failed: number; exhausted: number }> {
|
||||
const now = nowIso();
|
||||
const due = await env.DB.prepare(
|
||||
`SELECT d.id AS delivery_id, d.attempt_count, w.id AS webhook_id, w.endpoint, w.secret, w.enabled,
|
||||
`SELECT d.id AS delivery_id, d.url, d.headers, d.key_enc, d.attempt_count,
|
||||
e.id AS email_id, e.mailbox_id, e.from_address, e.to_addresses, e.subject, e.text_body,
|
||||
e.received_at, e.expires_at
|
||||
FROM webhook_deliveries d
|
||||
JOIN webhooks w ON w.id = d.webhook_id
|
||||
JOIN emails e ON e.id = d.email_id
|
||||
WHERE d.status IN ('pending', 'failed')
|
||||
AND d.attempt_count < ?
|
||||
@@ -120,22 +333,7 @@ export async function processDueDeliveries(
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(WEBHOOK_MAX_ATTEMPTS, now, limit)
|
||||
.all<{
|
||||
delivery_id: string;
|
||||
attempt_count: number;
|
||||
webhook_id: string;
|
||||
endpoint: string;
|
||||
secret: string;
|
||||
enabled: number;
|
||||
email_id: string;
|
||||
mailbox_id: string | null;
|
||||
from_address: string | null;
|
||||
to_addresses: string;
|
||||
subject: string | null;
|
||||
text_body: string | null;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
}>();
|
||||
.all<DueDeliveryRow>();
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
@@ -149,7 +347,6 @@ export async function processDueDeliveries(
|
||||
exhausted++;
|
||||
continue;
|
||||
}
|
||||
if (!row.enabled) continue;
|
||||
|
||||
const mailbox = row.mailbox_id
|
||||
? (
|
||||
@@ -168,7 +365,7 @@ export async function processDueDeliveries(
|
||||
receivedAt: row.received_at,
|
||||
});
|
||||
const result = await attemptDelivery(
|
||||
{ id: row.webhook_id, endpoint: row.endpoint, secret: row.secret },
|
||||
{ url: row.url, headers: safeParseHeaders(row.headers), key: await decryptWebhookKey(env, row.key_enc) },
|
||||
row.delivery_id,
|
||||
payload,
|
||||
resolve,
|
||||
@@ -200,3 +397,32 @@ export async function processDueDeliveries(
|
||||
}
|
||||
return { processed: due.results.length, success, failed, exhausted };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 入队:为邮件按三级解析结果创建一条投递记录(未配置任何 Webhook 则不入队)
|
||||
* 返回值: 命中的层级或 null
|
||||
*/
|
||||
export async function enqueueDeliveryForEmail(
|
||||
env: Env,
|
||||
emailId: string,
|
||||
mailboxId: string | null,
|
||||
): Promise<WebhookConfig["targetType"] | null> {
|
||||
const config = await resolveWebhookConfig(env, mailboxId);
|
||||
if (!config) return null;
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO webhook_deliveries (id, email_id, target_type, url, headers, key_enc, status, attempt_count, next_retry_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
ids.delivery(),
|
||||
emailId,
|
||||
config.targetType,
|
||||
config.url,
|
||||
JSON.stringify(config.headers),
|
||||
await encryptWebhookKey(env, config.key),
|
||||
nowIso(),
|
||||
nowIso(),
|
||||
)
|
||||
.run();
|
||||
return config.targetType;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user