Files
Wez Furlong 7ac9a5eac0 smtp_dispatcher: handle pathologic all-452 case
Continuing in the same vein as 94628f06ab,
ensure that we have a reasonable outcome when a batch of size > 1
encounters 100% ambiguous 452 responses.

Without special consideration we'd end up in an infinite retry loop for
these, as we'd think that we'd hit the recpient limit on the connection
every time.

This commit introduces a small amount of state into the dispatcher so
that we can remember the set of (spoolid, recipient) from the prior
transaction on the session.

When deciding whether something is eligible for immediate retry
we will gate that choice on the current (spoolid, recipient) being
absent from the prior set of ids.  In this way, we bound the immediate
retry for a recipient to one per connection.

To make things a bit more robust when trying to decide whether a 452
means "too many recipients" or not, if we're not sure, we now call out
to the bounce classifier which can be configured to emit a
`TooManyRecipients` classification.  If the classifier produces either
that or some non-Uncategorized response, then we have a confident
assessment.  Otherwise, we're unsure and will factor that into the
retry strategy.
2025-11-07 08:13:36 +00:00

314 lines
9.7 KiB
Rust

use ordermap::OrderMap;
use regex::{RegexSet, RegexSetBuilder};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)]
#[serde(from = "String", into = "String")]
pub enum BounceClass {
PreDefined(PreDefinedBounceClass),
UserDefined(String),
}
impl From<String> for BounceClass {
fn from(s: String) -> BounceClass {
if let Ok(pre) = PreDefinedBounceClass::from_str(&s) {
BounceClass::PreDefined(pre)
} else {
BounceClass::UserDefined(s)
}
}
}
impl From<BounceClass> for String {
fn from(val: BounceClass) -> Self {
match val {
BounceClass::PreDefined(pre) => pre.to_string(),
BounceClass::UserDefined(s) => s,
}
}
}
impl Default for BounceClass {
fn default() -> Self {
PreDefinedBounceClass::Uncategorized.into()
}
}
impl From<PreDefinedBounceClass> for BounceClass {
fn from(c: PreDefinedBounceClass) -> BounceClass {
BounceClass::PreDefined(c)
}
}
#[derive(
Serialize,
Deserialize,
Debug,
PartialEq,
Eq,
Hash,
Copy,
Clone,
Ord,
PartialOrd,
strum::EnumString,
strum::Display,
)]
pub enum PreDefinedBounceClass {
/// 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,
/// A session or batch limit on the number of recipients was hit.
/// This is a transient error even if it has a 5xx status code!
TooManyRecipients,
/// 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: OrderMap<BounceClass, Vec<String>>,
}
/// Holds state for compiling rules files into a classifier
#[derive(Default)]
pub struct BounceClassifierBuilder {
rules: Vec<(BounceClass, String)>,
}
impl BounceClassifierBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn add_rule(&mut self, class: BounceClass, rule: String) {
self.rules.push((class, rule));
}
pub fn merge(&mut self, decoded_file: BounceClassifierFile) {
for (class, rules) in decoded_file.rules {
for rule in rules {
self.add_rule(class.clone(), rule);
}
}
}
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, rule) 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.
pattern_to_class.push(class.clone());
patterns.push(rule);
}
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))
.cloned()
.unwrap_or(BounceClass::PreDefined(
PreDefinedBounceClass::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_rule_order() {
let f1: BounceClassifierFile = toml::from_str(
r#"
[rules]
foo = ["woot", "aaa"]
bar = ["woot", "aaa", "bbb"]
"#,
)
.unwrap();
let f2: BounceClassifierFile = toml::from_str(
r#"
[rules]
second_file = ["bbb", "ccc"]
"#,
)
.unwrap();
let mut builder = BounceClassifierBuilder::new();
builder.merge(f1);
builder.merge(f2);
let classifier = builder.build().unwrap();
assert_eq!(
classifier.classify_str("woot"),
BounceClass::UserDefined("foo".to_string()),
"foo should match rather than bar"
);
assert_eq!(
classifier.classify_str("aaa"),
BounceClass::UserDefined("foo".to_string()),
"foo should match rather than bar"
);
assert_eq!(
classifier.classify_str("bbb"),
BounceClass::UserDefined("bar".to_string()),
);
assert_eq!(
classifier.classify_str("ccc"),
BounceClass::UserDefined("second_file".to_string()),
);
}
#[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",
PreDefinedBounceClass::QuotaIssues,
),
(
"552 4.2.2 mailbox is stuffed",
PreDefinedBounceClass::QuotaIssues,
),
(
"552 4.2.2 mailbox is stuffed",
PreDefinedBounceClass::QuotaIssues,
),
(
"352 5.2.2 mailbox is stuffed",
PreDefinedBounceClass::Uncategorized,
),
(
"525 4.7.13 user account is disabled",
PreDefinedBounceClass::InactiveMailbox,
),
(
"551 4.7.17 mailbox owner has changed",
PreDefinedBounceClass::InvalidRecipient,
),
(
"551 4.7.18 domain owner has changed",
PreDefinedBounceClass::BadDomain,
),
];
for &(input, output) in corpus {
assert_eq!(
classifier.classify_str(input),
output.into(),
"expected {input} -> {output:?}"
);
}
}
}