mailrs_outbound_queue/
dsn.rs1use mailrs_mail_builder::{Attachment, MessageBuilder};
11
12pub 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 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 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 assert!(dsn.contains("To: sender@example.com\r\n"));
93 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 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 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 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 let unfold = dsn.replace("\r\n ", " ").replace("\r\n\t", " ");
183 assert!(unfold.contains("<rcpt@remote.com>"));
184 }
185}