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
381impl Report {
382 pub fn parse(input: &[u8]) -> anyhow::Result<Option<Self>> {
383 let mail = MimePart::parse(input).with_context(|| {
384 format!(
385 "Report::parse top; input is {:?}",
386 String::from_utf8_lossy(input)
387 )
388 })?;
389
390 if content_type(&mail).as_ref().map(|b| b.as_bstr()) != Some(BStr::new("multipart/report"))
391 {
392 return Ok(None);
393 }
394
395 let mut original_message = None;
396
397 for part in mail.child_parts() {
398 let ct = content_type(part);
399 let ct = ct.as_ref().map(|b| b.as_bstr());
400 if ct == Some(BStr::new("message/rfc822"))
401 || ct == Some(BStr::new("text/rfc822-headers"))
402 {
403 original_message = Some(BString::new(
404 part.raw_body().as_bytes().replace(b"\r\n", b"\n"),
405 ));
406 }
407 }
408
409 for part in mail.child_parts() {
410 let ct = content_type(part);
411 let ct = ct.as_ref().map(|b| b.as_bstr());
412 if ct == Some(BStr::new("message/delivery-status"))
413 || ct == Some(BStr::new("message/global-delivery-status"))
414 {
415 return Ok(Some(Self::parse_inner(part, original_message)?));
416 }
417 }
418
419 anyhow::bail!("delivery-status part missing");
420 }
421
422 fn parse_inner(part: &MimePart, original_message: Option<BString>) -> anyhow::Result<Self> {
423 let body = part.body()?.to_string_lossy().replace("\r\n", "\n");
424 let mut parts = body.trim().split("\n\n");
425
426 let per_message = parts
427 .next()
428 .ok_or_else(|| anyhow!("missing per-message section"))?;
429 let per_message = PerMessageReportEntry::parse(per_message)?;
430 let mut per_recipient = vec![];
431 for part in parts {
432 let part = PerRecipientReportEntry::parse(part)?;
433 per_recipient.push(part);
434 }
435
436 Ok(Self {
437 per_message,
438 per_recipient,
439 original_message,
440 })
441 }
442
443 pub fn generate(
446 params: &ReportGenerationParams,
447 msg: Option<&MimePart<'_>>,
448 log: &JsonLogRecord,
449 ) -> anyhow::Result<Option<MimePart<'static>>> {
450 if log.sender.is_empty() {
451 return Ok(None);
453 }
454
455 let action = match &log.kind {
456 RecordType::Bounce
457 if params.enable_bounce && log.delivery_protocol.as_deref() == Some("ESMTP") =>
458 {
459 ReportAction::Failed
460 }
461 RecordType::Expiration if params.enable_expiration => ReportAction::Failed,
462 _ => return Ok(None),
463 };
464
465 let created = format_rfc2822_date(log.created);
466
467 let arrival_date = Some(log.created);
468
469 let per_message = PerMessageReportEntry {
470 arrival_date,
471 dsn_gateway: None,
472 extensions: Default::default(),
473 original_envelope_id: None,
474 received_from_mta: None,
475 reporting_mta: params.reporting_mta.clone(),
476 };
477
478 let mut per_recipient = vec![];
479 let recip_list = log.recipient.join(", ");
480
481 for recip in &log.recipient {
482 per_recipient.push(PerRecipientReportEntry {
483 action,
484 extensions: Default::default(),
485 status: (&log.response).into(),
486 diagnostic_code: Some(DiagnosticCode {
487 diagnostic_type: "smtp".into(),
488 diagnostic: log.response.to_single_line(),
489 }),
490 final_log_id: None,
491 original_recipient: None,
492 final_recipient: Recipient {
493 recipient_type: "rfc822".to_string(),
494 recipient: recip.to_string(),
495 },
496 remote_mta: log.peer_address.as_ref().map(|addr| RemoteMta {
497 mta_type: "dns".to_string(),
498 name: addr.name.to_string(),
499 }),
500 last_attempt_date: Some(log.timestamp),
501 will_retry_until: None,
502 });
503 }
504
505 let mut parts = vec![];
506
507 let exposition = match &log.kind {
508 RecordType::Bounce => {
509 let mut data = format!(
510 "The message was received at {created}\r\n\
511 from {sender} and addressed to {recip_list}.\r\n\
512 ",
513 sender = log.sender,
514 );
515 if let Some(peer) = &log.peer_address {
516 data.push_str(&format!(
517 "While communicating with {host} ({ip}):\r\n\
518 Response: {resp}\r\n",
519 host = peer.name,
520 ip = peer.addr,
521 resp = log.response.to_single_line(),
522 ));
523 } else {
524 data.push_str(&format!("Status: {}\r\n", log.response.to_single_line()));
525 }
526
527 data.push_str(
528 "\r\nThe message will be deleted from the queue.\r\n\
529 No further attempts will be made to deliver it.\r\n",
530 );
531
532 data
533 }
534 RecordType::Expiration => {
535 format!(
536 "The message was received at {created}\r\n\
537 from {sender} and addressed to {recip_list}.\r\n\
538 Status: {status}\r\n\
539 The message will be deleted from the queue.\r\n\
540 No further attempts will be made to deliver it.\r\n\
541 ",
542 sender = log.sender,
543 status = log.response.to_single_line()
544 )
545 }
546 _ => unreachable!(),
547 };
548
549 parts.push(MimePart::new_text_plain(&*exposition).context("new_text_plain")?);
550
551 let mut status_text = format!("{per_message}\r\n");
552 for per_recip in per_recipient {
553 status_text.push_str(&format!("{per_recip}\r\n"));
554 }
555 parts.push(
556 MimePart::new_text("message/delivery-status", &*status_text).context("new_text")?,
557 );
558
559 match (params.include_original_message, msg) {
560 (IncludeOriginalMessage::No, _) | (_, None) => {}
561 (IncludeOriginalMessage::HeadersOnly, Some(msg)) => {
562 let mut data = vec![];
563 for hdr in msg.headers().iter() {
564 hdr.write_header(&mut data).ok();
565 }
566 parts.push(
567 MimePart::new_no_transfer_encoding("text/rfc822-headers", &data)
568 .context("new_no_transfer_encoding")?,
569 );
570 }
571 (IncludeOriginalMessage::FullContent, Some(msg)) => {
572 let mut data = vec![];
573 msg.write_message(&mut data).ok();
574 parts.push(
575 MimePart::new_no_transfer_encoding("message/rfc822", &data)
576 .context("new_no_transfer_encoding")?,
577 );
578 }
579 };
580
581 let mut report_msg = MimePart::new_multipart(
582 "multipart/report",
583 parts,
584 if params.stable_content {
585 Some(b"report-boundary")
586 } else {
587 None
588 },
589 )?;
590
591 let mut ct = report_msg
592 .headers()
593 .content_type()
594 .context("get content_type")?
595 .expect("assigned during construction");
596 ct.set("report-type", "delivery-status");
597 report_msg
598 .headers_mut()
599 .set_content_type(ct)
600 .context("set_content_type")?;
601 report_msg
602 .headers_mut()
603 .set_subject("Returned mail")
604 .context("set_subject")?;
605 report_msg
606 .headers_mut()
607 .set_mime_version("1.0")
608 .context("set_mime_version")?;
609
610 let message_id = if params.stable_content {
611 format!("<UUID@{}>", params.reporting_mta.name)
612 } else {
613 let id = uuid_helper::now_v1();
614 format!("<{id}@{}>", params.reporting_mta.name)
615 };
616 report_msg
617 .headers_mut()
618 .set_message_id(message_id.as_str())?;
619 report_msg
620 .headers_mut()
621 .set_to(log.sender.as_str())
622 .context("set_to")?;
623
624 let from = format!(
625 "Mail Delivery Subsystem <mailer-daemon@{}>",
626 params.reporting_mta.name
627 );
628 report_msg
629 .headers_mut()
630 .set_from(from.as_str())
631 .context("set_from")?;
632
633 Ok(Some(report_msg))
634 }
635}
636
637#[derive(Default, Debug, PartialEq, Clone, Copy, Deserialize)]
638pub enum IncludeOriginalMessage {
639 #[default]
640 No,
641 HeadersOnly,
642 FullContent,
643}
644
645#[derive(Debug, PartialEq, Clone, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct ReportGenerationParams {
648 pub include_original_message: IncludeOriginalMessage,
649 #[serde(default)]
650 pub enable_expiration: bool,
651 #[serde(default)]
652 pub enable_bounce: bool,
653 pub reporting_mta: RemoteMta,
661
662 #[serde(default)]
664 pub stable_content: bool,
665}
666
667#[cfg(test)]
668mod test {
669 use super::*;
670 use crate::ResolvedAddress;
671 use rfc5321::{EnhancedStatusCode, Response};
672
673 #[test]
674 fn parse_ses_obsolete_arrival_date() {
675 let report = Report::parse(include_bytes!("../data/rfc3464/obsolete_arrival_date.eml"))
679 .unwrap()
680 .expect("multipart/report DSN should parse as a report");
681 k9::snapshot!(
682 &report,
683 r#"
684Report {
685 per_message: PerMessageReportEntry {
686 original_envelope_id: None,
687 reporting_mta: RemoteMta {
688 mta_type: "dns",
689 name: "mx.example.com",
690 },
691 dsn_gateway: None,
692 received_from_mta: None,
693 arrival_date: Some(
694 2026-07-02T18:55:38Z,
695 ),
696 extensions: {},
697 },
698 per_recipient: [
699 PerRecipientReportEntry {
700 final_recipient: Recipient {
701 recipient_type: "rfc822",
702 recipient: "user@example.com",
703 },
704 action: Failed,
705 status: ReportStatus {
706 class: 5,
707 subject: 1,
708 detail: 1,
709 comment: None,
710 },
711 original_recipient: Some(
712 Recipient {
713 recipient_type: "rfc822",
714 recipient: "user@example.com",
715 },
716 ),
717 remote_mta: None,
718 diagnostic_code: Some(
719 DiagnosticCode {
720 diagnostic_type: "smtp",
721 diagnostic: "550 5.1.1 Mailbox does not exist",
722 },
723 ),
724 last_attempt_date: None,
725 final_log_id: None,
726 will_retry_until: None,
727 extensions: {},
728 },
729 ],
730 original_message: None,
731}
732"#
733 );
734 }
735
736 fn make_message() -> MimePart<'static> {
737 let mut part = MimePart::new_text_plain("hello there").unwrap();
738 part.headers_mut().set_subject("Hello!").unwrap();
739
740 part
741 }
742
743 fn make_bounce() -> JsonLogRecord {
744 let nodeid = uuid_helper::now_v1();
745 let created = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
746 let now = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
747 JsonLogRecord {
748 kind: RecordType::Bounce,
749 id: "ID".to_string(),
750 nodeid,
751 created: created.into(),
752 bounce_classification: Default::default(),
753 delivery_protocol: Some("ESMTP".to_string()),
754 egress_pool: None,
755 egress_source: None,
756 feedback_report: None,
757 headers: Default::default(),
758 meta: Default::default(),
759 num_attempts: 1,
760 peer_address: Some(ResolvedAddress {
761 name: "target.example.com".to_string(),
762 addr: "42.42.42.42".to_string().try_into().unwrap(),
763 is_secure: false,
764 }),
765 provider_name: None,
766 queue: "target.example.com".to_string(),
767 reception_protocol: None,
768 recipient: vec!["recip@target.example.com".to_string()],
769 sender: "sender@sender.example.com".to_string(),
770 session_id: None,
771 response: Response {
772 code: 550,
773 command: None,
774 content: "no thanks".to_string(),
775 enhanced_code: Some(EnhancedStatusCode {
776 class: 5,
777 subject: 7,
778 detail: 1,
779 }),
780 },
781 site: "some-site".to_string(),
782 size: 0,
783 source_address: None,
784 timestamp: now.into(),
785 tls_cipher: None,
786 tls_peer_subject_name: None,
787 tls_protocol_version: None,
788 }
789 }
790
791 fn make_expiration() -> JsonLogRecord {
792 let nodeid = uuid_helper::now_v1();
793 let created = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
794 let now = mailparsing::parse_rfc2822_date("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
795 JsonLogRecord {
796 kind: RecordType::Expiration,
797 id: "ID".to_string(),
798 nodeid,
799 created: created.into(),
800 bounce_classification: Default::default(),
801 delivery_protocol: None,
802 egress_pool: None,
803 egress_source: None,
804 feedback_report: None,
805 headers: Default::default(),
806 meta: Default::default(),
807 num_attempts: 3,
808 peer_address: None,
809 provider_name: None,
810 queue: "target.example.com".to_string(),
811 reception_protocol: None,
812 recipient: vec!["recip@target.example.com".to_string()],
813 sender: "sender@sender.example.com".to_string(),
814 session_id: None,
815 response: Response {
816 code: 551,
817 command: None,
818 content: "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling".to_string(),
819 enhanced_code: Some(EnhancedStatusCode {
820 class: 5,
821 subject: 4,
822 detail: 7,
823 }),
824 },
825 site: "".to_string(),
826 size: 0,
827 source_address: None,
828 timestamp: now.into(),
829 tls_cipher: None,
830 tls_peer_subject_name: None,
831 tls_protocol_version: None,
832 }
833 }
834
835 #[test]
836 fn generate_expiration_with_headers() {
837 let params = ReportGenerationParams {
838 reporting_mta: RemoteMta {
839 mta_type: "dns".to_string(),
840 name: "mta1.example.com".to_string(),
841 },
842 enable_bounce: false,
843 enable_expiration: true,
844 include_original_message: IncludeOriginalMessage::HeadersOnly,
845 stable_content: true,
846 };
847
848 let original_msg = make_message();
849
850 let log = make_expiration();
851
852 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
853 .unwrap()
854 .unwrap();
855 let report_eml = BString::from(report_msg.to_message_bytes());
856 k9::snapshot!(
857 &report_eml,
858 r#"
859Content-Type: multipart/report;\r
860\tboundary="report-boundary";\r
861\treport-type="delivery-status"\r
862Subject: Returned mail\r
863Mime-Version: 1.0\r
864Message-ID: <UUID@mta1.example.com>\r
865To: sender@sender.example.com\r
866From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
867\r
868--report-boundary\r
869Content-Type: text/plain;\r
870\tcharset="us-ascii"\r
871Content-Transfer-Encoding: quoted-printable\r
872\r
873The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
874from sender@sender.example.com and addressed to recip@target.example.com.\r
875Status: 551 5.4.7 Next delivery time would be at SOME TIME which exceeds th=\r
876e expiry time EXPIRES configured via set_scheduling\r
877The message will be deleted from the queue.\r
878No further attempts will be made to deliver it.\r
879--report-boundary\r
880Content-Type: message/delivery-status;\r
881\tcharset="us-ascii"\r
882Content-Transfer-Encoding: quoted-printable\r
883\r
884Reporting-MTA: dns; mta1.example.com\r
885Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
886\r
887Final-Recipient: rfc822;recip@target.example.com\r
888Action: failed\r
889Status: 5.4.7 Next delivery time would be at SOME TIME which exceeds the ex=\r
890piry time EXPIRES configured via set_scheduling\r
891Diagnostic-Code: smtp; 551 5.4.7 Next delivery time would be at SOME TIME w=\r
892hich exceeds the expiry time EXPIRES configured via set_scheduling\r
893Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
894\r
895--report-boundary\r
896Content-Type: text/rfc822-headers\r
897\r
898Content-Type: text/plain;\r
899\tcharset="us-ascii"\r
900Subject: Hello!\r
901--report-boundary--\r
902
903"#
904 );
905
906 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
907 k9::snapshot!(
908 &round_trip,
909 r#"
910Report {
911 per_message: PerMessageReportEntry {
912 original_envelope_id: None,
913 reporting_mta: RemoteMta {
914 mta_type: "dns",
915 name: "mta1.example.com",
916 },
917 dsn_gateway: None,
918 received_from_mta: None,
919 arrival_date: Some(
920 2003-07-01T08:52:37Z,
921 ),
922 extensions: {},
923 },
924 per_recipient: [
925 PerRecipientReportEntry {
926 final_recipient: Recipient {
927 recipient_type: "rfc822",
928 recipient: "recip@target.example.com",
929 },
930 action: Failed,
931 status: ReportStatus {
932 class: 5,
933 subject: 4,
934 detail: 7,
935 comment: Some(
936 "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
937 ),
938 },
939 original_recipient: None,
940 remote_mta: None,
941 diagnostic_code: Some(
942 DiagnosticCode {
943 diagnostic_type: "smtp",
944 diagnostic: "551 5.4.7 Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
945 },
946 ),
947 last_attempt_date: Some(
948 2003-07-01T10:52:37Z,
949 ),
950 final_log_id: None,
951 will_retry_until: None,
952 extensions: {},
953 },
954 ],
955 original_message: Some(
956 "Content-Type: text/plain;
957\tcharset="us-ascii"
958Subject: Hello!
959",
960 ),
961}
962"#
963 );
964 }
965
966 #[test]
967 fn generate_bounce_with_headers() {
968 let params = ReportGenerationParams {
969 reporting_mta: RemoteMta {
970 mta_type: "dns".to_string(),
971 name: "mta1.example.com".to_string(),
972 },
973 enable_bounce: true,
974 enable_expiration: true,
975 include_original_message: IncludeOriginalMessage::HeadersOnly,
976 stable_content: true,
977 };
978
979 let original_msg = make_message();
980
981 let log = make_bounce();
982
983 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
984 .unwrap()
985 .unwrap();
986 let report_eml = BString::from(report_msg.to_message_bytes());
987 k9::snapshot!(
988 &report_eml,
989 r#"
990Content-Type: multipart/report;\r
991\tboundary="report-boundary";\r
992\treport-type="delivery-status"\r
993Subject: Returned mail\r
994Mime-Version: 1.0\r
995Message-ID: <UUID@mta1.example.com>\r
996To: sender@sender.example.com\r
997From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
998\r
999--report-boundary\r
1000Content-Type: text/plain;\r
1001\tcharset="us-ascii"\r
1002\r
1003The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1004from sender@sender.example.com and addressed to recip@target.example.com.\r
1005While communicating with target.example.com (42.42.42.42):\r
1006Response: 550 5.7.1 no thanks\r
1007\r
1008The message will be deleted from the queue.\r
1009No further attempts will be made to deliver it.\r
1010--report-boundary\r
1011Content-Type: message/delivery-status;\r
1012\tcharset="us-ascii"\r
1013\r
1014Reporting-MTA: dns; mta1.example.com\r
1015Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1016\r
1017Final-Recipient: rfc822;recip@target.example.com\r
1018Action: failed\r
1019Status: 5.7.1 no thanks\r
1020Remote-MTA: dns; target.example.com\r
1021Diagnostic-Code: smtp; 550 5.7.1 no thanks\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: 7,
1063 detail: 1,
1064 comment: Some(
1065 "no thanks",
1066 ),
1067 },
1068 original_recipient: None,
1069 remote_mta: Some(
1070 RemoteMta {
1071 mta_type: "dns",
1072 name: "target.example.com",
1073 },
1074 ),
1075 diagnostic_code: Some(
1076 DiagnosticCode {
1077 diagnostic_type: "smtp",
1078 diagnostic: "550 5.7.1 no thanks",
1079 },
1080 ),
1081 last_attempt_date: Some(
1082 2003-07-01T10:52:37Z,
1083 ),
1084 final_log_id: None,
1085 will_retry_until: None,
1086 extensions: {},
1087 },
1088 ],
1089 original_message: Some(
1090 "Content-Type: text/plain;
1091\tcharset="us-ascii"
1092Subject: Hello!
1093",
1094 ),
1095}
1096"#
1097 );
1098 }
1099 #[test]
1100 fn generate_bounce_with_message() {
1101 let params = ReportGenerationParams {
1102 reporting_mta: RemoteMta {
1103 mta_type: "dns".to_string(),
1104 name: "mta1.example.com".to_string(),
1105 },
1106 enable_bounce: true,
1107 enable_expiration: true,
1108 include_original_message: IncludeOriginalMessage::FullContent,
1109 stable_content: true,
1110 };
1111
1112 let original_msg = make_message();
1113
1114 let log = make_bounce();
1115
1116 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
1117 .unwrap()
1118 .unwrap();
1119 let report_eml = BString::from(report_msg.to_message_bytes());
1120 k9::snapshot!(
1121 &report_eml,
1122 r#"
1123Content-Type: multipart/report;\r
1124\tboundary="report-boundary";\r
1125\treport-type="delivery-status"\r
1126Subject: Returned mail\r
1127Mime-Version: 1.0\r
1128Message-ID: <UUID@mta1.example.com>\r
1129To: sender@sender.example.com\r
1130From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1131\r
1132--report-boundary\r
1133Content-Type: text/plain;\r
1134\tcharset="us-ascii"\r
1135\r
1136The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1137from sender@sender.example.com and addressed to recip@target.example.com.\r
1138While communicating with target.example.com (42.42.42.42):\r
1139Response: 550 5.7.1 no thanks\r
1140\r
1141The message will be deleted from the queue.\r
1142No further attempts will be made to deliver it.\r
1143--report-boundary\r
1144Content-Type: message/delivery-status;\r
1145\tcharset="us-ascii"\r
1146\r
1147Reporting-MTA: dns; mta1.example.com\r
1148Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1149\r
1150Final-Recipient: rfc822;recip@target.example.com\r
1151Action: failed\r
1152Status: 5.7.1 no thanks\r
1153Remote-MTA: dns; target.example.com\r
1154Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1155Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1156\r
1157--report-boundary\r
1158Content-Type: message/rfc822\r
1159\r
1160Content-Type: text/plain;\r
1161\tcharset="us-ascii"\r
1162Subject: Hello!\r
1163\r
1164hello there\r
1165--report-boundary--\r
1166
1167"#
1168 );
1169
1170 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1171 k9::snapshot!(
1172 &round_trip,
1173 r#"
1174Report {
1175 per_message: PerMessageReportEntry {
1176 original_envelope_id: None,
1177 reporting_mta: RemoteMta {
1178 mta_type: "dns",
1179 name: "mta1.example.com",
1180 },
1181 dsn_gateway: None,
1182 received_from_mta: None,
1183 arrival_date: Some(
1184 2003-07-01T08:52:37Z,
1185 ),
1186 extensions: {},
1187 },
1188 per_recipient: [
1189 PerRecipientReportEntry {
1190 final_recipient: Recipient {
1191 recipient_type: "rfc822",
1192 recipient: "recip@target.example.com",
1193 },
1194 action: Failed,
1195 status: ReportStatus {
1196 class: 5,
1197 subject: 7,
1198 detail: 1,
1199 comment: Some(
1200 "no thanks",
1201 ),
1202 },
1203 original_recipient: None,
1204 remote_mta: Some(
1205 RemoteMta {
1206 mta_type: "dns",
1207 name: "target.example.com",
1208 },
1209 ),
1210 diagnostic_code: Some(
1211 DiagnosticCode {
1212 diagnostic_type: "smtp",
1213 diagnostic: "550 5.7.1 no thanks",
1214 },
1215 ),
1216 last_attempt_date: Some(
1217 2003-07-01T10:52:37Z,
1218 ),
1219 final_log_id: None,
1220 will_retry_until: None,
1221 extensions: {},
1222 },
1223 ],
1224 original_message: Some(
1225 "Content-Type: text/plain;
1226\tcharset="us-ascii"
1227Subject: Hello!
1228
1229hello there
1230",
1231 ),
1232}
1233"#
1234 );
1235 }
1236
1237 #[test]
1238 fn generate_bounce_no_message() {
1239 let params = ReportGenerationParams {
1240 reporting_mta: RemoteMta {
1241 mta_type: "dns".to_string(),
1242 name: "mta1.example.com".to_string(),
1243 },
1244 enable_bounce: true,
1245 enable_expiration: true,
1246 include_original_message: IncludeOriginalMessage::No,
1247 stable_content: true,
1248 };
1249
1250 let original_msg = make_message();
1251
1252 let log = make_bounce();
1253
1254 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
1255 .unwrap()
1256 .unwrap();
1257 let report_eml = BString::from(report_msg.to_message_bytes());
1258 k9::snapshot!(
1259 &report_eml,
1260 r#"
1261Content-Type: multipart/report;\r
1262\tboundary="report-boundary";\r
1263\treport-type="delivery-status"\r
1264Subject: Returned mail\r
1265Mime-Version: 1.0\r
1266Message-ID: <UUID@mta1.example.com>\r
1267To: sender@sender.example.com\r
1268From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1269\r
1270--report-boundary\r
1271Content-Type: text/plain;\r
1272\tcharset="us-ascii"\r
1273\r
1274The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1275from sender@sender.example.com and addressed to recip@target.example.com.\r
1276While communicating with target.example.com (42.42.42.42):\r
1277Response: 550 5.7.1 no thanks\r
1278\r
1279The message will be deleted from the queue.\r
1280No further attempts will be made to deliver it.\r
1281--report-boundary\r
1282Content-Type: message/delivery-status;\r
1283\tcharset="us-ascii"\r
1284\r
1285Reporting-MTA: dns; mta1.example.com\r
1286Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1287\r
1288Final-Recipient: rfc822;recip@target.example.com\r
1289Action: failed\r
1290Status: 5.7.1 no thanks\r
1291Remote-MTA: dns; target.example.com\r
1292Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1293Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1294\r
1295--report-boundary--\r
1296
1297"#
1298 );
1299
1300 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1301 k9::snapshot!(
1302 &round_trip,
1303 r#"
1304Report {
1305 per_message: PerMessageReportEntry {
1306 original_envelope_id: None,
1307 reporting_mta: RemoteMta {
1308 mta_type: "dns",
1309 name: "mta1.example.com",
1310 },
1311 dsn_gateway: None,
1312 received_from_mta: None,
1313 arrival_date: Some(
1314 2003-07-01T08:52:37Z,
1315 ),
1316 extensions: {},
1317 },
1318 per_recipient: [
1319 PerRecipientReportEntry {
1320 final_recipient: Recipient {
1321 recipient_type: "rfc822",
1322 recipient: "recip@target.example.com",
1323 },
1324 action: Failed,
1325 status: ReportStatus {
1326 class: 5,
1327 subject: 7,
1328 detail: 1,
1329 comment: Some(
1330 "no thanks",
1331 ),
1332 },
1333 original_recipient: None,
1334 remote_mta: Some(
1335 RemoteMta {
1336 mta_type: "dns",
1337 name: "target.example.com",
1338 },
1339 ),
1340 diagnostic_code: Some(
1341 DiagnosticCode {
1342 diagnostic_type: "smtp",
1343 diagnostic: "550 5.7.1 no thanks",
1344 },
1345 ),
1346 last_attempt_date: Some(
1347 2003-07-01T10:52:37Z,
1348 ),
1349 final_log_id: None,
1350 will_retry_until: None,
1351 extensions: {},
1352 },
1353 ],
1354 original_message: None,
1355}
1356"#
1357 );
1358 }
1359
1360 #[test]
1361 fn generate_bounce_far_future_created() {
1362 use chrono::TimeZone;
1363
1364 let params = ReportGenerationParams {
1365 reporting_mta: RemoteMta {
1366 mta_type: "dns".to_string(),
1367 name: "mta1.example.com".to_string(),
1368 },
1369 enable_bounce: true,
1370 enable_expiration: true,
1371 include_original_message: IncludeOriginalMessage::No,
1372 stable_content: true,
1373 };
1374
1375 let mut log = make_bounce();
1379 log.created = chrono::Utc.with_ymd_and_hms(60123, 1, 1, 0, 0, 0).unwrap();
1380
1381 let report_msg = Report::generate(¶ms, None, &log).unwrap().unwrap();
1382 let report_eml = BString::from(report_msg.to_message_bytes());
1383 k9::snapshot!(
1384 &report_eml,
1385 r#"
1386Content-Type: multipart/report;\r
1387\tboundary="report-boundary";\r
1388\treport-type="delivery-status"\r
1389Subject: Returned mail\r
1390Mime-Version: 1.0\r
1391Message-ID: <UUID@mta1.example.com>\r
1392To: sender@sender.example.com\r
1393From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1394\r
1395--report-boundary\r
1396Content-Type: text/plain;\r
1397\tcharset="us-ascii"\r
1398\r
1399The message was received at Fri, 1 Jan 60123 00:00:00 +0000\r
1400from sender@sender.example.com and addressed to recip@target.example.com.\r
1401While communicating with target.example.com (42.42.42.42):\r
1402Response: 550 5.7.1 no thanks\r
1403\r
1404The message will be deleted from the queue.\r
1405No further attempts will be made to deliver it.\r
1406--report-boundary\r
1407Content-Type: message/delivery-status;\r
1408\tcharset="us-ascii"\r
1409\r
1410Reporting-MTA: dns; mta1.example.com\r
1411Arrival-Date: Fri, 1 Jan 60123 00:00:00 +0000\r
1412\r
1413Final-Recipient: rfc822;recip@target.example.com\r
1414Action: failed\r
1415Status: 5.7.1 no thanks\r
1416Remote-MTA: dns; target.example.com\r
1417Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1418Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1419\r
1420--report-boundary--\r
1421
1422"#
1423 );
1424 }
1425
1426 #[test]
1427 fn rfc3464_1() {
1428 let result = Report::parse(include_bytes!("../data/rfc3464/1.eml")).unwrap();
1429 k9::snapshot!(
1430 &result,
1431 r#"
1432Some(
1433 Report {
1434 per_message: PerMessageReportEntry {
1435 original_envelope_id: None,
1436 reporting_mta: RemoteMta {
1437 mta_type: "dns",
1438 name: "cs.utk.edu",
1439 },
1440 dsn_gateway: None,
1441 received_from_mta: None,
1442 arrival_date: None,
1443 extensions: {},
1444 },
1445 per_recipient: [
1446 PerRecipientReportEntry {
1447 final_recipient: Recipient {
1448 recipient_type: "rfc822",
1449 recipient: "louisl@larry.slip.umd.edu",
1450 },
1451 action: Failed,
1452 status: ReportStatus {
1453 class: 4,
1454 subject: 0,
1455 detail: 0,
1456 comment: None,
1457 },
1458 original_recipient: Some(
1459 Recipient {
1460 recipient_type: "rfc822",
1461 recipient: "louisl@larry.slip.umd.edu",
1462 },
1463 ),
1464 remote_mta: None,
1465 diagnostic_code: Some(
1466 DiagnosticCode {
1467 diagnostic_type: "smtp",
1468 diagnostic: "426 connection timed out",
1469 },
1470 ),
1471 last_attempt_date: Some(
1472 1994-07-07T21:15:49Z,
1473 ),
1474 final_log_id: None,
1475 will_retry_until: None,
1476 extensions: {},
1477 },
1478 ],
1479 original_message: Some(
1480 "[original message goes here]
1481
1482",
1483 ),
1484 },
1485)
1486"#
1487 );
1488
1489 let report = result.unwrap();
1490
1491 assert_eq!(
1492 report.per_message.to_string(),
1493 "Reporting-MTA: dns; cs.utk.edu\r\n"
1494 );
1495 assert_eq!(
1496 report.per_recipient[0].to_string(),
1497 "Original-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1498 Final-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1499 Action: failed\r\n\
1500 Status: 4.0.0\r\n\
1501 Diagnostic-Code: smtp; 426 connection timed out\r\n\
1502 Last-Attempt-Date: Thu, 7 Jul 1994 21:15:49 +0000\r\n"
1503 );
1504 }
1505
1506 #[test]
1507 fn rfc3464_2() {
1508 let result = Report::parse(include_bytes!("../data/rfc3464/2.eml")).unwrap();
1509 k9::snapshot!(
1510 result,
1511 r#"
1512Some(
1513 Report {
1514 per_message: PerMessageReportEntry {
1515 original_envelope_id: None,
1516 reporting_mta: RemoteMta {
1517 mta_type: "dns",
1518 name: "cs.utk.edu",
1519 },
1520 dsn_gateway: None,
1521 received_from_mta: None,
1522 arrival_date: None,
1523 extensions: {},
1524 },
1525 per_recipient: [
1526 PerRecipientReportEntry {
1527 final_recipient: Recipient {
1528 recipient_type: "rfc822",
1529 recipient: "arathib@vnet.ibm.com",
1530 },
1531 action: Failed,
1532 status: ReportStatus {
1533 class: 5,
1534 subject: 0,
1535 detail: 0,
1536 comment: Some(
1537 "(permanent failure)",
1538 ),
1539 },
1540 original_recipient: Some(
1541 Recipient {
1542 recipient_type: "rfc822",
1543 recipient: "arathib@vnet.ibm.com",
1544 },
1545 ),
1546 remote_mta: Some(
1547 RemoteMta {
1548 mta_type: "dns",
1549 name: "vnet.ibm.com",
1550 },
1551 ),
1552 diagnostic_code: Some(
1553 DiagnosticCode {
1554 diagnostic_type: "smtp",
1555 diagnostic: "550 'arathib@vnet.IBM.COM' is not a registered gateway user",
1556 },
1557 ),
1558 last_attempt_date: None,
1559 final_log_id: None,
1560 will_retry_until: None,
1561 extensions: {},
1562 },
1563 PerRecipientReportEntry {
1564 final_recipient: Recipient {
1565 recipient_type: "rfc822",
1566 recipient: "johnh@hpnjld.njd.hp.com",
1567 },
1568 action: Delayed,
1569 status: ReportStatus {
1570 class: 4,
1571 subject: 0,
1572 detail: 0,
1573 comment: Some(
1574 "(hpnjld.njd.jp.com: host name lookup failure)",
1575 ),
1576 },
1577 original_recipient: Some(
1578 Recipient {
1579 recipient_type: "rfc822",
1580 recipient: "johnh@hpnjld.njd.hp.com",
1581 },
1582 ),
1583 remote_mta: None,
1584 diagnostic_code: None,
1585 last_attempt_date: None,
1586 final_log_id: None,
1587 will_retry_until: None,
1588 extensions: {},
1589 },
1590 PerRecipientReportEntry {
1591 final_recipient: Recipient {
1592 recipient_type: "rfc822",
1593 recipient: "wsnell@sdcc13.ucsd.edu",
1594 },
1595 action: Failed,
1596 status: ReportStatus {
1597 class: 5,
1598 subject: 0,
1599 detail: 0,
1600 comment: None,
1601 },
1602 original_recipient: Some(
1603 Recipient {
1604 recipient_type: "rfc822",
1605 recipient: "wsnell@sdcc13.ucsd.edu",
1606 },
1607 ),
1608 remote_mta: Some(
1609 RemoteMta {
1610 mta_type: "dns",
1611 name: "sdcc13.ucsd.edu",
1612 },
1613 ),
1614 diagnostic_code: Some(
1615 DiagnosticCode {
1616 diagnostic_type: "smtp",
1617 diagnostic: "550 user unknown",
1618 },
1619 ),
1620 last_attempt_date: None,
1621 final_log_id: None,
1622 will_retry_until: None,
1623 extensions: {},
1624 },
1625 ],
1626 original_message: Some(
1627 "[original message goes here]
1628
1629",
1630 ),
1631 },
1632)
1633"#
1634 );
1635 }
1636
1637 #[test]
1638 fn rfc3464_3() {
1639 let result = Report::parse(include_bytes!("../data/rfc3464/3.eml")).unwrap();
1640 k9::snapshot!(
1641 result,
1642 r#"
1643Some(
1644 Report {
1645 per_message: PerMessageReportEntry {
1646 original_envelope_id: None,
1647 reporting_mta: RemoteMta {
1648 mta_type: "mailbus",
1649 name: "SYS30",
1650 },
1651 dsn_gateway: None,
1652 received_from_mta: None,
1653 arrival_date: None,
1654 extensions: {},
1655 },
1656 per_recipient: [
1657 PerRecipientReportEntry {
1658 final_recipient: Recipient {
1659 recipient_type: "unknown",
1660 recipient: "nair_s",
1661 },
1662 action: Failed,
1663 status: ReportStatus {
1664 class: 5,
1665 subject: 0,
1666 detail: 0,
1667 comment: Some(
1668 "(unknown permanent failure)",
1669 ),
1670 },
1671 original_recipient: None,
1672 remote_mta: None,
1673 diagnostic_code: None,
1674 last_attempt_date: None,
1675 final_log_id: None,
1676 will_retry_until: None,
1677 extensions: {},
1678 },
1679 ],
1680 original_message: None,
1681 },
1682)
1683"#
1684 );
1685 }
1686
1687 #[test]
1688 fn rfc3464_4() {
1689 let result = Report::parse(include_bytes!("../data/rfc3464/4.eml")).unwrap();
1690 k9::snapshot!(
1691 result,
1692 r#"
1693Some(
1694 Report {
1695 per_message: PerMessageReportEntry {
1696 original_envelope_id: None,
1697 reporting_mta: RemoteMta {
1698 mta_type: "dns",
1699 name: "sun2.nsfnet-relay.ac.uk",
1700 },
1701 dsn_gateway: None,
1702 received_from_mta: None,
1703 arrival_date: None,
1704 extensions: {},
1705 },
1706 per_recipient: [
1707 PerRecipientReportEntry {
1708 final_recipient: Recipient {
1709 recipient_type: "rfc822",
1710 recipient: "thomas@de-montfort.ac.uk",
1711 },
1712 action: Delayed,
1713 status: ReportStatus {
1714 class: 4,
1715 subject: 0,
1716 detail: 0,
1717 comment: Some(
1718 "(unknown temporary failure)",
1719 ),
1720 },
1721 original_recipient: None,
1722 remote_mta: None,
1723 diagnostic_code: None,
1724 last_attempt_date: None,
1725 final_log_id: None,
1726 will_retry_until: None,
1727 extensions: {},
1728 },
1729 ],
1730 original_message: None,
1731 },
1732)
1733"#
1734 );
1735 }
1736
1737 #[test]
1738 fn rfc3464_5() {
1739 let result = Report::parse(include_bytes!("../data/rfc3464/5.eml")).unwrap();
1740 k9::snapshot!(
1741 result,
1742 r#"
1743Some(
1744 Report {
1745 per_message: PerMessageReportEntry {
1746 original_envelope_id: None,
1747 reporting_mta: RemoteMta {
1748 mta_type: "dns",
1749 name: "mx-by.bbox.fr",
1750 },
1751 dsn_gateway: None,
1752 received_from_mta: None,
1753 arrival_date: Some(
1754 2025-01-29T16:36:51Z,
1755 ),
1756 extensions: {
1757 "x-postfix-queue-id": [
1758 "897DAC0",
1759 ],
1760 "x-postfix-sender": [
1761 "rfc822; user@example.com",
1762 ],
1763 },
1764 },
1765 per_recipient: [
1766 PerRecipientReportEntry {
1767 final_recipient: Recipient {
1768 recipient_type: "rfc822",
1769 recipient: "recipient@domain.com",
1770 },
1771 action: Failed,
1772 status: ReportStatus {
1773 class: 5,
1774 subject: 0,
1775 detail: 0,
1776 comment: None,
1777 },
1778 original_recipient: Some(
1779 Recipient {
1780 recipient_type: "rfc822",
1781 recipient: "recipient@domain.com",
1782 },
1783 ),
1784 remote_mta: Some(
1785 RemoteMta {
1786 mta_type: "dns",
1787 name: "lmtp.cs.dolmen.bouyguestelecom.fr",
1788 },
1789 ),
1790 diagnostic_code: Some(
1791 DiagnosticCode {
1792 diagnostic_type: "smtp",
1793 diagnostic: "552 <recipient@domain.com> rejected: over quota",
1794 },
1795 ),
1796 last_attempt_date: None,
1797 final_log_id: None,
1798 will_retry_until: None,
1799 extensions: {},
1800 },
1801 ],
1802 original_message: Some(
1803 "[original message goes here]
1804
1805",
1806 ),
1807 },
1808)
1809"#
1810 );
1811 }
1812
1813 #[test]
1814 fn rfc3464_6() {
1815 let result = Report::parse(include_bytes!("../data/rfc3464/6.eml")).unwrap();
1816 k9::snapshot!(
1817 result,
1818 r#"
1819Some(
1820 Report {
1821 per_message: PerMessageReportEntry {
1822 original_envelope_id: None,
1823 reporting_mta: RemoteMta {
1824 mta_type: "dns",
1825 name: "tls02.example.com",
1826 },
1827 dsn_gateway: None,
1828 received_from_mta: None,
1829 arrival_date: None,
1830 extensions: {},
1831 },
1832 per_recipient: [
1833 PerRecipientReportEntry {
1834 final_recipient: Recipient {
1835 recipient_type: "rfc822",
1836 recipient: "redacted@example.com",
1837 },
1838 action: Failed,
1839 status: ReportStatus {
1840 class: 5,
1841 subject: 0,
1842 detail: 0,
1843 comment: None,
1844 },
1845 original_recipient: Some(
1846 Recipient {
1847 recipient_type: "rfc822",
1848 recipient: "redacted@example.com",
1849 },
1850 ),
1851 remote_mta: Some(
1852 RemoteMta {
1853 mta_type: "dns",
1854 name: "example-com.mail.eo.outlook.com:25",
1855 },
1856 ),
1857 diagnostic_code: Some(
1858 DiagnosticCode {
1859 diagnostic_type: "smtp",
1860 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]",
1861 },
1862 ),
1863 last_attempt_date: None,
1864 final_log_id: None,
1865 will_retry_until: None,
1866 extensions: {},
1867 },
1868 ],
1869 original_message: Some(
1870 "Subject: [Bulk Mail] the subject
1871From: INFO <info@email.example.com>
1872To: redacted@example.com
1873
1874",
1875 ),
1876 },
1877)
1878"#
1879 );
1880 }
1881
1882 #[test]
1883 fn original_message_serializes_as_json_string() {
1884 let report = Report {
1885 per_message: PerMessageReportEntry {
1886 original_envelope_id: None,
1887 reporting_mta: RemoteMta {
1888 mta_type: "dns".to_string(),
1889 name: "mta.example.com".to_string(),
1890 },
1891 dsn_gateway: None,
1892 received_from_mta: None,
1893 arrival_date: None,
1894 extensions: BTreeMap::new(),
1895 },
1896 per_recipient: vec![],
1897 original_message: Some(BString::from("Subject: hi\n\nhello")),
1898 };
1899 let json = serde_json::to_value(&report).unwrap();
1900 k9::assert_equal!(
1901 json["original_message"],
1902 serde_json::Value::String("Subject: hi\n\nhello".to_string())
1903 );
1904 }
1905
1906 #[test]
1907 fn original_message_non_utf8_falls_back_to_bytes() {
1908 let report = Report {
1909 per_message: PerMessageReportEntry {
1910 original_envelope_id: None,
1911 reporting_mta: RemoteMta {
1912 mta_type: "dns".to_string(),
1913 name: "mta.example.com".to_string(),
1914 },
1915 dsn_gateway: None,
1916 received_from_mta: None,
1917 arrival_date: None,
1918 extensions: BTreeMap::new(),
1919 },
1920 per_recipient: vec![],
1921 original_message: Some(BString::from(&b"abc\x80\xffxyz"[..])),
1922 };
1923 let json = serde_json::to_value(&report).unwrap();
1924 assert!(json["original_message"].is_array());
1925 }
1926}