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