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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ias-mail"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+351
-21
@@ -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}]: "),
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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