kumo_log_types/
rfc3464.rs

1//! This module parses out RFC3464 delivery status reports
2//! from an email message
3use 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}