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::{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", when.to_rfc2822())?;
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", when.to_rfc2822())?;
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", when.to_rfc2822())?;
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 arrival_date = Some(log.created);
466
467 let per_message = PerMessageReportEntry {
468 arrival_date,
469 dsn_gateway: None,
470 extensions: Default::default(),
471 original_envelope_id: None,
472 received_from_mta: None,
473 reporting_mta: params.reporting_mta.clone(),
474 };
475
476 let mut per_recipient = vec![];
477 let recip_list = log.recipient.join(", ");
478
479 for recip in &log.recipient {
480 per_recipient.push(PerRecipientReportEntry {
481 action,
482 extensions: Default::default(),
483 status: (&log.response).into(),
484 diagnostic_code: Some(DiagnosticCode {
485 diagnostic_type: "smtp".into(),
486 diagnostic: log.response.to_single_line(),
487 }),
488 final_log_id: None,
489 original_recipient: None,
490 final_recipient: Recipient {
491 recipient_type: "rfc822".to_string(),
492 recipient: recip.to_string(),
493 },
494 remote_mta: log.peer_address.as_ref().map(|addr| RemoteMta {
495 mta_type: "dns".to_string(),
496 name: addr.name.to_string(),
497 }),
498 last_attempt_date: Some(log.timestamp),
499 will_retry_until: None,
500 });
501 }
502
503 let mut parts = vec![];
504
505 let exposition = match &log.kind {
506 RecordType::Bounce => {
507 let mut data = format!(
508 "The message was received at {created}\r\n\
509 from {sender} and addressed to {recip_list}.\r\n\
510 ",
511 created = log.created.to_rfc2822(),
512 sender = log.sender,
513 );
514 if let Some(peer) = &log.peer_address {
515 data.push_str(&format!(
516 "While communicating with {host} ({ip}):\r\n\
517 Response: {resp}\r\n",
518 host = peer.name,
519 ip = peer.addr,
520 resp = log.response.to_single_line(),
521 ));
522 } else {
523 data.push_str(&format!("Status: {}\r\n", log.response.to_single_line()));
524 }
525
526 data.push_str(
527 "\r\nThe message will be deleted from the queue.\r\n\
528 No further attempts will be made to deliver it.\r\n",
529 );
530
531 data
532 }
533 RecordType::Expiration => {
534 format!(
535 "The message was received at {created}\r\n\
536 from {sender} and addressed to {recip_list}.\r\n\
537 Status: {status}\r\n\
538 The message will be deleted from the queue.\r\n\
539 No further attempts will be made to deliver it.\r\n\
540 ",
541 created = log.created.to_rfc2822(),
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 =
746 chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
747 let now = chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
748 JsonLogRecord {
749 kind: RecordType::Bounce,
750 id: "ID".to_string(),
751 nodeid,
752 created: created.into(),
753 bounce_classification: Default::default(),
754 delivery_protocol: Some("ESMTP".to_string()),
755 egress_pool: None,
756 egress_source: None,
757 feedback_report: None,
758 headers: Default::default(),
759 meta: Default::default(),
760 num_attempts: 1,
761 peer_address: Some(ResolvedAddress {
762 name: "target.example.com".to_string(),
763 addr: "42.42.42.42".to_string().try_into().unwrap(),
764 is_secure: false,
765 }),
766 provider_name: None,
767 queue: "target.example.com".to_string(),
768 reception_protocol: None,
769 recipient: vec!["recip@target.example.com".to_string()],
770 sender: "sender@sender.example.com".to_string(),
771 session_id: None,
772 response: Response {
773 code: 550,
774 command: None,
775 content: "no thanks".to_string(),
776 enhanced_code: Some(EnhancedStatusCode {
777 class: 5,
778 subject: 7,
779 detail: 1,
780 }),
781 },
782 site: "some-site".to_string(),
783 size: 0,
784 source_address: None,
785 timestamp: now.into(),
786 tls_cipher: None,
787 tls_peer_subject_name: None,
788 tls_protocol_version: None,
789 }
790 }
791
792 fn make_expiration() -> JsonLogRecord {
793 let nodeid = uuid_helper::now_v1();
794 let created =
795 chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
796 let now = chrono::DateTime::parse_from_rfc2822("Tue, 1 Jul 2003 12:52:37 +0200").unwrap();
797 JsonLogRecord {
798 kind: RecordType::Expiration,
799 id: "ID".to_string(),
800 nodeid,
801 created: created.into(),
802 bounce_classification: Default::default(),
803 delivery_protocol: None,
804 egress_pool: None,
805 egress_source: None,
806 feedback_report: None,
807 headers: Default::default(),
808 meta: Default::default(),
809 num_attempts: 3,
810 peer_address: None,
811 provider_name: None,
812 queue: "target.example.com".to_string(),
813 reception_protocol: None,
814 recipient: vec!["recip@target.example.com".to_string()],
815 sender: "sender@sender.example.com".to_string(),
816 session_id: None,
817 response: Response {
818 code: 551,
819 command: None,
820 content: "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling".to_string(),
821 enhanced_code: Some(EnhancedStatusCode {
822 class: 5,
823 subject: 4,
824 detail: 7,
825 }),
826 },
827 site: "".to_string(),
828 size: 0,
829 source_address: None,
830 timestamp: now.into(),
831 tls_cipher: None,
832 tls_peer_subject_name: None,
833 tls_protocol_version: None,
834 }
835 }
836
837 #[test]
838 fn generate_expiration_with_headers() {
839 let params = ReportGenerationParams {
840 reporting_mta: RemoteMta {
841 mta_type: "dns".to_string(),
842 name: "mta1.example.com".to_string(),
843 },
844 enable_bounce: false,
845 enable_expiration: true,
846 include_original_message: IncludeOriginalMessage::HeadersOnly,
847 stable_content: true,
848 };
849
850 let original_msg = make_message();
851
852 let log = make_expiration();
853
854 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
855 .unwrap()
856 .unwrap();
857 let report_eml = BString::from(report_msg.to_message_bytes());
858 k9::snapshot!(
859 &report_eml,
860 r#"
861Content-Type: multipart/report;\r
862\tboundary="report-boundary";\r
863\treport-type="delivery-status"\r
864Subject: Returned mail\r
865Mime-Version: 1.0\r
866Message-ID: <UUID@mta1.example.com>\r
867To: sender@sender.example.com\r
868From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
869\r
870--report-boundary\r
871Content-Type: text/plain;\r
872\tcharset="us-ascii"\r
873Content-Transfer-Encoding: quoted-printable\r
874\r
875The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
876from sender@sender.example.com and addressed to recip@target.example.com.\r
877Status: 551 5.4.7 Next delivery time would be at SOME TIME which exceeds th=\r
878e expiry time EXPIRES configured via set_scheduling\r
879The message will be deleted from the queue.\r
880No further attempts will be made to deliver it.\r
881--report-boundary\r
882Content-Type: message/delivery-status;\r
883\tcharset="us-ascii"\r
884Content-Transfer-Encoding: quoted-printable\r
885\r
886Reporting-MTA: dns; mta1.example.com\r
887Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
888\r
889Final-Recipient: rfc822;recip@target.example.com\r
890Action: failed\r
891Status: 5.4.7 Next delivery time would be at SOME TIME which exceeds the ex=\r
892piry time EXPIRES configured via set_scheduling\r
893Diagnostic-Code: smtp; 551 5.4.7 Next delivery time would be at SOME TIME w=\r
894hich exceeds the expiry time EXPIRES configured via set_scheduling\r
895Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
896\r
897--report-boundary\r
898Content-Type: text/rfc822-headers\r
899\r
900Content-Type: text/plain;\r
901\tcharset="us-ascii"\r
902Subject: Hello!\r
903--report-boundary--\r
904
905"#
906 );
907
908 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
909 k9::snapshot!(
910 &round_trip,
911 r#"
912Report {
913 per_message: PerMessageReportEntry {
914 original_envelope_id: None,
915 reporting_mta: RemoteMta {
916 mta_type: "dns",
917 name: "mta1.example.com",
918 },
919 dsn_gateway: None,
920 received_from_mta: None,
921 arrival_date: Some(
922 2003-07-01T08:52:37Z,
923 ),
924 extensions: {},
925 },
926 per_recipient: [
927 PerRecipientReportEntry {
928 final_recipient: Recipient {
929 recipient_type: "rfc822",
930 recipient: "recip@target.example.com",
931 },
932 action: Failed,
933 status: ReportStatus {
934 class: 5,
935 subject: 4,
936 detail: 7,
937 comment: Some(
938 "Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
939 ),
940 },
941 original_recipient: None,
942 remote_mta: None,
943 diagnostic_code: Some(
944 DiagnosticCode {
945 diagnostic_type: "smtp",
946 diagnostic: "551 5.4.7 Next delivery time would be at SOME TIME which exceeds the expiry time EXPIRES configured via set_scheduling",
947 },
948 ),
949 last_attempt_date: Some(
950 2003-07-01T10:52:37Z,
951 ),
952 final_log_id: None,
953 will_retry_until: None,
954 extensions: {},
955 },
956 ],
957 original_message: Some(
958 "Content-Type: text/plain;
959\tcharset="us-ascii"
960Subject: Hello!
961",
962 ),
963}
964"#
965 );
966 }
967
968 #[test]
969 fn generate_bounce_with_headers() {
970 let params = ReportGenerationParams {
971 reporting_mta: RemoteMta {
972 mta_type: "dns".to_string(),
973 name: "mta1.example.com".to_string(),
974 },
975 enable_bounce: true,
976 enable_expiration: true,
977 include_original_message: IncludeOriginalMessage::HeadersOnly,
978 stable_content: true,
979 };
980
981 let original_msg = make_message();
982
983 let log = make_bounce();
984
985 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
986 .unwrap()
987 .unwrap();
988 let report_eml = BString::from(report_msg.to_message_bytes());
989 k9::snapshot!(
990 &report_eml,
991 r#"
992Content-Type: multipart/report;\r
993\tboundary="report-boundary";\r
994\treport-type="delivery-status"\r
995Subject: Returned mail\r
996Mime-Version: 1.0\r
997Message-ID: <UUID@mta1.example.com>\r
998To: sender@sender.example.com\r
999From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1000\r
1001--report-boundary\r
1002Content-Type: text/plain;\r
1003\tcharset="us-ascii"\r
1004\r
1005The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1006from sender@sender.example.com and addressed to recip@target.example.com.\r
1007While communicating with target.example.com (42.42.42.42):\r
1008Response: 550 5.7.1 no thanks\r
1009\r
1010The message will be deleted from the queue.\r
1011No further attempts will be made to deliver it.\r
1012--report-boundary\r
1013Content-Type: message/delivery-status;\r
1014\tcharset="us-ascii"\r
1015\r
1016Reporting-MTA: dns; mta1.example.com\r
1017Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1018\r
1019Final-Recipient: rfc822;recip@target.example.com\r
1020Action: failed\r
1021Status: 5.7.1 no thanks\r
1022Remote-MTA: dns; target.example.com\r
1023Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1024Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1025\r
1026--report-boundary\r
1027Content-Type: text/rfc822-headers\r
1028\r
1029Content-Type: text/plain;\r
1030\tcharset="us-ascii"\r
1031Subject: Hello!\r
1032--report-boundary--\r
1033
1034"#
1035 );
1036
1037 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1038 k9::snapshot!(
1039 &round_trip,
1040 r#"
1041Report {
1042 per_message: PerMessageReportEntry {
1043 original_envelope_id: None,
1044 reporting_mta: RemoteMta {
1045 mta_type: "dns",
1046 name: "mta1.example.com",
1047 },
1048 dsn_gateway: None,
1049 received_from_mta: None,
1050 arrival_date: Some(
1051 2003-07-01T08:52:37Z,
1052 ),
1053 extensions: {},
1054 },
1055 per_recipient: [
1056 PerRecipientReportEntry {
1057 final_recipient: Recipient {
1058 recipient_type: "rfc822",
1059 recipient: "recip@target.example.com",
1060 },
1061 action: Failed,
1062 status: ReportStatus {
1063 class: 5,
1064 subject: 7,
1065 detail: 1,
1066 comment: Some(
1067 "no thanks",
1068 ),
1069 },
1070 original_recipient: None,
1071 remote_mta: Some(
1072 RemoteMta {
1073 mta_type: "dns",
1074 name: "target.example.com",
1075 },
1076 ),
1077 diagnostic_code: Some(
1078 DiagnosticCode {
1079 diagnostic_type: "smtp",
1080 diagnostic: "550 5.7.1 no thanks",
1081 },
1082 ),
1083 last_attempt_date: Some(
1084 2003-07-01T10:52:37Z,
1085 ),
1086 final_log_id: None,
1087 will_retry_until: None,
1088 extensions: {},
1089 },
1090 ],
1091 original_message: Some(
1092 "Content-Type: text/plain;
1093\tcharset="us-ascii"
1094Subject: Hello!
1095",
1096 ),
1097}
1098"#
1099 );
1100 }
1101 #[test]
1102 fn generate_bounce_with_message() {
1103 let params = ReportGenerationParams {
1104 reporting_mta: RemoteMta {
1105 mta_type: "dns".to_string(),
1106 name: "mta1.example.com".to_string(),
1107 },
1108 enable_bounce: true,
1109 enable_expiration: true,
1110 include_original_message: IncludeOriginalMessage::FullContent,
1111 stable_content: true,
1112 };
1113
1114 let original_msg = make_message();
1115
1116 let log = make_bounce();
1117
1118 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
1119 .unwrap()
1120 .unwrap();
1121 let report_eml = BString::from(report_msg.to_message_bytes());
1122 k9::snapshot!(
1123 &report_eml,
1124 r#"
1125Content-Type: multipart/report;\r
1126\tboundary="report-boundary";\r
1127\treport-type="delivery-status"\r
1128Subject: Returned mail\r
1129Mime-Version: 1.0\r
1130Message-ID: <UUID@mta1.example.com>\r
1131To: sender@sender.example.com\r
1132From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1133\r
1134--report-boundary\r
1135Content-Type: text/plain;\r
1136\tcharset="us-ascii"\r
1137\r
1138The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1139from sender@sender.example.com and addressed to recip@target.example.com.\r
1140While communicating with target.example.com (42.42.42.42):\r
1141Response: 550 5.7.1 no thanks\r
1142\r
1143The message will be deleted from the queue.\r
1144No further attempts will be made to deliver it.\r
1145--report-boundary\r
1146Content-Type: message/delivery-status;\r
1147\tcharset="us-ascii"\r
1148\r
1149Reporting-MTA: dns; mta1.example.com\r
1150Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1151\r
1152Final-Recipient: rfc822;recip@target.example.com\r
1153Action: failed\r
1154Status: 5.7.1 no thanks\r
1155Remote-MTA: dns; target.example.com\r
1156Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1157Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1158\r
1159--report-boundary\r
1160Content-Type: message/rfc822\r
1161\r
1162Content-Type: text/plain;\r
1163\tcharset="us-ascii"\r
1164Subject: Hello!\r
1165\r
1166hello there\r
1167--report-boundary--\r
1168
1169"#
1170 );
1171
1172 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1173 k9::snapshot!(
1174 &round_trip,
1175 r#"
1176Report {
1177 per_message: PerMessageReportEntry {
1178 original_envelope_id: None,
1179 reporting_mta: RemoteMta {
1180 mta_type: "dns",
1181 name: "mta1.example.com",
1182 },
1183 dsn_gateway: None,
1184 received_from_mta: None,
1185 arrival_date: Some(
1186 2003-07-01T08:52:37Z,
1187 ),
1188 extensions: {},
1189 },
1190 per_recipient: [
1191 PerRecipientReportEntry {
1192 final_recipient: Recipient {
1193 recipient_type: "rfc822",
1194 recipient: "recip@target.example.com",
1195 },
1196 action: Failed,
1197 status: ReportStatus {
1198 class: 5,
1199 subject: 7,
1200 detail: 1,
1201 comment: Some(
1202 "no thanks",
1203 ),
1204 },
1205 original_recipient: None,
1206 remote_mta: Some(
1207 RemoteMta {
1208 mta_type: "dns",
1209 name: "target.example.com",
1210 },
1211 ),
1212 diagnostic_code: Some(
1213 DiagnosticCode {
1214 diagnostic_type: "smtp",
1215 diagnostic: "550 5.7.1 no thanks",
1216 },
1217 ),
1218 last_attempt_date: Some(
1219 2003-07-01T10:52:37Z,
1220 ),
1221 final_log_id: None,
1222 will_retry_until: None,
1223 extensions: {},
1224 },
1225 ],
1226 original_message: Some(
1227 "Content-Type: text/plain;
1228\tcharset="us-ascii"
1229Subject: Hello!
1230
1231hello there
1232",
1233 ),
1234}
1235"#
1236 );
1237 }
1238
1239 #[test]
1240 fn generate_bounce_no_message() {
1241 let params = ReportGenerationParams {
1242 reporting_mta: RemoteMta {
1243 mta_type: "dns".to_string(),
1244 name: "mta1.example.com".to_string(),
1245 },
1246 enable_bounce: true,
1247 enable_expiration: true,
1248 include_original_message: IncludeOriginalMessage::No,
1249 stable_content: true,
1250 };
1251
1252 let original_msg = make_message();
1253
1254 let log = make_bounce();
1255
1256 let report_msg = Report::generate(¶ms, Some(&original_msg), &log)
1257 .unwrap()
1258 .unwrap();
1259 let report_eml = BString::from(report_msg.to_message_bytes());
1260 k9::snapshot!(
1261 &report_eml,
1262 r#"
1263Content-Type: multipart/report;\r
1264\tboundary="report-boundary";\r
1265\treport-type="delivery-status"\r
1266Subject: Returned mail\r
1267Mime-Version: 1.0\r
1268Message-ID: <UUID@mta1.example.com>\r
1269To: sender@sender.example.com\r
1270From: Mail Delivery Subsystem <mailer-daemon@mta1.example.com>\r
1271\r
1272--report-boundary\r
1273Content-Type: text/plain;\r
1274\tcharset="us-ascii"\r
1275\r
1276The message was received at Tue, 1 Jul 2003 08:52:37 +0000\r
1277from sender@sender.example.com and addressed to recip@target.example.com.\r
1278While communicating with target.example.com (42.42.42.42):\r
1279Response: 550 5.7.1 no thanks\r
1280\r
1281The message will be deleted from the queue.\r
1282No further attempts will be made to deliver it.\r
1283--report-boundary\r
1284Content-Type: message/delivery-status;\r
1285\tcharset="us-ascii"\r
1286\r
1287Reporting-MTA: dns; mta1.example.com\r
1288Arrival-Date: Tue, 1 Jul 2003 08:52:37 +0000\r
1289\r
1290Final-Recipient: rfc822;recip@target.example.com\r
1291Action: failed\r
1292Status: 5.7.1 no thanks\r
1293Remote-MTA: dns; target.example.com\r
1294Diagnostic-Code: smtp; 550 5.7.1 no thanks\r
1295Last-Attempt-Date: Tue, 1 Jul 2003 10:52:37 +0000\r
1296\r
1297--report-boundary--\r
1298
1299"#
1300 );
1301
1302 let round_trip = Report::parse(report_eml.as_bytes()).unwrap().unwrap();
1303 k9::snapshot!(
1304 &round_trip,
1305 r#"
1306Report {
1307 per_message: PerMessageReportEntry {
1308 original_envelope_id: None,
1309 reporting_mta: RemoteMta {
1310 mta_type: "dns",
1311 name: "mta1.example.com",
1312 },
1313 dsn_gateway: None,
1314 received_from_mta: None,
1315 arrival_date: Some(
1316 2003-07-01T08:52:37Z,
1317 ),
1318 extensions: {},
1319 },
1320 per_recipient: [
1321 PerRecipientReportEntry {
1322 final_recipient: Recipient {
1323 recipient_type: "rfc822",
1324 recipient: "recip@target.example.com",
1325 },
1326 action: Failed,
1327 status: ReportStatus {
1328 class: 5,
1329 subject: 7,
1330 detail: 1,
1331 comment: Some(
1332 "no thanks",
1333 ),
1334 },
1335 original_recipient: None,
1336 remote_mta: Some(
1337 RemoteMta {
1338 mta_type: "dns",
1339 name: "target.example.com",
1340 },
1341 ),
1342 diagnostic_code: Some(
1343 DiagnosticCode {
1344 diagnostic_type: "smtp",
1345 diagnostic: "550 5.7.1 no thanks",
1346 },
1347 ),
1348 last_attempt_date: Some(
1349 2003-07-01T10:52:37Z,
1350 ),
1351 final_log_id: None,
1352 will_retry_until: None,
1353 extensions: {},
1354 },
1355 ],
1356 original_message: None,
1357}
1358"#
1359 );
1360 }
1361
1362 #[test]
1363 fn rfc3464_1() {
1364 let result = Report::parse(include_bytes!("../data/rfc3464/1.eml")).unwrap();
1365 k9::snapshot!(
1366 &result,
1367 r#"
1368Some(
1369 Report {
1370 per_message: PerMessageReportEntry {
1371 original_envelope_id: None,
1372 reporting_mta: RemoteMta {
1373 mta_type: "dns",
1374 name: "cs.utk.edu",
1375 },
1376 dsn_gateway: None,
1377 received_from_mta: None,
1378 arrival_date: None,
1379 extensions: {},
1380 },
1381 per_recipient: [
1382 PerRecipientReportEntry {
1383 final_recipient: Recipient {
1384 recipient_type: "rfc822",
1385 recipient: "louisl@larry.slip.umd.edu",
1386 },
1387 action: Failed,
1388 status: ReportStatus {
1389 class: 4,
1390 subject: 0,
1391 detail: 0,
1392 comment: None,
1393 },
1394 original_recipient: Some(
1395 Recipient {
1396 recipient_type: "rfc822",
1397 recipient: "louisl@larry.slip.umd.edu",
1398 },
1399 ),
1400 remote_mta: None,
1401 diagnostic_code: Some(
1402 DiagnosticCode {
1403 diagnostic_type: "smtp",
1404 diagnostic: "426 connection timed out",
1405 },
1406 ),
1407 last_attempt_date: Some(
1408 1994-07-07T21:15:49Z,
1409 ),
1410 final_log_id: None,
1411 will_retry_until: None,
1412 extensions: {},
1413 },
1414 ],
1415 original_message: Some(
1416 "[original message goes here]
1417
1418",
1419 ),
1420 },
1421)
1422"#
1423 );
1424
1425 let report = result.unwrap();
1426
1427 assert_eq!(
1428 report.per_message.to_string(),
1429 "Reporting-MTA: dns; cs.utk.edu\r\n"
1430 );
1431 assert_eq!(
1432 report.per_recipient[0].to_string(),
1433 "Original-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1434 Final-Recipient: rfc822;louisl@larry.slip.umd.edu\r\n\
1435 Action: failed\r\n\
1436 Status: 4.0.0\r\n\
1437 Diagnostic-Code: smtp; 426 connection timed out\r\n\
1438 Last-Attempt-Date: Thu, 7 Jul 1994 21:15:49 +0000\r\n"
1439 );
1440 }
1441
1442 #[test]
1443 fn rfc3464_2() {
1444 let result = Report::parse(include_bytes!("../data/rfc3464/2.eml")).unwrap();
1445 k9::snapshot!(
1446 result,
1447 r#"
1448Some(
1449 Report {
1450 per_message: PerMessageReportEntry {
1451 original_envelope_id: None,
1452 reporting_mta: RemoteMta {
1453 mta_type: "dns",
1454 name: "cs.utk.edu",
1455 },
1456 dsn_gateway: None,
1457 received_from_mta: None,
1458 arrival_date: None,
1459 extensions: {},
1460 },
1461 per_recipient: [
1462 PerRecipientReportEntry {
1463 final_recipient: Recipient {
1464 recipient_type: "rfc822",
1465 recipient: "arathib@vnet.ibm.com",
1466 },
1467 action: Failed,
1468 status: ReportStatus {
1469 class: 5,
1470 subject: 0,
1471 detail: 0,
1472 comment: Some(
1473 "(permanent failure)",
1474 ),
1475 },
1476 original_recipient: Some(
1477 Recipient {
1478 recipient_type: "rfc822",
1479 recipient: "arathib@vnet.ibm.com",
1480 },
1481 ),
1482 remote_mta: Some(
1483 RemoteMta {
1484 mta_type: "dns",
1485 name: "vnet.ibm.com",
1486 },
1487 ),
1488 diagnostic_code: Some(
1489 DiagnosticCode {
1490 diagnostic_type: "smtp",
1491 diagnostic: "550 'arathib@vnet.IBM.COM' is not a registered gateway user",
1492 },
1493 ),
1494 last_attempt_date: None,
1495 final_log_id: None,
1496 will_retry_until: None,
1497 extensions: {},
1498 },
1499 PerRecipientReportEntry {
1500 final_recipient: Recipient {
1501 recipient_type: "rfc822",
1502 recipient: "johnh@hpnjld.njd.hp.com",
1503 },
1504 action: Delayed,
1505 status: ReportStatus {
1506 class: 4,
1507 subject: 0,
1508 detail: 0,
1509 comment: Some(
1510 "(hpnjld.njd.jp.com: host name lookup failure)",
1511 ),
1512 },
1513 original_recipient: Some(
1514 Recipient {
1515 recipient_type: "rfc822",
1516 recipient: "johnh@hpnjld.njd.hp.com",
1517 },
1518 ),
1519 remote_mta: None,
1520 diagnostic_code: None,
1521 last_attempt_date: None,
1522 final_log_id: None,
1523 will_retry_until: None,
1524 extensions: {},
1525 },
1526 PerRecipientReportEntry {
1527 final_recipient: Recipient {
1528 recipient_type: "rfc822",
1529 recipient: "wsnell@sdcc13.ucsd.edu",
1530 },
1531 action: Failed,
1532 status: ReportStatus {
1533 class: 5,
1534 subject: 0,
1535 detail: 0,
1536 comment: None,
1537 },
1538 original_recipient: Some(
1539 Recipient {
1540 recipient_type: "rfc822",
1541 recipient: "wsnell@sdcc13.ucsd.edu",
1542 },
1543 ),
1544 remote_mta: Some(
1545 RemoteMta {
1546 mta_type: "dns",
1547 name: "sdcc13.ucsd.edu",
1548 },
1549 ),
1550 diagnostic_code: Some(
1551 DiagnosticCode {
1552 diagnostic_type: "smtp",
1553 diagnostic: "550 user unknown",
1554 },
1555 ),
1556 last_attempt_date: None,
1557 final_log_id: None,
1558 will_retry_until: None,
1559 extensions: {},
1560 },
1561 ],
1562 original_message: Some(
1563 "[original message goes here]
1564
1565",
1566 ),
1567 },
1568)
1569"#
1570 );
1571 }
1572
1573 #[test]
1574 fn rfc3464_3() {
1575 let result = Report::parse(include_bytes!("../data/rfc3464/3.eml")).unwrap();
1576 k9::snapshot!(
1577 result,
1578 r#"
1579Some(
1580 Report {
1581 per_message: PerMessageReportEntry {
1582 original_envelope_id: None,
1583 reporting_mta: RemoteMta {
1584 mta_type: "mailbus",
1585 name: "SYS30",
1586 },
1587 dsn_gateway: None,
1588 received_from_mta: None,
1589 arrival_date: None,
1590 extensions: {},
1591 },
1592 per_recipient: [
1593 PerRecipientReportEntry {
1594 final_recipient: Recipient {
1595 recipient_type: "unknown",
1596 recipient: "nair_s",
1597 },
1598 action: Failed,
1599 status: ReportStatus {
1600 class: 5,
1601 subject: 0,
1602 detail: 0,
1603 comment: Some(
1604 "(unknown permanent failure)",
1605 ),
1606 },
1607 original_recipient: None,
1608 remote_mta: None,
1609 diagnostic_code: None,
1610 last_attempt_date: None,
1611 final_log_id: None,
1612 will_retry_until: None,
1613 extensions: {},
1614 },
1615 ],
1616 original_message: None,
1617 },
1618)
1619"#
1620 );
1621 }
1622
1623 #[test]
1624 fn rfc3464_4() {
1625 let result = Report::parse(include_bytes!("../data/rfc3464/4.eml")).unwrap();
1626 k9::snapshot!(
1627 result,
1628 r#"
1629Some(
1630 Report {
1631 per_message: PerMessageReportEntry {
1632 original_envelope_id: None,
1633 reporting_mta: RemoteMta {
1634 mta_type: "dns",
1635 name: "sun2.nsfnet-relay.ac.uk",
1636 },
1637 dsn_gateway: None,
1638 received_from_mta: None,
1639 arrival_date: None,
1640 extensions: {},
1641 },
1642 per_recipient: [
1643 PerRecipientReportEntry {
1644 final_recipient: Recipient {
1645 recipient_type: "rfc822",
1646 recipient: "thomas@de-montfort.ac.uk",
1647 },
1648 action: Delayed,
1649 status: ReportStatus {
1650 class: 4,
1651 subject: 0,
1652 detail: 0,
1653 comment: Some(
1654 "(unknown temporary failure)",
1655 ),
1656 },
1657 original_recipient: None,
1658 remote_mta: None,
1659 diagnostic_code: None,
1660 last_attempt_date: None,
1661 final_log_id: None,
1662 will_retry_until: None,
1663 extensions: {},
1664 },
1665 ],
1666 original_message: None,
1667 },
1668)
1669"#
1670 );
1671 }
1672
1673 #[test]
1674 fn rfc3464_5() {
1675 let result = Report::parse(include_bytes!("../data/rfc3464/5.eml")).unwrap();
1676 k9::snapshot!(
1677 result,
1678 r#"
1679Some(
1680 Report {
1681 per_message: PerMessageReportEntry {
1682 original_envelope_id: None,
1683 reporting_mta: RemoteMta {
1684 mta_type: "dns",
1685 name: "mx-by.bbox.fr",
1686 },
1687 dsn_gateway: None,
1688 received_from_mta: None,
1689 arrival_date: Some(
1690 2025-01-29T16:36:51Z,
1691 ),
1692 extensions: {
1693 "x-postfix-queue-id": [
1694 "897DAC0",
1695 ],
1696 "x-postfix-sender": [
1697 "rfc822; user@example.com",
1698 ],
1699 },
1700 },
1701 per_recipient: [
1702 PerRecipientReportEntry {
1703 final_recipient: Recipient {
1704 recipient_type: "rfc822",
1705 recipient: "recipient@domain.com",
1706 },
1707 action: Failed,
1708 status: ReportStatus {
1709 class: 5,
1710 subject: 0,
1711 detail: 0,
1712 comment: None,
1713 },
1714 original_recipient: Some(
1715 Recipient {
1716 recipient_type: "rfc822",
1717 recipient: "recipient@domain.com",
1718 },
1719 ),
1720 remote_mta: Some(
1721 RemoteMta {
1722 mta_type: "dns",
1723 name: "lmtp.cs.dolmen.bouyguestelecom.fr",
1724 },
1725 ),
1726 diagnostic_code: Some(
1727 DiagnosticCode {
1728 diagnostic_type: "smtp",
1729 diagnostic: "552 <recipient@domain.com> rejected: over quota",
1730 },
1731 ),
1732 last_attempt_date: None,
1733 final_log_id: None,
1734 will_retry_until: None,
1735 extensions: {},
1736 },
1737 ],
1738 original_message: Some(
1739 "[original message goes here]
1740
1741",
1742 ),
1743 },
1744)
1745"#
1746 );
1747 }
1748
1749 #[test]
1750 fn rfc3464_6() {
1751 let result = Report::parse(include_bytes!("../data/rfc3464/6.eml")).unwrap();
1752 k9::snapshot!(
1753 result,
1754 r#"
1755Some(
1756 Report {
1757 per_message: PerMessageReportEntry {
1758 original_envelope_id: None,
1759 reporting_mta: RemoteMta {
1760 mta_type: "dns",
1761 name: "tls02.example.com",
1762 },
1763 dsn_gateway: None,
1764 received_from_mta: None,
1765 arrival_date: None,
1766 extensions: {},
1767 },
1768 per_recipient: [
1769 PerRecipientReportEntry {
1770 final_recipient: Recipient {
1771 recipient_type: "rfc822",
1772 recipient: "redacted@example.com",
1773 },
1774 action: Failed,
1775 status: ReportStatus {
1776 class: 5,
1777 subject: 0,
1778 detail: 0,
1779 comment: None,
1780 },
1781 original_recipient: Some(
1782 Recipient {
1783 recipient_type: "rfc822",
1784 recipient: "redacted@example.com",
1785 },
1786 ),
1787 remote_mta: Some(
1788 RemoteMta {
1789 mta_type: "dns",
1790 name: "example-com.mail.eo.outlook.com:25",
1791 },
1792 ),
1793 diagnostic_code: Some(
1794 DiagnosticCode {
1795 diagnostic_type: "smtp",
1796 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]",
1797 },
1798 ),
1799 last_attempt_date: None,
1800 final_log_id: None,
1801 will_retry_until: None,
1802 extensions: {},
1803 },
1804 ],
1805 original_message: Some(
1806 "Subject: [Bulk Mail] the subject
1807From: INFO <info@email.example.com>
1808To: redacted@example.com
1809
1810",
1811 ),
1812 },
1813)
1814"#
1815 );
1816 }
1817
1818 #[test]
1819 fn original_message_serializes_as_json_string() {
1820 let report = Report {
1821 per_message: PerMessageReportEntry {
1822 original_envelope_id: None,
1823 reporting_mta: RemoteMta {
1824 mta_type: "dns".to_string(),
1825 name: "mta.example.com".to_string(),
1826 },
1827 dsn_gateway: None,
1828 received_from_mta: None,
1829 arrival_date: None,
1830 extensions: BTreeMap::new(),
1831 },
1832 per_recipient: vec![],
1833 original_message: Some(BString::from("Subject: hi\n\nhello")),
1834 };
1835 let json = serde_json::to_value(&report).unwrap();
1836 k9::assert_equal!(
1837 json["original_message"],
1838 serde_json::Value::String("Subject: hi\n\nhello".to_string())
1839 );
1840 }
1841
1842 #[test]
1843 fn original_message_non_utf8_falls_back_to_bytes() {
1844 let report = Report {
1845 per_message: PerMessageReportEntry {
1846 original_envelope_id: None,
1847 reporting_mta: RemoteMta {
1848 mta_type: "dns".to_string(),
1849 name: "mta.example.com".to_string(),
1850 },
1851 dsn_gateway: None,
1852 received_from_mta: None,
1853 arrival_date: None,
1854 extensions: BTreeMap::new(),
1855 },
1856 per_recipient: vec![],
1857 original_message: Some(BString::from(&b"abc\x80\xffxyz"[..])),
1858 };
1859 let json = serde_json::to_value(&report).unwrap();
1860 assert!(json["original_message"].is_array());
1861 }
1862}