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::{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", when.to_rfc2822())?;
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", when.to_rfc2822())?;
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", when.to_rfc2822())?;
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 arrival_date = Some(log.created);
466
467        let per_message = PerMessageReportEntry {
468            arrival_date,
469            dsn_gateway: None,
470            extensions: Default::default(),
471            original_envelope_id: None,
472            received_from_mta: None,
473            reporting_mta: params.reporting_mta.clone(),
474        };
475
476        let mut per_recipient = vec![];
477        let recip_list = log.recipient.join(", ");
478
479        for recip in &log.recipient {
480            per_recipient.push(PerRecipientReportEntry {
481                action,
482                extensions: Default::default(),
483                status: (&log.response).into(),
484                diagnostic_code: Some(DiagnosticCode {
485                    diagnostic_type: "smtp".into(),
486                    diagnostic: log.response.to_single_line(),
487                }),
488                final_log_id: None,
489                original_recipient: None,
490                final_recipient: Recipient {
491                    recipient_type: "rfc822".to_string(),
492                    recipient: recip.to_string(),
493                },
494                remote_mta: log.peer_address.as_ref().map(|addr| RemoteMta {
495                    mta_type: "dns".to_string(),
496                    name: addr.name.to_string(),
497                }),
498                last_attempt_date: Some(log.timestamp),
499                will_retry_until: None,
500            });
501        }
502
503        let mut parts = vec![];
504
505        let exposition = match &log.kind {
506            RecordType::Bounce => {
507                let mut data = format!(
508                    "The message was received at {created}\r\n\
509                    from {sender} and addressed to {recip_list}.\r\n\
510                    ",
511                    created = log.created.to_rfc2822(),
512                    sender = log.sender,
513                );
514                if let Some(peer) = &log.peer_address {
515                    data.push_str(&format!(
516                        "While communicating with {host} ({ip}):\r\n\
517                        Response: {resp}\r\n",
518                        host = peer.name,
519                        ip = peer.addr,
520                        resp = log.response.to_single_line(),
521                    ));
522                } else {
523                    data.push_str(&format!("Status: {}\r\n", log.response.to_single_line()));
524                }
525
526                data.push_str(
527                    "\r\nThe message will be deleted from the queue.\r\n\
528                    No further attempts will be made to deliver it.\r\n",
529                );
530
531                data
532            }
533            RecordType::Expiration => {
534                format!(
535                    "The message was received at {created}\r\n\
536                    from {sender} and addressed to {recip_list}.\r\n\
537                    Status: {status}\r\n\
538                    The message will be deleted from the queue.\r\n\
539                    No further attempts will be made to deliver it.\r\n\
540                    ",
541                    created = log.created.to_rfc2822(),
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 =
746            chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
747        let now = chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
748        JsonLogRecord {
749            kind: RecordType::Bounce,
750            id: "ID".to_string(),
751            nodeid,
752            created: created.into(),
753            bounce_classification: Default::default(),
754            delivery_protocol: Some("ESMTP".to_string()),
755            egress_pool: None,
756            egress_source: None,
757            feedback_report: None,
758            headers: Default::default(),
759            meta: Default::default(),
760            num_attempts: 1,
761            peer_address: Some(ResolvedAddress {
762                name: "target.example.com".to_string(),
763                addr: "42.42.42.42".to_string().try_into().unwrap(),
764                is_secure: false,
765            }),
766            provider_name: None,
767            queue: "target.example.com".to_string(),
768            reception_protocol: None,
769            recipient: vec!["recip@target.example.com".to_string()],
770            sender: "sender@sender.example.com".to_string(),
771            session_id: None,
772            response: Response {
773                code: 550,
774                command: None,
775                content: "no thanks".to_string(),
776                enhanced_code: Some(EnhancedStatusCode {
777                    class: 5,
778                    subject: 7,
779                    detail: 1,
780                }),
781            },
782            site: "some-site".to_string(),
783            size: 0,
784            source_address: None,
785            timestamp: now.into(),
786            tls_cipher: None,
787            tls_peer_subject_name: None,
788            tls_protocol_version: None,
789        }
790    }
791
792    fn make_expiration() -> JsonLogRecord {
793        let nodeid = uuid_helper::now_v1();
794        let created =
795            chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
796        let now = chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
797        JsonLogRecord {
798            kind: RecordType::Expiration,
799            id: "ID".to_string(),
800            nodeid,
801            created: created.into(),
802            bounce_classification: Default::default(),
803            delivery_protocol: None,
804            egress_pool: None,
805            egress_source: None,
806            feedback_report: None,
807            headers: Default::default(),
808            meta: Default::default(),
809            num_attempts: 3,
810            peer_address: None,
811            provider_name: None,
812            queue: "target.example.com".to_string(),
813            reception_protocol: None,
814            recipient: vec!["recip@target.example.com".to_string()],
815            sender: "sender@sender.example.com".to_string(),
816            session_id: None,
817            response: Response {
818                code: 551,
819                command: None,
820                content: "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling".to_string(),
821                enhanced_code: Some(EnhancedStatusCode {
822                    class: 5,
823                    subject: 4,
824                    detail: 7,
825                }),
826            },
827            site: "".to_string(),
828            size: 0,
829            source_address: None,
830            timestamp: now.into(),
831            tls_cipher: None,
832            tls_peer_subject_name: None,
833            tls_protocol_version: None,
834        }
835    }
836
837    #[test]
838    fn generate_expiration_with_headers() {
839        let params = ReportGenerationParams {
840            reporting_mta: RemoteMta {
841                mta_type: "dns".to_string(),
842                name: "mta1.example.com".to_string(),
843            },
844            enable_bounce: false,
845            enable_expiration: true,
846            include_original_message: IncludeOriginalMessage::HeadersOnly,
847            stable_content: true,
848        };
849
850        let original_msg = make_message();
851
852        let log = make_expiration();
853
854        let report_msg = Report::generate(&params, Some(&original_msg), &log)
855            .unwrap()
856            .unwrap();
857        let report_eml = BString::from(report_msg.to_message_bytes());
858        k9::snapshot!(
859            &report_eml,
860            r#"
861Content-Type: multipart/report;\r
862\tboundary="report-boundary";\r
863\treport-type="delivery-status"\r
864Subject: Returned mail\r
865Mime-Version: 1.0\r
866Message-ID: <UUID@mta1.example.com>\r
867To: sender@sender.example.com\r
868From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
869\r
870--report-boundary\r
871Content-Type: text/plain;\r
872\tcharset="us-ascii"\r
873Content-Transfer-Encoding: quoted-printable\r
874\r
875The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
876from sender@sender.example.com and addressed to recip@target.example.com.\r
877Status: 551 5.4.7 Next delivery time would be at SOME TIME which exceeds th=\r
878e expiry time EXPIRES configured via set_scheduling\r
879The message will be deleted from the queue.\r
880No further attempts will be made to deliver it.\r
881--report-boundary\r
882Content-Type: message/delivery-status;\r
883\tcharset="us-ascii"\r
884Content-Transfer-Encoding: quoted-printable\r
885\r
886Reporting-MTA: dns; mta1.example.com\r
887Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
888\r
889Final-Recipient: rfc822;recip@target.example.com\r
890Action: failed\r
891Status: 5.4.7 Next delivery time would be at SOME TIME which exceeds the ex=\r
892piry time EXPIRES configured via set_scheduling\r
893Diagnostic-Code: smtp; 551 5.4.7 Next delivery time would be at SOME TIME w=\r
894hich exceeds the expiry time EXPIRES configured via set_scheduling\r
895Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
896\r
897--report-boundary\r
898Content-Type: text/rfc822-headers\r
899\r
900Content-Type: text/plain;\r
901\tcharset="us-ascii"\r
902Subject: Hello!\r
903--report-boundary--\r
904
905"#
906        );
907
908        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
909        k9::snapshot!(
910            &round_trip,
911            r#"
912Report {
913    per_message: PerMessageReportEntry {
914        original_envelope_id: None,
915        reporting_mta: RemoteMta {
916            mta_type: "dns",
917            name: "mta1.example.com",
918        },
919        dsn_gateway: None,
920        received_from_mta: None,
921        arrival_date: Some(
922            2003-07-01T08:52:37Z,
923        ),
924        extensions: {},
925    },
926    per_recipient: [
927        PerRecipientReportEntry {
928            final_recipient: Recipient {
929                recipient_type: "rfc822",
930                recipient: "recip@target.example.com",
931            },
932            action: Failed,
933            status: ReportStatus {
934                class: 5,
935                subject: 4,
936                detail: 7,
937                comment: Some(
938                    "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
939                ),
940            },
941            original_recipient: None,
942            remote_mta: None,
943            diagnostic_code: Some(
944                DiagnosticCode {
945                    diagnostic_type: "smtp",
946                    diagnostic: "551 5.4.7 Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
947                },
948            ),
949            last_attempt_date: Some(
950                2003-07-01T10:52:37Z,
951            ),
952            final_log_id: None,
953            will_retry_until: None,
954            extensions: {},
955        },
956    ],
957    original_message: Some(
958        "Content-Type: text/plain;
959\tcharset="us-ascii"
960Subject: Hello!
961",
962    ),
963}
964"#
965        );
966    }
967
968    #[test]
969    fn generate_bounce_with_headers() {
970        let params = ReportGenerationParams {
971            reporting_mta: RemoteMta {
972                mta_type: "dns".to_string(),
973                name: "mta1.example.com".to_string(),
974            },
975            enable_bounce: true,
976            enable_expiration: true,
977            include_original_message: IncludeOriginalMessage::HeadersOnly,
978            stable_content: true,
979        };
980
981        let original_msg = make_message();
982
983        let log = make_bounce();
984
985        let report_msg = Report::generate(&params, Some(&original_msg), &log)
986            .unwrap()
987            .unwrap();
988        let report_eml = BString::from(report_msg.to_message_bytes());
989        k9::snapshot!(
990            &report_eml,
991            r#"
992Content-Type: multipart/report;\r
993\tboundary="report-boundary";\r
994\treport-type="delivery-status"\r
995Subject: Returned mail\r
996Mime-Version: 1.0\r
997Message-ID: <UUID@mta1.example.com>\r
998To: sender@sender.example.com\r
999From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1000\r
1001--report-boundary\r
1002Content-Type: text/plain;\r
1003\tcharset="us-ascii"\r
1004\r
1005The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1006from sender@sender.example.com and addressed to recip@target.example.com.\r
1007While communicating with target.example.com (42.42.42.42):\r
1008Response: 550 5.7.1 no thanks\r
1009\r
1010The message will be deleted from the queue.\r
1011No further attempts will be made to deliver it.\r
1012--report-boundary\r
1013Content-Type: message/delivery-status;\r
1014\tcharset="us-ascii"\r
1015\r
1016Reporting-MTA: dns; mta1.example.com\r
1017Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1018\r
1019Final-Recipient: rfc822;recip@target.example.com\r
1020Action: failed\r
1021Status: 5.7.1 no thanks\r
1022Remote-MTA: dns; target.example.com\r
1023Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1024Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1025\r
1026--report-boundary\r
1027Content-Type: text/rfc822-headers\r
1028\r
1029Content-Type: text/plain;\r
1030\tcharset="us-ascii"\r
1031Subject: Hello!\r
1032--report-boundary--\r
1033
1034"#
1035        );
1036
1037        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1038        k9::snapshot!(
1039            &round_trip,
1040            r#"
1041Report {
1042    per_message: PerMessageReportEntry {
1043        original_envelope_id: None,
1044        reporting_mta: RemoteMta {
1045            mta_type: "dns",
1046            name: "mta1.example.com",
1047        },
1048        dsn_gateway: None,
1049        received_from_mta: None,
1050        arrival_date: Some(
1051            2003-07-01T08:52:37Z,
1052        ),
1053        extensions: {},
1054    },
1055    per_recipient: [
1056        PerRecipientReportEntry {
1057            final_recipient: Recipient {
1058                recipient_type: "rfc822",
1059                recipient: "recip@target.example.com",
1060            },
1061            action: Failed,
1062            status: ReportStatus {
1063                class: 5,
1064                subject: 7,
1065                detail: 1,
1066                comment: Some(
1067                    "no thanks",
1068                ),
1069            },
1070            original_recipient: None,
1071            remote_mta: Some(
1072                RemoteMta {
1073                    mta_type: "dns",
1074                    name: "target.example.com",
1075                },
1076            ),
1077            diagnostic_code: Some(
1078                DiagnosticCode {
1079                    diagnostic_type: "smtp",
1080                    diagnostic: "550 5.7.1 no thanks",
1081                },
1082            ),
1083            last_attempt_date: Some(
1084                2003-07-01T10:52:37Z,
1085            ),
1086            final_log_id: None,
1087            will_retry_until: None,
1088            extensions: {},
1089        },
1090    ],
1091    original_message: Some(
1092        "Content-Type: text/plain;
1093\tcharset="us-ascii"
1094Subject: Hello!
1095",
1096    ),
1097}
1098"#
1099        );
1100    }
1101    #[test]
1102    fn generate_bounce_with_message() {
1103        let params = ReportGenerationParams {
1104            reporting_mta: RemoteMta {
1105                mta_type: "dns".to_string(),
1106                name: "mta1.example.com".to_string(),
1107            },
1108            enable_bounce: true,
1109            enable_expiration: true,
1110            include_original_message: IncludeOriginalMessage::FullContent,
1111            stable_content: true,
1112        };
1113
1114        let original_msg = make_message();
1115
1116        let log = make_bounce();
1117
1118        let report_msg = Report::generate(&params, Some(&original_msg), &log)
1119            .unwrap()
1120            .unwrap();
1121        let report_eml = BString::from(report_msg.to_message_bytes());
1122        k9::snapshot!(
1123            &report_eml,
1124            r#"
1125Content-Type: multipart/report;\r
1126\tboundary="report-boundary";\r
1127\treport-type="delivery-status"\r
1128Subject: Returned mail\r
1129Mime-Version: 1.0\r
1130Message-ID: <UUID@mta1.example.com>\r
1131To: sender@sender.example.com\r
1132From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1133\r
1134--report-boundary\r
1135Content-Type: text/plain;\r
1136\tcharset="us-ascii"\r
1137\r
1138The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1139from sender@sender.example.com and addressed to recip@target.example.com.\r
1140While communicating with target.example.com (42.42.42.42):\r
1141Response: 550 5.7.1 no thanks\r
1142\r
1143The message will be deleted from the queue.\r
1144No further attempts will be made to deliver it.\r
1145--report-boundary\r
1146Content-Type: message/delivery-status;\r
1147\tcharset="us-ascii"\r
1148\r
1149Reporting-MTA: dns; mta1.example.com\r
1150Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1151\r
1152Final-Recipient: rfc822;recip@target.example.com\r
1153Action: failed\r
1154Status: 5.7.1 no thanks\r
1155Remote-MTA: dns; target.example.com\r
1156Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1157Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1158\r
1159--report-boundary\r
1160Content-Type: message/rfc822\r
1161\r
1162Content-Type: text/plain;\r
1163\tcharset="us-ascii"\r
1164Subject: Hello!\r
1165\r
1166hello there\r
1167--report-boundary--\r
1168
1169"#
1170        );
1171
1172        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1173        k9::snapshot!(
1174            &round_trip,
1175            r#"
1176Report {
1177    per_message: PerMessageReportEntry {
1178        original_envelope_id: None,
1179        reporting_mta: RemoteMta {
1180            mta_type: "dns",
1181            name: "mta1.example.com",
1182        },
1183        dsn_gateway: None,
1184        received_from_mta: None,
1185        arrival_date: Some(
1186            2003-07-01T08:52:37Z,
1187        ),
1188        extensions: {},
1189    },
1190    per_recipient: [
1191        PerRecipientReportEntry {
1192            final_recipient: Recipient {
1193                recipient_type: "rfc822",
1194                recipient: "recip@target.example.com",
1195            },
1196            action: Failed,
1197            status: ReportStatus {
1198                class: 5,
1199                subject: 7,
1200                detail: 1,
1201                comment: Some(
1202                    "no thanks",
1203                ),
1204            },
1205            original_recipient: None,
1206            remote_mta: Some(
1207                RemoteMta {
1208                    mta_type: "dns",
1209                    name: "target.example.com",
1210                },
1211            ),
1212            diagnostic_code: Some(
1213                DiagnosticCode {
1214                    diagnostic_type: "smtp",
1215                    diagnostic: "550 5.7.1 no thanks",
1216                },
1217            ),
1218            last_attempt_date: Some(
1219                2003-07-01T10:52:37Z,
1220            ),
1221            final_log_id: None,
1222            will_retry_until: None,
1223            extensions: {},
1224        },
1225    ],
1226    original_message: Some(
1227        "Content-Type: text/plain;
1228\tcharset="us-ascii"
1229Subject: Hello!
1230
1231hello there
1232",
1233    ),
1234}
1235"#
1236        );
1237    }
1238
1239    #[test]
1240    fn generate_bounce_no_message() {
1241        let params = ReportGenerationParams {
1242            reporting_mta: RemoteMta {
1243                mta_type: "dns".to_string(),
1244                name: "mta1.example.com".to_string(),
1245            },
1246            enable_bounce: true,
1247            enable_expiration: true,
1248            include_original_message: IncludeOriginalMessage::No,
1249            stable_content: true,
1250        };
1251
1252        let original_msg = make_message();
1253
1254        let log = make_bounce();
1255
1256        let report_msg = Report::generate(&params, Some(&original_msg), &log)
1257            .unwrap()
1258            .unwrap();
1259        let report_eml = BString::from(report_msg.to_message_bytes());
1260        k9::snapshot!(
1261            &report_eml,
1262            r#"
1263Content-Type: multipart/report;\r
1264\tboundary="report-boundary";\r
1265\treport-type="delivery-status"\r
1266Subject: Returned mail\r
1267Mime-Version: 1.0\r
1268Message-ID: <UUID@mta1.example.com>\r
1269To: sender@sender.example.com\r
1270From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1271\r
1272--report-boundary\r
1273Content-Type: text/plain;\r
1274\tcharset="us-ascii"\r
1275\r
1276The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1277from sender@sender.example.com and addressed to recip@target.example.com.\r
1278While communicating with target.example.com (42.42.42.42):\r
1279Response: 550 5.7.1 no thanks\r
1280\r
1281The message will be deleted from the queue.\r
1282No further attempts will be made to deliver it.\r
1283--report-boundary\r
1284Content-Type: message/delivery-status;\r
1285\tcharset="us-ascii"\r
1286\r
1287Reporting-MTA: dns; mta1.example.com\r
1288Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1289\r
1290Final-Recipient: rfc822;recip@target.example.com\r
1291Action: failed\r
1292Status: 5.7.1 no thanks\r
1293Remote-MTA: dns; target.example.com\r
1294Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1295Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1296\r
1297--report-boundary--\r
1298
1299"#
1300        );
1301
1302        let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1303        k9::snapshot!(
1304            &round_trip,
1305            r#"
1306Report {
1307    per_message: PerMessageReportEntry {
1308        original_envelope_id: None,
1309        reporting_mta: RemoteMta {
1310            mta_type: "dns",
1311            name: "mta1.example.com",
1312        },
1313        dsn_gateway: None,
1314        received_from_mta: None,
1315        arrival_date: Some(
1316            2003-07-01T08:52:37Z,
1317        ),
1318        extensions: {},
1319    },
1320    per_recipient: [
1321        PerRecipientReportEntry {
1322            final_recipient: Recipient {
1323                recipient_type: "rfc822",
1324                recipient: "recip@target.example.com",
1325            },
1326            action: Failed,
1327            status: ReportStatus {
1328                class: 5,
1329                subject: 7,
1330                detail: 1,
1331                comment: Some(
1332                    "no thanks",
1333                ),
1334            },
1335            original_recipient: None,
1336            remote_mta: Some(
1337                RemoteMta {
1338                    mta_type: "dns",
1339                    name: "target.example.com",
1340                },
1341            ),
1342            diagnostic_code: Some(
1343                DiagnosticCode {
1344                    diagnostic_type: "smtp",
1345                    diagnostic: "550 5.7.1 no thanks",
1346                },
1347            ),
1348            last_attempt_date: Some(
1349                2003-07-01T10:52:37Z,
1350            ),
1351            final_log_id: None,
1352            will_retry_until: None,
1353            extensions: {},
1354        },
1355    ],
1356    original_message: None,
1357}
1358"#
1359        );
1360    }
1361
1362    #[test]
1363    fn rfc3464_1() {
1364        let result = Report::parse(include_bytes!("../data/rfc3464/1.eml")).unwrap();
1365        k9::snapshot!(
1366            &result,
1367            r#"
1368Some(
1369    Report {
1370        per_message: PerMessageReportEntry {
1371            original_envelope_id: None,
1372            reporting_mta: RemoteMta {
1373                mta_type: "dns",
1374                name: "cs.utk.edu",
1375            },
1376            dsn_gateway: None,
1377            received_from_mta: None,
1378            arrival_date: None,
1379            extensions: {},
1380        },
1381        per_recipient: [
1382            PerRecipientReportEntry {
1383                final_recipient: Recipient {
1384                    recipient_type: "rfc822",
1385                    recipient: "louisl@larry.slip.umd.edu",
1386                },
1387                action: Failed,
1388                status: ReportStatus {
1389                    class: 4,
1390                    subject: 0,
1391                    detail: 0,
1392                    comment: None,
1393                },
1394                original_recipient: Some(
1395                    Recipient {
1396                        recipient_type: "rfc822",
1397                        recipient: "louisl@larry.slip.umd.edu",
1398                    },
1399                ),
1400                remote_mta: None,
1401                diagnostic_code: Some(
1402                    DiagnosticCode {
1403                        diagnostic_type: "smtp",
1404                        diagnostic: "426 connection timed out",
1405                    },
1406                ),
1407                last_attempt_date: Some(
1408                    1994-07-07T21:15:49Z,
1409                ),
1410                final_log_id: None,
1411                will_retry_until: None,
1412                extensions: {},
1413            },
1414        ],
1415        original_message: Some(
1416            "[original message goes here]
1417
1418",
1419        ),
1420    },
1421)
1422"#
1423        );
1424
1425        let report = result.unwrap();
1426
1427        assert_eq!(
1428            report.per_message.to_string(),
1429            "Reporting-MTA: dns; cs.utk.edu\r\n"
1430        );
1431        assert_eq!(
1432            report.per_recipient[0].to_string(),
1433            "Original-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1434            Final-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1435            Action: failed\r\n\
1436            Status: 4.0.0\r\n\
1437            Diagnostic-Code: smtp; 426 connection timed out\r\n\
1438            Last-Attempt-Date: Thu, 7 Jul 1994 21:15:49 +0000\r\n"
1439        );
1440    }
1441
1442    #[test]
1443    fn rfc3464_2() {
1444        let result = Report::parse(include_bytes!("../data/rfc3464/2.eml")).unwrap();
1445        k9::snapshot!(
1446            result,
1447            r#"
1448Some(
1449    Report {
1450        per_message: PerMessageReportEntry {
1451            original_envelope_id: None,
1452            reporting_mta: RemoteMta {
1453                mta_type: "dns",
1454                name: "cs.utk.edu",
1455            },
1456            dsn_gateway: None,
1457            received_from_mta: None,
1458            arrival_date: None,
1459            extensions: {},
1460        },
1461        per_recipient: [
1462            PerRecipientReportEntry {
1463                final_recipient: Recipient {
1464                    recipient_type: "rfc822",
1465                    recipient: "arathib@vnet.ibm.com",
1466                },
1467                action: Failed,
1468                status: ReportStatus {
1469                    class: 5,
1470                    subject: 0,
1471                    detail: 0,
1472                    comment: Some(
1473                        "(permanent failure)",
1474                    ),
1475                },
1476                original_recipient: Some(
1477                    Recipient {
1478                        recipient_type: "rfc822",
1479                        recipient: "arathib@vnet.ibm.com",
1480                    },
1481                ),
1482                remote_mta: Some(
1483                    RemoteMta {
1484                        mta_type: "dns",
1485                        name: "vnet.ibm.com",
1486                    },
1487                ),
1488                diagnostic_code: Some(
1489                    DiagnosticCode {
1490                        diagnostic_type: "smtp",
1491                        diagnostic: "550 'arathib@vnet.IBM.COM' is not a registered gateway user",
1492                    },
1493                ),
1494                last_attempt_date: None,
1495                final_log_id: None,
1496                will_retry_until: None,
1497                extensions: {},
1498            },
1499            PerRecipientReportEntry {
1500                final_recipient: Recipient {
1501                    recipient_type: "rfc822",
1502                    recipient: "johnh@hpnjld.njd.hp.com",
1503                },
1504                action: Delayed,
1505                status: ReportStatus {
1506                    class: 4,
1507                    subject: 0,
1508                    detail: 0,
1509                    comment: Some(
1510                        "(hpnjld.njd.jp.com: host name lookup failure)",
1511                    ),
1512                },
1513                original_recipient: Some(
1514                    Recipient {
1515                        recipient_type: "rfc822",
1516                        recipient: "johnh@hpnjld.njd.hp.com",
1517                    },
1518                ),
1519                remote_mta: None,
1520                diagnostic_code: None,
1521                last_attempt_date: None,
1522                final_log_id: None,
1523                will_retry_until: None,
1524                extensions: {},
1525            },
1526            PerRecipientReportEntry {
1527                final_recipient: Recipient {
1528                    recipient_type: "rfc822",
1529                    recipient: "wsnell@sdcc13.ucsd.edu",
1530                },
1531                action: Failed,
1532                status: ReportStatus {
1533                    class: 5,
1534                    subject: 0,
1535                    detail: 0,
1536                    comment: None,
1537                },
1538                original_recipient: Some(
1539                    Recipient {
1540                        recipient_type: "rfc822",
1541                        recipient: "wsnell@sdcc13.ucsd.edu",
1542                    },
1543                ),
1544                remote_mta: Some(
1545                    RemoteMta {
1546                        mta_type: "dns",
1547                        name: "sdcc13.ucsd.edu",
1548                    },
1549                ),
1550                diagnostic_code: Some(
1551                    DiagnosticCode {
1552                        diagnostic_type: "smtp",
1553                        diagnostic: "550 user unknown",
1554                    },
1555                ),
1556                last_attempt_date: None,
1557                final_log_id: None,
1558                will_retry_until: None,
1559                extensions: {},
1560            },
1561        ],
1562        original_message: Some(
1563            "[original message goes here]
1564
1565",
1566        ),
1567    },
1568)
1569"#
1570        );
1571    }
1572
1573    #[test]
1574    fn rfc3464_3() {
1575        let result = Report::parse(include_bytes!("../data/rfc3464/3.eml")).unwrap();
1576        k9::snapshot!(
1577            result,
1578            r#"
1579Some(
1580    Report {
1581        per_message: PerMessageReportEntry {
1582            original_envelope_id: None,
1583            reporting_mta: RemoteMta {
1584                mta_type: "mailbus",
1585                name: "SYS30",
1586            },
1587            dsn_gateway: None,
1588            received_from_mta: None,
1589            arrival_date: None,
1590            extensions: {},
1591        },
1592        per_recipient: [
1593            PerRecipientReportEntry {
1594                final_recipient: Recipient {
1595                    recipient_type: "unknown",
1596                    recipient: "nair_s",
1597                },
1598                action: Failed,
1599                status: ReportStatus {
1600                    class: 5,
1601                    subject: 0,
1602                    detail: 0,
1603                    comment: Some(
1604                        "(unknown permanent failure)",
1605                    ),
1606                },
1607                original_recipient: None,
1608                remote_mta: None,
1609                diagnostic_code: None,
1610                last_attempt_date: None,
1611                final_log_id: None,
1612                will_retry_until: None,
1613                extensions: {},
1614            },
1615        ],
1616        original_message: None,
1617    },
1618)
1619"#
1620        );
1621    }
1622
1623    #[test]
1624    fn rfc3464_4() {
1625        let result = Report::parse(include_bytes!("../data/rfc3464/4.eml")).unwrap();
1626        k9::snapshot!(
1627            result,
1628            r#"
1629Some(
1630    Report {
1631        per_message: PerMessageReportEntry {
1632            original_envelope_id: None,
1633            reporting_mta: RemoteMta {
1634                mta_type: "dns",
1635                name: "sun2.nsfnet-relay.ac.uk",
1636            },
1637            dsn_gateway: None,
1638            received_from_mta: None,
1639            arrival_date: None,
1640            extensions: {},
1641        },
1642        per_recipient: [
1643            PerRecipientReportEntry {
1644                final_recipient: Recipient {
1645                    recipient_type: "rfc822",
1646                    recipient: "thomas@de-montfort.ac.uk",
1647                },
1648                action: Delayed,
1649                status: ReportStatus {
1650                    class: 4,
1651                    subject: 0,
1652                    detail: 0,
1653                    comment: Some(
1654                        "(unknown temporary failure)",
1655                    ),
1656                },
1657                original_recipient: None,
1658                remote_mta: None,
1659                diagnostic_code: None,
1660                last_attempt_date: None,
1661                final_log_id: None,
1662                will_retry_until: None,
1663                extensions: {},
1664            },
1665        ],
1666        original_message: None,
1667    },
1668)
1669"#
1670        );
1671    }
1672
1673    #[test]
1674    fn rfc3464_5() {
1675        let result = Report::parse(include_bytes!("../data/rfc3464/5.eml")).unwrap();
1676        k9::snapshot!(
1677            result,
1678            r#"
1679Some(
1680    Report {
1681        per_message: PerMessageReportEntry {
1682            original_envelope_id: None,
1683            reporting_mta: RemoteMta {
1684                mta_type: "dns",
1685                name: "mx-by.bbox.fr",
1686            },
1687            dsn_gateway: None,
1688            received_from_mta: None,
1689            arrival_date: Some(
1690                2025-01-29T16:36:51Z,
1691            ),
1692            extensions: {
1693                "x-postfix-queue-id": [
1694                    "897DAC0",
1695                ],
1696                "x-postfix-sender": [
1697                    "rfc822; user@example.com",
1698                ],
1699            },
1700        },
1701        per_recipient: [
1702            PerRecipientReportEntry {
1703                final_recipient: Recipient {
1704                    recipient_type: "rfc822",
1705                    recipient: "recipient@domain.com",
1706                },
1707                action: Failed,
1708                status: ReportStatus {
1709                    class: 5,
1710                    subject: 0,
1711                    detail: 0,
1712                    comment: None,
1713                },
1714                original_recipient: Some(
1715                    Recipient {
1716                        recipient_type: "rfc822",
1717                        recipient: "recipient@domain.com",
1718                    },
1719                ),
1720                remote_mta: Some(
1721                    RemoteMta {
1722                        mta_type: "dns",
1723                        name: "lmtp.cs.dolmen.bouyguestelecom.fr",
1724                    },
1725                ),
1726                diagnostic_code: Some(
1727                    DiagnosticCode {
1728                        diagnostic_type: "smtp",
1729                        diagnostic: "552 <recipient@domain.com> rejected: over quota",
1730                    },
1731                ),
1732                last_attempt_date: None,
1733                final_log_id: None,
1734                will_retry_until: None,
1735                extensions: {},
1736            },
1737        ],
1738        original_message: Some(
1739            "[original message goes here]
1740
1741",
1742        ),
1743    },
1744)
1745"#
1746        );
1747    }
1748
1749    #[test]
1750    fn rfc3464_6() {
1751        let result = Report::parse(include_bytes!("../data/rfc3464/6.eml")).unwrap();
1752        k9::snapshot!(
1753            result,
1754            r#"
1755Some(
1756    Report {
1757        per_message: PerMessageReportEntry {
1758            original_envelope_id: None,
1759            reporting_mta: RemoteMta {
1760                mta_type: "dns",
1761                name: "tls02.example.com",
1762            },
1763            dsn_gateway: None,
1764            received_from_mta: None,
1765            arrival_date: None,
1766            extensions: {},
1767        },
1768        per_recipient: [
1769            PerRecipientReportEntry {
1770                final_recipient: Recipient {
1771                    recipient_type: "rfc822",
1772                    recipient: "redacted@example.com",
1773                },
1774                action: Failed,
1775                status: ReportStatus {
1776                    class: 5,
1777                    subject: 0,
1778                    detail: 0,
1779                    comment: None,
1780                },
1781                original_recipient: Some(
1782                    Recipient {
1783                        recipient_type: "rfc822",
1784                        recipient: "redacted@example.com",
1785                    },
1786                ),
1787                remote_mta: Some(
1788                    RemoteMta {
1789                        mta_type: "dns",
1790                        name: "example-com.mail.eo.outlook.com:25",
1791                    },
1792                ),
1793                diagnostic_code: Some(
1794                    DiagnosticCode {
1795                        diagnostic_type: "smtp",
1796                        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]",
1797                    },
1798                ),
1799                last_attempt_date: None,
1800                final_log_id: None,
1801                will_retry_until: None,
1802                extensions: {},
1803            },
1804        ],
1805        original_message: Some(
1806            "Subject: [Bulk Mail] the subject
1807From: INFO <info@email.example.com>
1808To: redacted@example.com
1809
1810",
1811        ),
1812    },
1813)
1814"#
1815        );
1816    }
1817
1818    #[test]
1819    fn original_message_serializes_as_json_string() {
1820        let report = Report {
1821            per_message: PerMessageReportEntry {
1822                original_envelope_id: None,
1823                reporting_mta: RemoteMta {
1824                    mta_type: "dns".to_string(),
1825                    name: "mta.example.com".to_string(),
1826                },
1827                dsn_gateway: None,
1828                received_from_mta: None,
1829                arrival_date: None,
1830                extensions: BTreeMap::new(),
1831            },
1832            per_recipient: vec![],
1833            original_message: Some(BString::from("Subject: hi\n\nhello")),
1834        };
1835        let json = serde_json::to_value(&report).unwrap();
1836        k9::assert_equal!(
1837            json["original_message"],
1838            serde_json::Value::String("Subject: hi\n\nhello".to_string())
1839        );
1840    }
1841
1842    #[test]
1843    fn original_message_non_utf8_falls_back_to_bytes() {
1844        let report = Report {
1845            per_message: PerMessageReportEntry {
1846                original_envelope_id: None,
1847                reporting_mta: RemoteMta {
1848                    mta_type: "dns".to_string(),
1849                    name: "mta.example.com".to_string(),
1850                },
1851                dsn_gateway: None,
1852                received_from_mta: None,
1853                arrival_date: None,
1854                extensions: BTreeMap::new(),
1855            },
1856            per_recipient: vec![],
1857            original_message: Some(BString::from(&b"abc\x80\xffxyz"[..])),
1858        };
1859        let json = serde_json::to_value(&report).unwrap();
1860        assert!(json["original_message"].is_array());
1861    }
1862}