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!(
53            "Mail Delivery System <mailer-daemon@{reporting_mta}>"
54        ))
55        .to(sender)
56        .subject("Delivery Status Notification (Failure)")
57        .header("Auto-Submitted", "auto-replied")
58        .text_body(human)
59        .attachment(Attachment::new(
60            "delivery-status.txt",
61            "message/delivery-status",
62            machine_body.into_bytes(),
63        ))
64        .report_type("delivery-status")
65        .message_id(dsn_id);
66
67    if let Some(mid) = message_id {
68        b = b.header("References", format!("<{mid}>"));
69    }
70
71    let bytes = b.build();
72    String::from_utf8(bytes).expect("mail-builder output is ASCII-safe by construction")
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn format_basic() {
81        let dsn = format_dsn(
82            "mx.example.com",
83            "sender@example.com",
84            "rcpt@remote.com",
85            "550 User not found",
86            None,
87        );
88        assert!(dsn.contains("From: Mail Delivery System <mailer-daemon@mx.example.com>\r\n"));
89        // mail-builder renders bare addresses without angle brackets;
90        // this is RFC 5322 §3.4 addr-spec form, equally valid as
91        // name-addr <addr-spec>.
92        assert!(dsn.contains("To: sender@example.com\r\n"));
93        // unfold before matching so soft-fold doesn't trip the substring check
94        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
95        assert!(unfold.contains("Final-Recipient: rfc822; rcpt@remote.com"));
96        assert!(unfold.contains("Diagnostic-Code: smtp; 550 User not found"));
97        assert!(!unfold.contains("References:"));
98    }
99
100    #[test]
101    fn format_with_message_id() {
102        let dsn = format_dsn(
103            "mx.example.com",
104            "sender@example.com",
105            "rcpt@remote.com",
106            "421 try again",
107            Some("abc123@example.com"),
108        );
109        assert!(dsn.contains("References: <abc123@example.com>"));
110    }
111
112    #[test]
113    fn dsn_structure() {
114        let dsn = format_dsn(
115            "mx.example.com",
116            "sender@example.com",
117            "rcpt@remote.com",
118            "550 no such user",
119            None,
120        );
121        // unfold for substring checks — header values may wrap
122        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
123        assert!(unfold.contains("multipart/report"));
124        assert!(unfold.contains("report-type=delivery-status"));
125        assert!(unfold.contains("message/delivery-status"));
126        // body still ends with a closing boundary marker — exact
127        // boundary string is random per call (mail-builder), so
128        // we just check there's at least one `--` envelope line
129        let envelope_opens = dsn.matches("\r\n--").count();
130        assert!(envelope_opens >= 2, "got {envelope_opens} envelope markers");
131        assert!(unfold.contains("Reporting-MTA: dns; mx.example.com"));
132        assert!(unfold.contains("Action: failed"));
133        assert!(unfold.contains("Status: 5.0.0"));
134    }
135
136    #[test]
137    fn special_chars_in_error() {
138        let dsn = format_dsn(
139            "mx.example.com",
140            "sender@example.com",
141            "rcpt@remote.com",
142            "550 5.1.1 <rcpt@remote.com>: Recipient address rejected",
143            None,
144        );
145        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
146        assert!(
147            unfold.contains("550 5.1.1 <rcpt@remote.com>: Recipient address rejected"),
148            "diagnostic-code line missing in: {unfold}",
149        );
150    }
151
152    #[test]
153    fn uses_crlf_line_endings() {
154        let dsn = format_dsn(
155            "mx.example.com",
156            "sender@example.com",
157            "rcpt@remote.com",
158            "550 error",
159            None,
160        );
161        // no bare \n (every \n should be preceded by \r)
162        for (i, &b) in dsn.as_bytes().iter().enumerate() {
163            if b == b'\n' {
164                assert!(
165                    i > 0 && dsn.as_bytes()[i - 1] == b'\r',
166                    "bare \\n at byte {i}"
167                );
168            }
169        }
170    }
171
172    #[test]
173    fn dsn_human_readable_part_mentions_recipient() {
174        let dsn = format_dsn(
175            "mx.example.com",
176            "sender@example.com",
177            "rcpt@remote.com",
178            "550 error",
179            None,
180        );
181        // body of the first part should mention the recipient address
182        let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
183        assert!(unfold.contains("<rcpt@remote.com>"));
184    }
185}