feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)
- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin) - Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出) - Email Worker:Cloudflare email handler + 本地联调入口 - shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护 - 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天 - Webhook:签名投递、指数退避重试、SSRF 校验、投递日志 - API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离) - Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令) - 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟 - LOGO/favicon 由 codex CLI 生成
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Cloudflare API 客户端抽象。
|
||||
*
|
||||
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
|
||||
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
|
||||
* 让 Add Node 自动安装链路在本地完整可演示、可测试。
|
||||
*
|
||||
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
|
||||
*/
|
||||
|
||||
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
|
||||
import type { Env } from "../env.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: OAuth 授权端点(real 模式) **/
|
||||
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
||||
|
||||
/** # 成员变量: OAuth token 端点(real 模式) **/
|
||||
export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
||||
|
||||
/** # 成员变量: Cloudflare API 基址(real 模式) **/
|
||||
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
||||
export const CF_OAUTH_SCOPES = [
|
||||
"account:read",
|
||||
"zone:read",
|
||||
"email_routing:edit",
|
||||
"workers_scripts:edit",
|
||||
].join(" ");
|
||||
|
||||
/** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/
|
||||
export interface CFAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CFZone {
|
||||
id: string;
|
||||
name: string;
|
||||
account_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||
export interface CFApi {
|
||||
/** 用 code 换 access token(real);sim 直接签发假 token */
|
||||
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
||||
/** 发现 token 可访问的账户 */
|
||||
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
||||
/** 发现账户下的域名 Zone */
|
||||
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
||||
/** 创建或更新 Email Worker(幂等) */
|
||||
putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/** 开启 Zone 的 Email Routing(幂等) */
|
||||
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
||||
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
|
||||
setCatchAllRule(opts: {
|
||||
accessToken: string;
|
||||
zoneId: string;
|
||||
workerName: string;
|
||||
}): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 根据环境构造 Cloudflare API 客户端(real 或 sim)
|
||||
*/
|
||||
export function getCFApi(env: Env, db: Env["DB"]): CFApi {
|
||||
const real = env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF;
|
||||
if (real) return new RealCFApi(env);
|
||||
return new SimCFApi(db);
|
||||
}
|
||||
|
||||
/** # 成员变量: 真实 Cloudflare API 实现 **/
|
||||
export class RealCFApi implements CFApi {
|
||||
/** # 成员变量 **/
|
||||
constructor(private env: Env) {}
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: this.env.CF_OAUTH_CLIENT_ID ?? "",
|
||||
client_secret: this.env.CF_OAUTH_CLIENT_SECRET ?? "",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`cloudflare token exchange failed: ${resp.status}`);
|
||||
const data = (await resp.json()) as { access_token: string; expires_in?: number };
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
||||
}
|
||||
|
||||
private async api<T>(token: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const resp = await fetch(`${CF_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const data = (await resp.json()) as { success: boolean; errors?: { message: string }[]; result: T };
|
||||
if (!data.success) throw new Error(`cloudflare api ${path}: ${data.errors?.[0]?.message ?? resp.status}`);
|
||||
return data.result;
|
||||
}
|
||||
|
||||
listAccounts(token: string) {
|
||||
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 putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
}) {
|
||||
// Email Worker 即带 email handler 的 Worker 脚本;ingress 地址与密钥以明文常量打进脚本,
|
||||
// 避免给 Worker 配置 Console 凭据(docs/08:Worker 只持有 Node ingress secret)。
|
||||
const script = renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"metadata",
|
||||
new Blob([JSON.stringify({ main_module: "worker.js", bindings: [] })], { type: "application/json" }),
|
||||
);
|
||||
form.append("worker.js", new Blob([script], { type: "application/javascript+module" }));
|
||||
const resp = await fetch(
|
||||
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${opts.accessToken}` },
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`upload email worker failed: ${resp.status}`);
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: true, skip_wizard: true }),
|
||||
});
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/rules/catch_all`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
matchers: [{ type: "all" }],
|
||||
actions: [{ type: "worker", value: [opts.workerName] }],
|
||||
enabled: true,
|
||||
name: "wmailhub catch-all",
|
||||
}),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: 本地 sim 实现:资源写入 cf_sim_resources,行为与真实链路一致 **/
|
||||
export class SimCFApi implements CFApi {
|
||||
/** # 成员变量 **/
|
||||
constructor(private db: Env["DB"]) {}
|
||||
|
||||
private async record(kind: string, refId: string, payload: unknown): Promise<void> {
|
||||
await this.db
|
||||
.prepare(
|
||||
"INSERT INTO cf_sim_resources (id, kind, ref_id, payload, created_at) VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(id) DO UPDATE SET payload = excluded.payload",
|
||||
)
|
||||
.bind(ids.simResource(), kind, refId, JSON.stringify(payload), nowIso())
|
||||
.run();
|
||||
}
|
||||
|
||||
async exchangeToken(_code: string, _redirectUri: string) {
|
||||
return { accessToken: "sim_" + ids.oauth(), expiresIn: 3600 };
|
||||
}
|
||||
|
||||
async listAccounts(_token: string): Promise<CFAccount[]> {
|
||||
// sim 固定一个演示账户;幂等记录
|
||||
const account = { id: "sim-account-0001", name: "WmailHub Sim Account" };
|
||||
await this.record("account", account.id, account);
|
||||
return [account];
|
||||
}
|
||||
|
||||
async listZones(_token: string, accountId: string): Promise<CFZone[]> {
|
||||
const zones = [
|
||||
{
|
||||
id: "sim-zone-example.test",
|
||||
name: "example.test",
|
||||
account_id: accountId,
|
||||
status: "active",
|
||||
},
|
||||
];
|
||||
for (const z of zones) await this.record("zone", z.id, z);
|
||||
return zones;
|
||||
}
|
||||
|
||||
async putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
}) {
|
||||
// 与真实实现同一模板渲染,但只记录不外发
|
||||
await this.record("email_worker", opts.scriptName, {
|
||||
account_id: opts.accountId,
|
||||
ingress_url: opts.ingressUrl,
|
||||
script: renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret).length,
|
||||
});
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
async enableEmailRouting(opts: { zoneId: string }) {
|
||||
await this.record("routing_settings", opts.zoneId, { enabled: true });
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
async setCatchAllRule(opts: { zoneId: string; workerName: string }) {
|
||||
await this.record("routing_rule", opts.zoneId, {
|
||||
type: "catch_all",
|
||||
action: "worker",
|
||||
value: opts.workerName,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* TTL 清理与 orphan 清理(docs/06、docs/08):
|
||||
* - cleanupExpiredEmails:expires_at <= now 的邮件 -> 先删 R2 对象(raw + 附件),全部成功才删行;
|
||||
* R2 失败则保留 DB 记录留待下次 Cron 重试(幂等)。
|
||||
* - cleanupOrphans:附件行存在但邮件已删 -> 删 R2 对象 + 附件行;
|
||||
* delivery 指向已删邮件 -> 删 delivery 行(数据最小化)。
|
||||
* - housekeeping:过期 nonce、过期 OAuth state 清理。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 清理统计 **/
|
||||
export interface CleanupStats {
|
||||
expiredEmails: number;
|
||||
deferredEmails: number;
|
||||
orphanAttachments: number;
|
||||
orphanDeliveries: number;
|
||||
housekeeping: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Cron 主入口:TTL 清理 + orphan 清理 + webhook 投递重试 + 琐碎清理。全程幂等。
|
||||
*/
|
||||
export async function runCron(
|
||||
env: Env,
|
||||
deliverDue: () => Promise<unknown>,
|
||||
): Promise<CleanupStats> {
|
||||
const stats: CleanupStats = {
|
||||
expiredEmails: 0,
|
||||
deferredEmails: 0,
|
||||
orphanAttachments: 0,
|
||||
orphanDeliveries: 0,
|
||||
housekeeping: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
await cleanupExpiredEmails(env, stats);
|
||||
await cleanupOrphans(env, stats);
|
||||
await housekeeping(env, stats);
|
||||
try {
|
||||
await deliverDue();
|
||||
} catch (err) {
|
||||
stats.errors.push(`deliver: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 删除过期邮件(R2 先行,全部成功才删 DB)
|
||||
*/
|
||||
export async function cleanupExpiredEmails(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const now = nowIso();
|
||||
const expired = await env.DB.prepare(
|
||||
"SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? LIMIT 100",
|
||||
)
|
||||
.bind(now)
|
||||
.all<{ id: string; raw_storage_key: string }>();
|
||||
|
||||
for (const email of expired.results) {
|
||||
try {
|
||||
const atts = await env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
|
||||
.bind(email.id)
|
||||
.all<{ storage_key: string | null }>();
|
||||
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(
|
||||
(k): k is string => Boolean(k),
|
||||
);
|
||||
if (keys.length > 0) await env.R2.delete(keys);
|
||||
// R2 delete 幂等,成功后删除 DB 行
|
||||
await env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(email.id).run();
|
||||
await env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(email.id).run();
|
||||
stats.expiredEmails++;
|
||||
} catch (err) {
|
||||
// R2 失败:保留记录,下次重试
|
||||
stats.deferredEmails++;
|
||||
stats.errors.push(`cleanup email ${email.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* orphan 清理:无主附件行、无主 delivery
|
||||
*/
|
||||
export async function cleanupOrphans(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const orphanAtts = await env.DB.prepare(
|
||||
"SELECT a.id, a.storage_key FROM email_attachments a LEFT JOIN emails e ON e.id = a.email_id WHERE e.id IS NULL LIMIT 100",
|
||||
).all<{ id: string; storage_key: string | null }>();
|
||||
for (const att of orphanAtts.results) {
|
||||
try {
|
||||
if (att.storage_key) await env.R2.delete(att.storage_key);
|
||||
await env.DB.prepare("DELETE FROM email_attachments WHERE id = ?").bind(att.id).run();
|
||||
stats.orphanAttachments++;
|
||||
} catch (err) {
|
||||
stats.errors.push(`orphan attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const orphanDeliveries = await env.DB.prepare(
|
||||
"SELECT d.id FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id WHERE e.id IS NULL LIMIT 100",
|
||||
).all<{ id: string }>();
|
||||
for (const d of orphanDeliveries.results) {
|
||||
await env.DB.prepare("DELETE FROM webhook_deliveries WHERE id = ?").bind(d.id).run();
|
||||
stats.orphanDeliveries++;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 琐碎清理:过期 nonce、过期 OAuth state
|
||||
*/
|
||||
export async function housekeeping(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const now = nowIso();
|
||||
const n1 = await env.DB.prepare("DELETE FROM nonces WHERE expires_at <= ?").bind(now).run();
|
||||
const n2 = await env.DB.prepare("DELETE FROM oauth_states WHERE expires_at <= ?").bind(now).run();
|
||||
stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 邮件接收管线(docs/06):
|
||||
* Console 收到 Node 转发的 raw MIME 后:
|
||||
* 校验大小限制 -> postal-mime 解析 -> 关联 Mailbox(精确匹配,否则 Domain catch-all)->
|
||||
* raw 写 R2、附件写 R2 -> emails/email_attachments 入库 -> 计算 expires_at ->
|
||||
* 为启用的 Webhook 创建 delivery(投递失败不影响邮件入库)。
|
||||
*
|
||||
* malformed mail 不拒绝:尽量解析,失败时仍保存 raw 并以 fallback 字段入库(可观测)。
|
||||
*/
|
||||
|
||||
import PostalMime from "postal-mime";
|
||||
import type { Env } from "../env.ts";
|
||||
import {
|
||||
ids,
|
||||
DEFAULT_RETENTION_SECONDS,
|
||||
MAX_EMAIL_BYTES,
|
||||
MAX_ATTACHMENTS,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/
|
||||
interface ParsedMail {
|
||||
messageId?: string;
|
||||
from?: string;
|
||||
to?: { address?: string }[];
|
||||
cc?: { address?: string }[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: {
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
content: Uint8Array;
|
||||
contentId?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** # 成员变量: 入站信封信息 **/
|
||||
export interface IngestInput {
|
||||
/** raw MIME 原文 **/
|
||||
raw: Uint8Array;
|
||||
/** SMTP 信封发件人(Email Worker 传递) **/
|
||||
mailFrom: string;
|
||||
/** SMTP 信封收件人(Email Worker 传递) **/
|
||||
rcptTo: string;
|
||||
/** 接收该邮件的 Node ID **/
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: 入库结果 **/
|
||||
export interface IngestResult {
|
||||
id: string;
|
||||
mailboxId: string | null;
|
||||
expiresAt: string;
|
||||
attachmentCount: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 存储路径:emails/{yyyy}/{mm}/{dd}/{email_id}/raw.eml(docs/09)
|
||||
*/
|
||||
export function rawStorageKey(emailId: string, date = new Date()): string {
|
||||
const yyyy = date.getUTCFullYear();
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
return `emails/${yyyy}/${mm}/${dd}/${emailId}/raw.eml`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 附件路径:emails/{...}/{email_id}/attachments/{attachment_id}
|
||||
*/
|
||||
export function attachmentStorageKey(emailId: string, attachmentId: string, date = new Date()): string {
|
||||
return rawStorageKey(emailId, date).replace(/raw\.eml$/, `attachments/${attachmentId}`);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 邮件入库主流程;大小/数量超限抛错(由 internal 路由转为 413)
|
||||
*/
|
||||
export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestResult> {
|
||||
if (input.raw.byteLength > MAX_EMAIL_BYTES) {
|
||||
throw new IngestError(`email exceeds max size ${MAX_EMAIL_BYTES}`, 413);
|
||||
}
|
||||
|
||||
const receivedAt = nowIso();
|
||||
const receivedDate = new Date(receivedAt);
|
||||
|
||||
// MIME 解析;malformed 邮件降级入库
|
||||
let parsed: ParsedMail | null = null;
|
||||
try {
|
||||
parsed = (await PostalMime.parse(input.raw)) as unknown as ParsedMail;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const toAddresses = (parsed?.to ?? [])
|
||||
.map((t) => t.address ?? "")
|
||||
.filter(Boolean)
|
||||
.concat(parsed ? [] : input.rcptTo ? [input.rcptTo] : []);
|
||||
const ccAddresses = (parsed?.cc ?? []).map((t) => t.address ?? "").filter(Boolean);
|
||||
// postal-mime 的 from 是 { address, name } 对象(也可能是数组)
|
||||
const fromAddress =
|
||||
typeof parsed?.from === "string"
|
||||
? parsed.from
|
||||
: Array.isArray(parsed?.from)
|
||||
? ((parsed.from[0] as { address?: string } | undefined)?.address ?? input.mailFrom)
|
||||
: ((parsed?.from as { address?: string } | undefined)?.address ?? input.mailFrom);
|
||||
|
||||
// Mailbox 关联:任意收件地址精确匹配,否则该域名 catch-all,否则 NULL
|
||||
const emailId = ids.email();
|
||||
const mailboxId = await resolveMailbox(env, [
|
||||
...toAddresses,
|
||||
...(toAddresses.length === 0 && input.rcptTo ? [input.rcptTo] : []),
|
||||
]);
|
||||
|
||||
// 保留策略:mailbox 覆盖 > 全局默认
|
||||
let retention = DEFAULT_RETENTION_SECONDS;
|
||||
if (mailboxId) {
|
||||
const mb = await env.DB.prepare("SELECT retention_seconds FROM mailboxes WHERE id = ?")
|
||||
.bind(mailboxId)
|
||||
.first<{ retention_seconds: number | null }>();
|
||||
if (mb?.retention_seconds) retention = mb.retention_seconds;
|
||||
}
|
||||
const expiresAt = new Date(receivedDate.getTime() + retention * 1000).toISOString();
|
||||
|
||||
// R2:raw MIME
|
||||
const rawKey = rawStorageKey(emailId, receivedDate);
|
||||
await env.R2.put(rawKey, input.raw, {
|
||||
httpMetadata: { "Content-Type": "message/rfc822" },
|
||||
});
|
||||
|
||||
// 附件(数量/单文件限额,超限附件跳过并继续)
|
||||
const attachments = (parsed?.attachments ?? []).slice(0, MAX_ATTACHMENTS);
|
||||
const attachmentRows: {
|
||||
id: string;
|
||||
email_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
storage_key: string;
|
||||
content_id: string | null;
|
||||
}[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.content.byteLength > MAX_ATTACHMENT_BYTES) continue;
|
||||
const attId = ids.attachment();
|
||||
const key = attachmentStorageKey(emailId, attId, receivedDate);
|
||||
await env.R2.put(key, att.content, {
|
||||
httpMetadata: { "Content-Type": att.mimeType || "application/octet-stream" },
|
||||
});
|
||||
attachmentRows.push({
|
||||
id: attId,
|
||||
email_id: emailId,
|
||||
filename: att.filename || "unnamed",
|
||||
content_type: att.mimeType || "application/octet-stream",
|
||||
size: att.content.byteLength,
|
||||
storage_key: key,
|
||||
content_id: att.contentId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// 入库
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO emails (id, node_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
subject, text_body, html_body, raw_storage_key, size, received_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
emailId,
|
||||
input.nodeId,
|
||||
mailboxId,
|
||||
parsed?.messageId ?? null,
|
||||
fromAddress,
|
||||
JSON.stringify(toAddresses),
|
||||
JSON.stringify(ccAddresses),
|
||||
parsed?.subject ?? "(unparsable mail)",
|
||||
parsed?.text ?? null,
|
||||
parsed?.html ?? null,
|
||||
rawKey,
|
||||
input.raw.byteLength,
|
||||
receivedAt,
|
||||
expiresAt,
|
||||
receivedAt,
|
||||
)
|
||||
.run();
|
||||
|
||||
for (const row of attachmentRows) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO email_attachments (id, email_id, filename, content_type, size, storage_key, content_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(row.id, row.email_id, row.filename, row.content_type, row.size, row.storage_key, row.content_id)
|
||||
.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,
|
||||
});
|
||||
|
||||
return {
|
||||
id: emailId,
|
||||
mailboxId,
|
||||
expiresAt,
|
||||
attachmentCount: attachmentRows.length,
|
||||
size: input.raw.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 成员变量: 带自定义 payload 的入站错误(HTTP 状态码) **/
|
||||
export class IngestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all
|
||||
*/
|
||||
export async function resolveMailbox(env: Env, addresses: string[]): Promise<string | null> {
|
||||
for (const addr of addresses) {
|
||||
const a = addr.toLowerCase();
|
||||
const hit = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ? AND status = 'active'")
|
||||
.bind(a)
|
||||
.first<{ id: string }>();
|
||||
if (hit) return hit.id;
|
||||
}
|
||||
for (const addr of addresses) {
|
||||
const a = addr.toLowerCase();
|
||||
const domain = a.includes("@") ? a.split("@")[1] : null;
|
||||
if (!domain) continue;
|
||||
const catchAll = await env.DB.prepare(
|
||||
`SELECT m.id FROM mailboxes m JOIN domains d ON m.domain_id = d.id
|
||||
WHERE m.is_catch_all = 1 AND m.status = 'active' AND d.name = ?`,
|
||||
)
|
||||
.bind(domain)
|
||||
.first<{ id: string }>();
|
||||
if (catchAll) return catchAll.id;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Node 自动安装管线(docs/02 流程):
|
||||
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
|
||||
* 创建 Domain/Mailbox -> Node Ready。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
|
||||
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { getCFApi, type CFApi } from "./cfapi.ts";
|
||||
import { encryptSecret, decryptSecret } 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 创建" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
export type StepStatus = "pending" | "running" | "done" | "error";
|
||||
|
||||
/** # 函数
|
||||
* 初始化步骤状态 JSON
|
||||
*/
|
||||
export function initialSteps(): Record<string, { status: StepStatus; error?: string }> {
|
||||
const out: Record<string, { status: StepStatus; error?: string }> = {};
|
||||
for (const s of PROVISION_STEPS) out[s.key] = { status: "pending" };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
*/
|
||||
export async function createNode(
|
||||
env: Env,
|
||||
opts: { name: string; ingressUrl: string },
|
||||
): Promise<{
|
||||
nodeId: string;
|
||||
enrollmentToken: string;
|
||||
ingressSecret: string;
|
||||
}> {
|
||||
const nodeId = ids.node();
|
||||
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,
|
||||
provision_steps, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptText(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return { nodeId, enrollmentToken, ingressSecret };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 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 = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
|
||||
return {
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptText(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;
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
|
||||
* 返回是否全部成功;失败时步骤状态里带 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 = ?",
|
||||
)
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node) return { ok: false, error: "node not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
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)
|
||||
.run();
|
||||
};
|
||||
|
||||
try {
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. 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]
|
||||
: accounts[0];
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, 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");
|
||||
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,
|
||||
});
|
||||
}
|
||||
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 }>();
|
||||
for (const d of domainRows.results) {
|
||||
await upsertMailbox(env, {
|
||||
domainId: d.id,
|
||||
address: `*@${d.name}`,
|
||||
isCatchAll: true,
|
||||
});
|
||||
}
|
||||
await mark("mailboxes", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// 标记第一个未完成步骤为 error
|
||||
for (const s of PROVISION_STEPS) {
|
||||
if (steps[s.key]?.status === "running") {
|
||||
await mark(s.key, "error", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
*/
|
||||
export async function loadSteps(env: Env, nodeId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
|
||||
.bind(nodeId)
|
||||
.first<{ provision_steps: string }>();
|
||||
if (!row?.provision_steps) return initialSteps();
|
||||
try {
|
||||
return JSON.parse(row.provision_steps) as Record<string, { status: StepStatus; error?: string }>;
|
||||
} catch {
|
||||
return initialSteps();
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
d: {
|
||||
cloudflareAccountId: string;
|
||||
nodeId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
)
|
||||
.bind(d.cloudflareAccountId, d.name)
|
||||
.first<{ id: string }>();
|
||||
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();
|
||||
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', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Mailbox(按 address 幂等)
|
||||
*/
|
||||
export async function upsertMailbox(
|
||||
env: Env,
|
||||
m: { domainId: string; address: string; isCatchAll?: boolean; retentionSeconds?: number },
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?")
|
||||
.bind(m.address.toLowerCase())
|
||||
.first<{ id: string }>();
|
||||
const now = nowIso();
|
||||
if (existing) return existing.id;
|
||||
const id = ids.mailbox();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, retention_seconds, is_catch_all, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
m.domainId,
|
||||
m.address.toLowerCase(),
|
||||
m.retentionSeconds ?? DEFAULT_RETENTION_SECONDS,
|
||||
m.isCatchAll ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Webhook 投递与重试(docs/06、docs/07):
|
||||
* - payload 按文档 JSON 结构(event=email.received)
|
||||
* - 签名头:X-WmailHub-Event / Delivery / Timestamp / Signature
|
||||
* signature = HMAC-SHA256(webhook_secret, `${timestamp}.${rawBody}`)
|
||||
* - 2xx 成功;5xx/超时重试(指数退避);4xx 记失败并按策略重试到上限
|
||||
* - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时)
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import {
|
||||
hmacSha256Hex,
|
||||
assertWebhookUrlSafe,
|
||||
type HostResolver,
|
||||
WEBHOOK_HEADERS,
|
||||
WEBHOOK_TIMEOUT_MS,
|
||||
WEBHOOK_MAX_ATTEMPTS,
|
||||
WEBHOOK_RETRY_BASE_MS,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: Webhook 投递 payload 结构(docs/07) **/
|
||||
export function buildWebhookPayload(email: {
|
||||
id: string;
|
||||
mailboxAddress: string | null;
|
||||
fromAddress: string | null;
|
||||
toAddresses: string;
|
||||
subject: string | null;
|
||||
textBody: string | null;
|
||||
receivedAt: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
event: "email.received",
|
||||
id: email.id,
|
||||
mailbox: email.mailboxAddress,
|
||||
from: email.fromAddress,
|
||||
to: safeParseArray(email.toAddresses),
|
||||
subject: email.subject,
|
||||
text: email.textBody,
|
||||
received_at: email.receivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function safeParseArray(s: string | null): string[] {
|
||||
if (!s) return [];
|
||||
try {
|
||||
const v = JSON.parse(s);
|
||||
return Array.isArray(v) ? v.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 执行一次投递;返回是否成功与错误信息(不修改库)
|
||||
*/
|
||||
export async function attemptDelivery(
|
||||
webhook: { id: string; endpoint: string; secret: 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);
|
||||
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}`);
|
||||
try {
|
||||
const resp = await fetch(webhook.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[WEBHOOK_HEADERS.event]: "email.received",
|
||||
[WEBHOOK_HEADERS.delivery]: deliveryId,
|
||||
[WEBHOOK_HEADERS.timestamp]: timestamp,
|
||||
[WEBHOOK_HEADERS.signature]: signature,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
||||
});
|
||||
if (resp.ok) return { ok: true, status: resp.status };
|
||||
return { ok: false, status: resp.status, error: `endpoint responded ${resp.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 计算第 attempt 次失败后的下次重试时间(指数退避)
|
||||
*/
|
||||
export function nextRetryIso(attemptCount: number): string {
|
||||
const delay = WEBHOOK_RETRY_BASE_MS * 2 ** attemptCount;
|
||||
return new Date(Date.now() + delay).toISOString();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Cron 驱动的投递循环:取到期 delivery 逐个投递并落状态。
|
||||
* 返回处理统计(供日志与 admin 接口)。
|
||||
*/
|
||||
export async function processDueDeliveries(
|
||||
env: Env,
|
||||
resolve?: HostResolver,
|
||||
limit = 20,
|
||||
allowPrivateHosts = false,
|
||||
): 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,
|
||||
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 < ?
|
||||
AND (d.next_retry_at IS NULL OR d.next_retry_at <= ?)
|
||||
ORDER BY d.created_at
|
||||
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;
|
||||
}>();
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let exhausted = 0;
|
||||
for (const row of due.results) {
|
||||
if (row.expires_at <= now) {
|
||||
// 邮件已过期:投递失去意义,直接结束(数据最小化)
|
||||
await env.DB.prepare("UPDATE webhook_deliveries SET status = 'exhausted', last_error = 'email expired' WHERE id = ?")
|
||||
.bind(row.delivery_id)
|
||||
.run();
|
||||
exhausted++;
|
||||
continue;
|
||||
}
|
||||
if (!row.enabled) continue;
|
||||
|
||||
const mailbox = row.mailbox_id
|
||||
? (
|
||||
await env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?")
|
||||
.bind(row.mailbox_id)
|
||||
.first<{ address: string }>()
|
||||
)?.address ?? null
|
||||
: null;
|
||||
const payload = buildWebhookPayload({
|
||||
id: row.email_id,
|
||||
mailboxAddress: mailbox,
|
||||
fromAddress: row.from_address,
|
||||
toAddresses: row.to_addresses,
|
||||
subject: row.subject,
|
||||
textBody: row.text_body,
|
||||
receivedAt: row.received_at,
|
||||
});
|
||||
const result = await attemptDelivery(
|
||||
{ id: row.webhook_id, endpoint: row.endpoint, secret: row.secret },
|
||||
row.delivery_id,
|
||||
payload,
|
||||
resolve,
|
||||
allowPrivateHosts,
|
||||
);
|
||||
const attempts = row.attempt_count + 1;
|
||||
if (result.ok) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE webhook_deliveries SET status = 'success', attempt_count = ?, delivered_at = ?, last_error = NULL, next_retry_at = NULL WHERE id = ?",
|
||||
)
|
||||
.bind(attempts, nowIso(), row.delivery_id)
|
||||
.run();
|
||||
success++;
|
||||
} else if (attempts >= WEBHOOK_MAX_ATTEMPTS) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE webhook_deliveries SET status = 'exhausted', attempt_count = ?, last_error = ? WHERE id = ?",
|
||||
)
|
||||
.bind(attempts, result.error ?? "unknown", row.delivery_id)
|
||||
.run();
|
||||
exhausted++;
|
||||
} else {
|
||||
await env.DB.prepare(
|
||||
"UPDATE webhook_deliveries SET status = 'failed', attempt_count = ?, last_error = ?, next_retry_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(attempts, result.error ?? "unknown", nextRetryIso(attempts), row.delivery_id)
|
||||
.run();
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { processed: due.results.length, success, failed, exhausted };
|
||||
}
|
||||
Reference in New Issue
Block a user