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,74 @@
|
||||
/**
|
||||
* Console 应用装配(Hono,Workers/Bun 双运行时)。
|
||||
*
|
||||
* 路由分区:
|
||||
* /api/health 健康检查
|
||||
* /api/auth/* 用户登录/登出/会话(cookie)
|
||||
* /api/nodes|domains|mailboxes|emails 管理资源 CRUD(会话保护)
|
||||
* /api/webhooks/* Webhook 管理(会话保护)
|
||||
* /api/keys/* API/MCP Key 管理(会话保护)
|
||||
* /api/oauth/* Cloudflare OAuth(会话保护)
|
||||
* /api/admin/* 版本/统计/Cron(会话保护)
|
||||
* /api/internal/* Node 内部协议(HMAC 签名)
|
||||
* /api/v1/mcp MCP JSON-RPC(Bearer MCP Key wmh_)
|
||||
* /api/v1/* 外部 API(Bearer API Key wmh_)
|
||||
* /* 前端 SPA 静态资源
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "./env.ts";
|
||||
import { ensureSeeded } from "./db.ts";
|
||||
import { csrfOriginCheck } from "./middleware.ts";
|
||||
import { authRoutes } from "./auth.ts";
|
||||
import { internalRoutes } from "./routes/internal.ts";
|
||||
import { resourceRoutes } from "./routes/resources.ts";
|
||||
import { webhookRoutes } from "./routes/webhooks.ts";
|
||||
import { keyRoutes } from "./routes/keys.ts";
|
||||
import { oauthRoutes } from "./routes/oauth.ts";
|
||||
import { apiV1Routes } from "./routes/apiv1.ts";
|
||||
import { mcpRoutes } from "./routes/mcp.ts";
|
||||
import { adminRoutes } from "./routes/admin.ts";
|
||||
import { APP_NAME, APP_VERSION } from "./version.ts";
|
||||
|
||||
/** # 函数
|
||||
* 构造 Console Hono 应用实例
|
||||
* 参数: opts.serveStatic - 静态资源处理器(本地运行时注入;Workers 端走 ASSETS 绑定)
|
||||
*/
|
||||
export function createApp(
|
||||
opts: {
|
||||
serveStatic?: (request: Request) => Promise<Response>;
|
||||
} = {},
|
||||
): Hono<{ Bindings: Env; Variables: AppVars }> {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
|
||||
// 每个请求先保证种子数据(幂等,内存标志去重)
|
||||
app.use("*", async (c, next) => {
|
||||
await ensureSeeded(c.env);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use("/api/*", csrfOriginCheck);
|
||||
|
||||
app.get("/api/health", (c) => c.json({ ok: true, name: APP_NAME, version: APP_VERSION }));
|
||||
|
||||
app.route("/api/auth", authRoutes());
|
||||
app.route("/api/internal", internalRoutes());
|
||||
app.route("/api/oauth", oauthRoutes());
|
||||
app.route("/api/webhooks", webhookRoutes());
|
||||
app.route("/api/keys", keyRoutes());
|
||||
app.route("/api/admin", adminRoutes());
|
||||
app.route("/api/v1/mcp", mcpRoutes());
|
||||
app.route("/api/v1", apiV1Routes());
|
||||
app.route("/api", resourceRoutes());
|
||||
|
||||
// 静态资源 / SPA fallback
|
||||
app.get("/*", async (c) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (path.startsWith("/api/")) return c.json({ error: "not found" }, 404);
|
||||
if (opts.serveStatic) return opts.serveStatic(c.req.raw);
|
||||
if (c.env.ASSETS) return c.env.ASSETS.fetch(c.req.raw);
|
||||
return c.text("frontend assets not configured", 404);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Console 用户认证:用户名密码登录 + HMAC 签名会话 cookie。
|
||||
* cookie 值: base64url(payload) "." hex(HMAC-SHA256(sessionSecret, payload))
|
||||
*/
|
||||
|
||||
import { hmacSha256Hex, timingSafeEqualHex, verifyPassword } from "../../shared/src/index.ts";
|
||||
import type { Env } from "./env.ts";
|
||||
import { getSessionSecret } from "./db.ts";
|
||||
import { nowIso, readJson } from "./util.ts";
|
||||
import { Hono } from "hono";
|
||||
|
||||
/** # 成员变量: 会话 cookie 名称 **/
|
||||
export const SESSION_COOKIE = "wmh_session";
|
||||
|
||||
/** # 成员变量: 会话有效期(秒),默认 7 天 **/
|
||||
const SESSION_TTL_SECONDS = 7 * 24 * 3600;
|
||||
|
||||
/** # 成员变量: 会话 payload **/
|
||||
interface SessionPayload {
|
||||
uid: string;
|
||||
username: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 签发会话 token
|
||||
*/
|
||||
export async function issueSessionToken(env: Env, uid: string, username: string): Promise<string> {
|
||||
const payload: SessionPayload = {
|
||||
uid,
|
||||
username,
|
||||
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
|
||||
};
|
||||
const body = base64UrlEncode(JSON.stringify(payload));
|
||||
const secret = await getSessionSecret(env);
|
||||
const sig = await hmacSha256Hex(secret, body);
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 校验会话 token,返回 payload;无效/过期返回 null
|
||||
*/
|
||||
export async function verifySessionToken(
|
||||
env: Env,
|
||||
token: string | undefined,
|
||||
): Promise<SessionPayload | null> {
|
||||
if (!token) return null;
|
||||
const dot = token.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const body = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const secret = await getSessionSecret(env);
|
||||
const expected = await hmacSha256Hex(secret, body);
|
||||
if (!timingSafeEqualHex(expected, sig)) return null;
|
||||
let payload: SessionPayload;
|
||||
try {
|
||||
payload = JSON.parse(base64UrlDecode(body)) as SessionPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload?.uid || typeof payload.exp !== "number") return null;
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 从 Request 中取会话 cookie
|
||||
*/
|
||||
export function readSessionCookie(request: Request): string | undefined {
|
||||
const cookie = request.headers.get("Cookie") ?? "";
|
||||
for (const part of cookie.split(";")) {
|
||||
const [k, ...rest] = part.trim().split("=");
|
||||
if (k === SESSION_COOKIE) return rest.join("=");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造 Set-Cookie 会话头
|
||||
*/
|
||||
export function sessionSetCookie(token: string): string {
|
||||
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_SECONDS}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造清除会话 cookie 头
|
||||
*/
|
||||
export function clearSessionCookie(): string {
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
||||
}
|
||||
|
||||
function base64UrlEncode(s: string): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64UrlDecode(s: string): string {
|
||||
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 装配认证路由:POST /login、POST /logout、GET /me
|
||||
*/
|
||||
export function authRoutes() {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
/** 登录:用户名密码 → 签名会话 cookie。默认账号 admin/admin(首次启动自动播种) */
|
||||
app.post("/login", async (c) => {
|
||||
const { username, password } = await readJson<{ username?: string; password?: string }>(c.req.raw);
|
||||
if (!username || !password) {
|
||||
return c.json({ error: "username and password required" }, 400);
|
||||
}
|
||||
const user = await c.env.DB.prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.first<{ id: string; username: string; password_hash: string }>();
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
return c.json({ error: "invalid username or password" }, 401);
|
||||
}
|
||||
const token = await issueSessionToken(c.env, user.id, user.username);
|
||||
c.header("Set-Cookie", sessionSetCookie(token));
|
||||
return c.json({ ok: true, user: { id: user.id, username: user.username } });
|
||||
});
|
||||
|
||||
/** 登出:清除会话 cookie */
|
||||
app.post("/logout", (c) => {
|
||||
c.header("Set-Cookie", clearSessionCookie());
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
app.get("/me", async (c) => {
|
||||
const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw));
|
||||
if (!payload) return c.json({ error: "unauthorized" }, 401);
|
||||
return c.json({ user: { id: payload.uid, username: payload.username }, now: nowIso() });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Console 数据访问助手:settings KV、主密钥、初始化种子(admin/admin)。
|
||||
* 全部基于 D1 API 子集,Workers 与本地运行时通用。
|
||||
*/
|
||||
|
||||
import type { Env } from "./env.ts";
|
||||
import {
|
||||
aesEncrypt,
|
||||
aesDecrypt,
|
||||
deriveAesKey,
|
||||
hashPassword,
|
||||
ids,
|
||||
sha256Hex,
|
||||
} from "../../shared/src/index.ts";
|
||||
import { nowIso } from "./util.ts";
|
||||
|
||||
/** # 函数
|
||||
* 读取 settings 表配置
|
||||
*/
|
||||
export async function getSetting(env: Env, key: string): Promise<string | null> {
|
||||
const row = await env.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(key).first<{ value: string }>();
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 写入 settings 表配置(upsert)
|
||||
*/
|
||||
export async function setSetting(env: Env, key: string, value: string): Promise<void> {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
)
|
||||
.bind(key, value)
|
||||
.run();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 获取应用主密钥(用于会话签名与敏感字段加密)。
|
||||
* 优先 env.SESSION_SECRET;否则首次自动生成 64 字符随机串并持久化到 settings,重启不变。
|
||||
*/
|
||||
export async function getMasterSecret(env: Env): Promise<string> {
|
||||
if (env.SESSION_SECRET) return env.SESSION_SECRET;
|
||||
const existing = await getSetting(env, "app_secret");
|
||||
if (existing) return existing;
|
||||
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||
let hex = "";
|
||||
for (const b of secret) hex += b.toString(16).padStart(2, "0");
|
||||
await setSetting(env, "app_secret", hex);
|
||||
return hex;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 获取 AES 加密密钥(OAuth token、node credential secret、ingress secret 加密存储)
|
||||
*/
|
||||
export async function getAesKey(env: Env): Promise<CryptoKey> {
|
||||
const master = await getMasterSecret(env);
|
||||
return deriveAesKey(master + ":encrypt");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 获取会话签名密钥
|
||||
*/
|
||||
export async function getSessionSecret(env: Env): Promise<string> {
|
||||
const master = await getMasterSecret(env);
|
||||
return master + ":session";
|
||||
}
|
||||
|
||||
/** # 成员变量: 种子初始化标志(每个运行实例只需一次) **/
|
||||
let seeded = false;
|
||||
|
||||
/** # 函数
|
||||
* 初始化种子数据:确保默认管理员 admin/admin 存在(仅在用户表为空时创建)。
|
||||
* Console 本地启动与 Workers 首个请求时都会调用,幂等。
|
||||
*/
|
||||
export async function ensureSeeded(env: Env): Promise<void> {
|
||||
if (seeded) return;
|
||||
const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
||||
if (!count || count.n === 0) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(ids.user(), "admin", await hashPassword("admin"), nowIso())
|
||||
.run();
|
||||
}
|
||||
seeded = true;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* AES 加密敏感字段(以应用主密钥)
|
||||
*/
|
||||
export async function encryptSecret(env: Env, plaintext: string): Promise<string> {
|
||||
return aesEncrypt(await getAesKey(env), plaintext);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解密敏感字段
|
||||
*/
|
||||
export async function decryptSecret(env: Env, blob: string): Promise<string> {
|
||||
return aesDecrypt(await getAesKey(env), blob);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 计算凭据 SHA-256 摘要(密钥入库形态)
|
||||
*/
|
||||
export function keyHash(key: string): Promise<string> {
|
||||
return sha256Hex(key);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Console 运行环境类型:同时兼容 Cloudflare Workers 与本地 Bun 运行时。
|
||||
* D1Database / R2Bucket 为本项目实际用到的最小接口子集。
|
||||
*/
|
||||
|
||||
/** # 成员变量: D1 预处理语句最小接口 **/
|
||||
export interface D1PreparedStatement {
|
||||
bind(...values: unknown[]): D1PreparedStatement;
|
||||
first<T = Record<string, unknown>>(colName?: string): Promise<T | null>;
|
||||
all<T = Record<string, unknown>>(): Promise<{ results: T[]; success?: boolean; meta?: unknown }>;
|
||||
run(): Promise<{ success: boolean; meta: { changes?: number; last_row_id?: number } }>;
|
||||
}
|
||||
|
||||
/** # 成员变量: D1 数据库最小接口 **/
|
||||
export interface D1Database {
|
||||
prepare(query: string): D1PreparedStatement;
|
||||
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<T[]>;
|
||||
exec(query: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** # 成员变量: R2 对象最小接口 **/
|
||||
export interface R2ObjectBody {
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
text(): Promise<string>;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** # 成员变量: R2 桶最小接口 **/
|
||||
export interface R2Bucket {
|
||||
put(
|
||||
key: string,
|
||||
value: ArrayBuffer | Uint8Array | string | ReadableStream | Blob,
|
||||
options?: { httpMetadata?: Record<string, string> },
|
||||
): Promise<unknown>;
|
||||
get(key: string): Promise<R2ObjectBody | null>;
|
||||
delete(keys: string | string[]): Promise<void>;
|
||||
head(key: string): Promise<{ key: string; size: number } | null>;
|
||||
}
|
||||
|
||||
/** # 成员变量: Console env 绑定 **/
|
||||
export interface Env {
|
||||
/** D1 数据库(本地运行时由 LocalD1 提供) **/
|
||||
DB: D1Database;
|
||||
/** R2 桶(本地运行时由 LocalR2 提供) **/
|
||||
R2: R2Bucket;
|
||||
/** Workers 静态资源绑定(本地运行时为空,由 Bun 直接读文件) **/
|
||||
ASSETS?: { fetch(request: Request): Promise<Response> };
|
||||
/** 会话/加密主密钥;未提供时自动生成并持久化到 settings 表 **/
|
||||
SESSION_SECRET?: string;
|
||||
/** Console 对外地址(用于 OAuth redirect_uri 与展示) **/
|
||||
PUBLIC_BASE_URL?: string;
|
||||
/** Cloudflare OAuth 客户端;两个都配置时走真实 OAuth,否则进入本地 sim 模式 **/
|
||||
CF_OAUTH_CLIENT_ID?: string;
|
||||
CF_OAUTH_CLIENT_SECRET?: string;
|
||||
CF_OAUTH_REDIRECT_URI?: string;
|
||||
/** 强制使用 sim 模式("1") **/
|
||||
WMAILHUB_SIM_CF?: string;
|
||||
/** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/
|
||||
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: Workers 执行上下文最小接口 **/
|
||||
export interface WorkersExecutionContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
/** # 成员变量: Workers Cron 触发事件最小接口 **/
|
||||
export interface ScheduledEvent {
|
||||
cron: string;
|
||||
scheduledTime: number;
|
||||
}
|
||||
|
||||
/** # 成员变量: Hono context 变量 **/
|
||||
export interface AppVars {
|
||||
/** 已登录用户 ID(cookie 会话) **/
|
||||
userId?: string;
|
||||
/** 已认证 API/MCP Key 信息 **/
|
||||
apiKeyKind?: "api" | "mcp";
|
||||
apiKeyId?: string;
|
||||
/** internal 路由中已读出的原始请求体(nodeAuth 中间件缓存) **/
|
||||
rawBody?: Uint8Array;
|
||||
/** internal 路由中通过校验的 credential id **/
|
||||
nodeCredentialId?: string;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Console 中间件:会话守卫、API/MCP Key 守卫、CSRF Origin 校验。
|
||||
*/
|
||||
|
||||
import type { Env } from "./env.ts";
|
||||
import type { AppVars } from "./env.ts";
|
||||
import { readSessionCookie, verifySessionToken } from "./auth.ts";
|
||||
import { keyHash } from "./db.ts";
|
||||
import { nowIso } from "./util.ts";
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
type Ctx = Context<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 会话守卫:未登录返回 401。通过后 c.set("userId")
|
||||
*/
|
||||
export async function requireSession(c: Ctx, next: Next) {
|
||||
const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw));
|
||||
if (!payload) return c.json({ error: "unauthorized" }, 401);
|
||||
c.set("userId", payload.uid);
|
||||
await next();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* CSRF 防护:对 cookie 会话的写请求校验 Origin/Referer 与 Host 一致。
|
||||
* API Key 调用(无 cookie)不受影响。
|
||||
*/
|
||||
export async function csrfOriginCheck(c: Ctx, next: Next) {
|
||||
const method = c.req.method.toUpperCase();
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(method)) return next();
|
||||
const hasCookie = (c.req.header("Cookie") ?? "").includes("wmh_session=");
|
||||
if (!hasCookie) return next();
|
||||
const origin = c.req.header("Origin") ?? c.req.header("Referer");
|
||||
if (!origin) return next(); // 同源非浏览器客户端
|
||||
try {
|
||||
const originHost = new URL(origin).host;
|
||||
const targetHost = new URL(c.req.url).host;
|
||||
if (originHost !== targetHost) return c.json({ error: "cross-origin request rejected" }, 403);
|
||||
} catch {
|
||||
return c.json({ error: "bad origin" }, 403);
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* API/MCP Key 守卫工厂:校验 Authorization: Bearer wmh_... 并限定 kind。
|
||||
* 通过后 c.set("apiKeyKind"/"apiKeyId")。
|
||||
* 参数: kind - 允许的凭据类型
|
||||
*/
|
||||
export function requireApiKey(kind: "api" | "mcp") {
|
||||
return async (c: Ctx, next: Next) => {
|
||||
const auth = c.req.header("Authorization") ?? "";
|
||||
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||
const key = m?.[1]?.trim();
|
||||
if (!key || !key.startsWith("wmh_")) {
|
||||
return c.json({ error: "missing or invalid api key" }, 401);
|
||||
}
|
||||
const row = await c.env.DB.prepare(
|
||||
"SELECT id, kind, revoked_at FROM api_credentials WHERE key_hash = ?",
|
||||
)
|
||||
.bind(await keyHash(key))
|
||||
.first<{ id: string; kind: string; revoked_at: string | null }>();
|
||||
if (!row || row.revoked_at) return c.json({ error: "invalid or revoked api key" }, 401);
|
||||
if (row.kind !== kind) {
|
||||
return c.json({ error: `this endpoint requires a ${kind} key` }, 403);
|
||||
}
|
||||
c.set("apiKeyKind", row.kind as "api" | "mcp");
|
||||
c.set("apiKeyId", row.id);
|
||||
// 记录最近使用时间(尽力而为,不阻塞请求;本地运行时无 ExecutionContext 时直接后台执行)
|
||||
const touch = c.env.DB.prepare("UPDATE api_credentials SET last_used_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), row.id)
|
||||
.run();
|
||||
try {
|
||||
c.executionCtx.waitUntil(touch);
|
||||
} catch {
|
||||
void touch;
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解析并校验 Bearer Key(返回凭据行);供 /api/v1 统一入口使用
|
||||
*/
|
||||
export async function resolveApiKey(
|
||||
env: Env,
|
||||
request: Request,
|
||||
): Promise<{ ok: true; id: string; kind: "api" | "mcp" } | { ok: false; status: number; error: string }> {
|
||||
const auth = request.headers.get("Authorization") ?? "";
|
||||
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||
const key = m?.[1]?.trim();
|
||||
if (!key || !key.startsWith("wmh_")) return { ok: false, status: 401, error: "missing or invalid api key" };
|
||||
const row = await env.DB.prepare("SELECT id, kind, revoked_at FROM api_credentials WHERE key_hash = ?")
|
||||
.bind(await keyHash(key))
|
||||
.first<{ id: string; kind: string; revoked_at: string | null }>();
|
||||
if (!row || row.revoked_at) return { ok: false, status: 401, error: "invalid or revoked api key" };
|
||||
return { ok: true, id: row.id, kind: row.kind as "api" | "mcp" };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Admin 路由:版本、Dashboard 统计、Cron 手动触发(本地开发/运维用)。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.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";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 admin 路由(会话保护)
|
||||
*/
|
||||
export function adminRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
/** 版本信息(来自 package.json) */
|
||||
app.get("/version", (c) => c.json({ name: APP_NAME, version: APP_VERSION }));
|
||||
|
||||
/** Dashboard 统计 */
|
||||
app.get("/stats", async (c) => {
|
||||
const count = async (sql: string) =>
|
||||
(await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0;
|
||||
const now = nowIso();
|
||||
const stats = {
|
||||
nodes_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
|
||||
nodes_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
|
||||
domains: await count("SELECT COUNT(*) AS n FROM domains"),
|
||||
mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"),
|
||||
emails: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at > '${now}'`),
|
||||
emails_expired: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at <= '${now}'`),
|
||||
webhooks: await count("SELECT COUNT(*) AS n FROM webhooks"),
|
||||
deliveries_pending: await count(
|
||||
`SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`,
|
||||
),
|
||||
oauth_connections: await count("SELECT COUNT(*) AS n FROM oauth_connections"),
|
||||
};
|
||||
const recent = await c.env.DB.prepare(
|
||||
`SELECT id, subject, from_address, received_at, size FROM emails
|
||||
WHERE expires_at > ? ORDER BY received_at DESC LIMIT 8`,
|
||||
)
|
||||
.bind(now)
|
||||
.all();
|
||||
return c.json({ ...stats, recent_emails: recent.results });
|
||||
});
|
||||
|
||||
/** 手动触发一次 Cron(TTL 清理 + webhook 投递) */
|
||||
app.post("/cron/run", async (c) => {
|
||||
const stats = await runCron(c.env, () =>
|
||||
processDueDeliveries(c.env, resolveHost, 20, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1"),
|
||||
);
|
||||
return c.json({ ok: true, stats });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 当前 ISO 时间
|
||||
*/
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* /api/v1/* :对外 Console API(docs/07),Bearer API Key(wmh_)认证。
|
||||
* 邮件读取强制检查未过期 + mailbox scope。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireApiKey } from "../middleware.ts";
|
||||
import { upsertMailbox } from "../services/provision.ts";
|
||||
import { nowIso, pageParams, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 /api/v1 路由
|
||||
*/
|
||||
export function apiV1Routes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireApiKey("api"));
|
||||
|
||||
/** 节点列表 */
|
||||
app.get("/nodes", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT id, name, cloudflare_account_id, status, version, last_seen_at, created_at
|
||||
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC`,
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 域名列表 */
|
||||
app.get("/domains", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
"SELECT id, name, status, node_id, created_at FROM domains ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 邮箱列表 */
|
||||
app.get("/mailboxes", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, m.domain_id, d.name AS domain_name
|
||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id ORDER BY m.created_at DESC`,
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 创建邮箱 */
|
||||
app.post("/mailboxes", async (c) => {
|
||||
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number }>(c.req.raw);
|
||||
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400);
|
||||
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
||||
.bind(body.domain_id)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!domain) return c.json({ error: "domain not found" }, 404);
|
||||
let address = body.address.trim().toLowerCase();
|
||||
if (!address.includes("@")) address = `${address}@${domain.name}`;
|
||||
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400);
|
||||
const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first();
|
||||
if (exists) return c.json({ error: "mailbox already exists" }, 409);
|
||||
const id = await upsertMailbox(c.env, {
|
||||
domainId: body.domain_id,
|
||||
address,
|
||||
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
|
||||
});
|
||||
return c.json({ ok: true, id, address }, 201);
|
||||
});
|
||||
|
||||
/** 删除邮箱 */
|
||||
app.delete("/mailboxes/:id", async (c) => {
|
||||
const res = await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(c.req.param("id")).run();
|
||||
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 邮件列表(仅未过期) */
|
||||
app.get("/emails", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const { limit, offset } = pageParams(url);
|
||||
const mailboxId = url.searchParams.get("mailbox_id");
|
||||
const conds = ["e.expires_at > ?"];
|
||||
const binds: unknown[] = [nowIso()];
|
||||
if (mailboxId) {
|
||||
conds.push("e.mailbox_id = ?");
|
||||
binds.push(mailboxId);
|
||||
}
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT e.id, e.mailbox_id, m.address AS mailbox_address, e.message_id, e.from_address, e.to_addresses,
|
||||
e.subject, e.size, e.received_at, e.expires_at
|
||||
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
|
||||
WHERE ${conds.join(" AND ")} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
|
||||
);
|
||||
const res = await rows.bind(...binds, limit, offset).all();
|
||||
return c.json({ items: res.results });
|
||||
});
|
||||
|
||||
/** 邮件详情(过期视为 410 Gone) */
|
||||
app.get("/emails/:id", async (c) => {
|
||||
const email = await c.env.DB.prepare(
|
||||
`SELECT e.*, m.address AS mailbox_address FROM emails e
|
||||
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
|
||||
)
|
||||
.bind(c.req.param("id"))
|
||||
.first<{ expires_at: string; html_body: string | null; text_body: string | null } & Record<string, unknown>>();
|
||||
if (!email) return c.json({ error: "not found" }, 404);
|
||||
if (email.expires_at <= nowIso()) return c.json({ error: "email expired" }, 410);
|
||||
const atts = await c.env.DB.prepare(
|
||||
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
|
||||
)
|
||||
.bind(c.req.param("id"))
|
||||
.all();
|
||||
return c.json({ email, attachments: atts.results });
|
||||
});
|
||||
|
||||
/** Webhook 列表 */
|
||||
app.get("/webhooks", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* /api/internal/* :Node <-> Console 内部协议路由(docs/04)
|
||||
*
|
||||
* POST /api/internal/nodes/{node_id}/emails 邮件转发(message/rfc822)
|
||||
* POST /api/internal/nodes/{node_id}/heartbeat 心跳
|
||||
* GET /api/internal/nodes/{node_id}/config 配置拉取
|
||||
* POST /api/internal/enroll bootstrap enrollment
|
||||
*
|
||||
* 前三者必须通过 HMAC 签名校验 + nonce 防重放 + credential 状态检查;
|
||||
* enroll 用一次性 enrollment token(节点尚无 secret)。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Context, Next } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { verifyNodeRequest } from "../../../shared/src/index.ts";
|
||||
import { ingestEmail, IngestError } from "../services/email_ingest.ts";
|
||||
import { enrollNode } from "../services/provision.ts";
|
||||
import { decryptSecret } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
type Ctx = Context<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 成员变量: nonce 防重放窗口(与 timestamp 窗口一致) **/
|
||||
const NONCE_TTL_SECONDS = 600;
|
||||
|
||||
/** # 函数
|
||||
* HMAC 校验中间件:验签、时间窗、body hash、nonce 唯一性、credential 状态。
|
||||
* 原始请求体缓存到 c.var.rawBody 供路由处理器复用。
|
||||
*/
|
||||
async function nodeAuth(c: Ctx, next: Next) {
|
||||
const nodeId = c.req.param("node_id");
|
||||
const cred = await c.env.DB.prepare(
|
||||
"SELECT id, secret_enc, status, revoked_at FROM node_credentials WHERE node_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(nodeId)
|
||||
.first<{ id: string; secret_enc: string; status: string; revoked_at: string | null }>();
|
||||
if (!cred) return c.json({ error: "unknown node or credential revoked" }, 401);
|
||||
|
||||
const raw = new Uint8Array(await c.req.arrayBuffer());
|
||||
const secret = await decryptSecret(c.env, cred.secret_enc);
|
||||
const verdict = await verifyNodeRequest({
|
||||
secret,
|
||||
method: c.req.method,
|
||||
url: c.req.url,
|
||||
body: raw,
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
if (!verdict.ok) return c.json({ error: verdict.error }, verdict.status);
|
||||
|
||||
// nonce 防重放:主键冲突即拒绝
|
||||
try {
|
||||
await c.env.DB.prepare("INSERT INTO nonces (nonce, node_id, expires_at) VALUES (?, ?, ?)")
|
||||
.bind(
|
||||
verdict.nonce,
|
||||
nodeId,
|
||||
new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
|
||||
)
|
||||
.run();
|
||||
} catch {
|
||||
return c.json({ error: "nonce replay detected" }, 401);
|
||||
}
|
||||
|
||||
// 凭据被撤销则在验签后仍然拒绝(撤销立即生效)
|
||||
if (cred.revoked_at) return c.json({ error: "credential revoked" }, 401);
|
||||
|
||||
c.set("rawBody", raw);
|
||||
c.set("nodeCredentialId", cred.id);
|
||||
await next();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 装配 internal 路由
|
||||
*/
|
||||
export function internalRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
|
||||
/** Enrollment:enrollment token 换取 Node 身份(凭证明文仅返回一次) */
|
||||
app.post("/enroll", async (c) => {
|
||||
let token = "";
|
||||
try {
|
||||
const body = (await c.req.json()) as { enrollment_token?: string };
|
||||
token = body.enrollment_token ?? "";
|
||||
} catch {
|
||||
return c.json({ error: "invalid body" }, 400);
|
||||
}
|
||||
if (!token.startsWith("wenr_")) return c.json({ error: "invalid enrollment token" }, 401);
|
||||
const result = await enrollNode(c.env, token);
|
||||
if (!result) return c.json({ error: "invalid or expired enrollment token" }, 401);
|
||||
return c.json({
|
||||
ok: true,
|
||||
node_id: result.node_id,
|
||||
credential_id: result.credential_id,
|
||||
credential_secret: result.credential_secret,
|
||||
ingress_secret: result.ingress_secret,
|
||||
});
|
||||
});
|
||||
|
||||
/** 邮件转发入口:message/rfc822 原文入库 */
|
||||
app.post("/nodes/:node_id/emails", nodeAuth, async (c) => {
|
||||
// nodeAuth 已确保节点存在,param 必有值
|
||||
const nodeId = c.req.param("node_id")!;
|
||||
const raw = c.get("rawBody") ?? new Uint8Array();
|
||||
const mailFrom = c.req.header("X-WmailHub-Mail-From") ?? "";
|
||||
const rcptTo = c.req.header("X-WmailHub-Rcpt-To") ?? "";
|
||||
try {
|
||||
const result = await ingestEmail(c.env, { raw, mailFrom, rcptTo, nodeId });
|
||||
// 更新节点可见性
|
||||
await c.env.DB.prepare("UPDATE nodes SET last_seen_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), nowIso(), nodeId)
|
||||
.run();
|
||||
return c.json(
|
||||
{ ok: true, id: result.id, mailbox_id: result.mailboxId, expires_at: result.expiresAt },
|
||||
202,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof IngestError) return c.json({ error: err.message }, err.status as 413);
|
||||
return c.json({ error: err instanceof Error ? err.message : "ingest failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** 心跳:更新 last_seen_at / version / status=online */
|
||||
app.post("/nodes/:node_id/heartbeat", nodeAuth, async (c) => {
|
||||
const nodeId = c.req.param("node_id");
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const body = c.get("rawBody") ?? new Uint8Array();
|
||||
const parsed = JSON.parse(new TextDecoder().decode(body)) as { version?: string };
|
||||
version = parsed.version ?? null;
|
||||
} catch {
|
||||
// 空 body 合法
|
||||
}
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE nodes SET last_seen_at = ?, status = 'online', version = COALESCE(?, version), updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(nowIso(), version, nowIso(), nodeId)
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 配置拉取:协议版本与默认保留时长 */
|
||||
app.get("/nodes/:node_id/config", nodeAuth, (c) => {
|
||||
return c.json({
|
||||
protocol_version: 1,
|
||||
default_retention_seconds: 86_400,
|
||||
server_time: nowIso(),
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Console API / MCP 凭据管理路由。
|
||||
* Key 规范:wmh_ 开头 + 随机串,总长 32;库中只存 SHA-256,明文仅创建时返回一次。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { generateWmailKey, isValidWmailKey } from "../../../shared/src/index.ts";
|
||||
import { keyHash } from "../db.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 API/MCP Key 管理路由(会话保护)
|
||||
*/
|
||||
export function keyRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
/** 列表(仅前 4 位尾缀,不返回明文) */
|
||||
app.get("/", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT id, kind, name, key_last4, created_at, revoked_at, last_used_at
|
||||
FROM api_credentials ORDER BY created_at DESC`,
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 生成新 Key(kind = api | mcp) */
|
||||
app.post("/", async (c) => {
|
||||
const body = await readJson<{ kind?: string; name?: string }>(c.req.raw);
|
||||
const kind = body.kind === "mcp" ? "mcp" : body.kind === "api" ? "api" : null;
|
||||
if (!kind) return c.json({ error: "kind must be api or mcp" }, 400);
|
||||
const key = generateWmailKey();
|
||||
if (!isValidWmailKey(key)) return c.json({ error: "key generator broken" }, 500);
|
||||
const id = ids.apiKey();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO api_credentials (id, kind, name, key_hash, key_last4, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, kind, (body.name ?? "").trim() || (kind === "api" ? "Console API Key" : "Console MCP Key"), await keyHash(key), key.slice(-4), nowIso())
|
||||
.run();
|
||||
return c.json({ ok: true, id, kind, key }, 201);
|
||||
});
|
||||
|
||||
/** 撤销 Key */
|
||||
app.post("/:id/revoke", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const res = await c.env.DB.prepare("UPDATE api_credentials SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
if (!res.meta?.changes) return c.json({ error: "not found or already revoked" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 彻底删除 Key 记录 */
|
||||
app.delete("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM api_credentials WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
|
||||
*
|
||||
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox / list_webhooks
|
||||
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
|
||||
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
|
||||
* 返回普通 JSON);GET 返回 405(无 SSE 流)。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireApiKey } from "../middleware.ts";
|
||||
import { upsertMailbox } from "../services/provision.ts";
|
||||
import { APP_VERSION } from "../version.ts";
|
||||
import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 成员变量: MCP 协议版本 **/
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
|
||||
/** # 成员变量: MCP 工具定义(JSON Schema) **/
|
||||
const TOOLS = [
|
||||
{
|
||||
name: "list_mailboxes",
|
||||
description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
name: "list_emails",
|
||||
description: "列出邮件(仅未过期),可按 mailbox_id 过滤",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mailbox_id: { type: "string", description: "可选,按邮箱过滤" },
|
||||
limit: { type: "number", description: "可选,默认 20,最大 100" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_email",
|
||||
description: "获取单封邮件详情(正文与附件元数据);过期邮件返回 410 语义错误",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", description: "邮件 ID(msg_...)" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_mailbox",
|
||||
description: "在指定域名下创建邮箱",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
domain_id: { type: "string", description: "域名 ID(dom_...)" },
|
||||
address: { type: "string", description: "邮箱地址或本地部分" },
|
||||
retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" },
|
||||
},
|
||||
required: ["domain_id", "address"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_mailbox",
|
||||
description: "删除邮箱",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", description: "邮箱 ID(mbx_...)" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_webhooks",
|
||||
description: "列出 Webhook(不含 secret)",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** # 函数
|
||||
* 装配 MCP 路由
|
||||
*/
|
||||
export function mcpRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
|
||||
app.use("*", requireApiKey("mcp"));
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const rpc = await readJson<JsonRpcRequest>(c.req.raw);
|
||||
if (typeof rpc.jsonrpc !== "string" || typeof rpc.method !== "string") {
|
||||
return c.json(jsonRpcError(null, -32600, "invalid request"), 400);
|
||||
}
|
||||
const id = rpc.id ?? null;
|
||||
try {
|
||||
switch (rpc.method) {
|
||||
case "initialize":
|
||||
return c.json({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "wmailhub", version: APP_VERSION },
|
||||
},
|
||||
});
|
||||
case "notifications/initialized":
|
||||
return new Response(null, { status: 202 });
|
||||
case "ping":
|
||||
return c.json({ jsonrpc: "2.0", id, result: {} });
|
||||
case "tools/list":
|
||||
return c.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
||||
case "tools/call": {
|
||||
const params = (rpc.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
|
||||
const result = await callTool(c.env, params.name ?? "", params.arguments ?? {});
|
||||
return c.json({ jsonrpc: "2.0", id, result });
|
||||
}
|
||||
default:
|
||||
return c.json(jsonRpcError(id, -32601, `method not found: ${rpc.method}`), 200);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof McpToolError ? err.message : `internal error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
const code = err instanceof McpToolError ? err.rpcCode : -32603;
|
||||
return c.json(jsonRpcError(id, code, message), 200);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/", (c) => c.json({ error: "POST JSON-RPC requests only" }, 405));
|
||||
return app;
|
||||
}
|
||||
|
||||
/** # 成员变量: JSON-RPC 请求结构 **/
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
id?: string | number | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造 JSON-RPC error 响应
|
||||
*/
|
||||
function jsonRpcError(id: string | number | null, code: number, message: string) {
|
||||
return { jsonrpc: "2.0", id, error: { code, message } };
|
||||
}
|
||||
|
||||
/** # 成员变量: 工具层错误(映射到 JSON-RPC error code) **/
|
||||
class McpToolError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public rpcCode: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 工具调用分发
|
||||
*/
|
||||
async function callTool(
|
||||
env: Env,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> {
|
||||
const data = await dispatchTool(env, name, args);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
switch (name) {
|
||||
case "list_mailboxes": {
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, d.name AS domain_name
|
||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id ORDER BY m.created_at DESC`,
|
||||
).all();
|
||||
return { mailboxes: rows.results };
|
||||
}
|
||||
case "list_emails": {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
|
||||
const mailboxId = typeof args.mailbox_id === "string" ? args.mailbox_id : null;
|
||||
const conds = ["e.expires_at > ?"];
|
||||
const binds: unknown[] = [nowIso()];
|
||||
if (mailboxId) {
|
||||
conds.push("e.mailbox_id = ?");
|
||||
binds.push(mailboxId);
|
||||
}
|
||||
const rows = await env.DB.prepare(
|
||||
`SELECT e.id, e.mailbox_id, m.address AS mailbox_address, e.from_address, e.subject,
|
||||
e.size, e.received_at, e.expires_at
|
||||
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
|
||||
WHERE ${conds.join(" AND ")} ORDER BY e.received_at DESC LIMIT ?`,
|
||||
)
|
||||
.bind(...binds, limit)
|
||||
.all();
|
||||
return { emails: rows.results };
|
||||
}
|
||||
case "get_email": {
|
||||
const id = String(args.id ?? "");
|
||||
if (!id) throw new McpToolError("id required", -32602);
|
||||
const email = await env.DB.prepare(
|
||||
`SELECT e.*, m.address AS mailbox_address FROM emails e
|
||||
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!email) throw new McpToolError("email not found", -32002);
|
||||
if ((email as { expires_at: string }).expires_at <= nowIso()) {
|
||||
throw new McpToolError("email expired", -32002);
|
||||
}
|
||||
const atts = await env.DB.prepare(
|
||||
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.all();
|
||||
return { email, attachments: atts.results };
|
||||
}
|
||||
case "create_mailbox": {
|
||||
const domainId = String(args.domain_id ?? "");
|
||||
const address = String(args.address ?? "").trim().toLowerCase();
|
||||
if (!domainId || !address) throw new McpToolError("domain_id and address required", -32602);
|
||||
const domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
||||
.bind(domainId)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!domain) throw new McpToolError("domain not found", -32002);
|
||||
let full = address;
|
||||
if (!full.includes("@")) full = `${full}@${domain.name}`;
|
||||
else if (!full.endsWith(`@${domain.name}`)) throw new McpToolError(`address must be @${domain.name}`, -32602);
|
||||
const exists = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(full).first();
|
||||
if (exists) throw new McpToolError("mailbox already exists", -32002);
|
||||
const retention = args.retention_seconds ? Number(args.retention_seconds) : undefined;
|
||||
const id = await upsertMailbox(env, { domainId, address: full, retentionSeconds: retention });
|
||||
return { id, address: full };
|
||||
}
|
||||
case "delete_mailbox": {
|
||||
const id = String(args.id ?? "");
|
||||
if (!id) throw new McpToolError("id required", -32602);
|
||||
const res = await env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
||||
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
|
||||
return { ok: true };
|
||||
}
|
||||
case "list_webhooks": {
|
||||
const rows = await env.DB.prepare(
|
||||
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
|
||||
).all();
|
||||
return { webhooks: rows.results };
|
||||
}
|
||||
default:
|
||||
throw new McpToolError(`unknown tool: ${name}`, -32601);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Cloudflare OAuth 路由(docs/02):
|
||||
* GET /api/oauth/cloudflare/start?node_id= 生成 state,返回授权跳转 URL
|
||||
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动安装
|
||||
* POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地)
|
||||
* GET /api/oauth/connections 已建立的 OAuth 连接
|
||||
*
|
||||
* 安全:state 一次性(用后即删)、5 分钟过期、绑定 node_id;token AES-GCM 加密入库,
|
||||
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { aesEncrypt, deriveAesKey, ids } from "../../../shared/src/index.ts";
|
||||
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
|
||||
import { provisionNode } from "../services/provision.ts";
|
||||
import { getSetting } from "../db.ts";
|
||||
import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 成员变量: OAuth state 有效期(秒) **/
|
||||
const STATE_TTL_SECONDS = 300;
|
||||
|
||||
/** # 函数
|
||||
* 是否使用真实 Cloudflare OAuth
|
||||
*/
|
||||
export function isRealOAuth(env: Env): boolean {
|
||||
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 装配 OAuth 路由
|
||||
*/
|
||||
export function oauthRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
/** 授权模式与入口信息(前端 Add Node 向导用) */
|
||||
app.get("/mode", (c) => {
|
||||
return c.json({
|
||||
mode: isRealOAuth(c.env) ? "cloudflare" : "sim",
|
||||
authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null,
|
||||
});
|
||||
});
|
||||
|
||||
/** 开始授权:创建 state 并返回跳转 URL */
|
||||
app.get("/cloudflare/start", async (c) => {
|
||||
const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
|
||||
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status = 'pending_oauth' OR id = ?")
|
||||
.bind(nodeId, nodeId)
|
||||
.first<{ id: string }>();
|
||||
if (!node) return c.json({ error: "node not found" }, 404);
|
||||
|
||||
const state = "cfstate_" + ids.oauthState();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO oauth_states (state, node_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(state, nodeId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
||||
.run();
|
||||
|
||||
if (isRealOAuth(c.env)) {
|
||||
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
|
||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
||||
const url =
|
||||
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
|
||||
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
|
||||
`&state=${encodeURIComponent(state)}`;
|
||||
return c.json({ url, state });
|
||||
}
|
||||
// sim 模式:前端内置模拟授权页
|
||||
return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state });
|
||||
});
|
||||
|
||||
/** 授权回调(浏览器重垒):校验 state -> 兑换 token -> 自动安装 -> 跳回控制台 */
|
||||
app.get("/cloudflare/callback", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const code = url.searchParams.get("code") ?? "";
|
||||
const state = url.searchParams.get("state") ?? "";
|
||||
const verdict = await consumeState(c.env, state);
|
||||
if (!verdict) {
|
||||
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
|
||||
}
|
||||
const cf = getCFApi(c.env, c.env.DB);
|
||||
try {
|
||||
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
|
||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
||||
const token = await cf.exchangeToken(code, redirectUri);
|
||||
// 加密入库(不进前端/日志)
|
||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
||||
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
|
||||
const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
|
||||
return c.redirect(target, 302);
|
||||
} catch (err) {
|
||||
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** sim 模式:模拟授权页的确认动作(本地开发用,需会话) */
|
||||
app.post("/cloudflare/sim/approve", async (c) => {
|
||||
const body = await readJson<{ state?: string }>(c.req.raw);
|
||||
const verdict = await consumeState(c.env, body.state ?? "");
|
||||
if (!verdict) return c.json({ error: "state invalid or expired" }, 400);
|
||||
const cf = getCFApi(c.env, c.env.DB);
|
||||
const token = await cf.exchangeToken("sim-code", "urn:unused");
|
||||
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
||||
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
|
||||
return c.json({ ok: result.ok, error: result.error, node_id: verdict.nodeId });
|
||||
});
|
||||
|
||||
/** 已有连接列表(不含 token) */
|
||||
app.get("/connections", async (c) => {
|
||||
const rows = await c.env.DB.prepare(
|
||||
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 消费一次性 state:存在且未过期则删除并返回 node_id,否则 null(CSRF 防护核心)
|
||||
*/
|
||||
async function consumeState(
|
||||
env: Env,
|
||||
state: string,
|
||||
): Promise<{ nodeId: string } | null> {
|
||||
if (!state) return null;
|
||||
const row = await env.DB.prepare("SELECT node_id, expires_at FROM oauth_states WHERE state = ?")
|
||||
.bind(state)
|
||||
.first<{ node_id: string; expires_at: string }>();
|
||||
if (!row) return null;
|
||||
await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run();
|
||||
if (row.expires_at < nowIso()) return null;
|
||||
return { nodeId: row.node_id };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth access token 加密入库
|
||||
*/
|
||||
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
|
||||
const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub");
|
||||
const encrypted = await aesEncrypt(key, accessToken);
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
|
||||
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Console 管理路由(Node / Domain / Mailbox / Email)。
|
||||
* 全部要求会话登录。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts";
|
||||
import { upsertMailbox, upsertDomain } from "../services/provision.ts";
|
||||
import { decryptSecret } from "../db.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { checkWebhookUrl } from "../../../shared/src/index.ts";
|
||||
import { nowIso, pageParams, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 Node / Domain / Mailbox / Email 管理路由
|
||||
*/
|
||||
export function resourceRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
installNodeRoutes(app);
|
||||
installDomainRoutes(app);
|
||||
installMailboxRoutes(app);
|
||||
installEmailRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node CRUD 与安装状态
|
||||
*/
|
||||
function installNodeRoutes(app: App) {
|
||||
/** Node 列表(不含任何 secret) */
|
||||
app.get("/nodes", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
|
||||
ingress_url, created_at, updated_at
|
||||
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(limit, offset)
|
||||
.all();
|
||||
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>();
|
||||
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
||||
});
|
||||
|
||||
/** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */
|
||||
app.post("/nodes", async (c) => {
|
||||
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
|
||||
const name = (body.name ?? "").trim();
|
||||
if (!name) return c.json({ error: "name required" }, 400);
|
||||
const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim();
|
||||
try {
|
||||
new URL(ingressUrl);
|
||||
} catch {
|
||||
return c.json({ error: "invalid ingress_url" }, 400);
|
||||
}
|
||||
const created = await createNode(c.env, { name, ingressUrl });
|
||||
return c.json({
|
||||
ok: true,
|
||||
node_id: created.nodeId,
|
||||
enrollment_token: created.enrollmentToken,
|
||||
ingress_secret: created.ingressSecret,
|
||||
worker_name: emailWorkerNameFor(created.nodeId),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
/** Node 详情:含安装步骤状态与 OAuth 连接状态 */
|
||||
app.get("/nodes/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const node = await c.env.DB.prepare(
|
||||
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
|
||||
ingress_url, enrollment_expires_at, created_at, updated_at
|
||||
FROM nodes WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!node) return c.json({ error: "not found" }, 404);
|
||||
const steps = await loadSteps(c.env, id);
|
||||
const oauth = await c.env.DB.prepare(
|
||||
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
|
||||
).first();
|
||||
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?")
|
||||
.bind(id)
|
||||
.all();
|
||||
return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results });
|
||||
});
|
||||
|
||||
/** 删除 Node(软删:撤销 credential + 标记 revoked) */
|
||||
app.delete("/nodes/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), id)
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 重新生成 enrollment token(credential 轮换入口) */
|
||||
app.post("/nodes/:id/rotate", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!node) return c.json({ error: "not found" }, 404);
|
||||
const token = "wenr_" + crypto.randomUUID().replace(/-/g, "");
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(token, new Date(Date.now() + 24 * 3600 * 1000).toISOString(), nowIso(), id)
|
||||
.run();
|
||||
return c.json({ ok: true, enrollment_token: token });
|
||||
});
|
||||
|
||||
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
|
||||
app.get("/nodes/:id/ingress-secret", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ ingress_secret_enc: string }>();
|
||||
if (!node) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Domain CRUD
|
||||
*/
|
||||
function installDomainRoutes(app: App) {
|
||||
app.get("/domains", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT d.*, n.name AS node_name,
|
||||
(SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count
|
||||
FROM domains d LEFT JOIN nodes n ON n.id = d.node_id
|
||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(limit, offset)
|
||||
.all();
|
||||
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM domains").first<{ n: number }>();
|
||||
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
||||
});
|
||||
|
||||
/** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */
|
||||
app.post("/domains", async (c) => {
|
||||
const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw);
|
||||
const name = (body.name ?? "").trim().toLowerCase();
|
||||
if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400);
|
||||
const id = await upsertDomain(c.env, {
|
||||
cloudflareAccountId: body.cloudflare_account_id ?? "manual",
|
||||
nodeId: body.node_id ?? null as unknown as string,
|
||||
name,
|
||||
zoneId: "manual",
|
||||
workerId: null as unknown as string,
|
||||
});
|
||||
return c.json({ ok: true, id }, 201);
|
||||
});
|
||||
|
||||
app.delete("/domains/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?")
|
||||
.bind(id)
|
||||
.first<{ n: number }>();
|
||||
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
|
||||
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Mailbox CRUD
|
||||
*/
|
||||
function installMailboxRoutes(app: App) {
|
||||
app.get("/mailboxes", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const domainId = new URL(c.req.url).searchParams.get("domain_id");
|
||||
const where = domainId ? "WHERE m.domain_id = ?" : "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT m.*, d.name AS domain_name,
|
||||
(SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count
|
||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
||||
${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`,
|
||||
);
|
||||
const bound = domainId ? rows.bind(domainId, limit, offset) : rows.bind(limit, offset);
|
||||
const res = await bound.all();
|
||||
const totalRows = domainId
|
||||
? await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?").bind(domainId).first<{ n: number }>()
|
||||
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes").first<{ n: number }>();
|
||||
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
|
||||
});
|
||||
|
||||
app.post("/mailboxes", async (c) => {
|
||||
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; catch_all?: boolean }>(c.req.raw);
|
||||
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400);
|
||||
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
|
||||
.bind(body.domain_id)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (!domain) return c.json({ error: "domain not found" }, 404);
|
||||
let address = body.address.trim().toLowerCase();
|
||||
if (body.catch_all) address = `*@${domain.name}`;
|
||||
else if (!address.includes("@")) address = `${address}@${domain.name}`;
|
||||
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400);
|
||||
const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first();
|
||||
if (exists) return c.json({ error: "mailbox already exists" }, 409);
|
||||
const id = await upsertMailbox(c.env, {
|
||||
domainId: body.domain_id,
|
||||
address,
|
||||
isCatchAll: Boolean(body.catch_all),
|
||||
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
|
||||
});
|
||||
return c.json({ ok: true, id, address }, 201);
|
||||
});
|
||||
|
||||
app.patch("/mailboxes/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await readJson<{ status?: string; retention_seconds?: number | null }>(c.req.raw);
|
||||
if (body.status && !["active", "disabled"].includes(body.status)) {
|
||||
return c.json({ error: "invalid status" }, 400);
|
||||
}
|
||||
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
|
||||
if (!mailbox) return c.json({ error: "not found" }, 404);
|
||||
if (body.status) {
|
||||
await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.status, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
if (body.retention_seconds !== undefined) {
|
||||
await c.env.DB.prepare("UPDATE mailboxes SET retention_seconds = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/mailboxes/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Email 查询 / 详情 / 附件 / 手动删除
|
||||
*/
|
||||
function installEmailRoutes(app: App) {
|
||||
app.get("/emails", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const { limit, offset } = pageParams(url);
|
||||
const mailboxId = url.searchParams.get("mailbox_id");
|
||||
const q = url.searchParams.get("q");
|
||||
const conds: string[] = [];
|
||||
const binds: unknown[] = [];
|
||||
if (mailboxId) {
|
||||
conds.push("mailbox_id = ?");
|
||||
binds.push(mailboxId);
|
||||
}
|
||||
if (q) {
|
||||
conds.push("(subject LIKE ? OR from_address LIKE ?)");
|
||||
binds.push(`%${q}%`, `%${q}%`);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size,
|
||||
e.received_at, e.expires_at, m.address AS mailbox_address,
|
||||
(SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count
|
||||
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
|
||||
${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
|
||||
);
|
||||
const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all();
|
||||
const totalRows = binds.length
|
||||
? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM emails ${where}`).bind(...binds).first<{ n: number }>()
|
||||
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
|
||||
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
|
||||
});
|
||||
|
||||
app.get("/emails/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const email = await c.env.DB.prepare(
|
||||
`SELECT e.*, m.address AS mailbox_address FROM emails e
|
||||
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!email) return c.json({ error: "not found" }, 404);
|
||||
const atts = await c.env.DB.prepare(
|
||||
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.all();
|
||||
return c.json({ email, attachments: atts.results });
|
||||
});
|
||||
|
||||
/** 附件下载 */
|
||||
app.get("/emails/:id/attachments/:attId", async (c) => {
|
||||
const attId = c.req.param("attId");
|
||||
const att = await c.env.DB.prepare("SELECT filename, content_type, storage_key FROM email_attachments WHERE id = ? AND email_id = ?")
|
||||
.bind(attId, c.req.param("id"))
|
||||
.first<{ filename: string; content_type: string; storage_key: string }>();
|
||||
if (!att) return c.json({ error: "not found" }, 404);
|
||||
const obj = await c.env.R2.get(att.storage_key);
|
||||
if (!obj) return c.json({ error: "object missing" }, 404);
|
||||
const buf = await obj.arrayBuffer();
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
"Content-Type": att.content_type || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(att.filename)}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/** raw MIME 下载(.eml) */
|
||||
app.get("/emails/:id/raw", async (c) => {
|
||||
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
|
||||
.bind(c.req.param("id"))
|
||||
.first<{ raw_storage_key: string }>();
|
||||
if (!email) return c.json({ error: "not found" }, 404);
|
||||
const obj = await c.env.R2.get(email.raw_storage_key);
|
||||
if (!obj) return c.json({ error: "object missing" }, 404);
|
||||
return new Response(await obj.arrayBuffer(), {
|
||||
headers: {
|
||||
"Content-Type": "message/rfc822",
|
||||
"Content-Disposition": `attachment; filename="${c.req.param("id")}.eml"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.delete("/emails/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ raw_storage_key: string }>();
|
||||
if (!email) return c.json({ error: "not found" }, 404);
|
||||
const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
|
||||
.bind(id)
|
||||
.all<{ storage_key: string | null }>();
|
||||
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[];
|
||||
if (keys.length) await c.env.R2.delete(keys);
|
||||
await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
|
||||
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
|
||||
await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* webhook endpoint 的基本格式校验(创建时调用)
|
||||
*/
|
||||
export function validateEndpoint(endpoint: string): string | null {
|
||||
try {
|
||||
new URL(endpoint);
|
||||
} catch {
|
||||
return "invalid url";
|
||||
}
|
||||
return checkWebhookUrl(endpoint);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Console Webhook 管理路由(docs/07):CRUD、secret、投递日志、手动测试/重投。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { ids, checkWebhookUrl } from "../../../shared/src/index.ts";
|
||||
import { attemptDelivery, buildWebhookPayload } from "../services/webhook_delivery.ts";
|
||||
import { pageParams, readJson, nowIso } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 装配 Webhook 管理路由(会话保护)
|
||||
*/
|
||||
export function webhookRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireSession);
|
||||
|
||||
/** 列表(不含 secret 明文) */
|
||||
app.get("/", async (c) => {
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT w.id, w.name, w.endpoint, w.enabled, w.created_at, w.updated_at,
|
||||
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id) AS delivery_count,
|
||||
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id AND d.status = 'success') AS success_count
|
||||
FROM webhooks w ORDER BY w.created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(limit, offset)
|
||||
.all();
|
||||
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM webhooks").first<{ n: number }>();
|
||||
return c.json({ items: rows.results, total: total?.n ?? 0 });
|
||||
});
|
||||
|
||||
/** 创建:secret 生成后明文仅返回一次 */
|
||||
app.post("/", async (c) => {
|
||||
const body = await readJson<{ name?: string; endpoint?: string }>(c.req.raw);
|
||||
const name = (body.name ?? "").trim();
|
||||
const endpoint = (body.endpoint ?? "").trim();
|
||||
if (!name || !endpoint) return c.json({ error: "name and endpoint required" }, 400);
|
||||
const unsafe = checkWebhookUrl(endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
|
||||
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
|
||||
const id = ids.webhook();
|
||||
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const now = nowIso();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.bind(id, name, endpoint, secret, now, now)
|
||||
.run();
|
||||
return c.json({ ok: true, id, secret }, 201);
|
||||
});
|
||||
|
||||
/** 更新(name/endpoint/enabled);endpoint 变更时重新过 SSRF 校验 */
|
||||
app.patch("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await readJson<{ name?: string; endpoint?: string; enabled?: boolean }>(c.req.raw);
|
||||
const hook = await c.env.DB.prepare("SELECT id FROM webhooks WHERE id = ?").bind(id).first();
|
||||
if (!hook) return c.json({ error: "not found" }, 404);
|
||||
if (body.endpoint !== undefined) {
|
||||
const unsafe = checkWebhookUrl(body.endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
|
||||
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
|
||||
await c.env.DB.prepare("UPDATE webhooks SET endpoint = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.endpoint, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
await c.env.DB.prepare("UPDATE webhooks SET name = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.name, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
await c.env.DB.prepare("UPDATE webhooks SET enabled = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(body.enabled ? 1 : 0, nowIso(), id)
|
||||
.run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 轮换 secret(明文仅返回一次) */
|
||||
app.post("/:id/rotate", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
const res = await c.env.DB.prepare("UPDATE webhooks SET secret = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(secret, nowIso(), id)
|
||||
.run();
|
||||
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ok: true, secret });
|
||||
});
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE webhook_id = ?").bind(id).run();
|
||||
await c.env.DB.prepare("DELETE FROM webhooks WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 投递日志 */
|
||||
app.get("/:id/deliveries", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const { limit, offset } = pageParams(new URL(c.req.url));
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT d.*, e.subject, e.from_address
|
||||
FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id
|
||||
WHERE d.webhook_id = ? ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(id, limit, offset)
|
||||
.all();
|
||||
return c.json({ items: rows.results });
|
||||
});
|
||||
|
||||
/** 手动测试投递(使用最近一封邮件,若无则发空壳测试事件) */
|
||||
app.post("/:id/test", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const hook = await c.env.DB.prepare("SELECT id, endpoint, secret, enabled FROM webhooks WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<{ id: string; endpoint: string; secret: string; enabled: number }>();
|
||||
if (!hook) return c.json({ error: "not found" }, 404);
|
||||
const lastEmail = await c.env.DB.prepare(
|
||||
"SELECT id, mailbox_id, from_address, to_addresses, subject, text_body, received_at FROM emails ORDER BY received_at DESC LIMIT 1",
|
||||
).first<{ id: string; mailbox_id: string | null; from_address: string | null; to_addresses: string; subject: string | null; text_body: string | null; received_at: string }>();
|
||||
const mailbox = lastEmail?.mailbox_id
|
||||
? (await c.env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?").bind(lastEmail.mailbox_id).first<{ address: string }>())?.address ?? null
|
||||
: null;
|
||||
const payload = lastEmail
|
||||
? buildWebhookPayload({ id: lastEmail.id, mailboxAddress: mailbox, fromAddress: lastEmail.from_address, toAddresses: lastEmail.to_addresses, subject: lastEmail.subject, textBody: lastEmail.text_body, receivedAt: lastEmail.received_at })
|
||||
: {
|
||||
event: "email.received",
|
||||
id: "test_" + crypto.randomUUID().slice(0, 8),
|
||||
mailbox: "test@example.com",
|
||||
from: "console@test.local",
|
||||
to: ["test@example.com"],
|
||||
subject: "WmailHub webhook test",
|
||||
text: "This is a manual test delivery.",
|
||||
received_at: nowIso(),
|
||||
};
|
||||
const result = await attemptDelivery(
|
||||
{ id: hook.id, endpoint: hook.endpoint, secret: hook.secret },
|
||||
"test",
|
||||
payload,
|
||||
undefined,
|
||||
c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1",
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/** 重投:把失败 delivery 重置为立即重试 */
|
||||
app.post("/deliveries/:deliveryId/redeliver", async (c) => {
|
||||
const deliveryId = c.req.param("deliveryId");
|
||||
const res = await c.env.DB.prepare(
|
||||
"UPDATE webhook_deliveries SET status = 'pending', next_retry_at = ?, last_error = NULL WHERE id = ?",
|
||||
)
|
||||
.bind(nowIso(), deliveryId)
|
||||
.run();
|
||||
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Cloudflare API 客户端抽象。
|
||||
*
|
||||
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
|
||||
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
|
||||
* 让 Add Node 自动安装链路在本地完整可演示、可测试。
|
||||
*
|
||||
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
|
||||
*/
|
||||
|
||||
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
|
||||
import type { Env } from "../env.ts";
|
||||
import { ids } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: OAuth 授权端点(real 模式) **/
|
||||
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
||||
|
||||
/** # 成员变量: OAuth token 端点(real 模式) **/
|
||||
export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
||||
|
||||
/** # 成员变量: Cloudflare API 基址(real 模式) **/
|
||||
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
||||
export const CF_OAUTH_SCOPES = [
|
||||
"account:read",
|
||||
"zone:read",
|
||||
"email_routing:edit",
|
||||
"workers_scripts:edit",
|
||||
].join(" ");
|
||||
|
||||
/** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/
|
||||
export interface CFAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CFZone {
|
||||
id: string;
|
||||
name: string;
|
||||
account_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||
export interface CFApi {
|
||||
/** 用 code 换 access token(real);sim 直接签发假 token */
|
||||
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
||||
/** 发现 token 可访问的账户 */
|
||||
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
||||
/** 发现账户下的域名 Zone */
|
||||
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
||||
/** 创建或更新 Email Worker(幂等) */
|
||||
putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/** 开启 Zone 的 Email Routing(幂等) */
|
||||
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
||||
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
|
||||
setCatchAllRule(opts: {
|
||||
accessToken: string;
|
||||
zoneId: string;
|
||||
workerName: string;
|
||||
}): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 根据环境构造 Cloudflare API 客户端(real 或 sim)
|
||||
*/
|
||||
export function getCFApi(env: Env, db: Env["DB"]): CFApi {
|
||||
const real = env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF;
|
||||
if (real) return new RealCFApi(env);
|
||||
return new SimCFApi(db);
|
||||
}
|
||||
|
||||
/** # 成员变量: 真实 Cloudflare API 实现 **/
|
||||
export class RealCFApi implements CFApi {
|
||||
/** # 成员变量 **/
|
||||
constructor(private env: Env) {}
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: this.env.CF_OAUTH_CLIENT_ID ?? "",
|
||||
client_secret: this.env.CF_OAUTH_CLIENT_SECRET ?? "",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`cloudflare token exchange failed: ${resp.status}`);
|
||||
const data = (await resp.json()) as { access_token: string; expires_in?: number };
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
||||
}
|
||||
|
||||
private async api<T>(token: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const resp = await fetch(`${CF_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const data = (await resp.json()) as { success: boolean; errors?: { message: string }[]; result: T };
|
||||
if (!data.success) throw new Error(`cloudflare api ${path}: ${data.errors?.[0]?.message ?? resp.status}`);
|
||||
return data.result;
|
||||
}
|
||||
|
||||
listAccounts(token: string) {
|
||||
return this.api<CFAccount[]>(token, "/accounts");
|
||||
}
|
||||
|
||||
listZones(token: string, accountId: string) {
|
||||
return this.api<CFZone[]>(token, `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50`);
|
||||
}
|
||||
|
||||
async putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
}) {
|
||||
// Email Worker 即带 email handler 的 Worker 脚本;ingress 地址与密钥以明文常量打进脚本,
|
||||
// 避免给 Worker 配置 Console 凭据(docs/08:Worker 只持有 Node ingress secret)。
|
||||
const script = renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"metadata",
|
||||
new Blob([JSON.stringify({ main_module: "worker.js", bindings: [] })], { type: "application/json" }),
|
||||
);
|
||||
form.append("worker.js", new Blob([script], { type: "application/javascript+module" }));
|
||||
const resp = await fetch(
|
||||
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${opts.accessToken}` },
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`upload email worker failed: ${resp.status}`);
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: true, skip_wizard: true }),
|
||||
});
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
||||
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/rules/catch_all`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
matchers: [{ type: "all" }],
|
||||
actions: [{ type: "worker", value: [opts.workerName] }],
|
||||
enabled: true,
|
||||
name: "wmailhub catch-all",
|
||||
}),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量: 本地 sim 实现:资源写入 cf_sim_resources,行为与真实链路一致 **/
|
||||
export class SimCFApi implements CFApi {
|
||||
/** # 成员变量 **/
|
||||
constructor(private db: Env["DB"]) {}
|
||||
|
||||
private async record(kind: string, refId: string, payload: unknown): Promise<void> {
|
||||
await this.db
|
||||
.prepare(
|
||||
"INSERT INTO cf_sim_resources (id, kind, ref_id, payload, created_at) VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(id) DO UPDATE SET payload = excluded.payload",
|
||||
)
|
||||
.bind(ids.simResource(), kind, refId, JSON.stringify(payload), nowIso())
|
||||
.run();
|
||||
}
|
||||
|
||||
async exchangeToken(_code: string, _redirectUri: string) {
|
||||
return { accessToken: "sim_" + ids.oauth(), expiresIn: 3600 };
|
||||
}
|
||||
|
||||
async listAccounts(_token: string): Promise<CFAccount[]> {
|
||||
// sim 固定一个演示账户;幂等记录
|
||||
const account = { id: "sim-account-0001", name: "WmailHub Sim Account" };
|
||||
await this.record("account", account.id, account);
|
||||
return [account];
|
||||
}
|
||||
|
||||
async listZones(_token: string, accountId: string): Promise<CFZone[]> {
|
||||
const zones = [
|
||||
{
|
||||
id: "sim-zone-example.test",
|
||||
name: "example.test",
|
||||
account_id: accountId,
|
||||
status: "active",
|
||||
},
|
||||
];
|
||||
for (const z of zones) await this.record("zone", z.id, z);
|
||||
return zones;
|
||||
}
|
||||
|
||||
async putEmailWorker(opts: {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
ingressUrl: string;
|
||||
ingressSecret: string;
|
||||
}) {
|
||||
// 与真实实现同一模板渲染,但只记录不外发
|
||||
await this.record("email_worker", opts.scriptName, {
|
||||
account_id: opts.accountId,
|
||||
ingress_url: opts.ingressUrl,
|
||||
script: renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret).length,
|
||||
});
|
||||
return { id: opts.scriptName };
|
||||
}
|
||||
|
||||
async enableEmailRouting(opts: { zoneId: string }) {
|
||||
await this.record("routing_settings", opts.zoneId, { enabled: true });
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
async setCatchAllRule(opts: { zoneId: string; workerName: string }) {
|
||||
await this.record("routing_rule", opts.zoneId, {
|
||||
type: "catch_all",
|
||||
action: "worker",
|
||||
value: opts.workerName,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* TTL 清理与 orphan 清理(docs/06、docs/08):
|
||||
* - cleanupExpiredEmails:expires_at <= now 的邮件 -> 先删 R2 对象(raw + 附件),全部成功才删行;
|
||||
* R2 失败则保留 DB 记录留待下次 Cron 重试(幂等)。
|
||||
* - cleanupOrphans:附件行存在但邮件已删 -> 删 R2 对象 + 附件行;
|
||||
* delivery 指向已删邮件 -> 删 delivery 行(数据最小化)。
|
||||
* - housekeeping:过期 nonce、过期 OAuth state 清理。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 清理统计 **/
|
||||
export interface CleanupStats {
|
||||
expiredEmails: number;
|
||||
deferredEmails: number;
|
||||
orphanAttachments: number;
|
||||
orphanDeliveries: number;
|
||||
housekeeping: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Cron 主入口:TTL 清理 + orphan 清理 + webhook 投递重试 + 琐碎清理。全程幂等。
|
||||
*/
|
||||
export async function runCron(
|
||||
env: Env,
|
||||
deliverDue: () => Promise<unknown>,
|
||||
): Promise<CleanupStats> {
|
||||
const stats: CleanupStats = {
|
||||
expiredEmails: 0,
|
||||
deferredEmails: 0,
|
||||
orphanAttachments: 0,
|
||||
orphanDeliveries: 0,
|
||||
housekeeping: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
await cleanupExpiredEmails(env, stats);
|
||||
await cleanupOrphans(env, stats);
|
||||
await housekeeping(env, stats);
|
||||
try {
|
||||
await deliverDue();
|
||||
} catch (err) {
|
||||
stats.errors.push(`deliver: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 删除过期邮件(R2 先行,全部成功才删 DB)
|
||||
*/
|
||||
export async function cleanupExpiredEmails(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const now = nowIso();
|
||||
const expired = await env.DB.prepare(
|
||||
"SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? LIMIT 100",
|
||||
)
|
||||
.bind(now)
|
||||
.all<{ id: string; raw_storage_key: string }>();
|
||||
|
||||
for (const email of expired.results) {
|
||||
try {
|
||||
const atts = await env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
|
||||
.bind(email.id)
|
||||
.all<{ storage_key: string | null }>();
|
||||
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(
|
||||
(k): k is string => Boolean(k),
|
||||
);
|
||||
if (keys.length > 0) await env.R2.delete(keys);
|
||||
// R2 delete 幂等,成功后删除 DB 行
|
||||
await env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(email.id).run();
|
||||
await env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(email.id).run();
|
||||
stats.expiredEmails++;
|
||||
} catch (err) {
|
||||
// R2 失败:保留记录,下次重试
|
||||
stats.deferredEmails++;
|
||||
stats.errors.push(`cleanup email ${email.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* orphan 清理:无主附件行、无主 delivery
|
||||
*/
|
||||
export async function cleanupOrphans(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const orphanAtts = await env.DB.prepare(
|
||||
"SELECT a.id, a.storage_key FROM email_attachments a LEFT JOIN emails e ON e.id = a.email_id WHERE e.id IS NULL LIMIT 100",
|
||||
).all<{ id: string; storage_key: string | null }>();
|
||||
for (const att of orphanAtts.results) {
|
||||
try {
|
||||
if (att.storage_key) await env.R2.delete(att.storage_key);
|
||||
await env.DB.prepare("DELETE FROM email_attachments WHERE id = ?").bind(att.id).run();
|
||||
stats.orphanAttachments++;
|
||||
} catch (err) {
|
||||
stats.errors.push(`orphan attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const orphanDeliveries = await env.DB.prepare(
|
||||
"SELECT d.id FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id WHERE e.id IS NULL LIMIT 100",
|
||||
).all<{ id: string }>();
|
||||
for (const d of orphanDeliveries.results) {
|
||||
await env.DB.prepare("DELETE FROM webhook_deliveries WHERE id = ?").bind(d.id).run();
|
||||
stats.orphanDeliveries++;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 琐碎清理:过期 nonce、过期 OAuth state
|
||||
*/
|
||||
export async function housekeeping(env: Env, stats: CleanupStats): Promise<void> {
|
||||
const now = nowIso();
|
||||
const n1 = await env.DB.prepare("DELETE FROM nonces WHERE expires_at <= ?").bind(now).run();
|
||||
const n2 = await env.DB.prepare("DELETE FROM oauth_states WHERE expires_at <= ?").bind(now).run();
|
||||
stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Node 自动安装管线(docs/02 流程):
|
||||
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
|
||||
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
|
||||
* 创建 Domain/Mailbox -> Node Ready。
|
||||
*
|
||||
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
|
||||
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
|
||||
*/
|
||||
|
||||
import type { Env } from "../env.ts";
|
||||
import { getCFApi, type CFApi } from "./cfapi.ts";
|
||||
import { encryptSecret, decryptSecret } from "../db.ts";
|
||||
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
/** # 成员变量: 安装步骤定义(顺序即展示顺序) **/
|
||||
export const PROVISION_STEPS = [
|
||||
{ key: "oauth", label: "Cloudflare OAuth 授权" },
|
||||
{ key: "identity", label: "Node identity 与 credential" },
|
||||
{ key: "discover", label: "Account / Zone 发现" },
|
||||
{ key: "email_worker", label: "Email Worker 创建/更新" },
|
||||
{ key: "email_routing", label: "Email Routing 配置" },
|
||||
{ key: "domains", label: "Domain 创建" },
|
||||
{ key: "mailboxes", label: "Mailbox 创建" },
|
||||
] as const;
|
||||
|
||||
/** # 成员变量: 步骤状态 **/
|
||||
export type StepStatus = "pending" | "running" | "done" | "error";
|
||||
|
||||
/** # 函数
|
||||
* 初始化步骤状态 JSON
|
||||
*/
|
||||
export function initialSteps(): Record<string, { status: StepStatus; error?: string }> {
|
||||
const out: Record<string, { status: StepStatus; error?: string }> = {};
|
||||
for (const s of PROVISION_STEPS) out[s.key] = { status: "pending" };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
|
||||
*/
|
||||
export function emailWorkerNameFor(nodeId: string): string {
|
||||
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
|
||||
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
|
||||
*/
|
||||
export async function createNode(
|
||||
env: Env,
|
||||
opts: { name: string; ingressUrl: string },
|
||||
): Promise<{
|
||||
nodeId: string;
|
||||
enrollmentToken: string;
|
||||
ingressSecret: string;
|
||||
}> {
|
||||
const nodeId = ids.node();
|
||||
const now = nowIso();
|
||||
const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, "");
|
||||
const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, "");
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version,
|
||||
last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at,
|
||||
provision_steps, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
nodeId,
|
||||
opts.name,
|
||||
opts.ingressUrl,
|
||||
await encryptText(env, ingressSecret),
|
||||
enrollmentToken,
|
||||
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
JSON.stringify(initialSteps()),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return { nodeId, enrollmentToken, ingressSecret };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Node bootstrap enrollment:用一次性 token 换取正式身份。
|
||||
* 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。
|
||||
*/
|
||||
export async function enrollNode(
|
||||
env: Env,
|
||||
enrollmentToken: string,
|
||||
): Promise<{
|
||||
node_id: string;
|
||||
credential_id: string;
|
||||
credential_secret: string;
|
||||
ingress_secret: string;
|
||||
console_url: string | null;
|
||||
} | null> {
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'",
|
||||
)
|
||||
.bind(enrollmentToken)
|
||||
.first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>();
|
||||
if (!node) return null;
|
||||
if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null;
|
||||
|
||||
// 轮换:撤销旧 credential,签发新 credential
|
||||
await env.DB.prepare(
|
||||
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(nowIso(), node.id)
|
||||
.run();
|
||||
const credentialId = ids.credential();
|
||||
const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"UPDATE nodes SET credential_id = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(credentialId, nowIso(), node.id)
|
||||
.run();
|
||||
|
||||
return {
|
||||
node_id: node.id,
|
||||
credential_id: credentialId,
|
||||
credential_secret: secret,
|
||||
ingress_secret: await decryptText(env, node.ingress_secret_enc),
|
||||
console_url: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function encryptText(env: Env, text: string): Promise<string> {
|
||||
return encryptSecret(env, text);
|
||||
}
|
||||
|
||||
async function decryptText(env: Env, blob: string): Promise<string> {
|
||||
return decryptSecret(env, blob);
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权完成后的完整安装流程参数 **/
|
||||
export interface ProvisionInput {
|
||||
nodeId: string;
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
|
||||
* 返回是否全部成功;失败时步骤状态里带 error。
|
||||
*/
|
||||
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const cf: CFApi = getCFApi(env, env.DB);
|
||||
const node = await env.DB.prepare(
|
||||
"SELECT id, name, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
|
||||
)
|
||||
.bind(input.nodeId)
|
||||
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
|
||||
if (!node) return { ok: false, error: "node not found" };
|
||||
|
||||
const steps = await loadSteps(env, input.nodeId);
|
||||
const mark = async (key: string, status: StepStatus, error?: string) => {
|
||||
steps[key] = { status, error };
|
||||
await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(JSON.stringify(steps), nowIso(), input.nodeId)
|
||||
.run();
|
||||
};
|
||||
|
||||
try {
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
|
||||
await mark("identity", "done");
|
||||
|
||||
// 3. Account/Zone 发现
|
||||
await mark("discover", "running");
|
||||
const accounts = await cf.listAccounts(input.accessToken);
|
||||
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
|
||||
const account = input.accountId
|
||||
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
|
||||
: accounts[0];
|
||||
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(account.id, nowIso(), input.nodeId)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, account.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 4. Email Worker(幂等上传)
|
||||
await mark("email_worker", "running");
|
||||
const workerName = emailWorkerNameFor(input.nodeId);
|
||||
const ingressSecret = await decryptText(env, node.ingress_secret_enc);
|
||||
await cf.putEmailWorker({
|
||||
accessToken: input.accessToken,
|
||||
accountId: account.id,
|
||||
scriptName: workerName,
|
||||
ingressUrl: node.ingress_url,
|
||||
ingressSecret,
|
||||
});
|
||||
await mark("email_worker", "done");
|
||||
|
||||
// 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain
|
||||
await mark("email_routing", "running");
|
||||
for (const zone of zones) {
|
||||
await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id });
|
||||
await cf.setCatchAllRule({
|
||||
accessToken: input.accessToken,
|
||||
zoneId: zone.id,
|
||||
workerName,
|
||||
});
|
||||
await upsertDomain(env, {
|
||||
cloudflareAccountId: account.id,
|
||||
nodeId: input.nodeId,
|
||||
name: zone.name,
|
||||
zoneId: zone.id,
|
||||
workerId: workerName,
|
||||
});
|
||||
}
|
||||
await mark("email_routing", "done");
|
||||
await mark("domains", "done");
|
||||
|
||||
// 7. 每个 Domain 创建 catch-all Mailbox(*@domain)
|
||||
await mark("mailboxes", "running");
|
||||
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
|
||||
.bind(input.nodeId)
|
||||
.all<{ id: string; name: string }>();
|
||||
for (const d of domainRows.results) {
|
||||
await upsertMailbox(env, {
|
||||
domainId: d.id,
|
||||
address: `*@${d.name}`,
|
||||
isCatchAll: true,
|
||||
});
|
||||
}
|
||||
await mark("mailboxes", "done");
|
||||
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// 标记第一个未完成步骤为 error
|
||||
for (const s of PROVISION_STEPS) {
|
||||
if (steps[s.key]?.status === "running") {
|
||||
await mark(s.key, "error", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
|
||||
.bind(nowIso(), input.nodeId)
|
||||
.run();
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取并解析 node 的步骤状态
|
||||
*/
|
||||
export async function loadSteps(env: Env, nodeId: string) {
|
||||
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
|
||||
.bind(nodeId)
|
||||
.first<{ provision_steps: string }>();
|
||||
if (!row?.provision_steps) return initialSteps();
|
||||
try {
|
||||
return JSON.parse(row.provision_steps) as Record<string, { status: StepStatus; error?: string }>;
|
||||
} catch {
|
||||
return initialSteps();
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Domain(按 account+name 幂等)
|
||||
*/
|
||||
export async function upsertDomain(
|
||||
env: Env,
|
||||
d: {
|
||||
cloudflareAccountId: string;
|
||||
nodeId: string;
|
||||
name: string;
|
||||
zoneId: string;
|
||||
workerId: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare(
|
||||
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
|
||||
)
|
||||
.bind(d.cloudflareAccountId, d.name)
|
||||
.first<{ id: string }>();
|
||||
const now = nowIso();
|
||||
if (existing) {
|
||||
await env.DB.prepare(
|
||||
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
|
||||
.run();
|
||||
return existing.id;
|
||||
}
|
||||
const id = ids.domain();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* upsert Mailbox(按 address 幂等)
|
||||
*/
|
||||
export async function upsertMailbox(
|
||||
env: Env,
|
||||
m: { domainId: string; address: string; isCatchAll?: boolean; retentionSeconds?: number },
|
||||
): Promise<string> {
|
||||
const existing = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?")
|
||||
.bind(m.address.toLowerCase())
|
||||
.first<{ id: string }>();
|
||||
const now = nowIso();
|
||||
if (existing) return existing.id;
|
||||
const id = ids.mailbox();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO mailboxes (id, domain_id, address, status, retention_seconds, is_catch_all, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
m.domainId,
|
||||
m.address.toLowerCase(),
|
||||
m.retentionSeconds ?? DEFAULT_RETENTION_SECONDS,
|
||||
m.isCatchAll ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* DNS 解析器:本地 Bun/Node 运行时用 node:dns 做真实解析(SSRF 防护 DNS rebinding 检查)。
|
||||
* Workers 运行时无 DNS API,返回 undefined 走静态 IP 字面量检查(Workers 出口本身无法
|
||||
* 访问私网,风险由平台边界兜底)。
|
||||
*/
|
||||
|
||||
let cachedResolve: ((host: string) => Promise<string[]>) | null | undefined;
|
||||
|
||||
/** # 函数
|
||||
* 获取主机名解析函数;非 Node/Bun 环境返回 undefined
|
||||
*/
|
||||
export function resolveHost(host: string): Promise<string[]> {
|
||||
if (cachedResolve === undefined) {
|
||||
cachedResolve = detectResolver();
|
||||
}
|
||||
if (!cachedResolve) return Promise.resolve([]);
|
||||
return cachedResolve(host);
|
||||
}
|
||||
|
||||
function detectResolver(): ((host: string) => Promise<string[]>) | null {
|
||||
const hasProcess = typeof process !== "undefined" && Boolean(process.versions);
|
||||
if (!hasProcess) return null;
|
||||
// 动态 require,避免 Workers 打包器静态分析 node:dns
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const dns = (globalThis as { process?: { getBuiltinModule?: (m: string) => unknown } })
|
||||
.process?.getBuiltinModule?.("node:dns") as
|
||||
| { promises?: { lookup: (h: string, o: { all: true }) => Promise<{ address: string }[]> } }
|
||||
| undefined;
|
||||
if (dns?.promises?.lookup) {
|
||||
return async (host) => {
|
||||
const res = await dns.promises!.lookup(host, { all: true });
|
||||
return res.map((r) => r.address);
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Console 通用小工具 */
|
||||
|
||||
/** # 函数
|
||||
* 当前时间 ISO 字符串(UTC)
|
||||
*/
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* unix 秒转 ISO
|
||||
*/
|
||||
export function isoAfter(seconds: number, from: number = Date.now()): string {
|
||||
return new Date(from + seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取 body 为 JSON 对象,失败返回 {}
|
||||
*/
|
||||
export async function readJson<T = Record<string, unknown>>(request: Request): Promise<T> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 分页参数解析
|
||||
*/
|
||||
export function pageParams(url: URL): { limit: number; offset: number } {
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 20, 1), 100);
|
||||
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 安全 JSON 解析,失败返回默认值
|
||||
*/
|
||||
export function parseJsonOr<T>(text: string | null | undefined, fallback: T): T {
|
||||
if (!text) return fallback;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 版本号:一律以项目根 package.json 的 version 字段为准(全局规范),
|
||||
* 运行时/构建时从 package.json 读取,禁止手写常量。
|
||||
*/
|
||||
import pkg from "../../package.json";
|
||||
|
||||
/** # 成员变量: 应用版本(来自 package.json) **/
|
||||
export const APP_VERSION: string = pkg.version;
|
||||
|
||||
/** # 成员变量: 应用名称 **/
|
||||
export const APP_NAME: string = "WmailHub";
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Console Cloudflare Workers 入口(生产部署):
|
||||
* fetch -> Hono 应用(前端资产 + 管理 API + OAuth 回调)
|
||||
* scheduled -> Cron Trigger:TTL 清理 + Webhook 重试(docs/09 建议每 15 分钟)
|
||||
*/
|
||||
|
||||
import { createApp } from "./app.ts";
|
||||
import type { Env, ScheduledEvent, WorkersExecutionContext } from "./env.ts";
|
||||
import { runCron } from "./services/cleanup.ts";
|
||||
import { processDueDeliveries } from "./services/webhook_delivery.ts";
|
||||
|
||||
/** # 成员变量: Hono 应用实例 **/
|
||||
const app = createApp();
|
||||
|
||||
/** # 成员变量: Workers 导出(fetch + scheduled) **/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: WorkersExecutionContext): Promise<Response> {
|
||||
return app.fetch(request, env, ctx as never);
|
||||
},
|
||||
|
||||
async scheduled(event: ScheduledEvent, env: Env, ctx: WorkersExecutionContext): Promise<void> {
|
||||
ctx.waitUntil(
|
||||
runCron(env, () => processDueDeliveries(env)).then((stats) => {
|
||||
console.log(JSON.stringify({ t: new Date().toISOString(), lvl: "info", msg: "cron", ...stats }));
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user