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:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+202
View File
@@ -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 };
}