diff --git a/crates/common/src/config/spamfilter.rs b/crates/common/src/config/spamfilter.rs index 932295c3..e8aa856d 100644 --- a/crates/common/src/config/spamfilter.rs +++ b/crates/common/src/config/spamfilter.rs @@ -87,6 +87,8 @@ pub struct ClassifierConfig { pub min_spam_samples: u64, pub auto_learn_reply_ham: bool, pub auto_learn_card_is_ham: bool, + pub auto_learn_spam_score: f32, + pub auto_learn_ham_score: f32, pub hold_samples_for: u64, pub train_frequency: Option, } @@ -482,6 +484,12 @@ impl ClassifierConfig { auto_learn_reply_ham: config .property_or_default("spam-filter.trusted-reply.learn", "true") .unwrap_or(true), + auto_learn_spam_score: config + .property_or_default("spam-filter.classifier.auto-learn.spam-score", "8.0") + .unwrap_or(8.0), + auto_learn_ham_score: config + .property_or_default("spam-filter.classifier.auto-learn.ham-score", "-8.0") + .unwrap_or(-8.0), hold_samples_for: config .property_or_default::("spam-filter.classifier.samples.hold-for", "180d") .unwrap_or(Duration::from_secs(180 * 24 * 60 * 60)) diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 87834afa..d583ba85 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -31,7 +31,9 @@ use jmap_proto::{ use jmap_tools::{JsonPointerItem, Key, Map, Value}; use std::future::Future; use store::{ - ValueKey, roaring::RoaringBitmap, write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue} + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive, BatchBuilder, assert::AssertValue}, }; use trc::AddContext; use types::{ @@ -526,12 +528,12 @@ impl MailboxSet for Server { // Role of internal folders cannot be modified if update.as_ref().is_some_and(|(document_id, _)| { - *document_id == INBOX_ID || *document_id == TRASH_ID + *document_id == INBOX_ID || *document_id == TRASH_ID || *document_id == JUNK_ID }) { return Ok(Err(SetError::invalid_properties() .with_property(MailboxProperty::Role) .with_description( - "You are not allowed to change the role of Inbox or Trash folders.", + "You are not allowed to change the role of Inbox, Junk or Trash folders.", ))); } } diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index d8e7d4f7..591a1fa3 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -425,7 +425,7 @@ impl Session { } // Run SPAM filter - let mut train_as_spam = false; + let mut train_spam = None; if self.server.core.spam.enabled && self .server @@ -446,7 +446,7 @@ impl Session { SpamFilterAction::Allow(score) => { // Add headers headers.extend_from_slice(score.headers.as_bytes()); - train_as_spam = score.spam_trap; + train_spam = score.train_spam; // Add scores for local recipients for (is_spam, recipient) in @@ -685,7 +685,7 @@ impl Session { { MessageSource::Unauthenticated { dmarc_pass: dmarc_pass || message.message.return_path.starts_with("dmarc-"), - train_as_spam, + train_spam, } } @@ -693,7 +693,7 @@ impl Session { { MessageSource::Unauthenticated { dmarc_pass, - train_as_spam, + train_spam, } } } else { diff --git a/crates/smtp/src/queue/mod.rs b/crates/smtp/src/queue/mod.rs index 2b81d996..fa1eb162 100644 --- a/crates/smtp/src/queue/mod.rs +++ b/crates/smtp/src/queue/mod.rs @@ -45,7 +45,7 @@ pub enum MessageSource { Authenticated, Unauthenticated { dmarc_pass: bool, - train_as_spam: bool, + train_spam: Option, }, Dsn, Report, diff --git a/crates/smtp/src/queue/spool.rs b/crates/smtp/src/queue/spool.rs index 45709efe..82fe9934 100644 --- a/crates/smtp/src/queue/spool.rs +++ b/crates/smtp/src/queue/spool.rs @@ -306,30 +306,30 @@ impl MessageWrapper { MessageSource::Authenticated => ( FROM_AUTHENTICATED, trc::QueueEvent::QueueMessageAuthenticated, - false, + None, ), MessageSource::Unauthenticated { dmarc_pass: true, - train_as_spam, + train_spam, } => ( FROM_UNAUTHENTICATED_DMARC, trc::QueueEvent::QueueMessage, - train_as_spam, + train_spam, ), MessageSource::Unauthenticated { dmarc_pass: false, - train_as_spam, + train_spam, } => ( FROM_UNAUTHENTICATED, trc::QueueEvent::QueueMessage, - train_as_spam, + train_spam, ), - MessageSource::Dsn => (FROM_DSN, trc::QueueEvent::QueueDsn, false), - MessageSource::Report => (FROM_REPORT, trc::QueueEvent::QueueReport, false), + MessageSource::Dsn => (FROM_DSN, trc::QueueEvent::QueueDsn, None), + MessageSource::Report => (FROM_REPORT, trc::QueueEvent::QueueReport, None), MessageSource::Autogenerated => ( FROM_AUTOGENERATED, trc::QueueEvent::QueueAutogenerated, - false, + None, ), }; self.message.flags |= flags; @@ -439,7 +439,9 @@ impl MessageWrapper { ); } - if train_spam && let Some(config) = &server.core.spam.classifier { + if let Some(is_spam) = train_spam + && let Some(config) = &server.core.spam.classifier + { let hold_period = now + config.hold_samples_for; batch @@ -455,12 +457,12 @@ impl MessageWrapper { hash: self.message.blob_hash.clone(), until: hold_period, }, - vec![1, 1], + vec![u8::from(is_spam), 1], ); trc::event!( Spam(SpamEvent::TrainSampleAdded), - Details = "spam", + Details = if is_spam { "spam" } else { "ham" }, Expires = trc::Value::Timestamp(hold_period), SpanId = self.span_id, ); diff --git a/crates/spam-filter/src/analysis/classifier.rs b/crates/spam-filter/src/analysis/classifier.rs index d6b90b17..783a8eaa 100644 --- a/crates/spam-filter/src/analysis/classifier.rs +++ b/crates/spam-filter/src/analysis/classifier.rs @@ -36,7 +36,6 @@ impl SpamFilterAnalyzeClassify for Server { match store.key_exists(addr.address.as_str()).await { Ok(true) => { ctx.result.add_tag("SPAM_TRAP"); - ctx.result.spam_trap = true; return true; } Ok(false) => (), diff --git a/crates/spam-filter/src/analysis/score.rs b/crates/spam-filter/src/analysis/score.rs index c653532c..8a96a40b 100644 --- a/crates/spam-filter/src/analysis/score.rs +++ b/crates/spam-filter/src/analysis/score.rs @@ -44,7 +44,7 @@ pub trait SpamFilterAnalyzeScore: Sync + Send { pub struct SpamFilterScore { pub results: Vec, pub headers: String, - pub spam_trap: bool, + pub train_spam: Option, pub score: f32, } @@ -163,10 +163,24 @@ impl SpamFilterAnalyzeScore for Server { ); } + // Autolearn + let mut train_spam = None; + let config = self.core.spam.classifier.as_ref().unwrap(); + if config.auto_learn_spam_score > 0.0 && final_score >= config.auto_learn_spam_score { + if !ctx.result.has_tag("PROB_SPAM_HIGH") { + train_spam = Some(true); + } + } else if config.auto_learn_ham_score < 0.0 + && final_score <= config.auto_learn_ham_score + && !ctx.result.has_tag("PROB_HAM_HIGH") + { + train_spam = Some(false); + } + SpamFilterAction::Allow(SpamFilterScore { results: user_results, headers, - spam_trap: ctx.result.spam_trap, + train_spam, score: final_score, }) } diff --git a/crates/spam-filter/src/lib.rs b/crates/spam-filter/src/lib.rs index ac1ae253..2710d35d 100644 --- a/crates/spam-filter/src/lib.rs +++ b/crates/spam-filter/src/lib.rs @@ -106,7 +106,6 @@ pub struct SpamFilterResult { pub rbl_url_checks: usize, pub rbl_email_checks: usize, pub llm_result: Option<(String, String)>, - pub spam_trap: bool, } pub struct SpamFilterContext<'x> {