feat: 统一 JSON API 到 /v1 前缀,移除旧邮件兼容路由
- 删除旧邮件查询兼容路由 GET /mail-api/messages、/mail-api/messages/{id}、/mail-api/search
- 所有 JSON API 统一使用 /v1/...:邮件、上下文记忆、动态页面创建和健康检查接口
- 页面路由保持非 /v1 地址:/mail、/mail/messages/{id}、/mail/{slug}、/web/{page_type}/{slug}、/updates
- AI 内部工具调用同步改用 /v1/web/pages 与 /v1/context/memories...
- 无数据库结构变化,新增 0.1.14 升级日志记录
This commit is contained in:
+714
-4
@@ -1,14 +1,16 @@
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Html;
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::model::MailListQuery;
|
||||
use crate::model::{MailListQuery, MailMessage};
|
||||
use crate::repository::MailRepository;
|
||||
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
type HtmlError = (StatusCode, Html<String>);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MailRouteState {
|
||||
@@ -26,8 +28,11 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/mail-api/messages", get(list_messages))
|
||||
.route("/mail-api/messages/{id}", get(get_message))
|
||||
.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)))
|
||||
}
|
||||
|
||||
@@ -35,12 +40,15 @@ async fn list_messages(
|
||||
Extension(state): Extension<MailRouteState>,
|
||||
Query(query): Query<MailListQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let messages = MailRepository::list_recent(&state.db, query.limit.unwrap_or(20))
|
||||
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,
|
||||
})))
|
||||
}
|
||||
@@ -60,6 +68,648 @@ async fn get_message(
|
||||
})))
|
||||
}
|
||||
|
||||
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_page(
|
||||
&query, &messages, total, page, per_page,
|
||||
)))
|
||||
}
|
||||
|
||||
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("邮件不存在", "没有找到对应的邮件记录"))?;
|
||||
|
||||
Ok(Html(render_mail_message_page(&message)))
|
||||
}
|
||||
|
||||
fn render_mail_list_page(
|
||||
query: &MailListQuery,
|
||||
messages: &[MailMessage],
|
||||
total: i64,
|
||||
page: i64,
|
||||
per_page: i64,
|
||||
) -> String {
|
||||
let rows = if messages.is_empty() {
|
||||
r#"<tr><td class="empty" colspan="6">没有匹配的邮件</td></tr>"#.to_string()
|
||||
} else {
|
||||
messages
|
||||
.iter()
|
||||
.map(render_mail_list_row)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let current_start = if total == 0 {
|
||||
0
|
||||
} else {
|
||||
((page - 1) * per_page + 1).min(total)
|
||||
};
|
||||
let current_end = ((page - 1) * per_page + messages.len() as i64).min(total);
|
||||
let pagination = render_pagination(query, total, page, per_page);
|
||||
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>邮件列表</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #20242a;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}}
|
||||
main {{
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 44px;
|
||||
}}
|
||||
header {{
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
}}
|
||||
.count {{
|
||||
color: #5e6978;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}}
|
||||
form {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.4fr) minmax(120px, .7fr) minmax(120px, .8fr) minmax(120px, .8fr) 92px 84px;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
align-items: end;
|
||||
}}
|
||||
label {{
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #5e6978;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}}
|
||||
input {{
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #cfd7e2;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #20242a;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
padding: 8px 10px;
|
||||
}}
|
||||
button, .pager a {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
border: 1px solid #245f9f;
|
||||
border-radius: 6px;
|
||||
background: #245f9f;
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.table-wrap {{
|
||||
overflow-x: auto;
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 860px;
|
||||
}}
|
||||
th, td {{
|
||||
border-bottom: 1px solid #e5eaf0;
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
th {{
|
||||
color: #5e6978;
|
||||
background: #fbfcfd;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}}
|
||||
tr:last-child td {{
|
||||
border-bottom: 0;
|
||||
}}
|
||||
a {{
|
||||
color: #245f9f;
|
||||
text-decoration: none;
|
||||
}}
|
||||
a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
.subject {{
|
||||
min-width: 260px;
|
||||
max-width: 460px;
|
||||
}}
|
||||
.subject a {{
|
||||
font-weight: 650;
|
||||
}}
|
||||
.summary {{
|
||||
margin-top: 4px;
|
||||
color: #697586;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
.mono {{
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}}
|
||||
.empty {{
|
||||
height: 96px;
|
||||
text-align: center;
|
||||
color: #697586;
|
||||
}}
|
||||
.pager {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
color: #5e6978;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.pager a {{
|
||||
min-width: 82px;
|
||||
background: #ffffff;
|
||||
color: #245f9f;
|
||||
}}
|
||||
.pager .disabled {{
|
||||
min-width: 82px;
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 6px;
|
||||
color: #9aa4b2;
|
||||
background: #ffffff;
|
||||
}}
|
||||
@media (max-width: 860px) {{
|
||||
main {{
|
||||
width: min(100% - 24px, 1180px);
|
||||
padding-top: 22px;
|
||||
}}
|
||||
header {{
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}}
|
||||
form {{
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}}
|
||||
button {{
|
||||
grid-column: span 2;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>邮件列表</h1>
|
||||
<div class="count">共 {total} 封,当前 {current_start}-{current_end}</div>
|
||||
</header>
|
||||
<form method="get" action="/mail">
|
||||
<label>关键词
|
||||
<input name="q" value="{q}" placeholder="发件人、主题、正文、Message-ID">
|
||||
</label>
|
||||
<label>账户 ID
|
||||
<input name="account_id" value="{account_id}" inputmode="numeric">
|
||||
</label>
|
||||
<label>邮箱目录
|
||||
<input name="mailbox" value="{mailbox}" placeholder="INBOX">
|
||||
</label>
|
||||
<label>来源
|
||||
<input name="source_key" value="{source_key}">
|
||||
</label>
|
||||
<label>每页
|
||||
<input name="per_page" value="{per_page}" inputmode="numeric">
|
||||
</label>
|
||||
<button type="submit">查询</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>收件时间</th>
|
||||
<th>发件人</th>
|
||||
<th>主题</th>
|
||||
<th>邮箱目录</th>
|
||||
<th>来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pagination}
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
total = total,
|
||||
current_start = current_start,
|
||||
current_end = current_end,
|
||||
q = escape_html(query.q.as_deref().unwrap_or("")),
|
||||
account_id = query
|
||||
.account_id
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
mailbox = escape_html(query.mailbox.as_deref().unwrap_or("")),
|
||||
source_key = escape_html(query.source_key.as_deref().unwrap_or("")),
|
||||
per_page = per_page,
|
||||
rows = rows,
|
||||
pagination = pagination
|
||||
)
|
||||
}
|
||||
|
||||
fn render_mail_list_row(message: &MailMessage) -> String {
|
||||
let subject = display_subject(&message.subject);
|
||||
let summary = if message.content_preview.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<div class="summary">{}</div>"#,
|
||||
escape_html(&message.content_preview)
|
||||
)
|
||||
};
|
||||
let received_at = message
|
||||
.received_at
|
||||
.as_ref()
|
||||
.map(format_mail_time)
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let account = message
|
||||
.mail_account_id
|
||||
.map(|id| format!("账户 #{id}"))
|
||||
.unwrap_or_else(|| "环境配置".to_string());
|
||||
|
||||
format!(
|
||||
r#"<tr>
|
||||
<td class="mono">{id}</td>
|
||||
<td>{received_at}</td>
|
||||
<td>{from}</td>
|
||||
<td class="subject"><a href="/mail/messages/{id}">{subject}</a>{summary}</td>
|
||||
<td>{mailbox}</td>
|
||||
<td><div>{source_key}</div><div class="summary">{account}</div></td>
|
||||
</tr>"#,
|
||||
id = message.id,
|
||||
received_at = escape_html(&received_at),
|
||||
from = escape_html(&message.from_label),
|
||||
subject = escape_html(subject),
|
||||
summary = summary,
|
||||
mailbox = escape_html(&message.mailbox),
|
||||
source_key = escape_html(&message.source_key),
|
||||
account = escape_html(&account)
|
||||
)
|
||||
}
|
||||
|
||||
fn render_pagination(query: &MailListQuery, total: i64, page: i64, per_page: i64) -> String {
|
||||
let last_page = if total == 0 {
|
||||
1
|
||||
} else {
|
||||
((total + per_page - 1) / per_page).max(1)
|
||||
};
|
||||
let previous = if page > 1 {
|
||||
format!(
|
||||
r#"<a href="{}">上一页</a>"#,
|
||||
escape_html(&mail_list_path(query, page - 1, per_page))
|
||||
)
|
||||
} else {
|
||||
r#"<span class="disabled">上一页</span>"#.to_string()
|
||||
};
|
||||
let next = if page < last_page {
|
||||
format!(
|
||||
r#"<a href="{}">下一页</a>"#,
|
||||
escape_html(&mail_list_path(query, page + 1, per_page))
|
||||
)
|
||||
} else {
|
||||
r#"<span class="disabled">下一页</span>"#.to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<nav class="pager">{previous}<span>第 {page} / {last_page} 页</span>{next}</nav>"#,
|
||||
previous = previous,
|
||||
page = page,
|
||||
last_page = last_page,
|
||||
next = next
|
||||
)
|
||||
}
|
||||
|
||||
fn mail_list_path(query: &MailListQuery, page: i64, per_page: i64) -> String {
|
||||
let mut params = Vec::new();
|
||||
push_query_param(&mut params, "q", query.q.as_deref());
|
||||
if let Some(account_id) = query.account_id {
|
||||
params.push(format!("account_id={account_id}"));
|
||||
}
|
||||
push_query_param(&mut params, "mailbox", query.mailbox.as_deref());
|
||||
push_query_param(&mut params, "source_key", query.source_key.as_deref());
|
||||
params.push(format!("page={}", page.max(1)));
|
||||
params.push(format!("per_page={}", per_page.clamp(1, 100)));
|
||||
|
||||
format!("/mail?{}", params.join("&"))
|
||||
}
|
||||
|
||||
fn push_query_param(params: &mut Vec<String>, key: &str, value: Option<&str>) {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
params.push(format!("{key}={}", percent_encode(value)));
|
||||
}
|
||||
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn render_mail_message_page(message: &MailMessage) -> String {
|
||||
let title = display_subject(&message.subject);
|
||||
let received_at = message
|
||||
.received_at
|
||||
.as_ref()
|
||||
.map(format_mail_time)
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let created_at = format_mail_time(&message.created_at);
|
||||
let account = message
|
||||
.mail_account_id
|
||||
.map(|id| format!("账户 #{id}"))
|
||||
.unwrap_or_else(|| "环境配置".to_string());
|
||||
let preview_url = message
|
||||
.preview_url
|
||||
.as_deref()
|
||||
.map(|url| {
|
||||
format!(
|
||||
r#"<a href="{url}">{label}</a>"#,
|
||||
url = escape_html(url),
|
||||
label = escape_html(url)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let message_id = message.message_id.as_deref().unwrap_or("-");
|
||||
let headers = message
|
||||
.raw_headers
|
||||
.as_deref()
|
||||
.filter(|headers| !headers.trim().is_empty())
|
||||
.map(|headers| {
|
||||
format!(
|
||||
r#"<details><summary>原始头信息</summary><pre>{}</pre></details>"#,
|
||||
escape_html(headers)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #20242a;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}}
|
||||
main {{
|
||||
width: min(920px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 48px;
|
||||
}}
|
||||
.back {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
color: #245f9f;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
.back:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 14px 0 10px;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
.summary {{
|
||||
margin: 0 0 18px;
|
||||
color: #5e6978;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
.meta {{
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
margin: 0 0 18px;
|
||||
padding: 16px;
|
||||
}}
|
||||
dl {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(112px, 170px) 1fr;
|
||||
gap: 10px 16px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
dt {{
|
||||
color: #5e6978;
|
||||
}}
|
||||
dd {{
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
a {{
|
||||
color: #245f9f;
|
||||
text-decoration: none;
|
||||
}}
|
||||
a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
article {{
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 22px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
details {{
|
||||
margin-top: 18px;
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 12px 14px;
|
||||
}}
|
||||
summary {{
|
||||
color: #245f9f;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}}
|
||||
pre {{
|
||||
margin: 12px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #2d333b;
|
||||
}}
|
||||
@media (max-width: 640px) {{
|
||||
main {{
|
||||
width: min(100% - 24px, 920px);
|
||||
padding-top: 22px;
|
||||
}}
|
||||
dl {{
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}}
|
||||
article {{
|
||||
padding: 18px;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<a class="back" href="/mail">返回邮件列表</a>
|
||||
<h1>{title}</h1>
|
||||
<p class="summary">{summary}</p>
|
||||
<section class="meta">
|
||||
<dl>
|
||||
<dt>发件人</dt><dd>{from}</dd>
|
||||
<dt>收件时间</dt><dd>{received_at}</dd>
|
||||
<dt>邮箱目录</dt><dd>{mailbox}</dd>
|
||||
<dt>来源</dt><dd>{source_key}</dd>
|
||||
<dt>账户</dt><dd>{account}</dd>
|
||||
<dt>UID</dt><dd>{uid}</dd>
|
||||
<dt>Message-ID</dt><dd>{message_id}</dd>
|
||||
<dt>缓存 ID</dt><dd>{id}</dd>
|
||||
<dt>原始大小</dt><dd>{raw_size} bytes</dd>
|
||||
<dt>入库时间</dt><dd>{created_at}</dd>
|
||||
<dt>提醒预览</dt><dd>{preview_url}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<article>{content}</article>
|
||||
{headers}
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
summary = escape_html(&message.summary),
|
||||
from = escape_html(&message.from_label),
|
||||
received_at = escape_html(&received_at),
|
||||
mailbox = escape_html(&message.mailbox),
|
||||
source_key = escape_html(&message.source_key),
|
||||
account = escape_html(&account),
|
||||
uid = escape_html(&message.uid),
|
||||
message_id = escape_html(message_id),
|
||||
id = message.id,
|
||||
raw_size = message.raw_size,
|
||||
created_at = escape_html(&created_at),
|
||||
preview_url = preview_url,
|
||||
content = render_text_content(&message.content),
|
||||
headers = headers
|
||||
)
|
||||
}
|
||||
|
||||
fn display_subject(subject: &str) -> &str {
|
||||
let subject = subject.trim();
|
||||
if subject.is_empty() {
|
||||
"无主题"
|
||||
} else {
|
||||
subject
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mail_time(time: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
time.format("%Y-%m-%d %H:%M:%S UTC").to_string()
|
||||
}
|
||||
|
||||
fn render_text_content(content: &str) -> String {
|
||||
content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.trim().is_empty() {
|
||||
"<br>".to_string()
|
||||
} else {
|
||||
escape_html(line)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("<br>\n")
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -70,6 +720,43 @@ fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||
)
|
||||
}
|
||||
|
||||
fn html_not_found(title: &str, message: &str) -> HtmlError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(render_error_page(title, message)),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
||||
eprintln!("读取 mail 页面失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(render_error_page("邮件页面暂时不可用", "读取邮件内容失败")),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_error_page(title: &str, message: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fa;color:#20242a;">
|
||||
<main style="width:min(760px,calc(100% - 32px));margin:0 auto;padding:44px 0;">
|
||||
<h1 style="font-size:30px;line-height:1.2;margin:0 0 10px;">{title}</h1>
|
||||
<p style="font-size:15px;line-height:1.7;margin:0;color:#5e6978;">{message}</p>
|
||||
<p style="margin:18px 0 0;"><a href="/mail" style="color:#245f9f;text-decoration:none;">返回邮件列表</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
message = escape_html(message)
|
||||
)
|
||||
}
|
||||
|
||||
fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
eprintln!("mail 接口失败: {error}");
|
||||
(
|
||||
@@ -80,3 +767,26 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::Router;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
#[test]
|
||||
fn builds_mail_routes_with_web_mail_slug_route() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("create tokio runtime");
|
||||
|
||||
runtime.block_on(async {
|
||||
let db = PgPoolOptions::new()
|
||||
.connect_lazy("postgres://postgres:postgres@localhost/ias_test")
|
||||
.expect("create lazy postgres pool");
|
||||
let _app: Router<()> =
|
||||
ias_http::web::routes::routes::<()>(db.clone()).merge(routes::<()>(db));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user