summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormdecimus <mauro@stalw.art>2023-09-24 19:06:35 +0200
committermdecimus <mauro@stalw.art>2023-09-24 19:06:35 +0200
commitf069691844bcec5d62b962f6338d9a8c6b012781 (patch)
tree91fa4e626a05d6654ebbd067b17e1fbfac04c0ea
parent4690a9d625aec8ef886e1ff1ab7c6e580c30b70a (diff)
Antispam from, recipient and reply-to modules
-rw-r--r--Cargo.lock57
-rw-r--r--crates/imap/src/op/fetch.rs3
-rw-r--r--crates/smtp/Cargo.toml1
-rw-r--r--crates/smtp/src/inbound/data.rs2
-rw-r--r--crates/smtp/src/inbound/ehlo.rs2
-rw-r--r--crates/smtp/src/inbound/mail.rs2
-rw-r--r--crates/smtp/src/inbound/rcpt.rs2
-rw-r--r--crates/smtp/src/inbound/spawn.rs2
-rw-r--r--crates/smtp/src/scripts/exec.rs28
-rw-r--r--crates/smtp/src/scripts/functions/mod.rs1
-rw-r--r--crates/smtp/src/scripts/functions/text.rs20
-rw-r--r--crates/utils/src/lib.rs1
-rw-r--r--resources/config/sieve/from.sieve20
-rw-r--r--resources/config/sieve/messageid.sieve2
-rw-r--r--resources/config/sieve/received.sieve2
-rw-r--r--resources/config/sieve/recipient.sieve131
-rw-r--r--resources/config/sieve/replyto.sieve70
-rw-r--r--tests/resources/smtp/antispam/from.test4
-rw-r--r--tests/resources/smtp/antispam/recipient.test109
-rw-r--r--tests/resources/smtp/antispam/replyto.test91
-rw-r--r--tests/src/smtp/inbound/antispam.rs10
-rw-r--r--tests/src/smtp/inbound/scripts.rs2
22 files changed, 510 insertions, 52 deletions
diff --git a/Cargo.lock b/Cargo.lock
index af00024d..7d4e667d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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();