feat: 拆分 worker 架构并增强 assistant 安全保护

- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议

- 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险

- 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则

- 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
2026-07-10 00:41:36 +08:00
parent 7deae286ca
commit af6cfdaa83
70 changed files with 5953 additions and 1608 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-mail"
version = "0.1.16"
version = "0.1.17"
edition = "2024"
[dependencies]
+92 -2
View File
@@ -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} 的邮件账户。");
+37
View File
@@ -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| {
+4
View File
@@ -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
View File
@@ -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");
+73 -5
View File
@@ -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,
+99
View File
@@ -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()]
));
}
}