feat: v2.1.0 界面与凭据改进 —— 单一 API/MCP Key、邮件 Markdown 正文、/inboxs 域名筛选、暗色主题
- 单一 Key:API 与 MCP 共用一个 wmh_ Key,凭据迁移至 settings 表(0004 删除 api_credentials) - Markdown:入库时 HTML→Markdown(mailhook 同思路),webhook payload 新增 markdown 字段 - /emails 路由改为 /inboxs 并支持域名筛选;设置按钮移入头像下拉 - 主题切换按钮(头像左侧,亮/暗持久化);设置模态仅 全局 Webhook/账号密码 两 tab 且高度限屏 - 列表操作列按钮加 SVG 图标;mailboxes 去 Webhook 列;最近邮件加收件人列
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
* /api/auth/* 用户登录/登出/会话(cookie)
|
||||
* /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护)
|
||||
* /api/settings/* 设置:全局 Webhook / 改密码(会话保护)
|
||||
* /api/keys/* API/MCP Key 管理(会话保护;设置模态窗口内使用)
|
||||
* /api/oauth/* Cloudflare OAuth(会话保护)
|
||||
* /api/admin/* 版本/统计/Cron(会话保护)
|
||||
* /internal/email/* Email Worker 内部协议:authorize / ingest(HMAC 签名,docs/11)
|
||||
@@ -23,7 +22,6 @@ import { authRoutes } from "./auth.ts";
|
||||
import { internalRoutes } from "./routes/internal.ts";
|
||||
import { resourceRoutes } from "./routes/resources.ts";
|
||||
import { settingsRoutes } from "./routes/settings.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";
|
||||
@@ -55,7 +53,6 @@ export function createApp(
|
||||
app.route("/internal", internalRoutes());
|
||||
app.route("/api/oauth", oauthRoutes());
|
||||
app.route("/api/settings", settingsRoutes());
|
||||
app.route("/api/keys", keyRoutes());
|
||||
app.route("/api/admin", adminRoutes());
|
||||
app.route("/api/v1/mcp", mcpRoutes());
|
||||
app.route("/api/v1", apiV1Routes());
|
||||
|
||||
@@ -75,9 +75,6 @@ export interface ScheduledEvent {
|
||||
export interface AppVars {
|
||||
/** 已登录用户 ID(cookie 会话) **/
|
||||
userId?: string;
|
||||
/** 已认证 API/MCP Key 信息 **/
|
||||
apiKeyKind?: "api" | "mcp";
|
||||
apiKeyId?: string;
|
||||
/** internal 路由中已读出的原始请求体(workerAuth 中间件缓存) **/
|
||||
rawBody?: Uint8Array;
|
||||
/** internal 路由中通过校验的 worker credential id **/
|
||||
|
||||
+24
-49
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Console 中间件:会话守卫、API/MCP Key 守卫、CSRF Origin 校验。
|
||||
* Console 中间件:会话守卫、API/MCP Key 守卫(单一 Key)、CSRF Origin 校验。
|
||||
*/
|
||||
|
||||
import type { Env } from "./env.ts";
|
||||
import type { AppVars } from "./env.ts";
|
||||
import type { Env, AppVars } from "./env.ts";
|
||||
import { readSessionCookie, verifySessionToken } from "./auth.ts";
|
||||
import { keyHash } from "./db.ts";
|
||||
import { nowIso } from "./util.ts";
|
||||
import { getSetting, keyHash } from "./db.ts";
|
||||
import type { Context, Next } from "hono";
|
||||
|
||||
type Ctx = Context<{ Bindings: Env; Variables: AppVars }>;
|
||||
@@ -43,56 +41,33 @@ export async function csrfOriginCheck(c: Ctx, next: Next) {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* API/MCP Key 守卫工厂:校验 Authorization: Bearer wmh_... 并限定 kind。
|
||||
* 通过后 c.set("apiKeyKind"/"apiKeyId")。
|
||||
* 参数: kind - 允许的凭据类型
|
||||
* API/MCP Key 守卫:校验 Authorization: Bearer wmh_...(settings 表单一 Key)。
|
||||
* API 与 MCP 共用同一 Key(docs/07 v2.1.0),不再区分 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();
|
||||
};
|
||||
export async function requireApiKey(c: Ctx, next: Next) {
|
||||
const key = await bearerKey(c.req.raw);
|
||||
if (!key) return c.json({ error: "missing or invalid api key" }, 401);
|
||||
if (!(await verifyApiKey(c.env, key))) return c.json({ error: "invalid or revoked api key" }, 401);
|
||||
await next();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解析并校验 Bearer Key(返回凭据行);供 /api/v1 统一入口使用
|
||||
* 校验 Bearer Key 是否为 settings 表中的单一 Key
|
||||
* 参数: env - 运行环境;key - 明文 Key
|
||||
* 返回值: 有效 true,否则 false
|
||||
*/
|
||||
export async function resolveApiKey(
|
||||
env: Env,
|
||||
request: Request,
|
||||
): Promise<{ ok: true; id: string; kind: "api" | "mcp" } | { ok: false; status: number; error: string }> {
|
||||
export async function verifyApiKey(env: Env, key: string): Promise<boolean> {
|
||||
const hash = await getSetting(env, "api_key_hash");
|
||||
if (!hash) return false;
|
||||
return hash === (await keyHash(key));
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 从 Authorization 头解析 Bearer Key(须 wmh_ 前缀)
|
||||
*/
|
||||
async function bearerKey(request: Request): Promise<string | null> {
|
||||
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" };
|
||||
return key && key.startsWith("wmh_") ? key : null;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function adminRoutes(): App {
|
||||
worker_credentials: await count("SELECT COUNT(*) AS n FROM worker_credentials WHERE status = 'active'"),
|
||||
};
|
||||
const recent = await c.env.DB.prepare(
|
||||
`SELECT id, subject, from_address, received_at, size FROM emails
|
||||
`SELECT id, subject, from_address, to_addresses, received_at, size FROM emails
|
||||
WHERE expires_at > ? ORDER BY received_at DESC LIMIT 8`,
|
||||
)
|
||||
.bind(now)
|
||||
|
||||
@@ -16,7 +16,7 @@ type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
*/
|
||||
export function apiV1Routes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
app.use("*", requireApiKey("api"));
|
||||
app.use("*", requireApiKey);
|
||||
|
||||
/** 账号列表 */
|
||||
app.get("/accounts", async (c) => {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const TOOLS = [
|
||||
export function mcpRoutes(): App {
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
||||
|
||||
app.use("*", requireApiKey("mcp"));
|
||||
app.use("*", requireApiKey);
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const rpc = await readJson<JsonRpcRequest>(c.req.raw);
|
||||
|
||||
@@ -348,6 +348,7 @@ function installEmailRoutes(app: App) {
|
||||
const url = new URL(c.req.url);
|
||||
const { limit, offset } = pageParams(url);
|
||||
const mailboxId = url.searchParams.get("mailbox_id");
|
||||
const domainId = url.searchParams.get("domain_id");
|
||||
const q = url.searchParams.get("q");
|
||||
const conds: string[] = [];
|
||||
const binds: unknown[] = [];
|
||||
@@ -355,6 +356,10 @@ function installEmailRoutes(app: App) {
|
||||
conds.push("mailbox_id = ?");
|
||||
binds.push(mailboxId);
|
||||
}
|
||||
if (domainId) {
|
||||
conds.push("m.domain_id = ?");
|
||||
binds.push(domainId);
|
||||
}
|
||||
if (q) {
|
||||
conds.push("(subject LIKE ? OR from_address LIKE ?)");
|
||||
binds.push(`%${q}%`, `%${q}%`);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* 设置路由(v2.0.0):
|
||||
* GET /api/settings 读取:全局 Webhook / 当前用户
|
||||
* PUT /api/settings 保存:全局 Webhook
|
||||
* PUT /api/settings/password 修改登录密码
|
||||
* API/MCP Key 管理沿用 /api/keys(前端设置模态窗口内调用)。
|
||||
* 设置路由(v2.1.0):
|
||||
* GET /api/settings 读取:全局 Webhook / API Key 元信息 / 当前用户
|
||||
* PUT /api/settings 保存:全局 Webhook
|
||||
* POST /api/settings/api-key 生成(轮换)单一 API/MCP Key,明文仅返回一次
|
||||
* DEL /api/settings/api-key 撤销并删除 API Key
|
||||
* PUT /api/settings/password 修改登录密码
|
||||
* API 与 MCP 共用同一 Key,凭据存 settings 表(不再单独建表)。
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Env, AppVars } from "../env.ts";
|
||||
import { requireSession } from "../middleware.ts";
|
||||
import { checkWebhookUrl, hashPassword, verifyPassword } from "../../../shared/src/index.ts";
|
||||
import { getSetting } from "../db.ts";
|
||||
import {
|
||||
checkWebhookUrl,
|
||||
generateWmailKey,
|
||||
hashPassword,
|
||||
isValidWmailKey,
|
||||
verifyPassword,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { getSetting, keyHash, setSetting } from "../db.ts";
|
||||
import {
|
||||
getGlobalWebhookKey,
|
||||
normalizeWebhookHeaders,
|
||||
@@ -20,6 +28,17 @@ import { nowIso, readJson } from "../util.ts";
|
||||
|
||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||
|
||||
/** # 函数
|
||||
* 读取 API Key 元信息(只有 last4,无明文)
|
||||
*/
|
||||
async function apiKeyMeta(env: Env): Promise<{ has_key: boolean; key_last4: string; created_at: string | null }> {
|
||||
const [last4, createdAt] = await Promise.all([
|
||||
getSetting(env, "api_key_last4"),
|
||||
getSetting(env, "api_key_created_at"),
|
||||
]);
|
||||
return { has_key: Boolean(last4), key_last4: last4 ?? "", created_at: createdAt ?? null };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 装配设置路由(会话保护)
|
||||
*/
|
||||
@@ -50,6 +69,7 @@ export function settingsRoutes(): App {
|
||||
return c.json({
|
||||
username: user?.username ?? "",
|
||||
webhook,
|
||||
api_key: await apiKeyMeta(c.env),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +103,25 @@ export function settingsRoutes(): App {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 生成(轮换)API/MCP Key:明文仅本次返回;旧 Key 立即失效 */
|
||||
app.post("/api-key", async (c) => {
|
||||
const key = generateWmailKey();
|
||||
if (!isValidWmailKey(key)) return c.json({ error: "key generator broken" }, 500);
|
||||
const createdAt = nowIso();
|
||||
await setSetting(c.env, "api_key_hash", await keyHash(key));
|
||||
await setSetting(c.env, "api_key_last4", key.slice(-4));
|
||||
await setSetting(c.env, "api_key_created_at", createdAt);
|
||||
return c.json({ ok: true, key, created_at: createdAt }, 201);
|
||||
});
|
||||
|
||||
/** 撤销并删除 API Key */
|
||||
app.delete("/api-key", async (c) => {
|
||||
for (const k of ["api_key_hash", "api_key_last4", "api_key_created_at"]) {
|
||||
await c.env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(k).run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 修改登录密码 */
|
||||
app.put("/password", async (c) => {
|
||||
const body = await readJson<{ old_password?: string; new_password?: string }>(c.req.raw);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import PostalMime from "postal-mime";
|
||||
import type { Env } from "../env.ts";
|
||||
import {
|
||||
htmlToMarkdown,
|
||||
ids,
|
||||
DEFAULT_RETENTION_SECONDS,
|
||||
MAX_EMAIL_BYTES,
|
||||
@@ -159,11 +160,16 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
|
||||
});
|
||||
}
|
||||
|
||||
// Markdown 正文:HTML 优先转换,其次纯文本(mailhook 同思路;webhook 投递使用)
|
||||
const markdownBody = parsed?.html
|
||||
? htmlToMarkdown(parsed.html)
|
||||
: (parsed?.text ?? "");
|
||||
|
||||
// 入库
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO emails (id, account_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
subject, text_body, html_body, markdown_body, raw_storage_key, size, received_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
emailId,
|
||||
@@ -176,6 +182,7 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
|
||||
parsed?.subject ?? "(unparsable mail)",
|
||||
parsed?.text ?? null,
|
||||
parsed?.html ?? null,
|
||||
markdownBody || null,
|
||||
rawKey,
|
||||
input.raw.byteLength,
|
||||
receivedAt,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
WEBHOOK_MAX_ATTEMPTS,
|
||||
WEBHOOK_RETRY_BASE_MS,
|
||||
} from "../../../shared/src/index.ts";
|
||||
import { htmlToMarkdown } from "../../../shared/src/index.ts";
|
||||
import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts";
|
||||
import { nowIso } from "../util.ts";
|
||||
|
||||
@@ -211,6 +212,8 @@ export function buildWebhookPayload(email: {
|
||||
toAddresses: string;
|
||||
subject: string | null;
|
||||
textBody: string | null;
|
||||
htmlBody: string | null;
|
||||
markdownBody: string | null;
|
||||
receivedAt: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -221,6 +224,11 @@ export function buildWebhookPayload(email: {
|
||||
to: safeParseArray(email.toAddresses),
|
||||
subject: email.subject,
|
||||
text: email.textBody,
|
||||
// Markdown 正文(HTML 转换优先,纯文本兜底);v2.1.0 新增
|
||||
markdown:
|
||||
email.markdownBody ??
|
||||
(email.htmlBody ? htmlToMarkdown(email.htmlBody) : null) ??
|
||||
email.textBody,
|
||||
received_at: email.receivedAt,
|
||||
};
|
||||
}
|
||||
@@ -305,6 +313,8 @@ interface DueDeliveryRow {
|
||||
to_addresses: string;
|
||||
subject: string | null;
|
||||
text_body: string | null;
|
||||
html_body: string | null;
|
||||
markdown_body: string | null;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
@@ -323,7 +333,7 @@ export async function processDueDeliveries(
|
||||
const due = await env.DB.prepare(
|
||||
`SELECT d.id AS delivery_id, d.url, d.headers, d.key_enc, d.attempt_count,
|
||||
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
|
||||
e.html_body, e.markdown_body, e.received_at, e.expires_at
|
||||
FROM webhook_deliveries d
|
||||
JOIN emails e ON e.id = d.email_id
|
||||
WHERE d.status IN ('pending', 'failed')
|
||||
@@ -362,6 +372,8 @@ export async function processDueDeliveries(
|
||||
toAddresses: row.to_addresses,
|
||||
subject: row.subject,
|
||||
textBody: row.text_body,
|
||||
htmlBody: row.html_body,
|
||||
markdownBody: row.markdown_body,
|
||||
receivedAt: row.received_at,
|
||||
});
|
||||
const result = await attemptDelivery(
|
||||
|
||||
Reference in New Issue
Block a user