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