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
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-mail"
version = "0.1.0"
version = "0.1.7"
edition = "2024"
[dependencies]
+351 -21
View File
@@ -5,7 +5,7 @@ use sqlx::PgPool;
use crate::config::MailPollerConfig;
use crate::model::NewMailAccount;
use crate::poller::test_connection;
use crate::poller::{pull_recent_mail, test_connection};
use crate::repository::MailRepository;
pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
@@ -24,7 +24,7 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", false)?;
let enabled = prompt_bool("是否启用该邮件账户", true)?;
let account = NewMailAccount {
let mut account = NewMailAccount {
name,
source_key: format!("imap:{host}:{username}"),
imap_host: host,
@@ -41,25 +41,7 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
};
if prompt_bool("保存前测试 IMAP 连接", true)? {
let config = MailPollerConfig {
mail_account_id: None,
name: account.name.clone(),
host: account.imap_host.clone(),
port: account.imap_port as u16,
username: account.imap_username.clone(),
password: account.imap_password.clone(),
mailbox: account.mailbox.clone(),
source_key: account.source_key.clone(),
notify_account_id: account.notify_account_id.clone(),
notify_user_id: account.notify_user_id.clone(),
poll_interval: Duration::from_secs(account.poll_seconds as u64),
fetch_limit: account.fetch_limit as usize,
accept_invalid_certs: account.accept_invalid_certs,
};
print!("正在测试 IMAP 连接... ");
io::stdout().flush()?;
test_connection(config).await?;
println!("成功");
test_account_interactively(&mut account).await?;
}
let saved = MailRepository::upsert_account(pool, account).await?;
@@ -102,6 +84,68 @@ pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<()> {
Ok(())
}
async fn test_account_interactively(account: &mut NewMailAccount) -> anyhow::Result<()> {
let mut accept_invalid = account.accept_invalid_certs;
loop {
let config = config_from_new_account(account, accept_invalid);
print!("正在测试 IMAP 连接... ");
io::stdout().flush()?;
match test_connection(config).await {
Ok(()) => {
println!("成功");
break;
}
Err(e) => {
let err_msg = format!("{e:#}");
println!("失败");
if is_tls_error(&err_msg) && !accept_invalid {
println!("TLS 证书校验失败,可能是自签名证书或主机名不匹配。");
if prompt_bool("是否接受无效证书后重试", true)? {
accept_invalid = true;
continue;
}
}
println!("跳过连接测试,将继续保存账户。");
break;
}
}
}
if accept_invalid != account.accept_invalid_certs {
account.accept_invalid_certs = true;
}
Ok(())
}
fn config_from_new_account(
account: &NewMailAccount,
accept_invalid_certs: bool,
) -> MailPollerConfig {
MailPollerConfig {
mail_account_id: None,
name: account.name.clone(),
host: account.imap_host.clone(),
port: account.imap_port as u16,
username: account.imap_username.clone(),
password: account.imap_password.clone(),
mailbox: account.mailbox.clone(),
source_key: account.source_key.clone(),
notify_account_id: account.notify_account_id.clone(),
notify_user_id: account.notify_user_id.clone(),
poll_interval: Duration::from_secs(account.poll_seconds as u64),
fetch_limit: account.fetch_limit as usize,
accept_invalid_certs,
}
}
fn is_tls_error(error: &str) -> bool {
error.contains("TLS")
|| error.contains("certificate")
|| error.contains("ssl")
|| error.to_ascii_lowercase().contains("ssl")
}
fn prompt_required(label: &str, default: Option<&str>) -> anyhow::Result<String> {
loop {
let value = prompt_line(label, default)?;
@@ -138,6 +182,292 @@ fn prompt_bool(label: &str, default: bool) -> anyhow::Result<bool> {
}
}
pub async fn show_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
let last_checked = account
.last_checked_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let last_error = account.last_error.as_deref().unwrap_or("-");
println!("邮件账户 #{id}:");
println!(" 名称: {}", account.name);
println!(
" IMAP 服务器: {}:{}",
account.imap_host, account.imap_port
);
println!(" 用户名: {}", account.imap_username);
println!(" 邮箱目录: {}", account.mailbox);
println!(" 通知 account_id: {}", account.notify_account_id);
println!(" 通知 user_id: {}", account.notify_user_id);
println!(" 轮询间隔: {}s", account.poll_seconds);
println!(" 抓取上限: {}", account.fetch_limit);
println!(" 接受无效证书: {}", account.accept_invalid_certs);
println!(" 启用状态: {}", account.enabled);
println!(" 上次检查时间: {last_checked}");
println!(" 上次错误: {last_error}");
println!(" 创建时间: {}", account.created_at);
println!(" 更新时间: {}", account.updated_at);
Ok(())
}
pub async fn edit_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(existing) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
println!("修改邮件账户 #{id}。直接回车会保留当前值,密码字段留空不会显示或修改现有密码。");
let host = prompt_required("IMAP 服务器", Some(&existing.imap_host))?;
let port = prompt_i32("IMAP 端口", existing.imap_port, 1, u16::MAX as i32)?;
let username = prompt_required("邮箱账号", Some(&existing.imap_username))?;
let password = prompt_line("IMAP 密码或授权码(留空保留现有)", None)?;
let password = if password.trim().is_empty() {
existing.imap_password.clone()
} else {
password.trim().to_string()
};
let mailbox = prompt_required("邮箱目录", Some(&existing.mailbox))?;
let name = prompt_required("账户名称", Some(&existing.name))?;
let notify_account_id = prompt_required(
"通知使用的微信 account_id",
Some(&existing.notify_account_id),
)?;
let notify_user_id =
prompt_required("通知接收人的 from_user_id", Some(&existing.notify_user_id))?;
let poll_seconds = prompt_i32("轮询间隔秒数", existing.poll_seconds, 10, 86400)?;
let fetch_limit = prompt_i32("单次最多抓取邮件数", existing.fetch_limit, 1, 100)?;
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", existing.accept_invalid_certs)?;
let enabled = prompt_bool("是否启用该邮件账户", existing.enabled)?;
let mut account = NewMailAccount {
name,
source_key: format!("imap:{host}:{username}"),
imap_host: host,
imap_port: port,
imap_username: username,
imap_password: password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
};
if prompt_bool("保存前测试 IMAP 连接", true)? {
test_account_interactively(&mut account).await?;
}
let Some(saved) = MailRepository::update_account(pool, id, account).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
println!(
"邮件账户已更新:#{} {} {}@{} mailbox={} enabled={}",
saved.id, saved.name, saved.imap_username, saved.imap_host, saved.mailbox, saved.enabled
);
println!("服务下次启动或重启后会加载更新后的邮件账户。");
Ok(())
}
pub async fn check_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
print!(
"正在检查邮件账户 #{} {} {}@{} mailbox={} ... ",
account.id, account.name, account.imap_username, account.imap_host, account.mailbox
);
io::stdout().flush()?;
match test_connection(MailPollerConfig::from_account(account.clone())).await {
Ok(()) => {
MailRepository::record_poll_success(pool, Some(account.id)).await?;
println!("成功");
Ok(())
}
Err(error) => {
let message = format!("{error:#}");
if let Err(update_error) =
MailRepository::record_poll_error(pool, Some(account.id), message.clone()).await
{
eprintln!("记录邮件检查结果失败:{update_error}");
}
println!("失败");
anyhow::bail!("邮件账户 #{} 连通性检查失败:{message}", account.id);
}
}
}
pub async fn pull_mail(pool: &PgPool, id: Option<i64>) -> anyhow::Result<()> {
let mut configs = Vec::new();
if let Some(id) = id {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
if !account.enabled {
println!("邮件账户 #{id} 当前已禁用,本次仍执行手动拉取。");
}
configs.push(MailPollerConfig::from_account(account));
} else {
configs.extend(
MailRepository::list_enabled_accounts(pool)
.await?
.into_iter()
.map(MailPollerConfig::from_account),
);
if configs.is_empty() {
match MailPollerConfig::from_env()? {
Some(config) => configs.push(config),
None => {
println!("未找到可拉取的邮件账户。可使用 ias mail auth 添加。");
return Ok(());
}
}
}
}
let mut failed = 0usize;
for config in configs {
println!(
"正在拉取邮件账户 {} {}@{} mailbox={} ...",
config.name, config.username, config.host, config.mailbox
);
match pull_recent_mail(pool, &config).await {
Ok(result) => {
println!(
"拉取完成:扫描={} 未读={} 新增={} 已存在={}",
result.scanned, result.unread_found, result.inserted, result.duplicates
);
}
Err(error) => {
failed += 1;
let message = format!("{error:#}");
if let Err(update_error) =
MailRepository::record_poll_error(pool, config.mail_account_id, message.clone())
.await
{
eprintln!("记录邮件拉取错误失败:{update_error}");
}
eprintln!("拉取失败:{message}");
}
}
}
if failed > 0 {
anyhow::bail!("{failed} 个邮件账户拉取失败");
}
Ok(())
}
pub async fn delete_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
print!(
"确认删除邮件账户 #{} {} ({}@{})? [y/N]: ",
account.id, account.name, account.imap_username, account.imap_host
);
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
let confirm = confirm.trim().to_ascii_lowercase();
if !matches!(confirm.as_str(), "y" | "yes") {
println!("已取消删除。");
return Ok(());
}
MailRepository::delete_account(pool, id).await?;
println!("已删除邮件账户 #{}", account.id);
Ok(())
}
pub async fn enable_account(pool: &PgPool, id: i64, enabled: bool) -> anyhow::Result<()> {
let Some(account) = MailRepository::update_account_enabled(pool, id, enabled).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
let status = if enabled { "已启用" } else { "已禁用" };
println!("{status}邮件账户 #{} {}", account.id, account.name);
Ok(())
}
pub async fn list_messages(pool: &PgPool, page: i64, per_page: i64) -> anyhow::Result<()> {
let (messages, total) = MailRepository::list_messages_paginated(pool, page, per_page).await?;
let total_pages = ((total as f64) / (per_page as f64)).ceil() as i64;
if messages.is_empty() {
println!("暂无邮件记录。");
return Ok(());
}
println!("邮件列表 (第 {page}/{total_pages} 页,共 {total} 封):");
for msg in messages {
let received = msg
.received_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let preview = msg.preview_url.as_deref().unwrap_or("-");
println!(
" #{id} | {from} | {subject} | {received} | {preview}",
id = msg.id,
from = msg.from_label,
subject = msg.subject,
received = received,
preview = preview
);
}
Ok(())
}
pub async fn show_message(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(msg) = MailRepository::find(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件。");
return Ok(());
};
let received = msg
.received_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let preview = msg.preview_url.as_deref().unwrap_or("-");
println!("邮件 #{id}:");
println!(" 发件人: {}", msg.from_label);
println!(" 主题: {}", msg.subject);
println!(" 摘要: {}", msg.summary);
println!(" 邮箱目录: {}", msg.mailbox);
println!(" Message-ID: {}", msg.message_id.as_deref().unwrap_or("-"));
println!(" UID: {}", msg.uid);
println!(" 接收时间: {received}");
println!(" 原始大小: {} bytes", msg.raw_size);
println!(" 预览链接: {preview}");
println!(" 入库时间: {}", msg.created_at);
println!();
println!("--- 正文预览 ---");
println!("{}", msg.content_preview);
println!("--- 正文结束 ---");
Ok(())
}
fn prompt_line(label: &str, default: Option<&str>) -> anyhow::Result<String> {
match default {
Some(default) => print!("{label} [{default}]: "),
+6
View File
@@ -89,4 +89,10 @@ pub struct MailNotification {
#[derive(Debug, Deserialize)]
pub struct MailListQuery {
pub limit: Option<i64>,
pub page: Option<i64>,
pub per_page: Option<i64>,
pub q: Option<String>,
pub account_id: Option<i64>,
pub mailbox: Option<String>,
pub source_key: Option<String>,
}
+92 -34
View File
@@ -1,5 +1,5 @@
use anyhow::{Context, anyhow};
use async_imap::types::Fetch;
use async_imap::types::{Fetch, Flag};
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use ias_http::web::{
@@ -16,9 +16,12 @@ use crate::config::MailPollerConfig;
use crate::model::{MailMessage, MailNotification, NewMailMessage};
use crate::repository::MailRepository;
const RECENT_MAIL_FETCH_LIMIT: u32 = 20;
#[derive(Debug, Clone)]
struct FetchedMail {
uid: String,
seen: bool,
message_id: Option<String>,
from_label: String,
subject: String,
@@ -29,6 +32,15 @@ struct FetchedMail {
raw_size: i64,
}
#[derive(Debug, Clone, Default)]
pub struct MailPullResult {
pub scanned: usize,
pub unread_found: usize,
pub inserted: usize,
pub duplicates: usize,
pub notified: usize,
}
pub fn start_mail_polling(
pool: PgPool,
config: MailPollerConfig,
@@ -67,10 +79,37 @@ async fn poll_once(
config: &MailPollerConfig,
notifications: &Sender<MailNotification>,
) -> anyhow::Result<()> {
let messages = fetch_unseen_messages(config).await?;
pull_recent_mail_with_notifications(pool, config, Some(notifications), true).await?;
Ok(())
}
pub async fn pull_recent_mail(
pool: &PgPool,
config: &MailPollerConfig,
) -> anyhow::Result<MailPullResult> {
pull_recent_mail_with_notifications(pool, config, None, false).await
}
async fn pull_recent_mail_with_notifications(
pool: &PgPool,
config: &MailPollerConfig,
notifications: Option<&Sender<MailNotification>>,
unread_only: bool,
) -> anyhow::Result<MailPullResult> {
let messages = fetch_recent_messages(config).await?;
MailRepository::record_poll_success(pool, config.mail_account_id).await?;
let mut result = MailPullResult {
scanned: messages.len(),
unread_found: messages.iter().filter(|message| !message.seen).count(),
..MailPullResult::default()
};
for fetched in messages {
if unread_only && fetched.seen {
continue;
}
let Some(inserted) = MailRepository::insert_if_new(
pool,
NewMailMessage {
@@ -91,33 +130,39 @@ async fn poll_once(
)
.await?
else {
result.duplicates += 1;
continue;
};
result.inserted += 1;
let message = create_preview_page(pool, inserted, &fetched.content).await?;
let text = notification_text(&message);
if notifications
.send(MailNotification {
account_id: config.notify_account_id.clone(),
from_user_id: config.notify_user_id.clone(),
text,
})
.await
.is_err()
{
eprintln!("邮件通知通道已关闭");
return Ok(());
if let Some(notifications) = notifications {
let text = notification_text(&message);
if notifications
.send(MailNotification {
account_id: config.notify_account_id.clone(),
from_user_id: config.notify_user_id.clone(),
text,
})
.await
.is_err()
{
eprintln!("邮件通知通道已关闭");
return Ok(result);
}
result.notified += 1;
}
}
Ok(())
Ok(result)
}
pub async fn test_connection(config: MailPollerConfig) -> anyhow::Result<()> {
test_connection_async(&config).await
}
async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<FetchedMail>> {
async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<FetchedMail>> {
let addr = format!("{}:{}", config.host, config.port);
let tcp_stream = TcpStream::connect(&addr)
.await
@@ -139,30 +184,20 @@ async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
.map_err(|(error, _)| error)
.context("IMAP 登录失败")?;
session
let mailbox = session
.select(&config.mailbox)
.await
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
let unseen = session.search("UNSEEN").await.context("查询未读邮件失败")?;
let mut sequence_numbers: Vec<u32> = unseen.into_iter().collect();
sequence_numbers.sort_unstable();
sequence_numbers.truncate(config.fetch_limit);
if sequence_numbers.is_empty() {
let Some(sequence_set) = recent_sequence_set(mailbox.exists, RECENT_MAIL_FETCH_LIMIT) else {
let _ = session.logout().await;
return Ok(Vec::new());
}
};
let sequence_set = sequence_numbers
.into_iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(",");
let fetches: Vec<Fetch> = session
.fetch(&sequence_set, "(UID RFC822)")
.fetch(&sequence_set, "(UID FLAGS BODY.PEEK[])")
.await
.context("抓取邮件正文失败")?
.context("抓取最近邮件失败")?
.try_collect()
.await
.context("收集抓取结果失败")?;
@@ -173,11 +208,12 @@ async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
let Some(body) = fetch.body() else {
continue;
};
let seen = is_seen(fetch);
let uid = fetch
.uid
.map(|uid| uid.to_string())
.unwrap_or_else(|| format!("seq-{}", fetch.message));
match parse_message(uid, body) {
match parse_message(uid, seen, body) {
Ok(message) => messages.push(message),
Err(error) => eprintln!("解析邮件失败: {error:#}"),
}
@@ -187,6 +223,19 @@ async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
Ok(messages)
}
fn recent_sequence_set(exists: u32, limit: u32) -> Option<String> {
if exists == 0 || limit == 0 {
return None;
}
let start = exists.saturating_sub(limit).saturating_add(1);
Some(format!("{start}:{exists}"))
}
fn is_seen(fetch: &Fetch) -> bool {
fetch.flags().any(|flag| matches!(flag, Flag::Seen))
}
async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> {
let addr = format!("{}:{}", config.host, config.port);
let tcp_stream = TcpStream::connect(&addr)
@@ -218,7 +267,7 @@ async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()>
Ok(())
}
fn parse_message(uid: String, raw: &[u8]) -> anyhow::Result<FetchedMail> {
fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedMail> {
let parsed = MessageParser::default()
.parse(raw)
.ok_or_else(|| anyhow!("mail-parser 未能解析邮件"))?;
@@ -245,6 +294,7 @@ fn parse_message(uid: String, raw: &[u8]) -> anyhow::Result<FetchedMail> {
Ok(FetchedMail {
uid,
seen,
message_id,
from_label,
subject,
@@ -374,7 +424,15 @@ fn split_headers(raw: &str) -> &str {
#[cfg(test)]
mod tests {
use super::{header_value, preview_text, summarize_mail};
use super::{header_value, preview_text, recent_sequence_set, summarize_mail};
#[test]
fn builds_recent_sequence_set_for_latest_messages() {
assert_eq!(recent_sequence_set(0, 20), None);
assert_eq!(recent_sequence_set(8, 20).as_deref(), Some("1:8"));
assert_eq!(recent_sequence_set(20, 20).as_deref(), Some("1:20"));
assert_eq!(recent_sequence_set(25, 20).as_deref(), Some("6:25"));
}
#[test]
fn reads_folded_header() {
+285
View File
@@ -1,5 +1,6 @@
use sqlx::PgPool;
use crate::model::MailListQuery;
use crate::model::{MailAccount, MailMessage, NewMailAccount, NewMailMessage};
pub struct MailRepository;
@@ -325,6 +326,91 @@ impl MailRepository {
Ok(rows)
}
pub async fn query_messages(
pool: &PgPool,
query: &MailListQuery,
) -> anyhow::Result<(Vec<MailMessage>, i64, i64, i64)> {
let per_page = query.per_page.or(query.limit).unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * per_page;
let mailbox = normalized_filter(query.mailbox.as_deref());
let source_key = normalized_filter(query.source_key.as_deref());
let keyword = normalized_filter(query.q.as_deref());
let total: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM ias_mail_messages
WHERE
($1::BIGINT IS NULL OR mail_account_id = $1)
AND ($2::TEXT IS NULL OR mailbox = $2)
AND ($3::TEXT IS NULL OR source_key = $3)
AND (
$4::TEXT IS NULL
OR from_label ILIKE ('%' || $4 || '%')
OR subject ILIKE ('%' || $4 || '%')
OR summary ILIKE ('%' || $4 || '%')
OR content ILIKE ('%' || $4 || '%')
OR message_id ILIKE ('%' || $4 || '%')
)
"#,
)
.bind(query.account_id)
.bind(mailbox.as_deref())
.bind(source_key.as_deref())
.bind(keyword.as_deref())
.fetch_one(pool)
.await?;
let rows = sqlx::query_as::<_, MailMessage>(
r#"
SELECT
id,
mail_account_id,
source_key,
mailbox,
uid,
message_id,
from_label,
subject,
summary,
content,
content_preview,
raw_headers,
preview_page_id,
preview_url,
received_at,
raw_size,
created_at
FROM ias_mail_messages
WHERE
($1::BIGINT IS NULL OR mail_account_id = $1)
AND ($2::TEXT IS NULL OR mailbox = $2)
AND ($3::TEXT IS NULL OR source_key = $3)
AND (
$4::TEXT IS NULL
OR from_label ILIKE ('%' || $4 || '%')
OR subject ILIKE ('%' || $4 || '%')
OR summary ILIKE ('%' || $4 || '%')
OR content ILIKE ('%' || $4 || '%')
OR message_id ILIKE ('%' || $4 || '%')
)
ORDER BY created_at DESC, id DESC
LIMIT $5 OFFSET $6
"#,
)
.bind(query.account_id)
.bind(mailbox.as_deref())
.bind(source_key.as_deref())
.bind(keyword.as_deref())
.bind(per_page)
.bind(offset)
.fetch_all(pool)
.await?;
Ok((rows, total.0, page, per_page))
}
pub async fn find(pool: &PgPool, id: i64) -> anyhow::Result<Option<MailMessage>> {
let row = sqlx::query_as::<_, MailMessage>(
r#"
@@ -356,4 +442,203 @@ impl MailRepository {
Ok(row)
}
pub async fn find_account(pool: &PgPool, id: i64) -> anyhow::Result<Option<MailAccount>> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
SELECT
id,
name,
source_key,
imap_host,
imap_port,
imap_username,
imap_password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
last_checked_at,
last_error,
created_at,
updated_at
FROM ias_mail_accounts
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn delete_account(pool: &PgPool, id: i64) -> anyhow::Result<bool> {
let result = sqlx::query("DELETE FROM ias_mail_accounts WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_account_enabled(
pool: &PgPool,
id: i64,
enabled: bool,
) -> anyhow::Result<Option<MailAccount>> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
UPDATE ias_mail_accounts
SET enabled = $2, updated_at = NOW()
WHERE id = $1
RETURNING
id,
name,
source_key,
imap_host,
imap_port,
imap_username,
imap_password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
last_checked_at,
last_error,
created_at,
updated_at
"#,
)
.bind(id)
.bind(enabled)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn update_account(
pool: &PgPool,
id: i64,
account: NewMailAccount,
) -> anyhow::Result<Option<MailAccount>> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
UPDATE ias_mail_accounts
SET
name = $2,
source_key = $3,
imap_host = $4,
imap_port = $5,
imap_username = $6,
imap_password = $7,
mailbox = $8,
notify_account_id = $9,
notify_user_id = $10,
poll_seconds = $11,
fetch_limit = $12,
accept_invalid_certs = $13,
enabled = $14,
last_error = NULL,
updated_at = NOW()
WHERE id = $1
RETURNING
id,
name,
source_key,
imap_host,
imap_port,
imap_username,
imap_password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
last_checked_at,
last_error,
created_at,
updated_at
"#,
)
.bind(id)
.bind(account.name)
.bind(account.source_key)
.bind(account.imap_host)
.bind(account.imap_port)
.bind(account.imap_username)
.bind(account.imap_password)
.bind(account.mailbox)
.bind(account.notify_account_id)
.bind(account.notify_user_id)
.bind(account.poll_seconds)
.bind(account.fetch_limit)
.bind(account.accept_invalid_certs)
.bind(account.enabled)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn list_messages_paginated(
pool: &PgPool,
page: i64,
per_page: i64,
) -> anyhow::Result<(Vec<MailMessage>, i64)> {
let per_page = per_page.clamp(1, 100);
let page = page.max(1);
let offset = (page - 1) * per_page;
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ias_mail_messages")
.fetch_one(pool)
.await?;
let rows = sqlx::query_as::<_, MailMessage>(
r#"
SELECT
id,
mail_account_id,
source_key,
mailbox,
uid,
message_id,
from_label,
subject,
summary,
content,
content_preview,
raw_headers,
preview_page_id,
preview_url,
received_at,
raw_size,
created_at
FROM ias_mail_messages
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(per_page)
.bind(offset)
.fetch_all(pool)
.await?;
Ok((rows, total.0))
}
}
fn normalized_filter(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
+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));
});
}
}