1use crate::rfc5965::{
4 extract_headers, extract_single, extract_single_conv, extract_single_req, DateTimeRfc2822,
5};
6use anyhow::{anyhow, Context};
7use chrono::{DateTime, Utc};
8use mailparsing::MimePart;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::str::FromStr;
12
13#[derive(Debug, Serialize, Deserialize, Copy, Clone, Eq, PartialEq)]
14#[serde(rename_all = "lowercase")]
15pub enum ReportAction {
16 Failed,
17 Delayed,
18 Delivered,
19 Relayed,
20 Expanded,
21}
22
23impl FromStr for ReportAction {
24 type Err = anyhow::Error;
25 fn from_str(input: &str) -> anyhow::Result<Self> {
26 Ok(match input {
27 "failed" => Self::Failed,
28 "delayed" => Self::Delayed,
29 "delivered" => Self::Delivered,
30 "relayed" => Self::Relayed,
31 "expanded" => Self::Expanded,
32 _ => anyhow::bail!("invalid action type {input}"),
33 })
34 }
35}
36
37#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
38pub struct ReportStatus {
39 pub class: u8,
40 pub subject: u16,
41 pub detail: u16,
42 pub comment: Option<String>,
43}
44
45impl FromStr for ReportStatus {
46 type Err = anyhow::Error;
47 fn from_str(input: &str) -> anyhow::Result<Self> {
48 let mut parts: Vec<_> = input.split(' ').collect();
49
50 let mut status = parts[0].split('.');
51 let class = status
52 .next()
53 .ok_or_else(|| anyhow!("invalid Status: {input}"))?
54 .parse()
55 .context("parsing status.class")?;
56 let subject = status
57 .next()
58 .ok_or_else(|| anyhow!("invalid Status: {input}"))?
59 .parse()
60 .context("parsing status.subject")?;
61 let detail = status
62 .next()
63 .ok_or_else(|| anyhow!("invalid Status: {input}"))?
64 .parse()
65 .context("parsing status.detail")?;
66
67 parts.remove(0);
68 let comment = if parts.is_empty() {
69 None
70 } else {
71 Some(parts.join(" "))
72 };
73
74 Ok(Self {
75 class,
76 subject,
77 detail,
78 comment,
79 })
80 }
81}
82
83#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
84pub struct RemoteMta {
85 pub mta_type: String,
86 pub name: String,
87}
88
89impl FromStr for RemoteMta {
90 type Err = anyhow::Error;
91
92 fn from_str(input: &str) -> anyhow::Result<Self> {
93 let (mta_type, name) = input
94 .split_once(";")
95 .ok_or_else(|| anyhow!("expected 'name-type; name', got {input}"))?;
96 Ok(Self {
97 mta_type: mta_type.trim().to_string(),
98 name: name.trim().to_string(),
99 })
100 }
101}
102
103#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
104pub struct Recipient {
105 pub recipient_type: String,
106 pub recipient: String,
107}
108impl FromStr for Recipient {
109 type Err = anyhow::Error;
110 fn from_str(input: &str) -> anyhow::Result<Self> {
111 let (recipient_type, recipient) = input
112 .split_once(";")
113 .ok_or_else(|| anyhow!("expected 'recipient-type; recipient', got {input}"))?;
114 Ok(Self {
115 recipient_type: recipient_type.trim().to_string(),
116 recipient: recipient.trim().to_string(),
117 })
118 }
119}
120
121#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
122pub struct DiagnosticCode {
123 pub diagnostic_type: String,
124 pub diagnostic: String,
125}
126impl FromStr for DiagnosticCode {
127 type Err = anyhow::Error;
128 fn from_str(input: &str) -> anyhow::Result<Self> {
129 let (diagnostic_type, diagnostic) = input
130 .split_once(";")
131 .ok_or_else(|| anyhow!("expected 'diagnostic-type; diagnostic', got {input}"))?;
132 Ok(Self {
133 diagnostic_type: diagnostic_type.trim().to_string(),
134 diagnostic: diagnostic.trim().to_string(),
135 })
136 }
137}
138
139#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
140pub struct PerRecipientReportEntry {
141 pub final_recipient: Recipient,
142 pub action: ReportAction,
143 pub status: ReportStatus,
144 pub original_recipient: Option<Recipient>,
145 pub remote_mta: Option<RemoteMta>,
146 pub diagnostic_code: Option<DiagnosticCode>,
147 pub last_attempt_date: Option<DateTime<Utc>>,
148 pub final_log_id: Option<String>,
149 pub will_retry_until: Option<DateTime<Utc>>,
150 pub extensions: BTreeMap<String, Vec<String>>,
151}
152
153impl PerRecipientReportEntry {
154 fn parse(part: &str) -> anyhow::Result<Self> {
155 let mut extensions = extract_headers(part.as_bytes())?;
156
157 let original_recipient = extract_single("original-recipient", &mut extensions)?;
158 let final_recipient = extract_single_req("final-recipient", &mut extensions)?;
159 let remote_mta = extract_single("remote-mta", &mut extensions)?;
160
161 let last_attempt_date = extract_single_conv::<DateTimeRfc2822, DateTime<Utc>>(
162 "last-attempt-date",
163 &mut extensions,
164 )?;
165 let will_retry_until = extract_single_conv::<DateTimeRfc2822, DateTime<Utc>>(
166 "will-retry-until",
167 &mut extensions,
168 )?;
169 let final_log_id = extract_single("final-log-id", &mut extensions)?;
170
171 let action = extract_single_req("action", &mut extensions)?;
172 let status = extract_single_req("status", &mut extensions)?;
173 let diagnostic_code = extract_single("diagnostic-code", &mut extensions)?;
174
175 Ok(Self {
176 final_recipient,
177 action,
178 status,
179 diagnostic_code,
180 original_recipient,
181 remote_mta,
182 last_attempt_date,
183 final_log_id,
184 will_retry_until,
185 extensions,
186 })
187 }
188}
189
190#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
191pub struct PerMessageReportEntry {
192 pub original_envelope_id: Option<String>,
193 pub reporting_mta: RemoteMta,
194 pub dsn_gateway: Option<RemoteMta>,
195 pub received_from_mta: Option<RemoteMta>,
196 pub arrival_date: Option<DateTime<Utc>>,
197 pub extensions: BTreeMap<String, Vec<String>>,
198}
199
200impl PerMessageReportEntry {
201 fn parse(part: &str) -> anyhow::Result<Self> {
202 let mut extensions = extract_headers(part.as_bytes())?;
203
204 let reporting_mta = extract_single_req("reporting-mta", &mut extensions)?;
205 let original_envelope_id = extract_single("original-envelope-id", &mut extensions)?;
206 let dsn_gateway = extract_single("dsn-gateway", &mut extensions)?;
207 let received_from_mta = extract_single("received-from-mta", &mut extensions)?;
208
209 let arrival_date =
210 extract_single_conv::<DateTimeRfc2822, DateTime<Utc>>("arrival-date", &mut extensions)?;
211
212 Ok(Self {
213 original_envelope_id,
214 reporting_mta,
215 dsn_gateway,
216 received_from_mta,
217 arrival_date,
218 extensions,
219 })
220 }
221}
222
223#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
224pub struct Report {
225 pub per_message: PerMessageReportEntry,
226 pub per_recipient: Vec<PerRecipientReportEntry>,
227 pub original_message: Option<String>,
228}
229
230pub(crate) fn content_type(part: &MimePart) -> Option<String> {
231 let ct = part.headers().content_type().ok()??;
232 Some(ct.value)
233}
234
235impl Report {
236 pub fn parse(input: &[u8]) -> anyhow::Result<Option<Self>> {
237 let mail = MimePart::parse(input).with_context(|| {
238 format!(
239 "Report::parse top; input is {:?}",
240 String::from_utf8_lossy(input)
241 )
242 })?;
243
244 if content_type(&mail).as_deref() != Some("multipart/report") {
245 return Ok(None);
246 }
247
248 let mut original_message = None;
249
250 for part in mail.child_parts() {
251 let ct = content_type(part);
252 let ct = ct.as_deref();
253 if ct == Some("message/rfc822") || ct == Some("text/rfc822-headers") {
254 original_message = Some(part.raw_body().replace("\r\n", "\n"));
255 }
256 }
257
258 for part in mail.child_parts() {
259 let ct = content_type(part);
260 let ct = ct.as_deref();
261 if ct == Some("message/delivery-status") || ct == Some("message/global-delivery-status")
262 {
263 return Ok(Some(Self::parse_inner(part, original_message)?));
264 }
265 }
266
267 anyhow::bail!("delivery-status part missing");
268 }
269
270 fn parse_inner(part: &MimePart, original_message: Option<String>) -> anyhow::Result<Self> {
271 let body = part.body()?.to_string_lossy().replace("\r\n", "\n");
272 let mut parts = body.trim().split("\n\n");
273
274 let per_message = parts
275 .next()
276 .ok_or_else(|| anyhow!("missing per-message section"))?;
277 let per_message = PerMessageReportEntry::parse(per_message)?;
278 let mut per_recipient = vec![];
279 for part in parts {
280 let part = PerRecipientReportEntry::parse(part)?;
281 per_recipient.push(part);
282 }
283
284 Ok(Self {
285 per_message,
286 per_recipient,
287 original_message,
288 })
289 }
290}
291
292#[cfg(test)]
293mod test {
294 use super::*;
295
296 #[test]
297 fn rfc3464_1() {
298 let result = Report::parse(include_bytes!("../data/rfc3464/1.eml")).unwrap();
299 k9::snapshot!(
300 result,
301 r#"
302Some(
303 Report {
304 per_message: PerMessageReportEntry {
305 original_envelope_id: None,
306 reporting_mta: RemoteMta {
307 mta_type: "dns",
308 name: "cs.utk.edu",
309 },
310 dsn_gateway: None,
311 received_from_mta: None,
312 arrival_date: None,
313 extensions: {},
314 },
315 per_recipient: [
316 PerRecipientReportEntry {
317 final_recipient: Recipient {
318 recipient_type: "rfc822",
319 recipient: "louisl@larry.slip.umd.edu",
320 },
321 action: Failed,
322 status: ReportStatus {
323 class: 4,
324 subject: 0,
325 detail: 0,
326 comment: None,
327 },
328 original_recipient: Some(
329 Recipient {
330 recipient_type: "rfc822",
331 recipient: "louisl@larry.slip.umd.edu",
332 },
333 ),
334 remote_mta: None,
335 diagnostic_code: Some(
336 DiagnosticCode {
337 diagnostic_type: "smtp",
338 diagnostic: "426 connection timed out",
339 },
340 ),
341 last_attempt_date: Some(
342 1994-07-07T21:15:49Z,
343 ),
344 final_log_id: None,
345 will_retry_until: None,
346 extensions: {},
347 },
348 ],
349 original_message: Some(
350 "[original message goes here]
351
352",
353 ),
354 },
355)
356"#
357 );
358 }
359
360 #[test]
361 fn rfc3464_2() {
362 let result = Report::parse(include_bytes!("../data/rfc3464/2.eml")).unwrap();
363 k9::snapshot!(
364 result,
365 r#"
366Some(
367 Report {
368 per_message: PerMessageReportEntry {
369 original_envelope_id: None,
370 reporting_mta: RemoteMta {
371 mta_type: "dns",
372 name: "cs.utk.edu",
373 },
374 dsn_gateway: None,
375 received_from_mta: None,
376 arrival_date: None,
377 extensions: {},
378 },
379 per_recipient: [
380 PerRecipientReportEntry {
381 final_recipient: Recipient {
382 recipient_type: "rfc822",
383 recipient: "arathib@vnet.ibm.com",
384 },
385 action: Failed,
386 status: ReportStatus {
387 class: 5,
388 subject: 0,
389 detail: 0,
390 comment: Some(
391 "(permanent failure)",
392 ),
393 },
394 original_recipient: Some(
395 Recipient {
396 recipient_type: "rfc822",
397 recipient: "arathib@vnet.ibm.com",
398 },
399 ),
400 remote_mta: Some(
401 RemoteMta {
402 mta_type: "dns",
403 name: "vnet.ibm.com",
404 },
405 ),
406 diagnostic_code: Some(
407 DiagnosticCode {
408 diagnostic_type: "smtp",
409 diagnostic: "550 'arathib@vnet.IBM.COM' is not a registered gateway user",
410 },
411 ),
412 last_attempt_date: None,
413 final_log_id: None,
414 will_retry_until: None,
415 extensions: {},
416 },
417 PerRecipientReportEntry {
418 final_recipient: Recipient {
419 recipient_type: "rfc822",
420 recipient: "johnh@hpnjld.njd.hp.com",
421 },
422 action: Delayed,
423 status: ReportStatus {
424 class: 4,
425 subject: 0,
426 detail: 0,
427 comment: Some(
428 "(hpnjld.njd.jp.com: host name lookup failure)",
429 ),
430 },
431 original_recipient: Some(
432 Recipient {
433 recipient_type: "rfc822",
434 recipient: "johnh@hpnjld.njd.hp.com",
435 },
436 ),
437 remote_mta: None,
438 diagnostic_code: None,
439 last_attempt_date: None,
440 final_log_id: None,
441 will_retry_until: None,
442 extensions: {},
443 },
444 PerRecipientReportEntry {
445 final_recipient: Recipient {
446 recipient_type: "rfc822",
447 recipient: "wsnell@sdcc13.ucsd.edu",
448 },
449 action: Failed,
450 status: ReportStatus {
451 class: 5,
452 subject: 0,
453 detail: 0,
454 comment: None,
455 },
456 original_recipient: Some(
457 Recipient {
458 recipient_type: "rfc822",
459 recipient: "wsnell@sdcc13.ucsd.edu",
460 },
461 ),
462 remote_mta: Some(
463 RemoteMta {
464 mta_type: "dns",
465 name: "sdcc13.ucsd.edu",
466 },
467 ),
468 diagnostic_code: Some(
469 DiagnosticCode {
470 diagnostic_type: "smtp",
471 diagnostic: "550 user unknown",
472 },
473 ),
474 last_attempt_date: None,
475 final_log_id: None,
476 will_retry_until: None,
477 extensions: {},
478 },
479 ],
480 original_message: Some(
481 "[original message goes here]
482
483",
484 ),
485 },
486)
487"#
488 );
489 }
490
491 #[test]
492 fn rfc3464_3() {
493 let result = Report::parse(include_bytes!("../data/rfc3464/3.eml")).unwrap();
494 k9::snapshot!(
495 result,
496 r#"
497Some(
498 Report {
499 per_message: PerMessageReportEntry {
500 original_envelope_id: None,
501 reporting_mta: RemoteMta {
502 mta_type: "mailbus",
503 name: "SYS30",
504 },
505 dsn_gateway: None,
506 received_from_mta: None,
507 arrival_date: None,
508 extensions: {},
509 },
510 per_recipient: [
511 PerRecipientReportEntry {
512 final_recipient: Recipient {
513 recipient_type: "unknown",
514 recipient: "nair_s",
515 },
516 action: Failed,
517 status: ReportStatus {
518 class: 5,
519 subject: 0,
520 detail: 0,
521 comment: Some(
522 "(unknown permanent failure)",
523 ),
524 },
525 original_recipient: None,
526 remote_mta: None,
527 diagnostic_code: None,
528 last_attempt_date: None,
529 final_log_id: None,
530 will_retry_until: None,
531 extensions: {},
532 },
533 ],
534 original_message: None,
535 },
536)
537"#
538 );
539 }
540
541 #[test]
542 fn rfc3464_4() {
543 let result = Report::parse(include_bytes!("../data/rfc3464/4.eml")).unwrap();
544 k9::snapshot!(
545 result,
546 r#"
547Some(
548 Report {
549 per_message: PerMessageReportEntry {
550 original_envelope_id: None,
551 reporting_mta: RemoteMta {
552 mta_type: "dns",
553 name: "sun2.nsfnet-relay.ac.uk",
554 },
555 dsn_gateway: None,
556 received_from_mta: None,
557 arrival_date: None,
558 extensions: {},
559 },
560 per_recipient: [
561 PerRecipientReportEntry {
562 final_recipient: Recipient {
563 recipient_type: "rfc822",
564 recipient: "thomas@de-montfort.ac.uk",
565 },
566 action: Delayed,
567 status: ReportStatus {
568 class: 4,
569 subject: 0,
570 detail: 0,
571 comment: Some(
572 "(unknown temporary failure)",
573 ),
574 },
575 original_recipient: None,
576 remote_mta: None,
577 diagnostic_code: None,
578 last_attempt_date: None,
579 final_log_id: None,
580 will_retry_until: None,
581 extensions: {},
582 },
583 ],
584 original_message: None,
585 },
586)
587"#
588 );
589 }
590
591 #[test]
592 fn rfc3464_5() {
593 let result = Report::parse(include_bytes!("../data/rfc3464/5.eml")).unwrap();
594 k9::snapshot!(
595 result,
596 r#"
597Some(
598 Report {
599 per_message: PerMessageReportEntry {
600 original_envelope_id: None,
601 reporting_mta: RemoteMta {
602 mta_type: "dns",
603 name: "mx-by.bbox.fr",
604 },
605 dsn_gateway: None,
606 received_from_mta: None,
607 arrival_date: Some(
608 2025-01-29T16:36:51Z,
609 ),
610 extensions: {
611 "x-postfix-queue-id": [
612 "897DAC0",
613 ],
614 "x-postfix-sender": [
615 "rfc822; user@example.com",
616 ],
617 },
618 },
619 per_recipient: [
620 PerRecipientReportEntry {
621 final_recipient: Recipient {
622 recipient_type: "rfc822",
623 recipient: "recipient@domain.com",
624 },
625 action: Failed,
626 status: ReportStatus {
627 class: 5,
628 subject: 0,
629 detail: 0,
630 comment: None,
631 },
632 original_recipient: Some(
633 Recipient {
634 recipient_type: "rfc822",
635 recipient: "recipient@domain.com",
636 },
637 ),
638 remote_mta: Some(
639 RemoteMta {
640 mta_type: "dns",
641 name: "lmtp.cs.dolmen.bouyguestelecom.fr",
642 },
643 ),
644 diagnostic_code: Some(
645 DiagnosticCode {
646 diagnostic_type: "smtp",
647 diagnostic: "552 <recipient@domain.com> rejected: over quota",
648 },
649 ),
650 last_attempt_date: None,
651 final_log_id: None,
652 will_retry_until: None,
653 extensions: {},
654 },
655 ],
656 original_message: Some(
657 "[original message goes here]
658
659",
660 ),
661 },
662)
663"#
664 );
665 }
666}