diff --git a/bun.lock b/bun.lock index 9600cf0..4f75151 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx index 5265a11..0d90390 100644 --- a/console/frontend/src/App.tsx +++ b/console/frontend/src/App.tsx @@ -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: , label: "账号 Accounts" }, { key: "/domains", icon: , label: "域名 Domains" }, { key: "/mailboxes", icon: , label: "邮箱 Mailboxes" }, - { key: "/emails", icon: , label: "邮件 Emails" }, + { key: "/inboxs", icon: , label: "收件箱 Inboxs" }, ]; /** # 成员变量: 路由 -> 页面标题/副标题 **/ @@ -31,15 +32,29 @@ const PAGE_META: Record = { "/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 = ( + + + + +); +const MoonIcon = ( + + + +); + /** # 函数 - * 应用框架组件:未登录跳 /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,67 +77,78 @@ export function App() { const meta = PAGE_META[location.pathname] ?? { title: "WmailHub", subtitle: "" }; return ( - - -
- WmailHub - WmailHub - {/* 版本号小一号、居上对齐(来源 package.json) */} - {version && v{version}} -
- navigate(key)} - style={{ borderInlineEnd: "none" }} - /> - - -
-
-
{meta.title}
-
{meta.subtitle}
+ <> + + +
+ WmailHub + WmailHub + {/* 版本号小一号、居上对齐(来源 package.json) */} + {version && v{version}}
-
- + navigate(key)} + style={{ borderInlineEnd: "none" }} + /> + + +
+
+
{meta.title}
+
{meta.subtitle}
+
+
+ {/* 主题切换:亮↔暗,位于头像左侧 */}
-
- - - +
+ + + +
setSettingsOpen(false)} /> - + ); } diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index 197d7b1..6b08d27 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -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; has_key: boolean; key_last4: string }; + api_key: { has_key: boolean; key_last4: string; created_at: string | null }; } export const getSettings = () => api("/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"); diff --git a/console/frontend/src/components/SettingsModal.tsx b/console/frontend/src/components/SettingsModal.tsx index bc14151..2c24b88 100644 --- a/console/frontend/src/components/SettingsModal.tsx +++ b/console/frontend/src/components/SettingsModal.tsx @@ -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 ( - + : null }, - { key: "api", label: "API Keys", children: }, - { key: "mcp", label: "MCP Keys", children: }, + { key: "webhook", label: "全局 Webhook", children: info ? : null }, + { key: "password", label: "账号密码", children: }, ]} /> @@ -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(); - 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 (
- 全局 Webhook void -
void savePassword()}> - 修改密码 - - - - - - - ({ - validator: (_, v) => - !v || v === getFieldValue("new_password") - ? Promise.resolve() - : Promise.reject(new Error("两次输入不一致")), - }), - ]} - > - - - -
+
); } /** # 函数 - * API/MCP Key 管理表格 + * API/MCP Key 卡片:单一 Key,API 与 MCP 接口共用;生成/轮换与撤销 */ -function KeysTab({ kind }: { kind: "api" | "mcp" }) { +function ApiKeyCard({ + apiKey, + onChanged, +}: { + apiKey: SettingsInfo["api_key"]; + onChanged: () => void; +}) { const { message, modal } = App.useApp(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(false); - const [q, setQ] = useState(""); + const [busy, setBusy] = useState(false); - 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:明文仅展示一次 */ + /** 生成(轮换)key:明文仅展示一次 */ const create = async () => { + setBusy(true); try { - const res = await createKey(kind); + const res = await createApiKey(); modal.info({ - title: `${kind === "api" ? "API" : "MCP"} Key 已生成(仅显示一次)`, + title: "API/MCP Key 已生成(仅显示一次)", content: (
{res.key} - 请立即保存;关闭后无法再次查看。 + API 与 MCP 接口共用此 Key;请立即保存,关闭后无法再次查看。
), }); - void load(); + 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 ( -
-
- setQ(e.target.value)} - /> - -
- ****{v} }, - { - title: "状态", - dataIndex: "revoked_at", - width: 90, - render: (v: string | null) => (v ? 已撤销 : 有效), - }, - { 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) => ( - - {!row.revoked_at && ( - { - await revokeKey(row.id); - message.success("已撤销"); - void load(); - }} - > - - - )} - - - ), - }, - ]} + API / MCP Key}> + - + + + {apiKey.has_key ? ( + + ****{apiKey.key_last4}{" "} + + {apiKey.created_at ? `创建于 ${new Date(apiKey.created_at).toLocaleString()}` : ""} + + + ) : ( + 未生成 + )} + + + + + {apiKey.has_key && ( + void remove()}> + + + )} + + + ); +} + +/** # 函数 + * 账号密码 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 ( + void savePassword()}> + + + + + + + ({ + validator: (_, v) => + !v || v === getFieldValue("new_password") + ? Promise.resolve() + : Promise.reject(new Error("两次输入不一致")), + }), + ]} + > + + + + ); } diff --git a/console/frontend/src/main.tsx b/console/frontend/src/main.tsx index d961c9a..2f0fbf4 100644 --- a/console/frontend/src/main.tsx +++ b/console/frontend/src/main.tsx @@ -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: }, { path: "/oauth/sim/authorize", element: }, @@ -28,11 +29,32 @@ const router = createBrowserRouter([ { path: "accounts", element: }, { path: "domains", element: }, { path: "mailboxes", element: }, - { path: "emails", element: }, + { path: "inboxs", element: }, ], }, ]); +/** # 函数 + * 顶层壳:持有主题状态,注入 Context 与 ConfigProvider + */ +function Shell() { + const [dark, setDark] = useState(loadDarkMode); + + useEffect(() => { + applyDarkMode(dark); + }, [dark]); + + return ( + setDark((v) => !v) }}> + + + + + + + ); +} + /** 未登录统一跳登录页 */ setUnauthorizedHandler(() => { if (!location.pathname.startsWith("/login")) { @@ -42,10 +64,6 @@ setUnauthorizedHandler(() => { createRoot(document.getElementById("root")!).render( - - - - - + , ); diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index 03aa378..cb2b56c 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -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() { ) : ( - )}
项目用量
存储98%
", + "", +].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; +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 ==="); diff --git a/tests/fixtures/e2e-receiver.ts b/tests/fixtures/e2e-receiver.ts index 8b6c392..ecf3328 100644 --- a/tests/fixtures/e2e-receiver.ts +++ b/tests/fixtures/e2e-receiver.ts @@ -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"), }), ); diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts new file mode 100644 index 0000000..da7e3ec --- /dev/null +++ b/tests/markdown.test.ts @@ -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 \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 \r +To: hello@example.test\r +Subject: Html Mail\r +MIME-Version: 1.0\r +Content-Type: text/html\r +\r +

Hello world and link

\r +`; + +describe("htmlToMarkdown(mailhook 同思路)", () => { + test("标题/加粗/链接/行内代码", () => { + expect(htmlToMarkdown("

Hello

World bold link cfg

")).toBe( + "## Hello\n\nWorld **bold** [link](https://x.com) `cfg`", + ); + }); + + test("列表与嵌套列表", () => { + expect(htmlToMarkdown("
  • one
  • two
    1. nested
")).toBe( + "- one\n\n- two\n\n 1. nested", + ); + }); + + test("表格转管道表(| 转义)", () => { + expect(htmlToMarkdown("
A
1|2
")).toBe( + "| A |\n| --- |\n| 1\\|2 |", + ); + }); + + test("pre 代码块保留换行;blockquote 引用;hr 分隔线", () => { + expect(htmlToMarkdown("
line1\nline2
")).toBe("```\nline1\nline2\n```"); + expect(htmlToMarkdown("
quoted
")).toBe("> quoted"); + expect(htmlToMarkdown("
")).toBe("---"); + }); + + test("script/style 丢弃;空内容返回空串", () => { + expect(htmlToMarkdown("")).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: "

hello world

", + 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); + }); +}); diff --git a/tests/ui-smoke.ts b/tests/ui-smoke.ts index a4a3051..ae7eefd 100644 --- a/tests/ui-smoke.ts +++ b/tests/ui-smoke.ts @@ -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();