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 crate::{JsonLogRecord, RecordType};
7use anyhow::{anyhow, Context};
8use bstr::{BStr, BString, ByteSlice};
9use chrono::{DateTime, Utc};
10use mailparsing::{format_rfc2822_date, BStringUtf8, MimePart};
11use rfc5321::parser::EnvelopeAddress;
12use serde::{Deserialize, Serialize};
13use serde_with::serde_as;
14use std::collections::BTreeMap;
15use std::str::FromStr;
16
17#[derive(Debug, Serialize, Deserialize, Copy, Clone, Eq, PartialEq)]
18#[serde(rename_all = "lowercase")]
19pub enum ReportAction {
20    Failed,
21    Delayed,
22    Delivered,
23    Relayed,
24    Expanded,
25}
26
27impl std::fmt::Display for ReportAction {
28    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
29        let label = match self {
30            Self::Failed => "failed",
31            Self::Delayed => "delayed",
32            Self::Delivered => "delivered",
33            Self::Relayed => "relayed",
34            Self::Expanded => "expanded",
35        };
36        write!(fmt, "{label}")
37    }
38}
39
40impl FromStr for ReportAction {
41    type Err = anyhow::Error;
42    fn from_str(input: &str) -> anyhow::Result<Self> {
43        Ok(match input {
44            "failed" => Self::Failed,
45            "delayed" => Self::Delayed,
46            "delivered" => Self::Delivered,
47            "relayed" => Self::Relayed,
48            "expanded" => Self::Expanded,
49            _ => anyhow::bail!("invalid action type {input}"),
50        })
51    }
52}
53
54#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
55pub struct ReportStatus {
56    pub class: u8,
57    pub subject: u16,
58    pub detail: u16,
59    pub comment: Option<String>,
60}
61
62impl std::fmt::Display for ReportStatus {
63    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
64        write!(fmt, "{}.{}.{}", self.class, self.subject, self.detail)?;
65        if let Some(comment) = &self.comment {
66            write!(fmt, " {comment}")?;
67        }
68        Ok(())
69    }
70}
71
72impl From<&rfc5321::Response> for ReportStatus {
73    fn from(response: &rfc5321::Response) -> ReportStatus {
74        let (class, subject, detail) = match &response.enhanced_code {
75            Some(enh) => (enh.class, enh.subject, enh.detail),
76            None => {
77                if response.code >= 500 {
78                    (5, 0, 0)
79                } else if response.code >= 400 {
80                    (4, 0, 0)
81                } else if response.code >= 200 && response.code < 300 {
82                    (2, 0, 0)
83                } else {
84                    (4, 0, 0)
85                }
86            }
87        };
88        ReportStatus {
89            class,
90            subject,
91            detail,
92            comment: Some(response.content.clone()),
93        }
94    }
95}
96
97impl FromStr for ReportStatus {
98    type Err = anyhow::Error;
99    fn from_str(input: &str) -> anyhow::Result<Self> {
100        let mut parts: Vec<_> = input.split(' ').collect();
101
102        let mut status = parts[0].split('.');
103        let class = status
104            .next()
105            .ok_or_else(|| anyhow!("invalid Status: {input}"))?
106            .parse()
107            .context("parsing status.class")?;
108        let subject = status
109            .next()
110            .ok_or_else(|| anyhow!("invalid Status: {input}"))?
111            .parse()
112            .context("parsing status.subject")?;
113        let detail = status
114            .next()
115            .ok_or_else(|| anyhow!("invalid Status: {input}"))?
116            .parse()
117            .context("parsing status.detail")?;
118
119        parts.remove(0);
120        let comment = if parts.is_empty() {
121            None
122        } else {
123            Some(parts.join(" "))
124        };
125
126        Ok(Self {
127            class,
128            subject,
129            detail,
130            comment,
131        })
132    }
133}
134
135#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
136pub struct RemoteMta {
137    pub mta_type: String,
138    pub name: String,
139}
140
141impl std::fmt::Display for RemoteMta {
142    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
143        write!(fmt, "{}; {}", self.mta_type, self.name)
144    }
145}
146
147impl FromStr for RemoteMta {
148    type Err = anyhow::Error;
149
150    fn from_str(input: &str) -> anyhow::Result<Self> {
151        let (mta_type, name) = input
152            .split_once(";")
153            .ok_or_else(|| anyhow!("expected 'name-type; name', got {input}"))?;
154        Ok(Self {
155            mta_type: mta_type.trim().to_string(),
156            name: name.trim().to_string(),
157        })
158    }
159}
160
161#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
162pub struct Recipient {
163    pub recipient_type: String,
164    pub recipient: String,
165}
166
167impl std::fmt::Display for Recipient {
168    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
169        write!(fmt, "{};{}", self.recipient_type, self.recipient)
170    }
171}
172
173impl FromStr for Recipient {
174    type Err = anyhow::Error;
175    fn from_str(input: &str) -> anyhow::Result<Self> {
176        let (recipient_type, recipient) = input
177            .split_once(";")
178            .ok_or_else(|| anyhow!("expected 'recipient-type; recipient', got {input}"))?;
179
180        let recipient = if recipient_type == "rfc822" {
181            recipient
182                .trim()
183                .parse::<EnvelopeAddress>()
184                .map_err(|err| anyhow!("{err}"))?
185                .to_string()
186        } else {
187            recipient.trim().to_string()
188        };
189
190        Ok(Self {
191            recipient_type: recipient_type.trim().to_string(),
192            recipient,
193        })
194    }
195}
196
197#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
198pub struct DiagnosticCode {
199    pub diagnostic_type: String,
200    pub diagnostic: String,
201}
202
203impl std::fmt::Display for DiagnosticCode {
204    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
205        write!(fmt, "{}; {}", self.diagnostic_type, self.diagnostic)
206    }
207}
208
209impl FromStr for DiagnosticCode {
210    type Err = anyhow::Error;
211    fn from_str(input: &str) -> anyhow::Result<Self> {
212        let (diagnostic_type, diagnostic) = input
213            .split_once(";")
214            .ok_or_else(|| anyhow!("expected 'diagnostic-type; diagnostic', got {input}"))?;
215        Ok(Self {
216            diagnostic_type: diagnostic_type.trim().to_string(),
217            diagnostic: diagnostic.trim().to_string(),
218        })
219    }
220}
221
222#[serde_as]
223#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
224pub struct PerRecipientReportEntry {
225    pub final_recipient: Recipient,
226    pub action: ReportAction,
227    pub status: ReportStatus,
228    pub original_recipient: Option<Recipient>,
229    pub remote_mta: Option<RemoteMta>,
230    pub diagnostic_code: Option<DiagnosticCode>,
231    pub last_attempt_date: Option<DateTime<Utc>>,
232    pub final_log_id: Option<String>,
233    pub will_retry_until: Option<DateTime<Utc>>,
234    #[serde_as(as = "BTreeMap<_, Vec<BStringUtf8>>")]
235    pub extensions: BTreeMap<String, Vec<BString>>,
236}
237
238impl std::fmt::Display for PerRecipientReportEntry {
239    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
240        if let Some(orig) = &self.original_recipient {
241            write!(fmt, "Original-Recipient: {orig}\r\n")?;
242        }
243        write!(fmt, "Final-Recipient: {}\r\n", self.final_recipient)?;
244        write!(fmt, "Action: {}\r\n", self.action)?;
245        write!(fmt, "Status: {}\r\n", self.status)?;
246        if let Some(mta) = &self.remote_mta {
247            write!(fmt, "Remote-MTA: {mta}\r\n")?;
248        }
249        if let Some(code) = &self.diagnostic_code {
250            write!(fmt, "Diagnostic-Code: {code}\r\n")?;
251        }
252        if let Some(when) = &self.last_attempt_date {
253            write!(fmt, "Last-Attempt-Date: {}\r\n", format_rfc2822_date(*when))?;
254        }
255        if let Some(id) = &self.final_log_id {
256            write!(fmt, "Final-Log-Id: {id}\r\n")?;
257        }
258        if let Some(when) = &self.will_retry_until {
259            write!(fmt, "Will-Retry-Until: {}\r\n", format_rfc2822_date(*when))?;
260        }
261        for (k, vlist) in &self.extensions {
262            for v in vlist {
263                write!(fmt, "{k}: {v}\r\n")?;
264            }
265        }
266        Ok(())
267    }
268}
269
270impl PerRecipientReportEntry {
271    fn parse(part: &str) -> anyhow::Result<Self> {
272        let mut extensions = extract_headers(part.as_bytes())?;
273
274        let original_recipient = extract_single("original-recipient", &mut extensions)?;
275        let final_recipient = extract_single_req("final-recipient", &mut extensions)?;
276        let remote_mta = extract_single("remote-mta", &mut extensions)?;
277
278        let last_attempt_date = extract_single_conv::<DateTimeRfc2822, DateTime<Utc>>(
279            "last-attempt-date",
280            &mut extensions,
281        )?;
282        let will_retry_until = extract_single_conv::<DateTimeRfc2822, DateTime<Utc>>(
283            "will-retry-until",
284            &mut extensions,
285        )?;
286        let final_log_id = extract_single("final-log-id", &mut extensions)?;
287
288        let action = extract_single_req("action", &mut extensions)?;
289        let status = extract_single_req("status", &mut extensions)?;
290        let diagnostic_code = extract_single("diagnostic-code", &mut extensions)?;
291
292        Ok(Self {
293            final_recipient,
294            action,
295            status,
296            diagnostic_code,
297            original_recipient,
298            remote_mta,
299            last_attempt_date,
300            final_log_id,
301            will_retry_until,
302            extensions,
303        })
304    }
305}
306
307#[serde_as]
308#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
309pub struct PerMessageReportEntry {
310    pub original_envelope_id: Option<String>,
311    pub reporting_mta: RemoteMta,
312    pub dsn_gateway: Option<RemoteMta>,
313    pub received_from_mta: Option<RemoteMta>,
314    pub arrival_date: Option<DateTime<Utc>>,
315    #[serde_as(as = "BTreeMap<_, Vec<BStringUtf8>>")]
316    pub extensions: BTreeMap<String, Vec<BString>>,
317}
318
319impl std::fmt::Display for PerMessageReportEntry {
320    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
321        write!(fmt, "Reporting-MTA: {}\r\n", self.reporting_mta)?;
322        if let Some(id) = &self.original_envelope_id {
323            write!(fmt, "Original-Envelope-Id: {id}\r\n")?;
324        }
325        if let Some(dsn_gateway) = &self.dsn_gateway {
326            write!(fmt, "DSN-Gateway: {dsn_gateway}\r\n")?;
327        }
328        if let Some(mta) = &self.received_from_mta {
329            write!(fmt, "Received-From-MTA: {mta}\r\n")?;
330        }
331        if let Some(when) = &self.arrival_date {
332            write!(fmt, "Arrival-Date: {}\r\n", format_rfc2822_date(*when))?;
333        }
334        for (k, vlist) in &self.extensions {
335            for v in vlist {
336                write!(fmt, "{k}: {v}\r\n")?;
337            }
338        }
339
340        Ok(())
341    }
342}
343
344impl PerMessageReportEntry {
345    fn parse(part: &str) -> anyhow::Result<Self> {
346        let mut extensions = extract_headers(part.as_bytes())?;
347
348        let reporting_mta = extract_single_req("reporting-mta", &mut extensions)?;
349        let original_envelope_id = extract_single("original-envelope-id", &mut extensions)?;
350        let dsn_gateway = extract_single("dsn-gateway", &mut extensions)?;
351        let received_from_mta = extract_single("received-from-mta", &mut extensions)?;
352
353        let arrival_date =
354            extract_single_conv::<DateTimeRfc2822, DateTime<Utc>>("arrival-date", &mut extensions)?;
355
356        Ok(Self {
357            original_envelope_id,
358            reporting_mta,
359            dsn_gateway,
360            received_from_mta,
361            arrival_date,
362            extensions,
363        })
364    }
365}
366
367#[serde_as]
368#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
369pub struct Report {
370    pub per_message: PerMessageReportEntry,
371    pub per_recipient: Vec<PerRecipientReportEntry>,
372    #[serde_as(as = "Option<BStringUtf8>")]
373    pub original_message: Option<BString>,
374}
375
376pub(crate) fn content_type(part: &MimePart) -> Option<BString> {
377    let ct = part.headers().content_type().ok()??;
378    Some(ct.value)
379}
380
381impl Report {
382    pub fn parse(input: &[u8]) -> anyhow::Result<Option<Self>> {
383        let mail = MimePart::parse(input).with_context(|| {
384            format!(
385                "Report::parse top; input is {:?}",
386                String::from_utf8_lossy(input)
387            )
388        })?;
389
390        if content_type(&mail).as_ref().map(|b| b.as_bstr()) != Some(BStr::new("multipart/report"))
391        {
392            return Ok(None);
393        }
394
395        let mut original_message = None;
396
397        for part in mail.child_parts() {
398            let ct = content_type(part);
399            let ct = ct.as_ref().map(|b| b.as_bstr());
400            if ct == Some(BStr::new("message/rfc822"))
401                || ct == Some(BStr::new("text/rfc822-headers"))
402            {
403                original_message = Some(BString::new(
404                    part.raw_body().as_bytes().replace(b"\r\n", b"\n"),
405                ));
406            }
407        }
408
409        for part in mail.child_parts() {
410            let ct = content_type(part);
411            let ct = ct.as_ref().map(|b| b.as_bstr());
412            if ct == Some(BStr::new("message/delivery-status"))
413                || ct == Some(BStr::new("message/global-delivery-status"))
414            {
415                return Ok(Some(Self::parse_inner(part, original_message)?));
416            }
417        }
418
419        anyhow::bail!("delivery-status part missing");
420    }
421
422    fn parse_inner(part: &MimePart, original_message: Option<BString>) -> anyhow::Result<Self> {
423        let body = part.body()?.to_string_lossy().replace("\r\n", "\n");
424        let mut parts = body.trim().split("\n\n");
425
426        let per_message = parts
427            .next()
428            .ok_or_else(|| anyhow!("missing per-message section"))?;
429        let per_message = PerMessageReportEntry::parse(per_message)?;
430        let mut per_recipient = vec![];
431        for part in parts {
432            let part = PerRecipientReportEntry::parse(part)?;
433            per_recipient.push(part);
434        }
435
436        Ok(Self {
437            per_message,
438            per_recipient,
439            original_message,
440        })
441    }
442
443    /// msg: the message that experienced an issue
444    /// log: the corresponding log record from the issue
445    pub fn generate(
446        params: &ReportGenerationParams,
447        msg: Option<&MimePart<'_>>,
448        log: &JsonLogRecord,
449    ) -> anyhow::Result<Option<MimePart<'static>>> {
450        if log.sender.is_empty() {
451            // Cannot send a bounce back to the null sender
452            return Ok(None);
453        }
454
455        let action = match &log.kind {
456            RecordType::Bounce
457                if params.enable_bounce && log.delivery_protocol.as_deref() == Some("ESMTP") =>
458            {
459                ReportAction::Failed
460            }
461            RecordType::Expiration if params.enable_expiration => ReportAction::Failed,
462            _ => return Ok(None),
463        };
464
465        let created = format_rfc2822_date(log.created);
466
467        let arrival_date = Some(log.created);
468
469        let per_message = PerMessageReportEntry {
470            arrival_date,
471            dsn_gateway: None,
472            extensions: Default::default(),
473            original_envelope_id: None,
474            received_from_mta: None,
475            reporting_mta: params.reporting_mta.clone(),
476        };
477
478        let mut per_recipient = vec![];
479        let recip_list = log.recipient.join(", ");
480
481        for recip in &log.recipient {
482            per_recipient.push(PerRecipientReportEntry {
483                action,
484                extensions: Default::default(),
485                status: (&log.response).into(),
486                diagnostic_code: Some(DiagnosticCode {
487                    diagnostic_type: "smtp".into(),
488                    diagnostic: log.response.to_single_line(),
489                }),
490                final_log_id: None,
491                original_recipient: None,
492                final_recipient: Recipient {
493                    recipient_type: "rfc822".to_string(),
494                    recipient: recip.to_string(),
495                },
496                remote_mta: log.peer_address.as_ref().map(|addr| RemoteMta {
497                    mta_type: "dns".to_string(),
498                    name: addr.name.to_string(),
499                }),
500                last_attempt_date: Some(log.timestamp),
501                will_retry_until: None,
502            });
503        }
504
505        let mut parts = vec![];
506
507        let exposition = match &log.kind {
508            RecordType::Bounce => {
509                let mut data = format!(
510                    "The message was received at {created}\r\n\
511                    from {sender} and addressed to {recip_list}.\r\n\
512                    ",
513                    sender = log.sender,
514                );
515                if let Some(peer) = &log.peer_address {
516                    data.push_str(&format!(
517                        "While communicating with {host} ({ip}):\r\n\
518                        Response: {resp}\r\n",
519                        host = peer.name,
520                        ip = peer.addr,
521                        resp = log.response.to_single_line(),
522                    ));
523                } else {
524                    data.push_str(&format!("Status: {}\r\n", log.response.to_single_line()));
525                }
526
527                data.push_str(
528                    "\r\nThe message will be deleted from the queue.\r\n\
529                    No further attempts will be made to deliver it.\r\n",
530                );
531
532                data
533            }
534            RecordType::Expiration => {
535                format!(
536                    "The message was received at {created}\r\n\
537                    from {sender} and addressed to {recip_list}.\r\n\
538                    Status: {status}\r\n\
539                    The message will be deleted from the queue.\r\n\
540                    No further attempts will be made to deliver it.\r\n\
541                    ",
542                    sender = log.sender,
543                    status = log.response.to_single_line()
544                )
545            }
546            _ => unreachable!(),
547        };
548
549        parts.push(MimePart::new_text_plain(&*exposition).context("new_text_plain")?);
550
551        let mut status_text = format!("{per_message}\r\n");
552        for per_recip in per_recipient {
553            status_text.push_str(&format!("{per_recip}\r\n"));
554        }
555        parts.push(
556            MimePart::new_text("message/delivery-status", &*status_text).context("new_text")?,
557        );
558
559        match (params.include_original_message, msg) {
560            (IncludeOriginalMessage::No, _) | (_, None) => {}
561            (IncludeOriginalMessage::HeadersOnly, Some(msg)) => {
562                let mut data = vec![];
563                for hdr in msg.headers().iter() {
564                    hdr.write_header(&mut data).ok();
565                }
566                parts.push(
567                    MimePart::new_no_transfer_encoding("text/rfc822-headers", &data)
568                        .context("new_no_transfer_encoding")?,
569                );
570            }
571            (IncludeOriginalMessage::FullContent, Some(msg)) => {
572                let mut data = vec![];
573                msg.write_message(&mut data).ok();
574                parts.push(
575                    MimePart::new_no_transfer_encoding("message/rfc822", &data)
576                        .context("new_no_transfer_encoding")?,
577                );
578            }
579        };
580
581        let mut report_msg = MimePart::new_multipart(
582            "multipart/report",
583            parts,
584            if params.stable_content {
585                Some(b"report-boundary")
586            } else {
587                None
588            },
589        )?;
590
591        let mut ct = report_msg
592            .headers()
593            .content_type()
594            .context("get content_type")?
595            .expect("assigned during construction");
596        ct.set("report-type", "delivery-status");
597        report_msg
598            .headers_mut()
599            .set_content_type(ct)
600            .context("set_content_type")?;
601        report_msg
602            .headers_mut()
603            .set_subject("Returned mail")
604            .context("set_subject")?;
605        report_msg
606            .headers_mut()
607            .set_mime_version("1.0")
608            .context("set_mime_version")?;
609
610        let message_id = if params.stable_content {
611            format!("<UUID@{}>", params.reporting_mta.name)
612        } else {
613            let id = uuid_helper::now_v1();
614            format!("<{id}@{}>", params.reporting_mta.name)
615        };
616        report_msg
617            .headers_mut()
618            .set_message_id(message_id.as_str())?;
619        report_msg
620            .headers_mut()
621            .set_to(log.sender.as_str())
622            .context("set_to")?;
623
624        let from = format!(
625            "Mail Delivery Subsystem <mailer-daemon@{}>",
626            params.reporting_mta.name
627        );
628        report_msg
629            .headers_mut()
630            .set_from(from.as_str())
631            .context("set_from")?;
632
633        Ok(Some(report_msg))
634    }
635}
636
637#[derive(Default, Debug, PartialEq, Clone, Copy, Deserialize)]
638pub enum IncludeOriginalMessage {
639    #[default]
640    No,
641    HeadersOnly,
642    FullContent,
643}
644
645#[derive(Debug, PartialEq, Clone, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct ReportGenerationParams {
648    pub include_original_message: IncludeOriginalMessage,
649    #[serde(default)]
650    pub enable_expiration: bool,
651    #[serde(default)]
652    pub enable_bounce: bool,
653    // If we decide to allow generating for delays in the future,
654    // we'll probably add `enable_delay` here, but we'll also need
655    // to have some kind of discriminating logic to decide when
656    // to emit a DSN; probably should be a list of num_attempts
657    // on which to emit? This is too fiddly to design for right
658    // now, considering that none of our target userbase will
659    // emit DSNs for delayed mail.
660    pub reporting_mta: RemoteMta,
661
662    /// When used for testing, use a stable mime boundary
663    #[serde(default)]
664    pub stable_content: bool,
665}
666
667#[cfg(test)]
668mod test {
669    use super::*;
670    use crate::ResolvedAddress;
671    use rfc5321::{EnhancedStatusCode, Response};
672
673    #[test]
674    fn parse_ses_obsolete_arrival_date() {
675        // Amazon SES OOB bounces carry an Arrival-Date in an obsolete RFC 2822
676        // form ("Thu, 02 Jul 26 18:55:38 UTC"). This must not fail the report;
677        // the arrival_date below shows it is recovered rather than dropped.
678        let report = Report::parse(include_bytes!("../data/rfc3464/obsolete_arrival_date.eml"))
679            .unwrap()
680            .expect("multipart/report DSN should parse as a report");
681        k9::snapshot!(
682            &report,
683            r#"
684Report {
685    per_message: PerMessageReportEntry {
686        original_envelope_id: None,
687        reporting_mta: RemoteMta {
688            mta_type: "dns",
689            name: "mx.example.com",
690        },
691        dsn_gateway: None,
692        received_from_mta: None,
693        arrival_date: Some(
694            2026-07-02T18:55:38Z,
695        ),
696        extensions: {},
697    },
698    per_recipient: [
699        PerRecipientReportEntry {
700            final_recipient: Recipient {
701                recipient_type: "rfc822",
702                recipient: "user@example.com",
703            },
704            action: Failed,
705            status: ReportStatus {
706                class: 5,
707                subject: 1,
708                detail: 1,
709                comment: None,
710            },
711            original_recipient: Some(
712                Recipient {
713                    recipient_type: "rfc822",
714                    recipient: "user@example.com",
715                },
716            ),
717            remote_mta: None,
718            diagnostic_code: Some(
719                DiagnosticCode {
720                    diagnostic_type: "smtp",
721                    diagnostic: "550 5.1.1 Mailbox does not exist",
722                },
723            ),
724            last_attempt_date: None,
725            final_log_id: None,
726            will_retry_until: None,
727            extensions: {},
728        },
729    ],
730    original_message: None,
731}
732"#
733        );
734    }
735
736    fn make_message() -> MimePart<'static> {
737        let mut part = MimePart::new_text_plain("hello there").unwrap();
738        part.headers_mut().set_subject("Hello!").unwrap();
739
740        part
741    }
742
743    fn make_bounce() -> JsonLogRecord {
744        let nodeid = uuid_helper::now_v1();
745        let created = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
746        let now = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
747        JsonLogRecord {
748            kind: RecordType::Bounce,
749            id: "ID".to_string(),
750            nodeid,
751            created: created.into(),
752            bounce_classification: Default::default(),
753            delivery_protocol: Some("ESMTP".to_string()),
754            egress_pool: None,
755            egress_source: None,
756            feedback_report: None,
757            headers: Default::default(),
758            meta: Default::default(),
759            num_attempts: 1,
760            peer_address: Some(ResolvedAddress {
761                name: "target.example.com".to_string(),
762                addr: "42.42.42.42".to_string().try_into().unwrap(),
763                is_secure: false,
764            }),
765            provider_name: None,
766            queue: "target.example.com".to_string(),
767            reception_protocol: None,
768            recipient: vec!["recip@target.example.com".to_string()],
769            sender: "sender@sender.example.com".to_string(),
770            session_id: None,
771            response: Response {
772                code: 550,
773                command: None,
774                content: "no thanks".to_string(),
775                enhanced_code: Some(EnhancedStatusCode {
776                    class: 5,
777                    subject: 7,
778                    detail: 1,
779                }),
780            },
781            site: "some-site".to_string(),
782            size: 0,
783            source_address: None,
784            timestamp: now.into(),
785            tls_cipher: None,
786            tls_peer_subject_name: None,
787            tls_protocol_version: None,
788        }
789    }
790
791    fn make_expiration() -> JsonLogRecord {
792        let nodeid = uuid_helper::now_v1();
793        let created = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
794        let now = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
795        JsonLogRecord {
796            kind: RecordType::Expiration,
797            id: "ID".to_string(),
798            nodeid,
799            created: created.into(),
800            bounce_classification: Default::default(),
801            delivery_protocol: None,
802            egress_pool: None,
803            egress_source: None,
804            feedback_report: None,
805            headers: Default::default(),
806            meta: Default::default(),
807            num_attempts: 3,
808            peer_address: None,
809            provider_name: None,
810            queue: "target.example.com".to_string(),
811            reception_protocol: None,
812            recipient: vec!["recip@target.example.com".to_string()],
813            sender: "sender@sender.example.com".to_string(),
814            session_id: None,
815            response: Response {
816                code: 551,
817                command: None,
818                content: "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling".to_string(),
819                enhanced_code: Some(EnhancedStatusCode {
820                    class: 5,
821                    subject: 4,
822                    detail: 7,
823                }),
824            },
825            site: "".to_string(),
826            size: 0,
827            source_address: None,
828            timestamp: now.into(),
829            tls_cipher: None,
830            tls_peer_subject_name: None,
831            tls_protocol_version: None,
832        }
833    }
834
835    #[test]
836    fn generate_expiration_with_headers() {
837        let params = ReportGenerationParams {
838            reporting_mta: RemoteMta {
839                mta_type: "dns".to_string(),
840                name: "mta1.example.com".to_string(),
841            },
842            enable_bounce: false,
843            enable_expiration: true,
844            include_original_message: IncludeOriginalMessage::HeadersOnly,
845            stable_content: true,
846        };
847
848        let original_msg = make_message();
849
850        let log = make_expiration();
851
852        let report_msg = Report::generate(&params, Some(&original_msg), &log)
853            .unwrap()
854            .unwrap();
855        let report_eml = BString::from(report_msg.to_message_bytes());
856        k9::snapshot!(
857            &report_eml,
858            r#"
859Content-Type: multipart/report;\r
860\tboundary="report-boundary";\r
861\treport-type="delivery-status"\r
862Subject: Returned mail\r
863Mime-Version: 1.0\r
864Message-ID: <UUID@mta1.example.com>\r
865To: sender@sender.example.com\r
866From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
867\r
868--report-boundary\r
869Content-Type: text/plain;\r
870\tcharset="us-ascii"\r
871Content-Transfer-Encoding: quoted-printable\r
872\r
873The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
874from sender@sender.example.com and addressed to recip@target.example.com.\r
875Status: 551 5.4.7 Next delivery time would be at SOME TIME which exceeds th=\r
876e expiry time EXPIRES configured via set_scheduling\r
877The message will be deleted from the queue.\r
878No further attempts will be made to deliver it.\r
879--report-boundary\r
880Content-Type: message/delivery-status;\r
881\tcharset="us-ascii"\r
882Content-Transfer-Encoding: quoted-printable\r
883\r
884Reporting-MTA: dns; mta1.example.com\r
885Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
886\r
887Final-Recipient: rfc822;recip@target.example.com\r
888Action: failed\r
889Status: 5.4.7 Next delivery time would be at SOME TIME which exceeds the ex=\r
890piry time EXPIRES configured via set_scheduling\r
891Diagnostic-Code: smtp; 551 5.4.7 Next delivery time would be at SOME TIME w=\r
892hich exceeds the expiry time EXPIRES configured via set_scheduling\r
893Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
894\r
895--report-boundary\r
896Content-Type: text/rfc822-headers\r
897\r
898Content-Type: text/plain;\r
899\tcharset="us-ascii"\r
900Subject: Hello!\r
901--report-boundary--\r
902
903"#
904        );
905
906        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
907        k9::snapshot!(
908            &round_trip,
909            r#"
910Report {
911    per_message: PerMessageReportEntry {
912        original_envelope_id: None,
913        reporting_mta: RemoteMta {
914            mta_type: "dns",
915            name: "mta1.example.com",
916        },
917        dsn_gateway: None,
918        received_from_mta: None,
919        arrival_date: Some(
920            2003-07-01T08:52:37Z,
921        ),
922        extensions: {},
923    },
924    per_recipient: [
925        PerRecipientReportEntry {
926            final_recipient: Recipient {
927                recipient_type: "rfc822",
928                recipient: "recip@target.example.com",
929            },
930            action: Failed,
931            status: ReportStatus {
932                class: 5,
933                subject: 4,
934                detail: 7,
935                comment: Some(
936                    "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
937                ),
938            },
939            original_recipient: None,
940            remote_mta: None,
941            diagnostic_code: Some(
942                DiagnosticCode {
943                    diagnostic_type: "smtp",
944                    diagnostic: "551 5.4.7 Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
945                },
946            ),
947            last_attempt_date: Some(
948                2003-07-01T10:52:37Z,
949            ),
950            final_log_id: None,
951            will_retry_until: None,
952            extensions: {},
953        },
954    ],
955    original_message: Some(
956        "Content-Type: text/plain;
957\tcharset="us-ascii"
958Subject: Hello!
959",
960    ),
961}
962"#
963        );
964    }
965
966    #[test]
967    fn generate_bounce_with_headers() {
968        let params = ReportGenerationParams {
969            reporting_mta: RemoteMta {
970                mta_type: "dns".to_string(),
971                name: "mta1.example.com".to_string(),
972            },
973            enable_bounce: true,
974            enable_expiration: true,
975            include_original_message: IncludeOriginalMessage::HeadersOnly,
976            stable_content: true,
977        };
978
979        let original_msg = make_message();
980
981        let log = make_bounce();
982
983        let report_msg = Report::generate(&params, Some(&original_msg), &log)
984            .unwrap()
985            .unwrap();
986        let report_eml = BString::from(report_msg.to_message_bytes());
987        k9::snapshot!(
988            &report_eml,
989            r#"
990Content-Type: multipart/report;\r
991\tboundary="report-boundary";\r
992\treport-type="delivery-status"\r
993Subject: Returned mail\r
994Mime-Version: 1.0\r
995Message-ID: <UUID@mta1.example.com>\r
996To: sender@sender.example.com\r
997From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
998\r
999--report-boundary\r
1000Content-Type: text/plain;\r
1001\tcharset="us-ascii"\r
1002\r
1003The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1004from sender@sender.example.com and addressed to recip@target.example.com.\r
1005While communicating with target.example.com (42.42.42.42):\r
1006Response: 550 5.7.1 no thanks\r
1007\r
1008The message will be deleted from the queue.\r
1009No further attempts will be made to deliver it.\r
1010--report-boundary\r
1011Content-Type: message/delivery-status;\r
1012\tcharset="us-ascii"\r
1013\r
1014Reporting-MTA: dns; mta1.example.com\r
1015Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1016\r
1017Final-Recipient: rfc822;recip@target.example.com\r
1018Action: failed\r
1019Status: 5.7.1 no thanks\r
1020Remote-MTA: dns; target.example.com\r
1021Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1022Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1023\r
1024--report-boundary\r
1025Content-Type: text/rfc822-headers\r
1026\r
1027Content-Type: text/plain;\r
1028\tcharset="us-ascii"\r
1029Subject: Hello!\r
1030--report-boundary--\r
1031
1032"#
1033        );
1034
1035        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1036        k9::snapshot!(
1037            &round_trip,
1038            r#"
1039Report {
1040    per_message: PerMessageReportEntry {
1041        original_envelope_id: None,
1042        reporting_mta: RemoteMta {
1043            mta_type: "dns",
1044            name: "mta1.example.com",
1045        },
1046        dsn_gateway: None,
1047        received_from_mta: None,
1048        arrival_date: Some(
1049            2003-07-01T08:52:37Z,
1050        ),
1051        extensions: {},
1052    },
1053    per_recipient: [
1054        PerRecipientReportEntry {
1055            final_recipient: Recipient {
1056                recipient_type: "rfc822",
1057                recipient: "recip@target.example.com",
1058            },
1059            action: Failed,
1060            status: ReportStatus {
1061                class: 5,
1062                subject: 7,
1063                detail: 1,
1064                comment: Some(
1065                    "no thanks",
1066                ),
1067            },
1068            original_recipient: None,
1069            remote_mta: Some(
1070                RemoteMta {
1071                    mta_type: "dns",
1072                    name: "target.example.com",
1073                },
1074            ),
1075            diagnostic_code: Some(
1076                DiagnosticCode {
1077                    diagnostic_type: "smtp",
1078                    diagnostic: "550 5.7.1 no thanks",
1079                },
1080            ),
1081            last_attempt_date: Some(
1082                2003-07-01T10:52:37Z,
1083            ),
1084            final_log_id: None,
1085            will_retry_until: None,
1086            extensions: {},
1087        },
1088    ],
1089    original_message: Some(
1090        "Content-Type: text/plain;
1091\tcharset="us-ascii"
1092Subject: Hello!
1093",
1094    ),
1095}
1096"#
1097        );
1098    }
1099    #[test]
1100    fn generate_bounce_with_message() {
1101        let params = ReportGenerationParams {
1102            reporting_mta: RemoteMta {
1103                mta_type: "dns".to_string(),
1104                name: "mta1.example.com".to_string(),
1105            },
1106            enable_bounce: true,
1107            enable_expiration: true,
1108            include_original_message: IncludeOriginalMessage::FullContent,
1109            stable_content: true,
1110        };
1111
1112        let original_msg = make_message();
1113
1114        let log = make_bounce();
1115
1116        let report_msg = Report::generate(&params, Some(&original_msg), &log)
1117            .unwrap()
1118            .unwrap();
1119        let report_eml = BString::from(report_msg.to_message_bytes());
1120        k9::snapshot!(
1121            &report_eml,
1122            r#"
1123Content-Type: multipart/report;\r
1124\tboundary="report-boundary";\r
1125\treport-type="delivery-status"\r
1126Subject: Returned mail\r
1127Mime-Version: 1.0\r
1128Message-ID: <UUID@mta1.example.com>\r
1129To: sender@sender.example.com\r
1130From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1131\r
1132--report-boundary\r
1133Content-Type: text/plain;\r
1134\tcharset="us-ascii"\r
1135\r
1136The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1137from sender@sender.example.com and addressed to recip@target.example.com.\r
1138While communicating with target.example.com (42.42.42.42):\r
1139Response: 550 5.7.1 no thanks\r
1140\r
1141The message will be deleted from the queue.\r
1142No further attempts will be made to deliver it.\r
1143--report-boundary\r
1144Content-Type: message/delivery-status;\r
1145\tcharset="us-ascii"\r
1146\r
1147Reporting-MTA: dns; mta1.example.com\r
1148Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1149\r
1150Final-Recipient: rfc822;recip@target.example.com\r
1151Action: failed\r
1152Status: 5.7.1 no thanks\r
1153Remote-MTA: dns; target.example.com\r
1154Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1155Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1156\r
1157--report-boundary\r
1158Content-Type: message/rfc822\r
1159\r
1160Content-Type: text/plain;\r
1161\tcharset="us-ascii"\r
1162Subject: Hello!\r
1163\r
1164hello there\r
1165--report-boundary--\r
1166
1167"#
1168        );
1169
1170        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1171        k9::snapshot!(
1172            &round_trip,
1173            r#"
1174Report {
1175    per_message: PerMessageReportEntry {
1176        original_envelope_id: None,
1177        reporting_mta: RemoteMta {
1178            mta_type: "dns",
1179            name: "mta1.example.com",
1180        },
1181        dsn_gateway: None,
1182        received_from_mta: None,
1183        arrival_date: Some(
1184            2003-07-01T08:52:37Z,
1185        ),
1186        extensions: {},
1187    },
1188    per_recipient: [
1189        PerRecipientReportEntry {
1190            final_recipient: Recipient {
1191                recipient_type: "rfc822",
1192                recipient: "recip@target.example.com",
1193            },
1194            action: Failed,
1195            status: ReportStatus {
1196                class: 5,
1197                subject: 7,
1198                detail: 1,
1199                comment: Some(
1200                    "no thanks",
1201                ),
1202            },
1203            original_recipient: None,
1204            remote_mta: Some(
1205                RemoteMta {
1206                    mta_type: "dns",
1207                    name: "target.example.com",
1208                },
1209            ),
1210            diagnostic_code: Some(
1211                DiagnosticCode {
1212                    diagnostic_type: "smtp",
1213                    diagnostic: "550 5.7.1 no thanks",
1214                },
1215            ),
1216            last_attempt_date: Some(
1217                2003-07-01T10:52:37Z,
1218            ),
1219            final_log_id: None,
1220            will_retry_until: None,
1221            extensions: {},
1222        },
1223    ],
1224    original_message: Some(
1225        "Content-Type: text/plain;
1226\tcharset="us-ascii"
1227Subject: Hello!
1228
1229hello there
1230",
1231    ),
1232}
1233"#
1234        );
1235    }
1236
1237    #[test]
1238    fn generate_bounce_no_message() {
1239        let params = ReportGenerationParams {
1240            reporting_mta: RemoteMta {
1241                mta_type: "dns".to_string(),
1242                name: "mta1.example.com".to_string(),
1243            },
1244            enable_bounce: true,
1245            enable_expiration: true,
1246            include_original_message: IncludeOriginalMessage::No,
1247            stable_content: true,
1248        };
1249
1250        let original_msg = make_message();
1251
1252        let log = make_bounce();
1253
1254        let report_msg = Report::generate(&params, Some(&original_msg), &log)
1255            .unwrap()
1256            .unwrap();
1257        let report_eml = BString::from(report_msg.to_message_bytes());
1258        k9::snapshot!(
1259            &report_eml,
1260            r#"
1261Content-Type: multipart/report;\r
1262\tboundary="report-boundary";\r
1263\treport-type="delivery-status"\r
1264Subject: Returned mail\r
1265Mime-Version: 1.0\r
1266Message-ID: <UUID@mta1.example.com>\r
1267To: sender@sender.example.com\r
1268From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1269\r
1270--report-boundary\r
1271Content-Type: text/plain;\r
1272\tcharset="us-ascii"\r
1273\r
1274The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1275from sender@sender.example.com and addressed to recip@target.example.com.\r
1276While communicating with target.example.com (42.42.42.42):\r
1277Response: 550 5.7.1 no thanks\r
1278\r
1279The message will be deleted from the queue.\r
1280No further attempts will be made to deliver it.\r
1281--report-boundary\r
1282Content-Type: message/delivery-status;\r
1283\tcharset="us-ascii"\r
1284\r
1285Reporting-MTA: dns; mta1.example.com\r
1286Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1287\r
1288Final-Recipient: rfc822;recip@target.example.com\r
1289Action: failed\r
1290Status: 5.7.1 no thanks\r
1291Remote-MTA: dns; target.example.com\r
1292Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1293Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1294\r
1295--report-boundary--\r
1296
1297"#
1298        );
1299
1300        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1301        k9::snapshot!(
1302            &round_trip,
1303            r#"
1304Report {
1305    per_message: PerMessageReportEntry {
1306        original_envelope_id: None,
1307        reporting_mta: RemoteMta {
1308            mta_type: "dns",
1309            name: "mta1.example.com",
1310        },
1311        dsn_gateway: None,
1312        received_from_mta: None,
1313        arrival_date: Some(
1314            2003-07-01T08:52:37Z,
1315        ),
1316        extensions: {},
1317    },
1318    per_recipient: [
1319        PerRecipientReportEntry {
1320            final_recipient: Recipient {
1321                recipient_type: "rfc822",
1322                recipient: "recip@target.example.com",
1323            },
1324            action: Failed,
1325            status: ReportStatus {
1326                class: 5,
1327                subject: 7,
1328                detail: 1,
1329                comment: Some(
1330                    "no thanks",
1331                ),
1332            },
1333            original_recipient: None,
1334            remote_mta: Some(
1335                RemoteMta {
1336                    mta_type: "dns",
1337                    name: "target.example.com",
1338                },
1339            ),
1340            diagnostic_code: Some(
1341                DiagnosticCode {
1342                    diagnostic_type: "smtp",
1343                    diagnostic: "550 5.7.1 no thanks",
1344                },
1345            ),
1346            last_attempt_date: Some(
1347                2003-07-01T10:52:37Z,
1348            ),
1349            final_log_id: None,
1350            will_retry_until: None,
1351            extensions: {},
1352        },
1353    ],
1354    original_message: None,
1355}
1356"#
1357        );
1358    }
1359
1360    #[test]
1361    fn generate_bounce_far_future_created() {
1362        use chrono::TimeZone;
1363
1364        let params = ReportGenerationParams {
1365            reporting_mta: RemoteMta {
1366                mta_type: "dns".to_string(),
1367                name: "mta1.example.com".to_string(),
1368            },
1369            enable_bounce: true,
1370            enable_expiration: true,
1371            include_original_message: IncludeOriginalMessage::No,
1372            stable_content: true,
1373        };
1374
1375        // A corrupt spool id can yield a creation time whose year is past 9999.
1376        // Generating the report must not panic. The date renders with all of
1377        // its digits in both the Arrival-Date header and the prose.
1378        let mut log = make_bounce();
1379        log.created = chrono::Utc.with_ymd_and_hms(60123, 1, 1, 0, 0, 0).unwrap();
1380
1381        let report_msg = Report::generate(&params, None, &log).unwrap().unwrap();
1382        let report_eml = BString::from(report_msg.to_message_bytes());
1383        k9::snapshot!(
1384            &report_eml,
1385            r#"
1386Content-Type: multipart/report;\r
1387\tboundary="report-boundary";\r
1388\treport-type="delivery-status"\r
1389Subject: Returned mail\r
1390Mime-Version: 1.0\r
1391Message-ID: <UUID@mta1.example.com>\r
1392To: sender@sender.example.com\r
1393From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1394\r
1395--report-boundary\r
1396Content-Type: text/plain;\r
1397\tcharset="us-ascii"\r
1398\r
1399The message was received at Fri, 1 Jan 60123 00:00:00 +0000\r
1400from sender@sender.example.com and addressed to recip@target.example.com.\r
1401While communicating with target.example.com (42.42.42.42):\r
1402Response: 550 5.7.1 no thanks\r
1403\r
1404The message will be deleted from the queue.\r
1405No further attempts will be made to deliver it.\r
1406--report-boundary\r
1407Content-Type: message/delivery-status;\r
1408\tcharset="us-ascii"\r
1409\r
1410Reporting-MTA: dns; mta1.example.com\r
1411Arrival-Date: Fri, 1 Jan 60123 00:00:00 +0000\r
1412\r
1413Final-Recipient: rfc822;recip@target.example.com\r
1414Action: failed\r
1415Status: 5.7.1 no thanks\r
1416Remote-MTA: dns; target.example.com\r
1417Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1418Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1419\r
1420--report-boundary--\r
1421
1422"#
1423        );
1424    }
1425
1426    #[test]
1427    fn rfc3464_1() {
1428        let result = Report::parse(include_bytes!("../data/rfc3464/1.eml")).unwrap();
1429        k9::snapshot!(
1430            &result,
1431            r#"
1432Some(
1433    Report {
1434        per_message: PerMessageReportEntry {
1435            original_envelope_id: None,
1436            reporting_mta: RemoteMta {
1437                mta_type: "dns",
1438                name: "cs.utk.edu",
1439            },
1440            dsn_gateway: None,
1441            received_from_mta: None,
1442            arrival_date: None,
1443            extensions: {},
1444        },
1445        per_recipient: [
1446            PerRecipientReportEntry {
1447                final_recipient: Recipient {
1448                    recipient_type: "rfc822",
1449                    recipient: "louisl@larry.slip.umd.edu",
1450                },
1451                action: Failed,
1452                status: ReportStatus {
1453                    class: 4,
1454                    subject: 0,
1455                    detail: 0,
1456                    comment: None,
1457                },
1458                original_recipient: Some(
1459                    Recipient {
1460                        recipient_type: "rfc822",
1461                        recipient: "louisl@larry.slip.umd.edu",
1462                    },
1463                ),
1464                remote_mta: None,
1465                diagnostic_code: Some(
1466                    DiagnosticCode {
1467                        diagnostic_type: "smtp",
1468                        diagnostic: "426 connection timed out",
1469                    },
1470                ),
1471                last_attempt_date: Some(
1472                    1994-07-07T21:15:49Z,
1473                ),
1474                final_log_id: None,
1475                will_retry_until: None,
1476                extensions: {},
1477            },
1478        ],
1479        original_message: Some(
1480            "[original message goes here]
1481
1482",
1483        ),
1484    },
1485)
1486"#
1487        );
1488
1489        let report = result.unwrap();
1490
1491        assert_eq!(
1492            report.per_message.to_string(),
1493            "Reporting-MTA: dns; cs.utk.edu\r\n"
1494        );
1495        assert_eq!(
1496            report.per_recipient[0].to_string(),
1497            "Original-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1498            Final-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1499            Action: failed\r\n\
1500            Status: 4.0.0\r\n\
1501            Diagnostic-Code: smtp; 426 connection timed out\r\n\
1502            Last-Attempt-Date: Thu, 7 Jul 1994 21:15:49 +0000\r\n"
1503        );
1504    }
1505
1506    #[test]
1507    fn rfc3464_2() {
1508        let result = Report::parse(include_bytes!("../data/rfc3464/2.eml")).unwrap();
1509        k9::snapshot!(
1510            result,
1511            r#"
1512Some(
1513    Report {
1514        per_message: PerMessageReportEntry {
1515            original_envelope_id: None,
1516            reporting_mta: RemoteMta {
1517                mta_type: "dns",
1518                name: "cs.utk.edu",
1519            },
1520            dsn_gateway: None,
1521            received_from_mta: None,
1522            arrival_date: None,
1523            extensions: {},
1524        },
1525        per_recipient: [
1526            PerRecipientReportEntry {
1527                final_recipient: Recipient {
1528                    recipient_type: "rfc822",
1529                    recipient: "arathib@vnet.ibm.com",
1530                },
1531                action: Failed,
1532                status: ReportStatus {
1533                    class: 5,
1534                    subject: 0,
1535                    detail: 0,
1536                    comment: Some(
1537                        "(permanent failure)",
1538                    ),
1539                },
1540                original_recipient: Some(
1541                    Recipient {
1542                        recipient_type: "rfc822",
1543                        recipient: "arathib@vnet.ibm.com",
1544                    },
1545                ),
1546                remote_mta: Some(
1547                    RemoteMta {
1548                        mta_type: "dns",
1549                        name: "vnet.ibm.com",
1550                    },
1551                ),
1552                diagnostic_code: Some(
1553                    DiagnosticCode {
1554                        diagnostic_type: "smtp",
1555                        diagnostic: "550 'arathib@vnet.IBM.COM' is not a registered gateway user",
1556                    },
1557                ),
1558                last_attempt_date: None,
1559                final_log_id: None,
1560                will_retry_until: None,
1561                extensions: {},
1562            },
1563            PerRecipientReportEntry {
1564                final_recipient: Recipient {
1565                    recipient_type: "rfc822",
1566                    recipient: "johnh@hpnjld.njd.hp.com",
1567                },
1568                action: Delayed,
1569                status: ReportStatus {
1570                    class: 4,
1571                    subject: 0,
1572                    detail: 0,
1573                    comment: Some(
1574                        "(hpnjld.njd.jp.com: host name lookup failure)",
1575                    ),
1576                },
1577                original_recipient: Some(
1578                    Recipient {
1579                        recipient_type: "rfc822",
1580                        recipient: "johnh@hpnjld.njd.hp.com",
1581                    },
1582                ),
1583                remote_mta: None,
1584                diagnostic_code: None,
1585                last_attempt_date: None,
1586                final_log_id: None,
1587                will_retry_until: None,
1588                extensions: {},
1589            },
1590            PerRecipientReportEntry {
1591                final_recipient: Recipient {
1592                    recipient_type: "rfc822",
1593                    recipient: "wsnell@sdcc13.ucsd.edu",
1594                },
1595                action: Failed,
1596                status: ReportStatus {
1597                    class: 5,
1598                    subject: 0,
1599                    detail: 0,
1600                    comment: None,
1601                },
1602                original_recipient: Some(
1603                    Recipient {
1604                        recipient_type: "rfc822",
1605                        recipient: "wsnell@sdcc13.ucsd.edu",
1606                    },
1607                ),
1608                remote_mta: Some(
1609                    RemoteMta {
1610                        mta_type: "dns",
1611                        name: "sdcc13.ucsd.edu",
1612                    },
1613                ),
1614                diagnostic_code: Some(
1615                    DiagnosticCode {
1616                        diagnostic_type: "smtp",
1617                        diagnostic: "550 user unknown",
1618                    },
1619                ),
1620                last_attempt_date: None,
1621                final_log_id: None,
1622                will_retry_until: None,
1623                extensions: {},
1624            },
1625        ],
1626        original_message: Some(
1627            "[original message goes here]
1628
1629",
1630        ),
1631    },
1632)
1633"#
1634        );
1635    }
1636
1637    #[test]
1638    fn rfc3464_3() {
1639        let result = Report::parse(include_bytes!("../data/rfc3464/3.eml")).unwrap();
1640        k9::snapshot!(
1641            result,
1642            r#"
1643Some(
1644    Report {
1645        per_message: PerMessageReportEntry {
1646            original_envelope_id: None,
1647            reporting_mta: RemoteMta {
1648                mta_type: "mailbus",
1649                name: "SYS30",
1650            },
1651            dsn_gateway: None,
1652            received_from_mta: None,
1653            arrival_date: None,
1654            extensions: {},
1655        },
1656        per_recipient: [
1657            PerRecipientReportEntry {
1658                final_recipient: Recipient {
1659                    recipient_type: "unknown",
1660                    recipient: "nair_s",
1661                },
1662                action: Failed,
1663                status: ReportStatus {
1664                    class: 5,
1665                    subject: 0,
1666                    detail: 0,
1667                    comment: Some(
1668                        "(unknown permanent failure)",
1669                    ),
1670                },
1671                original_recipient: None,
1672                remote_mta: None,
1673                diagnostic_code: None,
1674                last_attempt_date: None,
1675                final_log_id: None,
1676                will_retry_until: None,
1677                extensions: {},
1678            },
1679        ],
1680        original_message: None,
1681    },
1682)
1683"#
1684        );
1685    }
1686
1687    #[test]
1688    fn rfc3464_4() {
1689        let result = Report::parse(include_bytes!("../data/rfc3464/4.eml")).unwrap();
1690        k9::snapshot!(
1691            result,
1692            r#"
1693Some(
1694    Report {
1695        per_message: PerMessageReportEntry {
1696            original_envelope_id: None,
1697            reporting_mta: RemoteMta {
1698                mta_type: "dns",
1699                name: "sun2.nsfnet-relay.ac.uk",
1700            },
1701            dsn_gateway: None,
1702            received_from_mta: None,
1703            arrival_date: None,
1704            extensions: {},
1705        },
1706        per_recipient: [
1707            PerRecipientReportEntry {
1708                final_recipient: Recipient {
1709                    recipient_type: "rfc822",
1710                    recipient: "thomas@de-montfort.ac.uk",
1711                },
1712                action: Delayed,
1713                status: ReportStatus {
1714                    class: 4,
1715                    subject: 0,
1716                    detail: 0,
1717                    comment: Some(
1718                        "(unknown temporary failure)",
1719                    ),
1720                },
1721                original_recipient: None,
1722                remote_mta: None,
1723                diagnostic_code: None,
1724                last_attempt_date: None,
1725                final_log_id: None,
1726                will_retry_until: None,
1727                extensions: {},
1728            },
1729        ],
1730        original_message: None,
1731    },
1732)
1733"#
1734        );
1735    }
1736
1737    #[test]
1738    fn rfc3464_5() {
1739        let result = Report::parse(include_bytes!("../data/rfc3464/5.eml")).unwrap();
1740        k9::snapshot!(
1741            result,
1742            r#"
1743Some(
1744    Report {
1745        per_message: PerMessageReportEntry {
1746            original_envelope_id: None,
1747            reporting_mta: RemoteMta {
1748                mta_type: "dns",
1749                name: "mx-by.bbox.fr",
1750            },
1751            dsn_gateway: None,
1752            received_from_mta: None,
1753            arrival_date: Some(
1754                2025-01-29T16:36:51Z,
1755            ),
1756            extensions: {
1757                "x-postfix-queue-id": [
1758                    "897DAC0",
1759                ],
1760                "x-postfix-sender": [
1761                    "rfc822; user@example.com",
1762                ],
1763            },
1764        },
1765        per_recipient: [
1766            PerRecipientReportEntry {
1767                final_recipient: Recipient {
1768                    recipient_type: "rfc822",
1769                    recipient: "recipient@domain.com",
1770                },
1771                action: Failed,
1772                status: ReportStatus {
1773                    class: 5,
1774                    subject: 0,
1775                    detail: 0,
1776                    comment: None,
1777                },
1778                original_recipient: Some(
1779                    Recipient {
1780                        recipient_type: "rfc822",
1781                        recipient: "recipient@domain.com",
1782                    },
1783                ),
1784                remote_mta: Some(
1785                    RemoteMta {
1786                        mta_type: "dns",
1787                        name: "lmtp.cs.dolmen.bouyguestelecom.fr",
1788                    },
1789                ),
1790                diagnostic_code: Some(
1791                    DiagnosticCode {
1792                        diagnostic_type: "smtp",
1793                        diagnostic: "552 <recipient@domain.com> rejected: over quota",
1794                    },
1795                ),
1796                last_attempt_date: None,
1797                final_log_id: None,
1798                will_retry_until: None,
1799                extensions: {},
1800            },
1801        ],
1802        original_message: Some(
1803            "[original message goes here]
1804
1805",
1806        ),
1807    },
1808)
1809"#
1810        );
1811    }
1812
1813    #[test]
1814    fn rfc3464_6() {
1815        let result = Report::parse(include_bytes!("../data/rfc3464/6.eml")).unwrap();
1816        k9::snapshot!(
1817            result,
1818            r#"
1819Some(
1820    Report {
1821        per_message: PerMessageReportEntry {
1822            original_envelope_id: None,
1823            reporting_mta: RemoteMta {
1824                mta_type: "dns",
1825                name: "tls02.example.com",
1826            },
1827            dsn_gateway: None,
1828            received_from_mta: None,
1829            arrival_date: None,
1830            extensions: {},
1831        },
1832        per_recipient: [
1833            PerRecipientReportEntry {
1834                final_recipient: Recipient {
1835                    recipient_type: "rfc822",
1836                    recipient: "redacted@example.com",
1837                },
1838                action: Failed,
1839                status: ReportStatus {
1840                    class: 5,
1841                    subject: 0,
1842                    detail: 0,
1843                    comment: None,
1844                },
1845                original_recipient: Some(
1846                    Recipient {
1847                        recipient_type: "rfc822",
1848                        recipient: "redacted@example.com",
1849                    },
1850                ),
1851                remote_mta: Some(
1852                    RemoteMta {
1853                        mta_type: "dns",
1854                        name: "example-com.mail.eo.outlook.com:25",
1855                    },
1856                ),
1857                diagnostic_code: Some(
1858                    DiagnosticCode {
1859                        diagnostic_type: "smtp",
1860                        diagnostic: "host example-com.mail.eo.outlook.com:25 says: 550 5.4.1 Recipient address rejected: Access denied. For more information see https://aka.ms/EXOSmtpErrors [XXX.namprd05.prod.outlook.com 2026-03-13T18:10:42.797Z XXX]",
1861                    },
1862                ),
1863                last_attempt_date: None,
1864                final_log_id: None,
1865                will_retry_until: None,
1866                extensions: {},
1867            },
1868        ],
1869        original_message: Some(
1870            "Subject: [Bulk Mail] the subject
1871From: INFO <info@email.example.com>
1872To: redacted@example.com
1873
1874",
1875        ),
1876    },
1877)
1878"#
1879        );
1880    }
1881
1882    #[test]
1883    fn original_message_serializes_as_json_string() {
1884        let report = Report {
1885            per_message: PerMessageReportEntry {
1886                original_envelope_id: None,
1887                reporting_mta: RemoteMta {
1888                    mta_type: "dns".to_string(),
1889                    name: "mta.example.com".to_string(),
1890                },
1891                dsn_gateway: None,
1892                received_from_mta: None,
1893                arrival_date: None,
1894                extensions: BTreeMap::new(),
1895            },
1896            per_recipient: vec![],
1897            original_message: Some(BString::from("Subject: hi\n\nhello")),
1898        };
1899        let json = serde_json::to_value(&report).unwrap();
1900        k9::assert_equal!(
1901            json["original_message"],
1902            serde_json::Value::String("Subject: hi\n\nhello".to_string())
1903        );
1904    }
1905
1906    #[test]
1907    fn original_message_non_utf8_falls_back_to_bytes() {
1908        let report = Report {
1909            per_message: PerMessageReportEntry {
1910                original_envelope_id: None,
1911                reporting_mta: RemoteMta {
1912                    mta_type: "dns".to_string(),
1913                    name: "mta.example.com".to_string(),
1914                },
1915                dsn_gateway: None,
1916                received_from_mta: None,
1917                arrival_date: None,
1918                extensions: BTreeMap::new(),
1919            },
1920            per_recipient: vec![],
1921            original_message: Some(BString::from(&b"abc\x80\xffxyz"[..])),
1922        };
1923        let json = serde_json::to_value(&report).unwrap();
1924        assert!(json["original_message"].is_array());
1925    }
1926}