Add extensible bounce classifier

The classifier can load and merge rules from multiple files.
A basic iana enhanced status codes ruleset is provided.
A deployment can define their own to augment that set.

This commit is just the core classifier functionality; it
is not yet connected to kumod.

The classification enum is a bit of an amalgam of categories
defined by other well-known vendors/products and the categories
defined by IANA.  Some classifications have no corresponding
rules at the current time.
This commit is contained in:
Wez Furlong
2023-03-02 08:27:32 -07:00
parent 3d521a6468
commit 74635be9fb
6 changed files with 346 additions and 0 deletions
Generated
+63
View File
@@ -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"
+1
View File
@@ -1,5 +1,6 @@
[workspace]
members = [
"crates/bounce-classify",
"crates/kumod",
"crates/rfc5321",
"crates/spool",
+60
View File
@@ -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
]
+13
View File
@@ -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"
+189
View File
@@ -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<BounceClass, Vec<String>>,
}
/// Holds state for compiling rules files into a classifier
#[derive(Default)]
pub struct BounceClassifierBuilder {
rules: BTreeMap<BounceClass, Vec<String>>,
}
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<BounceClassifier, String> {
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<BounceClass>,
}
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:?}"
);
}
}
}
+20
View File
@@ -504,6 +504,26 @@ pub struct Response {
pub command: Option<String>,
}
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,