feat: 邮件已读/未读状态持久化与 Web 页面增强 (0.2.8)

- 邮件列表/详情页显示已读/未读状态,支持按状态筛选
- 邮件 API GET /v1/mail/messages 支持 seen 过滤
- 数据库 ias_mail_messages 新增 seen 字段
- 新增邮件查询 YAML 工具 query_mail_messages
- 新增 /tools 和 /configs Web 页面
- 新增 ias-http shell 和 ias-service console 模块
- 补充历史版本升级日志迁移 SQL
This commit is contained in:
2026-07-07 09:54:42 +08:00
parent 2669861af1
commit 3d77733e11
34 changed files with 2818 additions and 1146 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-service"
version = "0.1.1"
version = "0.1.7"
edition = "2024"
[dependencies]
+16
View File
@@ -0,0 +1,16 @@
use std::fs;
use std::path::Path;
fn main() {
let migrations = Path::new("migrations");
println!("cargo:rerun-if-changed={}", migrations.display());
if let Ok(entries) = fs::read_dir(migrations) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "sql") {
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
}
@@ -0,0 +1,36 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.15',
'2026-07-06T00:00:00+08:00',
'邮件预览页面优化',
'邮件预览页面全新设计,支持上下篇导航、HTML 邮件渲染和元信息一键复制。',
'[
"邮件预览页面全新设计:现代化卡片布局,顶部展示发件人、时间和来源等关键信息。",
"新增上一封/下一封快速导航,无需返回列表即可连续浏览邮件。",
"详细信息折叠面板,UID 和 Message-ID 支持一键复制。",
"HTML 邮件自动检测并在沙箱 iframe 中渲染,纯文本邮件保持原有展示方式。",
"新增 find_adjacent_ids 仓库方法,按 ID 查询相邻邮件记录。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.1",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.1",
"ias-mail": "0.1.8",
"ias-service": "0.1.1",
"ias-main": "0.1.15"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,40 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.1',
'2026-07-06T00:00:00+08:00',
'全面采用 Tailwind CSS,回滚服务端渲染',
'移除独立 React 前端,恢复服务端 HTML 渲染,全面采用 Tailwind CSS CDN 统一页面样式。',
'[
"全面采用 Tailwind CSS:通过 CDN 引入,统一所有页面样式,替代原有内联 CSS。",
"更新日志页面:卡片布局,蓝色版本标签点缀,响应式设计。",
"邮件列表页面:现代化搜索筛选栏,表格交互 Hover 高亮,分页器优化。",
"邮件详情页面:发件人/时间/来源信息卡片,详细信息折叠面板,键盘左右键快速导航。",
"Web 页面/Mail 提醒页面:元数据网格展示,文本内容自动换行。",
"提取公共 shell 模块 (ias-http/src/shell.rs):统一 HTML 布局、顶部导航栏和 Tailwind CDN 引入。",
"新增首页 / 重定向到更新日志。",
"移除独立 React 前端项目 ias-web 及相关 build.rs。",
"保留所有 JSON API 端点 (/v1/*),供内部调用。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.1",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.2",
"ias-mail": "0.1.9",
"ias-service": "0.1.2",
"ias-main": "0.2.1"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,37 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.0',
'2026-07-06T00:00:00+08:00',
'前后端分离与 React 前端',
'将服务端 HTML 渲染调整为 RESTful JSON API 与 React 前端,并新增前端静态文件构建与托管能力。',
'[
"重大架构调整:前后端分离,将 HTML 服务端渲染改为 RESTful JSON API + React 前端。",
"新增 ias-web 前端项目,基于 Vite + React + TypeScript 构建。",
"后端 API 路由调整:更新日志、Web 页面、邮件页面均提供 /v1 JSON 接口。",
"后端添加 CORS 支持,并集成前端静态文件托管。",
"新增 build.rs,前端源文件变更时自动触发 npm build。",
"支持 SKIP_FRONTEND_BUILD 和 IA_STATIC_DIR 环境变量。",
"前端开发代理配置自动转发 /v1 API 请求到后端。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.1",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.2",
"ias-mail": "0.1.9",
"ias-service": "0.1.2",
"ias-main": "0.2.0"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,36 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.2',
'2026-07-06T00:00:00+08:00',
'修复 HTML 渲染安全与错误页响应',
'修复邮件详情和 Web 动态页面中的 HTML 渲染边界问题,并补齐缺失的 0.2.0 升级日志记录。',
'[
"修复邮件详情页预览链接的 HTML 属性转义,避免异常 URL 破坏页面结构或注入属性。",
"修复长中文或 Emoji 邮件内容、Message-ID 截断时可能因 UTF-8 非字符边界切片导致的页面错误。",
"收紧 HTML 邮件 iframe 沙箱:移除同源权限,并通过 CSP 默认禁止外部资源、表单提交和脚本加载。",
"修复 Web 动态页面 404/500 错误页的响应类型,确保浏览器按 HTML 页面渲染。",
"补齐 0.2.0 数据库升级日志记录。",
"新增针对邮件 HTML 安全渲染、Unicode 截断和 Web 错误页响应类型的单元测试。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.1",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.3",
"ias-mail": "0.1.10",
"ias-service": "0.1.3",
"ias-main": "0.2.2"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,34 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.3',
'2026-07-07T00:00:00+08:00',
'修复邮件自动通知漏发',
'修复后台邮件轮询因 IMAP 已读标记跳过新邮件通知的问题,并让轮询抓取范围遵循账户配置。',
'[
"修复邮件自动通知漏发:后台轮询现在会对所有新入库邮件发送通知,不再因为 IMAP \\Seen 已读标记跳过通知。",
"邮件轮询现在尊重账户配置的 fetch_limit,不再固定只检查最近 20 封邮件。",
"邮件轮询启动日志新增 fetch_limit,便于确认实际加载的抓取范围。",
"新增单元测试覆盖已读新邮件仍会被自动轮询处理,以及不同抓取上限下的 IMAP 序列范围计算。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.1",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.3",
"ias-mail": "0.1.11",
"ias-service": "0.1.4",
"ias-main": "0.2.3"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,33 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.4',
'2026-07-07T00:00:00+08:00',
'新增邮件查询 YAML HTTP 工具',
'新增 YAML 配置的邮件查询 HTTP 工具,允许通过现有邮件 API 查询或搜索邮件列表。',
'[
"新增 YAML 配置的邮件查询 HTTP 工具 query_mail_messages,通过 /v1/mail/messages 查询或搜索邮件列表。",
"工具支持关键词、邮箱目录、来源、页码和每页数量参数,返回分页信息和当前页邮件 JSON 列表。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.2",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.3",
"ias-mail": "0.1.11",
"ias-service": "0.1.5",
"ias-main": "0.2.4"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,33 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.5',
'2026-07-07T00:00:00+08:00',
'修复迁移文件变化后的后台服务重编译',
'修复新增 SQL 迁移文件后后台服务可能继续使用旧二进制迁移列表的问题,确保迁移目录变化会触发重新编译。',
'[
"修复新增 SQL 迁移文件后后台服务可能继续使用旧二进制迁移列表的问题。",
"ias-service 新增 build.rs,显式监听 migrations 目录和 SQL 文件变化,确保 sqlx::migrate! 重新嵌入最新迁移。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.2",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.3",
"ias-mail": "0.1.11",
"ias-service": "0.1.6",
"ias-main": "0.2.5"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,36 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.6',
'2026-07-07T00:00:00+08:00',
'新增工具和用户配置列表',
'新增服务端渲染的工具列表、用户配置列表,以及对应的只读 JSON API。',
'[
"新增工具列表 HTTP 页面 /tools,展示内置工具和 tools/*.yaml 工具定义。",
"新增工具列表 API GET /v1/tools,返回内置工具、YAML 工具、参数、环境变量名和工具目录加载告警。",
"新增用户配置列表 HTTP 页面 /configs,展示微信渠道账号和邮件账户配置。",
"新增用户配置列表 API GET /v1/user-configs,返回脱敏后的微信与邮件配置,不暴露微信 token、更新游标和邮件密码明文。",
"顶部导航新增工具和配置入口。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.2",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.4",
"ias-mail": "0.1.11",
"ias-service": "0.1.7",
"ias-main": "0.2.6"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,34 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.7',
'2026-07-07T00:00:00+08:00',
'修复 HTML 邮件详情渲染',
'修复 multipart 邮件详情页只展示纯文本 fallback 的问题,优先保存和渲染 HTML 邮件体。',
'[
"修复 multipart/alternative 邮件详情页只展示纯文本 fallback 的问题;现在详情页优先保存和渲染 HTML 邮件体。",
"邮件列表摘要、搜索预览和微信通知继续使用纯文本正文;没有纯文本正文时会从 HTML 中提取简要文本,避免标签污染摘要。",
"邮件详情 iframe 继续禁止脚本和表单,但允许加载 http/https 图片,使 Facebook 等 HTML 邮件更接近邮箱客户端渲染效果。",
"无数据库结构变化;已缓存的旧邮件若此前只保存了纯文本,需要重新抓取或刷新缓存后才能显示 HTML 版本。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.2",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.4",
"ias-mail": "0.1.12",
"ias-service": "0.1.7",
"ias-main": "0.2.7"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,22 @@
UPDATE ias_upgrade_logs
SET
title = '修复 HTML 邮件详情渲染',
description = '修复 multipart 邮件详情页只展示纯文本 fallback 的问题,优先保存和渲染 HTML 邮件体,并刷新最近重复邮件缓存。',
changes = '[
"修复 multipart/alternative 邮件详情页只展示纯文本 fallback 的问题;现在详情页优先保存和渲染 HTML 邮件体。",
"邮件列表摘要、搜索预览和微信通知继续使用纯文本正文;没有纯文本正文时会从 HTML 中提取简要文本,避免标签污染摘要。",
"邮件轮询或手动拉取遇到已缓存的重复邮件时,会刷新缓存正文和摘要,使最近抓取范围内的旧纯文本缓存可自动更新为 HTML 正文。",
"邮件详情 iframe 继续禁止脚本和表单,但允许加载 http/https 图片,使 Facebook 等 HTML 邮件更接近邮箱客户端渲染效果。",
"无数据库结构变化;不在最近抓取范围内的旧邮件仍需要扩大抓取范围或重新入库后才能显示 HTML 版本。"
]'::jsonb,
modules = '{
"ias-common": "0.1.0",
"ias-ai": "0.1.2",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.4",
"ias-mail": "0.1.12",
"ias-service": "0.1.7",
"ias-main": "0.2.7"
}'::jsonb
WHERE version = '0.2.7';
@@ -0,0 +1,41 @@
ALTER TABLE ias_mail_messages
ADD COLUMN IF NOT EXISTS seen BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_ias_mail_messages_seen
ON ias_mail_messages(seen, received_at DESC, id DESC);
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.2.8',
'2026-07-07T00:00:00+08:00',
'新增邮件已读未读状态',
'保存 IMAP 已读状态并在邮件列表、详情页和 API 中展示与筛选。',
'[
"新增邮件已读/未读状态持久化,保存 IMAP \\Seen 标记到 ias_mail_messages.seen。",
"邮件列表页面新增状态筛选,支持查看全部、未读或已读邮件。",
"邮件列表卡片和详情页显示已读/未读状态,未读邮件在列表中使用更醒目的样式。",
"邮件 API GET /v1/mail/messages 支持 seen=true/false 过滤,并在返回的邮件对象中包含 seen 字段。",
"数据库新增 ias_mail_messages.seen 字段;已有历史邮件默认标记为未读,后续拉取最近邮件时会刷新为 IMAP 当前状态。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.2",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.4",
"ias-mail": "0.1.13",
"ias-service": "0.1.7",
"ias-main": "0.2.8"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
+851
View File
@@ -0,0 +1,851 @@
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::response::Html;
use axum::routing::get;
use axum::{Json, Router};
use chrono::{DateTime, NaiveDateTime, Utc};
use ias_ai::workflow::types::FlowConfig;
use ias_http::shell::{escape_html, html_shell};
use ias_mail::model::MailAccount;
use ias_mail::repository::MailRepository;
use serde::Serialize;
use serde_json::{Value, json};
use sqlx::PgPool;
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::model::user::WechatAccount;
use crate::repository::user_repository::UserRepositor;
type ApiError = (StatusCode, Json<Value>);
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
pub struct ConsoleRouteState {
pub db: PgPool,
}
impl ConsoleRouteState {
pub fn new(db: PgPool) -> Self {
Self { db }
}
}
#[derive(Debug, Clone, Serialize)]
struct ToolList {
tools_path: String,
builtin_tools: Vec<ToolListItem>,
capability_tools: Vec<ToolListItem>,
warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ToolListItem {
name: String,
description: String,
source: String,
path: Option<String>,
params: Vec<ToolParamItem>,
required_env: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ToolParamItem {
name: String,
param_type: String,
required: bool,
description: String,
}
#[derive(Debug, Clone, Serialize)]
struct UserConfigList {
wechat_accounts: Vec<WechatConfigItem>,
mail_accounts: Vec<MailConfigItem>,
}
#[derive(Debug, Clone, Serialize)]
struct WechatConfigItem {
id: i64,
user_name: Option<String>,
account_id: Option<String>,
base_url: Option<String>,
user_id: Option<String>,
user_status: Option<i32>,
token_configured: bool,
updates_buf_present: bool,
created_at: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Serialize)]
struct MailConfigItem {
id: i64,
name: String,
source_key: String,
imap_host: String,
imap_port: i32,
imap_username: String,
mailbox: String,
notify_account_id: String,
notify_user_id: String,
poll_seconds: i32,
fetch_limit: i32,
accept_invalid_certs: bool,
enabled: bool,
password_configured: bool,
last_checked_at: Option<DateTime<Utc>>,
last_error: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
pub fn routes<S>(db: PgPool) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/tools", get(show_tools))
.route("/configs", get(show_user_configs))
.route("/user-configs", get(show_user_configs))
.route("/v1/tools", get(list_tools_api))
.route("/v1/user-configs", get(list_user_configs_api))
.layer(Extension(ConsoleRouteState::new(db)))
}
// ── JSON API ─────────────────────────────────────────────
async fn list_tools_api() -> Result<Json<Value>, ApiError> {
let tools = load_tool_list().await;
let total = tools.builtin_tools.len() + tools.capability_tools.len();
Ok(Json(json!({
"success": true,
"total": total,
"tools_path": tools.tools_path,
"builtin_tools": tools.builtin_tools,
"capability_tools": tools.capability_tools,
"warnings": tools.warnings,
})))
}
async fn list_user_configs_api(
Extension(state): Extension<ConsoleRouteState>,
) -> Result<Json<Value>, ApiError> {
let configs = load_user_config_list(&state.db)
.await
.map_err(api_internal_error("读取用户配置列表失败"))?;
Ok(Json(json!({
"success": true,
"wechat_total": configs.wechat_accounts.len(),
"mail_total": configs.mail_accounts.len(),
"wechat_accounts": configs.wechat_accounts,
"mail_accounts": configs.mail_accounts,
})))
}
// ── HTML 页面 ────────────────────────────────────────────
async fn show_tools() -> Result<Html<String>, HtmlError> {
let tools = load_tool_list().await;
Ok(Html(render_tools_page(&tools)))
}
async fn show_user_configs(
Extension(state): Extension<ConsoleRouteState>,
) -> Result<Html<String>, HtmlError> {
let configs = load_user_config_list(&state.db)
.await
.map_err(html_internal_error("读取用户配置列表失败"))?;
Ok(Html(render_user_configs_page(&configs)))
}
fn render_tools_page(tools: &ToolList) -> String {
let total = tools.builtin_tools.len() + tools.capability_tools.len();
let warnings = render_warnings(&tools.warnings);
let builtin = render_tool_section("内置工具", &tools.builtin_tools);
let capability = render_tool_section("YAML 工具", &tools.capability_tools);
let body = format!(
r#"<div class="mb-7">
<h1 class="text-2xl md:text-3xl font-bold tracking-tight">工具列表</h1>
<p class="mt-1.5 text-sm text-gray-500">共 {total} 个工具;YAML 工具目录:<code class="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">{tools_path}</code></p>
</div>
{warnings}
{builtin}
{capability}"#,
total = total,
tools_path = escape_html(&tools.tools_path),
warnings = warnings,
builtin = builtin,
capability = capability,
);
html_shell("工具列表", &body)
}
fn render_tool_section(title: &str, items: &[ToolListItem]) -> String {
let content = if items.is_empty() {
r#"<div class="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">暂无工具</div>"#.to_string()
} else {
items
.iter()
.map(render_tool_card)
.collect::<Vec<_>>()
.join("\n")
};
format!(
r#"<section class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">{title}</h2>
<span class="text-sm text-gray-400">{count} 个</span>
</div>
<div class="flex flex-col gap-3">
{content}
</div>
</section>"#,
title = escape_html(title),
count = items.len(),
content = content,
)
}
fn render_tool_card(item: &ToolListItem) -> String {
let path = item
.path
.as_deref()
.map(|path| {
format!(
r#"<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 text-gray-500">{}</span>"#,
escape_html(path)
)
})
.unwrap_or_default();
let env = if item.required_env.is_empty() {
String::new()
} else {
format!(
r#"<div class="mt-3 text-xs text-gray-500">环境变量:{}</div>"#,
item.required_env
.iter()
.map(|value| format!(
r#"<code class="bg-gray-100 px-1.5 py-0.5 rounded">{}</code>"#,
escape_html(value)
))
.collect::<Vec<_>>()
.join(" ")
)
};
let params = render_tool_params(&item.params);
format!(
r#"<article class="bg-white rounded-lg border border-gray-200 p-4 md:p-5">
<div class="flex items-start justify-between gap-3 mb-2">
<div class="min-w-0">
<h3 class="text-base font-semibold text-gray-900 break-words">{name}</h3>
<p class="mt-1 text-sm text-gray-500 leading-relaxed">{description}</p>
</div>
<span class="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700">{source}</span>
</div>
<div class="flex flex-wrap gap-2 mb-3">{path}</div>
{params}
{env}
</article>"#,
name = escape_html(&item.name),
description = escape_html(&item.description),
source = escape_html(&item.source),
path = path,
params = params,
env = env,
)
}
fn render_tool_params(params: &[ToolParamItem]) -> String {
if params.is_empty() {
return r#"<p class="text-sm text-gray-400">无参数</p>"#.to_string();
}
let rows = params
.iter()
.map(|param| {
let required = if param.required {
r#"<span class="text-[11px] font-medium text-red-600 bg-red-50 px-1.5 py-0.5 rounded">必填</span>"#
} else {
r#"<span class="text-[11px] font-medium text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">可选</span>"#
};
format!(
r#"<div class="py-1.5 border-t border-gray-100 first:border-t-0">
<div class="flex flex-wrap items-center gap-2">
<code class="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded">{name}</code>
<span class="text-xs text-gray-400">{param_type}</span>
{required}
</div>
<p class="mt-1 text-sm text-gray-500">{description}</p>
</div>"#,
name = escape_html(&param.name),
param_type = escape_html(&param.param_type),
required = required,
description = escape_html(&param.description),
)
})
.collect::<Vec<_>>()
.join("\n");
format!(r#"<div class="mt-3">{rows}</div>"#)
}
fn render_warnings(warnings: &[String]) -> String {
if warnings.is_empty() {
return String::new();
}
let items = warnings
.iter()
.map(|warning| {
format!(
r#"<li class="ml-4 list-disc">{}</li>"#,
escape_html(warning)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<section class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 mb-5 text-sm">
<div class="font-semibold mb-1">部分工具定义未能加载</div>
<ul>{items}</ul>
</section>"#
)
}
fn render_user_configs_page(configs: &UserConfigList) -> String {
let wechat = render_wechat_configs(&configs.wechat_accounts);
let mail = render_mail_configs(&configs.mail_accounts);
let total = configs.wechat_accounts.len() + configs.mail_accounts.len();
let body = format!(
r#"<div class="mb-7">
<h1 class="text-2xl md:text-3xl font-bold tracking-tight">用户配置列表</h1>
<p class="mt-1.5 text-sm text-gray-500">共 {total} 项配置,包含微信渠道账号和邮件账户配置。</p>
</div>
{wechat}
{mail}"#,
total = total,
wechat = wechat,
mail = mail,
);
html_shell("用户配置列表", &body)
}
fn render_wechat_configs(items: &[WechatConfigItem]) -> String {
let content = if items.is_empty() {
r#"<div class="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">暂无微信账号配置</div>"#.to_string()
} else {
items
.iter()
.map(render_wechat_config_card)
.collect::<Vec<_>>()
.join("\n")
};
format!(
r#"<section class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">微信渠道账号</h2>
<span class="text-sm text-gray-400">{count} 个</span>
</div>
<div class="flex flex-col gap-3">{content}</div>
</section>"#,
count = items.len(),
content = content,
)
}
fn render_wechat_config_card(item: &WechatConfigItem) -> String {
let title = item
.user_name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("未命名微信账号");
let status = item
.user_status
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
format!(
r#"<article class="bg-white rounded-lg border border-gray-200 p-4 md:p-5">
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0">
<h3 class="text-base font-semibold text-gray-900 break-words">{title}</h3>
<p class="mt-1 text-sm text-gray-500 break-all">{account_id}</p>
</div>
<span class="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-green-50 text-green-700">微信</span>
</div>
<dl class="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-400">账号 ID</dt><dd class="text-gray-700 break-all">{account_id}</dd>
<dt class="text-gray-400">用户 ID</dt><dd class="text-gray-700 break-all">{user_id}</dd>
<dt class="text-gray-400">服务地址</dt><dd class="text-gray-700 break-all">{base_url}</dd>
<dt class="text-gray-400">状态值</dt><dd class="text-gray-700">{status}</dd>
<dt class="text-gray-400">Token</dt><dd class="text-gray-700">{token_status}</dd>
<dt class="text-gray-400">更新游标</dt><dd class="text-gray-700">{updates_status}</dd>
<dt class="text-gray-400">创建时间</dt><dd class="text-gray-700">{created_at}</dd>
</dl>
</article>"#,
title = escape_html(title),
account_id = escape_optional(item.account_id.as_deref()),
user_id = escape_optional(item.user_id.as_deref()),
base_url = escape_optional(item.base_url.as_deref()),
status = escape_html(&status),
token_status = configured_label(item.token_configured),
updates_status = configured_label(item.updates_buf_present),
created_at = escape_html(&format_naive_time(item.created_at)),
)
}
fn render_mail_configs(items: &[MailConfigItem]) -> String {
let content = if items.is_empty() {
r#"<div class="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">暂无邮件账户配置</div>"#.to_string()
} else {
items
.iter()
.map(render_mail_config_card)
.collect::<Vec<_>>()
.join("\n")
};
format!(
r#"<section class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">邮件账户</h2>
<span class="text-sm text-gray-400">{count} 个</span>
</div>
<div class="flex flex-col gap-3">{content}</div>
</section>"#,
count = items.len(),
content = content,
)
}
fn render_mail_config_card(item: &MailConfigItem) -> String {
let enabled = if item.enabled {
r#"<span class="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-green-50 text-green-700">已启用</span>"#
} else {
r#"<span class="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-gray-100 text-gray-500">已禁用</span>"#
};
let error = item
.last_error
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| {
format!(
r#"<div class="mt-3 text-sm text-red-600 bg-red-50 border border-red-100 rounded-md px-3 py-2 break-words">{}</div>"#,
escape_html(value)
)
})
.unwrap_or_default();
format!(
r#"<article class="bg-white rounded-lg border border-gray-200 p-4 md:p-5">
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0">
<h3 class="text-base font-semibold text-gray-900 break-words">{name}</h3>
<p class="mt-1 text-sm text-gray-500 break-all">{username} @ {host}:{port}</p>
</div>
{enabled}
</div>
<dl class="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-400">配置 ID</dt><dd class="text-gray-700">#{id}</dd>
<dt class="text-gray-400">来源键</dt><dd class="text-gray-700 break-all">{source_key}</dd>
<dt class="text-gray-400">邮箱目录</dt><dd class="text-gray-700">{mailbox}</dd>
<dt class="text-gray-400">通知账号</dt><dd class="text-gray-700 break-all">{notify_account_id}</dd>
<dt class="text-gray-400">通知用户</dt><dd class="text-gray-700 break-all">{notify_user_id}</dd>
<dt class="text-gray-400">轮询间隔</dt><dd class="text-gray-700">{poll_seconds}s</dd>
<dt class="text-gray-400">抓取上限</dt><dd class="text-gray-700">{fetch_limit}</dd>
<dt class="text-gray-400">无效证书</dt><dd class="text-gray-700">{accept_invalid_certs}</dd>
<dt class="text-gray-400">密码</dt><dd class="text-gray-700">{password_status}</dd>
<dt class="text-gray-400">最近检查</dt><dd class="text-gray-700">{last_checked}</dd>
<dt class="text-gray-400">更新时间</dt><dd class="text-gray-700">{updated_at}</dd>
</dl>
{error}
</article>"#,
id = item.id,
name = escape_html(&item.name),
username = escape_html(&item.imap_username),
host = escape_html(&item.imap_host),
port = item.imap_port,
enabled = enabled,
source_key = escape_html(&item.source_key),
mailbox = escape_html(&item.mailbox),
notify_account_id = escape_html(&item.notify_account_id),
notify_user_id = escape_html(&item.notify_user_id),
poll_seconds = item.poll_seconds,
fetch_limit = item.fetch_limit,
accept_invalid_certs = bool_label(item.accept_invalid_certs),
password_status = configured_label(item.password_configured),
last_checked = escape_html(&format_utc_time(item.last_checked_at)),
updated_at = escape_html(&format_utc_time(Some(item.updated_at))),
error = error,
)
}
// ── 数据读取与转换 ───────────────────────────────────────
async fn load_tool_list() -> ToolList {
let (capability_tools, warnings, tools_path) = load_capability_tools().await;
ToolList {
tools_path,
builtin_tools: builtin_tool_items(),
capability_tools,
warnings,
}
}
fn builtin_tool_items() -> Vec<ToolListItem> {
ias_ai::toolkit::get_tool()
.into_iter()
.filter_map(extract_builtin_tool)
.collect()
}
fn extract_builtin_tool(value: Value) -> Option<ToolListItem> {
let function = value.get("function")?;
let name = function.get("name")?.as_str()?.to_string();
let description = function
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let parameters = function.get("parameters");
let required = parameters
.and_then(|value| value.get("required"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut params = parameters
.and_then(|value| value.get("properties"))
.and_then(Value::as_object)
.map(|properties| {
let mut params = properties
.iter()
.map(|(param_name, spec)| ToolParamItem {
name: param_name.clone(),
param_type: spec
.get("type")
.and_then(Value::as_str)
.unwrap_or("object")
.to_string(),
required: required.iter().any(|value| value == param_name),
description: spec
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
})
.collect::<Vec<_>>();
params.sort_by(|left, right| left.name.cmp(&right.name));
params
})
.unwrap_or_default();
params.sort_by_key(|param| (!param.required, param.name.clone()));
Some(ToolListItem {
name,
description,
source: "内置".to_string(),
path: None,
params,
required_env: Vec::new(),
})
}
async fn load_capability_tools() -> (Vec<ToolListItem>, Vec<String>, String) {
let tools_path = configured_tools_path();
let tools_path_label = tools_path.display().to_string();
let mut warnings = Vec::new();
let mut files = Vec::new();
let mut dir = match fs::read_dir(&tools_path).await {
Ok(dir) => dir,
Err(error) => {
warnings.push(format!("读取工具目录失败: {error}"));
return (Vec::new(), warnings, tools_path_label);
}
};
loop {
match dir.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if is_yaml_file(&path) {
files.push(path);
}
}
Ok(None) => break,
Err(error) => {
warnings.push(format!("读取工具目录项失败: {error}"));
break;
}
}
}
files.sort_by(|left, right| left.file_name().cmp(&right.file_name()));
let mut tools = Vec::new();
for file in files {
let file_label = file_label(&file);
match fs::read_to_string(&file).await {
Ok(content) => match serde_yaml::from_str::<FlowConfig>(&content) {
Ok(config) => tools.push(tool_item_from_flow(config, &file)),
Err(error) => warnings.push(format!("{file_label} 解析失败: {error}")),
},
Err(error) => warnings.push(format!("{file_label} 读取失败: {error}")),
}
}
(tools, warnings, tools_path_label)
}
fn tool_item_from_flow(config: FlowConfig, path: &Path) -> ToolListItem {
ToolListItem {
name: config.name,
description: config.desc,
source: "YAML".to_string(),
path: Some(file_label(path)),
params: config
.params
.into_iter()
.map(|param| ToolParamItem {
name: param.name,
param_type: param.param_type,
required: param.required,
description: param.description,
})
.collect(),
required_env: config.env,
}
}
async fn load_user_config_list(pool: &PgPool) -> anyhow::Result<UserConfigList> {
let wechat_accounts = UserRepositor::list_wechat_accounts(pool)
.await?
.into_iter()
.map(sanitize_wechat_account)
.collect();
let mail_accounts = MailRepository::list_accounts(pool)
.await?
.into_iter()
.map(sanitize_mail_account)
.collect();
Ok(UserConfigList {
wechat_accounts,
mail_accounts,
})
}
fn sanitize_wechat_account(account: WechatAccount) -> WechatConfigItem {
WechatConfigItem {
id: account.id,
user_name: account.user_name,
account_id: account.account_id,
base_url: account.base_url,
user_id: account.user_id,
user_status: account.user_status,
token_configured: configured(account.token.as_deref()),
updates_buf_present: configured(account.updates_buf.as_deref()),
created_at: account.created_at,
}
}
fn sanitize_mail_account(account: MailAccount) -> MailConfigItem {
MailConfigItem {
id: account.id,
name: account.name,
source_key: account.source_key,
imap_host: account.imap_host,
imap_port: account.imap_port,
imap_username: account.imap_username,
mailbox: account.mailbox,
notify_account_id: account.notify_account_id,
notify_user_id: account.notify_user_id,
poll_seconds: account.poll_seconds,
fetch_limit: account.fetch_limit,
accept_invalid_certs: account.accept_invalid_certs,
enabled: account.enabled,
password_configured: configured(Some(&account.imap_password)),
last_checked_at: account.last_checked_at,
last_error: account.last_error,
created_at: account.created_at,
updated_at: account.updated_at,
}
}
// ── 辅助函数 ─────────────────────────────────────────────
fn configured_tools_path() -> PathBuf {
std::env::var("IA_TOOLS_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or_else(|| Path::new("."))
.join("tools")
})
}
fn is_yaml_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "yaml" | "yml"))
.unwrap_or(false)
}
fn file_label(path: &Path) -> String {
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_string()
}
fn configured(value: Option<&str>) -> bool {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
}
fn configured_label(value: bool) -> &'static str {
if value { "已配置" } else { "未配置" }
}
fn bool_label(value: bool) -> &'static str {
if value { "" } else { "" }
}
fn escape_optional(value: Option<&str>) -> String {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(escape_html)
.unwrap_or_else(|| "-".to_string())
}
fn format_naive_time(value: Option<NaiveDateTime>) -> String {
value
.map(|value| value.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "-".to_string())
}
fn format_utc_time(value: Option<DateTime<Utc>>) -> String {
value
.map(|value| value.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| "-".to_string())
}
fn api_internal_error(message: &str) -> impl Fn(anyhow::Error) -> ApiError + use<> {
let message = message.to_string();
move |error: anyhow::Error| -> ApiError {
eprintln!("{message}: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"success": false,
"message": message,
})),
)
}
}
fn html_internal_error(message: &str) -> impl Fn(anyhow::Error) -> HtmlError + use<> {
let message = message.to_string();
move |error: anyhow::Error| -> HtmlError {
eprintln!("{message}: {error}");
let body = format!(
r#"<h1 class="text-2xl font-bold">页面暂时不可用</h1>
<p class="mt-2 text-gray-500">{message},请稍后重试。</p>"#
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(html_shell("页面不可用", &body)),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_tools_include_query_capabilities() {
let tools = builtin_tool_items();
assert!(tools.iter().any(|tool| tool.name == "query_capabilities"));
}
#[test]
fn sanitize_wechat_account_does_not_expose_secret_values() {
let account = WechatAccount {
id: 1,
user_name: Some("测试账号".to_string()),
token: Some("secret-token".to_string()),
account_id: Some("account-1".to_string()),
base_url: Some("http://127.0.0.1".to_string()),
user_id: Some("user-1".to_string()),
updates_buf: Some("cursor".to_string()),
user_status: Some(1),
created_at: None,
};
let item = sanitize_wechat_account(account);
let json = serde_json::to_value(item).unwrap();
assert_eq!(json["token_configured"], true);
assert_eq!(json["updates_buf_present"], true);
assert!(json.get("token").is_none());
assert!(json.get("updates_buf").is_none());
}
#[test]
fn sanitize_mail_account_does_not_expose_password() {
let now = Utc::now();
let account = MailAccount {
id: 2,
name: "收件箱".to_string(),
source_key: "mail:test@example.com".to_string(),
imap_host: "imap.example.com".to_string(),
imap_port: 993,
imap_username: "test@example.com".to_string(),
imap_password: "secret-password".to_string(),
mailbox: "INBOX".to_string(),
notify_account_id: "wechat-1".to_string(),
notify_user_id: "user-1".to_string(),
poll_seconds: 60,
fetch_limit: 20,
accept_invalid_certs: false,
enabled: true,
last_checked_at: Some(now),
last_error: None,
created_at: now,
updated_at: now,
};
let item = sanitize_mail_account(account);
let json = serde_json::to_value(item).unwrap();
assert_eq!(json["password_configured"], true);
assert!(json.get("imap_password").is_none());
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
mod channel;
mod console;
mod core;
pub mod model;
mod repository;
@@ -108,7 +109,8 @@ pub async fn run_service(pool: PgPool) -> anyhow::Result<()> {
runtime.write_snapshot(&paths.state_file).await?;
let routes = ias_context::routes::routes::<()>(state.db.clone())
.merge(ias_mail::routes::routes::<()>(state.db.clone()));
.merge(ias_mail::routes::routes::<()>(state.db.clone()))
.merge(console::routes::<()>(state.db.clone()));
let result = create_http(host.clone(), shutdown_rx, state.db.clone(), routes).await;
runtime.mark_stopping().await;
runtime.write_snapshot(&paths.state_file).await?;