From 68facdaba4c87110d99c191eb12496ff3c93495a Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Mon, 4 May 2026 13:25:22 +0200 Subject: [PATCH] Fix spam filter: Use original instead of rewritten `RCPT` on checks --- CHANGELOG.md | 1 + crates/jmap/src/registry/mapping/action.rs | 3 ++- crates/smtp/src/core/mod.rs | 6 ------ crates/smtp/src/inbound/spam.rs | 13 ++++++++++++- crates/spam-filter/src/analysis/classifier.rs | 2 +- crates/spam-filter/src/analysis/init.rs | 9 +++++++-- crates/spam-filter/src/analysis/recipient.rs | 4 ++-- crates/spam-filter/src/analysis/replyto.rs | 6 +++--- crates/spam-filter/src/analysis/score.rs | 2 +- crates/spam-filter/src/analysis/subject.rs | 2 +- crates/spam-filter/src/lib.rs | 9 ++++++--- crates/spam-filter/src/modules/classifier.rs | 16 +++++++++------- crates/spam-filter/src/modules/expression.rs | 2 +- 13 files changed, 46 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d111d9c..20369ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - Timezone changes from the `AccountSettings` object return `invalidProperties`. - `mail-parser` panic with certain messages containing corrupted attachments. - Pagination by anchor for queued messages, tasks and metrics. +- Spam filter: Use original instead of rewritten `RCPT` on checks. ## [0.16.3] - 2026-04-30 diff --git a/crates/jmap/src/registry/mapping/action.rs b/crates/jmap/src/registry/mapping/action.rs index ee350f6b..8e1fae14 100644 --- a/crates/jmap/src/registry/mapping/action.rs +++ b/crates/jmap/src/registry/mapping/action.rs @@ -372,7 +372,8 @@ async fn classify_spam(server: &Server, mut request: SpamClassify) -> Option MAIL_SMTPUTF8, None => 0, }, - env_rcpt_to: request.env_rcpt_to.iter().map(String::as_str).collect(), + env_rcpt_orig_to: request.env_rcpt_to.iter().map(String::as_str).collect(), + env_rcpt_rewritten_to: request.env_rcpt_to.iter().map(String::as_str).collect(), is_test: true, is_train: false, }; diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index fdad2da8..247627d5 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -294,12 +294,6 @@ impl SessionData { } } -/*impl Default for SessionData { - fn default() -> Self { - Self::local(AccessToken::from_id(0), None, vec![], vec![], 0) - } -}*/ - impl SessionAddress { pub fn new(address: String) -> Self { let address_lcase = address.to_lowercase(); diff --git a/crates/smtp/src/inbound/spam.rs b/crates/smtp/src/inbound/spam.rs index 23bc73db..7d059c85 100644 --- a/crates/smtp/src/inbound/spam.rs +++ b/crates/smtp/src/inbound/spam.rs @@ -79,12 +79,23 @@ impl Session { .as_ref() .map(|m| m.flags) .unwrap_or_default(), - env_rcpt_to: self + env_rcpt_rewritten_to: self .data .rcpt_to .iter() .map(|r| r.address_lcase.as_str()) .collect(), + env_rcpt_orig_to: self + .data + .rcpt_to + .iter() + .map(|r| { + r.dsn_info + .as_deref() + .and_then(|info| info.strip_prefix("rfc822;")) + .unwrap_or(r.address_lcase.as_str()) + }) + .collect(), is_test: false, is_train: false, } diff --git a/crates/spam-filter/src/analysis/classifier.rs b/crates/spam-filter/src/analysis/classifier.rs index f34fafac..b2262bfa 100644 --- a/crates/spam-filter/src/analysis/classifier.rs +++ b/crates/spam-filter/src/analysis/classifier.rs @@ -32,7 +32,7 @@ impl SpamFilterAnalyzeClassify for Server { async fn spam_filter_analyze_spam_trap(&self, ctx: &mut SpamFilterContext<'_>) -> bool { if let Some(store) = self.get_lookup_store("spam-traps") { - for addr in &ctx.output.env_to_addr { + for addr in &ctx.output.env_to_orig_addr { match store.key_exists(addr.address.as_str()).await { Ok(true) => { ctx.result.add_tag("SPAM_TRAP"); diff --git a/crates/spam-filter/src/analysis/init.rs b/crates/spam-filter/src/analysis/init.rs index 42d820b0..036b73b3 100644 --- a/crates/spam-filter/src/analysis/init.rs +++ b/crates/spam-filter/src/analysis/init.rs @@ -279,8 +279,13 @@ impl SpamFilterInit for Server { env_from_postmaster: env_from_addr.address.is_empty() || POSTMASTER_ADDRESSES.contains(&env_from_addr.local_part.as_str()), env_from_addr, - env_to_addr: input - .env_rcpt_to + env_to_orig_addr: input + .env_rcpt_orig_to + .iter() + .map(|rcpt| Email::new(rcpt)) + .collect(), + env_to_rewritten_addr: input + .env_rcpt_rewritten_to .iter() .map(|rcpt| Email::new(rcpt)) .collect(), diff --git a/crates/spam-filter/src/analysis/recipient.rs b/crates/spam-filter/src/analysis/recipient.rs index 04c64c63..b04ec5d9 100644 --- a/crates/spam-filter/src/analysis/recipient.rs +++ b/crates/spam-filter/src/analysis/recipient.rs @@ -161,7 +161,7 @@ impl SpamFilterAnalyzeRecipient for Server { } // Recipient is present in envelope - if ctx.output.env_to_addr.contains(&rcpt.email) { + if ctx.output.env_to_orig_addr.contains(&rcpt.email) { to_match_envrcpt += 1; } @@ -202,7 +202,7 @@ impl SpamFilterAnalyzeRecipient for Server { } if !has_list_id && !has_list_unsubscribe { - for env_rcpt in &ctx.output.env_to_addr { + for env_rcpt in &ctx.output.env_to_orig_addr { if !unique_recipients.iter().any(|rcpt| rcpt.email == *env_rcpt) && env_rcpt != &ctx.output.env_from_addr { diff --git a/crates/spam-filter/src/analysis/replyto.rs b/crates/spam-filter/src/analysis/replyto.rs index dd6697a2..6d3fb4fe 100644 --- a/crates/spam-filter/src/analysis/replyto.rs +++ b/crates/spam-filter/src/analysis/replyto.rs @@ -79,11 +79,11 @@ impl SpamFilterAnalyzeReplyTo for Server { .any(|r| r.email == ctx.output.from.email) || ctx .output - .env_to_addr + .env_to_orig_addr .iter() .any(|r| r.domain_part.sld == ctx.output.from.email.domain_part.sld) - || ctx.output.env_to_addr.len() == 1 - && ctx.output.env_to_addr.contains(&ctx.output.from.email)) + || ctx.output.env_to_orig_addr.len() == 1 + && ctx.output.env_to_orig_addr.contains(&ctx.output.from.email)) { ctx.result.add_tag("SPOOF_REPLYTO"); } diff --git a/crates/spam-filter/src/analysis/score.rs b/crates/spam-filter/src/analysis/score.rs index 3c19cfba..c834f3c0 100644 --- a/crates/spam-filter/src/analysis/score.rs +++ b/crates/spam-filter/src/analysis/score.rs @@ -88,7 +88,7 @@ impl SpamFilterAnalyzeScore for Server { let mut total_results = 0; let mut user_results = vec![ ctx.result.score >= self.core.spam.scores.spam_threshold; - ctx.input.env_rcpt_to.len() + ctx.input.env_rcpt_rewritten_to.len() ]; if !ctx.result.classifier_confidence.is_empty() { for (idx, &confidence) in ctx.result.classifier_confidence.iter().enumerate() { diff --git a/crates/spam-filter/src/analysis/subject.rs b/crates/spam-filter/src/analysis/subject.rs index 413bdb82..5ce8ca74 100644 --- a/crates/spam-filter/src/analysis/subject.rs +++ b/crates/spam-filter/src/analysis/subject.rs @@ -128,7 +128,7 @@ impl SpamFilterAnalyzeSubject for Server { } TokenType::Email(email) => { // Subject contains recipient - if ctx.output.env_to_addr.contains(email) + if ctx.output.env_to_orig_addr.contains(email) || ctx .output .all_recipients() diff --git a/crates/spam-filter/src/lib.rs b/crates/spam-filter/src/lib.rs index 8a6cbfce..a04d4f25 100644 --- a/crates/spam-filter/src/lib.rs +++ b/crates/spam-filter/src/lib.rs @@ -47,7 +47,8 @@ pub struct SpamFilterInput<'x> { // Envelope pub env_from: &'x str, pub env_from_flags: u64, - pub env_rcpt_to: Vec<&'x str>, + pub env_rcpt_orig_to: Vec<&'x str>, + pub env_rcpt_rewritten_to: Vec<&'x str>, pub is_train: bool, pub is_test: bool, @@ -59,7 +60,8 @@ pub struct SpamFilterOutput<'x> { pub env_from_addr: Email, pub env_from_postmaster: bool, - pub env_to_addr: HashSet, + pub env_to_orig_addr: HashSet, + pub env_to_rewritten_addr: HashSet, pub from: Recipient, pub recipients_to: Vec, pub recipients_cc: Vec, @@ -156,7 +158,8 @@ impl<'x> SpamFilterInput<'x> { is_tls: true, env_from: "", env_from_flags: 0, - env_rcpt_to: vec![], + env_rcpt_rewritten_to: vec![], + env_rcpt_orig_to: vec![], is_test: false, is_train: false, } diff --git a/crates/spam-filter/src/modules/classifier.rs b/crates/spam-filter/src/modules/classifier.rs index 7002b7df..46379700 100644 --- a/crates/spam-filter/src/modules/classifier.rs +++ b/crates/spam-filter/src/modules/classifier.rs @@ -579,7 +579,8 @@ impl SpamClassifier for Server { let started = Instant::now(); match classifier.as_ref() { spamfilter::SpamClassifier::FhClassifier { classifier, .. } => { - let mut classifier_confidence = Vec::with_capacity(ctx.input.env_rcpt_to.len()); + let mut classifier_confidence = + Vec::with_capacity(ctx.input.env_rcpt_rewritten_to.len()); let mut has_prediction = false; let mut tokens = self.spam_build_tokens(ctx).await.0; let feature_builder = classifier.feature_builder(); @@ -587,7 +588,7 @@ impl SpamClassifier for Server { feature_builder.scale(&mut tokens); } - for rcpt in &ctx.input.env_rcpt_to { + for rcpt in &ctx.input.env_rcpt_rewritten_to { let prediction = if let Some(account_id) = self .account_id_from_email(rcpt, true) .await @@ -617,11 +618,12 @@ impl SpamClassifier for Server { config.l2_normalize, )); ctx.result.classifier_confidence = - vec![prediction.into(); ctx.input.env_rcpt_to.len()]; + vec![prediction.into(); ctx.input.env_rcpt_rewritten_to.len()]; } } spamfilter::SpamClassifier::CcfhClassifier { classifier, .. } => { - let mut classifier_confidence = Vec::with_capacity(ctx.input.env_rcpt_to.len()); + let mut classifier_confidence = + Vec::with_capacity(ctx.input.env_rcpt_rewritten_to.len()); let mut has_prediction = false; let mut tokens = self.spam_build_tokens(ctx).await.0; let feature_builder = classifier.feature_builder(); @@ -629,7 +631,7 @@ impl SpamClassifier for Server { feature_builder.scale(&mut tokens); } - for rcpt in &ctx.input.env_rcpt_to { + for rcpt in &ctx.input.env_rcpt_rewritten_to { let prediction = if let Some(account_id) = self .account_id_from_email(rcpt, true) .await @@ -659,7 +661,7 @@ impl SpamClassifier for Server { config.l2_normalize, )); ctx.result.classifier_confidence = - vec![prediction.into(); ctx.input.env_rcpt_to.len()]; + vec![prediction.into(); ctx.input.env_rcpt_rewritten_to.len()]; } } spamfilter::SpamClassifier::Disabled => { @@ -673,7 +675,7 @@ impl SpamClassifier for Server { .result .classifier_confidence .iter() - .zip(ctx.input.env_rcpt_to.iter()) + .zip(ctx.input.env_rcpt_rewritten_to.iter()) .map(|(v, rcpt)| trc::Value::Array(vec![ trc::Value::from(rcpt.to_string()), trc::Value::from(*v) diff --git a/crates/spam-filter/src/modules/expression.rs b/crates/spam-filter/src/modules/expression.rs index f1ffa9b3..7dfae43a 100644 --- a/crates/spam-filter/src/modules/expression.rs +++ b/crates/spam-filter/src/modules/expression.rs @@ -64,7 +64,7 @@ impl ResolveVariable for SpamFilterResolver<'_, T> { ExpressionVariable::EnvTo => self .ctx .output - .env_to_addr + .env_to_orig_addr .iter() .map(|e| Variable::from(e.address.as_str())) .collect::>()