feat: 模块统一 ias- 前缀、独立版本与内网地址支持 (0.1.7)

- 所有 Rust crate 统一加 ias- 前缀:ai → ias-ai、common → ias-common、context → ias-context、mail → ias-mail、service → ias-service、wechat → ias-wechat
- 新增 ias-main 模块承载 CLI 入口和二进制打包
- 各模块开始独立维护版本号,ias-main 0.1.7 代表整体版本
- 新增 IA_WEB_INTERNAL_URL 环境变量,版本通知同时发送公网和内网链接
- 将已有升级日志翻译为中文
- ias_upgrade_logs 新增 modules JSONB 字段
- 新增 VERSION 文件和 CHANGELOG.md
- 新增 AGENTS.md 项目规则文件
This commit is contained in:
2026-07-06 17:57:48 +08:00
parent ed7f89d44f
commit 25dd0daa8b
91 changed files with 5546 additions and 777 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "ias-mail"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
axum = "0.8.9"
chrono = { version = "0.4", features = ["serde"] }
ias-http = { path = "../ias-http" }
futures = "0.3"
mail-parser = "0.11"
native-tls = "0.2"
tokio-native-tls = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.9.0", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"chrono",
] }
tokio = { version = "1.0", features = ["rt", "sync", "time", "net", "io-util"] }
async-imap = { version = "0.11", default-features = false, features = ["runtime-tokio"] }
+156
View File
@@ -0,0 +1,156 @@
use std::io::{self, Write};
use std::time::Duration;
use sqlx::PgPool;
use crate::config::MailPollerConfig;
use crate::model::NewMailAccount;
use crate::poller::test_connection;
use crate::repository::MailRepository;
pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
println!("开始配置邮件账户。请准备 IMAP 服务器地址、邮箱账号、密码或授权码。");
let host = prompt_required("IMAP 服务器", None)?;
let port = prompt_i32("IMAP 端口", 993, 1, u16::MAX as i32)?;
let username = prompt_required("邮箱账号", None)?;
let password = prompt_required("IMAP 密码或授权码", None)?;
let mailbox = prompt_required("邮箱目录", Some("INBOX"))?;
let name = prompt_required("账户名称", Some(&username))?;
let notify_account_id = prompt_required("通知使用的微信 account_id", None)?;
let notify_user_id = prompt_required("通知接收人的 from_user_id", None)?;
let poll_seconds = prompt_i32("轮询间隔秒数", 60, 10, 86400)?;
let fetch_limit = prompt_i32("单次最多抓取邮件数", 10, 1, 100)?;
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", false)?;
let enabled = prompt_bool("是否启用该邮件账户", true)?;
let 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)? {
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!("成功");
}
let saved = MailRepository::upsert_account(pool, account).await?;
println!(
"邮件账户已保存:#{} {} {}@{} mailbox={} enabled={}",
saved.id, saved.name, saved.imap_username, saved.imap_host, saved.mailbox, saved.enabled
);
println!("服务下次启动或重启后会加载该邮件账户。");
Ok(())
}
pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<()> {
let accounts = MailRepository::list_accounts(pool).await?;
if accounts.is_empty() {
println!("尚未配置邮件账户。可使用 ias mail auth 添加。");
return Ok(());
}
println!("已配置邮件账户:");
for account in accounts {
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!(
" - #{} {} {}@{} mailbox={} enabled={} poll={}s last_checked={} last_error={}",
account.id,
account.name,
account.imap_username,
account.imap_host,
account.mailbox,
account.enabled,
account.poll_seconds,
last_checked,
last_error
);
}
Ok(())
}
fn prompt_required(label: &str, default: Option<&str>) -> anyhow::Result<String> {
loop {
let value = prompt_line(label, default)?;
if !value.trim().is_empty() {
return Ok(value.trim().to_string());
}
println!("{label} 不能为空。");
}
}
fn prompt_i32(label: &str, default: i32, min: i32, max: i32) -> anyhow::Result<i32> {
loop {
let value = prompt_line(label, Some(&default.to_string()))?;
match value.trim().parse::<i32>() {
Ok(value) if (min..=max).contains(&value) => return Ok(value),
_ => println!("{label} 必须是 {min}{max} 之间的整数。"),
}
}
}
fn prompt_bool(label: &str, default: bool) -> anyhow::Result<bool> {
let default_text = if default { "Y/n" } else { "y/N" };
loop {
let value = prompt_line(&format!("{label} ({default_text})"), None)?;
let value = value.trim().to_ascii_lowercase();
if value.is_empty() {
return Ok(default);
}
match value.as_str() {
"y" | "yes" | "true" | "1" | "" => return Ok(true),
"n" | "no" | "false" | "0" | "" => return Ok(false),
_ => println!("请输入 y 或 n。"),
}
}
}
fn prompt_line(label: &str, default: Option<&str>) -> anyhow::Result<String> {
match default {
Some(default) => print!("{label} [{default}]: "),
None => print!("{label}: "),
}
io::stdout().flush()?;
let mut value = String::new();
io::stdin().read_line(&mut value)?;
let value = value.trim().to_string();
if value.is_empty() {
Ok(default.unwrap_or_default().to_string())
} else {
Ok(value)
}
}
+119
View File
@@ -0,0 +1,119 @@
use std::env;
use std::time::Duration;
use anyhow::anyhow;
use crate::model::MailAccount;
#[derive(Debug, Clone)]
pub struct MailPollerConfig {
pub mail_account_id: Option<i64>,
pub name: String,
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub mailbox: String,
pub source_key: String,
pub notify_account_id: String,
pub notify_user_id: String,
pub poll_interval: Duration,
pub fetch_limit: usize,
pub accept_invalid_certs: bool,
}
impl MailPollerConfig {
pub fn from_account(account: MailAccount) -> Self {
Self {
mail_account_id: Some(account.id),
name: account.name,
source_key: account.source_key,
host: account.imap_host,
port: account.imap_port.clamp(1, u16::MAX as i32) as u16,
username: account.imap_username,
password: account.imap_password,
mailbox: account.mailbox,
notify_account_id: account.notify_account_id,
notify_user_id: account.notify_user_id,
poll_interval: Duration::from_secs(account.poll_seconds.max(10) as u64),
fetch_limit: account.fetch_limit.clamp(1, 100) as usize,
accept_invalid_certs: account.accept_invalid_certs,
}
}
pub fn from_env() -> anyhow::Result<Option<Self>> {
if !env_bool("IA_MAIL_ENABLED", true) {
return Ok(None);
}
let host = optional_env("IA_MAIL_IMAP_HOST");
let username = optional_env("IA_MAIL_IMAP_USERNAME");
let password = optional_env("IA_MAIL_IMAP_PASSWORD");
let notify_account_id = optional_env("IA_MAIL_NOTIFY_ACCOUNT_ID");
let notify_user_id = optional_env("IA_MAIL_NOTIFY_USER_ID")
.or_else(|| optional_env("IA_MAIL_NOTIFY_FROM_USER_ID"));
let required_values = [
host.as_ref(),
username.as_ref(),
password.as_ref(),
notify_account_id.as_ref(),
notify_user_id.as_ref(),
];
if required_values.iter().all(|value| value.is_none()) {
return Ok(None);
}
if required_values.iter().any(|value| value.is_none()) {
return Err(anyhow!(
"邮件轮询配置不完整,需要 IA_MAIL_IMAP_HOST、IA_MAIL_IMAP_USERNAME、IA_MAIL_IMAP_PASSWORD、IA_MAIL_NOTIFY_ACCOUNT_ID、IA_MAIL_NOTIFY_USER_ID"
));
}
let host = host.unwrap();
let username = username.unwrap();
let port = optional_env("IA_MAIL_IMAP_PORT")
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(993);
let mailbox = optional_env("IA_MAIL_IMAP_MAILBOX").unwrap_or_else(|| "INBOX".to_string());
let poll_seconds = optional_env("IA_MAIL_POLL_SECONDS")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60)
.max(10);
let fetch_limit = optional_env("IA_MAIL_FETCH_LIMIT")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(10)
.clamp(1, 100);
Ok(Some(Self {
mail_account_id: None,
name: username.clone(),
source_key: format!("imap:{host}:{username}"),
host,
port,
username,
password: password.unwrap(),
mailbox,
notify_account_id: notify_account_id.unwrap(),
notify_user_id: notify_user_id.unwrap(),
poll_interval: Duration::from_secs(poll_seconds),
fetch_limit,
accept_invalid_certs: env_bool("IA_MAIL_ACCEPT_INVALID_CERTS", false),
}))
}
}
fn optional_env(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn env_bool(name: &str, default: bool) -> bool {
env::var(name)
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(default)
}
+10
View File
@@ -0,0 +1,10 @@
pub mod auth;
pub mod config;
pub mod model;
pub mod poller;
pub mod repository;
pub mod routes;
pub use config::MailPollerConfig;
pub use model::MailNotification;
pub use poller::start_mail_polling;
+92
View File
@@ -0,0 +1,92 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow;
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MailAccount {
pub id: i64,
pub name: String,
pub source_key: String,
pub imap_host: String,
pub imap_port: i32,
pub imap_username: String,
pub imap_password: String,
pub mailbox: String,
pub notify_account_id: String,
pub notify_user_id: String,
pub poll_seconds: i32,
pub fetch_limit: i32,
pub accept_invalid_certs: bool,
pub enabled: bool,
pub last_checked_at: Option<DateTime<Utc>>,
pub last_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct NewMailAccount {
pub name: String,
pub source_key: String,
pub imap_host: String,
pub imap_port: i32,
pub imap_username: String,
pub imap_password: String,
pub mailbox: String,
pub notify_account_id: String,
pub notify_user_id: String,
pub poll_seconds: i32,
pub fetch_limit: i32,
pub accept_invalid_certs: bool,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MailMessage {
pub id: i64,
pub mail_account_id: Option<i64>,
pub source_key: String,
pub mailbox: String,
pub uid: String,
pub message_id: Option<String>,
pub from_label: String,
pub subject: String,
pub summary: String,
pub content: String,
pub content_preview: String,
pub raw_headers: Option<String>,
pub preview_page_id: Option<i64>,
pub preview_url: Option<String>,
pub received_at: Option<DateTime<Utc>>,
pub raw_size: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct NewMailMessage {
pub mail_account_id: Option<i64>,
pub source_key: String,
pub mailbox: String,
pub uid: String,
pub message_id: Option<String>,
pub from_label: String,
pub subject: String,
pub summary: String,
pub content: String,
pub content_preview: String,
pub raw_headers: Option<String>,
pub received_at: Option<DateTime<Utc>>,
pub raw_size: i64,
}
#[derive(Debug, Clone)]
pub struct MailNotification {
pub account_id: String,
pub from_user_id: String,
pub text: String,
}
#[derive(Debug, Deserialize)]
pub struct MailListQuery {
pub limit: Option<i64>,
}
+401
View File
@@ -0,0 +1,401 @@
use anyhow::{Context, anyhow};
use async_imap::types::Fetch;
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use ias_http::web::{
NewWebPage, WebPageRepository, generate_slug, public_path, public_url_for_path,
};
use mail_parser::MessageParser;
use serde_json::json;
use sqlx::PgPool;
use tokio::net::TcpStream;
use tokio::sync::mpsc::Sender;
use tokio::task::JoinHandle;
use crate::config::MailPollerConfig;
use crate::model::{MailMessage, MailNotification, NewMailMessage};
use crate::repository::MailRepository;
#[derive(Debug, Clone)]
struct FetchedMail {
uid: String,
message_id: Option<String>,
from_label: String,
subject: String,
summary: String,
content: String,
raw_headers: Option<String>,
received_at: Option<DateTime<Utc>>,
raw_size: i64,
}
pub fn start_mail_polling(
pool: PgPool,
config: MailPollerConfig,
notifications: Sender<MailNotification>,
) -> JoinHandle<()> {
tokio::spawn(async move {
println!(
"邮件轮询已启动: {} {}@{} mailbox={} interval={}s",
config.name,
config.username,
config.host,
config.mailbox,
config.poll_interval.as_secs()
);
loop {
if let Err(error) = poll_once(&pool, &config, &notifications).await {
eprintln!("邮件轮询失败: {error:#}");
if let Err(update_error) = MailRepository::record_poll_error(
&pool,
config.mail_account_id,
error.to_string(),
)
.await
{
eprintln!("记录邮件轮询错误失败: {update_error}");
}
}
tokio::time::sleep(config.poll_interval).await;
}
})
}
async fn poll_once(
pool: &PgPool,
config: &MailPollerConfig,
notifications: &Sender<MailNotification>,
) -> anyhow::Result<()> {
let messages = fetch_unseen_messages(config).await?;
MailRepository::record_poll_success(pool, config.mail_account_id).await?;
for fetched in messages {
let Some(inserted) = MailRepository::insert_if_new(
pool,
NewMailMessage {
mail_account_id: config.mail_account_id,
source_key: config.source_key.clone(),
mailbox: config.mailbox.clone(),
uid: fetched.uid.clone(),
message_id: fetched.message_id.clone(),
from_label: fetched.from_label.clone(),
subject: fetched.subject.clone(),
summary: fetched.summary.clone(),
content: fetched.content.clone(),
content_preview: preview_text(&fetched.content, 4000),
raw_headers: fetched.raw_headers.clone(),
received_at: fetched.received_at,
raw_size: fetched.raw_size,
},
)
.await?
else {
continue;
};
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(());
}
}
Ok(())
}
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>> {
let addr = format!("{}:{}", config.host, config.port);
let tcp_stream = TcpStream::connect(&addr)
.await
.with_context(|| format!("连接 IMAP 服务器失败: {addr}"))?;
let native_tls = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(config.accept_invalid_certs)
.build()?;
let tls = tokio_native_tls::TlsConnector::from(native_tls);
let tls_stream = tls
.connect(&config.host, tcp_stream)
.await
.with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?;
let client = async_imap::Client::new(tls_stream);
let mut session = client
.login(&config.username, &config.password)
.await
.map_err(|(error, _)| error)
.context("IMAP 登录失败")?;
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 _ = 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)")
.await
.context("抓取邮件正文失败")?
.try_collect()
.await
.context("收集抓取结果失败")?;
let mut messages = Vec::new();
for fetch in &fetches {
let Some(body) = fetch.body() else {
continue;
};
let uid = fetch
.uid
.map(|uid| uid.to_string())
.unwrap_or_else(|| format!("seq-{}", fetch.message));
match parse_message(uid, body) {
Ok(message) => messages.push(message),
Err(error) => eprintln!("解析邮件失败: {error:#}"),
}
}
let _ = session.logout().await;
Ok(messages)
}
async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> {
let addr = format!("{}:{}", config.host, config.port);
let tcp_stream = TcpStream::connect(&addr)
.await
.with_context(|| format!("连接 IMAP 服务器失败: {addr}"))?;
let native_tls = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(config.accept_invalid_certs)
.build()?;
let tls = tokio_native_tls::TlsConnector::from(native_tls);
let tls_stream = tls
.connect(&config.host, tcp_stream)
.await
.with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?;
let client = async_imap::Client::new(tls_stream);
let mut session = client
.login(&config.username, &config.password)
.await
.map_err(|(error, _)| error)
.context("IMAP 登录失败")?;
session
.select(&config.mailbox)
.await
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
let _ = session.logout().await;
Ok(())
}
fn parse_message(uid: String, raw: &[u8]) -> anyhow::Result<FetchedMail> {
let parsed = MessageParser::default()
.parse(raw)
.ok_or_else(|| anyhow!("mail-parser 未能解析邮件"))?;
let subject = parsed
.subject()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("(无主题)")
.to_string();
let content = parsed
.body_text(0)
.or_else(|| parsed.body_html(0))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "(邮件正文为空或暂不支持解析)".to_string());
let from_label = header_value(raw, "From").unwrap_or_else(|| "(未知发件人)".to_string());
let message_id = header_value(raw, "Message-ID");
let raw_headers = raw_headers(raw);
let received_at = parsed
.date()
.and_then(|date| DateTime::parse_from_rfc3339(&date.to_rfc3339()).ok())
.map(|time| time.with_timezone(&Utc));
let summary = summarize_mail(&from_label, &subject, &content);
Ok(FetchedMail {
uid,
message_id,
from_label,
subject,
summary,
content,
raw_headers,
received_at,
raw_size: raw.len() as i64,
})
}
async fn create_preview_page(
pool: &PgPool,
message: MailMessage,
content: &str,
) -> anyhow::Result<MailMessage> {
let received_at = message
.received_at
.map(|time| time.to_rfc3339())
.unwrap_or_default();
let metadata = json!({
"from": message.from_label,
"subject": message.subject,
"message_id": message.message_id,
"mailbox": message.mailbox,
"uid": message.uid,
"received_at": received_at,
})
.to_string();
let page = WebPageRepository::create(
pool,
NewWebPage {
slug: generate_slug(),
page_type: "mail".to_string(),
title: message.subject.clone(),
summary: Some(message.summary.clone()),
content: content.to_string(),
source_label: Some(message.from_label.clone()),
metadata,
},
)
.await?;
let preview_url = public_url_for_path(&public_path(&page));
MailRepository::attach_preview(pool, message.id, page.id, preview_url).await
}
fn notification_text(message: &MailMessage) -> String {
let preview_url = message
.preview_url
.as_deref()
.unwrap_or("(预览地址生成失败)");
format!(
"收到新邮件\n发件人:{}\n主题:{}\n摘要:{}\n预览:{}",
message.from_label, message.subject, message.summary, preview_url
)
}
fn summarize_mail(from_label: &str, subject: &str, content: &str) -> String {
let snippet = preview_text(content, 180);
format!("来自 {from_label} 的邮件「{subject}」:{snippet}")
}
fn preview_text(content: &str, max_chars: usize) -> String {
let compact = content.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() <= max_chars {
return compact;
}
let mut value = compact.chars().take(max_chars).collect::<String>();
value.push_str("...");
value
}
fn header_value(raw: &[u8], name: &str) -> Option<String> {
let raw = String::from_utf8_lossy(raw);
let header_block = split_headers(raw.as_ref());
let target = name.to_ascii_lowercase();
let mut current_name = String::new();
let mut current_value = String::new();
for line in header_block.lines() {
if line.starts_with(' ') || line.starts_with('\t') {
if !current_value.is_empty() {
current_value.push(' ');
current_value.push_str(line.trim());
}
continue;
}
if !current_name.is_empty()
&& current_name.eq_ignore_ascii_case(&target)
&& !current_value.trim().is_empty()
{
return Some(current_value.trim().to_string());
}
let Some((field, value)) = line.split_once(':') else {
current_name.clear();
current_value.clear();
continue;
};
current_name = field.trim().to_ascii_lowercase();
current_value = value.trim().to_string();
}
if current_name.eq_ignore_ascii_case(&target) && !current_value.trim().is_empty() {
Some(current_value.trim().to_string())
} else {
None
}
}
fn raw_headers(raw: &[u8]) -> Option<String> {
let raw = String::from_utf8_lossy(raw);
let headers = split_headers(raw.as_ref()).trim().to_string();
(!headers.is_empty()).then_some(headers)
}
fn split_headers(raw: &str) -> &str {
raw.split_once("\r\n\r\n")
.map(|(headers, _)| headers)
.or_else(|| raw.split_once("\n\n").map(|(headers, _)| headers))
.unwrap_or(raw)
}
#[cfg(test)]
mod tests {
use super::{header_value, preview_text, summarize_mail};
#[test]
fn reads_folded_header() {
let raw = b"From: Alice\r\n <alice@example.com>\r\nSubject: Hi\r\n\r\nBody";
assert_eq!(
header_value(raw, "from").as_deref(),
Some("Alice <alice@example.com>")
);
}
#[test]
fn truncates_preview_text() {
assert_eq!(preview_text("a b c d", 10), "a b c d");
assert_eq!(preview_text("abcdef", 3), "abc...");
}
#[test]
fn builds_summary() {
let summary = summarize_mail("Alice", "Hello", "first line\nsecond line");
assert!(summary.contains("Alice"));
assert!(summary.contains("Hello"));
assert!(summary.contains("first line second line"));
}
}
+359
View File
@@ -0,0 +1,359 @@
use sqlx::PgPool;
use crate::model::{MailAccount, MailMessage, NewMailAccount, NewMailMessage};
pub struct MailRepository;
impl MailRepository {
pub async fn upsert_account(
pool: &PgPool,
account: NewMailAccount,
) -> anyhow::Result<MailAccount> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
INSERT INTO ias_mail_accounts (
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
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (source_key) DO UPDATE SET
name = EXCLUDED.name,
imap_host = EXCLUDED.imap_host,
imap_port = EXCLUDED.imap_port,
imap_username = EXCLUDED.imap_username,
imap_password = EXCLUDED.imap_password,
mailbox = EXCLUDED.mailbox,
notify_account_id = EXCLUDED.notify_account_id,
notify_user_id = EXCLUDED.notify_user_id,
poll_seconds = EXCLUDED.poll_seconds,
fetch_limit = EXCLUDED.fetch_limit,
accept_invalid_certs = EXCLUDED.accept_invalid_certs,
enabled = EXCLUDED.enabled,
last_error = NULL,
updated_at = NOW()
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(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_one(pool)
.await?;
Ok(row)
}
pub async fn list_enabled_accounts(pool: &PgPool) -> anyhow::Result<Vec<MailAccount>> {
let rows = 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 enabled = TRUE
ORDER BY id
"#,
)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<Vec<MailAccount>> {
let rows = 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
ORDER BY id
"#,
)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn record_poll_success(pool: &PgPool, account_id: Option<i64>) -> anyhow::Result<()> {
let Some(account_id) = account_id else {
return Ok(());
};
sqlx::query(
r#"
UPDATE ias_mail_accounts
SET last_checked_at = NOW(), last_error = NULL, updated_at = NOW()
WHERE id = $1
"#,
)
.bind(account_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn record_poll_error(
pool: &PgPool,
account_id: Option<i64>,
error: String,
) -> anyhow::Result<()> {
let Some(account_id) = account_id else {
return Ok(());
};
sqlx::query(
r#"
UPDATE ias_mail_accounts
SET last_checked_at = NOW(), last_error = $2, updated_at = NOW()
WHERE id = $1
"#,
)
.bind(account_id)
.bind(error)
.execute(pool)
.await?;
Ok(())
}
pub async fn insert_if_new(
pool: &PgPool,
message: NewMailMessage,
) -> anyhow::Result<Option<MailMessage>> {
let row = sqlx::query_as::<_, MailMessage>(
r#"
INSERT INTO ias_mail_messages (
mail_account_id,
source_key,
mailbox,
uid,
message_id,
from_label,
subject,
summary,
content,
content_preview,
raw_headers,
received_at,
raw_size
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (source_key, mailbox, uid) DO NOTHING
RETURNING
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
"#,
)
.bind(message.mail_account_id)
.bind(message.source_key)
.bind(message.mailbox)
.bind(message.uid)
.bind(message.message_id)
.bind(message.from_label)
.bind(message.subject)
.bind(message.summary)
.bind(message.content)
.bind(message.content_preview)
.bind(message.raw_headers)
.bind(message.received_at)
.bind(message.raw_size)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn attach_preview(
pool: &PgPool,
id: i64,
preview_page_id: i64,
preview_url: String,
) -> anyhow::Result<MailMessage> {
let row = sqlx::query_as::<_, MailMessage>(
r#"
UPDATE ias_mail_messages
SET preview_page_id = $2, preview_url = $3
WHERE id = $1
RETURNING
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
"#,
)
.bind(id)
.bind(preview_page_id)
.bind(preview_url)
.fetch_one(pool)
.await?;
Ok(row)
}
pub async fn list_recent(pool: &PgPool, limit: i64) -> anyhow::Result<Vec<MailMessage>> {
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
"#,
)
.bind(limit.clamp(1, 100))
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn find(pool: &PgPool, id: i64) -> anyhow::Result<Option<MailMessage>> {
let row = 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 id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
}
+82
View File
@@ -0,0 +1,82 @@
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Extension, Json, Router};
use serde_json::{Value, json};
use sqlx::PgPool;
use crate::model::MailListQuery;
use crate::repository::MailRepository;
type ApiError = (StatusCode, Json<Value>);
#[derive(Clone)]
pub struct MailRouteState {
pub db: PgPool,
}
impl MailRouteState {
pub fn new(db: PgPool) -> Self {
Self { db }
}
}
pub fn routes<S>(db: PgPool) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/mail-api/messages", get(list_messages))
.route("/mail-api/messages/{id}", get(get_message))
.layer(Extension(MailRouteState::new(db)))
}
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))
.await
.map_err(api_internal_error)?;
Ok(Json(json!({
"success": true,
"messages": messages,
})))
}
async fn get_message(
Extension(state): Extension<MailRouteState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
let message = MailRepository::find(&state.db, id)
.await
.map_err(api_internal_error)?
.ok_or_else(|| api_not_found("邮件记录不存在"))?;
Ok(Json(json!({
"success": true,
"message": message,
})))
}
fn api_not_found(message: impl Into<String>) -> ApiError {
(
StatusCode::NOT_FOUND,
Json(json!({
"success": false,
"message": message.into(),
})),
)
}
fn api_internal_error(error: anyhow::Error) -> ApiError {
eprintln!("mail 接口失败: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"success": false,
"message": "mail 接口失败",
})),
)
}