feat: add React UI and AI page sharing tools

Move HTTP page rendering to the React app, fix multi-tool-call message handling, add API docs lookup, web page lookup, and Markdown preview page sharing.
This commit is contained in:
2026-07-07 22:27:14 +08:00
parent 3d77733e11
commit 640ba9e7d2
65 changed files with 8020 additions and 1732 deletions
+8 -6
View File
@@ -368,13 +368,15 @@ async fn create_preview_page(
}
fn notification_text(message: &MailMessage) -> String {
let preview_url = message
.preview_url
.as_deref()
.unwrap_or("(预览地址生成失败)");
let url = public_url_for_path(&format!("/mail/messages/{}", message.id));
let subject = if message.subject.trim().is_empty() {
"(无主题)"
} else {
&message.subject
};
format!(
"收到新邮件\n发件人:{}\n主题:{}\n摘要:{}\n预览:{}",
message.from_label, message.subject, message.summary, preview_url
"收到新邮件\n发件人:{}\n主题:{}\n摘要:{}\n[查看邮件]({})",
message.from_label, subject, message.summary, url
)
}
+16 -1
View File
@@ -272,7 +272,7 @@ impl MailRepository {
content = $9,
content_preview = $10,
raw_headers = $11,
seen = $12,
seen = ias_mail_messages.seen OR $12,
received_at = $13,
raw_size = $14
WHERE source_key = $1 AND mailbox = $2 AND uid = $3
@@ -740,6 +740,21 @@ impl MailRepository {
Ok((prev.map(|r| r.0), next.map(|r| r.0)))
}
pub async fn mark_as_seen(pool: &PgPool, id: i64) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE ias_mail_messages
SET seen = TRUE
WHERE id = $1 AND seen = FALSE
"#,
)
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
fn normalized_filter(value: Option<&str>) -> Option<String> {
+11 -747
View File
@@ -1,17 +1,14 @@
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::model::MailListQuery;
use crate::repository::MailRepository;
type ApiError = (StatusCode, Json<Value>);
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
pub struct MailRouteState {
@@ -32,8 +29,6 @@ where
.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)))
}
@@ -60,715 +55,28 @@ async fn get_message(
Extension(state): Extension<MailRouteState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
let message = MailRepository::find(&state.db, id)
let mut message = MailRepository::find(&state.db, id)
.await
.map_err(api_internal_error)?
.ok_or_else(|| api_not_found("邮件记录不存在"))?;
// 标记已读
if !message.seen {
match MailRepository::mark_as_seen(&state.db, id).await {
Ok(true) => message.seen = true,
Ok(false) => {}
Err(error) => eprintln!("标记邮件已读失败 id={id}: {error}"),
}
}
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,
@@ -789,47 +97,3 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
})),
)
}
#[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'"));
}
}