1use crate::rfc3464::{content_type, RemoteMta};
3use anyhow::anyhow;
4use bstr::{BStr, BString, ByteSlice};
5use chrono::{DateTime, Utc};
6use mailparsing::{BStringUtf8, Header, HeaderParseResult, MimePart};
7use rfc5321::parser::EnvelopeAddress;
8use serde::{Deserialize, Serialize};
9use serde_with::serde_as;
10use std::collections::BTreeMap;
11use std::str::FromStr;
12
13#[serde_as]
14#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
15pub struct ARFReport {
16 pub feedback_type: String,
17 pub user_agent: String,
18 pub version: String,
19
20 #[serde(default)]
21 pub arrival_date: Option<DateTime<Utc>>,
22 #[serde(default)]
23 pub incidents: Option<u32>,
24 #[serde(default)]
25 pub original_envelope_id: Option<String>,
26 #[serde(default)]
27 pub original_mail_from: Option<String>,
28 #[serde(default)]
29 pub reporting_mta: Option<RemoteMta>,
30 #[serde(default)]
31 pub source_ip: Option<String>,
32
33 #[serde(default)]
34 pub authentication_results: Vec<String>,
35 #[serde(default)]
36 pub original_rcpto_to: Vec<String>,
37 #[serde(default)]
38 pub reported_domain: Vec<String>,
39 #[serde(default)]
40 pub reported_uri: Vec<String>,
41
42 #[serde_as(as = "BTreeMap<_, Vec<BStringUtf8>>")]
43 pub extensions: BTreeMap<String, Vec<BString>>,
44
45 #[serde_as(as = "Option<BStringUtf8>")]
46 pub original_message: Option<BString>,
47 pub supplemental_trace: Option<serde_json::Value>,
48}
49
50impl ARFReport {
51 pub fn parse(input: &[u8]) -> anyhow::Result<Option<Self>> {
52 let mail = MimePart::parse(input)?;
53 let ct = mail.headers().content_type()?;
54 let ct = match ct {
55 None => return Ok(None),
56 Some(ct) => ct,
57 };
58
59 if ct.value != "multipart/report" {
60 return Ok(None);
61 }
62
63 if ct.get("report-type").as_ref().map(|b| b.as_bstr()) != Some(BStr::new("feedback-report"))
64 {
65 return Ok(None);
66 }
67
68 let mut original_message = None;
69 let mut supplemental_trace = None;
70
71 for part in mail.child_parts() {
72 let ct = content_type(part);
73 let ct = ct.as_ref().map(|b| b.as_bstr());
74 if ct == Some(BStr::new("message/rfc822"))
75 || ct == Some(BStr::new("text/rfc822-headers"))
76 {
77 if let Ok(HeaderParseResult { headers, .. }) =
78 Header::parse_headers(part.raw_body())
79 {
80 for hdr in headers.iter() {
82 if !(hdr.get_name().starts_with_str("X-")
83 || hdr.get_name().starts_with_str("x-"))
84 {
85 continue;
86 }
87 let encoded: Vec<u8> = hdr
91 .get_raw_value()
92 .iter()
93 .copied()
94 .filter(|b| !b.is_ascii_whitespace())
95 .collect();
96 if let Ok(decoded) = data_encoding::BASE64.decode(&encoded) {
97 #[derive(Deserialize)]
98 struct Wrap {
99 #[serde(rename = "_@_")]
100 marker: String,
101 #[serde(flatten)]
102 payload: serde_json::Value,
103 }
104 if let Ok(obj) = serde_json::from_slice::<Wrap>(&decoded) {
105 if obj.marker == "\\_/" {
109 supplemental_trace.replace(obj.payload);
110 break;
111 }
112 }
113 }
114 }
115 }
116
117 original_message = Some(BString::new(
118 part.raw_body().as_bytes().replace("\r\n", "\n"),
119 ));
120 }
121 }
122
123 for part in mail.child_parts() {
124 let ct = content_type(part);
125 let ct = ct.as_ref().map(|b| b.as_bstr());
126 if ct == Some(BStr::new("message/feedback-report")) {
127 return Ok(Some(Self::parse_inner(
128 part,
129 original_message,
130 supplemental_trace,
131 )?));
132 }
133 }
134
135 anyhow::bail!("feedback-report part missing");
136 }
137
138 fn parse_inner(
139 part: &MimePart,
140 original_message: Option<BString>,
141 supplemental_trace: Option<serde_json::Value>,
142 ) -> anyhow::Result<Self> {
143 let body = part.raw_body();
144 let mut extensions = extract_headers(body.as_bytes())?;
145
146 let feedback_type = extract_single_req("feedback-type", &mut extensions)?;
147 let user_agent = extract_single_req("user-agent", &mut extensions)?;
148 let version = extract_single_req("version", &mut extensions)?;
149 let arrival_date = extract_single_conv_fallback::<DateTimeRfc2822, DateTime<Utc>>(
150 "arrival-date",
151 "received-date",
152 &mut extensions,
153 );
154 let incidents = extract_single("incidents", &mut extensions)?;
155 let original_envelope_id = extract_single("original-envelope-id", &mut extensions)?;
156 let original_mail_from =
157 extract_single::<EnvelopeAddress>("original-mail-from", &mut extensions)?
158 .map(|a| a.to_string());
159 let reporting_mta = extract_single("reporting-mta", &mut extensions)?;
160 let source_ip = extract_single("source-ip", &mut extensions)?;
161 let authentication_results = extract_multiple("authentication-results", &mut extensions)?;
162 let original_rcpto_to =
163 extract_multiple::<EnvelopeAddress>("original-rcpt-to", &mut extensions)?
164 .into_iter()
165 .map(|a| a.to_string())
166 .collect();
167 let reported_domain = extract_multiple("reported-domain", &mut extensions)?;
168 let reported_uri = extract_multiple("reported-uri", &mut extensions)?;
169
170 Ok(Self {
171 feedback_type,
172 user_agent,
173 version,
174 arrival_date,
175 incidents,
176 original_envelope_id,
177 original_mail_from,
178 reporting_mta,
179 source_ip,
180 authentication_results,
181 original_rcpto_to,
182 reported_domain,
183 reported_uri,
184 extensions,
185 original_message,
186 supplemental_trace,
187 })
188 }
189}
190
191pub(crate) fn extract_headers(part: &[u8]) -> anyhow::Result<BTreeMap<String, Vec<BString>>> {
192 let HeaderParseResult { headers, .. } = Header::parse_headers(part)?;
193
194 let mut extensions = BTreeMap::new();
195
196 for hdr in headers.iter() {
197 let name = String::from_utf8_lossy(&hdr.get_name()).to_ascii_lowercase();
198 extensions
199 .entry(name)
200 .or_insert_with(std::vec::Vec::new)
201 .push(hdr.as_unstructured()?);
202 }
203 Ok(extensions)
204}
205
206#[derive(Debug)]
207pub(crate) struct DateTimeRfc2822(pub DateTime<Utc>);
208
209impl FromStr for DateTimeRfc2822 {
210 type Err = anyhow::Error;
211 fn from_str(input: &str) -> anyhow::Result<Self> {
212 let date = mailparsing::parse_rfc2822_date(input)?;
213 Ok(Self(date.into()))
214 }
215}
216
217impl From<DateTimeRfc2822> for DateTime<Utc> {
218 fn from(val: DateTimeRfc2822) -> Self {
219 val.0
220 }
221}
222
223pub(crate) fn extract_single_req<R>(
224 name: &str,
225 extensions: &mut BTreeMap<String, Vec<BString>>,
226) -> anyhow::Result<R>
227where
228 R: FromStr,
229 <R as FromStr>::Err: std::fmt::Display,
230{
231 extract_single(name, extensions)?
232 .ok_or_else(|| anyhow!("required header {name} is not present"))
233}
234
235pub(crate) fn extract_single<R>(
236 name: &str,
237 extensions: &mut BTreeMap<String, Vec<BString>>,
238) -> anyhow::Result<Option<R>>
239where
240 R: FromStr,
241 <R as FromStr>::Err: std::fmt::Display,
242{
243 match extensions.remove(name) {
244 Some(mut hdrs) if hdrs.len() == 1 => {
245 let value = hdrs.remove(0);
246 let value = value
247 .to_str()
248 .map_err(|err| anyhow!("{value} could not be converted to UTF-8: {err:#}"))?;
249 let converted = value
250 .parse::<R>()
251 .map_err(|err| anyhow!("failed to convert '{value}': {err:#}"))?;
252 Ok(Some(converted))
253 }
254 Some(_) => anyhow::bail!("header {name} should have only a single value"),
255 None => Ok(None),
256 }
257}
258
259pub(crate) fn extract_single_conv<R, T>(
260 name: &str,
261 extensions: &mut BTreeMap<String, Vec<BString>>,
262) -> anyhow::Result<Option<T>>
263where
264 R: FromStr,
265 <R as FromStr>::Err: std::fmt::Display,
266 R: Into<T>,
267{
268 Ok(extract_single::<R>(name, extensions)?.map(|v| v.into()))
269}
270
271pub(crate) fn extract_single_conv_fallback<R, T>(
272 name: &str,
273 fallback: &str,
274 extensions: &mut BTreeMap<String, Vec<BString>>,
275) -> Option<T>
276where
277 R: FromStr,
278 <R as FromStr>::Err: std::fmt::Display,
279 R: Into<T>,
280{
281 let maybe = extract_single::<R>(name, extensions).ok()?;
282 match maybe {
283 Some(value) => Some(value.into()),
284 None => extract_single::<R>(fallback, extensions)
285 .ok()?
286 .map(Into::into),
287 }
288}
289
290pub(crate) fn extract_multiple<R>(
291 name: &str,
292 extensions: &mut BTreeMap<String, Vec<BString>>,
293) -> anyhow::Result<Vec<R>>
294where
295 R: FromStr,
296 <R as FromStr>::Err: std::fmt::Display,
297{
298 match extensions.remove(name) {
299 Some(hdrs) => {
300 let mut results = vec![];
301 for h in hdrs {
302 let value = h
303 .to_str()
304 .map_err(|err| anyhow!("{h} could not be converted to UTF-8: {err:#}"))?;
305 let converted = value
306 .parse::<R>()
307 .map_err(|err| anyhow!("failed to convert {h}: {err:#}"))?;
308 results.push(converted);
309 }
310 Ok(results)
311 }
312 None => Ok(vec![]),
313 }
314}
315
316#[cfg(test)]
317mod test {
318 use super::*;
319 use chrono::TimeZone;
320
321 #[test]
322 fn datetime_rfc2822_obsolete_utc_zone() {
323 let expected = Utc.with_ymd_and_hms(2026, 7, 2, 18, 55, 38).unwrap();
325
326 let parsed: DateTime<Utc> = "Thu, 02 Jul 26 18:55:38 UTC"
327 .parse::<DateTimeRfc2822>()
328 .expect("SES obsolete UTC zone should parse")
329 .into();
330 k9::assert_equal!(parsed, expected);
331
332 let canonical: DateTime<Utc> = "Thu, 02 Jul 2026 18:55:38 +0000"
334 .parse::<DateTimeRfc2822>()
335 .unwrap()
336 .into();
337 k9::assert_equal!(canonical, expected);
338
339 "not a date".parse::<DateTimeRfc2822>().unwrap_err();
341 }
342
343 #[test]
344 fn rfc5965_1() {
345 let result = ARFReport::parse(include_bytes!("../data/rfc5965/1.eml")).unwrap();
346 k9::snapshot!(
347 result,
348 r#"
349Some(
350 ARFReport {
351 feedback_type: "abuse",
352 user_agent: "SomeGenerator/1.0",
353 version: "1",
354 arrival_date: None,
355 incidents: None,
356 original_envelope_id: None,
357 original_mail_from: None,
358 reporting_mta: None,
359 source_ip: None,
360 authentication_results: [],
361 original_rcpto_to: [],
362 reported_domain: [],
363 reported_uri: [],
364 extensions: {},
365 original_message: Some(
366 "Received: from mailserver.example.net
367 (mailserver.example.net [192.0.2.1])
368 by example.com with ESMTP id M63d4137594e46;
369 Thu, 08 Mar 2005 14:00:00 -0400
370From: <somespammer@example.net>
371To: <Undisclosed Recipients>
372Subject: Earn money
373MIME-Version: 1.0
374Content-type: text/plain
375Message-ID: 8787KJKJ3K4J3K4J3K4J3.mail@example.net
376Date: Thu, 02 Sep 2004 12:31:03 -0500
377
378Spam Spam Spam
379Spam Spam Spam
380Spam Spam Spam
381Spam Spam Spam
382",
383 ),
384 supplemental_trace: None,
385 },
386)
387"#
388 );
389 }
390
391 #[test]
392 fn rfc5965_2() {
393 let result = ARFReport::parse(include_bytes!("../data/rfc5965/2.eml")).unwrap();
394 k9::snapshot!(
395 result,
396 r#"
397Some(
398 ARFReport {
399 feedback_type: "abuse",
400 user_agent: "SomeGenerator/1.0",
401 version: "1",
402 arrival_date: None,
403 incidents: None,
404 original_envelope_id: None,
405 original_mail_from: Some(
406 "somespammer@example.net",
407 ),
408 reporting_mta: Some(
409 RemoteMta {
410 mta_type: "dns",
411 name: "mail.example.com",
412 },
413 ),
414 source_ip: Some(
415 "192.0.2.1",
416 ),
417 authentication_results: [
418 "mail.example.com; spf=fail smtp.mail=somespammer@example.com",
419 ],
420 original_rcpto_to: [
421 "user@example.com",
422 ],
423 reported_domain: [
424 "example.net",
425 ],
426 reported_uri: [
427 "http://example.net/earn_money.html",
428 "mailto:user@example.com",
429 ],
430 extensions: {
431 "removal-recipient": [
432 "user@example.com",
433 ],
434 },
435 original_message: Some(
436 "From: <somespammer@example.net>
437Received: from mailserver.example.net (mailserver.example.net
438 [192.0.2.1]) by example.com with ESMTP id M63d4137594e46;
439 Tue, 08 Mar 2005 14:00:00 -0400
440X-KumoRef: eyJfQF8iOiJcXF8vIiwicmVjaXBpZW50IjoidGVzdEBleGFtcGxlLmNvbSJ9
441To: <Undisclosed Recipients>
442Subject: Earn money
443MIME-Version: 1.0
444Content-type: text/plain
445Message-ID: 8787KJKJ3K4J3K4J3K4J3.mail@example.net
446Date: Thu, 02 Sep 2004 12:31:03 -0500
447
448Spam Spam Spam
449Spam Spam Spam
450Spam Spam Spam
451Spam Spam Spam
452",
453 ),
454 supplemental_trace: Some(
455 Object {
456 "recipient": String("test@example.com"),
457 },
458 ),
459 },
460)
461"#
462 );
463 }
464
465 #[test]
468 fn supplemental_trace_folded_header() {
469 let report = concat!(
470 "Content-Type: multipart/report; report-type=feedback-report;\r\n",
471 " boundary=\"b\"\r\n",
472 "\r\n",
473 "--b\r\n",
474 "Content-Type: message/feedback-report\r\n",
475 "\r\n",
476 "Feedback-Type: abuse\r\n",
477 "User-Agent: SomeGenerator/1.0\r\n",
478 "Version: 1\r\n",
479 "\r\n",
480 "--b\r\n",
481 "Content-Type: message/rfc822\r\n",
482 "\r\n",
483 "From: <somespammer@example.net>\r\n",
484 "X-KumoRef: eyJfQF8iOiJcXF8vIiwicmVjaXBpZW50Ijoi\r\n",
486 "\tdGVzdEBleGFtcGxlLmNvbSJ9\r\n",
487 "Subject: Earn money\r\n",
488 "\r\n",
489 "Spam\r\n",
490 "--b--\r\n",
491 );
492
493 let result = ARFReport::parse(report.as_bytes()).unwrap().unwrap();
494 k9::assert_equal!(
495 result.supplemental_trace,
496 Some(serde_json::json!({ "recipient": "test@example.com" }))
497 );
498 }
499
500 #[test]
501 fn rfc5965_3() {
502 let result = ARFReport::parse(include_bytes!("../data/rfc5965/3.eml")).unwrap();
503 k9::snapshot!(
504 result,
505 r#"
506Some(
507 ARFReport {
508 feedback_type: "abuse",
509 user_agent: "Yahoo!-Mail-Feedback/2.0",
510 version: "0.1",
511 arrival_date: Some(
512 2023-12-14T16:16:15Z,
513 ),
514 incidents: None,
515 original_envelope_id: None,
516 original_mail_from: Some(
517 "test1@example.com",
518 ),
519 reporting_mta: None,
520 source_ip: None,
521 authentication_results: [
522 "authentication result string is not available",
523 ],
524 original_rcpto_to: [
525 "user@example.com",
526 ],
527 reported_domain: [
528 "bounce.kumo.example.com",
529 ],
530 reported_uri: [],
531 extensions: {},
532 original_message: Some(
533 "Date: Thu, 14 Dec 2023 16:16:14 +0000
534To: user@example.com
535Subject: test Thu, 14 Dec 2023 16:16:14 +0000
536
537This is a test mailing
538
539",
540 ),
541 supplemental_trace: None,
542 },
543)
544"#
545 );
546 }
547
548 #[test]
549 fn rfc5965_4() {
550 let result = ARFReport::parse(include_bytes!("../data/rfc5965/4.eml")).unwrap();
551 k9::snapshot!(
552 result,
553 r#"
554Some(
555 ARFReport {
556 feedback_type: "abuse",
557 user_agent: "ReturnPathFBL/2.0",
558 version: "1",
559 arrival_date: Some(
560 2023-12-13T19:03:30Z,
561 ),
562 incidents: None,
563 original_envelope_id: None,
564 original_mail_from: Some(
565 "foo@bounce.example.com",
566 ),
567 reporting_mta: None,
568 source_ip: Some(
569 "x.x.x.x",
570 ),
571 authentication_results: [],
572 original_rcpto_to: [
573 "cb4a01a48251d4765f489076aa81e2a4@comcast.net",
574 ],
575 reported_domain: [
576 "bounce.example.com",
577 ],
578 reported_uri: [],
579 extensions: {
580 "abuse-type": [
581 "complaint",
582 ],
583 "source": [
584 "Comcast",
585 ],
586 "subscription-link": [
587 "https://fbl.returnpath.net/manage/subscriptions/xxxx",
588 ],
589 },
590 original_message: Some(
591 "Date: Thu, 14 Dec 2023 16:16:14 +0000
592To: user@example.com
593Subject: test Thu, 14 Dec 2023 16:16:14 +0000
594
595This is a test mailing
596
597",
598 ),
599 supplemental_trace: None,
600 },
601)
602"#
603 );
604 }
605}