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:
+11
-747
@@ -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=" 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("<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'"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user