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