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
+77
View File
@@ -0,0 +1,77 @@
/**
* LocalD1:基于 bun:sqlite 的 D1 API 子集实现(仅本地开发/测试用)。
* 与 Cloudflare D1 的 prepare/bind/first/all/run/batch/exec 行为对齐:
* - all() -> { results: rows }
* - first()-> row | null
* - run() -> { success, meta: { changes, last_row_id } }
* - bind(undefined) 宽容为 null
*/
import { Database, type SQLQueryBindings } from "bun:sqlite";
import type { D1Database, D1PreparedStatement } from "../env.ts";
/** # 成员变量: LocalD1 实例 **/
export class LocalD1 implements D1Database {
/** # 成员变量: 底层 SQLite 句柄 **/
readonly db: Database;
/** # 成员变量 **/
constructor(db: Database) {
this.db = db;
}
prepare(query: string): D1PreparedStatement {
return new LocalStatement(this.db, query);
}
async batch<T>(statements: D1PreparedStatement[]): Promise<T[]> {
const out: T[] = [];
const tx = this.db.transaction(() => {
for (const stmt of statements) {
out.push(this.db.exec((stmt as LocalStatement).sql) as T);
}
});
tx();
return out;
}
async exec(query: string): Promise<unknown> {
return this.db.exec(query);
}
}
/** # 成员变量: 单条预处理语句 **/
class LocalStatement implements D1PreparedStatement {
/** # 成员变量: 绑定参数 **/
private params: unknown[] = [];
/** # 成员变量 **/
constructor(
private db: Database,
readonly sql: string,
) {}
bind(...values: unknown[]): D1PreparedStatement {
const stmt = new LocalStatement(this.db, this.sql);
stmt.params = values.map((v) => (v === undefined ? null : v));
return stmt;
}
async first<T = Record<string, unknown>>(_colName?: string): Promise<T | null> {
const row = this.db.prepare(this.sql).get(...(this.params as SQLQueryBindings[])) as T | null;
return row ?? null;
}
async all<T = Record<string, unknown>>(): Promise<{ results: T[]; success?: boolean }> {
const rows = this.db.prepare(this.sql).all(...(this.params as SQLQueryBindings[])) as T[];
return { results: rows, success: true };
}
async run(): Promise<{ success: boolean; meta: { changes?: number; last_row_id?: number } }> {
const meta = this.db.prepare(this.sql).run(...(this.params as SQLQueryBindings[]));
return {
success: true,
meta: { changes: Number(meta.changes), last_row_id: Number(meta.lastInsertRowid) },
};
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Console 本地运行时入口(开发用):
* bun console/src/local/main.ts
*
* - bun:sqlite 文件库(LocalD1)+ 文件系统 R2(LocalR2)
* - 启动时自动应用 migrations/*.sql(幂等)与 admin/admin 种子
* - 每 60s 本地模拟一次 Cloudflare Cron(TTL 清理 + Webhook 重试)
* - 静态资源直接读 console/public(SPA fallback index.html)
*/
import { Database } from "bun:sqlite";
import { readdirSync, existsSync } from "node:fs";
import path from "node:path";
import { createApp } from "../app.ts";
import { LocalD1 } from "./d1.ts";
import { LocalR2 } from "./r2.ts";
import type { Env } from "../env.ts";
import { runCron } from "../services/cleanup.ts";
import { processDueDeliveries } from "../services/webhook_delivery.ts";
import { resolveHost } from "../ssrf-resolver.ts";
import { APP_NAME, APP_VERSION } from "../version.ts";
const ROOT = path.resolve(import.meta.dir, "../.."); // console/
const DATA_DIR = process.env.WMAILHUB_DATA_DIR ?? path.join(ROOT, "data");
const PUBLIC_DIR = path.join(ROOT, "public");
const PORT = Number(process.env.WMAILHUB_CONSOLE_PORT ?? 18787);
// ---- 数据库:自动迁移 -------------------------------------------------------
await mkdirSafe(DATA_DIR);
const db = new Database(path.join(DATA_DIR, "wmailhub.local.sqlite"), { create: true, strict: true });
db.run("PRAGMA journal_mode = WAL;");
const migrationsDir = path.join(ROOT, "..", "migrations");
if (existsSync(migrationsDir)) {
for (const file of readdirSync(migrationsDir).sort()) {
if (!file.endsWith(".sql")) continue;
const sql = await Bun.file(path.join(migrationsDir, file)).text();
db.exec(sql);
console.log(`[console] migration applied: ${file}`);
}
}
// ---- env 绑定 ---------------------------------------------------------------
const env: Env = {
DB: new LocalD1(db),
R2: new LocalR2(path.join(DATA_DIR, "r2")),
SESSION_SECRET: process.env.WMAILHUB_SESSION_SECRET,
PUBLIC_BASE_URL: process.env.WMAILHUB_PUBLIC_BASE_URL ?? `http://127.0.0.1:${PORT}`,
WMAILHUB_SIM_CF: process.env.WMAILHUB_SIM_CF ?? "1",
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: process.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS ?? "1",
};
// ---- 静态资源 ----------------------------------------------------------------
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".json": "application/json",
".woff2": "font/woff2",
".map": "application/json",
};
async function serveStatic(request: Request): Promise<Response> {
const url = new URL(request.url);
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/") pathname = "/index.html";
const file = path.normalize(path.join(PUBLIC_DIR, pathname));
if (file.startsWith(PUBLIC_DIR) && existsSync(file) && !existsSync(file + "/")) {
const ext = path.extname(file).toLowerCase();
return new Response(Bun.file(file), {
headers: { "Content-Type": MIME[ext] ?? "application/octet-stream" },
});
}
// SPA fallback
const index = path.join(PUBLIC_DIR, "index.html");
if (existsSync(index)) {
return new Response(Bun.file(index), {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return new Response("frontend not built. run: bun run build", { status: 404 });
}
// ---- 启动 ---------------------------------------------------------------------
const app = createApp({ serveStatic });
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
fetch: (req) => app.fetch(req, env),
});
console.log(
JSON.stringify({
t: new Date().toISOString(),
lvl: "info",
msg: "console started",
service: APP_NAME,
version: APP_VERSION,
url: `http://127.0.0.1:${PORT}`,
login: "admin/admin (default)",
}),
);
// 本地模拟 Cloudflare Cron:每 60s 清理过期邮件 + 投递到期 webhook
const cronTimer = setInterval(() => {
void runCron(env, () => processDueDeliveries(env, resolveHost, 20, true))
.then((stats) => {
if (stats.expiredEmails || stats.orphanAttachments || stats.errors.length) {
console.log(JSON.stringify({ t: new Date().toISOString(), lvl: "info", msg: "cron", ...stats }));
}
})
.catch((err) => {
console.error(JSON.stringify({ t: new Date().toISOString(), lvl: "error", msg: "cron failed", error: String(err) }));
});
}, 60_000);
/** 优雅退出:停 cron、停服务、关库 */
function shutdown(signal: string) {
console.log(JSON.stringify({ t: new Date().toISOString(), lvl: "info", msg: "console shutting down", signal }));
clearInterval(cronTimer);
server.stop(true);
db.close();
process.exit(0);
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
async function mkdirSafe(dir: string) {
const { mkdir } = await import("node:fs/promises");
await mkdir(dir, { recursive: true });
}
+66
View File
@@ -0,0 +1,66 @@
/**
* LocalR2:基于文件系统的 R2 API 子集实现(仅本地开发/测试用)。
* key 直接映射为 root 下的相对路径(含子目录)。
*/
import { mkdir, rm } from "node:fs/promises";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import type { R2Bucket, R2ObjectBody } from "../env.ts";
/** # 成员变量: LocalR2 实例 **/
export class LocalR2 implements R2Bucket {
/** # 成员变量 **/
constructor(private root: string) {
mkdirSync(root, { recursive: true });
}
private resolve(key: string): string {
const safe = path.normalize(key).replace(/^(\.\.[/\\])+/, "");
return path.join(this.root, safe);
}
async put(
key: string,
value: ArrayBuffer | Uint8Array | string | ReadableStream | Blob,
): Promise<unknown> {
const file = this.resolve(key);
await mkdir(path.dirname(file), { recursive: true });
const data =
typeof value === "string"
? new TextEncoder().encode(value)
: value instanceof Uint8Array
? value
: value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(await new Response(value as ReadableStream).arrayBuffer());
await Bun.write(file, data);
return { key };
}
async get(key: string): Promise<R2ObjectBody | null> {
const file = this.resolve(key);
if (!existsSync(file)) return null;
const buf = await Bun.file(file).arrayBuffer();
return {
arrayBuffer: async () => buf,
text: async () => new TextDecoder().decode(buf),
json: async () => JSON.parse(new TextDecoder().decode(buf)),
};
}
async delete(keys: string | string[]): Promise<void> {
const list = Array.isArray(keys) ? keys : [keys];
for (const key of list) {
const file = this.resolve(key);
await rm(file, { force: true });
}
}
async head(key: string): Promise<{ key: string; size: number } | null> {
const file = this.resolve(key);
if (!existsSync(file)) return null;
const stat = await Bun.file(file).stat();
return { key, size: stat.size };
}
}