feat: 拆分 worker 架构并增强 assistant 安全保护
- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议 - 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险 - 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则 - 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ias-mail"
|
||||
version = "0.1.16"
|
||||
version = "0.1.17"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+92
-2
@@ -3,7 +3,7 @@ use std::time::Duration;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::MailPollerConfig;
|
||||
use crate::config::{MailPollerConfig, parse_setting_list};
|
||||
use crate::model::NewMailAccount;
|
||||
use crate::poller::{pull_recent_mail, test_connection};
|
||||
use crate::repository::MailRepository;
|
||||
@@ -19,6 +19,11 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
|
||||
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 notification_subject_keywords = prompt_setting_list(
|
||||
"推送标题关键字(逗号分隔,留空表示全部推送,输入 - 清空)",
|
||||
None,
|
||||
)?;
|
||||
let trusted_senders = prompt_setting_list("可信发件人邮箱列表(逗号分隔,输入 - 清空)", None)?;
|
||||
let poll_seconds = prompt_i32("轮询间隔秒数", 60, 10, 86400)?;
|
||||
let fetch_limit = prompt_i32("单次最多抓取邮件数", 20, 1, 100)?;
|
||||
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", false)?;
|
||||
@@ -34,6 +39,8 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -68,7 +75,7 @@ pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<()> {
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let last_error = account.last_error.as_deref().unwrap_or("-");
|
||||
println!(
|
||||
" - #{} {} {}@{} mailbox={} enabled={} poll={}s last_checked={} last_error={}",
|
||||
" - #{} {} {}@{} mailbox={} enabled={} poll={}s push_filter={} trusted={} last_checked={} last_error={}",
|
||||
account.id,
|
||||
account.name,
|
||||
account.imap_username,
|
||||
@@ -76,6 +83,8 @@ pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<()> {
|
||||
account.mailbox,
|
||||
account.enabled,
|
||||
account.poll_seconds,
|
||||
format_setting_list(&account.notification_subject_keywords, "全部"),
|
||||
format_setting_list(&account.trusted_senders, "无"),
|
||||
last_checked,
|
||||
last_error
|
||||
);
|
||||
@@ -133,6 +142,8 @@ fn config_from_new_account(
|
||||
source_key: account.source_key.clone(),
|
||||
notify_account_id: account.notify_account_id.clone(),
|
||||
notify_user_id: account.notify_user_id.clone(),
|
||||
notification_subject_keywords: account.notification_subject_keywords.clone(),
|
||||
trusted_senders: account.trusted_senders.clone(),
|
||||
poll_interval: Duration::from_secs(account.poll_seconds as u64),
|
||||
fetch_limit: account.fetch_limit as usize,
|
||||
accept_invalid_certs,
|
||||
@@ -182,6 +193,29 @@ fn prompt_bool(label: &str, default: bool) -> anyhow::Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_setting_list(label: &str, default: Option<&[String]>) -> anyhow::Result<Vec<String>> {
|
||||
let default_text = default.and_then(|values| {
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values.join(", "))
|
||||
}
|
||||
});
|
||||
let value = prompt_line(label, default_text.as_deref())?;
|
||||
if value.trim() == "-" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(parse_setting_list(&value))
|
||||
}
|
||||
|
||||
fn format_setting_list(values: &[String], empty_label: &str) -> String {
|
||||
if values.is_empty() {
|
||||
empty_label.to_string()
|
||||
} else {
|
||||
values.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn show_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
|
||||
let Some(account) = MailRepository::find_account(pool, id).await? else {
|
||||
println!("未找到 ID 为 {id} 的邮件账户。");
|
||||
@@ -204,6 +238,14 @@ pub async fn show_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
|
||||
println!(" 邮箱目录: {}", account.mailbox);
|
||||
println!(" 通知 account_id: {}", account.notify_account_id);
|
||||
println!(" 通知 user_id: {}", account.notify_user_id);
|
||||
println!(
|
||||
" 推送标题关键字: {}",
|
||||
format_setting_list(&account.notification_subject_keywords, "全部推送")
|
||||
);
|
||||
println!(
|
||||
" 可信发件人: {}",
|
||||
format_setting_list(&account.trusted_senders, "无")
|
||||
);
|
||||
println!(" 轮询间隔: {}s", account.poll_seconds);
|
||||
println!(" 抓取上限: {}", account.fetch_limit);
|
||||
println!(" 接受无效证书: {}", account.accept_invalid_certs);
|
||||
@@ -240,6 +282,14 @@ pub async fn edit_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
|
||||
)?;
|
||||
let notify_user_id =
|
||||
prompt_required("通知接收人的 from_user_id", Some(&existing.notify_user_id))?;
|
||||
let notification_subject_keywords = prompt_setting_list(
|
||||
"推送标题关键字(逗号分隔,留空表示全部推送,输入 - 清空)",
|
||||
Some(&existing.notification_subject_keywords),
|
||||
)?;
|
||||
let trusted_senders = prompt_setting_list(
|
||||
"可信发件人邮箱列表(逗号分隔,输入 - 清空)",
|
||||
Some(&existing.trusted_senders),
|
||||
)?;
|
||||
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)?;
|
||||
@@ -255,6 +305,8 @@ pub async fn edit_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -278,6 +330,44 @@ pub async fn edit_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn configure_account_settings(pool: &PgPool, id: i64) -> anyhow::Result<()> {
|
||||
let Some(existing) = MailRepository::find_account(pool, id).await? else {
|
||||
println!("未找到 ID 为 {id} 的邮件账户。");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
println!("修改邮件账户 #{id} 的推送过滤和可信发件人设置。直接回车会保留当前值。");
|
||||
let notification_subject_keywords = prompt_setting_list(
|
||||
"推送标题关键字(逗号分隔,留空表示全部推送,输入 - 清空)",
|
||||
Some(&existing.notification_subject_keywords),
|
||||
)?;
|
||||
let trusted_senders = prompt_setting_list(
|
||||
"可信发件人邮箱列表(逗号分隔,输入 - 清空)",
|
||||
Some(&existing.trusted_senders),
|
||||
)?;
|
||||
|
||||
let Some(saved) = MailRepository::update_account_mail_settings(
|
||||
pool,
|
||||
id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
println!("未找到 ID 为 {id} 的邮件账户。");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
println!(
|
||||
"邮箱设置已更新:#{} push_filter={} trusted={}",
|
||||
saved.id,
|
||||
format_setting_list(&saved.notification_subject_keywords, "全部"),
|
||||
format_setting_list(&saved.trusted_senders, "无")
|
||||
);
|
||||
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} 的邮件账户。");
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct MailPollerConfig {
|
||||
pub source_key: String,
|
||||
pub notify_account_id: String,
|
||||
pub notify_user_id: String,
|
||||
pub notification_subject_keywords: Vec<String>,
|
||||
pub trusted_senders: Vec<String>,
|
||||
pub poll_interval: Duration,
|
||||
pub fetch_limit: usize,
|
||||
pub accept_invalid_certs: bool,
|
||||
@@ -35,6 +37,10 @@ impl MailPollerConfig {
|
||||
mailbox: account.mailbox,
|
||||
notify_account_id: account.notify_account_id,
|
||||
notify_user_id: account.notify_user_id,
|
||||
notification_subject_keywords: normalize_setting_values(
|
||||
account.notification_subject_keywords,
|
||||
),
|
||||
trusted_senders: normalize_setting_values(account.trusted_senders),
|
||||
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,
|
||||
@@ -83,6 +89,11 @@ impl MailPollerConfig {
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 100);
|
||||
let notification_subject_keywords = optional_env_list(&[
|
||||
"IA_MAIL_NOTIFICATION_SUBJECT_KEYWORDS",
|
||||
"IA_MAIL_NOTIFY_SUBJECT_KEYWORDS",
|
||||
]);
|
||||
let trusted_senders = optional_env_list(&["IA_MAIL_TRUSTED_SENDERS"]);
|
||||
|
||||
Ok(Some(Self {
|
||||
mail_account_id: None,
|
||||
@@ -95,6 +106,8 @@ impl MailPollerConfig {
|
||||
mailbox,
|
||||
notify_account_id: notify_account_id.unwrap(),
|
||||
notify_user_id: notify_user_id.unwrap(),
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_interval: Duration::from_secs(poll_seconds),
|
||||
fetch_limit,
|
||||
accept_invalid_certs: env_bool("IA_MAIL_ACCEPT_INVALID_CERTS", false),
|
||||
@@ -109,6 +122,30 @@ fn optional_env(name: &str) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn optional_env_list(names: &[&str]) -> Vec<String> {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|name| optional_env(name))
|
||||
.map(|value| parse_setting_list(&value))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_setting_list(value: &str) -> Vec<String> {
|
||||
normalize_setting_values(
|
||||
value
|
||||
.split([',', ',', ';', ';', '\n'])
|
||||
.map(ToString::to_string),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_setting_values(values: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn env_bool(name: &str, default: bool) -> bool {
|
||||
env::var(name)
|
||||
.map(|value| {
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct MailAccount {
|
||||
pub mailbox: String,
|
||||
pub notify_account_id: String,
|
||||
pub notify_user_id: String,
|
||||
pub notification_subject_keywords: Vec<String>,
|
||||
pub trusted_senders: Vec<String>,
|
||||
pub poll_seconds: i32,
|
||||
pub fetch_limit: i32,
|
||||
pub accept_invalid_certs: bool,
|
||||
@@ -35,6 +37,8 @@ pub struct NewMailAccount {
|
||||
pub mailbox: String,
|
||||
pub notify_account_id: String,
|
||||
pub notify_user_id: String,
|
||||
pub notification_subject_keywords: Vec<String>,
|
||||
pub trusted_senders: Vec<String>,
|
||||
pub poll_seconds: i32,
|
||||
pub fetch_limit: i32,
|
||||
pub accept_invalid_certs: bool,
|
||||
|
||||
+34
-2
@@ -147,6 +147,9 @@ async fn pull_recent_mail_with_notifications(
|
||||
let message = create_preview_page(pool, inserted, &fetched.content).await?;
|
||||
|
||||
if let Some(notifications) = notifications {
|
||||
if !should_notify_subject(&message.subject, &config.notification_subject_keywords) {
|
||||
continue;
|
||||
}
|
||||
let text = notification_text(&message);
|
||||
if notifications
|
||||
.send(MailNotification {
|
||||
@@ -263,6 +266,18 @@ fn should_skip_fetched_message(seen: bool, unread_only: bool) -> bool {
|
||||
unread_only && seen
|
||||
}
|
||||
|
||||
fn should_notify_subject(subject: &str, keywords: &[String]) -> bool {
|
||||
if keywords.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let subject = subject.to_lowercase();
|
||||
keywords.iter().any(|keyword| {
|
||||
let keyword = keyword.trim();
|
||||
!keyword.is_empty() && subject.contains(&keyword.to_lowercase())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_seen(fetch: &Fetch) -> bool {
|
||||
fetch.flags().any(|flag| matches!(flag, Flag::Seen))
|
||||
}
|
||||
@@ -530,8 +545,8 @@ fn split_headers(raw: &str) -> &str {
|
||||
mod tests {
|
||||
use super::{
|
||||
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,
|
||||
parse_message, preview_text, recent_sequence_set, should_notify_subject,
|
||||
should_skip_fetched_message, summarize_mail,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -554,6 +569,23 @@ mod tests {
|
||||
assert!(!should_skip_fetched_message(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_notifications_by_subject_keywords() {
|
||||
assert!(should_notify_subject("系统告警:任务失败", &[]));
|
||||
assert!(should_notify_subject(
|
||||
"系统告警:任务失败",
|
||||
&["告警".to_string()]
|
||||
));
|
||||
assert!(should_notify_subject(
|
||||
"Invoice paid",
|
||||
&["INVOICE".to_string()]
|
||||
));
|
||||
assert!(!should_notify_subject(
|
||||
"日常摘要",
|
||||
&["告警".to_string(), "账单".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_numeric_imap_uid_for_server_mutation() {
|
||||
assert_eq!(normalized_imap_uid("123").unwrap(), "123");
|
||||
|
||||
@@ -22,12 +22,14 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
enabled
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
ON CONFLICT (source_key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
imap_host = EXCLUDED.imap_host,
|
||||
@@ -37,6 +39,8 @@ impl MailRepository {
|
||||
mailbox = EXCLUDED.mailbox,
|
||||
notify_account_id = EXCLUDED.notify_account_id,
|
||||
notify_user_id = EXCLUDED.notify_user_id,
|
||||
notification_subject_keywords = EXCLUDED.notification_subject_keywords,
|
||||
trusted_senders = EXCLUDED.trusted_senders,
|
||||
poll_seconds = EXCLUDED.poll_seconds,
|
||||
fetch_limit = EXCLUDED.fetch_limit,
|
||||
accept_invalid_certs = EXCLUDED.accept_invalid_certs,
|
||||
@@ -54,6 +58,8 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -73,6 +79,8 @@ impl MailRepository {
|
||||
.bind(account.mailbox)
|
||||
.bind(account.notify_account_id)
|
||||
.bind(account.notify_user_id)
|
||||
.bind(account.notification_subject_keywords)
|
||||
.bind(account.trusted_senders)
|
||||
.bind(account.poll_seconds)
|
||||
.bind(account.fetch_limit)
|
||||
.bind(account.accept_invalid_certs)
|
||||
@@ -97,6 +105,8 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -130,6 +140,8 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -529,6 +541,8 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -577,6 +591,8 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -613,10 +629,12 @@ impl MailRepository {
|
||||
mailbox = $8,
|
||||
notify_account_id = $9,
|
||||
notify_user_id = $10,
|
||||
poll_seconds = $11,
|
||||
fetch_limit = $12,
|
||||
accept_invalid_certs = $13,
|
||||
enabled = $14,
|
||||
notification_subject_keywords = $11,
|
||||
trusted_senders = $12,
|
||||
poll_seconds = $13,
|
||||
fetch_limit = $14,
|
||||
accept_invalid_certs = $15,
|
||||
enabled = $16,
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
@@ -631,6 +649,8 @@ impl MailRepository {
|
||||
mailbox,
|
||||
notify_account_id,
|
||||
notify_user_id,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
@@ -651,6 +671,8 @@ impl MailRepository {
|
||||
.bind(account.mailbox)
|
||||
.bind(account.notify_account_id)
|
||||
.bind(account.notify_user_id)
|
||||
.bind(account.notification_subject_keywords)
|
||||
.bind(account.trusted_senders)
|
||||
.bind(account.poll_seconds)
|
||||
.bind(account.fetch_limit)
|
||||
.bind(account.accept_invalid_certs)
|
||||
@@ -661,6 +683,52 @@ impl MailRepository {
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn update_account_mail_settings(
|
||||
pool: &PgPool,
|
||||
id: i64,
|
||||
notification_subject_keywords: Vec<String>,
|
||||
trusted_senders: Vec<String>,
|
||||
) -> anyhow::Result<Option<MailAccount>> {
|
||||
let row = sqlx::query_as::<_, MailAccount>(
|
||||
r#"
|
||||
UPDATE ias_mail_accounts
|
||||
SET
|
||||
notification_subject_keywords = $2,
|
||||
trusted_senders = $3,
|
||||
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,
|
||||
notification_subject_keywords,
|
||||
trusted_senders,
|
||||
poll_seconds,
|
||||
fetch_limit,
|
||||
accept_invalid_certs,
|
||||
enabled,
|
||||
last_checked_at,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(notification_subject_keywords)
|
||||
.bind(trusted_senders)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn list_messages_paginated(
|
||||
pool: &PgPool,
|
||||
page: i64,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
|
||||
@@ -17,6 +18,12 @@ 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 }
|
||||
@@ -69,10 +76,12 @@ async fn get_message(
|
||||
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,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -107,6 +116,67 @@ async fn mail_config_for_message(
|
||||
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,
|
||||
@@ -127,3 +197,32 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[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()]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user