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
+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("页面不存在"));
}
}