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:
2026-07-06 21:39:56 +08:00
parent 25dd0daa8b
commit 2669861af1
29 changed files with 1845 additions and 89 deletions
+714 -4
View File
@@ -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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => 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));
});
}
}