diff options
author | mdecimus <mauro@stalw.art> | 2023-09-24 19:06:35 +0200 |
---|---|---|
committer | mdecimus <mauro@stalw.art> | 2023-09-24 19:06:35 +0200 |
commit | f069691844bcec5d62b962f6338d9a8c6b012781 (patch) | |
tree | 91fa4e626a05d6654ebbd067b17e1fbfac04c0ea | |
parent | 4690a9d625aec8ef886e1ff1ab7c6e580c30b70a (diff) |
Antispam from, recipient and reply-to modules
-rw-r--r-- | Cargo.lock | 57 | ||||
-rw-r--r-- | crates/imap/src/op/fetch.rs | 3 | ||||
-rw-r--r-- | crates/smtp/Cargo.toml | 1 | ||||
-rw-r--r-- | crates/smtp/src/inbound/data.rs | 2 | ||||
-rw-r--r-- | crates/smtp/src/inbound/ehlo.rs | 2 | ||||
-rw-r--r-- | crates/smtp/src/inbound/mail.rs | 2 | ||||
-rw-r--r-- | crates/smtp/src/inbound/rcpt.rs | 2 | ||||
-rw-r--r-- | crates/smtp/src/inbound/spawn.rs | 2 | ||||
-rw-r--r-- | crates/smtp/src/scripts/exec.rs | 28 | ||||
-rw-r--r-- | crates/smtp/src/scripts/functions/mod.rs | 1 | ||||
-rw-r--r-- | crates/smtp/src/scripts/functions/text.rs | 20 | ||||
-rw-r--r-- | crates/utils/src/lib.rs | 1 | ||||
-rw-r--r-- | resources/config/sieve/from.sieve | 20 | ||||
-rw-r--r-- | resources/config/sieve/messageid.sieve | 2 | ||||
-rw-r--r-- | resources/config/sieve/received.sieve | 2 | ||||
-rw-r--r-- | resources/config/sieve/recipient.sieve | 131 | ||||
-rw-r--r-- | resources/config/sieve/replyto.sieve | 70 | ||||
-rw-r--r-- | tests/resources/smtp/antispam/from.test | 4 | ||||
-rw-r--r-- | tests/resources/smtp/antispam/recipient.test | 109 | ||||
-rw-r--r-- | tests/resources/smtp/antispam/replyto.test | 91 | ||||
-rw-r--r-- | tests/src/smtp/inbound/antispam.rs | 10 | ||||
-rw-r--r-- | tests/src/smtp/inbound/scripts.rs | 2 |
22 files changed, 510 insertions, 52 deletions
@@ -2439,9 +2439,9 @@ dependencies = [ "sqlx", "store", "tokio", - "tokio-tungstenite 0.20.0", + "tokio-tungstenite 0.20.1", "tracing", - "tungstenite 0.20.0", + "tungstenite 0.20.1", "utils", ] @@ -2665,6 +2665,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + +[[package]] name = "linux-raw-sys" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2735,8 +2744,8 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.9.0" -source = "git+https://github.com/stalwartlabs/mail-parser#816d944f2e29e024e93eca8b0980e50ecb54aa4b" +version = "0.9.1" +source = "git+https://github.com/stalwartlabs/mail-parser#b6c080ff45c0de95703a4fadb6a41dab49cd1566" dependencies = [ "encoding_rs", "serde", @@ -2859,10 +2868,11 @@ dependencies = [ [[package]] name = "md-5" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ + "cfg-if", "digest 0.10.7", ] @@ -4357,7 +4367,7 @@ dependencies = [ "lalrpop-util", "lazy_static", "libc", - "md-5 0.10.5", + "md-5 0.10.6", "memsec", "num-bigint-dig", "once_cell", @@ -4549,7 +4559,7 @@ checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" [[package]] name = "sieve-rs" version = "0.3.1" -source = "git+https://github.com/stalwartlabs/sieve#4979238d265027e31f546fa438823207df90317f" +source = "git+https://github.com/stalwartlabs/sieve#886956e94bde837a3a3e60de2bea9cc1b50c077b" dependencies = [ "ahash 0.8.3", "bincode", @@ -4620,6 +4630,7 @@ dependencies = [ "hyper-util", "imagesize", "lazy_static", + "linkify", "lru-cache", "mail-auth", "mail-builder", @@ -4854,7 +4865,7 @@ dependencies = [ "hmac 0.12.1", "itoa", "log", - "md-5 0.10.5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", @@ -4894,7 +4905,7 @@ dependencies = [ "home", "itoa", "log", - "md-5 0.10.5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.5", @@ -5217,9 +5228,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" +checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" dependencies = [ "deranged", "itoa", @@ -5230,15 +5241,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" dependencies = [ "time-core", ] @@ -5355,14 +5366,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2dbec703c26b00d74844519606ef15d09a7d6857860f84ad223dec002ddea2" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.20.0", + "tungstenite 0.20.1", ] [[package]] @@ -5629,9 +5640,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e862a1c4128df0112ab625f55cd5c934bcb4312ba80b39ae4b4835a3fd58e649" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" dependencies = [ "byteorder", "bytes", @@ -6227,9 +6238,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab77e97b50aee93da431f2cee7cd0f43b4d1da3c408042f2d7d164187774f0a" +checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" [[package]] name = "xxhash-rust" diff --git a/crates/imap/src/op/fetch.rs b/crates/imap/src/op/fetch.rs index e29f0132..abb0f4d3 100644 --- a/crates/imap/src/op/fetch.rs +++ b/crates/imap/src/op/fetch.rs @@ -733,9 +733,8 @@ impl<'x> AsImapDataItem<'x> for Message<'x> { extension.body_disposition = part .headers .header_value(&HeaderName::ContentDisposition) + .and_then(|cd| cd.as_content_type()) .map(|cd| { - let cd = cd.content_type(); - ( cd.c_type.as_ref().into(), cd.attributes diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 6db15787..1cb90b8a 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -50,6 +50,7 @@ lazy_static = "1.4" whatlang = "0.16" imagesize = "0.12" phf = "0.11" +linkify = "0.10" [features] test_mode = [] diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index f411b2ec..078de1ac 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -413,7 +413,7 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { // Sieve filtering if let Some(script) = dc.script.eval(self).await { let params = self - .build_script_parameters() + .build_script_parameters("data") .with_message(edited_message.as_ref().unwrap_or(&raw_message).clone()) .set_variable("dmarc.from", auth_message.from().to_string()) .set_variable( diff --git a/crates/smtp/src/inbound/ehlo.rs b/crates/smtp/src/inbound/ehlo.rs index 430a92d8..d8521eaa 100644 --- a/crates/smtp/src/inbound/ehlo.rs +++ b/crates/smtp/src/inbound/ehlo.rs @@ -98,7 +98,7 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { // Sieve filtering if let Some(script) = self.core.session.config.ehlo.script.eval(self).await { if let ScriptResult::Reject(message) = self - .run_script(script.clone(), self.build_script_parameters()) + .run_script(script.clone(), self.build_script_parameters("ehlo")) .await { tracing::debug!(parent: &self.span, diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs index f9e4d8a4..82f78a26 100644 --- a/crates/smtp/src/inbound/mail.rs +++ b/crates/smtp/src/inbound/mail.rs @@ -141,7 +141,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> { // Sieve filtering if let Some(script) = self.core.session.config.mail.script.eval(self).await { match self - .run_script(script.clone(), self.build_script_parameters()) + .run_script(script.clone(), self.build_script_parameters("mail")) .await { ScriptResult::Accept { modifications } => { diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs index bd16b242..b4f5829e 100644 --- a/crates/smtp/src/inbound/rcpt.rs +++ b/crates/smtp/src/inbound/rcpt.rs @@ -92,7 +92,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> { // Sieve filtering if let Some(script) = rcpt_script { match self - .run_script(script.clone(), self.build_script_parameters()) + .run_script(script.clone(), self.build_script_parameters("rcpt")) .await { ScriptResult::Accept { modifications } => { diff --git a/crates/smtp/src/inbound/spawn.rs b/crates/smtp/src/inbound/spawn.rs index a8125acc..84635f7a 100644 --- a/crates/smtp/src/inbound/spawn.rs +++ b/crates/smtp/src/inbound/spawn.rs @@ -118,7 +118,7 @@ impl<T: AsyncRead + AsyncWrite + IsTls + Unpin> Session<T> { // Sieve filtering if let Some(script) = self.core.session.config.connect.script.eval(self).await { if let ScriptResult::Reject(message) = self - .run_script(script.clone(), self.build_script_parameters()) + .run_script(script.clone(), self.build_script_parameters("connect")) .await { tracing::debug!(parent: &self.span, diff --git a/crates/smtp/src/scripts/exec.rs b/crates/smtp/src/scripts/exec.rs index cf1c8eb2..d09b97b3 100644 --- a/crates/smtp/src/scripts/exec.rs +++ b/crates/smtp/src/scripts/exec.rs @@ -38,7 +38,7 @@ use crate::{ use super::{ScriptParameters, ScriptResult}; impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> { - pub fn build_script_parameters(&self) -> ScriptParameters { + pub fn build_script_parameters(&self, stage: &'static str) -> ScriptParameters { let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher(); let mut params = ScriptParameters::new() .set_variable("remote_ip", self.data.remote_ip.to_string()) @@ -70,7 +70,8 @@ impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> { .unwrap_or_default(), ) .set_variable("tls.version", tls_version) - .set_variable("tls.cipher", tls_cipher); + .set_variable("tls.cipher", tls_cipher) + .set_variable("stage", stage); if let Some(ip_rev) = &self.data.iprev { params = params.set_variable("iprev.result", ip_rev.result().as_str()); if let Some(ptr) = ip_rev.ptr.as_ref().and_then(|addrs| addrs.first()) { @@ -87,16 +88,27 @@ impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> { .envelope .push((Envelope::Envid, env_id.to_lowercase().into())); } - if let Some(rcpt) = self.data.rcpt_to.last() { - params - .envelope - .push((Envelope::To, rcpt.address_lcase.to_string().into())); - if let Some(orcpt) = &rcpt.dsn_info { + + if stage != "data" { + if let Some(rcpt) = self.data.rcpt_to.last() { params .envelope - .push((Envelope::Orcpt, orcpt.to_lowercase().into())); + .push((Envelope::To, rcpt.address_lcase.to_string().into())); + if let Some(orcpt) = &rcpt.dsn_info { + params + .envelope + .push((Envelope::Orcpt, orcpt.to_lowercase().into())); + } } + } else { + // Build recipients list + let mut recipients = vec![]; + for rcpt in &self.data.rcpt_to { + recipients.push(Variable::String(rcpt.address_lcase.to_string())); + } + params.envelope.push((Envelope::To, recipients.into())); } + if (mail_from.flags & MAIL_RET_FULL) != 0 { params.envelope.push((Envelope::Ret, "FULL".into())); } else if (mail_from.flags & MAIL_RET_HDRS) != 0 { diff --git a/crates/smtp/src/scripts/functions/mod.rs b/crates/smtp/src/scripts/functions/mod.rs index d7a90636..098182d4 100644 --- a/crates/smtp/src/scripts/functions/mod.rs +++ b/crates/smtp/src/scripts/functions/mod.rs @@ -52,6 +52,7 @@ pub fn register_functions() -> FunctionMap { .with_function("is_lowercase", fn_is_lowercase) .with_function("tokenize_words", fn_tokenize_words) .with_function("tokenize_html", fn_tokenize_html) + .with_function("tokenize_url", fn_tokenize_url) .with_function("max_line_len", fn_max_line_len) .with_function("count_spaces", fn_count_spaces) .with_function("count_uppercase", fn_count_uppercase) diff --git a/crates/smtp/src/scripts/functions/text.rs b/crates/smtp/src/scripts/functions/text.rs index 83bc0943..4513ad2a 100644 --- a/crates/smtp/src/scripts/functions/text.rs +++ b/crates/smtp/src/scripts/functions/text.rs @@ -282,6 +282,26 @@ pub fn fn_split<'x>(_: &'x Context<'x>, v: Vec<Variable<'x>>) -> Variable<'x> { } } +pub fn fn_tokenize_url<'x>(_: &'x Context<'x>, mut v: Vec<Variable<'x>>) -> Variable<'x> { + match v.remove(0) { + Variable::StringRef(text) => linkify::LinkFinder::new() + .url_must_have_scheme(false) + .links(text.as_ref()) + .map(|s| Variable::from(s.as_str())) + .collect::<Vec<_>>() + .into(), + v @ (Variable::String(_) | Variable::Array(_) | Variable::ArrayRef(_)) => { + linkify::LinkFinder::new() + .url_must_have_scheme(false) + .links(v.to_cow().as_ref()) + .map(|s| Variable::from(s.as_str().to_string())) + .collect::<Vec<_>>() + .into() + } + v => v, + } +} + /** * `levenshtein-rs` - levenshtein * diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 6bd8bc1c..413b0f76 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -108,6 +108,7 @@ pub fn enable_tracing(config: &Config, message: &str) -> config::Result<Option<W tracing_subscriber::FmtSubscriber::builder() .with_env_filter(env_filter) .with_writer(non_blocking) + .with_ansi(config.property_or_static("global.tracing.ansi", "true")?) .finish(), ) .failed("Failed to set subscriber"); diff --git a/resources/config/sieve/from.sieve b/resources/config/sieve/from.sieve index 56a53129..928281b9 100644 --- a/resources/config/sieve/from.sieve +++ b/resources/config/sieve/from.sieve @@ -51,14 +51,18 @@ if eval "from_count > 0" { } } + if eval "is_empty(envelope.from) && + (from_local == 'postmaster' || + from_local == 'mailer-daemon' || + from_local == 'root')" { + set "t.HFILTER_FROM_BOUNCE" "1"; + } + if eval "(!is_empty(envelope.from) && eq_ignore_case(from_addr, envelope.from)) || - (is_empty(envelope.from) && + (t.HFILTER_FROM_BOUNCE && !is_empty(from_domain) && - domain_part(from_domain, 'sld') == domain_part(env.helo_domain, 'sld') && - ( from_local == 'postmaster' || - from_local == 'mailer-daemon' || - from_local == 'root'))" { + domain_part(from_domain, 'sld') == domain_part(env.helo_domain, 'sld'))" { set "t.FROM_EQ_ENVFROM" "1"; } elsif eval "!t.FROM_INVALID" { set "t.FORGED_SENDER" "1"; @@ -69,11 +73,11 @@ if eval "from_count > 0" { set "t.TAGGED_FROM" "1"; } - set "to" "%{header.to[*].addr[*]}"; + set "to" "%{header.to:cc[*].addr[*]}"; if eval "count(to) == 1" { - if eval "eq_ignore_case(to, from_addr)" { + if eval "eq_ignore_case(to[0], from_addr)" { set "t.TO_EQ_FROM" "1"; - } elsif eval "eq_ignore_case(email_part(to, 'domain'), from_domain)" { + } elsif eval "eq_ignore_case(email_part(to[0], 'domain'), from_domain)" { set "t.TO_DOM_EQ_FROM_DOM" "1"; } } diff --git a/resources/config/sieve/messageid.sieve b/resources/config/sieve/messageid.sieve index 7549d53f..ec0e124e 100644 --- a/resources/config/sieve/messageid.sieve +++ b/resources/config/sieve/messageid.sieve @@ -42,7 +42,7 @@ if eval "!is_empty(mid_raw)" { } # To/Cc addresses present in Message-ID checks - set "recipients" "%{winnow(header.to:cc[*].addr[*])}"; + set "recipients" "%{winnow(header.to:cc:bcc[*].addr[*])}"; set "recipients_len" "%{count(recipients)}"; set "i" "0"; diff --git a/resources/config/sieve/received.sieve b/resources/config/sieve/received.sieve index 64f10400..d9521c90 100644 --- a/resources/config/sieve/received.sieve +++ b/resources/config/sieve/received.sieve @@ -44,7 +44,7 @@ if eval "!is_empty(env.iprev.ptr) && !eq_ignore_case(env.helo_domain, env.iprev. } set "i" "0"; -set "recipients" "%{header.to[*].addr[*]}"; +set "recipients" "%{header.to:cc:bcc[*].addr[*]}"; set "tls_count" "0"; while "i < rcvd_count" { set "i" "%{i + 1}"; diff --git a/resources/config/sieve/recipient.sieve b/resources/config/sieve/recipient.sieve new file mode 100644 index 00000000..15ad1ba8 --- /dev/null +++ b/resources/config/sieve/recipient.sieve @@ -0,0 +1,131 @@ + +set "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 + set "t.TO_EXCESS_QP" "1"; + } elsif eval "contains(to_raw, '?b?')" { + # To header is unnecessarily encoded in base64 + set "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 + set "t.TO_NEEDS_ENCODING" "1"; + } +} else { + set "t.MISSING_TO" "1"; +} + +set "rcpt_addr" "%{to_lowercase(header.to:cc:bcc[*].addr[*])}"; +set "rcpt_count" "%{count(winnow(rcpt_addr))}"; + +if eval "rcpt_count > 0" { + if eval "rcpt_count == 1" { + set "t.RCPT_COUNT_ONE" "1"; + } elsif eval "rcpt_count == 2" { + set "t.RCPT_COUNT_TWO" "1"; + } elsif eval "rcpt_count == 3" { + set "t.RCPT_COUNT_THREE" "1"; + } elsif eval "rcpt_count <= 5" { + set "t.RCPT_COUNT_FIVE" "1"; + } elsif eval "rcpt_count <= 7" { + set "t.RCPT_COUNT_SEVEN" "1"; + } elsif eval "rcpt_count <= 12" { + set "t.RCPT_COUNT_TWELVE" "1"; + } else { + set "t.RCPT_COUNT_GT_50" "1"; + } + + set "rcpt_name" "%{to_lowercase(header.to:cc:bcc[*].name[*])}"; + set "i" "%{count(rcpt_addr)}"; + set "to_dn_count" "0"; + set "to_dn_eq_addr_count" "0"; + set "to_match_envrcpt" "0"; + set "subject" "%{to_lowercase(thread_name(header.subject))}"; + + while "i != 0" { + set "i" "%{i - 1}"; + set "addr" "%{rcpt_addr[i]}"; + + if eval "!is_empty(addr)" { + set "name" "%{rcpt_name[i]}"; + + if eval "!is_empty(name)" { + if eval "name == addr" { + set "to_dn_eq_addr_count" "%{to_dn_eq_addr_count + 1}"; + } else { + set "to_dn_count" "%{to_dn_count + 1}"; + if eval "name == 'recipient' || name == 'recipients'" { + set "t.TO_DN_RECIPIENTS" "1"; + } + } + } + + if eval "contains(envelope.to, addr)" { + set "to_match_envrcpt" "%{to_match_envrcpt + 1}"; + } + + # Check if the local part is present in the subject + set "local_part" "%{email_part(addr, 'local')}"; + if eval "contains(subject, addr)" { + set "t.RCPT_ADDR_IN_SUBJECT" "1"; + } elsif eval "len(local_part) > 3 && contains(subject, local_part)" { + set "t.RCPT_LOCAL_IN_SUBJECT" "1"; + } + + if eval "contains(local_part, '+')" { + set "t.TAGGED_RCPT" "1"; + } + } + } + + if eval "to_dn_count == 0 && to_dn_eq_addr_count == 0" { + set "t.TO_DN_NONE" "1"; + } elsif eval "to_dn_count == rcpt_count" { + set "t.TO_DN_ALL" "1"; + } elsif eval "to_dn_count > 0" { + set "t.TO_DN_SOME" "1"; + } + + if eval "to_dn_eq_addr_count == rcpt_count" { + set "t.TO_DN_EQ_ADDR_ALL" "1"; + } elsif eval "to_dn_eq_addr_count > 0" { + set "t.TO_DN_EQ_ADDR_SOME" "1"; + } + + if eval "to_match_envrcpt == rcpt_count" { + set "t.TO_MATCH_ENVRCPT_ALL" "1"; + } else { + if eval "to_match_envrcpt > 0" { + set "t.TO_MATCH_ENVRCPT_SOME" "1"; + } + + if eval "is_empty(header.List-Unsubscribe:List-Id[*])" { + set "i" "%{count(envelope.to)}"; + while "i != 0" { + set "i" "%{i - 1}"; + set "env_rcpt" "%{envelope.to[i]}"; + + if eval "!contains(rcpt_addr, env_rcpt) && env_rcpt != envelope.from" { + set "t.FORGED_RECIPIENTS" "1"; + break; + } + } + } + } + + # Message from bounce and over 1 recipient + if eval "rcpt_count > 1 && + (is_empty(envelope.from) || + starts_with(envelope.from, 'postmaster@') || + starts_with(envelope.from, 'mailer-daemon@'))" { + set "t.HFILTER_RCPT_BOUNCEMOREONE" "1"; + } +} else { + set "t.RCPT_COUNT_ZERO" "1"; + + if eval "contains(to_raw, 'undisclosed') && contains(to_raw, 'recipients')" { + set "t.R_UNDISC_RCPT" "1"; + } +} diff --git a/resources/config/sieve/replyto.sieve b/resources/config/sieve/replyto.sieve new file mode 100644 index 00000000..9e2b46ba --- /dev/null +++ b/resources/config/sieve/replyto.sieve @@ -0,0 +1,70 @@ + +set "rto_raw" "%{to_lowercase(header.reply-to.raw)}"; +if eval "!is_empty(rto_raw)" { + set "rto_addr" "%{to_lowercase(header.reply-to.addr)}"; + set "rto_name" "%{to_lowercase(header.reply-to.name)}"; + + if eval "is_email(rto_addr)" { + set "t.HAS_REPLYTO" "1"; + + if eval "eq_ignore_case(header.reply-to, header.from)" { + set "t.REPLYTO_EQ_FROM" "1"; + } else { + set "from_addr" "%{to_lowercase(header.from.addr)}"; + set "from_domain" "%{domain_part(email_part(from_addr, 'domain'), 'sld')}"; + + if eval "domain_part(email_part(rto_addr, 'domain'), 'sld') == from_domain" { + set "t.REPLYTO_DOM_EQ_FROM_DOM" "1"; + } else { + set "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(header.to:cc:bcc[*].addr[*], rto_addr)" { + set "t.REPLYTO_EQ_TO_ADDR" "1"; + } else { + set "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)" { + set "i" "%{count(envelope.to)}"; + set "found_domain" "0"; + + while "i != 0" { + set "i" "%{i - 1}"; + + if eval "domain_part(email_part(envelope.to[i], 'domain'), 'sld') == from_domain" { + set "found_domain" "1"; + break; + } + } + + if eval "!found_domain" { + set "t.SPOOF_REPLYTO" "1"; + } + } + } + + if eval "!is_empty(rto_name) && eq_ignore_case(rto_name, header.from.name)" { + set "t.REPLYTO_DN_EQ_FROM_DN" "1"; + } + } + + } else { + set "t.REPLYTO_UNPARSEABLE" "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 + set "t.REPLYTO_EXCESS_QP" "1"; + } elsif eval "contains(rto_raw, '?b?')" { + # Reply-To header is unnecessarily encoded in base64 + set "t.REPLYTO_EXCESS_BASE64" "1"; + } + } + + if eval "contains(rto_name, 'mr. ') || contains(rto_name, 'ms. ') || contains(rto_name, 'mrs. ') || contains(rto_name, 'dr. ')" { + set "t.REPLYTO_EMAIL_HAS_TITLE" "1"; + } +} + diff --git a/tests/resources/smtp/antispam/from.test b/tests/resources/smtp/antispam/from.test index 70ee3ac7..e4114e36 100644 --- a/tests/resources/smtp/antispam/from.test +++ b/tests/resources/smtp/antispam/from.test @@ -48,14 +48,14 @@ From: "hello@other.world.co.uk" <hello@world.co.uk> Test <!-- NEXT TEST --> helo_domain mx.world.co.uk -expect FROM_EQ_ENVFROM FROM_NEQ_DISPLAY_NAME FROM_HAS_DN +expect FROM_EQ_ENVFROM FROM_NEQ_DISPLAY_NAME FROM_HAS_DN HFILTER_FROM_BOUNCE From: "postmaster@mx.world.co.uk" <postmaster@world.co.uk> Test <!-- NEXT TEST --> helo_domain mx.world.co.uk -expect FROM_EQ_ENVFROM FROM_HAS_DN +expect FROM_EQ_ENVFROM FROM_HAS_DN HFILTER_FROM_BOUNCE From: "Mailer Daemon" <MAILER-DAEMON@world.co.uk> diff --git a/tests/resources/smtp/antispam/recipient.test b/tests/resources/smtp/antispam/recipient.test new file mode 100644 index 00000000..0843614d --- /dev/null +++ b/tests/resources/smtp/antispam/recipient.test @@ -0,0 +1,109 @@ +expect MISSING_TO RCPT_COUNT_ZERO + +X-To: hello@world.com + +Test +<!-- NEXT TEST --> +expect RCPT_COUNT_ONE TO_DN_ALL + +To: "Hello World" <hello@world.com> + +Test +<!-- NEXT TEST --> +expect RCPT_COUNT_ONE TO_DN_NONE TAGGED_RCPT + +To: hello+there@world.com + +Test +<!-- NEXT TEST --> +envelope_from user@domain.org +expect TO_DN_RECIPIENTS RCPT_COUNT_TWO TO_DN_SOME + +To: "recipients" <user@domain.org> +Cc: other@user.org + +Test +<!-- NEXT TEST --> +expect RCPT_ADDR_IN_SUBJECT TO_DN_NONE RCPT_COUNT_ONE + +To: hello@world.com +Subject: Special offer for HELLO@world.com + +Test +<!-- NEXT TEST --> +expect RCPT_LOCAL_IN_SUBJECT TO_DN_NONE RCPT_COUNT_ONE + +To: hello@world.com +Subject: Special offer for hello + +Test +<!-- NEXT TEST --> +envelope_from +envelope_to hello@world.com +envelope_to goodbye@world.com +expect HFILTER_RCPT_BOUNCEMOREONE TO_MATCH_ENVRCPT_ALL TO_DN_NONE RCPT_COUNT_TWO + +To: hello@world.com +Cc: goodbye@world.com + +Test +<!-- NEXT TEST --> +envelope_from postmaster@domain.org +envelope_to hello@world.com +envelope_to goodbye@world.com +expect HFILTER_RCPT_BOUNCEMOREONE TO_MATCH_ENVRCPT_SOME TO_DN_NONE RCPT_COUNT_THREE + +To: hello@world.com, test@domain.com +Cc: goodbye@world.com + +Test +<!-- NEXT TEST --> +expect RCPT_COUNT_ZERO R_UNDISC_RCPT + +To: Undisclosed recipients:; + +Test +<!-- NEXT TEST --> +envelope_from list@domain.org +envelope_to hello@world.com +expect TO_DN_ALL RCPT_COUNT_ONE + +List-Id: <list.domain.org> +To: "Mailing List" <list@domain.org> + +Test +<!-- NEXT TEST --> +envelope_from spammer@domain.org +envelope_to hello@world.com +expect FORGED_RECIPIENTS TO_NEEDS_ENCODING TO_DN_ALL RCPT_COUNT_ONE + +To: "Thé Spámmer" <spammer@domain.org> + +Test +<!-- NEXT TEST --> +envelope_from user@domain.org +envelope_to hello@world.com +envelope_to user@domain.org +expect TO_DN_ALL TO_MATCH_ENVRCPT_ALL RCPT_COUNT_ONE + +To: "Hello World" <hello@world.com> + +Test +<!-- NEXT TEST --> +envelope_from user@domain.org +envelope_to hello@world.com +envelope_to user@domain.org +expect TO_EXCESS_QP TO_DN_ALL TO_MATCH_ENVRCPT_ALL RCPT_COUNT_ONE + +To: "=?iso-8859-1?Q?Die_Hasen_und_die_Froesche?=" <hello@world.com> + +Test +<!-- NEXT TEST --> +envelope_from user@domain.org +envelope_to hello@world.com +envelope_to user@domain.org +expect TO_EXCESS_BASE64 TO_DN_ALL TO_MATCH_ENVRCPT_ALL RCPT_COUNT_ONE + +To: "=?iso-8859-1?B?RGllIEhhc2VuIHVuIGRpZSBGcm9lc2NoZQ==?=" <hello@world.com> + +Test diff --git a/tests/resources/smtp/antispam/replyto.test b/tests/resources/smtp/antispam/replyto.test new file mode 100644 index 00000000..5aa55e77 --- /dev/null +++ b/tests/resources/smtp/antispam/replyto.test @@ -0,0 +1,91 @@ +expect REPLYTO_UNPARSEABLE + +Reply-to: hello + +Test +<!-- NEXT TEST --> +expect REPLYTO_EQ_FROM HAS_REPLYTO + +From: hello@domain.org +Reply-to: hello@domain.org + +Test +<!-- NEXT TEST --> +expect REPLYTO_DOM_EQ_FROM_DOM REPLYTO_DN_EQ_FROM_DN HAS_REPLYTO + +From: "Hello" <hello@host.domain.org.uk> +Reply-to: "Hello" <hello@domain.org.uk> + +Test +<!-- NEXT TEST --> +envelope_from hello@otherdomain.org.uk +envelope_to user@somedomain.com +expect REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: hello@otherdomain.org.uk +To: user@somedomain.com, hello@otherdomain.org.uk +Reply-to: hello@domain.org.uk + +Test +<!-- NEXT TEST --> +envelope_from sender@foo.org +envelope_to user@somedomain.com +expect REPLYTO_EQ_TO_ADDR SPOOF_REPLYTO HAS_REPLYTO + +From: sender@foo.org +To: user@somedomain.com +Reply-to: user@somedomain.com + +Test +<!-- NEXT TEST --> +envelope_from list@foo.org +envelope_to user@somedomain.com +expect REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: list@foo.org +List-Unsubscribe: unsubcribe@foo.org +To: user@somedomain.com +Reply-to: user@somedomain.com + +Test +<!-- NEXT TEST --> +envelope_from user@foo.org +envelope_to other@foo.org +expect REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: user@foo.org +To: otheruser@foo.org +Reply-to: user@otherdomain.org + +Test +<!-- NEXT TEST --> +envelope_from user@foo.org +envelope_to otheruser@domain.org +expect SPOOF_REPLYTO REPLYTO_DOM_NEQ_FROM_DOM HAS_REPLYTO + +From: user@foo.org +To: otheruser@domain.org +Reply-to: user@otherdomain.org + +Test +<!-- NEXT TEST --> +expect REPLYTO_EXCESS_QP REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@domain.org +Reply-to: =?iso-8859-1?Q?Die_Hasen_und_die_Froesche?= <hello@domain.org> + +Test +<!-- NEXT TEST --> +expect REPLYTO_EXCESS_BASE64 REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@domain.org +Reply-to: "=?iso-8859-1?B?RGllIEhhc2VuIHVuIGRpZSBGcm9lc2NoZQ==?=" <hello@domain.org> + +Test +<!-- NEXT TEST --> +expect REPLYTO_EMAIL_HAS_TITLE REPLYTO_DOM_EQ_FROM_DOM HAS_REPLYTO + +From: hello@domain.org +Reply-to: "Mr. Hello" <hello@domain.org> + +Test diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index 6f749931..e6104d4e 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -64,6 +64,8 @@ async fn antispam() { "messageid", "date", "from", + "replyto", + "recipient", ]; let mut core = SMTP::test(); let qr = core.init_test_queue("smtp_antispam_test"); @@ -154,6 +156,12 @@ async fn antispam() { "envelope_from" => { session.data.mail_from = Some(SessionAddress::new(value.to_string())); } + "envelope_to" => { + session + .data + .rcpt_to + .push(SessionAddress::new(value.to_string())); + } "iprev.ptr" | "dmarc.from" => { variables.insert(param.to_string(), value.to_string()); } @@ -210,7 +218,7 @@ async fn antispam() { expected.sort_unstable_by(|a, b| b.cmp(a)); println!("Testing tags {:?}", expected); let mut params = session - .build_script_parameters() + .build_script_parameters("data") .with_expected_variables(expected_variables) .with_message(Arc::new(message.into_bytes())); for (name, value) in variables { diff --git a/tests/src/smtp/inbound/scripts.rs b/tests/src/smtp/inbound/scripts.rs index b4426083..fbd15fbf 100644 --- a/tests/src/smtp/inbound/scripts.rs +++ b/tests/src/smtp/inbound/scripts.rs @@ -158,7 +158,7 @@ async fn sieve_scripts() { } let script = script.clone(); let params = session - .build_script_parameters() + .build_script_parameters("data") .set_variable("from", "john.doe@example.org"); let handle = Handle::current(); let span = span.clone(); |