kumo_log_types/
rfc3464.rs

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