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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user