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