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:
@@ -8,6 +8,7 @@
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"antd": "^6.6.5",
|
||||
"hono": "^4.9.0",
|
||||
"node-html-parser": "^9.0.4",
|
||||
"postal-mime": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -175,6 +176,8 @@
|
||||
|
||||
"antd": ["antd@6.6.5", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.4", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.22.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.6", "@rc-component/image": "~1.10.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/listy": "~1.2.3", "@rc-component/mentions": "~1.12.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.8", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.12.2", "@rc-component/progress": "~1.0.3", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.4.0", "@rc-component/select": "~1.10.1", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.3", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.11.1", "@rc-component/tabs": "~1.13.0", "@rc-component/tooltip": "~1.5.2", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.4.0", "@rc-component/tree-select": "~1.16.1", "@rc-component/trigger": "^3.10.1", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-biJB/aORNU2lvZZCX783S/g8atfE8akrEKRjRrsXVttoMtJbSHLf/Z29niv0blzt6H3BgKvhkQgHoUn5MgCx7w=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
@@ -183,12 +186,26 @@
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -225,6 +242,10 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@9.0.4", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-reUSrZkF1rO7hEYe5mJwtbVcQ6mB5QrViCZH6Wv+iz3er/PA9Q50gol15dMLQRDfdli4VzqPMPR24HNRFgVKFw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
@@ -272,5 +293,7 @@
|
||||
"@rc-component/segmented/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="],
|
||||
|
||||
"@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 设置入口 */
|
||||
/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 主题切换 + 头像下拉(设置/退出) */
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Tooltip } from "antd";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu } from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
MailOutlined,
|
||||
CloudServerOutlined,
|
||||
GlobalOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { health, logout, me } from "./api.ts";
|
||||
import { SettingsModal } from "./components/SettingsModal.tsx";
|
||||
import { useTheme } from "./theme-context.ts";
|
||||
|
||||
const { Sider, Header, Content } = Layout;
|
||||
|
||||
@@ -22,7 +23,7 @@ const MENU_ITEMS = [
|
||||
{ key: "/accounts", icon: <CloudServerOutlined />, label: "账号 Accounts" },
|
||||
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
|
||||
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
|
||||
{ key: "/emails", icon: <MailOutlined />, label: "邮件 Emails" },
|
||||
{ key: "/inboxs", icon: <MailOutlined />, label: "收件箱 Inboxs" },
|
||||
];
|
||||
|
||||
/** # 成员变量: 路由 -> 页面标题/副标题 **/
|
||||
@@ -31,15 +32,29 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" },
|
||||
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
|
||||
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
|
||||
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
|
||||
"/inboxs": { title: "收件箱", subtitle: "已接收邮件(默认保留 1 天),支持按域名筛选" },
|
||||
};
|
||||
|
||||
/** # 成员变量: 主题切换按钮的太阳/月亮 SVG 图标 **/
|
||||
const SunIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
);
|
||||
const MoonIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** # 函数
|
||||
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 设置入口;侧栏品牌带版本号
|
||||
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 主题切换 + 头像下拉;侧栏品牌带版本号
|
||||
*/
|
||||
export function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { dark, toggle } = useTheme();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [version, setVersion] = useState("");
|
||||
@@ -62,8 +77,9 @@ export function App() {
|
||||
const meta = PAGE_META[location.pathname] ?? { title: "WmailHub", subtitle: "" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout className="app-layout">
|
||||
<Sider width={228} className="app-sider" theme="light">
|
||||
<Sider width={228} className="app-sider" theme={dark ? "dark" : "light"}>
|
||||
<div className="brand">
|
||||
<img src="/logo.svg" alt="WmailHub" />
|
||||
<span className="name">WmailHub</span>
|
||||
@@ -85,19 +101,28 @@ export function App() {
|
||||
<div className="subtitle">{meta.subtitle}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<Tooltip title="设置(全局 Webhook / 密码 / API & MCP Keys)">
|
||||
{/* 主题切换:亮↔暗,位于头像左侧 */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="设置"
|
||||
aria-label="切换主题"
|
||||
title={dark ? "切换到亮色模式" : "切换到暗色模式"}
|
||||
icon={dark ? SunIcon : MoonIcon}
|
||||
onClick={toggle}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
|
||||
{/* 设置入口收进头像下拉 */}
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: "设置",
|
||||
onClick: () => setSettingsOpen(true),
|
||||
},
|
||||
{
|
||||
type: "divider" as const,
|
||||
},
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
@@ -122,7 +147,8 @@ export function App() {
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</Layout>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-21
@@ -99,7 +99,14 @@ export const stats = () =>
|
||||
deliveries_pending: number;
|
||||
oauth_connections: number;
|
||||
worker_credentials: number;
|
||||
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
|
||||
recent_emails: {
|
||||
id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
to_addresses: string;
|
||||
received_at: string;
|
||||
size: number;
|
||||
}[];
|
||||
}>("/api/admin/stats");
|
||||
|
||||
/** 手动触发 Cron */
|
||||
@@ -243,8 +250,13 @@ export interface EmailRow {
|
||||
attachment_count: number;
|
||||
}
|
||||
|
||||
export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) =>
|
||||
api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
|
||||
export const listEmails = (params: {
|
||||
domain_id?: string;
|
||||
mailbox_id?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
|
||||
|
||||
export interface EmailDetail {
|
||||
email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string };
|
||||
@@ -260,6 +272,7 @@ export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
||||
export interface SettingsInfo {
|
||||
username: string;
|
||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||
api_key: { has_key: boolean; key_last4: string; created_at: string | null };
|
||||
}
|
||||
|
||||
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
@@ -272,23 +285,11 @@ export const putSettings = (body: {
|
||||
export const changePassword = (old_password: string, new_password: string) =>
|
||||
api("/api/settings/password", "PUT", { old_password, new_password });
|
||||
|
||||
/* ---- API/MCP Keys(设置模态窗口内使用) ---- */
|
||||
/* ---- API/MCP Key(单一 Key,设置模态窗口内使用;API 与 MCP 共用) ---- */
|
||||
|
||||
export interface KeyRow {
|
||||
id: string;
|
||||
kind: "api" | "mcp";
|
||||
name: string;
|
||||
key_last4: string;
|
||||
created_at: string;
|
||||
revoked_at: string | null;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
/** 生成(轮换)Key:明文仅本次返回 */
|
||||
export const createApiKey = () =>
|
||||
api<{ ok: boolean; key: string; created_at: string }>("/api/settings/api-key", "POST");
|
||||
|
||||
export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys");
|
||||
|
||||
export const createKey = (kind: "api" | "mcp", name?: string) =>
|
||||
api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name });
|
||||
|
||||
export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST");
|
||||
|
||||
export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");
|
||||
/** 撤销并删除 Key */
|
||||
export const deleteApiKey = () => api("/api/settings/api-key", "DELETE");
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
/**
|
||||
* 设置模态窗口(v2.0.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 修改密码
|
||||
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
|
||||
* 设置模态窗口(v2.1.0):
|
||||
* - 两个 tab:全局 Webhook(含 API/MCP Key 卡片)、账号密码
|
||||
* - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次
|
||||
* - 内容高度不超过屏幕(超出滚动)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
changePassword,
|
||||
createKey,
|
||||
deleteKey,
|
||||
getSettings,
|
||||
listKeys,
|
||||
putSettings,
|
||||
revokeKey,
|
||||
type KeyRow,
|
||||
type SettingsInfo,
|
||||
} from "../api.ts";
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { changePassword, createApiKey, deleteApiKey, getSettings, putSettings, type SettingsInfo } from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 设置模态窗口组件
|
||||
@@ -51,12 +47,21 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}, [open, load]);
|
||||
|
||||
return (
|
||||
<Modal title="设置" open={open} onCancel={onClose} footer={null} width={760} destroyOnHidden>
|
||||
<Modal
|
||||
title="设置"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={720}
|
||||
centered
|
||||
destroyOnHidden
|
||||
// 高度不超过屏幕:内容区受限滚动
|
||||
styles={{ body: { maxHeight: "calc(100vh - 180px)", overflowY: "auto", paddingRight: 8 } }}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "general", label: "基本设置", children: info ? <GeneralTab info={info} onSaved={load} /> : null },
|
||||
{ key: "api", label: "API Keys", children: <KeysTab kind="api" /> },
|
||||
{ key: "mcp", label: "MCP Keys", children: <KeysTab kind="mcp" /> },
|
||||
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
|
||||
{ key: "password", label: "账号密码", children: <PasswordTab /> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -71,16 +76,11 @@ interface WebhookFormValues {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 基本设置:全局 Webhook + 修改密码
|
||||
* 全局 Webhook tab:Webhook 表单 + 单一 API/MCP Key 卡片
|
||||
*/
|
||||
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
function WebhookTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [webhookForm] = Form.useForm<WebhookFormValues>();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm: string;
|
||||
}>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,22 +117,9 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
}
|
||||
};
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
try {
|
||||
await changePassword(values.old_password, values.new_password);
|
||||
message.success("密码已更新");
|
||||
pwdForm.resetFields();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "修改失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Form form={webhookForm} layout="vertical">
|
||||
<Typography.Title level={5}>全局 Webhook</Typography.Title>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
@@ -165,8 +152,130 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<ApiKeyCard apiKey={info.api_key} onChanged={onSaved} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* API/MCP Key 卡片:单一 Key,API 与 MCP 接口共用;生成/轮换与撤销
|
||||
*/
|
||||
function ApiKeyCard({
|
||||
apiKey,
|
||||
onChanged,
|
||||
}: {
|
||||
apiKey: SettingsInfo["api_key"];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
/** 生成(轮换)key:明文仅展示一次 */
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await createApiKey();
|
||||
modal.info({
|
||||
title: "API/MCP Key 已生成(仅显示一次)",
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
||||
{res.key}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">API 与 MCP 接口共用此 Key;请立即保存,关闭后无法再次查看。</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "生成失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 撤销删除 key */
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteApiKey();
|
||||
message.success("已撤销并删除 Key");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="small" title={<span><KeyOutlined style={{ marginRight: 8 }} />API / MCP Key</span>}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="Console API(/api/v1)与 MCP(/api/v1/mcp)共用同一个 Key;生成新 Key 后旧 Key 立即失效。"
|
||||
/>
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="状态">
|
||||
{apiKey.has_key ? (
|
||||
<span>
|
||||
<span className="mono">****{apiKey.key_last4}</span>{" "}
|
||||
<Typography.Text type="secondary">
|
||||
{apiKey.created_at ? `创建于 ${new Date(apiKey.created_at).toLocaleString()}` : ""}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未生成</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={apiKey.has_key ? <CopyOutlined /> : <PlusOutlined />}
|
||||
loading={busy}
|
||||
onClick={() => void create()}
|
||||
>
|
||||
{apiKey.has_key ? "重新生成(轮换)" : "生成 Key"}
|
||||
</Button>
|
||||
{apiKey.has_key && (
|
||||
<Popconfirm title="撤销后使用该 Key 的请求将被拒绝,确认?" onConfirm={() => void remove()}>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={busy}>
|
||||
撤销
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号密码 tab:修改登录密码
|
||||
*/
|
||||
function PasswordTab() {
|
||||
const { message } = App.useApp();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm: string;
|
||||
}>();
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
try {
|
||||
await changePassword(values.old_password, values.new_password);
|
||||
message.success("密码已更新");
|
||||
pwdForm.resetFields();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "修改失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Typography.Title level={5}>修改密码</Typography.Title>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
@@ -193,125 +302,5 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* API/MCP Key 管理表格
|
||||
*/
|
||||
function KeysTab({ kind }: { kind: "api" | "mcp" }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<KeyRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems((await listKeys()).items.filter((k) => k.kind === kind));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = items.filter((k) =>
|
||||
`${k.name} ${k.key_last4}`.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
/** 生成新 key:明文仅展示一次 */
|
||||
const create = async () => {
|
||||
try {
|
||||
const res = await createKey(kind);
|
||||
modal.info({
|
||||
title: `${kind === "api" ? "API" : "MCP"} Key 已生成(仅显示一次)`,
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
||||
{res.key}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">请立即保存;关闭后无法再次查看。</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "生成失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索名称 / 尾号"
|
||||
style={{ width: 220 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void create()}>
|
||||
生成 {kind === "api" ? "API" : "MCP"} Key
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name", ellipsis: true },
|
||||
{ title: "尾号", dataIndex: "key_last4", width: 80, render: (v: string) => <span className="mono">****{v}</span> },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "revoked_at",
|
||||
width: 90,
|
||||
render: (v: string | null) => (v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>),
|
||||
},
|
||||
{ title: "创建时间", dataIndex: "created_at", width: 165, render: (v: string) => new Date(v).toLocaleString() },
|
||||
{
|
||||
title: "最近使用",
|
||||
dataIndex: "last_used_at",
|
||||
width: 165,
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 130,
|
||||
render: (_: unknown, row: KeyRow) => (
|
||||
<Space>
|
||||
{!row.revoked_at && (
|
||||
<Popconfirm
|
||||
title="撤销后使用该 Key 的请求将被拒绝,确认?"
|
||||
onConfirm={async () => {
|
||||
await revokeKey(row.id);
|
||||
message.success("已撤销");
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Button size="small">撤销</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteKey(row.id);
|
||||
message.success("已删除");
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** 前端入口:antd v6 + react-router(Vite 构建) */
|
||||
import React from "react";
|
||||
/** 前端入口:antd v6 + react-router(Vite 构建);亮/暗主题状态在顶层持有 */
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ConfigProvider, App as AntdApp } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { antdTheme } from "./theme.ts";
|
||||
import { applyDarkMode, buildAntdTheme, loadDarkMode } from "./theme.ts";
|
||||
import { ThemeContext } from "./theme-context.ts";
|
||||
import "./styles.css";
|
||||
import { App } from "./App.tsx";
|
||||
import { LoginPage } from "./pages/Login.tsx";
|
||||
@@ -13,10 +14,10 @@ import { DashboardPage } from "./pages/Dashboard.tsx";
|
||||
import { AccountsPage } from "./pages/Accounts.tsx";
|
||||
import { DomainsPage } from "./pages/Domains.tsx";
|
||||
import { MailboxesPage } from "./pages/Mailboxes.tsx";
|
||||
import { EmailsPage } from "./pages/Emails.tsx";
|
||||
import { InboxPage } from "./pages/Inbox.tsx";
|
||||
import { setUnauthorizedHandler } from "./api.ts";
|
||||
|
||||
/** # 成员变量: 路由表 **/
|
||||
/** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/oauth/sim/authorize", element: <OAuthSimPage /> },
|
||||
@@ -28,11 +29,32 @@ const router = createBrowserRouter([
|
||||
{ path: "accounts", element: <AccountsPage /> },
|
||||
{ path: "domains", element: <DomainsPage /> },
|
||||
{ path: "mailboxes", element: <MailboxesPage /> },
|
||||
{ path: "emails", element: <EmailsPage /> },
|
||||
{ path: "inboxs", element: <InboxPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
/** # 函数
|
||||
* 顶层壳:持有主题状态,注入 Context 与 ConfigProvider
|
||||
*/
|
||||
function Shell() {
|
||||
const [dark, setDark] = useState(loadDarkMode);
|
||||
|
||||
useEffect(() => {
|
||||
applyDarkMode(dark);
|
||||
}, [dark]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ dark, toggle: () => setDark((v) => !v) }}>
|
||||
<ConfigProvider locale={zhCN} theme={buildAntdTheme(dark)}>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 未登录统一跳登录页 */
|
||||
setUnauthorizedHandler(() => {
|
||||
if (!location.pathname.startsWith("/login")) {
|
||||
@@ -42,10 +64,6 @@ setUnauthorizedHandler(() => {
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN} theme={antdTheme}>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
<Shell />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Typography,
|
||||
App,
|
||||
} from "antd";
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
deleteAccount,
|
||||
getAccount,
|
||||
@@ -167,6 +167,7 @@ export function AccountsPage() {
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
modal.confirm({
|
||||
|
||||
@@ -4,14 +4,33 @@ import { Button, Card, Col, Row, Statistic, Table, message } from "antd";
|
||||
import { ReloadOutlined, ClearOutlined } from "@ant-design/icons";
|
||||
import { health, runCron, stats } from "../api.ts";
|
||||
|
||||
/** # 成员变量: 最近邮件表格列 **/
|
||||
/** # 成员变量: 最近邮件表格列(含收件人) **/
|
||||
const recentColumns = [
|
||||
{ title: "主题", dataIndex: "subject", ellipsis: true },
|
||||
{ title: "发件人", dataIndex: "from_address", width: 240, ellipsis: true },
|
||||
{ title: "大小", dataIndex: "size", width: 100, render: (v: number) => fmtSize(v) },
|
||||
{ title: "接收时间", dataIndex: "received_at", width: 190, render: (v: string) => new Date(v).toLocaleString() },
|
||||
{ title: "发件人", dataIndex: "from_address", width: 200, ellipsis: true },
|
||||
{
|
||||
title: "收件人",
|
||||
dataIndex: "to_addresses",
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => firstRecipient(v) ?? "-",
|
||||
},
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
|
||||
{ title: "接收时间", dataIndex: "received_at", width: 180, render: (v: string) => new Date(v).toLocaleString() },
|
||||
];
|
||||
|
||||
/** # 函数
|
||||
* 取收件人列表第一个地址(JSON 数组字段)
|
||||
*/
|
||||
function firstRecipient(s: string | null): string | null {
|
||||
try {
|
||||
const v = JSON.parse(s ?? "[]");
|
||||
return Array.isArray(v) && typeof v[0] === "string" && v[0] ? v[0] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 字节数格式化
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
||||
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
deleteDomain,
|
||||
listDomainAccounts,
|
||||
@@ -132,17 +132,18 @@ export function DomainsPage() {
|
||||
render: (_: unknown, row: DomainRow) => (
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
{row.status !== "active" ? (
|
||||
<Button size="small" type="primary" onClick={() => void runSetup(row)}>
|
||||
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => void runSetup(row)}>
|
||||
设置 Email Workers
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" onClick={() => void runSetup(row)}>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => void runSetup(row)}>
|
||||
重新配置
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
modal.confirm({
|
||||
title: `删除域名 ${row.name}?`,
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
/** 邮件页:搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
|
||||
/** 收件箱页(/inboxs):域名筛选、搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Card, Descriptions, Drawer, Input, Space, Table, Tag, Typography, App, Popconfirm, Modal } from "antd";
|
||||
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { deleteEmail, getEmail, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Popconfirm,
|
||||
Modal,
|
||||
} from "antd";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { deleteEmail, getEmail, listDomains, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 字节数格式化
|
||||
@@ -21,8 +41,8 @@ const columns = [
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v ?? <span className="muted">(无主题)</span>,
|
||||
},
|
||||
{ title: "发件人", dataIndex: "from_address", width: 220, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "mailbox_address", width: 200, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "发件人", dataIndex: "from_address", width: 200, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "mailbox_address", width: 190, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "附件",
|
||||
dataIndex: "attachment_count",
|
||||
@@ -45,15 +65,17 @@ const columns = [
|
||||
];
|
||||
|
||||
/** # 函数
|
||||
* 邮件页组件
|
||||
* 收件箱页组件
|
||||
*/
|
||||
export function EmailsPage() {
|
||||
export function InboxPage() {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<EmailRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [q, setQ] = useState("");
|
||||
const [domainId, setDomainId] = useState<string | undefined>();
|
||||
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
||||
const [htmlPreview, setHtmlPreview] = useState(false);
|
||||
@@ -61,18 +83,28 @@ export function EmailsPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listEmails({ q, limit: pageSize, offset: (page - 1) * pageSize });
|
||||
const res = await listEmails({ q, domain_id: domainId, limit: pageSize, offset: (page - 1) * pageSize });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, page, pageSize]);
|
||||
}, [q, domainId, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setDomains((await listDomains()).items.map((d) => ({ id: d.id, name: d.name })));
|
||||
} catch {
|
||||
// 域名选项加载失败不阻塞页面
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
@@ -81,12 +113,25 @@ export function EmailsPage() {
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索主题 / 发件人"
|
||||
style={{ width: 280 }}
|
||||
style={{ width: 260 }}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="按域名筛选"
|
||||
style={{ width: 200 }}
|
||||
options={domains.map((d) => ({ value: d.id, label: d.name }))}
|
||||
value={domainId}
|
||||
onChange={(v) => {
|
||||
setDomainId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
</div>
|
||||
@@ -126,10 +171,10 @@ export function EmailsPage() {
|
||||
extra={
|
||||
detail && (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
|
||||
{htmlPreview ? "显示文本" : "HTML 预览"}
|
||||
</Button>
|
||||
<Button size="small" href={`/api/emails/${detail.email.id}/raw`} target="_blank">
|
||||
<Button size="small" icon={<DownloadOutlined />} href={`/api/emails/${detail.email.id}/raw`} target="_blank">
|
||||
下载 .eml
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -141,7 +186,7 @@ export function EmailsPage() {
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -174,6 +219,7 @@ export function EmailsPage() {
|
||||
<Button
|
||||
key={att.id}
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
href={`/api/emails/${detail.email.id}/attachments/${att.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
createMailbox,
|
||||
deleteMailbox,
|
||||
@@ -97,12 +97,6 @@ export function MailboxesPage() {
|
||||
width: 95,
|
||||
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "Webhook",
|
||||
width: 90,
|
||||
render: (_: unknown, row: MailboxRow) =>
|
||||
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
|
||||
},
|
||||
{
|
||||
title: "保留(秒)",
|
||||
dataIndex: "retention_seconds",
|
||||
@@ -122,12 +116,13 @@ export function MailboxesPage() {
|
||||
width: 150,
|
||||
render: (_: unknown, row: MailboxRow) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWebhookTarget(row)}>
|
||||
<Button size="small" icon={<ApiOutlined />} onClick={() => setWebhookTarget(row)}>
|
||||
Webhook
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
modal.confirm({
|
||||
title: `删除邮箱 ${row.address}?`,
|
||||
|
||||
@@ -20,6 +20,33 @@ body,
|
||||
border-right: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
/* 暗色主题下的边框/文字覆盖(data-theme 由主题切换维护) */
|
||||
[data-theme="dark"] .app-sider,
|
||||
[data-theme="dark"] .app-header {
|
||||
border-color: rgba(253, 253, 253, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .app-sider .brand .name,
|
||||
[data-theme="dark"] .app-header .title {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .app-header .subtitle,
|
||||
[data-theme="dark"] .muted {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .brand-version {
|
||||
color: #85a5ff;
|
||||
background: rgba(133, 165, 255, 0.12);
|
||||
border-color: rgba(133, 165, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .email-html-frame {
|
||||
background: #fff; /* HTML 邮件按原样渲染,保持白色底 */
|
||||
border-color: rgba(253, 253, 253, 0.15);
|
||||
}
|
||||
|
||||
.app-sider .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 主题上下文:App 框架读取当前模式与切换函数(状态保存在 main.tsx 顶层) */
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** # 成员变量: 主题上下文 **/
|
||||
export const ThemeContext = createContext<{ dark: boolean; toggle: () => void }>({
|
||||
dark: false,
|
||||
toggle: () => undefined,
|
||||
});
|
||||
|
||||
/** # 函数
|
||||
* 组件内读取主题状态
|
||||
*/
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
@@ -1,21 +1,36 @@
|
||||
/** antd v6 主题配置:默认主题 + 少量 token 覆写(主色与品牌一致) */
|
||||
/** antd v6 主题配置:亮/暗双主题(暗色用 darkAlgorithm),品牌色一致 */
|
||||
import type { ThemeConfig } from "antd";
|
||||
import { theme } from "antd";
|
||||
|
||||
/** # 成员变量: 品牌主色 **/
|
||||
export const BRAND_COLOR = "#2f54eb";
|
||||
|
||||
/** # 成员变量: antd 主题 */
|
||||
export const antdTheme: ThemeConfig = {
|
||||
token: {
|
||||
/** # 成员变量: 主题存储键(localStorage) **/
|
||||
export const THEME_STORAGE_KEY = "wmh_theme";
|
||||
|
||||
/** # 成员变量: 亮色主题基础 token(两种主题共用) **/
|
||||
const baseToken = {
|
||||
colorPrimary: BRAND_COLOR,
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* 构建主题配置
|
||||
* 参数: dark - 是否暗色模式
|
||||
* 返回值: antd ThemeConfig
|
||||
*/
|
||||
export function buildAntdTheme(dark: boolean): ThemeConfig {
|
||||
return {
|
||||
algorithm: dark ? [theme.darkAlgorithm] : undefined,
|
||||
token: {
|
||||
...baseToken,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: "#ffffff",
|
||||
headerBg: "#ffffff",
|
||||
siderBg: dark ? "#141414" : "#ffffff",
|
||||
headerBg: dark ? "#141414" : "#ffffff",
|
||||
headerHeight: 64,
|
||||
},
|
||||
Card: {
|
||||
@@ -23,3 +38,27 @@ export const antdTheme: ThemeConfig = {
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取本地存储的主题偏好(默认亮色)
|
||||
*/
|
||||
export function loadDarkMode(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(THEME_STORAGE_KEY) === "dark";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 持久化主题偏好并同步 <html data-theme>(供 styles.css 暗色覆盖)
|
||||
*/
|
||||
export function applyDarkMode(dark: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, dark ? "dark" : "light");
|
||||
} catch {
|
||||
// 隐私模式等场景忽略
|
||||
}
|
||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||
}
|
||||
|
||||
@@ -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 **/
|
||||
|
||||
+23
-48
@@ -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;
|
||||
}
|
||||
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 / 当前用户
|
||||
* 设置路由(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 管理沿用 /api/keys(前端设置模态窗口内调用)。
|
||||
* 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(
|
||||
|
||||
@@ -34,7 +34,8 @@ webhook_url, webhook_headers, webhook_key_enc, created_at, updated_at
|
||||
|
||||
```text
|
||||
id, account_id, mailbox_id nullable, message_id, from_address, to_addresses,
|
||||
cc_addresses, subject, text_body, html_body, raw_storage_key, size,
|
||||
cc_addresses, subject, text_body, html_body, markdown_body(v2.1.0:正文 Markdown,
|
||||
入库时从 HTML/纯文本转换), raw_storage_key, size,
|
||||
received_at, expires_at, created_at
|
||||
```
|
||||
|
||||
@@ -78,4 +79,4 @@ status(active | revoked), created_at, revoked_at
|
||||
|
||||
## 其它
|
||||
|
||||
users、settings、api_credentials(wmh_ Key)、nonces、oauth_states、cf_sim_resources(本地 sim)。
|
||||
users、settings(全局 Webhook、单一 API/MCP Key:api_key_hash/api_key_last4/api_key_created_at)、nonces、oauth_states、cf_sim_resources(本地 sim)。v2.1.0 起 api_credentials 表已删除(Key 合并为单一 Key 存 settings)。
|
||||
|
||||
@@ -5,7 +5,9 @@ Webhook、API、MCP 全部属于 Console,Email Worker 不实现这些能力。
|
||||
## Webhook(三级:邮箱 -> 域名 -> 全局)
|
||||
|
||||
收到邮件后按 邮箱 -> 域名 -> 全局 的顺序取第一个已配置的 Webhook(无配置则逐级回退),
|
||||
入队 delivery(冻结当时的 url/headers/key 配置)。Payload(docs/07):
|
||||
入队 delivery(冻结当时的 url/headers/key 配置)。Payload(docs/07,
|
||||
`markdown` 为正文 Markdown:入库时从 HTML part 转换(参考 mailhook 的 HTML→Markdown 思路),
|
||||
无 HTML 时取纯文本;v2.1.0 起 payload 携带该字段):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -16,6 +18,7 @@ Webhook、API、MCP 全部属于 Console,Email Worker 不实现这些能力。
|
||||
"to": ["hello@example.com"],
|
||||
"subject": "Hello",
|
||||
"text": "Hello World",
|
||||
"markdown": "Hello **World**",
|
||||
"received_at": "2026-09-30T10:00:00Z"
|
||||
}
|
||||
```
|
||||
@@ -43,6 +46,10 @@ API 属于 Console,Bearer API Key(wmh_)认证。
|
||||
MCP 允许 Agent 查询邮箱、获取邮件、创建/删除邮箱(list_mailboxes、list_emails、get_email、
|
||||
create_mailbox、delete_mailbox),Bearer MCP Key(wmh_)认证。
|
||||
|
||||
**单一 Key(v2.1.0)**:API 与 MCP 共用同一个 Key(wmh_ 开头 32 位,设置模态窗口中生成/轮换/撤销),
|
||||
凭据存 settings 表(api_key_hash/api_key_last4/api_key_created_at),不再单独建 api_credentials 表;
|
||||
生成新 Key 后旧 Key 立即失效。
|
||||
|
||||
MCP/API 获取邮件时必须检查邮件是否过期、mailbox scope 和 API credential scope。
|
||||
|
||||
## 内部 API
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 迁移 0004(v2.1.0):
|
||||
-- 1) emails 增加 markdown_body 列(入库时从 HTML/纯文本转出的 Markdown 正文,webhook 投递使用)
|
||||
-- 2) API/MCP Key 合并为单一 Key:把最近一条有效凭据的 SHA-256 迁入 settings 表,随后删除 api_credentials 表
|
||||
|
||||
ALTER TABLE emails ADD COLUMN markdown_body TEXT;
|
||||
|
||||
INSERT INTO settings (key, value)
|
||||
SELECT 'api_key_hash', key_hash FROM api_credentials
|
||||
WHERE revoked_at IS NULL ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
INSERT INTO settings (key, value)
|
||||
SELECT 'api_key_last4', key_last4 FROM api_credentials
|
||||
WHERE revoked_at IS NULL ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
INSERT INTO settings (key, value)
|
||||
SELECT 'api_key_created_at', created_at FROM api_credentials
|
||||
WHERE revoked_at IS NULL ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
DROP TABLE api_credentials;
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wmailhub",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"antd": "^6.6.5",
|
||||
"hono": "^4.9.0",
|
||||
"node-html-parser": "^9.0.4",
|
||||
"postal-mime": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from "./ids.ts";
|
||||
export * from "./ssrf.ts";
|
||||
export * from "./email-worker-template.ts";
|
||||
export * from "./constants.ts";
|
||||
export * from "./markdown.ts";
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* HTML -> Markdown 转换(参考 mailhook(Rust) mail.rs 的实现思路):
|
||||
* 遍历 DOM,按块级/行内语义转换,表格/列表/引用/代码块均有映射;
|
||||
* 输出折叠连续空行。用于邮件正文提取(docs/07 webhook payload 的 markdown 字段)。
|
||||
*/
|
||||
|
||||
import { parse, HTMLElement, TextNode, type Node as HtmlNode } from "node-html-parser";
|
||||
|
||||
/** # 成员变量: 不参与转换的标签(内容整体丢弃) **/
|
||||
const SKIP_TAGS = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"head",
|
||||
"noscript",
|
||||
"template",
|
||||
"meta",
|
||||
"link",
|
||||
"title",
|
||||
]);
|
||||
|
||||
/** # 函数
|
||||
* 将 HTML 片段转换为 Markdown
|
||||
* 参数: html - HTML 片段(可为完整文档)
|
||||
* 返回值: Markdown 文本;空内容返回空串
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
let root: HTMLElement;
|
||||
try {
|
||||
root = parse(html);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
const blocks: string[] = [];
|
||||
renderChildren(root, blocks, 0);
|
||||
return collapseBlankLines(
|
||||
blocks
|
||||
.filter((b) => b.trim().length > 0)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 压缩连续空行为单个空行
|
||||
*/
|
||||
function collapseBlankLines(s: string): string {
|
||||
const lines = s.split("\n");
|
||||
const out: string[] = [];
|
||||
let blank = 0;
|
||||
for (const line of lines) {
|
||||
if (line.trim().length === 0) {
|
||||
blank += 1;
|
||||
if (blank > 1) continue;
|
||||
} else {
|
||||
blank = 0;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 折叠空白(换行/多空格折叠为单空格)
|
||||
*/
|
||||
function normalizeWs(s: string): string {
|
||||
return s.split(/\s+/).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 将 归一为空格
|
||||
*/
|
||||
function collapseNbsp(s: string): string {
|
||||
return s.replace(/\u00a0/g, " ");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 取元素行内文本(递归处理 strong/em/code/a 等行内语义)
|
||||
*/
|
||||
function inlineText(el: HTMLElement): string {
|
||||
const buf: string[] = [];
|
||||
renderInlineChildren(el, buf);
|
||||
return normalizeWs(buf.join(""));
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 渲染元素子节点为块级 Markdown 片段列表
|
||||
*/
|
||||
function renderChildren(el: HTMLElement, blocks: string[], indent: number): void {
|
||||
for (const child of el.childNodes) {
|
||||
renderNode(child, blocks, indent);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 渲染单个节点(元素或文本)
|
||||
*/
|
||||
function renderNode(node: HtmlNode, blocks: string[], indent: number): void {
|
||||
if (node instanceof HTMLElement) {
|
||||
const tag = node.rawTagName?.toLowerCase() ?? "";
|
||||
if (SKIP_TAGS.has(tag)) return;
|
||||
renderElement(node, tag, blocks, indent);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType === 3) {
|
||||
// 文本节点
|
||||
const txt = normalizeWs(collapseNbsp(node.text ?? ""));
|
||||
if (txt) blocks.push(" ".repeat(indent) + txt);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 块级元素映射:h1-h6/p/hr/pre/blockquote/ul/ol/table/img 等
|
||||
*/
|
||||
function renderElement(el: HTMLElement, tag: string, blocks: string[], indent: number): void {
|
||||
const pad = " ".repeat(indent);
|
||||
switch (tag) {
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6": {
|
||||
const level = Number(tag.slice(1)) || 1;
|
||||
blocks.push(`${pad}${"#".repeat(level)} ${inlineText(el)}`);
|
||||
return;
|
||||
}
|
||||
case "p": {
|
||||
const buf: string[] = [];
|
||||
renderInlineChildren(el, buf);
|
||||
const txt = normalizeWs(buf.join(""));
|
||||
if (txt) blocks.push(pad + txt);
|
||||
return;
|
||||
}
|
||||
case "br":
|
||||
blocks.push("");
|
||||
return;
|
||||
case "hr":
|
||||
blocks.push(`${pad}---`);
|
||||
return;
|
||||
case "pre": {
|
||||
const code = collectPreText(el);
|
||||
blocks.push(`${pad}\`\`\`\n${code}\n${pad}\`\`\``);
|
||||
return;
|
||||
}
|
||||
case "blockquote": {
|
||||
const inner: string[] = [];
|
||||
renderChildren(el, inner, 0);
|
||||
const quoted = inner
|
||||
.join("\n\n")
|
||||
.split("\n")
|
||||
.map((l) => `${pad}> ${l}`)
|
||||
.join("\n");
|
||||
blocks.push(quoted);
|
||||
return;
|
||||
}
|
||||
case "ul":
|
||||
case "ol": {
|
||||
let idx = 0;
|
||||
for (const li of el.querySelectorAll(":scope > li")) {
|
||||
idx += 1;
|
||||
const own: string[] = [];
|
||||
// 行内内容(跳过嵌套列表)
|
||||
for (const c of li.childNodes) {
|
||||
const isList =
|
||||
c instanceof HTMLElement && ["ul", "ol"].includes(c.rawTagName?.toLowerCase() ?? "");
|
||||
if (isList) continue;
|
||||
renderInlineNode(c, own);
|
||||
}
|
||||
const marker = tag === "ol" ? `${idx}.` : "-";
|
||||
blocks.push(`${pad}${marker} ${normalizeWs(own.join(""))}`);
|
||||
// 嵌套列表
|
||||
for (const sub of li.querySelectorAll(":scope > ul, :scope > ol")) {
|
||||
renderElement(sub, sub.rawTagName.toLowerCase(), blocks, indent + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "table": {
|
||||
const md = tableMarkdown(el, indent);
|
||||
if (md) blocks.push(md);
|
||||
return;
|
||||
}
|
||||
case "img": {
|
||||
const src = el.getAttribute("src") ?? "";
|
||||
const alt = el.getAttribute("alt") ?? "";
|
||||
if (src) blocks.push(`${pad}`);
|
||||
return;
|
||||
}
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
const txt = inlineText(el);
|
||||
if (txt) blocks.push(`${pad}[${txt}](${href})`);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// 通用块级容器:递归处理子节点
|
||||
renderChildren(el, blocks, indent);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 收集 pre 元素保留原始换行的文本
|
||||
*/
|
||||
function collectPreText(el: HTMLElement): string {
|
||||
const out: string[] = [];
|
||||
const walk = (node: HtmlNode) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
if ((node.rawTagName ?? "").toLowerCase() === "br") out.push("\n");
|
||||
for (const c of node.childNodes) walk(c);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType === 3) out.push(collapseNbsp(node.text ?? ""));
|
||||
};
|
||||
for (const c of el.childNodes) walk(c);
|
||||
return out.join("").replace(/^\n+|\n+$/g, "");
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 渲染元素子节点为行内 Markdown
|
||||
*/
|
||||
function renderInlineChildren(el: HTMLElement, out: string[]): void {
|
||||
for (const child of el.childNodes) {
|
||||
renderInlineNode(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 行内节点映射:strong/em/del/code/br/a/img;行内上下文的块级标签降级为空格分隔
|
||||
*/
|
||||
function renderInlineNode(node: HtmlNode, out: string[]): void {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
if (node.nodeType === 3) out.push(collapseNbsp(node.text ?? ""));
|
||||
return;
|
||||
}
|
||||
const tag = node.rawTagName?.toLowerCase() ?? "";
|
||||
if (SKIP_TAGS.has(tag)) return;
|
||||
const inner = (): string => {
|
||||
const buf: string[] = [];
|
||||
renderInlineChildren(node, buf);
|
||||
return buf.join("").trim();
|
||||
};
|
||||
switch (tag) {
|
||||
case "strong":
|
||||
case "b": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`**${buf}**`);
|
||||
return;
|
||||
}
|
||||
case "em":
|
||||
case "i": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`*${buf}*`);
|
||||
return;
|
||||
}
|
||||
case "del":
|
||||
case "s":
|
||||
case "strike": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`~~${buf}~~`);
|
||||
return;
|
||||
}
|
||||
case "code": {
|
||||
const buf = inner();
|
||||
if (buf) out.push(`\`${buf}\``);
|
||||
return;
|
||||
}
|
||||
case "br":
|
||||
out.push("\n");
|
||||
return;
|
||||
case "a": {
|
||||
const href = node.getAttribute("href") ?? "";
|
||||
const buf = normalizeWs(node.textContent ?? "");
|
||||
out.push(buf ? `[${buf}](${href})` : `<${href}>`);
|
||||
return;
|
||||
}
|
||||
case "img": {
|
||||
const src = node.getAttribute("src") ?? "";
|
||||
const alt = node.getAttribute("alt") ?? "";
|
||||
out.push(``);
|
||||
return;
|
||||
}
|
||||
case "p":
|
||||
case "div":
|
||||
case "tr":
|
||||
case "li":
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
case "table":
|
||||
case "blockquote":
|
||||
case "pre": {
|
||||
// 行内上下文里出现的块级标签:换行后继续,避免内容粘连
|
||||
out.push(" ");
|
||||
renderInlineChildren(node, out);
|
||||
out.push(" ");
|
||||
return;
|
||||
}
|
||||
default:
|
||||
renderInlineChildren(node, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 表格 -> Markdown 管道表(首行作表头,列数对齐,| 转义)
|
||||
*/
|
||||
function tableMarkdown(table: HTMLElement, indent: number): string | null {
|
||||
const pad = " ".repeat(indent);
|
||||
const rows: string[][] = [];
|
||||
for (const tr of table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")) {
|
||||
const cells: string[] = [];
|
||||
for (const cell of tr.querySelectorAll(":scope > th, :scope > td")) {
|
||||
cells.push(inlineText(cell));
|
||||
}
|
||||
if (cells.length > 0) rows.push(cells);
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
const width = Math.max(...rows.map((r) => r.length));
|
||||
const padRow = (r: string[]) => {
|
||||
const c = [...r];
|
||||
while (c.length < width) c.push("");
|
||||
return c;
|
||||
};
|
||||
const esc = (s: string) => s.replace(/\|/g, "\\|");
|
||||
const lines: string[] = [];
|
||||
lines.push(`${pad}| ${padRow(rows[0]).map(esc).join(" | ")} |`);
|
||||
lines.push(`${pad}|${new Array(width).fill(" --- ").join("|")}|`);
|
||||
for (const r of rows.slice(1)) {
|
||||
lines.push(`${pad}| ${padRow(r).map(esc).join(" | ")} |`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
+51
-54
@@ -1,44 +1,42 @@
|
||||
/** API v1 / MCP 测试:Key 隔离、过期邮件 410、MCP 协议与工具 */
|
||||
/** API v1 / MCP 测试:单一 Key(共用)、过期邮件 410、MCP 协议与工具(v2.1.0) */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ingestEmail } from "../console/src/services/email_ingest.ts";
|
||||
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
/** # 函数
|
||||
* 创建 Key 并返回明文
|
||||
* 创建(轮换)单一 API/MCP Key 并返回明文
|
||||
*/
|
||||
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, kind: "api" | "mcp") {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind }),
|
||||
});
|
||||
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string) {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "POST" });
|
||||
expect(resp.status).toBe(201);
|
||||
return ((await resp.json()) as { key: string }).key;
|
||||
}
|
||||
|
||||
describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
test("api key 可读资源;mcp key 访问 v1 被 403(kind 隔离)", async () => {
|
||||
test("单一 Key 同时可访问 API 与 MCP(不再区分 kind)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const key = await createKey(t, cookie);
|
||||
|
||||
const ok = await t.app.fetch(
|
||||
const v1 = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const data = (await ok.json()) as { items: { address: string }[] };
|
||||
expect(v1.status).toBe(200);
|
||||
const data = (await v1.json()) as { items: { address: string }[] };
|
||||
expect(data.items.map((m) => m.address)).toContain("*@example.test");
|
||||
|
||||
const bad = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${mcpKey}` },
|
||||
const mcp = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(bad.status).toBe(403);
|
||||
expect(mcp.status).toBe(200);
|
||||
});
|
||||
|
||||
test("无 key / 伪造 key / 已撤销 key 均被拒", async () => {
|
||||
@@ -55,30 +53,37 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
);
|
||||
expect(fake.status).toBe(401);
|
||||
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
// 找到 key id 并撤销
|
||||
const list = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/keys")
|
||||
).json()) as { items: { id: string; revoked_at: string | null }[] };
|
||||
const keyId = list.items.find((k) => !k.revoked_at)!.id;
|
||||
await authedFetch(t, cookie, `http://localhost/api/keys/${keyId}/revoke`, { method: "POST" });
|
||||
const key = await createKey(t, cookie);
|
||||
// 撤销(删除 settings 中的 Key)
|
||||
const del = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "DELETE" });
|
||||
expect(del.status).toBe(200);
|
||||
const revoked = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(revoked.status).toBe(401);
|
||||
|
||||
// 重新生成后旧 Key 依旧失效(轮换语义)
|
||||
await createKey(t, cookie);
|
||||
const rotated = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(rotated.status).toBe(401);
|
||||
});
|
||||
|
||||
test("v1 可创建与删除 mailbox", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const key = await createKey(t, cookie);
|
||||
const create = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain_id: t.domainId, address: "newbox" }),
|
||||
}),
|
||||
t.env,
|
||||
@@ -89,7 +94,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
const del = await t.app.fetch(
|
||||
new Request(`http://localhost/api/v1/mailboxes/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
@@ -99,7 +104,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
test("过期邮件:v1 详情返回 410,列表不出现", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const key = await createKey(t, cookie);
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
@@ -111,14 +116,14 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
.run();
|
||||
const detail = await t.app.fetch(
|
||||
new Request(`http://localhost/api/v1/emails/${email.id}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(detail.status).toBe(410);
|
||||
const list = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/emails", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
@@ -128,35 +133,27 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
});
|
||||
|
||||
describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, mcpKey: string, body: unknown) {
|
||||
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, key: string, body: unknown) {
|
||||
return t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${mcpKey}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
}
|
||||
|
||||
test("api key 不能访问 MCP(权限基于 credential kind)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const resp = await mcpCall(t, apiKey, { jsonrpc: "2.0", id: 1, method: "tools/list" });
|
||||
expect(resp.status).toBe(403);
|
||||
});
|
||||
|
||||
test("initialize + tools/list 返回五个工具与 serverInfo", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const init = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||
const key = await createKey(t, cookie);
|
||||
const init = await mcpCall(t, key, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||
const initData = (await init.json()) as { result: { serverInfo: { name: string }; protocolVersion: string } };
|
||||
expect(initData.result.serverInfo.name).toBe("wmailhub");
|
||||
expect(initData.result.protocolVersion).toBe("2025-06-18");
|
||||
|
||||
const tools = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
||||
const tools = await mcpCall(t, key, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
||||
const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } };
|
||||
expect(toolsData.result.tools.map((x) => x.name)).toEqual([
|
||||
"list_mailboxes",
|
||||
@@ -170,7 +167,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
test("tools/call:create_mailbox -> list_mailboxes -> get_email -> delete_mailbox", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const key = await createKey(t, cookie);
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
@@ -178,7 +175,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
accountId: t.accountId,
|
||||
});
|
||||
|
||||
const created = await mcpCall(t, mcpKey, {
|
||||
const created = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
@@ -187,7 +184,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
const createdData = (await created.json()) as { result: { content: { text: string }[] } };
|
||||
expect(JSON.parse(createdData.result.content[0].text).address).toBe("mcptest@example.test");
|
||||
|
||||
const list = await mcpCall(t, mcpKey, {
|
||||
const list = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
method: "tools/call",
|
||||
@@ -199,7 +196,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
};
|
||||
expect(mailboxes.mailboxes.map((m) => m.address)).toContain("mcptest@example.test");
|
||||
|
||||
const got = await mcpCall(t, mcpKey, {
|
||||
const got = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "tools/call",
|
||||
@@ -210,7 +207,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
expect(gotEmail.email.subject).toBe("Test Mail");
|
||||
|
||||
const target = mailboxes.mailboxes.find((m) => m.address === "mcptest@example.test")!.id;
|
||||
const del = await mcpCall(t, mcpKey, {
|
||||
const del = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
method: "tools/call",
|
||||
@@ -223,13 +220,13 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
test("未知方法返回 -32601;GET 返回 405", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const bad = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
|
||||
const key = await createKey(t, cookie);
|
||||
const bad = await mcpCall(t, key, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
|
||||
const badData = (await bad.json()) as { error: { code: number } };
|
||||
expect(badData.error.code).toBe(-32601);
|
||||
const get = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
headers: { Authorization: `Bearer ${mcpKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
|
||||
+1
-2
@@ -48,10 +48,9 @@ describe("用户认证(默认 admin/admin)", () => {
|
||||
test("跨域 Origin 写请求被拒(CSRF)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: "https://evil.example.com" },
|
||||
body: JSON.stringify({ kind: "api" }),
|
||||
});
|
||||
expect(resp.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 一次性 E2E 验证:authorize -> ingest 发送带 HTML 正文的邮件,
|
||||
* 等 webhook 投递后检查 receiver 收到的 payload 是否包含 markdown 字段。
|
||||
*/
|
||||
|
||||
import { signWorkerRequest, generateWmailKey } from "../shared/src/index.ts";
|
||||
|
||||
const CONSOLE = "http://127.0.0.1:18787";
|
||||
|
||||
/** # 函数
|
||||
* 登录拿会话 cookie
|
||||
*/
|
||||
async function login(): Promise<string> {
|
||||
const resp = await fetch(`${CONSOLE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "admin" }),
|
||||
});
|
||||
const setCookie = resp.headers.get("Set-Cookie") ?? "";
|
||||
return /wmh_session=[^;]+/.exec(setCookie)?.[0] ?? "";
|
||||
}
|
||||
|
||||
const cookie = await login();
|
||||
if (!cookie) throw new Error("login failed");
|
||||
|
||||
/** 取 worker secret(签名用) */
|
||||
const secretResp = await fetch(`${CONSOLE}/api/accounts`, { headers: { Cookie: cookie } });
|
||||
const accounts = (await secretResp.json()) as { items: { id: string; status: string }[] };
|
||||
const account = accounts.items.find((a) => a.status === "active");
|
||||
if (!account) throw new Error("no active account");
|
||||
const wsResp = await fetch(`${CONSOLE}/api/accounts/${account.id}/worker-secret`, { headers: { Cookie: cookie } });
|
||||
const { worker_id, secret } = (await wsResp.json()) as { worker_id: string; secret: string };
|
||||
console.log("worker:", worker_id, "secret:", secret.slice(0, 8) + "…");
|
||||
|
||||
/** 带结构化 HTML 的测试邮件(标题/加粗/链接/列表/表格) */
|
||||
const mime = [
|
||||
"From: E2E <e2e@example.org>",
|
||||
"To: hello@example.test",
|
||||
"Subject: Markdown 全链测试",
|
||||
"Message-ID: <md-e2e-1@example.org>",
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: text/html; charset="utf-8"',
|
||||
"",
|
||||
"<html><body><h2>升级通知</h2><p>您的配额已 <b>用尽</b>,请前往 <a href=\"https://example.com/billing\">账单页</a> 处理。</p>",
|
||||
"<ul><li>方案 A</li><li>方案 B</li></ul>",
|
||||
"<table><tr><th>项目</th><th>用量</th></tr><tr><td>存储</td><td>98%</td></tr></table></body></html>",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
const recipient = "hello@example.test";
|
||||
const sender = "e2e@example.org";
|
||||
|
||||
// Phase 1: authorize
|
||||
const auth = await signWorkerRequest(secret, worker_id, "POST", `${CONSOLE}/internal/email/authorize`,
|
||||
JSON.stringify({ recipient, sender, message_id: "md-e2e-1@example.org" }));
|
||||
const authResp = await fetch(`${CONSOLE}/internal/email/authorize`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recipient, sender, message_id: "md-e2e-1@example.org" }),
|
||||
});
|
||||
const decision = (await authResp.json()) as { decision: string };
|
||||
console.log("authorize:", authResp.status, JSON.stringify(decision));
|
||||
if (decision.decision !== "accept") throw new Error("not accepted");
|
||||
|
||||
// Phase 2: ingest(raw MIME)
|
||||
const ingestAuth = await signWorkerRequest(secret, worker_id, "POST", `${CONSOLE}/internal/email/ingest`, mime);
|
||||
const ingestResp = await fetch(`${CONSOLE}/internal/email/ingest`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...ingestAuth,
|
||||
"Content-Type": "message/rfc822",
|
||||
"X-WmailHub-Recipient": recipient,
|
||||
"X-WmailHub-Sender": sender,
|
||||
},
|
||||
body: mime,
|
||||
});
|
||||
const ingestData = (await ingestResp.json()) as { ok: boolean; id: string; webhook: string | null };
|
||||
console.log("ingest:", ingestResp.status, JSON.stringify(ingestData));
|
||||
|
||||
// 触发 cron 投递
|
||||
const cron = await fetch(`${CONSOLE}/api/admin/cron/run`, { method: "POST", headers: { Cookie: cookie } });
|
||||
console.log("cron:", cron.status, JSON.stringify(await cron.json()));
|
||||
|
||||
// 检查 receiver 侧收到的 payload(直接查库里这条邮件的 markdown_body + receiver 日志)
|
||||
const detail = await fetch(`${CONSOLE}/api/emails/${ingestData.id}`, { headers: { Cookie: cookie } });
|
||||
const emailDetail = (await detail.json()) as { email: { markdown_body: string | null; subject: string } };
|
||||
console.log("markdown_body from DB:", JSON.stringify(emailDetail.email.markdown_body));
|
||||
if (!emailDetail.email.markdown_body?.includes("**用尽**")) throw new Error("markdown missing bold");
|
||||
|
||||
// 等 receiver 落日志后抓最后一条校验 markdown 字段
|
||||
await Bun.write("/tmp/last_payload_check.txt", "pending");
|
||||
const tail = await Bun.file("/tmp/receiver.log").text();
|
||||
const lines = tail.trim().split("\n").filter(Boolean);
|
||||
const last = lines[lines.length - 1];
|
||||
const payload = JSON.parse(last) as Record<string, unknown>;
|
||||
console.log("receiver last:", JSON.stringify(payload).slice(0, 300));
|
||||
const md = String(payload.markdown ?? "");
|
||||
if (!md.includes("**用尽**") || !md.includes("98%")) {
|
||||
console.log("FULL MARKDOWN:", md);
|
||||
throw new Error("receiver payload missing markdown");
|
||||
}
|
||||
console.log("\n=== E2E OK: webhook payload 含完整 Markdown ===");
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ Bun.serve({
|
||||
event: payload.event,
|
||||
subject: payload.subject,
|
||||
mailbox: payload.mailbox,
|
||||
markdown: payload.markdown,
|
||||
custom_header: req.headers.get("X-Custom"),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/** Markdown 转换与 webhook markdown 字段测试(v2.1.0) */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { htmlToMarkdown } from "../shared/src/markdown.ts";
|
||||
import { ingestEmail } from "../console/src/services/email_ingest.ts";
|
||||
import { buildWebhookPayload } from "../console/src/services/webhook_delivery.ts";
|
||||
import { loginCookie, makeTestEnv, TEST_MAIL, authedFetch } from "./helpers.ts";
|
||||
|
||||
/** # 成员变量: 纯文本邮件样例(无 HTML part) **/
|
||||
const TEST_MAIL_PLAIN = `From: Bob <bob@example.org>\r
|
||||
To: hello@example.test\r
|
||||
Subject: Plain Mail\r
|
||||
Content-Type: text/plain\r
|
||||
\r
|
||||
plain body\r
|
||||
`;
|
||||
|
||||
/** # 成员变量: HTML 邮件样例(text/html part 带 p/b 结构) **/
|
||||
const TEST_MAIL_HTML = `From: Carl <carl@example.org>\r
|
||||
To: hello@example.test\r
|
||||
Subject: Html Mail\r
|
||||
MIME-Version: 1.0\r
|
||||
Content-Type: text/html\r
|
||||
\r
|
||||
<html><body><p>Hello <b>world</b> and <a href="https://example.com">link</a></p></body></html>\r
|
||||
`;
|
||||
|
||||
describe("htmlToMarkdown(mailhook 同思路)", () => {
|
||||
test("标题/加粗/链接/行内代码", () => {
|
||||
expect(htmlToMarkdown("<h2>Hello</h2><p>World <b>bold</b> <a href='https://x.com'>link</a> <code>cfg</code></p>")).toBe(
|
||||
"## Hello\n\nWorld **bold** [link](https://x.com) `cfg`",
|
||||
);
|
||||
});
|
||||
|
||||
test("列表与嵌套列表", () => {
|
||||
expect(htmlToMarkdown("<ul><li>one</li><li>two<ol><li>nested</li></ol></li></ul>")).toBe(
|
||||
"- one\n\n- two\n\n 1. nested",
|
||||
);
|
||||
});
|
||||
|
||||
test("表格转管道表(| 转义)", () => {
|
||||
expect(htmlToMarkdown("<table><tr><th>A</th></tr><tr><td>1|2</td></tr></table>")).toBe(
|
||||
"| A |\n| --- |\n| 1\\|2 |",
|
||||
);
|
||||
});
|
||||
|
||||
test("pre 代码块保留换行;blockquote 引用;hr 分隔线", () => {
|
||||
expect(htmlToMarkdown("<pre>line1\nline2</pre>")).toBe("```\nline1\nline2\n```");
|
||||
expect(htmlToMarkdown("<blockquote>quoted</blockquote>")).toBe("> quoted");
|
||||
expect(htmlToMarkdown("<hr/>")).toBe("---");
|
||||
});
|
||||
|
||||
test("script/style 丢弃;空内容返回空串", () => {
|
||||
expect(htmlToMarkdown("<script>alert(1)</script><style>.x{}</style>")).toBe("");
|
||||
expect(htmlToMarkdown("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("邮件入库 markdown_body 与 webhook markdown 字段", () => {
|
||||
test("HTML 邮件入库时存 Markdown;纯文本邮件 markdown=纯文本", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const html = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL_HTML),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const row = await t.env.DB.prepare("SELECT markdown_body, text_body, html_body FROM emails WHERE id = ?")
|
||||
.bind(html.id)
|
||||
.first<{ markdown_body: string | null; text_body: string | null; html_body: string | null }>();
|
||||
expect(row?.html_body).toBeTruthy();
|
||||
expect(row?.markdown_body).toContain("**");
|
||||
expect(row?.markdown_body?.trim().length ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const plain = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL_PLAIN),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const row2 = await t.env.DB.prepare("SELECT markdown_body FROM emails WHERE id = ?")
|
||||
.bind(plain.id)
|
||||
.first<{ markdown_body: string | null }>();
|
||||
expect(row2?.markdown_body).toBeTruthy();
|
||||
});
|
||||
|
||||
test("webhook payload 含 markdown 字段(优先存量 markdown_body)", () => {
|
||||
const payload = buildWebhookPayload({
|
||||
id: "msg_x",
|
||||
mailboxAddress: "hello@example.test",
|
||||
fromAddress: "a@b.c",
|
||||
toAddresses: '["hello@example.test"]',
|
||||
subject: "s",
|
||||
textBody: "plain body",
|
||||
htmlBody: "<p>hello <b>world</b></p>",
|
||||
markdownBody: null,
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(payload.markdown).toBe("hello **world**");
|
||||
|
||||
const payload2 = buildWebhookPayload({
|
||||
id: "msg_y",
|
||||
mailboxAddress: null,
|
||||
fromAddress: null,
|
||||
toAddresses: "[]",
|
||||
subject: null,
|
||||
textBody: null,
|
||||
htmlBody: null,
|
||||
markdownBody: "stored md",
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(payload2.markdown).toBe("stored md");
|
||||
});
|
||||
|
||||
test("设置接口返回 api_key 元信息;生成接口明文 32 位 wmh_", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const before = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||
api_key: { has_key: boolean; key_last4: string };
|
||||
};
|
||||
expect(before.api_key.has_key).toBe(false);
|
||||
|
||||
const created = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "POST" })
|
||||
).json()) as { key: string };
|
||||
expect(created.key.startsWith("wmh_")).toBe(true);
|
||||
expect(created.key.length).toBe(32);
|
||||
|
||||
const after = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||
api_key: { has_key: boolean; key_last4: string };
|
||||
};
|
||||
expect(after.api_key.has_key).toBe(true);
|
||||
expect(created.key.endsWith(after.api_key.key_last4)).toBe(true);
|
||||
});
|
||||
});
|
||||
+19
-11
@@ -42,7 +42,7 @@ await page.click("button[type=submit]");
|
||||
await page.waitForURL(`${BASE}/`, { timeout: 8000 });
|
||||
await page.waitForSelector("text=概览", { timeout: 8000 });
|
||||
check("登录成功进入概览页", true);
|
||||
// 品牌区版本号 tag(v1.1.0,来源 package.json)
|
||||
// 品牌区版本号 tag(v2.1.0,来源 package.json)
|
||||
const versionTag = await page.locator(".brand-version").textContent();
|
||||
check("LOGO 版本号 tag 显示", Boolean(versionTag && /^v\d+\.\d+\.\d+$/.test(versionTag.trim())), versionTag?.trim());
|
||||
await page.waitForTimeout(600);
|
||||
@@ -53,7 +53,7 @@ const pages = [
|
||||
{ path: "/accounts", expect: "账号", shot: "03-accounts.png" },
|
||||
{ path: "/domains", expect: "域名", shot: "04-domains.png" },
|
||||
{ path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
|
||||
{ path: "/emails", expect: "邮件", shot: "06-emails.png" },
|
||||
{ path: "/inboxs", expect: "收件箱", shot: "06-inboxs.png" },
|
||||
];
|
||||
for (const p of pages) {
|
||||
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
|
||||
@@ -76,7 +76,7 @@ await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// 5. 邮件详情(点击第一行)
|
||||
await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" });
|
||||
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
|
||||
await page.locator(".ant-table-row").first().click();
|
||||
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
|
||||
@@ -86,18 +86,26 @@ await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// 6. 设置模态窗口:基本设置 + 生成 MCP Key
|
||||
await page.locator("button[aria-label='设置']").click();
|
||||
// 头像下拉里打开设置(设置按钮已移入下拉菜单)
|
||||
await page.locator(".ant-dropdown-trigger, .app-header .ant-dropdown-trigger").first().hover();
|
||||
await page.waitForSelector(".ant-dropdown:not(.ant-dropdown-hidden)", { timeout: 8000 });
|
||||
await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
|
||||
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
|
||||
check("设置模态窗口打开(全局 Webhook / Ingress / 密码)", true);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09-settings-general.png` });
|
||||
check("设置模态窗口经头像下拉打开(仅 全局 Webhook / 账号密码 两 tab)", true);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
|
||||
|
||||
await page.click(".ant-tabs-tab:has-text('MCP Keys')");
|
||||
await page.waitForSelector("button:has-text('生成 MCP Key')", { timeout: 8000 });
|
||||
await page.click("button:has-text('生成 MCP Key')");
|
||||
// 单一 API/MCP Key:在 全局 Webhook tab 内生成
|
||||
const genBtn = page.getByRole("button", { name: /生成/ });
|
||||
await genBtn.waitFor({ state: "visible", timeout: 8000 });
|
||||
await genBtn.click();
|
||||
await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 });
|
||||
const keyText = await page.locator(".ant-modal:has-text('仅显示一次') .mono").first().textContent();
|
||||
check("生成 MCP Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/10-mcp-key-created.png` });
|
||||
check("生成 API/MCP Key 弹窗(wmh_ 32 位,单一 Key)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/10-api-key-created.png` });
|
||||
|
||||
// 主题切换按钮存在(头像左侧)
|
||||
const themeBtn = await page.locator("button[aria-label='切换主题']").count();
|
||||
check("主题切换按钮存在(头像左侧)", themeBtn === 1);
|
||||
|
||||
await browser.close();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user