af6cfdaa83
- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议 - 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险 - 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则 - 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
229 lines
6.7 KiB
Rust
229 lines
6.7 KiB
Rust
use axum::extract::{Path, Query};
|
|
use axum::http::StatusCode;
|
|
use axum::routing::get;
|
|
use axum::{Extension, Json, Router};
|
|
use serde::Serialize;
|
|
use serde_json::{Value, json};
|
|
use sqlx::PgPool;
|
|
|
|
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>);
|
|
|
|
#[derive(Clone)]
|
|
pub struct MailRouteState {
|
|
pub db: PgPool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct MailRenderingSettings {
|
|
trusted_sender: bool,
|
|
remote_resources_allowed: bool,
|
|
}
|
|
|
|
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("/v1/mail/messages", get(list_messages))
|
|
.route("/v1/mail/search", get(list_messages))
|
|
.route("/v1/mail/messages/{id}", get(get_message))
|
|
.layer(Extension(MailRouteState::new(db)))
|
|
}
|
|
|
|
// ── JSON API ─────────────────────────────────────────────
|
|
|
|
async fn list_messages(
|
|
Extension(state): Extension<MailRouteState>,
|
|
Query(query): Query<MailListQuery>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
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,
|
|
})))
|
|
}
|
|
|
|
async fn get_message(
|
|
Extension(state): Extension<MailRouteState>,
|
|
Path(id): Path<i64>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let mut message = MailRepository::find(&state.db, id)
|
|
.await
|
|
.map_err(api_internal_error)?
|
|
.ok_or_else(|| api_not_found("邮件记录不存在"))?;
|
|
|
|
if !message.seen {
|
|
match mark_message_seen(&state.db, &message).await {
|
|
Ok(true) => message.seen = true,
|
|
Ok(false) => {}
|
|
Err(error) => eprintln!("同步邮件已读失败 id={id}: {error:#}"),
|
|
}
|
|
}
|
|
let rendering = rendering_settings_for_message(&state.db, &message).await;
|
|
|
|
Ok(Json(json!({
|
|
"success": true,
|
|
"message": message,
|
|
"rendering": rendering,
|
|
})))
|
|
}
|
|
|
|
// ── 辅助函数 ─────────────────────────────────────────────
|
|
|
|
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)
|
|
}
|
|
|
|
async fn rendering_settings_for_message(
|
|
pool: &PgPool,
|
|
message: &MailMessage,
|
|
) -> MailRenderingSettings {
|
|
let config = match mail_config_for_message(pool, message).await {
|
|
Ok(config) => config,
|
|
Err(error) => {
|
|
eprintln!("读取邮件渲染配置失败 id={}: {error:#}", message.id);
|
|
None
|
|
}
|
|
};
|
|
let trusted_sender = config
|
|
.as_ref()
|
|
.map(|config| sender_matches_trusted_list(&message.from_label, &config.trusted_senders))
|
|
.unwrap_or(false);
|
|
|
|
MailRenderingSettings {
|
|
trusted_sender,
|
|
remote_resources_allowed: trusted_sender,
|
|
}
|
|
}
|
|
|
|
fn sender_matches_trusted_list(from_label: &str, trusted_senders: &[String]) -> bool {
|
|
if trusted_senders.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let candidates = sender_candidates(from_label);
|
|
trusted_senders.iter().any(|trusted| {
|
|
let trusted_candidates = sender_candidates(trusted);
|
|
!trusted_candidates.is_empty()
|
|
&& trusted_candidates
|
|
.iter()
|
|
.any(|trusted| candidates.iter().any(|candidate| candidate == trusted))
|
|
})
|
|
}
|
|
|
|
fn sender_candidates(from_label: &str) -> Vec<String> {
|
|
let mut values = Vec::new();
|
|
if let Some(start) = from_label.find('<') {
|
|
if let Some(end) = from_label[start + 1..].find('>') {
|
|
push_sender_candidate(&mut values, &from_label[start + 1..start + 1 + end]);
|
|
}
|
|
} else {
|
|
// 没有尖括号时,整个 label 可能就是纯邮箱地址
|
|
push_sender_candidate(&mut values, from_label);
|
|
}
|
|
|
|
values
|
|
}
|
|
|
|
fn push_sender_candidate(values: &mut Vec<String>, value: &str) {
|
|
let value = value
|
|
.trim()
|
|
.trim_matches(['<', '>', '"', '\'', '(', ')', '[', ']'])
|
|
.to_ascii_lowercase();
|
|
if value.contains('@') && !values.iter().any(|existing| existing == &value) {
|
|
values.push(value);
|
|
}
|
|
}
|
|
|
|
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 接口失败",
|
|
})),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::sender_matches_trusted_list;
|
|
|
|
#[test]
|
|
fn matches_trusted_sender_email_from_display_label() {
|
|
assert!(sender_matches_trusted_list(
|
|
"Facebook <reminders@facebookmail.com>",
|
|
&["reminders@facebookmail.com".to_string()]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_sender_matching_is_case_insensitive_and_exact() {
|
|
assert!(sender_matches_trusted_list(
|
|
"Alice <ALICE@example.com>",
|
|
&["alice@example.com".to_string()]
|
|
));
|
|
assert!(sender_matches_trusted_list(
|
|
"Alice <alice@example.com>",
|
|
&["Trusted <alice@example.com>".to_string()]
|
|
));
|
|
assert!(!sender_matches_trusted_list(
|
|
"mallory@example.net",
|
|
&["alice@example.com".to_string()]
|
|
));
|
|
}
|
|
}
|