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