diff --git a/Cargo.lock b/Cargo.lock index 4e29ec41..3154c78e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bounce-classify" +version = "0.1.0" +dependencies = [ + "regex", + "rfc5321", + "serde", + "serde_json", + "toml", +] + [[package]] name = "bstr" version = "0.2.17" @@ -2544,6 +2555,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2877,6 +2897,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7afcae9e3f0fe2c370fd4657108972cbb2fa9db1b9f84849cefd80741b01cb6" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1eb0622d28f4b9c90adc4ea4b2b46b47663fde9ac5fafcb14a1369d5508825" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -3389,6 +3443,15 @@ version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" +[[package]] +name = "winnow" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09497b8f8b5ac5d3bb4d05c0a99be20f26fd3d5f2db7b0716e946d5103658" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index ba1f3225..ed6b97c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/bounce-classify", "crates/kumod", "crates/rfc5321", "crates/spool", diff --git a/assets/bounce_classifier/iana.toml b/assets/bounce_classifier/iana.toml new file mode 100644 index 00000000..e8b8c357 --- /dev/null +++ b/assets/bounce_classifier/iana.toml @@ -0,0 +1,60 @@ +# This file contains rules that match SMTP ENHANCEDSTATUSCODES +# codes as defined in the IANA registry: +# https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml +# to bounce classifications. +[rules] +InvalidRecipient = [ + "^(451|550) [45]\\.1\\.[1234] ", + "^45[02] [45]\\.2\\.4 ", # Mailing list expansion + "^5\\d{2} [45]\\.7\\.17 ", # RRVS: Mailbox owner has changed +] +BadDomain = [ + "^(451|550) [45]\\.1\\.10 ", # NULL MX + "^5\\d{2} [45]\\.7\\.18 ", # RRVS: domain owner has changed +] +InactiveMailbox = [ + "^(451|550) [45]\\.1\\.[6] ", + "^[45]\\d{2} [45]\\.2\\.1 ", + "^525 [45]\\.7\\.13 ", # User account disabled +] +InvalidSender = [ + "^(451|550) [45]\\.1\\.[78] ", + "^\\d{3} [45]\\.7\\.27 ", # Send address has NULL MX +] +QuotaIssues = [ + "^552 [45]\\.2\\.2 ", + "^552 [45]\\.2\\.3 ", + "^452 [45]\\.3\\.1 ", # Mail System Full + "^55[24] [45]\\.3\\.4 ", # Message too large for system +] +NoAnswerFromHost = [ + "^451 [45]\\.4\\.1 ", +] +BadConnection = [ + "^421 [45]\\.4\\.2 ", +] +DNSFailure = [ + "^(451|550) [45]\\.4\\.3 ", # directory server failure +] +RoutingErrors = [ + "^\\d{3} [45]\\.4\\.4 ", # unable to route + "^\\d{3} [45]\\.4\\.6 ", # routing loop detected +] +TransientFailure = [ + "^451 [45]\\.4\\.5 ", # Congestion +] +MessageExpired = [ + "^\\d{3} [45]\\.4\\.7 ", # delivery time expired +] +ProtocolErrors = [ + "^\\d{3} [45]\\.5\\.\\d+ ", # misc protocol error + "^\\d{3} [45]\\.6\\.\\d+ ", # content negotiation protocol error +] +AuthenticationFailed = [ + # Note that a couple of x.7.x codes map to BadDomain and InvalidRecipient + # so take care to avoid an ambiguous match here + "^5\\d{2} [45]\\.7\\.(0|1|2|3|4|5|6|7|8|9|10|11|12|14|15|19|20|21|22|23|24|25|26|29|30) ", +] +PolicyRelated = [ + "^\\d{3} [45]\\.7\\.38 ", # mail flood detected +] diff --git a/crates/bounce-classify/Cargo.toml b/crates/bounce-classify/Cargo.toml new file mode 100644 index 00000000..7b303989 --- /dev/null +++ b/crates/bounce-classify/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bounce-classify" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +regex = "1.7" +rfc5321 = {path="../rfc5321"} +serde = {version="1.0", features=["derive"]} +serde_json = "1.0" +toml = "0.7" diff --git a/crates/bounce-classify/src/lib.rs b/crates/bounce-classify/src/lib.rs new file mode 100644 index 00000000..11c0c0f3 --- /dev/null +++ b/crates/bounce-classify/src/lib.rs @@ -0,0 +1,189 @@ +use regex::{RegexSet, RegexSetBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Copy, Clone, Ord, PartialOrd)] +pub enum BounceClass { + /// The recipient is invalid + InvalidRecipient, + /// The message bounced due to a DNS failure. + DNSFailure, + /// The message was blocked by the receiver as coming from a known spam source. + SpamBlock, + /// The message was blocked by the receiver as spam + SpamContent, + /// The message was blocked by the receiver because it contained an attachment + ProhibitedAttachment, + /// The message was blocked by the receiver because relaying is not allowed. + RelayDenied, + /// The message is an auto-reply/vacation mail. + AutoReply, + /// Message transmission has been temporarily delayed. + TransientFailure, + /// The message is a subscribe request. + Subscribe, + /// The message is an unsubscribe request. + Unsubscribe, + /// The message is a challenge-response probe. + ChallengeResponse, + /// messages rejected due to configuration issues with remote host, 5.X.X error + BadConfiguration, + /// messages bounced due to bad connection issues with remote host, 4.X.X error + BadConnection, + /// messages bounced due to invalid or non-existing domains, 5.X.X error + BadDomain, + /// messages refused or blocked due to content related reasons, 5.X.X error + ContentRelated, + /// messages rejected due to expired, inactive, or disabled recipient addresses, 5.X.X error + InactiveMailbox, + /// messages bounced due to invalid DNS or MX entry for sending domain + InvalidSender, + /// messages bounced due to not being delivered before the bounce-after, 4.X.X error + MessageExpired, + /// messages bounces due to receiving no response from remote host after connecting, 4.X.X or 5.X.X error + NoAnswerFromHost, + /// messages refused or blocked due to general policy reasons, 5.X.X error + PolicyRelated, + /// messages rejected due to SMTP protocol syntax or sequence errors, 5.X.X error + ProtocolErrors, + /// messages rejected or blocked due to mailbox quota issues, 4.X.X or 5.X.X error + QuotaIssues, + /// messages refused or blocked due to remote mail server relaying issues, 5.X.X error + RelayingIssues, + /// messages bounced due to mail routing issues for recipient domain, 5.X.X error + RoutingErrors, + /// messages refused or blocked due to spam related reasons, 5.X.X error + SpamRelated, + /// messages refused or blocked due to virus related reasons, 5.X.X error + VirusRelated, + /// authentication policy was not met + AuthenticationFailed, + /// messages rejected due to other reasons, 4.X.X or 5.X.X error + Uncategorized, +} + +/// Defines the content of bounce classifier rules files +#[derive(Deserialize, Serialize, Debug)] +pub struct BounceClassifierFile { + pub rules: BTreeMap>, +} + +/// Holds state for compiling rules files into a classifier +#[derive(Default)] +pub struct BounceClassifierBuilder { + rules: BTreeMap>, +} + +impl BounceClassifierBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_rule(&mut self, class: BounceClass, rule: String) { + self.rules.entry(class).or_default().push(rule); + } + + pub fn merge(&mut self, mut decoded_file: BounceClassifierFile) { + self.rules.append(&mut decoded_file.rules); + } + + pub fn merge_json_file(&mut self, file_name: &str) -> Result<(), String> { + let mut f = std::fs::File::open(file_name) + .map_err(|err| format!("reading file: {file_name}: {err:#}"))?; + let decoded: BounceClassifierFile = serde_json::from_reader(&mut f) + .map_err(|err| format!("decoding {file_name} as BounceClassifierFile: {err:#}"))?; + self.merge(decoded); + Ok(()) + } + + pub fn merge_toml_file(&mut self, file_name: &str) -> Result<(), String> { + let data = std::fs::read_to_string(file_name) + .map_err(|err| format!("reading file: {file_name}: {err:#}"))?; + let decoded: BounceClassifierFile = toml::from_str(&data) + .map_err(|err| format!("decoding {file_name} as BounceClassifierFile: {err:#}"))?; + self.merge(decoded); + Ok(()) + } + + pub fn build(self) -> Result { + let mut pattern_to_class = vec![]; + let mut patterns = vec![]; + for (class, mut rules) in self.rules { + // Build a simple implicit reverse map from pattern + // index to the bounce classification. This gives + // an O(1) mapping from the regex result at the + // cost of O(n) memory. If the rules get very large, + // this could be changed to a structure that tracks + // start/end ranges of pattern indices and uses a + // binary search. + for _ in 0..rules.len() { + pattern_to_class.push(class); + } + patterns.append(&mut rules); + } + + pattern_to_class.shrink_to_fit(); + + let set = RegexSetBuilder::new(patterns) + .build() + .map_err(|err| format!("compiling rules: {err:#}"))?; + Ok(BounceClassifier { + set, + pattern_to_class, + }) + } +} + +pub struct BounceClassifier { + set: RegexSet, + pattern_to_class: Vec, +} + +impl BounceClassifier { + pub fn classify_str(&self, s: &str) -> BounceClass { + self.set + .matches(s) + .into_iter() + .next() + .and_then(|idx| self.pattern_to_class.get(idx)) + .copied() + .unwrap_or(BounceClass::Uncategorized) + } + + pub fn classify_response(&self, response: &rfc5321::Response) -> BounceClass { + let line = response.to_single_line(); + self.classify_str(&line) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_bounce_classify_iana() { + let mut builder = BounceClassifierBuilder::new(); + builder + .merge_toml_file("../../assets/bounce_classifier/iana.toml") + .unwrap(); + let classifier = builder.build().unwrap(); + + let corpus = &[ + ("552 5.2.2 mailbox is stuffed", BounceClass::QuotaIssues), + ("552 4.2.2 mailbox is stuffed", BounceClass::QuotaIssues), + ("552 4.2.2 mailbox is stuffed", BounceClass::QuotaIssues), + ("352 5.2.2 mailbox is stuffed", BounceClass::Uncategorized), + ("525 4.7.13 user account is disabled", BounceClass::InactiveMailbox), + ("551 4.7.17 mailbox owner has changed", BounceClass::InvalidRecipient), + ("551 4.7.18 domain owner has changed", BounceClass::BadDomain), + ]; + + for &(input, output) in corpus { + assert_eq!( + classifier.classify_str(input), + output, + "expected {input} -> {output:?}" + ); + } + } +} diff --git a/crates/rfc5321/src/client.rs b/crates/rfc5321/src/client.rs index bb6401a7..871e17d1 100644 --- a/crates/rfc5321/src/client.rs +++ b/crates/rfc5321/src/client.rs @@ -504,6 +504,26 @@ pub struct Response { pub command: Option, } +impl Response { + pub fn to_single_line(&self) -> String { + let mut line = format!("{} ", self.code); + + if let Some(enh) = &self.enhanced_code { + line.push_str(&format!("{}.{}.{} ", enh.class, enh.subject, enh.detail)); + } + + for c in self.content.chars() { + match c { + '\r' => line.push_str("\\r"), + '\n' => line.push_str("\\n"), + c => line.push(c), + } + } + + line + } +} + #[derive(Debug, PartialEq, Eq)] struct ResponseLine<'a> { pub code: u16,