Skip to main content

mailrs_outbound_queue/
dsn.rs

1//! RFC 3464 Delivery Status Notification (DSN) builder.
2//!
3//! Wraps `mailrs-mail-builder`'s canonical `MessageBuilder` so the
4//! multipart/report envelope, header folding, encoded-word
5//! handling, and boundary collision-scan are all shared with the
6//! rest of the outbound stack. The DSN-specific `message/delivery-
7//! status` machine-readable part is constructed inline (RFC 3464
8//! §2 grammar — pure ASCII key/value pairs).
9
10use mailrs_mail_builder::{Attachment, MessageBuilder};
11
12/// Format a DSN (Delivery Status Notification) bounce message per
13/// RFC 3464. The returned message uses CRLF line endings (RFC 5322
14/// §2.1) and `multipart/report; report-type=delivery-status`.
15pub fn format_dsn(
16    reporting_mta: &str,
17    sender: &str,
18    recipient: &str,
19    error: &str,
20    message_id: Option<&str>,
21) -> String {
22    let now = chrono::Utc::now();
23    let dsn_id = format!(
24        "<{}.{}@{}>",
25        now.timestamp(),
26        std::process::id(),
27        reporting_mta,
28    );
29
30    // Machine-readable RFC 3464 §2 fields. Pure ASCII; we want
31    // these bytes to land in the part body unchanged, so a 7bit
32    // CTE will pick them up automatically.
33    let machine_body = format!(
34        "Reporting-MTA: dns; {reporting_mta}\r\n\
35         \r\n\
36         Final-Recipient: rfc822; {recipient}\r\n\
37         Action: failed\r\n\
38         Status: 5.0.0\r\n\
39         Diagnostic-Code: smtp; {error}\r\n",
40    );
41
42    // Human-readable part body — RFC 3464 §2 calls for at least
43    // one preceding part of any type that explains the failure to
44    // the recipient (the bounced-back original sender).
45    let human = format!(
46        "Your message to <{recipient}> could not be delivered.\r\n\
47         \r\n\
48         Error: {error}\r\n",
49    );
50
51    let mut b = MessageBuilder::new()
52        .from(format!("Mail Delivery System <mailer-daemon@{reporting_mta}>"))
53        .to(sender)
54        .subject("Delivery Status Notification (Failure)")
55        .header("Auto-Submitted", "auto-replied")
56        .text_body(human)
57        .attachment(Attachment::new(
58            "delivery-status.txt",
59            "message/delivery-status",
60            machine_body.into_bytes(),
61        ))
62        .report_type("delivery-status")
63        .message_id(dsn_id);
64
65    if let Some(mid) = message_id {
66        b = b.header("References", format!("<{mid}>"));
67    }
68
69    let bytes = b.build();
70    String::from_utf8(bytes).expect("mail-builder output is ASCII-safe by construction")
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn format_basic() {
79        let dsn = format_dsn(
80            "mx.example.com",
81            "sender@example.com",
82            "rcpt@remote.com",
83            "550 User not found",
84            None,
85        );
86        assert!(dsn.contains("From: Mail Delivery System <mailer-daemon@mx.example.com>\r\n"));
87        // mail-builder renders bare addresses without angle brackets;
88        // this is RFC 5322 §3.4 addr-spec form, equally valid as
89        // name-addr <addr-spec>.
90        assert!(dsn.contains("To: sender@example.com\r\n"));
91        // unfold before matching so soft-fold doesn't trip the substring check
92        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
93        assert!(unfold.contains("Final-Recipient: rfc822; rcpt@remote.com"));
94        assert!(unfold.contains("Diagnostic-Code: smtp; 550 User not found"));
95        assert!(!unfold.contains("References:"));
96    }
97
98    #[test]
99    fn format_with_message_id() {
100        let dsn = format_dsn(
101            "mx.example.com",
102            "sender@example.com",
103            "rcpt@remote.com",
104            "421 try again",
105            Some("abc123@example.com"),
106        );
107        assert!(dsn.contains("References: <abc123@example.com>"));
108    }
109
110    #[test]
111    fn dsn_structure() {
112        let dsn = format_dsn(
113            "mx.example.com",
114            "sender@example.com",
115            "rcpt@remote.com",
116            "550 no such user",
117            None,
118        );
119        // unfold for substring checks — header values may wrap
120        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
121        assert!(unfold.contains("multipart/report"));
122        assert!(unfold.contains("report-type=delivery-status"));
123        assert!(unfold.contains("message/delivery-status"));
124        // body still ends with a closing boundary marker — exact
125        // boundary string is random per call (mail-builder), so
126        // we just check there's at least one `--` envelope line
127        let envelope_opens = dsn.matches("\r\n--").count();
128        assert!(envelope_opens >= 2, "got {envelope_opens} envelope markers");
129        assert!(unfold.contains("Reporting-MTA: dns; mx.example.com"));
130        assert!(unfold.contains("Action: failed"));
131        assert!(unfold.contains("Status: 5.0.0"));
132    }
133
134    #[test]
135    fn special_chars_in_error() {
136        let dsn = format_dsn(
137            "mx.example.com",
138            "sender@example.com",
139            "rcpt@remote.com",
140            "550 5.1.1 <rcpt@remote.com>: Recipient address rejected",
141            None,
142        );
143        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
144        assert!(
145            unfold.contains("550 5.1.1 <rcpt@remote.com>: Recipient address rejected"),
146            "diagnostic-code line missing in: {unfold}",
147        );
148    }
149
150    #[test]
151    fn uses_crlf_line_endings() {
152        let dsn = format_dsn(
153            "mx.example.com",
154            "sender@example.com",
155            "rcpt@remote.com",
156            "550 error",
157            None,
158        );
159        // no bare \n (every \n should be preceded by \r)
160        for (i, &b) in dsn.as_bytes().iter().enumerate() {
161            if b == b'\n' {
162                assert!(
163                    i > 0 && dsn.as_bytes()[i - 1] == b'\r',
164                    "bare \\n at byte {i}"
165                );
166            }
167        }
168    }
169
170    #[test]
171    fn dsn_human_readable_part_mentions_recipient() {
172        let dsn = format_dsn(
173            "mx.example.com",
174            "sender@example.com",
175            "rcpt@remote.com",
176            "550 error",
177            None,
178        );
179        // body of the first part should mention the recipient address
180        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
181        assert!(unfold.contains("<rcpt@remote.com>"));
182    }
183}