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