diff --git a/CHANGELOG.md b/CHANGELOG.md index 244bfc4e..d3556d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - Do not panic when `hash = 0` in reports. (#60) - JMAP Session resource returns `EmailSubmission` capabilities using arrays rather than objects. +- ManageSieve `PUTSCRIPT` should replace existing scripts. ## [0.3.5] - 2023-08-18 diff --git a/Cargo.lock b/Cargo.lock index 8a687831..d6315d7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" +checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783" dependencies = [ "memchr", ] @@ -641,9 +641,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.6.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05" +checksum = "4c2f7349907b712260e64b0afe2f84692af14a454be26187d9df565c7f69266a" dependencies = [ "memchr", "serde", @@ -777,9 +777,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.26" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +checksum = "f56b4c72906975ca04becb8a30e102dfecddd0c06181e3e95ddc444be28881f8" dependencies = [ "android-tzdata", "iana-time-zone", @@ -788,7 +788,7 @@ dependencies = [ "serde", "time 0.1.45", "wasm-bindgen", - "winapi", + "windows-targets 0.48.5", ] [[package]] @@ -1147,9 +1147,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "5.5.1" +version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd72493923899c6f10c641bdbdeddc7183d6396641d99c1a0d1597f37f92e28" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", "hashbrown 0.14.0", @@ -2868,9 +2868,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.6.0" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76fc44e2588d5b436dbc3c6cf62aef290f90dab6235744a93dfe1cc18f451e2c" +checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" [[package]] name = "memoffset" @@ -4553,7 +4553,7 @@ checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" [[package]] name = "sieve-rs" version = "0.3.1" -source = "git+https://github.com/stalwartlabs/sieve#cea1451c13006016cff96aacfb88290318576e96" +source = "git+https://github.com/stalwartlabs/sieve#51558ab5dec44907ab7be65c492b519c327ff0f5" dependencies = [ "ahash 0.8.3", "bincode", diff --git a/crates/antispam/src/import/spamassassin.rs b/crates/antispam/src/import/spamassassin.rs index 5cf305d5..09caa925 100644 --- a/crates/antispam/src/import/spamassassin.rs +++ b/crates/antispam/src/import/spamassassin.rs @@ -718,6 +718,7 @@ pub fn import_spamassassin(path: PathBuf, extension: String, do_warn: bool) { } } "tflags" => { + let TODO = "implement flags"; let mut params = params.split_ascii_whitespace(); if let Some(test_name) = params.next() { let test_flags = &mut rules.entry(test_name.to_string()).or_default().flags; @@ -1091,7 +1092,7 @@ pub fn import_spamassassin(path: PathBuf, extension: String, do_warn: bool) { let mut script = String::from(concat!( "require [\"variables\", \"include\", \"regex\", \"body\", \"vnd.stalwart.plugins\"];\n\n", "set \"score\" \"0.0\";\n", - "set \"spam_score\" \"0.0\";\n", + "set \"spam_score\" \"5.0\";\n", "set \"awl_factor\" \"0.5\";\n", "\n" )); @@ -1284,16 +1285,160 @@ impl Display for Rule { RuleType::Eval { function, params } => match function.as_str() { "check_from_in_auto_welcomelist" | "check_from_in_auto_whitelist" => { f.write_str(concat!( - "query :set [\"awl_score\", \"awl_count\"] \"SELECT score, count FROM awl WHERE from = ? AND ip = ?\" [\"${envelope.from}\", \"%{env.remote_ip}\"];\n", + "query :use \"spam\" :set [\"awl_score\", \"awl_count\"] \"SELECT score, count FROM awl WHERE sender = ? AND ip = ?\" [\"${env.from}\", \"%{env.remote_ip}\"];\n", "if eval \"awl_count > 0\" {\n", - "\tquery \"UPDATE awl SET score += ?, count += 1 WHERE from = ? AND ip = ?\" [\"%{score}\", \"${envelope.from}\", \"%{env.remote_ip}\"];\n", + "\tquery :use \"spam\" \"UPDATE awl SET score = score + ?, count = count + 1 WHERE sender = ? AND ip = ?\" [\"%{score}\", \"${env.from}\", \"%{env.remote_ip}\"];\n", "\tset \"score\" \"%{score + ((awl_score / awl_count) - score) * awl_factor}\";\n", "} else {\n", - "\tquery \"INSERT OR IGNORE INTO (score, count, from, ip) (?, 1, ?, ?)\" [\"%{score}\", \"${envelope.from}\", \"%{env.remote_ip}\"];\n", + "\tquery :use \"spam\" \"INSERT INTO awl (score, count, sender, ip) VALUES (?, 1, ?, ?)\" [\"%{score}\", \"${env.from}\", \"%{env.remote_ip}\"];\n", "}\n\n", ))?; return Ok(()); } + "check_from_in_blacklist" + | "check_from_in_blocklist" + | "check_from_in_default_welcomelist" + | "check_from_in_default_whitelist" + | "check_from_in_welcomelist" + | "check_from_in_whitelist" + | "check_to_in_blacklist" + | "check_to_in_blocklist" + | "check_to_in_welcomelist" + | "check_to_in_whitelist" + | "check_subject_in_blacklist" + | "check_subject_in_blocklist" + | "check_subject_in_welcomelist" + | "check_subject_in_whitelist" + | "check_to_in_more_spam" + | "check_to_in_all_spam" => { + let mut parts = function.split('_').peekable(); + parts.next(); + let header = parts.next().unwrap(); + parts.next(); + let mut list = String::new(); + + for part in parts { + if !list.is_empty() { + list.push('_'); + } + list.push_str(match part { + "welcomelist" | "whitelist" => "welcome", + "blacklist" | "blocklist" => "block", + "more" | "all" | "spam" => part, + "default" => "def", + _ => unreachable!(), + }) + } + + let fnc = if header == "subject" { + "header" + } else { + "address" + }; + write!(f, "if {fnc} :list \"{header}\" \"sa/list_{list}_{header}\"")?; + } + "check_from_in_list" | "check_replyto_in_list" => { + let mut header = function.split('_').nth(1).unwrap(); + if header == "replyto" { + header = "reply-to"; + } + #[allow(clippy::print_in_format_impl)] + if let Some(list) = params.first() { + write!( + f, + "if address :list \"{header}\" \"sa/list_{}\"", + list.to_lowercase() + )?; + } else { + eprintln!("Warning: Found invalid 'check_{header}_in_list' command without parameters."); + write!(f, "if false")?; + } + } + "check_for_spf_helo_fail" + | "check_for_spf_helo_neutral" + | "check_for_spf_helo_none" + | "check_for_spf_helo_pass" + | "check_for_spf_helo_permerror" + | "check_for_spf_helo_softfail" + | "check_for_spf_helo_temperror" + | "check_for_spf_neutral" + | "check_for_spf_none" + | "check_for_spf_fail" + | "check_for_spf_pass" + | "check_for_spf_permerror" + | "check_for_spf_softfail" + | "check_for_spf_temperror" => { + let mut parts = function.split('_').rev(); + let result = parts.next().unwrap(); + let spf = if parts.next().unwrap() == "helo" { + "spf_ehlo" + } else { + "spf" + }; + write!(f, "if string :is \"${{env.{spf}_result}}\" \"{result}\"")?; + } + "check_arc_signed" => { + f.write_str("if string :value \"ne\" \"${env.arc_result}\" \"none\"")?; + } + "check_arc_valid" => { + f.write_str("if string :is \"${env.arc_result}\" \"pass\"")?; + } + "check_dmarc_missing" => { + f.write_str("if string :is \"${env.dmarc_policy}\" \"none\"")?; + } + "check_dmarc_pass" => { + f.write_str("if string :is \"${env.dmarc_result}\" \"pass\"")?; + } + "check_dmarc_none" | "check_dmarc_quarantine" | "check_dmarc_reject" => { + let policy = function.split('_').nth(2).unwrap(); + write!(f, "if allof(string :is \"${{env.dmarc_result}}\" \"fail\", string :is \"${{env.dmarc_policy}}\" \"{policy}\")")?; + } + "check_dkim_adsp" + | "check_dkim_signall" + | "check_dkim_signsome" + | "check_dkim_valid_author_sig" => { + // ADSP is deprecated (see https://datatracker.ietf.org/doc/status-change-adsp-rfc5617-to-historic/) + f.write_str("if false")?; + } + "check_dkim_dependable" => { + writeln!(f, "set :local \"{}\" \"1\";", self.name)?; + return Ok(()); + } + "check_dkim_signed" => { + f.write_str("if string :value \"ne\" \"${env.dkim_result}\" \"none\"")?; + } + "check_dkim_testing" => { + f.write_str("if header :contains \"DKIM-Signature\" \"t=y\"")?; + } + "check_dkim_valid" => { + f.write_str("if string :is \"${env.dkim_result}\" \"pass\"")?; + } + "check_dkim_valid_envelopefrom" => { + f.write_str("if allof(string :is \"${env.dkim_result}\" \"pass\", string :is \"${envelope.from}\" \"${env.from}\")")?; + } + "check_for_def_dkim_welcomelist_from" + | "check_for_def_dkim_whitelist_from" + | "check_for_dkim_welcomelist_from" + | "check_for_dkim_whitelist_from" + | "check_for_def_spf_welcomelist_from" + | "check_for_def_spf_whitelist_from" + | "check_for_spf_welcomelist_from" + | "check_for_spf_whitelist_from" => { + let list = match (function.contains("dkim"), function.contains("def")) { + (true, true) => "def_dkim", + (true, false) => "dkim", + (false, true) => "def_spf", + (false, false) => "spf", + }; + write!(f, "if address :list \"from\" \"sa/list_{list}\"")?; + } + "check_for_missing_to_header" => { + write!(f, "if not exists \"to\"")?; + } + "check_for_to_in_subject" => { + f.write_str("if address :list \"to\" \"${header.subject}\"")?; + } + _ => { write!(f, "if {function}")?; for param in params { diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs index 778adfd9..6f5e5ee3 100644 --- a/crates/imap-proto/src/protocol/mod.rs +++ b/crates/imap-proto/src/protocol/mod.rs @@ -197,7 +197,7 @@ pub fn literal_string(buf: &mut Vec, text: &str) { pub fn quoted_timestamp(buf: &mut Vec, timestamp: i64) { buf.push(b'"'); buf.extend_from_slice( - DateTime::::from_utc( + DateTime::::from_naive_utc_and_offset( NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap_or_default(), Utc, ) diff --git a/crates/smtp/src/config/session.rs b/crates/smtp/src/config/session.rs index 849c8c54..4e62b470 100644 --- a/crates/smtp/src/config/session.rs +++ b/crates/smtp/src/config/session.rs @@ -339,7 +339,7 @@ impl ConfigSession for Config { .unwrap_or_default() .map_if_block(&ctx.scripts, "session.rcpt.script", "script")?, relay: self - .parse_if_block("session.rcpt.relay", ctx, &available_keys)? + .parse_if_block("session.rcpt.relay", ctx, &available_keys_full)? .unwrap_or_else(|| IfBlock::new(false)), directory: self .parse_if_block::>>( diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index 6a6e6db0..3572d474 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -227,7 +227,6 @@ pub struct SessionParameters { pub auth_errors_wait: Duration, // Rcpt parameters - pub rcpt_relay: bool, pub rcpt_errors_max: usize, pub rcpt_errors_wait: Duration, pub rcpt_max: usize, @@ -427,7 +426,6 @@ impl Session { auth_require: Default::default(), auth_errors_max: Default::default(), auth_errors_wait: Default::default(), - rcpt_relay: Default::default(), rcpt_errors_max: Default::default(), rcpt_errors_wait: Default::default(), rcpt_max: Default::default(), diff --git a/crates/smtp/src/core/params.rs b/crates/smtp/src/core/params.rs index 79df57bb..502beaa4 100644 --- a/crates/smtp/src/core/params.rs +++ b/crates/smtp/src/core/params.rs @@ -64,7 +64,6 @@ impl Session { pub async fn eval_rcpt_params(&mut self) { let rc = &self.core.session.config.rcpt; - self.params.rcpt_relay = *rc.relay.eval(self).await; self.params.rcpt_errors_max = *rc.errors_max.eval(self).await; self.params.rcpt_errors_wait = *rc.errors_wait.eval(self).await; self.params.rcpt_max = *rc.max_recipients.eval(self).await; diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 26360c0d..5bcad207 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -205,7 +205,7 @@ impl Session { } // Verify DMARC - let dmarc_result = match &self.data.spf_mail_from { + let (dmarc_result, dmarc_policy) = match &self.data.spf_mail_from { Some(spf_output) if dmarc.verify() => { let dmarc_output = self .core @@ -244,6 +244,7 @@ impl Session { } else { DmarcResult::None }; + let dmarc_policy = dmarc_output.policy(); if !rejected { tracing::debug!(parent: &self.span, @@ -284,9 +285,9 @@ impl Session { }; } - dmarc_result.into() + (dmarc_result.into(), dmarc_policy.into()) } - _ => None, + _ => (None, None), }; // Analyze reports @@ -416,14 +417,14 @@ impl Session { .with_message(edited_message.as_ref().unwrap_or(&raw_message).clone()) .set_variable("from", auth_message.from().to_string()) .set_variable( - "arc", + "arc_result", arc_output .as_ref() .map(|a| a.result().as_str()) .unwrap_or_default(), ) .set_variable( - "dkim", + "dkim_result", dkim_output .iter() .find(|r| matches!(r.result(), DkimResult::Pass)) @@ -432,11 +433,18 @@ impl Session { .unwrap_or_default(), ) .set_variable( - "dmarc", + "dmarc_result", dmarc_result .as_ref() .map(|a| a.as_str()) .unwrap_or_default(), + ) + .set_variable( + "dmarc_policy", + dmarc_policy + .as_ref() + .map(|a| a.as_str()) + .unwrap_or_default(), ); match self.run_script(script.clone(), params).await { diff --git a/crates/smtp/src/inbound/mod.rs b/crates/smtp/src/inbound/mod.rs index 25ca57be..61cc99a0 100644 --- a/crates/smtp/src/inbound/mod.rs +++ b/crates/smtp/src/inbound/mod.rs @@ -22,8 +22,8 @@ */ use mail_auth::{ - arc::ArcSet, dkim::Signature, ArcOutput, AuthenticatedMessage, AuthenticationResults, - DkimResult, DmarcResult, IprevResult, SpfResult, + arc::ArcSet, dkim::Signature, dmarc::Policy, ArcOutput, AuthenticatedMessage, + AuthenticationResults, DkimResult, DmarcResult, IprevResult, SpfResult, }; use tokio::net::TcpStream; use tokio_rustls::server::TlsStream; @@ -187,3 +187,13 @@ impl AuthResult for DmarcResult { } } } + +impl AuthResult for Policy { + fn as_str(&self) -> &'static str { + match self { + Policy::Reject => "reject", + Policy::Quarantine => "quarantine", + Policy::None | Policy::Unspecified => "none", + } + } +} diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs index ebaa4649..cbac2aa7 100644 --- a/crates/smtp/src/inbound/rcpt.rs +++ b/crates/smtp/src/inbound/rcpt.rs @@ -183,7 +183,7 @@ impl Session { .write(b"451 4.4.3 Unable to verify address at this time.\r\n") .await; } - } else if !self.params.rcpt_relay { + } else if !*self.core.session.config.rcpt.relay.eval(self).await { tracing::debug!(parent: &self.span, context = "rcpt", event = "error", @@ -205,7 +205,7 @@ impl Session { .write(b"451 4.4.3 Unable to verify address at this time.\r\n") .await; } - } else if !self.params.rcpt_relay { + } else if !*self.core.session.config.rcpt.relay.eval(self).await { tracing::debug!(parent: &self.span, context = "rcpt", event = "error", diff --git a/crates/smtp/src/scripts/event_loop.rs b/crates/smtp/src/scripts/event_loop.rs index 5c77ce99..20f7cc5b 100644 --- a/crates/smtp/src/scripts/event_loop.rs +++ b/crates/smtp/src/scripts/event_loop.rs @@ -125,6 +125,7 @@ impl SMTP { handle: &handle, core: self, data: &mut plugin_data, + message: instance.message(), arguments, }, ); diff --git a/crates/smtp/src/scripts/exec.rs b/crates/smtp/src/scripts/exec.rs index a8e32cb3..19c6edab 100644 --- a/crates/smtp/src/scripts/exec.rs +++ b/crates/smtp/src/scripts/exec.rs @@ -41,7 +41,7 @@ impl Session { .set_variable("helo_domain", self.data.helo_domain.to_string()) .set_variable("authenticated_as", self.data.authenticated_as.clone()) .set_variable( - "spf", + "spf_result", self.data .spf_mail_from .as_ref() @@ -49,7 +49,7 @@ impl Session { .unwrap_or_default(), ) .set_variable( - "spf_ehlo", + "spf_ehlo_result", self.data .spf_ehlo .as_ref() @@ -57,7 +57,7 @@ impl Session { .unwrap_or_default(), ) .set_variable( - "iprev", + "iprev_result", self.data .iprev .as_ref() diff --git a/crates/smtp/src/scripts/plugins/mod.rs b/crates/smtp/src/scripts/plugins/mod.rs index 966f40b9..afafb49c 100644 --- a/crates/smtp/src/scripts/plugins/mod.rs +++ b/crates/smtp/src/scripts/plugins/mod.rs @@ -25,6 +25,7 @@ pub mod exec; pub mod query; use ahash::AHashMap; +use mail_parser::Message; use sieve::{compiler::Number, Compiler, Input, PluginArgument}; use tokio::runtime::Handle; @@ -38,6 +39,7 @@ pub struct PluginContext<'x> { pub handle: &'x Handle, pub core: &'x SMTP, pub data: &'x mut AHashMap, + pub message: &'x Message<'x>, pub arguments: Vec>, }