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:
bigcode
2026-09-30 13:27:28 +08:00
parent fec96e49cc
commit 7370cce784
38 changed files with 1281 additions and 512 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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) => {
-66
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+5
View File
@@ -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}%`);
+46 -7
View File
@@ -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);