mirror of
https://github.com/mailscope/kumomta.git
synced 2026-09-05 10:08:56 +00:00
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:
Generated
+63
@@ -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,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/bounce-classify",
|
||||
"crates/kumod",
|
||||
"crates/rfc5321",
|
||||
"crates/spool",
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user