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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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, ¬ifications).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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 接口失败",
|
||||
})),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user