1use 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
381pub(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 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 part.transfer_decoded_body().ok()
403 } else {
404 None
405 }
406}
407
408fn 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 Ok(()) if data.is_ascii() => {
427 MimePart::new_no_transfer_encoding("message/rfc822", &data).ok()
428 }
429 _ => returned_headers_part(msg),
432 }
433 }
434 }
435}
436
437fn 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 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 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 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 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 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 pub reporting_mta: RemoteMta,
718
719 #[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 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(¶ms, 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(¶ms, 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(¶ms, 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 #[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(¶ms, 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(¶ms, 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 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(¶ms, 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 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 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 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(¶ms, Some(&original_msg), &log)
1685 .unwrap()
1686 .unwrap()
1687 .to_message_bytes()
1688 .unwrap();
1689
1690 assert!(
1693 report_eml.is_ascii(),
1694 "generated report must be 7-bit clean"
1695 );
1696
1697 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 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(¶ms, 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}