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
+281
View File
@@ -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();
}
}