kumo_log_types/
rfc3464.rs

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