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