feat: 0.2.27 - 抽离数据库基础设施模块
- 新增 ias-db crate,集中管理 PostgreSQL 连接池和 SQLx 迁移
- 迁移 ias-service/migrations 到 ias-db/migrations
- ias-main 改用 ias_db::connect_db(),ias-service 只接 PgPool
- 新增操作日志 HTTP 查询接口 (GET /v1/logs, GET /v1/logs/{id})
- 新增操作日志 Web 前端页面,支持按级别/模块/关键词筛选
- 消息队列关键节点增加操作日志写入
- ias-mail 新增 mark_remote_message_seen,IMAP 已读同步
- 工作流配置加载改为文件级容错 (load_configs_with_warnings)
- ias-context 工具调用说明更新为 query_api_docs + call_http_api
- 新增高德地图地理编码 HTTP 能力配置
模块版本: ias-db 0.1.0, ias-ai 0.1.14, ias-context 0.1.6, ias-http 0.1.15, ias-mail 0.1.16, ias-service 0.1.25, ias-main 0.2.27
This commit is contained in:
+64
-31
@@ -171,28 +171,37 @@ pub async fn test_connection(config: MailPollerConfig) -> anyhow::Result<()> {
|
||||
test_connection_async(&config).await
|
||||
}
|
||||
|
||||
pub async fn mark_remote_message_seen(config: &MailPollerConfig, uid: &str) -> anyhow::Result<()> {
|
||||
let uid = normalized_imap_uid(uid)?;
|
||||
let mut session = connect_imap_session(config).await?;
|
||||
session
|
||||
.select(&config.mailbox)
|
||||
.await
|
||||
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
|
||||
let _: Vec<Fetch> = session
|
||||
.uid_store(uid, "+FLAGS.SILENT (\\Seen)")
|
||||
.await
|
||||
.with_context(|| format!("设置邮件已读失败 uid={uid}"))?
|
||||
.try_collect()
|
||||
.await
|
||||
.with_context(|| format!("确认邮件已读同步失败 uid={uid}"))?;
|
||||
let fetches: Vec<Fetch> = session
|
||||
.uid_fetch(uid, "(FLAGS)")
|
||||
.await
|
||||
.with_context(|| format!("读取邮件已读状态失败 uid={uid}"))?
|
||||
.try_collect()
|
||||
.await
|
||||
.with_context(|| format!("确认邮件已读状态失败 uid={uid}"))?;
|
||||
let seen = fetches.iter().any(is_seen);
|
||||
let _ = session.logout().await;
|
||||
if !seen {
|
||||
anyhow::bail!("服务器未确认邮件已读 uid={uid}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
.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 登录失败")?;
|
||||
|
||||
let mut session = connect_imap_session(config).await?;
|
||||
let mailbox = session
|
||||
.select(&config.mailbox)
|
||||
.await
|
||||
@@ -233,6 +242,14 @@ async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn normalized_imap_uid(uid: &str) -> anyhow::Result<&str> {
|
||||
let uid = uid.trim();
|
||||
if uid.is_empty() || !uid.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
anyhow::bail!("邮件 UID 无法用于 IMAP 同步: {uid}");
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
fn recent_sequence_set(exists: u32, limit: u32) -> Option<String> {
|
||||
if exists == 0 || limit == 0 {
|
||||
return None;
|
||||
@@ -251,6 +268,18 @@ fn is_seen(fetch: &Fetch) -> bool {
|
||||
}
|
||||
|
||||
async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> {
|
||||
let mut session = connect_imap_session(config).await?;
|
||||
session
|
||||
.select(&config.mailbox)
|
||||
.await
|
||||
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
|
||||
let _ = session.logout().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_imap_session(
|
||||
config: &MailPollerConfig,
|
||||
) -> anyhow::Result<async_imap::Session<tokio_native_tls::TlsStream<TcpStream>>> {
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let tcp_stream = TcpStream::connect(&addr)
|
||||
.await
|
||||
@@ -267,18 +296,12 @@ async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()>
|
||||
.with_context(|| format!("IMAP TLS 握手失败: {}", config.host))?;
|
||||
|
||||
let client = async_imap::Client::new(tls_stream);
|
||||
let mut session = client
|
||||
let 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(())
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedMail> {
|
||||
@@ -506,8 +529,9 @@ fn split_headers(raw: &str) -> &str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, parse_message,
|
||||
preview_text, recent_sequence_set, should_skip_fetched_message, summarize_mail,
|
||||
AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, normalized_imap_uid,
|
||||
parse_message, preview_text, recent_sequence_set, should_skip_fetched_message,
|
||||
summarize_mail,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -530,6 +554,15 @@ mod tests {
|
||||
assert!(!should_skip_fetched_message(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_numeric_imap_uid_for_server_mutation() {
|
||||
assert_eq!(normalized_imap_uid("123").unwrap(), "123");
|
||||
assert_eq!(normalized_imap_uid(" 123 ").unwrap(), "123");
|
||||
assert!(normalized_imap_uid("").is_err());
|
||||
assert!(normalized_imap_uid("seq-1").is_err());
|
||||
assert!(normalized_imap_uid("1:*").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_folded_header() {
|
||||
let raw = b"From: Alice\r\n <alice@example.com>\r\nSubject: Hi\r\n\r\nBody";
|
||||
|
||||
+34
-4
@@ -5,7 +5,9 @@ use axum::{Extension, Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::model::MailListQuery;
|
||||
use crate::config::MailPollerConfig;
|
||||
use crate::model::{MailListQuery, MailMessage};
|
||||
use crate::poller::mark_remote_message_seen;
|
||||
use crate::repository::MailRepository;
|
||||
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
@@ -60,12 +62,11 @@ async fn get_message(
|
||||
.map_err(api_internal_error)?
|
||||
.ok_or_else(|| api_not_found("邮件记录不存在"))?;
|
||||
|
||||
// 标记已读
|
||||
if !message.seen {
|
||||
match MailRepository::mark_as_seen(&state.db, id).await {
|
||||
match mark_message_seen(&state.db, &message).await {
|
||||
Ok(true) => message.seen = true,
|
||||
Ok(false) => {}
|
||||
Err(error) => eprintln!("标记邮件已读失败 id={id}: {error}"),
|
||||
Err(error) => eprintln!("同步邮件已读失败 id={id}: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +78,35 @@ async fn get_message(
|
||||
|
||||
// ── 辅助函数 ─────────────────────────────────────────────
|
||||
|
||||
async fn mark_message_seen(pool: &PgPool, message: &MailMessage) -> anyhow::Result<bool> {
|
||||
let mut config = mail_config_for_message(pool, message)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("没有可用于同步已读状态的邮件账户配置"))?;
|
||||
config.mailbox = message.mailbox.clone();
|
||||
mark_remote_message_seen(&config, &message.uid).await?;
|
||||
MailRepository::mark_as_seen(pool, message.id).await
|
||||
}
|
||||
|
||||
async fn mail_config_for_message(
|
||||
pool: &PgPool,
|
||||
message: &MailMessage,
|
||||
) -> anyhow::Result<Option<MailPollerConfig>> {
|
||||
if let Some(account_id) = message.mail_account_id {
|
||||
let account = MailRepository::find_account(pool, account_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("邮件账户不存在: {account_id}"))?;
|
||||
return Ok(Some(MailPollerConfig::from_account(account)));
|
||||
}
|
||||
|
||||
let Some(config) = MailPollerConfig::from_env()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if config.source_key == message.source_key {
|
||||
return Ok(Some(config));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn api_not_found(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
|
||||
Reference in New Issue
Block a user