Files
iAs/ias-mail/src/routes.rs
T
wunianxiao 3d77733e11 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
2026-07-07 09:54:42 +08:00

836 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Value>);
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
pub struct MailRouteState {
pub db: PgPool,
}
impl MailRouteState {
pub fn new(db: PgPool) -> Self {
Self { db }
}
}
pub fn routes<S>(db: PgPool) -> Router<S>
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<MailRouteState>,
Query(query): Query<MailListQuery>,
) -> Result<Json<Value>, 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<MailRouteState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<MailRouteState>,
Query(query): Query<MailListQuery>,
) -> Result<Html<String>, 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#"<div class="text-center py-16 text-gray-400">
<div class="text-4xl mb-3">📭</div>
<p class="text-base">没有匹配的邮件</p>
</div>"#
.to_string()
} else {
messages
.iter()
.map(render_mail_card)
.collect::<Vec<_>>()
.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#"<div class="mb-6">
<h1 class="text-2xl md:text-3xl font-bold tracking-tight">📧 邮件列表</h1>
<p class="mt-1.5 text-sm text-gray-500">{subtitle}</p>
</div>
{filter_html}
<div class="flex flex-col gap-3 mb-4">
{cards}
</div>
{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#"<a href="/mail" class="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 transition">清除</a>"#
} else {
""
};
format!(
r#"<form method="get" action="/mail" class="bg-white rounded-xl border border-gray-200 p-4 md:p-5 mb-5">
<div class="flex flex-col md:flex-row gap-3">
<div class="flex-1 min-w-0">
<input name="q" value="{q}" placeholder="搜索发件人、主题或正文…"
class="w-full border border-gray-200 rounded-lg px-3.5 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400">
</div>
<div class="flex gap-2">
<input name="account_id" value="{account_id}" placeholder="账户ID" inputmode="numeric"
class="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400">
<input name="mailbox" value="{mailbox}" placeholder="目录"
class="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400">
<input name="source_key" value="{source_key}" placeholder="来源"
class="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400">
<select name="seen"
class="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-white focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition">
<option value="" {seen_all_selected}>全部</option>
<option value="false" {seen_unread_selected}>未读</option>
<option value="true" {seen_read_selected}>已读</option>
</select>
</div>
<input type="hidden" name="per_page" value="{per_page}">
<div class="flex gap-2">
<button type="submit" class="inline-flex items-center justify-center px-5 py-2.5 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition shadow-sm">查询</button>
{clear_btn}
</div>
</div>
</form>"#,
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#"<a href="/mail/messages/{id}" class="{card_class}">
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="text-sm md:text-base font-semibold text-gray-900 group-hover:text-blue-700 leading-snug min-w-0 break-words">{subject}</h3>
<div class="shrink-0 flex items-center gap-2 pt-0.5">
{status_badge}
<span class="text-xs text-gray-400 whitespace-nowrap hidden sm:inline">{received}</span>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1.5 mb-2.5">
<span class="text-sm text-gray-600 truncate">{from}</span>
<div class="flex items-center gap-1.5">
<span class="sm:hidden text-xs text-gray-400">{received}</span>
<span class="text-xs text-gray-400">{relative}</span>
</div>
</div>
{preview_html}
<div class="flex items-center gap-2 mt-2.5">
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-blue-50 text-blue-600">#{id}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">{mailbox}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">{source}</span>
</div>
</a>"#,
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#"<p class="text-xs text-gray-400 line-clamp-2 leading-relaxed">{}</p>"#,
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#"<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">已读</span>"#
} else {
r#"<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-semibold bg-blue-50 text-blue-700"><span class="w-1.5 h-1.5 rounded-full bg-blue-600"></span>未读</span>"#
}
}
// ── 分页 ────────────────────────────────────────────────
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#"<a href="{}" class="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition no-underline"> 上一页</a>"#,
escape_html(&mail_list_url(query, page - 1, per_page))
)
} else {
r#"<span class="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default"> 上一页</span>"#.to_string()
};
let next = if page < last_page {
format!(
r#"<a href="{}" class="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition no-underline">下一页 </a>"#,
escape_html(&mail_list_url(query, page + 1, per_page))
)
} else {
r#"<span class="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">下一页 </span>"#.to_string()
};
let nums = render_page_numbers(query, page, last_page, per_page);
format!(
r#"<nav class="flex items-center justify-between gap-3 mt-5">
{prev}
<div class="hidden sm:flex gap-1 items-center">{nums}</div>
<span class="sm:hidden text-sm text-gray-400">{page} / {last_page}</span>
{next}
</nav>"#
)
}
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#"<a href="{}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm text-blue-600 hover:bg-blue-50 transition no-underline">1</a>"#,
escape_html(&mail_list_url(query, 1, per_page))
));
if start > 2 {
nums.push(r#"<span class="text-gray-300 px-1 text-sm">…</span>"#.to_string());
}
}
for p in start..=end {
if p == page {
nums.push(format!(r#"<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm font-semibold bg-blue-600 text-white">{p}</span>"#));
} else {
nums.push(format!(
r#"<a href="{}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm text-blue-600 hover:bg-blue-50 transition no-underline">{p}</a>"#,
escape_html(&mail_list_url(query, p, per_page))
));
}
}
if end < last_page {
if end < last_page - 1 {
nums.push(r#"<span class="text-gray-300 px-1 text-sm">…</span>"#.to_string());
}
nums.push(format!(
r#"<a href="{}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm text-blue-600 hover:bg-blue-50 transition no-underline">{last_page}</a>"#,
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<MailRouteState>,
Path(id): Path<i64>,
) -> Result<Html<String>, 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<i64>, next_id: Option<i64>) -> 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#"<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<iframe sandbox srcdoc="{}" class="w-full min-h-[420px] border-0 block bg-white" loading="lazy" title="邮件内容"></iframe>
</div>"#,
escape_srcdoc(&wrapped)
)
} else {
format!(
r#"<article class="bg-white rounded-xl border border-gray-200 p-4 md:p-6 text-sm md:text-base leading-relaxed whitespace-normal break-words">{}</article>"#,
render_text(&msg.content)
)
};
let headers_html = msg.raw_headers.as_deref().filter(|h| !h.trim().is_empty()).map(|h| {
format!(r#"<details class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-4">
<summary class="px-4 md:px-5 py-3 cursor-pointer text-sm font-semibold text-blue-600 hover:bg-gray-50 transition select-none">📋 原始头信息</summary>
<pre class="m-0 px-4 md:px-5 py-4 text-xs leading-relaxed font-mono text-gray-700 whitespace-pre-wrap break-all border-t border-gray-100 max-h-80 overflow-y-auto">{}</pre>
</details>"#, escape_html(h))
}).unwrap_or_default();
// 导航按钮
let prev_btn = match prev_id {
Some(pid) => format!(r#"<a href="/mail/messages/{pid}" class="inline-flex items-center gap-1 px-3 md:px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition no-underline">← 上一封</a>"#),
None => r#"<span class="inline-flex items-center gap-1 px-3 md:px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">← 上一封</span>"#.to_string(),
};
let next_btn = match next_id {
Some(nid) => format!(r#"<a href="/mail/messages/{nid}" class="inline-flex items-center gap-1 px-3 md:px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition no-underline">下一封 →</a>"#),
None => r#"<span class="inline-flex items-center gap-1 px-3 md:px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">下一封 →</span>"#.to_string(),
};
let body = format!(
r#"<div class="flex items-center justify-between flex-wrap gap-2 mb-5">
<a href="/mail" class="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition">← 邮件列表</a>
<div class="flex gap-2">{prev_btn}{next_btn}</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
<h1 class="text-lg md:text-xl font-bold leading-snug break-words mb-3">{subject}</h1>
<div class="flex flex-wrap gap-x-4 gap-y-1.5 text-sm text-gray-500">
<span class="inline-flex items-center gap-1"><span class="font-semibold text-gray-700">发件人</span>{from}</span>
<span class="inline-flex items-center gap-1"><span class="font-semibold text-gray-700">时间</span>{received}</span>
<span class="inline-flex items-center gap-1"><span class="font-semibold text-gray-700">来源</span>{source}</span>
</div>
</div>
<details class="bg-white rounded-xl border border-gray-200 overflow-hidden mb-4">
<summary class="px-4 md:px-5 py-3 cursor-pointer text-sm font-semibold text-blue-600 hover:bg-gray-50 transition select-none">📎 详细信息</summary>
<dl class="grid grid-cols-1 md:grid-cols-[auto,1fr] gap-x-5 gap-y-2.5 p-4 md:p-5 pt-2 md:pt-3 border-t border-gray-100 text-sm">
<dt class="text-gray-400 md:text-right">邮箱目录</dt><dd class="text-gray-700 break-all">{mailbox}</dd>
<dt class="text-gray-400 md:text-right">状态</dt><dd class="text-gray-700">{seen_status}</dd>
<dt class="text-gray-400 md:text-right">账户</dt><dd class="text-gray-700">{account}</dd>
<dt class="text-gray-400 md:text-right">UID</dt>
<dd class="text-gray-700 break-all"><code class="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{uid}</code></dd>
<dt class="text-gray-400 md:text-right">Message-ID</dt>
<dd class="text-gray-700 break-all"><code class="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{mid}</code></dd>
<dt class="text-gray-400 md:text-right">缓存 ID</dt><dd class="text-gray-700">{id}</dd>
<dt class="text-gray-400 md:text-right">原始大小</dt><dd class="text-gray-700">{size}</dd>
<dt class="text-gray-400 md:text-right">入库时间</dt><dd class="text-gray-700">{created}</dd>
<dt class="text-gray-400 md:text-right">提醒预览</dt><dd class="text-gray-700">{preview}</dd>
</dl>
</details>
{content_html}
{headers_html}
<script>
document.addEventListener('keydown', function(e) {{
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') {{ var b = document.querySelector('a[href*="/mail/messages/"]:not([href="/mail"])'); if (b && b.textContent.includes('上一封')) b.click(); }}
if (e.key === 'ArrowRight') {{ var links = document.querySelectorAll('a[href*="/mail/messages/"]:not([href="/mail"])'); links.forEach(function(l) {{ if (l.textContent.includes('下一封')) l.click(); }}); }}
}});
</script>"#,
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::<String>()
.to_lowercase();
lower.contains("<html")
|| lower.contains("<!doctype")
|| lower.contains("<body")
|| lower.contains("<div")
|| lower.contains("<table")
|| lower.contains("<meta")
|| lower.contains("<head")
|| lower.contains("<a href")
|| lower.contains("<p ")
|| lower.contains("<br")
|| lower.contains("<img")
|| lower.contains("<span")
}
fn extract_body_and_styles(html: &str) -> (String, String) {
let lower = html.to_lowercase();
// 提取 head 中的 style 标签内容
let styles = if let Some(head_start) = lower.find("<head") {
let head_content_start = lower[head_start..]
.find('>')
.map(|i| head_start + i + 1)
.unwrap_or(head_start);
if let Some(head_end) = lower[head_content_start..].find("</head>") {
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("<body") {
let tag_end = lower[body_start..]
.find('>')
.map(|i| body_start + i + 1)
.unwrap_or(body_start);
if let Some(body_end) = lower[tag_end..].find("</body>") {
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("<style") {
let abs_start = pos + style_start;
let content_start = lower[abs_start..]
.find('>')
.map(|i| abs_start + i + 1)
.unwrap_or(abs_start);
if let Some(style_end) = lower[content_start..].find("</style>") {
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 + "</style>".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#"<a href="{escaped}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline break-all">{escaped}</a>"#
)
}
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!("<style>{styles}</style>")
};
format!(
r#"<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: cid: https: http:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">{styles}</head><body>{body}</body></html>"#
)
}
/// 转义 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<String> = p
.lines()
.map(|line| {
let line = line.trim();
if line.is_empty() {
"<br>".to_string()
} else {
escape_html(line)
}
})
.collect();
format!("<p class=\"mb-2 last:mb-0\">{}</p>", lines.join("<br>\n"))
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.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<chrono::Utc>) -> String {
t.format("%Y-%m-%d %H:%M UTC").to_string()
}
fn relative_time(t: &chrono::DateTime<chrono::Utc>) -> 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#"<h1 class="text-2xl font-bold">{title}</h1>
<p class="mt-2 text-gray-500">{msg}</p>
<p class="mt-4"><a href="/mail" class="text-blue-600 hover:text-blue-800">返回邮件列表</a></p>"#
);
(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#"<h1 class="text-2xl font-bold">页面暂时不可用</h1>
<p class="mt-2 text-gray-500">{msg},请稍后重试。</p>
<p class="mt-4"><a href="/mail" class="text-blue-600 hover:text-blue-800">返回邮件列表</a></p>"#
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(html_shell("页面不可用", &body)),
)
}
}
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!("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!("{}<html><body>ok</body></html>", "邮件".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=&quot; onmouseover=&quot;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("<img src=\"https://example.test/pixel.png\">", "");
assert!(html.contains("default-src 'none'"));
assert!(html.contains("img-src data: cid: https: http:"));
assert!(html.contains("form-action 'none'"));
}
}