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
+90
View File
@@ -1,5 +1,95 @@
# 更新日志
## 0.2.8 - 2026-07-07
- 新增邮件已读/未读状态持久化,保存 IMAP `\Seen` 标记到 `ias_mail_messages.seen`
- 邮件列表页面新增状态筛选,支持查看全部、未读或已读邮件。
- 邮件列表卡片和详情页显示已读/未读状态,未读邮件在列表中使用更醒目的样式。
- 邮件 API `GET /v1/mail/messages` 支持 `seen=true/false` 过滤,并在返回的邮件对象中包含 `seen` 字段。
- 数据库新增 `ias_mail_messages.seen` 字段;已有历史邮件默认标记为未读,后续拉取最近邮件时会刷新为 IMAP 当前状态。
## 0.2.7 - 2026-07-07
- 修复 multipart/alternative 邮件详情页只展示纯文本 fallback 的问题;现在详情页优先保存和渲染 HTML 邮件体。
- 邮件列表摘要、搜索预览和微信通知继续使用纯文本正文;没有纯文本正文时会从 HTML 中提取简要文本,避免标签污染摘要。
- 邮件轮询或手动拉取遇到已缓存的重复邮件时,会刷新缓存正文和摘要,使最近抓取范围内的旧纯文本缓存可自动更新为 HTML 正文。
- 邮件详情 iframe 继续禁止脚本和表单,但允许加载 `http/https` 图片,使 Facebook 等 HTML 邮件更接近邮箱客户端渲染效果。
- 无数据库结构变化;不在最近抓取范围内的旧邮件仍需要扩大抓取范围或重新入库后才能显示 HTML 版本。
## 0.2.6 - 2026-07-07
- 新增工具列表 HTTP 页面 `/tools`,展示内置工具和 `tools/*.yaml` 工具定义。
- 新增工具列表 API `GET /v1/tools`,返回内置工具、YAML 工具、参数、环境变量名和工具目录加载告警。
- 新增用户配置列表 HTTP 页面 `/configs`,展示微信渠道账号和邮件账户配置。
- 新增用户配置列表 API `GET /v1/user-configs`,返回脱敏后的微信与邮件配置,不暴露微信 token、更新游标和邮件密码明文。
- 顶部导航新增“工具”和“配置”入口;无数据库结构变化,新增 `0.2.6` 升级日志记录。
## 0.2.5 - 2026-07-07
- 修复新增 SQL 迁移文件后后台服务可能继续使用旧二进制迁移列表的问题。
- `ias-service` 新增 `build.rs`,显式监听 `migrations` 目录和 SQL 文件变化,确保 `sqlx::migrate!` 重新嵌入最新迁移。
- 无数据库结构变化;新增 `0.2.5` 升级日志记录。
## 0.2.4 - 2026-07-07
- 新增 YAML 配置的邮件查询 HTTP 工具 `query_mail_messages`,通过 `/v1/mail/messages` 查询或搜索邮件列表。
- 工具支持关键词、邮箱目录、来源、页码和每页数量参数,返回分页信息和当前页邮件 JSON 列表。
- 无数据库结构变化;新增 `0.2.4` 升级日志记录。
## 0.2.3 - 2026-07-07
- 修复邮件自动通知漏发:后台轮询现在会对所有新入库邮件发送通知,不再因为 IMAP `\Seen` 已读标记跳过通知。
- 邮件轮询现在尊重账户配置的 `fetch_limit`,不再固定只检查最近 20 封邮件。
- 邮件轮询启动日志新增 `fetch_limit`,便于确认实际加载的抓取范围。
- 新增单元测试覆盖已读新邮件仍会被自动轮询处理,以及不同抓取上限下的 IMAP 序列范围计算。
## 0.2.2 - 2026-07-06
- 修复邮件详情页预览链接的 HTML 属性转义,避免异常 URL 破坏页面结构或注入属性。
- 修复长中文或 Emoji 邮件内容、Message-ID 截断时可能因 UTF-8 非字符边界切片导致的页面错误。
- 收紧 HTML 邮件 iframe 沙箱:移除同源权限,并通过 CSP 默认禁止外部资源、表单提交和脚本加载。
- 修复 Web 动态页面 404/500 错误页的响应类型,确保浏览器按 HTML 页面渲染。
- 补齐 `0.2.0` 数据库升级日志记录,并新增本版本升级日志记录。
- 新增针对邮件 HTML 安全渲染、Unicode 截断和 Web 错误页响应类型的单元测试。
## 0.2.1 - 2026-07-06
- **回滚为服务端渲染架构**:恢复 HTML 页面渲染,移除独立 React 前端项目
- **全面采用 Tailwind CSS**:通过 CDN 引入,统一所有页面样式
- 更新日志列表/详情:卡片布局,蓝色点缀
- 邮件列表:支持筛选表单、分页、Hover 高亮
- 邮件详情:详细信息折叠面板、键盘左右键导航
- Web 页面/Mail 提醒页面:元数据网格展示
- 提取公共 `shell` 模块:统一 HTML 布局、导航栏和 Tailwind CDN 引入
- 保留 JSON API 端点(`/v1/*`),供内部调用
- 首页 `/` 重定向到更新日志
## 0.2.0 - 2026-07-06
- **重大架构调整**:前后端分离,将 HTML 服务端渲染改为 RESTful JSON API + React 前端
- 新增 `ias-web` 前端项目,基于 Vite + React + TypeScript 构建
- 后端 API 路由调整:
- 更新日志接口改为 `/v1/updates`JSON
- 新增页面列表接口 `/v1/web/pages`GET
- Web 页面查看接口改为 `/v1/web/pages/{type}/{slug}`JSON
- 邮件页面查看接口改为 `/v1/web/mail/{slug}`JSON
- 移除所有 HTML 渲染端点(`/updates``/mail``/web/{type}/{slug}` 等)
- 后端添加 CORS 支持(tower-http
- 后端集成前端静态文件托管,`cargo build` 自动构建前端并嵌入服务
- 新增 `build.rs`:前端源文件变更自动触发 `npm build`,无变更时零开销跳过
- 支持 `SKIP_FRONTEND_BUILD``IA_STATIC_DIR` 环境变量
- React 前端支持页面:首页、更新日志列表/详情、邮件列表/详情、通知页面
- 前端开发代理配置自动转发 `/v1` API 请求到后端
## 0.1.15 - 2026-07-06
- 邮件预览页面全新设计:现代化卡片布局,顶部展示发件人、时间和来源等关键信息。
- 新增上一封/下一封快速导航,无需返回列表即可连续浏览邮件。
- 详细信息折叠面板,UID 和 Message-ID 支持一键复制。
- HTML 邮件自动检测并在沙箱 iframe 中渲染,纯文本邮件保持原有展示方式。
- 新增 `find_adjacent_ids` 仓库方法,按 ID 查询相邻邮件记录。
- 无数据库结构变化;新增 `0.1.15` 升级日志记录。
## 0.1.14 - 2026-07-06
- 删除旧邮件查询兼容路由 `GET /mail-api/messages``GET /mail-api/messages/{id}``GET /mail-api/search`
Generated
+6 -5
View File
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "ias-ai"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"anyhow",
"chrono",
@@ -1080,7 +1080,7 @@ dependencies = [
[[package]]
name = "ias-http"
version = "0.1.1"
version = "0.1.4"
dependencies = [
"anyhow",
"axum",
@@ -1089,12 +1089,13 @@ dependencies = [
"serde_json",
"sqlx",
"tokio",
"tower-http",
"uuid",
]
[[package]]
name = "ias-mail"
version = "0.1.7"
version = "0.1.13"
dependencies = [
"anyhow",
"async-imap",
@@ -1113,7 +1114,7 @@ dependencies = [
[[package]]
name = "ias-main"
version = "0.1.14"
version = "0.2.8"
dependencies = [
"anyhow",
"clap",
@@ -1128,7 +1129,7 @@ dependencies = [
[[package]]
name = "ias-service"
version = "0.1.1"
version = "0.1.7"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1 +1 @@
0.1.14
0.2.8
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-ai"
version = "0.1.1"
version = "0.1.2"
edition = "2024"
[dependencies]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-http"
version = "0.1.1"
version = "0.1.4"
edition = "2024"
[dependencies]
@@ -16,4 +16,5 @@ sqlx = { version = "0.9.0", features = [
"chrono",
] }
tokio = { version = "1.0", features = ["net", "sync"] }
tower-http = { version = "0.6", features = ["cors"] }
uuid = { version = "1", features = ["v4", "serde"] }
+15 -1
View File
@@ -1,7 +1,9 @@
use axum::response::Redirect;
use axum::routing::get;
use axum::{Json, Router};
use sqlx::PgPool;
use tokio::sync::watch;
use tower_http::cors::{Any, CorsLayer};
use crate::updates::routes as update_routes;
use crate::web::routes as web_routes;
@@ -12,11 +14,19 @@ pub async fn create_http(
db: PgPool,
extra_routes: Router,
) -> anyhow::Result<()> {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/", get(home))
.route("/v1/health", get(health))
.merge(update_routes::<()>(db.clone()))
.merge(web_routes::routes::<()>(db))
.merge(extra_routes);
.merge(extra_routes)
.layer(cors);
let listener = tokio::net::TcpListener::bind(&host).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::AddrInUse {
anyhow::anyhow!(
@@ -40,6 +50,10 @@ pub async fn create_http(
Ok(())
}
async fn home() -> Redirect {
Redirect::permanent("/updates")
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"success": true,
+1
View File
@@ -1,4 +1,5 @@
pub mod app;
pub mod shell;
pub mod updates;
pub mod web;
+50
View File
@@ -0,0 +1,50 @@
/// 公共 HTML shellTailwind CSS CDN
pub fn html_shell(title: &str, body: &str) -> String {
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif; }}
</style>
</head>
<body class="bg-gray-100 min-h-screen text-gray-900">
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-gray-200">
<div class="max-w-5xl mx-auto px-5 h-[52px] flex items-center justify-between">
<a href="/" class="text-xl font-bold text-gray-900 no-underline tracking-tight">iAs</a>
<nav class="flex gap-1">
<a href="/updates" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">更新日志</a>
<a href="/mail" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">邮件</a>
<a href="/tools" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">工具</a>
<a href="/configs" class="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition">配置</a>
</nav>
</div>
</header>
<main class="max-w-5xl mx-auto px-5 py-6 pb-16">
{body}
</main>
</body>
</html>"#,
title = escape_html(title),
body = body
)
}
pub fn escape_html(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
+63 -169
View File
@@ -8,6 +8,8 @@ use serde_json::Value;
use sqlx::PgPool;
use sqlx::prelude::FromRow;
use crate::shell::{escape_html, html_shell};
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
@@ -81,221 +83,113 @@ async fn find_upgrade_log(pool: &PgPool, version: &str) -> anyhow::Result<Option
Ok(row)
}
// ── 列表页 ──────────────────────────────────────────────
fn render_updates_page(logs: &[UpgradeLog]) -> String {
let items = logs
let items: String = logs
.iter()
.map(|log| {
format!(
r#"<li><a href="/updates/{version}">v{version}</a><span>{date}</span><strong>{title}</strong><p>{description}</p></li>"#,
r#"<a href="/updates/{version}" class="block bg-white rounded-lg border border-gray-200 p-5 hover:border-blue-400 hover:shadow-md transition">
<div class="flex items-center justify-between mb-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700">v{version}</span>
<span class="text-xs text-gray-400">{date}</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-1">{title}</h3>
<p class="text-sm text-gray-500">{description}</p>
</a>"#,
version = escape_html(&log.version),
date = escape_html(&format_date(log.released_at)),
title = escape_html(&log.title),
description = escape_html(&log.description),
)
})
.collect::<Vec<_>>()
.join("");
.collect();
render_shell(
"更新日志",
r#"<p class="summary">服务版本更新记录。</p>"#,
&format!(r#"<ol class="updates">{items}</ol>"#),
)
let body = format!(
r#"<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight">更新日志</h1>
<p class="mt-1.5 text-gray-500">服务版本更新记录</p>
</div>
<div class="flex flex-col gap-3">
{items}
</div>"#,
items = items,
);
html_shell("更新日志", &body)
}
// ── 详情页 ──────────────────────────────────────────────
fn render_update_page(log: &UpgradeLog) -> String {
let changes = render_changes(&log.changes);
let body = format!(
r#"<p class="summary">{description}</p>
<section class="meta">
r#"<a href="/updates" class="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6 transition">← 返回更新列表</a>
<div class="mb-6">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 mb-3">更新日志</span>
<h1 class="text-2xl font-bold tracking-tight">v{version} {title}</h1>
<p class="mt-1.5 text-gray-500">{description}</p>
</div>
<div class="flex flex-wrap gap-x-6 gap-y-2 mb-6 text-sm text-gray-400">
<span>版本:v{version}</span>
<span>发布时间:{date}</span>
</section>
<section class="panel">
<h2>变更内容</h2>
</div>
<section class="bg-white rounded-lg border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-3">变更内容</h2>
{changes}
</section>
<p class="back"><a href="/updates">查看全部更新</a></p>"#,
description = escape_html(&log.description),
</section>"#,
version = escape_html(&log.version),
title = escape_html(&log.title),
description = escape_html(&log.description),
date = escape_html(&format_date(log.released_at)),
changes = changes,
);
render_shell(&format!("v{} {}", log.version, log.title), "", &body)
html_shell(&format!("v{} {}", log.version, log.title), &body)
}
fn render_changes(value: &Value) -> String {
let Some(items) = value.as_array() else {
return format!("<pre>{}</pre>", escape_html(&value.to_string()));
return format!(
r#"<pre class="text-sm text-gray-700 whitespace-pre-wrap">{}</pre>"#,
escape_html(&value.to_string())
);
};
let items = items
let items: String = items
.iter()
.filter_map(Value::as_str)
.map(|item| format!("<li>{}</li>", escape_html(item)))
.collect::<Vec<_>>()
.join("");
format!("<ul>{items}</ul>")
}
.map(|item| format!(r#"<li class="ml-4 list-disc">{}</li>"#, escape_html(item)))
.collect();
fn render_shell(title: &str, intro: &str, body: &str) -> String {
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #1f2328;
}}
body {{
margin: 0;
min-height: 100vh;
background: #f6f7f9;
}}
main {{
width: min(880px, calc(100% - 32px));
margin: 0 auto;
padding: 40px 0 56px;
}}
h1 {{
margin: 0 0 12px;
font-size: 34px;
line-height: 1.18;
letter-spacing: 0;
}}
h2 {{
margin: 0 0 14px;
font-size: 20px;
line-height: 1.3;
letter-spacing: 0;
}}
.summary {{
margin: 0 0 22px;
color: #57606a;
font-size: 16px;
line-height: 1.7;
}}
.meta {{
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin: 0 0 24px;
color: #57606a;
font-size: 14px;
}}
.panel {{
border: 1px solid #d8dee4;
border-radius: 8px;
background: #ffffff;
padding: 22px 24px;
}}
ul {{
margin: 0;
padding-left: 22px;
line-height: 1.8;
}}
.updates {{
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 12px;
}}
.updates li {{
border: 1px solid #d8dee4;
border-radius: 8px;
background: #ffffff;
padding: 18px 20px;
}}
.updates a {{
display: inline-block;
margin-right: 12px;
color: #0969da;
font-weight: 700;
text-decoration: none;
}}
.updates span {{
color: #6e7781;
font-size: 13px;
}}
.updates strong {{
display: block;
margin-top: 8px;
font-size: 18px;
line-height: 1.35;
}}
.updates p {{
margin: 8px 0 0;
color: #57606a;
line-height: 1.65;
}}
.back {{
margin: 18px 0 0;
}}
.back a {{
color: #0969da;
text-decoration: none;
}}
</style>
</head>
<body>
<main>
<h1>{title}</h1>
{intro}
{body}
</main>
</body>
</html>"#,
title = escape_html(title),
intro = intro,
body = body,
)
format!(r#"<ul class="space-y-1.5">{items}</ul>"#)
}
fn format_date(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M:%S UTC").to_string()
value.format("%Y-%m-%d %H:%M UTC").to_string()
}
fn html_not_found() -> HtmlError {
let body = r#"<h1 class="text-2xl font-bold">更新日志不存在</h1>
<p class="mt-2 text-gray-500">没有找到对应版本的更新日志。</p>
<p class="mt-4"><a href="/updates" class="text-blue-600 hover:text-blue-800">查看全部更新</a></p>"#;
(
StatusCode::NOT_FOUND,
Html(render_shell(
"更新日志不存在",
r#"<p class="summary">没有找到对应版本的更新日志。</p>"#,
r#"<p class="back"><a href="/updates">查看全部更新</a></p>"#,
)),
Html(html_shell("更新日志不存在", body)),
)
}
fn html_internal_error(error: anyhow::Error) -> HtmlError {
eprintln!("读取更新日志失败: {error}");
let body = r#"<h1 class="text-2xl font-bold">更新日志暂时不可用</h1>
<p class="mt-2 text-gray-500">读取更新日志失败,请稍后重试。</p>"#;
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(render_shell(
"更新日志暂时不可用",
r#"<p class="summary">读取更新日志失败。</p>"#,
"",
)),
Html(html_shell("更新日志不可用", body)),
)
}
fn escape_html(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
+50
View File
@@ -53,4 +53,54 @@ impl WebPageRepository {
Ok(row)
}
pub async fn list_pages(
pool: &PgPool,
page_type: Option<&str>,
limit: i64,
offset: i64,
) -> anyhow::Result<(Vec<WebPage>, i64)> {
let total: i64 = if let Some(pt) = page_type {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_web_pages WHERE page_type = $1"#)
.bind(pt)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_web_pages"#)
.fetch_one(pool)
.await?
};
let rows = if let Some(pt) = page_type {
sqlx::query_as::<_, WebPage>(
r#"
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
FROM ias_web_pages
WHERE page_type = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(pt)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
} else {
sqlx::query_as::<_, WebPage>(
r#"
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
FROM ias_web_pages
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
};
Ok((rows, total))
}
}
+249 -269
View File
@@ -1,12 +1,14 @@
use axum::extract::Path;
use axum::extract::{Path, Query};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Html;
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::PgPool;
use uuid::Uuid;
use crate::shell::{escape_html, html_shell};
use crate::web::model::{
CreateMailPageRequest, CreateWebPageRequest, CreateWebPageResponse, NewWebPage, WebPage,
};
@@ -26,18 +28,181 @@ impl WebRouteState {
}
}
#[derive(Debug, Deserialize)]
pub struct PageListQuery {
pub page_type: Option<String>,
pub page: Option<i64>,
pub per_page: Option<i64>,
}
pub fn routes<S>(db: PgPool) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/v1/web/pages", post(create_page))
// JSON API(内部调用用)
.route("/v1/web/pages", get(list_pages).post(create_page))
.route("/v1/web/mail", post(create_mail_page))
.route("/mail/{slug}", get(show_mail_page))
.route("/v1/web/pages/{page_type}/{slug}", get(get_page))
.route("/v1/web/mail/{slug}", get(get_mail_page))
// HTML 页面(用户浏览用)
.route("/web/{page_type}/{slug}", get(show_typed_page))
.route("/mail/{slug}", get(show_mail_page))
.layer(Extension(WebRouteState::new(db)))
}
// ── HTML 页面 ────────────────────────────────────────────
async fn show_mail_page(
Extension(state): Extension<WebRouteState>,
Path(slug): Path<String>,
) -> Result<Html<String>, HtmlError> {
let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug)
.await
.map_err(html_internal_error)?
.ok_or_else(html_not_found)?;
Ok(Html(render_page_html(&page)))
}
async fn show_typed_page(
Extension(state): Extension<WebRouteState>,
Path((page_type, slug)): Path<(String, String)>,
) -> Result<Html<String>, HtmlError> {
let page_type = normalize_key(&page_type, "page_type").map_err(|_| html_not_found())?;
let page = WebPageRepository::find_by_type_and_slug(&state.db, &page_type, &slug)
.await
.map_err(html_internal_error)?
.ok_or_else(html_not_found)?;
Ok(Html(render_page_html(&page)))
}
fn render_page_html(page: &WebPage) -> String {
let summary = page
.summary
.as_deref()
.map(|s| {
format!(
r#"<p class="text-gray-500 text-base mt-1.5">{}</p>"#,
escape_html(s)
)
})
.unwrap_or_default();
let source = page
.source_label
.as_deref()
.map(|s| {
format!(
r#"<span class="text-gray-400 text-sm">来源:{}</span>"#,
escape_html(s)
)
})
.unwrap_or_default();
let created = page
.created_at
.map(|t| {
format!(
"<span class=\"text-gray-400 text-sm\">创建时间:{}</span>",
t.format("%Y-%m-%d %H:%M:%S")
)
})
.unwrap_or_default();
let metadata_html = render_metadata_html(&page.metadata);
let content_html = render_text_content(&page.content);
let body = format!(
r#"<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 mb-3">{page_type}</span>
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
{summary}
<div class="flex flex-wrap gap-x-5 gap-y-1 mt-3 mb-6">{source}{created}</div>
{metadata_html}
<article class="bg-white rounded-lg border border-gray-200 p-6 text-base leading-relaxed whitespace-normal break-words">{content_html}</article>"#,
page_type = escape_html(&page.page_type),
title = escape_html(&page.title),
summary = summary,
source = source,
created = created,
metadata_html = metadata_html,
content_html = content_html,
);
html_shell(&page.title, &body)
}
fn render_metadata_html(metadata: &str) -> String {
let Ok(Value::Object(values)) = serde_json::from_str::<Value>(metadata) else {
return String::new();
};
let items: String = values
.into_iter()
.filter_map(|(key, value)| {
let val = metadata_value_to_text(value)?;
Some(format!(
r#"<dt class="text-gray-400 text-sm">{}</dt><dd class="text-gray-700 text-sm break-all">{}</dd>"#,
escape_html(metadata_label(&key)),
escape_html(&val),
))
})
.collect();
if items.is_empty() {
String::new()
} else {
format!(
r#"<section class="bg-white rounded-lg border border-gray-200 mb-5">
<dl class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 p-4">{items}</dl>
</section>"#
)
}
}
fn metadata_label(key: &str) -> &str {
match key {
"from" => "发件人",
"subject" => "主题",
"message_id" => "邮件 ID",
"mailbox" => "邮箱目录",
"uid" => "UID",
"reminder" => "提醒",
"received_at" => "收件时间",
other => other,
}
}
fn metadata_value_to_text(value: Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => {
let s = s.trim().to_string();
(!s.is_empty()).then_some(s)
}
Value::Array(v) if v.is_empty() => None,
Value::Object(v) if v.is_empty() => None,
other => Some(other.to_string()),
}
}
fn render_text_content(content: &str) -> String {
content
.lines()
.map(|line| {
if line.trim().is_empty() {
"<br>".to_string()
} else {
escape_html(line)
}
})
.collect::<Vec<_>>()
.join("<br>\n")
}
// ── JSON API ─────────────────────────────────────────────
async fn create_page(
Extension(state): Extension<WebRouteState>,
headers: HeaderMap,
@@ -104,31 +269,66 @@ async fn create_mail_page(
Ok(Json(response_for_page(&page, &headers)))
}
async fn show_mail_page(
async fn list_pages(
Extension(state): Extension<WebRouteState>,
Path(slug): Path<String>,
) -> Result<Html<String>, HtmlError> {
let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug)
.await
.map_err(html_internal_error)?
.ok_or_else(html_not_found)?;
Query(query): Query<PageListQuery>,
) -> Result<Json<Value>, ApiError> {
let page_type = query
.page_type
.map(|v| normalize_key(&v, "page_type"))
.transpose()?;
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * per_page;
Ok(Html(render_page(&page)))
let (pages, total) =
WebPageRepository::list_pages(&state.db, page_type.as_deref(), per_page, offset)
.await
.map_err(api_internal_error)?;
Ok(Json(json!({
"success": true,
"page": page,
"per_page": per_page,
"total": total,
"pages": pages,
})))
}
async fn show_typed_page(
async fn get_page(
Extension(state): Extension<WebRouteState>,
Path((page_type, slug)): Path<(String, String)>,
) -> Result<Html<String>, HtmlError> {
let page_type = normalize_key(&page_type, "page_type").map_err(|_| html_not_found())?;
) -> Result<Json<Value>, ApiError> {
let page_type =
normalize_key(&page_type, "page_type").map_err(|_| api_not_found("页面不存在"))?;
let page = WebPageRepository::find_by_type_and_slug(&state.db, &page_type, &slug)
.await
.map_err(html_internal_error)?
.ok_or_else(html_not_found)?;
.map_err(api_internal_error)?
.ok_or_else(|| api_not_found("页面不存在"))?;
Ok(Html(render_page(&page)))
Ok(Json(json!({
"success": true,
"page": &page,
})))
}
async fn get_mail_page(
Extension(state): Extension<WebRouteState>,
Path(slug): Path<String>,
) -> Result<Json<Value>, ApiError> {
let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug)
.await
.map_err(api_internal_error)?
.ok_or_else(|| api_not_found("页面不存在"))?;
Ok(Json(json!({
"success": true,
"page": &page,
})))
}
// ── 辅助函数 ─────────────────────────────────────────────
fn response_for_page(page: &WebPage, headers: &HeaderMap) -> CreateWebPageResponse {
let path = public_path(page);
let url = format!("{}{}", request_base_url(headers), path);
@@ -237,235 +437,6 @@ fn normalize_key(value: &str, field: &str) -> Result<String, ApiError> {
Ok(normalized)
}
fn render_page(page: &WebPage) -> String {
let summary = page
.summary
.as_deref()
.map(|summary| format!(r#"<p class="summary">{}</p>"#, escape_html(summary)))
.unwrap_or_default();
let source = page
.source_label
.as_deref()
.map(|source| {
format!(
r#"<span class="meta-item">来源:{}</span>"#,
escape_html(source)
)
})
.unwrap_or_default();
let created_at = page
.created_at
.map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "-".to_string());
let metadata = render_metadata(&page.metadata);
let content = render_text_content(&page.content);
let page_type = escape_html(&page.page_type);
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #1f2328;
}}
body {{
margin: 0;
min-height: 100vh;
background: #f6f7f9;
}}
main {{
width: min(880px, calc(100% - 32px));
margin: 0 auto;
padding: 40px 0 56px;
}}
.eyebrow {{
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid #d8dee4;
border-radius: 999px;
background: #ffffff;
color: #57606a;
font-size: 13px;
line-height: 1;
}}
h1 {{
margin: 18px 0 10px;
font-size: 32px;
line-height: 1.18;
font-weight: 720;
letter-spacing: 0;
}}
.summary {{
margin: 0 0 18px;
color: #57606a;
font-size: 17px;
line-height: 1.65;
}}
.meta {{
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0 0 28px;
color: #6e7781;
font-size: 14px;
}}
.content {{
border: 1px solid #d8dee4;
border-radius: 8px;
background: #ffffff;
padding: 24px;
font-size: 16px;
line-height: 1.8;
white-space: normal;
overflow-wrap: anywhere;
}}
.details {{
margin: 24px 0;
border-top: 1px solid #d8dee4;
border-bottom: 1px solid #d8dee4;
padding: 14px 0;
}}
dl {{
display: grid;
grid-template-columns: minmax(88px, 160px) 1fr;
gap: 10px 16px;
margin: 0;
font-size: 14px;
line-height: 1.6;
}}
dt {{
color: #6e7781;
}}
dd {{
margin: 0;
color: #24292f;
overflow-wrap: anywhere;
}}
@media (max-width: 640px) {{
main {{
width: min(100% - 24px, 880px);
padding-top: 28px;
}}
.content {{
padding: 18px;
}}
dl {{
grid-template-columns: 1fr;
gap: 2px 0;
}}
}}
</style>
</head>
<body>
<main>
<span class="eyebrow">{page_type}</span>
<h1>{title}</h1>
{summary}
<div class="meta">{source}<span class="meta-item">创建时间:{created_at}</span></div>
{metadata}
<article class="content">{content}</article>
</main>
</body>
</html>"#,
title = escape_html(&page.title),
summary = summary,
source = source,
created_at = escape_html(&created_at),
metadata = metadata,
content = content,
page_type = page_type
)
}
fn render_metadata(metadata: &str) -> String {
let Ok(Value::Object(values)) = serde_json::from_str::<Value>(metadata) else {
return String::new();
};
let items = values
.into_iter()
.filter_map(|(key, value)| {
let value = metadata_value_to_text(value)?;
Some(format!(
"<dt>{}</dt><dd>{}</dd>",
escape_html(metadata_label(&key)),
escape_html(&value)
))
})
.collect::<Vec<_>>()
.join("");
if items.is_empty() {
String::new()
} else {
format!(r#"<section class="details"><dl>{items}</dl></section>"#)
}
}
fn metadata_label(key: &str) -> &str {
match key {
"from" => "发件人",
"subject" => "主题",
"message_id" => "邮件 ID",
"mailbox" => "邮箱目录",
"uid" => "UID",
"reminder" => "提醒",
"received_at" => "收件时间",
other => other,
}
}
fn metadata_value_to_text(value: Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(value) => {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}
Value::Array(values) if values.is_empty() => None,
Value::Object(values) if values.is_empty() => None,
other => Some(other.to_string()),
}
}
fn render_text_content(content: &str) -> String {
content
.lines()
.map(|line| {
if line.trim().is_empty() {
"<br>".to_string()
} else {
escape_html(line)
}
})
.collect::<Vec<_>>()
.join("<br>\n")
}
fn escape_html(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
fn api_bad_request(message: impl Into<String>) -> ApiError {
(
StatusCode::BAD_REQUEST,
@@ -476,13 +447,23 @@ fn api_bad_request(message: impl Into<String>) -> ApiError {
)
}
fn api_not_found(message: impl Into<String>) -> ApiError {
(
StatusCode::NOT_FOUND,
Json(json!({
"success": false,
"message": message.into(),
})),
)
}
fn api_internal_error(error: anyhow::Error) -> ApiError {
eprintln!("创建 web 页面失败: {error}");
eprintln!("web 接口失败: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"success": false,
"message": "创建 web 页面失败",
"message": "web 接口失败",
})),
)
}
@@ -490,7 +471,10 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
fn html_not_found() -> HtmlError {
(
StatusCode::NOT_FOUND,
Html(render_error_page("页面不存在", "没有找到对应的内容页面")),
Html(html_shell(
"页面不存在",
r#"<h1 class="text-2xl font-bold">页面不存在</h1><p class="mt-2 text-gray-500">没有找到对应的内容页面</p>"#,
)),
)
}
@@ -498,27 +482,23 @@ fn html_internal_error(error: anyhow::Error) -> HtmlError {
eprintln!("读取 web 页面失败: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(render_error_page("页面暂时不可用", "读取页面内容失败")),
Html(html_shell(
"页面暂时不可用",
r#"<h1 class="text-2xl font-bold">页面暂时不可用</h1><p class="mt-2 text-gray-500">读取页面内容失败,请稍后重试</p>"#,
)),
)
}
fn render_error_page(title: &str, message: &str) -> String {
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
</head>
<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328;">
<main style="width:min(760px,calc(100% - 32px));margin:0 auto;padding:48px 0;">
<h1 style="font-size:32px;line-height:1.2;margin:0 0 12px;">{title}</h1>
<p style="font-size:16px;line-height:1.7;margin:0;color:#57606a;">{message}</p>
</main>
</body>
</html>"#,
title = escape_html(title),
message = escape_html(message)
)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_not_found_returns_html_response() {
let (status, Html(body)) = html_not_found();
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("<!doctype html>"));
assert!(body.contains("页面不存在"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-mail"
version = "0.1.7"
version = "0.1.13"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -20,7 +20,7 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
let notify_account_id = prompt_required("通知使用的微信 account_id", None)?;
let notify_user_id = prompt_required("通知接收人的 from_user_id", None)?;
let poll_seconds = prompt_i32("轮询间隔秒数", 60, 10, 86400)?;
let fetch_limit = prompt_i32("单次最多抓取邮件数", 10, 1, 100)?;
let fetch_limit = prompt_i32("单次最多抓取邮件数", 20, 1, 100)?;
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", false)?;
let enabled = prompt_bool("是否启用该邮件账户", true)?;
+3
View File
@@ -55,6 +55,7 @@ pub struct MailMessage {
pub content: String,
pub content_preview: String,
pub raw_headers: Option<String>,
pub seen: bool,
pub preview_page_id: Option<i64>,
pub preview_url: Option<String>,
pub received_at: Option<DateTime<Utc>>,
@@ -75,6 +76,7 @@ pub struct NewMailMessage {
pub content: String,
pub content_preview: String,
pub raw_headers: Option<String>,
pub seen: bool,
pub received_at: Option<DateTime<Utc>>,
pub raw_size: i64,
}
@@ -95,4 +97,5 @@ pub struct MailListQuery {
pub account_id: Option<i64>,
pub mailbox: Option<String>,
pub source_key: Option<String>,
pub seen: Option<bool>,
}
+157 -31
View File
@@ -16,7 +16,7 @@ use crate::config::MailPollerConfig;
use crate::model::{MailMessage, MailNotification, NewMailMessage};
use crate::repository::MailRepository;
const RECENT_MAIL_FETCH_LIMIT: u32 = 20;
const AUTOMATIC_POLL_UNREAD_ONLY: bool = false;
#[derive(Debug, Clone)]
struct FetchedMail {
@@ -27,6 +27,7 @@ struct FetchedMail {
subject: String,
summary: String,
content: String,
preview_content: String,
raw_headers: Option<String>,
received_at: Option<DateTime<Utc>>,
raw_size: i64,
@@ -48,12 +49,13 @@ pub fn start_mail_polling(
) -> JoinHandle<()> {
tokio::spawn(async move {
println!(
"邮件轮询已启动: {} {}@{} mailbox={} interval={}s",
"邮件轮询已启动: {} {}@{} mailbox={} interval={}s fetch_limit={}",
config.name,
config.username,
config.host,
config.mailbox,
config.poll_interval.as_secs()
config.poll_interval.as_secs(),
config.fetch_limit
);
loop {
@@ -79,7 +81,13 @@ async fn poll_once(
config: &MailPollerConfig,
notifications: &Sender<MailNotification>,
) -> anyhow::Result<()> {
pull_recent_mail_with_notifications(pool, config, Some(notifications), true).await?;
pull_recent_mail_with_notifications(
pool,
config,
Some(notifications),
AUTOMATIC_POLL_UNREAD_ONLY,
)
.await?;
Ok(())
}
@@ -106,30 +114,31 @@ async fn pull_recent_mail_with_notifications(
};
for fetched in messages {
if unread_only && fetched.seen {
if should_skip_fetched_message(fetched.seen, unread_only) {
continue;
}
let Some(inserted) = MailRepository::insert_if_new(
pool,
NewMailMessage {
mail_account_id: config.mail_account_id,
source_key: config.source_key.clone(),
mailbox: config.mailbox.clone(),
uid: fetched.uid.clone(),
message_id: fetched.message_id.clone(),
from_label: fetched.from_label.clone(),
subject: fetched.subject.clone(),
summary: fetched.summary.clone(),
content: fetched.content.clone(),
content_preview: preview_text(&fetched.content, 4000),
raw_headers: fetched.raw_headers.clone(),
received_at: fetched.received_at,
raw_size: fetched.raw_size,
},
)
.await?
else {
let message = NewMailMessage {
mail_account_id: config.mail_account_id,
source_key: config.source_key.clone(),
mailbox: config.mailbox.clone(),
uid: fetched.uid.clone(),
message_id: fetched.message_id.clone(),
from_label: fetched.from_label.clone(),
subject: fetched.subject.clone(),
summary: fetched.summary.clone(),
content: fetched.content.clone(),
content_preview: preview_text(&fetched.preview_content, 4000),
raw_headers: fetched.raw_headers.clone(),
seen: fetched.seen,
received_at: fetched.received_at,
raw_size: fetched.raw_size,
};
let Some(inserted) = MailRepository::insert_if_new(pool, message.clone()).await? else {
if let Err(error) = MailRepository::refresh_cached_message(pool, message).await {
eprintln!("刷新已缓存邮件失败 uid={}: {error:#}", fetched.uid);
}
result.duplicates += 1;
continue;
};
@@ -189,7 +198,8 @@ async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
.await
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
let Some(sequence_set) = recent_sequence_set(mailbox.exists, RECENT_MAIL_FETCH_LIMIT) else {
let fetch_limit = config.fetch_limit.clamp(1, 100) as u32;
let Some(sequence_set) = recent_sequence_set(mailbox.exists, fetch_limit) else {
let _ = session.logout().await;
return Ok(Vec::new());
};
@@ -232,6 +242,10 @@ fn recent_sequence_set(exists: u32, limit: u32) -> Option<String> {
Some(format!("{start}:{exists}"))
}
fn should_skip_fetched_message(seen: bool, unread_only: bool) -> bool {
unread_only && seen
}
fn is_seen(fetch: &Fetch) -> bool {
fetch.flags().any(|flag| matches!(flag, Flag::Seen))
}
@@ -277,12 +291,21 @@ fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedM
.filter(|value| !value.is_empty())
.unwrap_or("(无主题)")
.to_string();
let content = parsed
let text_body = parsed
.body_text(0)
.or_else(|| parsed.body_html(0))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| !value.is_empty());
let html_body = parsed
.body_html(0)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let content = html_body
.clone()
.or_else(|| text_body.clone())
.unwrap_or_else(|| "(邮件正文为空或暂不支持解析)".to_string());
let preview_content = text_body
.clone()
.unwrap_or_else(|| html_to_preview_text(&content));
let from_label = header_value(raw, "From").unwrap_or_else(|| "(未知发件人)".to_string());
let message_id = header_value(raw, "Message-ID");
let raw_headers = raw_headers(raw);
@@ -290,7 +313,7 @@ fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedM
.date()
.and_then(|date| DateTime::parse_from_rfc3339(&date.to_rfc3339()).ok())
.map(|time| time.with_timezone(&Utc));
let summary = summarize_mail(&from_label, &subject, &content);
let summary = summarize_mail(&from_label, &subject, &preview_content);
Ok(FetchedMail {
uid,
@@ -300,6 +323,7 @@ fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedM
subject,
summary,
content,
preview_content,
raw_headers,
received_at,
raw_size: raw.len() as i64,
@@ -370,6 +394,61 @@ fn preview_text(content: &str, max_chars: usize) -> String {
value
}
fn html_to_preview_text(content: &str) -> String {
let mut text = String::with_capacity(content.len());
let mut in_tag = false;
let mut entity = String::new();
for ch in content.chars() {
if in_tag {
if ch == '>' {
in_tag = false;
text.push(' ');
}
continue;
}
if ch == '<' {
in_tag = true;
text.push(' ');
continue;
}
if !entity.is_empty() {
entity.push(ch);
if ch == ';' || entity.len() > 12 {
text.push_str(decode_html_entity(&entity).unwrap_or(&entity));
entity.clear();
}
continue;
}
if ch == '&' {
entity.push(ch);
} else {
text.push(ch);
}
}
if !entity.is_empty() {
text.push_str(&entity);
}
text
}
fn decode_html_entity(entity: &str) -> Option<&'static str> {
match entity {
"&amp;" => Some("&"),
"&lt;" => Some("<"),
"&gt;" => Some(">"),
"&quot;" => Some("\""),
"&#39;" | "&apos;" => Some("'"),
"&nbsp;" => Some(" "),
_ => None,
}
}
fn header_value(raw: &[u8], name: &str) -> Option<String> {
let raw = String::from_utf8_lossy(raw);
let header_block = split_headers(raw.as_ref());
@@ -424,7 +503,10 @@ fn split_headers(raw: &str) -> &str {
#[cfg(test)]
mod tests {
use super::{header_value, preview_text, recent_sequence_set, summarize_mail};
use super::{
AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, parse_message,
preview_text, recent_sequence_set, should_skip_fetched_message, summarize_mail,
};
#[test]
fn builds_recent_sequence_set_for_latest_messages() {
@@ -432,6 +514,18 @@ mod tests {
assert_eq!(recent_sequence_set(8, 20).as_deref(), Some("1:8"));
assert_eq!(recent_sequence_set(20, 20).as_deref(), Some("1:20"));
assert_eq!(recent_sequence_set(25, 20).as_deref(), Some("6:25"));
assert_eq!(recent_sequence_set(25, 10).as_deref(), Some("16:25"));
}
#[test]
fn automatic_polling_processes_new_messages_even_when_seen() {
assert!(!AUTOMATIC_POLL_UNREAD_ONLY);
assert!(!should_skip_fetched_message(
true,
AUTOMATIC_POLL_UNREAD_ONLY
));
assert!(should_skip_fetched_message(true, true));
assert!(!should_skip_fetched_message(false, true));
}
#[test]
@@ -456,4 +550,36 @@ mod tests {
assert!(summary.contains("Hello"));
assert!(summary.contains("first line second line"));
}
#[test]
fn prefers_html_body_for_display_and_text_body_for_preview() {
let raw = concat!(
"From: Facebook <reminders@facebookmail.com>\r\n",
"Subject: Friend suggestion\r\n",
"Content-Type: multipart/alternative; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Plain fallback\r\n",
"--b\r\n",
"Content-Type: text/html; charset=utf-8\r\n",
"\r\n",
"<html><body><table><tr><td>Rich HTML</td></tr></table></body></html>\r\n",
"--b--\r\n"
);
let message = parse_message("1".to_string(), false, raw.as_bytes()).unwrap();
assert!(message.content.contains("<table>"));
assert_eq!(message.preview_content, "Plain fallback");
assert!(message.summary.contains("Plain fallback"));
}
#[test]
fn strips_html_for_preview_when_plain_body_is_missing() {
let preview = html_to_preview_text("<div>Hello&nbsp;<strong>world</strong></div>");
assert_eq!(preview_text(&preview, 100), "Hello world");
}
}
+108 -2
View File
@@ -209,10 +209,11 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
received_at,
raw_size
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (source_key, mailbox, uid) DO NOTHING
RETURNING
id,
@@ -227,6 +228,7 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
@@ -245,6 +247,68 @@ impl MailRepository {
.bind(message.content)
.bind(message.content_preview)
.bind(message.raw_headers)
.bind(message.seen)
.bind(message.received_at)
.bind(message.raw_size)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn refresh_cached_message(
pool: &PgPool,
message: NewMailMessage,
) -> anyhow::Result<Option<MailMessage>> {
let row = sqlx::query_as::<_, MailMessage>(
r#"
UPDATE ias_mail_messages
SET
mail_account_id = $4,
message_id = $5,
from_label = $6,
subject = $7,
summary = $8,
content = $9,
content_preview = $10,
raw_headers = $11,
seen = $12,
received_at = $13,
raw_size = $14
WHERE source_key = $1 AND mailbox = $2 AND uid = $3
RETURNING
id,
mail_account_id,
source_key,
mailbox,
uid,
message_id,
from_label,
subject,
summary,
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
raw_size,
created_at
"#,
)
.bind(message.source_key)
.bind(message.mailbox)
.bind(message.uid)
.bind(message.mail_account_id)
.bind(message.message_id)
.bind(message.from_label)
.bind(message.subject)
.bind(message.summary)
.bind(message.content)
.bind(message.content_preview)
.bind(message.raw_headers)
.bind(message.seen)
.bind(message.received_at)
.bind(message.raw_size)
.fetch_optional(pool)
@@ -277,6 +341,7 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
@@ -309,6 +374,7 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
@@ -345,6 +411,7 @@ impl MailRepository {
($1::BIGINT IS NULL OR mail_account_id = $1)
AND ($2::TEXT IS NULL OR mailbox = $2)
AND ($3::TEXT IS NULL OR source_key = $3)
AND ($5::BOOLEAN IS NULL OR seen = $5)
AND (
$4::TEXT IS NULL
OR from_label ILIKE ('%' || $4 || '%')
@@ -359,6 +426,7 @@ impl MailRepository {
.bind(mailbox.as_deref())
.bind(source_key.as_deref())
.bind(keyword.as_deref())
.bind(query.seen)
.fetch_one(pool)
.await?;
@@ -377,6 +445,7 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
@@ -387,6 +456,7 @@ impl MailRepository {
($1::BIGINT IS NULL OR mail_account_id = $1)
AND ($2::TEXT IS NULL OR mailbox = $2)
AND ($3::TEXT IS NULL OR source_key = $3)
AND ($5::BOOLEAN IS NULL OR seen = $5)
AND (
$4::TEXT IS NULL
OR from_label ILIKE ('%' || $4 || '%')
@@ -396,13 +466,14 @@ impl MailRepository {
OR message_id ILIKE ('%' || $4 || '%')
)
ORDER BY created_at DESC, id DESC
LIMIT $5 OFFSET $6
LIMIT $6 OFFSET $7
"#,
)
.bind(query.account_id)
.bind(mailbox.as_deref())
.bind(source_key.as_deref())
.bind(keyword.as_deref())
.bind(query.seen)
.bind(per_page)
.bind(offset)
.fetch_all(pool)
@@ -427,6 +498,7 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
@@ -617,6 +689,7 @@ impl MailRepository {
content,
content_preview,
raw_headers,
seen,
preview_page_id,
preview_url,
received_at,
@@ -634,6 +707,39 @@ impl MailRepository {
Ok((rows, total.0))
}
pub async fn find_adjacent_ids(
pool: &PgPool,
id: i64,
) -> anyhow::Result<(Option<i64>, Option<i64>)> {
let prev: Option<(i64,)> = sqlx::query_as(
r#"
SELECT id
FROM ias_mail_messages
WHERE id < $1
ORDER BY id DESC
LIMIT 1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
let next: Option<(i64,)> = sqlx::query_as(
r#"
SELECT id
FROM ias_mail_messages
WHERE id > $1
ORDER BY id ASC
LIMIT 1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok((prev.map(|r| r.0), next.map(|r| r.0)))
}
}
fn normalized_filter(value: Option<&str>) -> Option<String> {
+703 -660
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "ias-main"
version = "0.1.14"
version = "0.2.8"
edition = "2024"
[[bin]]
name = "ias-main"
name = "ias"
path = "src/main.rs"
[dependencies]
+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?;
+62
View File
@@ -0,0 +1,62 @@
name: query_mail_messages
desc: 通过 iAs 邮件 HTTP API 查询邮件列表或搜索邮件,支持关键词、邮箱目录、来源和分页过滤。返回邮件总数、分页信息和当前页邮件 JSON 列表。
params:
- name: base_url
type: string
required: false
default: "http://127.0.0.1:9003"
description: iAs HTTP 服务地址,默认 http://127.0.0.1:9003;如果服务绑定了其他地址可覆盖。
- name: q
type: string
required: false
default: ""
description: 搜索关键词,会匹配发件人、主题、摘要、正文和 Message-ID。
- name: mailbox
type: string
required: false
default: ""
description: 邮箱目录过滤,例如 INBOX;为空则不过滤。
- name: source_key
type: string
required: false
default: ""
description: 邮件来源过滤;为空则不过滤。
- name: page
type: integer
required: false
default: "1"
description: 页码,默认 1。
- name: per_page
type: integer
required: false
default: "10"
description: 每页数量,默认 10,接口最大 100。
steps:
- url: ${param:base_url}/v1/mail/messages
method: GET
query:
- [q, "${param:q}"]
- [mailbox, "${param:mailbox}"]
- [source_key, "${param:source_key}"]
- [page, "${param:page}"]
- [per_page, "${param:per_page}"]
timeout: 20
var:
- name: success
extract: success
default: "false"
- name: page
extract: page
default: "1"
- name: per_page
extract: per_page
default: "0"
- name: total
extract: total
default: "0"
- name: messages
extract: messages
default: "[]"
result: |
邮件查询 success=${step0:success}, page=${step0:page}, per_page=${step0:per_page}, total=${step0:total}
messages=${step0:messages}