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,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