diff --git a/Cargo.lock b/Cargo.lock index a8313c80..df832517 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6453,6 +6453,7 @@ dependencies = [ "mail-send", "nlp", "psl", + "smtp-proto", "store", "tokio", "trc", diff --git a/crates/common/src/config/spamfilter.rs b/crates/common/src/config/spamfilter.rs index 291f8393..cf48c703 100644 --- a/crates/common/src/config/spamfilter.rs +++ b/crates/common/src/config/spamfilter.rs @@ -10,6 +10,8 @@ use utils::{config::Config, glob::GlobSet}; pub struct SpamFilterConfig { pub list_dmarc_allow: GlobSet, pub list_spf_dkim_allow: GlobSet, + pub list_freemail_providers: GlobSet, + pub list_disposable_providers: GlobSet, } impl SpamFilterConfig { diff --git a/crates/common/src/scripts/functions/text.rs b/crates/common/src/scripts/functions/text.rs index 32a15507..f46e50fb 100644 --- a/crates/common/src/scripts/functions/text.rs +++ b/crates/common/src/scripts/functions/text.rs @@ -243,20 +243,24 @@ pub fn fn_levenshtein_distance<'x>(_: &'x Context<'x>, v: Vec) -> Vari let a = v[0].to_string(); let b = v[1].to_string(); + levenshtein_distance(a.as_ref(), b.as_ref()).into() +} + +pub fn levenshtein_distance(a: &str, b: &str) -> usize { let mut result = 0; /* Shortcut optimizations / degenerate cases. */ if a == b { - return result.into(); + return result; } let length_a = a.chars().count(); let length_b = b.chars().count(); if length_a == 0 { - return length_b.into(); + return length_b; } else if length_b == 0 { - return length_a.into(); + return length_a; } /* Initialize the vector. @@ -297,7 +301,7 @@ pub fn fn_levenshtein_distance<'x>(_: &'x Context<'x>, v: Vec) -> Vari } } - result.into() + result } pub fn fn_detect_language<'x>(_: &'x Context<'x>, v: Vec) -> Variable { diff --git a/crates/spam-filter/Cargo.toml b/crates/spam-filter/Cargo.toml index 1805f2c9..5ae0580e 100644 --- a/crates/spam-filter/Cargo.toml +++ b/crates/spam-filter/Cargo.toml @@ -10,6 +10,7 @@ nlp = { path = "../nlp" } store = { path = "../store" } trc = { path = "../trc" } common = { path = "../common" } +smtp-proto = { version = "0.1", features = ["serde_support"] } mail-parser = { version = "0.9", features = ["full_encoding", "ludicrous_mode"] } mail-builder = { version = "0.3", features = ["ludicrous_mode"] } mail-auth = { version = "0.5" } diff --git a/crates/spam-filter/src/analysis/date.rs b/crates/spam-filter/src/analysis/date.rs index 4788da52..1e857200 100644 --- a/crates/spam-filter/src/analysis/date.rs +++ b/crates/spam-filter/src/analysis/date.rs @@ -5,14 +5,14 @@ use store::write::now; use crate::SpamFilterContext; -pub trait SpamFilterAnalyzeEhlo: Sync + Send { +pub trait SpamFilterAnalyzeDate: Sync + Send { fn spam_filter_analyze_date( &self, ctx: &mut SpamFilterContext<'_>, ) -> impl Future + Send; } -impl SpamFilterAnalyzeEhlo for Core { +impl SpamFilterAnalyzeDate for Core { async fn spam_filter_analyze_date(&self, ctx: &mut SpamFilterContext<'_>) { if let Some(date) = ctx.input.message.date() { let date = date.to_timestamp(); @@ -21,16 +21,16 @@ impl SpamFilterAnalyzeEhlo for Core { if date_diff > 86400 { // Older than a day - ctx.add_tag("DATE_IN_PAST"); + ctx.result.add_tag("DATE_IN_PAST"); } else if -date_diff > 7200 { //# More than 2 hours in the future - ctx.add_tag("DATE_IN_FUTURE"); + ctx.result.add_tag("DATE_IN_FUTURE"); } } else { - ctx.add_tag("INVALID_DATE"); + ctx.result.add_tag("INVALID_DATE"); } } else { - ctx.add_tag("MISSING_DATE"); + ctx.result.add_tag("MISSING_DATE"); } } } diff --git a/crates/spam-filter/src/analysis/dmarc.rs b/crates/spam-filter/src/analysis/dmarc.rs index 9053e1cc..8d6b4a28 100644 --- a/crates/spam-filter/src/analysis/dmarc.rs +++ b/crates/spam-filter/src/analysis/dmarc.rs @@ -7,26 +7,27 @@ use mail_auth::{ use crate::SpamFilterContext; -pub trait SpamFilterAnalyzeEhlo: Sync + Send { +pub trait SpamFilterAnalyzeDmarc: Sync + Send { fn spam_filter_analyze_dmarc( &self, ctx: &mut SpamFilterContext<'_>, ) -> impl Future + Send; } -impl SpamFilterAnalyzeEhlo for Core { +impl SpamFilterAnalyzeDmarc for Core { async fn spam_filter_analyze_dmarc(&self, ctx: &mut SpamFilterContext<'_>) { - ctx.add_tag(match ctx.input.spf_mail_from_result.result() { - SpfResult::Pass => "SPF_ALLOW", - SpfResult::Fail => "SPF_FAIL", - SpfResult::SoftFail => "SPF_SOFTFAIL", - SpfResult::Neutral => "SPF_NEUTRAL", - SpfResult::TempError => "SPF_DNSFAIL", - SpfResult::PermError => "SPF_PERMFAIL", - SpfResult::None => "SPF_NA", - }); + ctx.result + .add_tag(match ctx.input.spf_mail_from_result.result() { + SpfResult::Pass => "SPF_ALLOW", + SpfResult::Fail => "SPF_FAIL", + SpfResult::SoftFail => "SPF_SOFTFAIL", + SpfResult::Neutral => "SPF_NEUTRAL", + SpfResult::TempError => "SPF_DNSFAIL", + SpfResult::PermError => "SPF_PERMFAIL", + SpfResult::None => "SPF_NA", + }); - ctx.add_tag( + ctx.result.add_tag( match ctx .input .dkim_result @@ -44,7 +45,7 @@ impl SpamFilterAnalyzeEhlo for Core { }, ); - ctx.add_tag(match ctx.input.arc_result.result() { + ctx.result.add_tag(match ctx.input.arc_result.result() { DkimResult::Pass => "ARC_ALLOW", DkimResult::Fail(_) => "ARC_REJECT", DkimResult::PermError(_) => "ARC_INVALID", @@ -52,7 +53,7 @@ impl SpamFilterAnalyzeEhlo for Core { DkimResult::Neutral(_) | DkimResult::None => "ARC_NA", }); - ctx.add_tag(match ctx.input.dmarc_result { + ctx.result.add_tag(match ctx.input.dmarc_result { DmarcResult::Pass => "DMARC_POLICY_ALLOW", DmarcResult::TempError(_) => "DMARC_DNSFAIL", DmarcResult::PermError(_) => "DMARC_BAD_POLICY", @@ -67,55 +68,55 @@ impl SpamFilterAnalyzeEhlo for Core { for header in ctx.input.message.headers() { let header_name = header.name(); if header_name.eq_ignore_ascii_case("DKIM-Signature") { - ctx.add_tag("DKIM_SIGNED"); + ctx.result.add_tag("DKIM_SIGNED"); } else if header_name.eq_ignore_ascii_case("ARC-Seal") { - ctx.add_tag("ARC_SIGNED"); + ctx.result.add_tag("ARC_SIGNED"); } } if self .spam .list_dmarc_allow - .contains(&ctx.output.from_addr.domain_part.fqdn) + .contains(&ctx.output.from.email.domain_part.fqdn) { if matches!(ctx.input.dmarc_result, DmarcResult::Pass) { - ctx.add_tag("ALLOWLIST_DMARC"); + ctx.result.add_tag("ALLOWLIST_DMARC"); } else { - ctx.add_tag("BLOCKLIST_DMARC"); + ctx.result.add_tag("BLOCKLIST_DMARC"); } } else if self .spam .list_spf_dkim_allow - .contains(&ctx.output.from_addr.domain_part.fqdn) + .contains(&ctx.output.from.email.domain_part.fqdn) { let is_dkim_pass = matches!(ctx.input.arc_result.result(), DkimResult::Pass) || ctx.input.dkim_result.iter().any(|r| { matches!(r.result(), DkimResult::Pass) && r.signature().map_or(false, |s| { - s.domain().to_lowercase() == ctx.output.from_addr.domain_part.fqdn + s.domain().to_lowercase() == ctx.output.from.email.domain_part.fqdn }) }); let is_spf_pass = matches!(ctx.input.spf_mail_from_result.result(), SpfResult::Pass); if is_dkim_pass && is_spf_pass { - ctx.add_tag("ALLOWLIST_SPF_DKIM"); + ctx.result.add_tag("ALLOWLIST_SPF_DKIM"); } else if is_dkim_pass { - ctx.add_tag("ALLOWLIST_DKIM"); + ctx.result.add_tag("ALLOWLIST_DKIM"); if !matches!( ctx.input.spf_mail_from_result.result(), SpfResult::TempError ) { - ctx.add_tag("BLOCKLIST_SPF"); + ctx.result.add_tag("BLOCKLIST_SPF"); } } else if is_spf_pass { - ctx.add_tag("ALLOWLIST_SPF"); + ctx.result.add_tag("ALLOWLIST_SPF"); if !ctx .input .dkim_result .iter() .any(|r| matches!(r.result(), DkimResult::TempError(_))) { - ctx.add_tag("BLOCKLIST_DKIM"); + ctx.result.add_tag("BLOCKLIST_DKIM"); } } else if !matches!( ctx.input.spf_mail_from_result.result(), @@ -126,7 +127,7 @@ impl SpamFilterAnalyzeEhlo for Core { .iter() .any(|r| matches!(r.result(), DkimResult::TempError(_))) { - ctx.add_tag("BLOCKLIST_SPF_DKIM"); + ctx.result.add_tag("BLOCKLIST_SPF_DKIM"); } } } diff --git a/crates/spam-filter/src/analysis/ehlo.rs b/crates/spam-filter/src/analysis/ehlo.rs index d69465a6..a4cf5b18 100644 --- a/crates/spam-filter/src/analysis/ehlo.rs +++ b/crates/spam-filter/src/analysis/ehlo.rs @@ -15,11 +15,11 @@ impl SpamFilterAnalyzeEhlo for Core { async fn spam_filter_analyze_ehlo(&self, ctx: &mut SpamFilterContext<'_>) { if let Some(ehlo_ip) = ctx.output.ehlo_host.ip { // Helo host is bare ip - ctx.add_tag("HELO_BAREIP"); + ctx.result.add_tag("HELO_BAREIP"); if ehlo_ip != ctx.input.remote_ip { // Helo A IP != hostname IP - ctx.add_tag("HELO_IP_A"); + ctx.result.add_tag("HELO_IP_A"); } } else if ctx.output.ehlo_host.sld.is_some() { if ctx @@ -29,7 +29,7 @@ impl SpamFilterAnalyzeEhlo for Core { .map_or(false, |ptr| ptr != &ctx.output.ehlo_host.fqdn) { // Helo does not match reverse IP - ctx.add_tag("HELO_IPREV_MISMATCH"); + ctx.result.add_tag("HELO_IPREV_MISMATCH"); } if matches!( @@ -40,16 +40,16 @@ impl SpamFilterAnalyzeEhlo for Core { (Ok(false), Ok(false)) ) { // Helo no resolve to A or MX - ctx.add_tag("HELO_NORES_A_OR_MX"); + ctx.result.add_tag("HELO_NORES_A_OR_MX"); } } else { if ctx.output.ehlo_host.fqdn.contains("user") { // Helo host contains 'user' - ctx.add_tag("RCVD_HELO_USER"); + ctx.result.add_tag("RCVD_HELO_USER"); } // Helo not FQDN - ctx.add_tag("HELO_NOT_FQDN"); + ctx.result.add_tag("HELO_NOT_FQDN"); } } } diff --git a/crates/spam-filter/src/analysis/from.rs b/crates/spam-filter/src/analysis/from.rs new file mode 100644 index 00000000..965513c7 --- /dev/null +++ b/crates/spam-filter/src/analysis/from.rs @@ -0,0 +1,310 @@ +use std::future::Future; + +use common::Core; +use mail_parser::HeaderName; +use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8}; + +use crate::{Email, SpamFilterContext}; + +pub trait SpamFilterAnalyzeFrom: Sync + Send { + fn spam_filter_analyze_from( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +const SERVICE_ACCOUNTS: [&str; 9] = [ + "www-data", + "anonymous", + "ftp", + "apache", + "nobody", + "guest", + "nginx", + "web", + "www", +]; +pub(crate) const TITLES: [&str; 7] = ["mr. ", "mrs. ", "ms. ", "dr. ", "prof. ", "rev. ", "hon. "]; + +impl SpamFilterAnalyzeFrom for Core { + async fn spam_filter_analyze_from(&self, ctx: &mut SpamFilterContext<'_>) { + let mut from_count = 0; + let mut from_raw = b"".as_slice(); + let mut crt = None; + let mut dnt = None; + let mut sender = None; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::From => { + from_count += 1; + from_raw = ctx + .input + .message + .raw_message() + .get(header.offset_start..header.offset_end) + .unwrap_or_default(); + } + HeaderName::Sender => { + sender = header + .value() + .as_address() + .and_then(|addrs| addrs.first()) + .and_then(|addr| addr.address()) + .map(Email::new); + } + HeaderName::Other(name) => { + if name.eq_ignore_ascii_case("X-Confirm-Reading-To") { + crt = ctx + .input + .header_as_address(header) + .map(|s| s.to_lowercase()); + } else if name.eq_ignore_ascii_case("Disposition-Notification-To") { + dnt = ctx + .input + .header_as_address(header) + .map(|s| s.to_lowercase()); + } + } + _ => {} + } + } + + match from_count { + 0 => { + ctx.result.add_tag("MISSING_FROM"); + } + 1 => {} + _ => { + ctx.result.add_tag("MULTIPLE_FROM"); + } + } + + let env_from_empty = ctx.output.env_from_addr.address.is_empty(); + let mut is_from_service_account = false; + let mut is_www_dot_domain = false; + let from_addr = &ctx.output.from.email; + let from_name = ctx.output.from.name.as_deref().unwrap_or_default(); + if from_count > 0 { + // Validate address + let from_addr_is_valid = from_addr.is_valid(); + if from_addr_is_valid { + if SERVICE_ACCOUNTS.contains(&from_addr.local_part.as_str()) { + is_from_service_account = true; + } + if from_addr.domain_part.fqdn.starts_with("www.") { + is_www_dot_domain = true; + } + if self + .spam + .list_freemail_providers + .contains(from_addr.domain_part.sld.as_deref().unwrap_or_default()) + { + ctx.result.add_tag("FREEMAIL_FROM"); + } else if self + .spam + .list_disposable_providers + .contains(from_addr.domain_part.sld.as_deref().unwrap_or_default()) + { + ctx.result.add_tag("DISPOSABLE_FROM"); + } + } else { + ctx.result.add_tag("FROM_INVALID"); + } + + // Validate from name + let from_name_trimmed = from_name.trim(); + if from_name_trimmed.is_empty() { + ctx.result.add_tag("FROM_NO_DN"); + } else if from_name_trimmed == from_addr.address { + ctx.result.add_tag("FROM_DN_EQ_ADDR"); + } else { + let from_name_addr = Email::new(from_name_trimmed); + if from_addr_is_valid { + ctx.result.add_tag("FROM_HAS_DN"); + } + if from_name_addr.is_valid() { + if (from_addr_is_valid + && from_name_addr.domain_part.sld != from_addr.domain_part.sld) + || (!env_from_empty + && ctx.output.env_from_addr.domain_part.sld + != from_name_addr.domain_part.sld) + || (env_from_empty + && ctx.output.ehlo_host.sld != from_name_addr.domain_part.sld) + { + ctx.result.add_tag("SPOOF_DISPLAY_NAME"); + } else { + ctx.result.add_tag("FROM_NEQ_DISPLAY_NAME"); + } + } else { + for title in TITLES { + if from_name.contains(title) { + ctx.result.add_tag("FROM_NAME_HAS_TITLE"); + break; + } + } + + if from_name.contains(" ") { + ctx.result.add_tag("FROM_NAME_EXCESS_SPACE"); + } + } + } + + // Check sender + if ctx.output.env_from_postmaster { + ctx.result.add_tag("FROM_BOUNCE"); + } + + if (!env_from_empty && ctx.output.env_from_addr.address == from_addr.address) + || (!ctx.output.env_from_postmaster + && from_addr_is_valid + && from_addr.domain_part.sld == ctx.output.ehlo_host.sld) + { + ctx.result.add_tag("FROM_EQ_ENVFROM"); + } else if from_addr_is_valid { + ctx.result.add_tag("FORGED_SENDER"); + ctx.result.add_tag("FROM_NEQ_ENVFROM"); + } + + if from_addr.local_part.contains("+") { + ctx.result.add_tag("TAGGED_FROM"); + } + + // Validate FROM/TO relationship + if ctx.output.recipients_to.len() + ctx.output.recipients_cc.len() == 1 { + let rcpt = ctx + .output + .recipients_to + .first() + .or_else(|| ctx.output.recipients_cc.first()) + .unwrap(); + if rcpt.email.address == from_addr.address { + ctx.result.add_tag("TO_EQ_FROM"); + } else if rcpt.email.domain_part.fqdn == from_addr.domain_part.fqdn { + ctx.result.add_tag("TO_DOM_EQ_FROM_DOM"); + } + } + + // Validate encoding + let from_raw_utf8 = std::str::from_utf8(from_raw); + if !from_raw.is_ascii() { + if (ctx.input.env_from_flags + & (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME)) + == 0 + { + ctx.result.add_tag("FROM_NEEDS_ENCODING"); + } + + if from_raw_utf8.is_err() { + ctx.result.add_tag("INVALID_FROM_8BIT"); + } + } + + // Validate unnecessary encoding + let from_raw_utf8 = from_raw_utf8.unwrap_or_default(); + if from_name.is_ascii() + && from_addr.address.is_ascii() + && from_raw_utf8.contains("=?") + && from_raw_utf8.contains("?=") + { + if from_raw_utf8.contains("?q?") || from_raw_utf8.contains("?Q?") { + // From header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("FROM_EXCESS_QP"); + } else if from_raw_utf8.contains("?b?") || from_raw_utf8.contains("?B?") { + // From header is unnecessarily encoded in base64 + ctx.result.add_tag("FROM_EXCESS_BASE64"); + } + } + + // Validate space in FROM + if !from_name.is_empty() + && !from_addr.address.is_empty() + && !from_raw_utf8.contains(" <") + { + ctx.result.add_tag("R_NO_SPACE_IN_FROM"); + } + + // Check whether read confirmation address is different to from address + if let Some(crt) = crt { + if crt != from_addr.address { + ctx.result.add_tag("HEADER_RCONFIRM_MISMATCH"); + } + } + } + + if !env_from_empty { + // Validate envelope address + if ctx.output.env_from_addr.is_valid() { + if SERVICE_ACCOUNTS.contains(&ctx.output.env_from_addr.local_part.as_str()) { + ctx.result.add_tag("ENVFROM_SERVICE_ACCT"); + } + if self.spam.list_freemail_providers.contains( + ctx.output + .env_from_addr + .domain_part + .sld + .as_deref() + .unwrap_or_default(), + ) { + ctx.result.add_tag("FREEMAIL_ENVFROM"); + } else if self.spam.list_disposable_providers.contains( + ctx.output + .env_from_addr + .domain_part + .sld + .as_deref() + .unwrap_or_default(), + ) { + ctx.result.add_tag("DISPOSABLE_ENVFROM"); + } + + // Mail from no resolve to A or MX + if matches!( + ( + self.dns_exists_ip(&ctx.output.env_from_addr.domain_part.fqdn) + .await, + self.dns_exists_mx(&ctx.output.env_from_addr.domain_part.fqdn) + .await + ), + (Ok(false), Ok(false)) + ) { + // Helo no resolve to A or MX + ctx.result.add_tag("FROMHOST_NORES_A_OR_MX"); + } + } else { + ctx.result.add_tag("ENVFROM_INVALID"); + } + + // Check whether disposition notification address is different to return path + if let Some(dnt) = dnt { + if dnt != ctx.output.env_from_addr.address { + ctx.result.add_tag("HEADER_FORGED_MDN"); + } + } + } + + for addr in [ + ctx.output.reply_to.as_ref().map(|s| &s.email), + sender.as_ref(), + ] + .into_iter() + .flatten() + { + if !is_from_service_account && SERVICE_ACCOUNTS.contains(&addr.local_part.as_str()) { + is_from_service_account = true; + } + + if !is_www_dot_domain && addr.domain_part.fqdn.starts_with("www.") { + is_www_dot_domain = true; + } + } + + if is_from_service_account { + ctx.result.add_tag("FROM_SERVICE_ACCT"); + } + + if is_www_dot_domain { + ctx.result.add_tag("WWW_DOT_DOMAIN"); + } + } +} diff --git a/crates/spam-filter/src/analysis/headers.rs b/crates/spam-filter/src/analysis/headers.rs index 71eceffd..6b8db3bd 100644 --- a/crates/spam-filter/src/analysis/headers.rs +++ b/crates/spam-filter/src/analysis/headers.rs @@ -6,14 +6,14 @@ use store::ahash::AHashSet; use crate::SpamFilterContext; -pub trait SpamFilterAnalyzeEhlo: Sync + Send { +pub trait SpamFilterAnalyzeHeaders: Sync + Send { fn spam_filter_analyze_headers( &self, ctx: &mut SpamFilterContext<'_>, ) -> impl Future + Send; } -impl SpamFilterAnalyzeEhlo for Core { +impl SpamFilterAnalyzeHeaders for Core { async fn spam_filter_analyze_headers(&self, ctx: &mut SpamFilterContext<'_>) { let mut list_score = 0.0; let mut unique_headers = AHashSet::new(); @@ -35,11 +35,11 @@ impl SpamFilterAnalyzeEhlo for Core { | HeaderName::References | HeaderName::InReplyTo => { if !unique_headers.insert(header.name.clone()) { - ctx.add_tag("MULTIPLE_UNIQUE_HEADERS"); + ctx.result.add_tag("MULTIPLE_UNIQUE_HEADERS"); } - if !matches!(raw_message.get(header.offset_field), Some(b' ')) { - ctx.add_tag("HEADER_EMPTY_DELIMITER"); + if !matches!(raw_message.get(header.offset_start), Some(b' ')) { + ctx.result.add_tag("HEADER_EMPTY_DELIMITER"); } } HeaderName::ListArchive @@ -56,7 +56,7 @@ impl SpamFilterAnalyzeEhlo for Core { } HeaderName::ListUnsubscribe => { list_score += 0.25; - ctx.add_tag("HAS_LIST_UNSUB"); + ctx.result.add_tag("HAS_LIST_UNSUB"); } HeaderName::Other(name) => { let value = header @@ -69,7 +69,7 @@ impl SpamFilterAnalyzeEhlo for Core { if name.eq_ignore_ascii_case("Precedence") { if value == "bulk" { list_score += 0.25; - ctx.add_tag("PRECEDENCE_BULK"); + ctx.result.add_tag("PRECEDENCE_BULK"); } else if value == "list" { list_score += 0.25; } @@ -78,50 +78,50 @@ impl SpamFilterAnalyzeEhlo for Core { } else if name.eq_ignore_ascii_case("X-Priority") { match value.parse::().unwrap_or(i32::MAX) { 0 => { - ctx.add_tag("HAS_X_PRIO_ZERO"); + ctx.result.add_tag("HAS_X_PRIO_ZERO"); } 1 => { - ctx.add_tag("HAS_X_PRIO_ONE"); + ctx.result.add_tag("HAS_X_PRIO_ONE"); } 2 => { - ctx.add_tag("HAS_X_PRIO_TWO"); + ctx.result.add_tag("HAS_X_PRIO_TWO"); } 3 | 4 => { - ctx.add_tag("HAS_X_PRIO_THREE"); + ctx.result.add_tag("HAS_X_PRIO_THREE"); } 4..=10000 => { - ctx.add_tag("HAS_X_PRIO_FIVE"); + ctx.result.add_tag("HAS_X_PRIO_FIVE"); } _ => {} } } else if name.eq_ignore_ascii_case("X-Mailer") { if name != "X-Mailer" { - ctx.add_tag("XM_CASE"); + ctx.result.add_tag("XM_CASE"); } if !value.is_empty() { if !value.as_bytes().iter().any(|&b| b.is_ascii_digit()) { - ctx.add_tag("XM_UA_NO_VERSION"); + ctx.result.add_tag("XM_UA_NO_VERSION"); } if value.contains("phpmailer") { - ctx.add_tag("HAS_PHPMAILER_SIG"); + ctx.result.add_tag("HAS_PHPMAILER_SIG"); } } } else if name.eq_ignore_ascii_case("User-Agent") { if !value.is_empty() && !value.as_bytes().iter().any(|&b| b.is_ascii_digit()) { - ctx.add_tag("XM_UA_NO_VERSION"); + ctx.result.add_tag("XM_UA_NO_VERSION"); } } else if name.eq_ignore_ascii_case("Organization") || name.eq_ignore_ascii_case("Organisation") { - ctx.add_tag("HAS_ORG_HEADER"); + ctx.result.add_tag("HAS_ORG_HEADER"); } else if name.eq_ignore_ascii_case("X-Originating-IP") { - ctx.add_tag("HAS_XOIP"); + ctx.result.add_tag("HAS_XOIP"); } else if name.eq_ignore_ascii_case("X-KLMS-AntiSpam-Status") { if value.contains("spam") { - ctx.add_tag("KLMS_SPAM"); + ctx.result.add_tag("KLMS_SPAM"); } } else if name.eq_ignore_ascii_case("X-Spam") || name.eq_ignore_ascii_case("X-Spam-Flag") @@ -129,53 +129,53 @@ impl SpamFilterAnalyzeEhlo for Core { { if value.contains("yes") || value.contains("true") || value.contains("spam") { - ctx.add_tag("SPAM_FLAG"); + ctx.result.add_tag("SPAM_FLAG"); } } else if name.eq_ignore_ascii_case("X-UI-Filterresults") || name.eq_ignore_ascii_case("X-UI-Out-Filterresults") { if value.contains("junk") { - ctx.add_tag("UNITEDINTERNET_SPAM"); + ctx.result.add_tag("UNITEDINTERNET_SPAM"); } } else if name.eq_ignore_ascii_case("X-PHP-Originating-Script") { - ctx.add_tag("HAS_X_POS"); + ctx.result.add_tag("HAS_X_POS"); if value.contains("eval()") { - ctx.add_tag("X_PHP_EVAL"); + ctx.result.add_tag("X_PHP_EVAL"); } if value.contains("../") { - ctx.add_tag("HIDDEN_SOURCE_OBJ"); + ctx.result.add_tag("HIDDEN_SOURCE_OBJ"); } } else if name.eq_ignore_ascii_case("X-PHP-Script") { - ctx.add_tag("HAS_X_PHP_SCRIPT"); + ctx.result.add_tag("HAS_X_PHP_SCRIPT"); if value.contains("eval()") { - ctx.add_tag("X_PHP_EVAL"); + ctx.result.add_tag("X_PHP_EVAL"); } if value.contains("../") { - ctx.add_tag("HIDDEN_SOURCE_OBJ"); + ctx.result.add_tag("HIDDEN_SOURCE_OBJ"); } if value.contains("sendmail.php") { - ctx.add_tag("PHP_XPS_PATTERN"); + ctx.result.add_tag("PHP_XPS_PATTERN"); } } else if name.eq_ignore_ascii_case("X-Source") || name.eq_ignore_ascii_case("X-Source-Args") || name.eq_ignore_ascii_case("X-Source-Dir") { - ctx.add_tag("HAS_X_SOURCE"); + ctx.result.add_tag("HAS_X_SOURCE"); if value.contains("'../") { - ctx.add_tag("HIDDEN_SOURCE_OBJ"); + ctx.result.add_tag("HIDDEN_SOURCE_OBJ"); } } else if name.eq_ignore_ascii_case("X-Authenticated-Sender") { if value.contains(": ") { - ctx.add_tag("HAS_X_AS"); + ctx.result.add_tag("HAS_X_AS"); } } else if name.eq_ignore_ascii_case("X-Get-Message-Sender-Via") { if value.contains("authenticated_id:") { - ctx.add_tag("HAS_X_GMSV"); + ctx.result.add_tag("HAS_X_GMSV"); } } else if name.eq_ignore_ascii_case("X-AntiAbuse") { - ctx.add_tag("HAS_X_ANTIABUSE"); + ctx.result.add_tag("HAS_X_ANTIABUSE"); } else if name.eq_ignore_ascii_case("X-Authentication-Warning") { - ctx.add_tag("HAS_XAW"); + ctx.result.add_tag("HAS_XAW"); } } _ => {} @@ -183,11 +183,11 @@ impl SpamFilterAnalyzeEhlo for Core { } if list_score >= 1.0 { - ctx.add_tag("MAILLIST"); + ctx.result.add_tag("MAILLIST"); } if unique_headers.is_empty() { - ctx.add_tag("MISSING_ESSENTIAL_HEADERS"); + ctx.result.add_tag("MISSING_ESSENTIAL_HEADERS"); } } } diff --git a/crates/spam-filter/src/analysis/init.rs b/crates/spam-filter/src/analysis/init.rs index 77eae653..9e4e4966 100644 --- a/crates/spam-filter/src/analysis/init.rs +++ b/crates/spam-filter/src/analysis/init.rs @@ -1,54 +1,115 @@ use common::Core; use mail_parser::{parsers::fields::thread::thread_name, HeaderName}; -use store::ahash::AHashSet; -use crate::{Email, Hostname, SpamFilterContext, SpamFilterInput, SpamFilterOutput}; +use crate::{ + Email, Hostname, Recipient, SpamFilterContext, SpamFilterInput, SpamFilterOutput, + SpamFilterResult, +}; pub trait SpamFilterInit { fn spam_filter_init<'x>(&self, input: SpamFilterInput<'x>) -> SpamFilterContext<'x>; } +const POSTMASTER_ADDRESSES: [&str; 3] = ["postmaster", "mailer-daemon", "root"]; + impl SpamFilterInit for Core { fn spam_filter_init<'x>(&self, input: SpamFilterInput<'x>) -> SpamFilterContext<'x> { - let subject = input.message.subject().unwrap_or_default().to_lowercase(); - let from = input.message.from().and_then(|f| f.first()); - let mut recipients = AHashSet::new(); + let mut subject = String::new(); + let mut from = None; + let mut reply_to = None; + let mut recipients_to = Vec::new(); + let mut recipients_cc = Vec::new(); + let mut recipients_bcc = Vec::new(); + for header in input.message.headers() { - if matches!( - header.name, - HeaderName::To | HeaderName::Cc | HeaderName::Bcc - ) { - if let Some(addrs) = header.value().as_address() { - for addr in addrs.iter() { - if let Some(addr) = addr.address() { - recipients.insert(Email::new(addr)); + match &header.name { + HeaderName::To | HeaderName::Cc | HeaderName::Bcc => { + if let Some(addrs) = header.value().as_address() { + for addr in addrs.iter() { + let rcpt = Recipient { + email: Email::new(addr.address().unwrap_or_default()), + name: addr.name().and_then(|s| { + let s = s.trim(); + if !s.is_empty() { + Some(s.to_lowercase()) + } else { + None + } + }), + }; + if header.name == HeaderName::To { + recipients_to.push(rcpt); + } else if header.name == HeaderName::Cc { + recipients_cc.push(rcpt); + } else { + recipients_bcc.push(rcpt); + } } } } + HeaderName::ReplyTo => { + reply_to = header + .value() + .as_address() + .and_then(|addrs| addrs.first()) + .and_then(|addr| { + Some(Recipient { + email: Email::new(addr.address()?), + name: addr.name().and_then(|s| { + let s = s.trim(); + if !s.is_empty() { + Some(s.to_lowercase()) + } else { + None + } + }), + }) + }); + } + HeaderName::Subject => { + subject = header.value().as_text().unwrap_or_default().to_lowercase(); + } + HeaderName::From => { + from = header.value().as_address().and_then(|addrs| addrs.first()); + } + _ => {} } } - let output = SpamFilterOutput { - tags: Default::default(), - ehlo_host: Hostname::new(input.ehlo_domain), - iprev_ptr: input - .iprev_result - .ptr - .as_ref() - .and_then(|ptr| ptr.first()) - .map(|ptr| ptr.strip_suffix('.').unwrap_or(ptr).to_lowercase()), - env_from_addr: Email::new(input.env_mail_from), - from_addr: Email::new(from.and_then(|f| f.address()).unwrap_or_default()), - from_name: from - .and_then(|f| f.name()) - .unwrap_or_default() - .to_lowercase(), - subject_thread: thread_name(&subject).to_string(), - subject, - recipients, - }; - - SpamFilterContext { output, input } + let env_from_addr = Email::new(input.env_from); + SpamFilterContext { + output: SpamFilterOutput { + ehlo_host: Hostname::new(input.ehlo_domain), + iprev_ptr: input + .iprev_result + .ptr + .as_ref() + .and_then(|ptr| ptr.first()) + .map(|ptr| ptr.strip_suffix('.').unwrap_or(ptr).to_lowercase()), + 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 + .iter() + .map(|rcpt| Email::new(rcpt)) + .collect(), + from: Recipient { + email: Email::new(from.and_then(|f| f.address()).unwrap_or_default()), + name: from.and_then(|f| f.name()).map(|s| s.to_lowercase()), + }, + reply_to, + subject_thread: thread_name(&subject).to_string(), + subject, + recipients_to, + recipients_cc, + recipients_bcc, + }, + input, + result: SpamFilterResult { + tags: Default::default(), + }, + } } } @@ -60,15 +121,15 @@ use common::Core; use crate::SpamFilterContext; -pub trait SpamFilterAnalyzeEhlo: Sync + Send { - fn spam_filter_analyze_ehlo( +pub trait SpamFilterAnalyze!: Sync + Send { + fn spam_filter_analyze_*( &self, ctx: &mut SpamFilterContext<'_>, ) -> impl Future + Send; } -impl SpamFilterAnalyzeEhlo for Core { - async fn spam_filter_analyze_ehlo(&self, ctx: &mut SpamFilterContext<'_>) { +impl SpamFilterAnalyze! for Core { + async fn spam_filter_analyze_*(&self, ctx: &mut SpamFilterContext<'_>) { todo!() } } diff --git a/crates/spam-filter/src/analysis/iprev.rs b/crates/spam-filter/src/analysis/iprev.rs index b59df277..d4b28936 100644 --- a/crates/spam-filter/src/analysis/iprev.rs +++ b/crates/spam-filter/src/analysis/iprev.rs @@ -5,18 +5,18 @@ use mail_auth::IprevResult; use crate::SpamFilterContext; -pub trait SpamFilterAnalyzeEhlo: Sync + Send { +pub trait SpamFilterAnalyzeIpRev: Sync + Send { fn spam_filter_analyze_iprev( &self, ctx: &mut SpamFilterContext<'_>, ) -> impl Future + Send; } -impl SpamFilterAnalyzeEhlo for Core { +impl SpamFilterAnalyzeIpRev for Core { async fn spam_filter_analyze_iprev(&self, ctx: &mut SpamFilterContext<'_>) { match &ctx.input.iprev_result.result { - IprevResult::TempError(_) => ctx.add_tag("RDNS_DNSFAIL"), - IprevResult::Fail(_) | IprevResult::PermError(_) => ctx.add_tag("RDNS_DNSFAIL"), + IprevResult::TempError(_) => ctx.result.add_tag("RDNS_DNSFAIL"), + IprevResult::Fail(_) | IprevResult::PermError(_) => ctx.result.add_tag("RDNS_DNSFAIL"), IprevResult::Pass | IprevResult::None => (), } } diff --git a/crates/spam-filter/src/analysis/messageid.rs b/crates/spam-filter/src/analysis/messageid.rs index 69fa2d75..112728fb 100644 --- a/crates/spam-filter/src/analysis/messageid.rs +++ b/crates/spam-filter/src/analysis/messageid.rs @@ -5,14 +5,14 @@ use mail_parser::HeaderName; use crate::{Hostname, SpamFilterContext}; -pub trait SpamFilterAnalyzeEhlo: Sync + Send { +pub trait SpamFilterAnalyzeMid: Sync + Send { fn spam_filter_analyze_message_id( &self, ctx: &mut SpamFilterContext<'_>, ) -> impl Future + Send; } -impl SpamFilterAnalyzeEhlo for Core { +impl SpamFilterAnalyzeMid for Core { async fn spam_filter_analyze_message_id(&self, ctx: &mut SpamFilterContext<'_>) { let mid_raw = ctx .input @@ -31,55 +31,55 @@ impl SpamFilterAnalyzeEhlo for Core { if let Some(mid_host) = mid.rsplit_once('@').map(|(_, host)| Hostname::new(host)) { if mid_host.ip.is_some() { if mid_host.fqdn.starts_with('[') { - ctx.add_tag("MID_RHS_IP_LITERAL"); + ctx.result.add_tag("MID_RHS_IP_LITERAL"); } else { - ctx.add_tag("MID_BARE_IP"); + ctx.result.add_tag("MID_BARE_IP"); } } else if !mid_host.fqdn.contains('.') { - ctx.add_tag("MID_RHS_NOT_FQDN"); + ctx.result.add_tag("MID_RHS_NOT_FQDN"); } else if mid_host.fqdn.starts_with("www.") { - ctx.add_tag("MID_RHS_WWW"); + ctx.result.add_tag("MID_RHS_WWW"); } if !mid_raw.is_ascii() || mid_raw.contains('(') || mid.starts_with('@') { - ctx.add_tag("INVALID_MSGID"); + ctx.result.add_tag("INVALID_MSGID"); } if mid_host.fqdn.len() > 255 { - ctx.add_tag("MID_RHS_TOO_LONG"); + ctx.result.add_tag("MID_RHS_TOO_LONG"); } // From address present in Message-ID checks - for sender in [&ctx.output.from_addr, &ctx.output.env_from_addr] { + for sender in [&ctx.output.from.email, &ctx.output.env_from_addr] { if !sender.address.is_empty() { if mid.contains(&sender.address) { - ctx.output.tags.insert("MID_CONTAINS_FROM".to_string()); + ctx.result.add_tag("MID_CONTAINS_FROM"); } else if mid_host.fqdn == sender.domain_part.fqdn { - ctx.output.tags.insert("MID_RHS_MATCH_FROM".to_string()); + ctx.result.add_tag("MID_RHS_MATCH_FROM"); } else if matches!((&mid_host.sld, &sender.domain_part.sld), (Some(mid_sld), Some(sender_sld)) if mid_sld == sender_sld) { - ctx.output.tags.insert("MID_RHS_MATCH_FROMTLD".to_string()); + ctx.result.add_tag("MID_RHS_MATCH_FROMTLD"); } } } // To/Cc addresses present in Message-ID checks - for addr in &ctx.output.recipients { - if mid.contains(&addr.address) { - ctx.output.tags.insert("MID_CONTAINS_TO".to_string()); - } else if mid_host.fqdn == addr.domain_part.fqdn { - ctx.output.tags.insert("MID_RHS_MATCH_TO".to_string()); + for rcpt in ctx.output.all_recipients() { + if mid.contains(&rcpt.email.address) { + ctx.result.add_tag("MID_CONTAINS_TO"); + } else if mid_host.fqdn == rcpt.email.domain_part.fqdn { + ctx.result.add_tag("MID_RHS_MATCH_TO"); } } } else { - ctx.add_tag("INVALID_MSGID"); + ctx.result.add_tag("INVALID_MSGID"); } if !mid_raw.starts_with('<') || !mid_raw.ends_with('>') { - ctx.add_tag("MID_MISSING_BRACKETS"); + ctx.result.add_tag("MID_MISSING_BRACKETS"); } } else { - ctx.add_tag("MISSING_MID"); + ctx.result.add_tag("MISSING_MID"); } } } diff --git a/crates/spam-filter/src/analysis/mod.rs b/crates/spam-filter/src/analysis/mod.rs index 055089b1..1d17c21f 100644 --- a/crates/spam-filter/src/analysis/mod.rs +++ b/crates/spam-filter/src/analysis/mod.rs @@ -1,15 +1,43 @@ -use crate::SpamFilterContext; +use std::borrow::Cow; + +use mail_parser::{parsers::MessageStream, Header}; + +use crate::{Recipient, SpamFilterInput, SpamFilterOutput, SpamFilterResult}; pub mod date; pub mod dmarc; pub mod ehlo; +pub mod from; pub mod headers; pub mod init; pub mod iprev; pub mod messageid; +pub mod recipient; +pub mod replyto; -impl SpamFilterContext<'_> { - pub fn add_tag(&mut self, tag: impl Into) { - self.output.tags.insert(tag.into()); +impl SpamFilterInput<'_> { + pub fn header_as_address(&self, header: &Header<'_>) -> Option> { + self.message + .raw_message() + .get(header.offset_start..header.offset_end) + .map(|bytes| MessageStream::new(bytes).parse_address()) + .and_then(|addr| addr.into_address()) + .and_then(|addr| addr.into_list().into_iter().next()) + .and_then(|addr| addr.address) + } +} + +impl SpamFilterOutput { + pub fn all_recipients(&self) -> impl Iterator { + self.recipients_to + .iter() + .chain(self.recipients_cc.iter()) + .chain(self.recipients_bcc.iter()) + } +} + +impl SpamFilterResult { + pub fn add_tag(&mut self, tag: impl Into) { + self.tags.insert(tag.into()); } } diff --git a/crates/spam-filter/src/analysis/recipient.rs b/crates/spam-filter/src/analysis/recipient.rs new file mode 100644 index 00000000..ab015717 --- /dev/null +++ b/crates/spam-filter/src/analysis/recipient.rs @@ -0,0 +1,301 @@ +use std::future::Future; + +use common::{scripts::functions::text::levenshtein_distance, Core}; +use mail_parser::HeaderName; +use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8}; +use store::ahash::HashSet; + +use crate::SpamFilterContext; + +pub trait SpamFilterAnalyzeRecipient: Sync + Send { + fn spam_filter_analyze_recipient( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeRecipient for Core { + async fn spam_filter_analyze_recipient(&self, ctx: &mut SpamFilterContext<'_>) { + let mut to_raw = b"".as_slice(); + let mut cc_raw = b"".as_slice(); + let mut bcc_raw = b"".as_slice(); + let mut has_list_unsubscribe = false; + let mut has_list_id = false; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::To | HeaderName::Cc | HeaderName::Bcc => { + let raw = ctx + .input + .message + .raw_message() + .get(header.offset_start..header.offset_end) + .unwrap_or_default(); + match header.name { + HeaderName::To => to_raw = raw, + HeaderName::Cc => cc_raw = raw, + HeaderName::Bcc => bcc_raw = raw, + _ => unreachable!(), + } + } + HeaderName::ListUnsubscribe => { + has_list_unsubscribe = true; + } + HeaderName::ListId => { + has_list_id = true; + } + _ => {} + } + } + + if to_raw.is_empty() { + ctx.result.add_tag("MISSING_TO"); + } + + let to_raw_utf8 = std::str::from_utf8(to_raw); + let cc_raw_utf8 = std::str::from_utf8(cc_raw); + let bcc_raw_utf8 = std::str::from_utf8(bcc_raw); + + for (raw, raw_utf8, recipients) in [ + (to_raw, &to_raw_utf8, &ctx.output.recipients_to), + (cc_raw, &cc_raw_utf8, &ctx.output.recipients_cc), + (bcc_raw, &bcc_raw_utf8, &ctx.output.recipients_bcc), + ] { + if !raw.is_empty() { + // Validate non-ASCII characters in recipient headers + if !raw.is_ascii() { + if (ctx.input.env_from_flags + & (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME)) + == 0 + { + ctx.result.add_tag("TO_NEEDS_ENCODING"); + } + + if raw_utf8.is_err() { + ctx.result.add_tag("INVALID_TO_8BIT"); + } + } + + // Validate unnecessary encoding in recipient headers + let raw_utf8 = raw_utf8.unwrap_or_default(); + if recipients.iter().all(|rcpt| { + rcpt.name.as_ref().map_or(true, |name| name.is_ascii()) + && rcpt.email.address.is_ascii() + }) && raw_utf8.contains("=?") + && raw_utf8.contains("?=") + { + if raw_utf8.contains("?q?") || raw_utf8.contains("?Q?") { + // To header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("TO_EXCESS_QP"); + } else if raw_utf8.contains("?b?") || raw_utf8.contains("?B?") { + // To header is unnecessarily encoded in base64 + ctx.result.add_tag("TO_EXCESS_BASE64"); + } + } + + // Check for spaces in recipient addresses + for token in raw_utf8.split('<') { + if let Some((addr, _)) = token.split_once('>') { + if addr.starts_with(' ') || addr.ends_with(' ') { + ctx.result.add_tag("TO_WRAPPED_IN_SPACES"); + break; + } + } + } + } + } + + let unique_recipients = ctx + .output + .all_recipients() + .filter(|rcpt| !rcpt.email.address.is_empty()) + .collect::>(); + let rcpt_count = unique_recipients.len(); + + match unique_recipients.len() { + 0 => { + ctx.result.add_tag("RCPT_COUNT_ZERO"); + for raw in &[to_raw_utf8, cc_raw_utf8, bcc_raw_utf8] { + if matches!(raw, Ok(raw) if raw.to_ascii_lowercase().contains("undisclosed")) { + ctx.result.add_tag("R_UNDISC_RCPT"); + break; + } + } + return; + } + 1 => { + ctx.result.add_tag("RCPT_COUNT_ONE"); + } + 2 => { + ctx.result.add_tag("RCPT_COUNT_TWO"); + } + 3 => { + ctx.result.add_tag("RCPT_COUNT_THREE"); + } + 4 | 5 => { + ctx.result.add_tag("RCPT_COUNT_FIVE"); + } + 6 | 7 => { + ctx.result.add_tag("RCPT_COUNT_SEVEN"); + } + 8..=12 => { + ctx.result.add_tag("RCPT_COUNT_TWELVE"); + } + 13.. => { + ctx.result.add_tag("RCPT_COUNT_GT_50"); + } + } + + let mut to_dn_eq_addr_count = 0; + let mut to_dn_count = 0; + let mut to_match_envrcpt = 0; + let is_from_info = ctx.output.from.email.local_part == "info"; + + for rcpt in &unique_recipients { + // Validate name + if let Some(rcpt_name) = &rcpt.name { + if rcpt_name == &rcpt.email.address { + to_dn_eq_addr_count += 1; + } else { + to_dn_count += 1; + if ["recipient", "recipients"].contains(&rcpt_name.as_str()) { + ctx.result.add_tag("TO_DN_RECIPIENTS"); + } + } + } + + // Recipient is present in envelope + if ctx.output.env_to_addr.contains(&rcpt.email) { + to_match_envrcpt += 1; + } + + // Check if the local part is present in the subject + if !rcpt.email.local_part.is_empty() { + if ctx.output.subject.contains(&rcpt.email.address) { + ctx.result.add_tag("RCPT_ADDR_IN_SUBJECT"); + } else if rcpt.email.local_part.len() > 3 + && ctx.output.subject.contains(&rcpt.email.local_part) + { + ctx.result.add_tag("RCPT_LOCAL_IN_SUBJECT"); + } + + if rcpt.email.local_part.contains('+') { + ctx.result.add_tag("TAGGED_RCPT"); + } + } + + // Check if it is an into to info + if has_list_unsubscribe && is_from_info && rcpt.email.local_part == "info" { + ctx.result.add_tag("INFO_TO_INFO_LU"); + } + + // Check for freemail or disposable domains + if let Some(domain) = rcpt.email.domain_part.sld.as_deref() { + if self.spam.list_freemail_providers.contains(domain) { + if ctx + .output + .recipients_to + .iter() + .any(|r| r.email == rcpt.email) + { + ctx.result.add_tag("FREEMAIL_TO"); + } else { + ctx.result.add_tag("FREEMAIL_CC"); + } + } else if self.spam.list_disposable_providers.contains(domain) { + if ctx + .output + .recipients_to + .iter() + .any(|r| r.email == rcpt.email) + { + ctx.result.add_tag("DISPOSABLE_TO"); + } else { + ctx.result.add_tag("DISPOSABLE_CC"); + } + } + } + } + + if to_dn_count == 0 && to_dn_eq_addr_count == 0 { + ctx.result.add_tag("TO_DN_NONE"); + } else if to_dn_count == rcpt_count { + ctx.result.add_tag("TO_DN_ALL"); + } else if to_dn_count > 0 { + ctx.result.add_tag("TO_DN_SOME"); + } + + if to_dn_eq_addr_count == rcpt_count { + ctx.result.add_tag("TO_DN_EQ_ADDR_ALL"); + } else if to_dn_eq_addr_count > 0 { + ctx.result.add_tag("TO_DN_EQ_ADDR_SOME"); + } + + if to_match_envrcpt == rcpt_count { + ctx.result.add_tag("TO_MATCH_ENVRCPT_ALL"); + } else { + if to_match_envrcpt > 0 { + ctx.result.add_tag("TO_MATCH_ENVRCPT_SOME"); + } + + if !has_list_id && !has_list_unsubscribe { + for env_rcpt in &ctx.output.env_to_addr { + if !unique_recipients.iter().any(|rcpt| rcpt.email == *env_rcpt) + && env_rcpt != &ctx.output.env_from_addr + { + ctx.result.add_tag("FORGED_RECIPIENTS"); + break; + } + } + } + } + + // Message from bounce and over 1 recipient + if rcpt_count > 1 && ctx.output.env_from_postmaster { + ctx.result.add_tag("RCPT_BOUNCEMOREONE"); + } + + for rcpts in [&ctx.output.recipients_to, &ctx.output.recipients_cc] { + let mut is_sorted = false; + if rcpts.len() >= 6 { + // Check if the recipients list is sorted + let mut sorted = true; + for i in 1..rcpts.len() { + if rcpts[i - 1].email.address > rcpts[i].email.address { + sorted = false; + break; + } + } + if sorted { + ctx.result.add_tag("SORTED_RECIPS"); + is_sorted = true; + } + } + + if !is_sorted && rcpt_count >= 5 { + // Look for similar recipients + let mut hits = 0; + let mut combinations = 0; + for i in 0..rcpts.len() { + for j in i + 1..rcpts.len() { + let a = &rcpts[i].email; + let b = &rcpts[j].email; + + if levenshtein_distance(&a.local_part, &b.local_part) < 3 + || (a.domain_part.fqdn != b.domain_part.fqdn + && levenshtein_distance(&a.domain_part.fqdn, &b.domain_part.fqdn) + < 4) + { + hits += 1; + } + combinations += 1; + } + } + + if hits as f64 / combinations as f64 > 0.65 { + ctx.result.add_tag("SUSPICIOUS_RECIPS"); + } + } + } + } +} diff --git a/crates/spam-filter/src/analysis/replyto.rs b/crates/spam-filter/src/analysis/replyto.rs new file mode 100644 index 00000000..d861cded --- /dev/null +++ b/crates/spam-filter/src/analysis/replyto.rs @@ -0,0 +1,153 @@ +use std::future::Future; + +use common::Core; +use mail_parser::HeaderName; + +use crate::SpamFilterContext; + +use super::from::TITLES; + +pub trait SpamFilterAnalyzeReplyTo: Sync + Send { + fn spam_filter_analyze_reply_to( + &self, + ctx: &mut SpamFilterContext<'_>, + ) -> impl Future + Send; +} + +impl SpamFilterAnalyzeReplyTo for Core { + async fn spam_filter_analyze_reply_to(&self, ctx: &mut SpamFilterContext<'_>) { + let mut reply_to_raw = b"".as_slice(); + let mut is_from_list = false; + + for header in ctx.input.message.headers() { + match &header.name { + HeaderName::ReplyTo => { + reply_to_raw = ctx + .input + .message + .raw_message() + .get(header.offset_start..header.offset_end) + .unwrap_or_default(); + } + HeaderName::ListUnsubscribe | HeaderName::ListId => { + is_from_list = true; + } + + HeaderName::Other(name) => { + if !is_from_list { + is_from_list = name.eq_ignore_ascii_case("X-To-Get-Off-This-List") + || name.eq_ignore_ascii_case("X-List") + || name.eq_ignore_ascii_case("Auto-Submitted"); + } + } + _ => {} + } + } + + if reply_to_raw.is_empty() { + return; + } + + if let Some(reply_to) = &ctx.output.reply_to { + let reply_to_name = reply_to.name.as_deref().unwrap_or_default(); + ctx.result.add_tag("HAS_REPLYTO"); + + if reply_to.email == ctx.output.from.email { + ctx.result.add_tag("REPLYTO_EQ_FROM"); + } else { + if reply_to.email.domain_part.sld == ctx.output.from.email.domain_part.sld { + ctx.result.add_tag("REPLYTO_DOM_EQ_FROM_DOM"); + } else { + if !is_from_list + && ctx + .output + .all_recipients() + .any(|r| r.email == reply_to.email) + { + ctx.result.add_tag("REPLYTO_EQ_TO_ADDR"); + } else { + ctx.result.add_tag("REPLYTO_DOM_NEQ_FROM_DOM"); + } + + if !(is_from_list + || ctx + .output + .recipients_to + .iter() + .any(|r| r.email == ctx.output.from.email) + || ctx + .output + .env_to_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.result.add_tag("SPOOF_REPLYTO"); + } + } + + if !reply_to_name.is_empty() + && reply_to_name == ctx.output.from.name.as_deref().unwrap_or_default() + { + ctx.result.add_tag("REPLYTO_DN_EQ_FROM_DN"); + } + } + + if reply_to.email == ctx.output.env_from_addr { + ctx.result.add_tag("REPLYTO_ADDR_EQ_FROM"); + } + + let reply_to_sld = reply_to + .email + .domain_part + .sld + .as_deref() + .unwrap_or_default(); + if self.spam.list_freemail_providers.contains(reply_to_sld) { + ctx.result.add_tag("FREEMAIL_REPLYTO"); + let from_domain_sld = ctx + .output + .from + .email + .domain_part + .sld + .as_deref() + .unwrap_or_default(); + if reply_to_sld != from_domain_sld + && self.spam.list_freemail_providers.contains(from_domain_sld) + { + ctx.result.add_tag("FREEMAIL_REPLYTO_NEQ_FROM_DOM"); + } + } else if self.spam.list_disposable_providers.contains(reply_to_sld) { + ctx.result.add_tag("DISPOSABLE_REPLYTO"); + } + + // Validate unnecessary encoding + let reply_to_raw_utf8 = std::str::from_utf8(reply_to_raw).unwrap_or_default(); + if reply_to.email.address.is_ascii() + && reply_to_name.is_ascii() + && reply_to_raw_utf8.contains("=?") + && reply_to_raw_utf8.contains("?=") + { + if reply_to_raw_utf8.contains("?q?") || reply_to_raw_utf8.contains("?Q?") { + // Reply-To header is unnecessarily encoded in quoted-printable + ctx.result.add_tag("REPLYTO_EXCESS_QP"); + } else if reply_to_raw_utf8.contains("?b?") || reply_to_raw_utf8.contains("?B?") { + // Reply-To header is unnecessarily encoded in base64 + ctx.result.add_tag("REPLYTO_EXCESS_BASE64"); + } + } + + // Validate reply-to name + for title in TITLES { + if reply_to_name.contains(title) { + ctx.result.add_tag("REPLYTO_EMAIL_HAS_TITLE"); + break; + } + } + } else { + ctx.result.add_tag("REPLYTO_UNPARSABLE"); + } + } +} diff --git a/crates/spam-filter/src/lib.rs b/crates/spam-filter/src/lib.rs index 9b6da274..a3c19f78 100644 --- a/crates/spam-filter/src/lib.rs +++ b/crates/spam-filter/src/lib.rs @@ -1,6 +1,7 @@ pub mod analysis; pub mod modules; +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::net::IpAddr; @@ -30,43 +31,58 @@ pub struct SpamFilterInput<'x> { pub tls_cipher: &'x str, // Envelope - pub env_mail_from: &'x str, + pub env_from: &'x str, + pub env_from_flags: u64, pub env_rcpt_to: &'x [&'x str], } pub struct SpamFilterOutput { - pub tags: AHashSet, pub ehlo_host: Hostname, pub iprev_ptr: Option, pub env_from_addr: Email, - pub from_addr: Email, - pub from_name: String, - pub recipients: AHashSet, + pub env_from_postmaster: bool, + pub env_to_addr: HashSet, + pub from: Recipient, + pub recipients_to: Vec, + pub recipients_cc: Vec, + pub recipients_bcc: Vec, + pub reply_to: Option, pub subject: String, pub subject_thread: String, } +pub struct SpamFilterResult { + pub tags: AHashSet, +} + pub struct SpamFilterContext<'x> { pub input: SpamFilterInput<'x>, pub output: SpamFilterOutput, + pub result: SpamFilterResult, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Hostname { pub fqdn: String, pub ip: Option, pub sld: Option, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Email { pub address: String, pub local_part: String, pub domain_part: Hostname, } +#[derive(Debug, Clone)] +pub struct Recipient { + pub email: Email, + pub name: Option, +} + impl PartialEq for Hostname { fn eq(&self, other: &Self) -> bool { self.fqdn.eq(&other.fqdn) @@ -94,3 +110,47 @@ impl Hash for Email { self.address.hash(state) } } + +impl Email { + pub fn is_valid(&self) -> bool { + self.domain_part.sld.is_some() && !self.local_part.is_empty() + } +} + +impl PartialEq for Recipient { + fn eq(&self, other: &Self) -> bool { + self.email.eq(&other.email) + } +} + +impl Eq for Recipient {} + +impl Hash for Recipient { + fn hash(&self, state: &mut H) { + self.email.hash(state) + } +} + +impl PartialOrd for Email { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialOrd for Recipient { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Email { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.address.cmp(&other.address) + } +} + +impl Ord for Recipient { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.email.cmp(&other.email) + } +} diff --git a/resources/config/spamfilter/scripts/from.sieve b/resources/config/spamfilter/scripts/from.sieve deleted file mode 100644 index aa1ea9c3..00000000 --- a/resources/config/spamfilter/scripts/from.sieve +++ /dev/null @@ -1,158 +0,0 @@ -let "from_count" "count(header.from[*].raw)"; -let "service_accounts" "['www-data', 'anonymous', 'ftp', 'apache', 'nobody', 'guest', 'nginx', 'web', 'www']"; - -if eval "from_count > 0" { - let "from_raw" "to_lowercase(header.from.raw)"; - - if eval "from_count > 1" { - let "t.MULTIPLE_FROM" "1"; - } - - if eval "is_email(from_addr)" { - if eval "contains(service_accounts, from_local)" { - let "t.FROM_SERVICE_ACCT" "1"; - } - if eval "starts_with(from_domain, 'www.')" { - let "t.WWW_DOT_DOMAIN" "1"; - } - - if eval "key_exists('spam-free', from_domain_sld)" { - let "t.FREEMAIL_FROM" "1"; - } elsif eval "key_exists('spam-disposable', from_domain_sld)" { - let "t.DISPOSABLE_FROM" "1"; - } - } else { - let "t.FROM_INVALID" "1"; - } - - if eval "is_empty(from_name)" { - let "t.FROM_NO_DN" "1"; - } elsif eval "eq_ignore_case(from_addr, from_name)" { - let "t.FROM_DN_EQ_ADDR" "1"; - } else { - if eval "!t.FROM_INVALID" { - let "t.FROM_HAS_DN" "1"; - } - - if eval "is_email(from_name)" { - let "from_name_sld" "domain_part(email_part(from_name, 'domain'), 'sld')"; - if eval "(!t.FROM_INVALID && from_domain_sld != from_name_sld) || - (!is_empty(envelope.from) && envfrom_domain_sld != from_name_sld) || - (is_empty(envelope.from) && helo_domain_sld != from_name_sld)" { - let "t.SPOOF_DISPLAY_NAME" "1"; - } else { - let "t.FROM_NEQ_DISPLAY_NAME" "1"; - } - } else { - if eval "contains(from_name, 'mr. ') || contains(from_name, 'ms. ') || contains(from_name, 'mrs. ') || contains(from_name, 'dr. ')" { - let "t.FROM_NAME_HAS_TITLE" "1"; - } - if eval "contains(header.from.name, ' ')" { - let "t.FROM_NAME_EXCESS_SPACE" "1"; - } - } - } - - if eval "is_empty(envelope.from) && - (from_local == 'postmaster' || - from_local == 'mailer-daemon' || - from_local == 'root')" { - let "t.FROM_BOUNCE" "1"; - } - - if eval "(!is_empty(envelope.from) && - eq_ignore_case(from_addr, envelope.from)) || - (t.FROM_BOUNCE && - !is_empty(from_domain) && - from_domain_sld == helo_domain_sld)" { - let "t.FROM_EQ_ENVFROM" "1"; - } elsif eval "!t.FROM_INVALID" { - let "t.FORGED_SENDER" "1"; - let "t.FROM_NEQ_ENVFROM" "1"; - } - - if eval "contains(from_local, '+')" { - let "t.TAGGED_FROM" "1"; - } - - if eval "count(recipients_to) + count(recipients_cc) == 1" { - if eval "eq_ignore_case(recipients_to[0], from_addr)" { - let "t.TO_EQ_FROM" "1"; - } elsif eval "eq_ignore_case(email_part(recipients_to[0], 'domain'), from_domain)" { - let "t.TO_DOM_EQ_FROM_DOM" "1"; - } - } - - if eval "!is_ascii(from_raw)" { - if eval "!env.param.smtputf8 && env.param.body != '8bitmime' && env.param.body != 'binarymime'" { - let "t.FROM_NEEDS_ENCODING" "1"; - } - if eval "!is_header_utf8_valid('From')" { - let "t.INVALID_FROM_8BIT" "1"; - } - } - - if eval "is_ascii(header.from) && contains(from_raw, '=?') && contains(from_raw, '?=')" { - if eval "contains(from_raw, '?q?')" { - # From header is unnecessarily encoded in quoted-printable - let "t.FROM_EXCESS_QP" "1"; - } elsif eval "contains(from_raw, '?b?')" { - # From header is unnecessarily encoded in base64 - let "t.FROM_EXCESS_BASE64" "1"; - } - } - - if eval "!is_empty(from_name) && !is_empty(from_addr) && !contains(from_raw, ' <')" { - let "t.R_NO_SPACE_IN_FROM" "1"; - } - - # Read confirmation address is different to from address - let "crt" "header.X-Confirm-Reading-To.addr"; - if eval "!is_empty(crt) && !eq_ignore_case(from_addr, crt)" { - let "t.HEADER_RCONFIRM_MISMATCH" "1"; - } -} else { - let "t.MISSING_FROM" "1"; -} - -if eval "!is_empty(envelope.from)" { - if eval "is_email(envelope.from)" { - if eval "contains(service_accounts, envfrom_local)" { - let "t.ENVFROM_SERVICE_ACCT" "1"; - } - } else { - let "t.ENVFROM_INVALID" "1"; - } - - if eval "!is_empty(envfrom_domain_sld)" { - if eval "key_exists('spam-free', envfrom_domain_sld)" { - let "t.FREEMAIL_ENVFROM" "1"; - } elsif eval "key_exists('spam-disposable', envfrom_domain_sld)" { - let "t.DISPOSABLE_ENVFROM" "1"; - } - - # Mail from no resolve to A or MX - if eval "!dns_exists(envfrom_domain, 'mx') && !dns_exists(envfrom_domain, 'ip')" { - let "t.FROMHOST_NORES_A_OR_MX" "1"; - } - } - - # Read confirmation address is different to return path - let "dnt" "header.Disposition-Notification-To.addr"; - if eval "!is_empty(dnt) && !eq_ignore_case(envelope.from, dnt)" { - let "t.HEADER_FORGED_MDN" "1"; - } -} - -if eval "!t.FROM_SERVICE_ACCT && - (contains_ignore_case(service_accounts, email_part(rto_addr, 'local')) || - contains_ignore_case(service_accounts, email_part(header.sender.addr, 'local')))" { - let "t.FROM_SERVICE_ACCT" "1"; -} - -if eval "!t.WWW_DOT_DOMAIN && - (contains_ignore_case(rto_addr, '@www.') || - contains_ignore_case(header.sender.addr, '@www.'))" { - let "t.WWW_DOT_DOMAIN" "1"; -} - diff --git a/resources/config/spamfilter/scripts/recipient.sieve b/resources/config/spamfilter/scripts/recipient.sieve deleted file mode 100644 index eea324e9..00000000 --- a/resources/config/spamfilter/scripts/recipient.sieve +++ /dev/null @@ -1,209 +0,0 @@ - -let "to_raw" "to_lowercase(header.to.raw)"; -if eval "!is_empty(to_raw)" { - if eval "is_ascii(header.to) && contains(to_raw, '=?') && contains(to_raw, '?=')" { - if eval "contains(to_raw, '?q?')" { - # To header is unnecessarily encoded in quoted-printable - let "t.TO_EXCESS_QP" "1"; - } elsif eval "contains(to_raw, '?b?')" { - # To header is unnecessarily encoded in base64 - let "t.TO_EXCESS_BASE64" "1"; - } - } elsif eval "!is_ascii(to_raw) && !env.param.smtputf8 && env.param.body != '8bitmime' && env.param.body != 'binarymime'" { - # To needs encoding - let "t.TO_NEEDS_ENCODING" "1"; - } -} else { - let "t.MISSING_TO" "1"; -} - -let "rcpt_count" "count(recipients_clean)"; - -if eval "rcpt_count > 0" { - if eval "rcpt_count == 1" { - let "t.RCPT_COUNT_ONE" "1"; - } elsif eval "rcpt_count == 2" { - let "t.RCPT_COUNT_TWO" "1"; - } elsif eval "rcpt_count == 3" { - let "t.RCPT_COUNT_THREE" "1"; - } elsif eval "rcpt_count <= 5" { - let "t.RCPT_COUNT_FIVE" "1"; - } elsif eval "rcpt_count <= 7" { - let "t.RCPT_COUNT_SEVEN" "1"; - } elsif eval "rcpt_count <= 12" { - let "t.RCPT_COUNT_TWELVE" "1"; - } else { - let "t.RCPT_COUNT_GT_50" "1"; - } - - let "rcpt_name" "to_lowercase(header.to:cc:bcc[*].name[*])"; - let "i" "count(recipients)"; - let "to_dn_count" "0"; - let "to_dn_eq_addr_count" "0"; - let "to_match_envrcpt" "0"; - - while "i != 0" { - let "i" "i - 1"; - let "addr" "recipients[i]"; - - if eval "!is_empty(addr)" { - let "name" "rcpt_name[i]"; - - if eval "!is_empty(name)" { - if eval "name == addr" { - let "to_dn_eq_addr_count" "to_dn_eq_addr_count + 1"; - } else { - let "to_dn_count" "to_dn_count + 1"; - if eval "name == 'recipient' || name == 'recipients'" { - let "t.TO_DN_RECIPIENTS" "1"; - } - } - } - - if eval "contains(envelope.to, addr)" { - let "to_match_envrcpt" "to_match_envrcpt + 1"; - } - - # Check if the local part is present in the subject - let "local_part" "email_part(addr, 'local')"; - if eval "!is_empty(local_part)" { - if eval "contains(subject_lc, addr)" { - let "t.RCPT_ADDR_IN_SUBJECT" "1"; - } elsif eval "len(local_part) > 3 && contains(subject_lc, local_part)" { - let "t.RCPT_LOCAL_IN_SUBJECT" "1"; - } - - if eval "contains(local_part, '+')" { - let "t.TAGGED_RCPT" "1"; - } - } - - # Check if it is an into to info - if eval "!t.INFO_TO_INFO_LU && - local_part == 'info' && - from_local == 'info' && - header.List-Unsubscribe.exists" { - let "t.INFO_TO_INFO_LU" "1"; - } - - # Check for freemail or disposable domains - let "domain" "domain_part(email_part(addr, 'domain'), 'sld')"; - if eval "!is_empty(domain)" { - if eval "key_exists('spam-free', domain)" { - if eval "!t.FREEMAIL_TO && contains_ignore_case(recipients_to, addr)" { - let "t.FREEMAIL_TO" "1"; - } elsif eval "!t.FREEMAIL_CC && contains_ignore_case(recipients_cc, addr)" { - let "t.FREEMAIL_CC" "1"; - } - } elsif eval "key_exists('spam-disposable', domain)" { - if eval "!t.DISPOSABLE_TO && contains_ignore_case(recipients_to, addr)" { - let "t.DISPOSABLE_TO" "1"; - } elsif eval "!t.DISPOSABLE_CC && contains_ignore_case(recipients_cc, addr)" { - let "t.DISPOSABLE_CC" "1"; - } - } - } - } - } - - if eval "to_dn_count == 0 && to_dn_eq_addr_count == 0" { - let "t.TO_DN_NONE" "1"; - } elsif eval "to_dn_count == rcpt_count" { - let "t.TO_DN_ALL" "1"; - } elsif eval "to_dn_count > 0" { - let "t.TO_DN_SOME" "1"; - } - - if eval "to_dn_eq_addr_count == rcpt_count" { - let "t.TO_DN_EQ_ADDR_ALL" "1"; - } elsif eval "to_dn_eq_addr_count > 0" { - let "t.TO_DN_EQ_ADDR_SOME" "1"; - } - - if eval "to_match_envrcpt == rcpt_count" { - let "t.TO_MATCH_ENVRCPT_ALL" "1"; - } else { - if eval "to_match_envrcpt > 0" { - let "t.TO_MATCH_ENVRCPT_SOME" "1"; - } - - if eval "is_empty(header.List-Unsubscribe:List-Id[*])" { - let "i" "count(envelope.to)"; - while "i != 0" { - let "i" "i - 1"; - let "env_rcpt" "envelope.to[i]"; - - if eval "!contains(recipients, env_rcpt) && env_rcpt != envelope.from" { - let "t.FORGED_RECIPIENTS" "1"; - break; - } - } - } - } - - # Message from bounce and over 1 recipient - if eval "rcpt_count > 1 && - (is_empty(envelope.from) || - envfrom_local == 'postmaster' || - envfrom_local == 'mailer-daemon')" { - let "t.RCPT_BOUNCEMOREONE" "1"; - } - - # Check for sorted recipients - if eval "rcpt_count >= 7 && sort(recipients_clean, false) == recipients_clean" { - let "t.SORTED_RECIPS" "1"; - } - - # Check for suspiciously similar recipients - if eval "!t.SORTED_RECIPS && rcpt_count => 5" { - let "i" "rcpt_count"; - let "hits" "0"; - let "combinations" "0"; - - while "i" { - let "i" "i - 1"; - let "j" "i"; - while "j" { - let "j" "j - 1"; - let "a" "recipients_clean[i]"; - let "b" "recipients_clean[j]"; - - if eval "levenshtein_distance(email_part(a, 'local'), email_part(b, 'local')) < 3" { - let "hits" "hits + 1"; - } - - let "a" "email_part(a, 'domain')"; - let "b" "email_part(b, 'domain')"; - - if eval "a != b && levenshtein_distance(a, b) < 4" { - let "hits" "hits + 1"; - } - - let "combinations" "combinations + 1"; - } - } - - if eval "hits / combinations > 0.65" { - let "t.SUSPICIOUS_RECIPS" "1"; - } - } - - # Check for spaces in recipient addresses - let "raw_to" "header.to:cc[*].raw"; - let "i" "len(raw_to)"; - while "i != 0" { - let "i" "i - 1"; - let "raw_addr" "rsplit(raw_to[i], '<')[0]"; - if eval "contains(raw_addr, '>') && (starts_with(raw_addr, ' ' ) || ends_with(raw_addr, ' >'))" { - let "t.TO_WRAPPED_IN_SPACES" "1"; - break; - } - } - -} else { - let "t.RCPT_COUNT_ZERO" "1"; - - if eval "contains(to_raw, 'undisclosed') && contains(to_raw, 'recipients')" { - let "t.R_UNDISC_RCPT" "1"; - } -} diff --git a/resources/config/spamfilter/scripts/replyto.sieve b/resources/config/spamfilter/scripts/replyto.sieve deleted file mode 100644 index bf0ef348..00000000 --- a/resources/config/spamfilter/scripts/replyto.sieve +++ /dev/null @@ -1,79 +0,0 @@ -let "rto_raw" "to_lowercase(header.reply-to.raw)"; -if eval "!is_empty(rto_raw)" { - let "rto_name" "to_lowercase(header.reply-to.name)"; - - if eval "is_email(rto_addr)" { - let "t.HAS_REPLYTO" "1"; - let "rto_domain_sld" "domain_part(email_part(rto_addr, 'domain'), 'sld')"; - - if eval "eq_ignore_case(header.reply-to, header.from)" { - let "t.REPLYTO_EQ_FROM" "1"; - } else { - if eval "rto_domain_sld == from_domain_sld" { - let "t.REPLYTO_DOM_EQ_FROM_DOM" "1"; - } else { - let "is_from_list" "!is_empty(header.List-Unsubscribe:List-Id:X-To-Get-Off-This-List:X-List:Auto-Submitted[*])"; - if eval "!is_from_list && contains_ignore_case(recipients_clean, rto_addr)" { - let "t.REPLYTO_EQ_TO_ADDR" "1"; - } else { - let "t.REPLYTO_DOM_NEQ_FROM_DOM" "1"; - } - - if eval "!is_from_list && - !eq_ignore_case(from_addr, header.to.addr) && - !(count(envelope.to) == 1 && envelope.to[0] == from_addr)" { - let "i" "count(envelope.to)"; - let "found_domain" "0"; - - while "i != 0" { - let "i" "i - 1"; - - if eval "domain_part(email_part(envelope.to[i], 'domain'), 'sld') == from_domain_sld" { - let "found_domain" "1"; - break; - } - } - - if eval "!found_domain" { - let "t.SPOOF_REPLYTO" "1"; - } - } - } - - if eval "!is_empty(rto_name) && eq_ignore_case(rto_name, header.from.name)" { - let "t.REPLYTO_DN_EQ_FROM_DN" "1"; - } - } - - if eval "rto_addr == envelope.from" { - let "t.REPLYTO_ADDR_EQ_FROM" "1"; - } - - if eval "key_exists('spam-free', rto_domain_sld)" { - let "t.FREEMAIL_REPLYTO" "1"; - if eval "rto_domain_sld != from_domain_sld && key_exists('spam-free', from_domain_sld)" { - let "t.FREEMAIL_REPLYTO_NEQ_FROM_DOM" "1"; - } - } elsif eval "key_exists('spam-disposable', rto_domain_sld)" { - let "t.DISPOSABLE_REPLYTO" "1"; - } - - } else { - let "t.REPLYTO_UNPARSABLE" "1"; - } - - if eval "is_ascii(header.reply-to) && contains(rto_raw, '=?') && contains(rto_raw, '?=')" { - if eval "contains(rto_raw, '?q?')" { - # Reply-To header is unnecessarily encoded in quoted-printable - let "t.REPLYTO_EXCESS_QP" "1"; - } elsif eval "contains(rto_raw, '?b?')" { - # Reply-To header is unnecessarily encoded in base64 - let "t.REPLYTO_EXCESS_BASE64" "1"; - } - } - - if eval "contains(rto_name, 'mr. ') || contains(rto_name, 'ms. ') || contains(rto_name, 'mrs. ') || contains(rto_name, 'dr. ')" { - let "t.REPLYTO_EMAIL_HAS_TITLE" "1"; - } -} -