1use ordermap::OrderMap;
2use regex::{RegexSet, RegexSetBuilder};
3use serde::{Deserialize, Serialize};
4use std::str::FromStr;
5
6#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)]
7#[serde(from = "String", into = "String")]
8pub enum BounceClass {
9 PreDefined(PreDefinedBounceClass),
10 UserDefined(String),
11}
12
13impl From<String> for BounceClass {
14 fn from(s: String) -> BounceClass {
15 if let Ok(pre) = PreDefinedBounceClass::from_str(&s) {
16 BounceClass::PreDefined(pre)
17 } else {
18 BounceClass::UserDefined(s)
19 }
20 }
21}
22
23impl From<BounceClass> for String {
24 fn from(val: BounceClass) -> Self {
25 match val {
26 BounceClass::PreDefined(pre) => pre.to_string(),
27 BounceClass::UserDefined(s) => s,
28 }
29 }
30}
31
32impl Default for BounceClass {
33 fn default() -> Self {
34 PreDefinedBounceClass::Uncategorized.into()
35 }
36}
37
38impl From<PreDefinedBounceClass> for BounceClass {
39 fn from(c: PreDefinedBounceClass) -> BounceClass {
40 BounceClass::PreDefined(c)
41 }
42}
43
44#[derive(
45 Serialize,
46 Deserialize,
47 Debug,
48 PartialEq,
49 Eq,
50 Hash,
51 Copy,
52 Clone,
53 Ord,
54 PartialOrd,
55 strum::EnumString,
56 strum::Display,
57)]
58pub enum PreDefinedBounceClass {
59 InvalidRecipient,
61 DNSFailure,
63 SpamBlock,
65 SpamContent,
67 ProhibitedAttachment,
69 RelayDenied,
71 AutoReply,
73 TransientFailure,
75 Subscribe,
77 Unsubscribe,
79 ChallengeResponse,
81 BadConfiguration,
83 BadConnection,
85 BadDomain,
87 ContentRelated,
89 InactiveMailbox,
91 InvalidSender,
93 MessageExpired,
95 NoAnswerFromHost,
97 PolicyRelated,
99 ProtocolErrors,
101 QuotaIssues,
103 RelayingIssues,
105 RoutingErrors,
107 SpamRelated,
109 VirusRelated,
111 AuthenticationFailed,
113 TooManyRecipients,
116 Uncategorized,
118}
119
120#[derive(Deserialize, Serialize, Debug)]
122pub struct BounceClassifierFile {
123 pub rules: OrderMap<BounceClass, Vec<String>>,
124}
125
126#[derive(Default)]
128pub struct BounceClassifierBuilder {
129 rules: Vec<(BounceClass, String)>,
130}
131
132impl BounceClassifierBuilder {
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn add_rule(&mut self, class: BounceClass, rule: String) {
138 self.rules.push((class, rule));
139 }
140
141 pub fn merge(&mut self, decoded_file: BounceClassifierFile) {
142 for (class, rules) in decoded_file.rules {
143 for rule in rules {
144 self.add_rule(class.clone(), rule);
145 }
146 }
147 }
148
149 pub fn merge_json_file(&mut self, file_name: &str) -> Result<(), String> {
150 let mut f = std::fs::File::open(file_name)
151 .map_err(|err| format!("reading file: {file_name}: {err:#}"))?;
152 let decoded: BounceClassifierFile = serde_json::from_reader(&mut f)
153 .map_err(|err| format!("decoding {file_name} as BounceClassifierFile: {err:#}"))?;
154 self.merge(decoded);
155 Ok(())
156 }
157
158 pub fn merge_toml_file(&mut self, file_name: &str) -> Result<(), String> {
159 let data = std::fs::read_to_string(file_name)
160 .map_err(|err| format!("reading file: {file_name}: {err:#}"))?;
161 let decoded: BounceClassifierFile = toml::from_str(&data)
162 .map_err(|err| format!("decoding {file_name} as BounceClassifierFile: {err:#}"))?;
163 self.merge(decoded);
164 Ok(())
165 }
166
167 pub fn build(self) -> Result<BounceClassifier, String> {
168 let mut pattern_to_class = vec![];
169 let mut patterns = vec![];
170 for (class, rule) in self.rules {
171 pattern_to_class.push(class.clone());
179 patterns.push(rule);
180 }
181
182 pattern_to_class.shrink_to_fit();
183
184 let set = RegexSetBuilder::new(patterns)
185 .build()
186 .map_err(|err| format!("compiling rules: {err:#}"))?;
187 Ok(BounceClassifier {
188 set,
189 pattern_to_class,
190 })
191 }
192}
193
194pub struct BounceClassifier {
195 set: RegexSet,
196 pattern_to_class: Vec<BounceClass>,
197}
198
199impl BounceClassifier {
200 pub fn classify_str(&self, s: &str) -> BounceClass {
201 self.set
202 .matches(s)
203 .into_iter()
204 .next()
205 .and_then(|idx| self.pattern_to_class.get(idx))
206 .cloned()
207 .unwrap_or(BounceClass::PreDefined(
208 PreDefinedBounceClass::Uncategorized,
209 ))
210 }
211
212 pub fn classify_response(&self, response: &rfc5321::Response) -> BounceClass {
213 let line = response.to_single_line();
214 self.classify_str(&line)
215 }
216}
217
218#[cfg(test)]
219mod test {
220 use super::*;
221
222 #[test]
223 fn test_rule_order() {
224 let f1: BounceClassifierFile = toml::from_str(
225 r#"
226[rules]
227foo = ["woot", "aaa"]
228bar = ["woot", "aaa", "bbb"]
229 "#,
230 )
231 .unwrap();
232
233 let f2: BounceClassifierFile = toml::from_str(
234 r#"
235[rules]
236second_file = ["bbb", "ccc"]
237 "#,
238 )
239 .unwrap();
240
241 let mut builder = BounceClassifierBuilder::new();
242 builder.merge(f1);
243 builder.merge(f2);
244
245 let classifier = builder.build().unwrap();
246 assert_eq!(
247 classifier.classify_str("woot"),
248 BounceClass::UserDefined("foo".to_string()),
249 "foo should match rather than bar"
250 );
251 assert_eq!(
252 classifier.classify_str("aaa"),
253 BounceClass::UserDefined("foo".to_string()),
254 "foo should match rather than bar"
255 );
256 assert_eq!(
257 classifier.classify_str("bbb"),
258 BounceClass::UserDefined("bar".to_string()),
259 );
260 assert_eq!(
261 classifier.classify_str("ccc"),
262 BounceClass::UserDefined("second_file".to_string()),
263 );
264 }
265
266 #[test]
267 fn test_bounce_classify_iana() {
268 let mut builder = BounceClassifierBuilder::new();
269 builder
270 .merge_toml_file("../../assets/bounce_classifier/iana.toml")
271 .unwrap();
272 let classifier = builder.build().unwrap();
273
274 let corpus = &[
275 (
276 "552 5.2.2 mailbox is stuffed",
277 PreDefinedBounceClass::QuotaIssues,
278 ),
279 (
280 "552 4.2.2 mailbox is stuffed",
281 PreDefinedBounceClass::QuotaIssues,
282 ),
283 (
284 "552 4.2.2 mailbox is stuffed",
285 PreDefinedBounceClass::QuotaIssues,
286 ),
287 (
288 "352 5.2.2 mailbox is stuffed",
289 PreDefinedBounceClass::Uncategorized,
290 ),
291 (
292 "525 4.7.13 user account is disabled",
293 PreDefinedBounceClass::InactiveMailbox,
294 ),
295 (
296 "551 4.7.17 mailbox owner has changed",
297 PreDefinedBounceClass::InvalidRecipient,
298 ),
299 (
300 "551 4.7.18 domain owner has changed",
301 PreDefinedBounceClass::BadDomain,
302 ),
303 ];
304
305 for &(input, output) in corpus {
306 assert_eq!(
307 classifier.classify_str(input),
308 output.into(),
309 "expected {input} -> {output:?}"
310 );
311 }
312 }
313}