use axum::extract::{Path, Query}; use axum::http::StatusCode; use axum::response::Html; use axum::routing::get; use axum::{Extension, Json, Router}; use ias_http::shell::{escape_html, html_shell}; use serde_json::{Value, json}; use sqlx::PgPool; use crate::model::{MailListQuery, MailMessage}; use crate::repository::MailRepository; type ApiError = (StatusCode, Json); type HtmlError = (StatusCode, Html); #[derive(Clone)] pub struct MailRouteState { pub db: PgPool, } impl MailRouteState { pub fn new(db: PgPool) -> Self { Self { db } } } pub fn routes(db: PgPool) -> Router where S: Clone + Send + Sync + 'static, { Router::new() .route("/v1/mail/messages", get(list_messages)) .route("/v1/mail/search", get(list_messages)) .route("/v1/mail/messages/{id}", get(get_message)) .route("/mail", get(show_mail_list)) .route("/mail/messages/{id}", get(show_mail_message)) .layer(Extension(MailRouteState::new(db))) } // ── JSON API ───────────────────────────────────────────── async fn list_messages( Extension(state): Extension, Query(query): Query, ) -> Result, ApiError> { let (messages, total, page, per_page) = MailRepository::query_messages(&state.db, &query) .await .map_err(api_internal_error)?; Ok(Json(json!({ "success": true, "page": page, "per_page": per_page, "total": total, "messages": messages, }))) } async fn get_message( Extension(state): Extension, Path(id): Path, ) -> Result, ApiError> { let message = MailRepository::find(&state.db, id) .await .map_err(api_internal_error)? .ok_or_else(|| api_not_found("邮件记录不存在"))?; Ok(Json(json!({ "success": true, "message": message, }))) } // ═══════════════════════════════════════════════════════════ // HTML 页面:邮件列表(卡片式布局,移动端友好) // ═══════════════════════════════════════════════════════════ async fn show_mail_list( Extension(state): Extension, Query(query): Query, ) -> Result, HtmlError> { let (messages, total, page, per_page) = MailRepository::query_messages(&state.db, &query) .await .map_err(html_internal_error("读取邮件列表失败"))?; Ok(Html(render_mail_list( &query, &messages, total, page, per_page, ))) } fn render_mail_list( query: &MailListQuery, messages: &[MailMessage], total: i64, page: i64, per_page: i64, ) -> String { let has_filter = query.q.is_some() || query.mailbox.is_some() || query.source_key.is_some() || query.account_id.is_some() || query.seen.is_some(); let subtitle = if has_filter { let start = if total == 0 { 0 } else { (page - 1) * per_page + 1 }; let end = ((page - 1) * per_page + messages.len() as i64).min(total); format!("筛选结果:共 {total} 封,当前 {start}–{end}") } else { format!("共 {total} 封邮件") }; let cards: String = if messages.is_empty() { r#"
📭

没有匹配的邮件

"# .to_string() } else { messages .iter() .map(render_mail_card) .collect::>() .join("\n") }; let pagination = render_pagination(query, total, page, per_page); let filter_html = render_filter_bar(query, has_filter, per_page); let body = format!( r#"

📧 邮件列表

{subtitle}

{filter_html}
{cards}
{pagination}"#, subtitle = subtitle, filter_html = filter_html, cards = cards, pagination = pagination, ); html_shell("邮件列表", &body) } // ── 筛选栏 ────────────────────────────────────────────── fn render_filter_bar(query: &MailListQuery, has_filter: bool, per_page: i64) -> String { let q = escape_html(query.q.as_deref().unwrap_or("")); let account_id = escape_html(&query.account_id.map(|v| v.to_string()).unwrap_or_default()); let mailbox = escape_html(query.mailbox.as_deref().unwrap_or("")); let source_key = escape_html(query.source_key.as_deref().unwrap_or("")); let seen_all_selected = selected_attr(query.seen.is_none()); let seen_unread_selected = selected_attr(query.seen == Some(false)); let seen_read_selected = selected_attr(query.seen == Some(true)); let pp = per_page; let clear_btn = if has_filter { r#"清除"# } else { "" }; format!( r#"
{clear_btn}
"#, q = q, account_id = account_id, mailbox = mailbox, source_key = source_key, seen_all_selected = seen_all_selected, seen_unread_selected = seen_unread_selected, seen_read_selected = seen_read_selected, per_page = pp, clear_btn = clear_btn, ) } fn selected_attr(selected: bool) -> &'static str { if selected { "selected" } else { "" } } // ── 邮件卡片(替代表格) ──────────────────────────────── fn render_mail_card(msg: &MailMessage) -> String { let received = msg .received_at .as_ref() .map(|t| t.format("%m-%d %H:%M").to_string()) .unwrap_or_else(|| "-".to_string()); let relative = msg .received_at .as_ref() .map(|t| relative_time(t)) .unwrap_or_default(); let subject = if msg.subject.trim().is_empty() { "无主题" } else { &msg.subject }; let preview = truncate(&msg.content_preview, 120); let status_badge = mail_status_badge(msg.seen); let card_class = if msg.seen { "block bg-white rounded-xl border border-gray-200 p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition group" } else { "block bg-white rounded-xl border border-blue-200 p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition group shadow-sm" }; format!( r#"

{subject}

{status_badge}
{from}
{received} {relative}
{preview_html}
#{id} {mailbox} {source}
"#, id = msg.id, card_class = card_class, subject = escape_html(subject), received = escape_html(&received), relative = escape_html(&relative), status_badge = status_badge, from = escape_html(&msg.from_label), preview_html = if preview.is_empty() { String::new() } else { format!( r#"

{}

"#, escape_html(&preview) ) }, mailbox = escape_html(&msg.mailbox), source = escape_html(&msg.source_key), ) } fn mail_status_badge(seen: bool) -> &'static str { if seen { r#"已读"# } else { r#"未读"# } } // ── 分页 ──────────────────────────────────────────────── fn render_pagination(query: &MailListQuery, total: i64, page: i64, per_page: i64) -> String { if total == 0 { return String::new(); } let last_page = ((total + per_page - 1) / per_page).max(1); let prev = if page > 1 { format!( r#"‹ 上一页"#, escape_html(&mail_list_url(query, page - 1, per_page)) ) } else { r#"‹ 上一页"#.to_string() }; let next = if page < last_page { format!( r#"下一页 ›"#, escape_html(&mail_list_url(query, page + 1, per_page)) ) } else { r#"下一页 ›"#.to_string() }; let nums = render_page_numbers(query, page, last_page, per_page); format!( r#""# ) } fn render_page_numbers(query: &MailListQuery, page: i64, last_page: i64, per_page: i64) -> String { let start = (page - 2).max(1); let end = (page + 2).min(last_page); let mut nums = Vec::new(); if start > 1 { nums.push(format!( r#"1"#, escape_html(&mail_list_url(query, 1, per_page)) )); if start > 2 { nums.push(r#""#.to_string()); } } for p in start..=end { if p == page { nums.push(format!(r#"{p}"#)); } else { nums.push(format!( r#"{p}"#, escape_html(&mail_list_url(query, p, per_page)) )); } } if end < last_page { if end < last_page - 1 { nums.push(r#""#.to_string()); } nums.push(format!( r#"{last_page}"#, escape_html(&mail_list_url(query, last_page, per_page)) )); } nums.join("\n") } fn mail_list_url(query: &MailListQuery, page: i64, per_page: i64) -> String { let mut params = Vec::new(); if let Some(ref q) = query.q { if !q.trim().is_empty() { params.push(format!("q={}", url_encode(q))); } } if let Some(account_id) = query.account_id { params.push(format!("account_id={account_id}")); } if let Some(ref mailbox) = query.mailbox { if !mailbox.trim().is_empty() { params.push(format!("mailbox={}", url_encode(mailbox))); } } if let Some(ref source_key) = query.source_key { if !source_key.trim().is_empty() { params.push(format!("source_key={}", url_encode(source_key))); } } if let Some(seen) = query.seen { params.push(format!("seen={seen}")); } params.push(format!("page={}", page.max(1))); params.push(format!("per_page={}", per_page.clamp(1, 100))); format!("/mail?{}", params.join("&")) } fn url_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { out.push(b as char); } else { out.push_str(&format!("%{b:02X}")); } } out } // ═══════════════════════════════════════════════════════════ // HTML 页面:邮件详情(移动端友好) // ═══════════════════════════════════════════════════════════ async fn show_mail_message( Extension(state): Extension, Path(id): Path, ) -> Result, HtmlError> { let message = MailRepository::find(&state.db, id) .await .map_err(html_internal_error("读取邮件失败"))? .ok_or_else(|| html_not_found("邮件不存在", "没有找到对应的邮件记录"))?; let (prev_id, next_id) = MailRepository::find_adjacent_ids(&state.db, id) .await .map_err(html_internal_error("查询相邻邮件失败"))?; Ok(Html(render_mail_detail(&message, prev_id, next_id))) } fn render_mail_detail(msg: &MailMessage, prev_id: Option, next_id: Option) -> String { let subject = if msg.subject.trim().is_empty() { "无主题" } else { &msg.subject }; let received = msg .received_at .as_ref() .map(|t| format_mail_time(t)) .unwrap_or_else(|| "-".to_string()); let account = msg .mail_account_id .map(|id| format!("账户 #{id}")) .unwrap_or_else(|| "环境配置".to_string()); let mid_short = msg .message_id .as_deref() .map(|m| truncate(m, 72)) .unwrap_or_else(|| "-".to_string()); let preview_url = msg .preview_url .as_deref() .map(render_preview_url) .unwrap_or_else(|| "-".to_string()); let is_html = content_is_html(&msg.content); let content_html = if is_html { let (body, styles) = extract_body_and_styles(&msg.content); let wrapped = render_sandboxed_html_email(&body, &styles); format!( r#"
"#, escape_srcdoc(&wrapped) ) } else { format!( r#"
{}
"#, render_text(&msg.content) ) }; let headers_html = msg.raw_headers.as_deref().filter(|h| !h.trim().is_empty()).map(|h| { format!(r#"
📋 原始头信息
{}
"#, escape_html(h)) }).unwrap_or_default(); // 导航按钮 let prev_btn = match prev_id { Some(pid) => format!(r#"← 上一封"#), None => r#"← 上一封"#.to_string(), }; let next_btn = match next_id { Some(nid) => format!(r#"下一封 →"#), None => r#"下一封 →"#.to_string(), }; let body = format!( r#"
← 邮件列表
{prev_btn}{next_btn}

{subject}

发件人{from} 时间{received} 来源{source}
📎 详细信息
邮箱目录
{mailbox}
状态
{seen_status}
账户
{account}
UID
{uid}
Message-ID
{mid}
缓存 ID
{id}
原始大小
{size}
入库时间
{created}
提醒预览
{preview}
{content_html} {headers_html} "#, subject = escape_html(subject), from = escape_html(&msg.from_label), received = escape_html(&received), source = escape_html(&msg.source_key), mailbox = escape_html(&msg.mailbox), seen_status = if msg.seen { "已读" } else { "未读" }, account = escape_html(&account), uid = escape_html(&msg.uid), mid = escape_html(&mid_short), id = msg.id, size = format_bytes(msg.raw_size), created = format_mail_time(&msg.created_at), preview = preview_url, content_html = content_html, headers_html = headers_html, prev_btn = prev_btn, next_btn = next_btn, ); html_shell(&format!("{subject} — 邮件"), &body) } // ── 辅助函数 ───────────────────────────────────────────── fn content_is_html(content: &str) -> bool { let trimmed = content.trim(); if trimmed.is_empty() { return false; } let lower = trimmed .chars() .take(1024) .collect::() .to_lowercase(); lower.contains(" (String, String) { let lower = html.to_lowercase(); // 提取 head 中的 style 标签内容 let styles = if let Some(head_start) = lower.find("') .map(|i| head_start + i + 1) .unwrap_or(head_start); if let Some(head_end) = lower[head_content_start..].find("") { let head_html = &html[head_content_start..head_content_start + head_end]; extract_style_tags(head_html) } else { String::new() } } else { String::new() }; // 提取 body 内容,没有 body 则返回全部 let body = if let Some(body_start) = lower.find("') .map(|i| body_start + i + 1) .unwrap_or(body_start); if let Some(body_end) = lower[tag_end..].find("") { html[tag_end..tag_end + body_end].to_string() } else { html[tag_end..].to_string() } } else { html.to_string() }; (body, styles) } fn extract_style_tags(head_html: &str) -> String { let lower = head_html.to_lowercase(); let mut styles = String::new(); let mut pos = 0; while let Some(style_start) = lower[pos..].find("') .map(|i| abs_start + i + 1) .unwrap_or(abs_start); if let Some(style_end) = lower[content_start..].find("") { let style_content = &head_html[content_start..content_start + style_end]; if !styles.is_empty() { styles.push('\n'); } styles.push_str(style_content); pos = content_start + style_end + "".len(); } else { break; } } styles } fn render_preview_url(url: &str) -> String { let url = url.trim(); if !is_safe_http_url(url) { return escape_html(url); } let escaped = escape_html(url); format!( r#"{escaped}"# ) } fn is_safe_http_url(url: &str) -> bool { let normalized = url.trim_start().to_ascii_lowercase(); normalized.starts_with("http://") || normalized.starts_with("https://") } fn render_sandboxed_html_email(body: &str, styles: &str) -> String { let styles = if styles.is_empty() { String::new() } else { format!("") }; format!( r#"{styles}{body}"# ) } /// 转义 HTML 使其安全放入 srcdoc 属性。 fn escape_srcdoc(html: &str) -> String { escape_html(html) } fn render_text(content: &str) -> String { // 清理 \r 并规范化换行 let cleaned = content.replace("\r\n", "\n").replace('\r', "\n"); let paragraphs: Vec<&str> = cleaned.split("\n\n").collect(); paragraphs .iter() .map(|p| { let p = p.trim(); if p.is_empty() { return String::new(); } let lines: Vec = p .lines() .map(|line| { let line = line.trim(); if line.is_empty() { "
".to_string() } else { escape_html(line) } }) .collect(); format!("

{}

", lines.join("
\n")) }) .filter(|s| !s.is_empty()) .collect::>() .join("\n") } fn truncate(s: &str, max: usize) -> String { let count = s.chars().count(); if count <= max { s.to_string() } else { let truncated: String = s.chars().take(max - 1).collect(); format!("{truncated}…") } } fn format_mail_time(t: &chrono::DateTime) -> String { t.format("%Y-%m-%d %H:%M UTC").to_string() } fn relative_time(t: &chrono::DateTime) -> String { let delta = chrono::Utc::now().signed_duration_since(*t); if delta.num_seconds() < 60 { "刚刚".to_string() } else if delta.num_minutes() < 60 { format!("{} 分钟前", delta.num_minutes()) } else if delta.num_hours() < 24 { format!("{} 小时前", delta.num_hours()) } else if delta.num_days() < 30 { format!("{} 天前", delta.num_days()) } else { String::new() } } fn format_bytes(bytes: i64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else { format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) } } // ── 错误处理 ───────────────────────────────────────────── fn html_not_found(title: &str, msg: &str) -> HtmlError { let body = format!( r#"

{title}

{msg}

返回邮件列表

"# ); (StatusCode::NOT_FOUND, Html(html_shell(title, &body))) } fn html_internal_error(msg: &str) -> impl Fn(anyhow::Error) -> HtmlError + use<> { let msg = msg.to_string(); move |error: anyhow::Error| -> HtmlError { eprintln!("{msg}: {error}"); let body = format!( r#"

页面暂时不可用

{msg},请稍后重试。

返回邮件列表

"# ); ( StatusCode::INTERNAL_SERVER_ERROR, Html(html_shell("页面不可用", &body)), ) } } fn api_not_found(message: impl Into) -> ApiError { ( StatusCode::NOT_FOUND, Json(json!({ "success": false, "message": message.into(), })), ) } fn api_internal_error(error: anyhow::Error) -> ApiError { eprintln!("mail 接口失败: {error}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "success": false, "message": "mail 接口失败", })), ) } #[cfg(test)] mod tests { use super::*; #[test] fn content_is_html_handles_long_unicode_prefix() { let content = format!("{}ok", "邮件".repeat(700)); let _ = content_is_html(&content); } #[test] fn truncate_handles_unicode_boundaries() { let value = "邮件".repeat(80); let truncated = truncate(&value, 72); assert_eq!(truncated.chars().count(), 72); assert!(truncated.ends_with('…')); } #[test] fn render_preview_url_escapes_href_attribute() { let html = render_preview_url("https://example.test/?q=\" onmouseover=\"x"); assert!(html.contains("https://example.test/?q=" onmouseover="x")); assert!(!html.contains("q=\" onmouseover=\"x")); } #[test] fn render_preview_url_rejects_non_http_protocols() { let html = render_preview_url("javascript:alert(1)"); assert_eq!(html, "javascript:alert(1)"); } #[test] fn render_sandboxed_html_email_blocks_scripts_and_forms() { let html = render_sandboxed_html_email("", ""); assert!(html.contains("default-src 'none'")); assert!(html.contains("img-src data: cid: https: http:")); assert!(html.contains("form-action 'none'")); } }