1use std::fmt::Write as FmtWrite;
2use std::net::IpAddr;
3
4use crate::common::dns::DnsResolver;
5use crate::common::domain;
6use crate::dmarc::types::{
7 AlignmentMode, FailureOption, Policy,
8};
9
10#[derive(Debug, Clone)]
14pub struct AggregateReport {
15 pub org_name: String,
17 pub email: String,
19 pub report_id: String,
21 pub date_range_begin: u64,
23 pub date_range_end: u64,
25 pub policy: PublishedPolicy,
27 pub records: Vec<ReportRecord>,
29}
30
31#[derive(Debug, Clone)]
33pub struct PublishedPolicy {
34 pub domain: String,
35 pub adkim: AlignmentMode,
36 pub aspf: AlignmentMode,
37 pub policy: Policy,
38 pub subdomain_policy: Policy,
39 pub percent: u8,
40}
41
42#[derive(Debug, Clone)]
44pub struct ReportRecord {
45 pub source_ip: IpAddr,
46 pub count: u32,
47 pub disposition: ReportDisposition,
48 pub dkim_result: ReportAuthResult,
49 pub spf_result: ReportAuthResult,
50 pub dkim_domain: Option<String>,
52 pub spf_domain: Option<String>,
54 pub envelope_from: Option<String>,
56 pub header_from: String,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ReportDisposition {
63 None,
64 Quarantine,
65 Reject,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ReportAuthResult {
71 Pass,
72 Fail,
73 None,
74}
75
76impl AggregateReport {
77 pub fn to_xml(&self) -> String {
79 let mut xml = String::new();
80 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
81 xml.push_str("<feedback>\n");
82
83 xml.push_str(" <report_metadata>\n");
85 write_xml_element(&mut xml, " ", "org_name", &self.org_name);
86 write_xml_element(&mut xml, " ", "email", &self.email);
87 write_xml_element(&mut xml, " ", "report_id", &self.report_id);
88 xml.push_str(" <date_range>\n");
89 write_xml_element(&mut xml, " ", "begin", &self.date_range_begin.to_string());
90 write_xml_element(&mut xml, " ", "end", &self.date_range_end.to_string());
91 xml.push_str(" </date_range>\n");
92 xml.push_str(" </report_metadata>\n");
93
94 xml.push_str(" <policy_published>\n");
96 write_xml_element(&mut xml, " ", "domain", &self.policy.domain);
97 write_xml_element(&mut xml, " ", "adkim", alignment_str(self.policy.adkim));
98 write_xml_element(&mut xml, " ", "aspf", alignment_str(self.policy.aspf));
99 write_xml_element(&mut xml, " ", "p", policy_str(self.policy.policy));
100 write_xml_element(&mut xml, " ", "sp", policy_str(self.policy.subdomain_policy));
101 write_xml_element(&mut xml, " ", "pct", &self.policy.percent.to_string());
102 xml.push_str(" </policy_published>\n");
103
104 for record in &self.records {
106 xml.push_str(" <record>\n");
107 xml.push_str(" <row>\n");
108 write_xml_element(&mut xml, " ", "source_ip", &record.source_ip.to_string());
109 write_xml_element(&mut xml, " ", "count", &record.count.to_string());
110 xml.push_str(" <policy_evaluated>\n");
111 write_xml_element(&mut xml, " ", "disposition", disposition_str(record.disposition));
112 xml.push_str(" <dkim>"); xml.push_str(auth_result_str(record.dkim_result)); xml.push_str("</dkim>\n");
113 xml.push_str(" <spf>"); xml.push_str(auth_result_str(record.spf_result)); xml.push_str("</spf>\n");
114 xml.push_str(" </policy_evaluated>\n");
115 xml.push_str(" </row>\n");
116 xml.push_str(" <identifiers>\n");
117 if let Some(ref ef) = record.envelope_from {
118 write_xml_element(&mut xml, " ", "envelope_from", ef);
119 }
120 write_xml_element(&mut xml, " ", "header_from", &record.header_from);
121 xml.push_str(" </identifiers>\n");
122 xml.push_str(" <auth_results>\n");
123 if let Some(ref dd) = record.dkim_domain {
124 xml.push_str(" <dkim>\n");
125 write_xml_element(&mut xml, " ", "domain", dd);
126 write_xml_element(&mut xml, " ", "result", auth_result_str(record.dkim_result));
127 xml.push_str(" </dkim>\n");
128 }
129 if let Some(ref sd) = record.spf_domain {
130 xml.push_str(" <spf>\n");
131 write_xml_element(&mut xml, " ", "domain", sd);
132 write_xml_element(&mut xml, " ", "result", auth_result_str(record.spf_result));
133 xml.push_str(" </spf>\n");
134 }
135 xml.push_str(" </auth_results>\n");
136 xml.push_str(" </record>\n");
137 }
138
139 xml.push_str("</feedback>\n");
140 xml
141 }
142}
143
144pub struct AggregateReportBuilder {
148 org_name: String,
149 email: String,
150 report_id: String,
151 date_range_begin: u64,
152 date_range_end: u64,
153 policy: PublishedPolicy,
154 records: Vec<ReportRecord>,
155}
156
157impl AggregateReportBuilder {
158 pub fn new(
159 org_name: impl Into<String>,
160 email: impl Into<String>,
161 report_id: impl Into<String>,
162 date_range_begin: u64,
163 date_range_end: u64,
164 policy: PublishedPolicy,
165 ) -> Self {
166 Self {
167 org_name: org_name.into(),
168 email: email.into(),
169 report_id: report_id.into(),
170 date_range_begin,
171 date_range_end,
172 policy,
173 records: Vec::new(),
174 }
175 }
176
177 pub fn add_record(&mut self, record: ReportRecord) {
178 self.records.push(record);
179 }
180
181 pub fn build(self) -> AggregateReport {
182 AggregateReport {
183 org_name: self.org_name,
184 email: self.email,
185 report_id: self.report_id,
186 date_range_begin: self.date_range_begin,
187 date_range_end: self.date_range_end,
188 policy: self.policy,
189 records: self.records,
190 }
191 }
192}
193
194pub async fn verify_external_report_uri<R: DnsResolver>(
200 resolver: &R,
201 sender_domain: &str,
202 report_address: &str,
203) -> bool {
204 let target_domain = match domain::domain_from_email(report_address) {
205 Some(d) => d,
206 None => return false,
207 };
208
209 let sender_norm = domain::normalize(sender_domain);
210 let target_norm = domain::normalize(target_domain);
211
212 if domain::domains_equal(&sender_norm, &target_norm) {
214 return true;
215 }
216
217 let query_name = format!("{}._report._dmarc.{}", sender_norm, target_norm);
219 let txt_records = match resolver.query_txt(&query_name).await {
220 Ok(records) => records,
221 Err(_) => return false, };
223
224 txt_records.iter().any(|r| {
226 let trimmed = r.trim();
227 trimmed.eq_ignore_ascii_case("v=DMARC1")
228 || trimmed.to_ascii_lowercase().starts_with("v=dmarc1;")
229 || trimmed.to_ascii_lowercase().starts_with("v=dmarc1 ")
230 })
231}
232
233#[derive(Debug, Clone)]
237pub struct FailureReport {
238 pub original_headers: String,
240 pub auth_failure: String,
242 pub from_domain: String,
244 pub source_ip: Option<IpAddr>,
246 pub reporting_domain: String,
248}
249
250impl FailureReport {
251 pub fn to_afrf(&self) -> String {
254 let boundary = "----=_DMARC_AFRF_Boundary";
255 let mut out = String::new();
256 let _ = writeln!(out, "MIME-Version: 1.0");
258 let _ = writeln!(out, "Content-Type: multipart/report; report-type=feedback-report; boundary=\"{}\"", boundary);
259 let _ = writeln!(out);
260
261 let _ = writeln!(out, "--{}", boundary);
263 let _ = writeln!(out, "Content-Type: text/plain");
264 let _ = writeln!(out);
265 let _ = writeln!(out, "DMARC authentication failure report for domain {}", self.from_domain);
266 let _ = writeln!(out);
267
268 let _ = writeln!(out, "--{}", boundary);
270 let _ = writeln!(out, "Content-Type: message/feedback-report");
271 let _ = writeln!(out);
272 let _ = writeln!(out, "Feedback-Type: auth-failure");
273 let _ = writeln!(out, "User-Agent: email-auth/0.1.0");
274 let _ = writeln!(out, "Version: 1");
275 let _ = writeln!(out, "Auth-Failure: dmarc");
276 let _ = writeln!(out, "Authentication-Results: {}; dmarc=fail", self.reporting_domain);
277 let _ = writeln!(out, "Reported-Domain: {}", self.from_domain);
278 if let Some(ip) = &self.source_ip {
279 let _ = writeln!(out, "Source-IP: {}", ip);
280 }
281 let _ = writeln!(out);
282
283 let _ = writeln!(out, "--{}", boundary);
285 let _ = writeln!(out, "Content-Type: text/rfc822-headers");
286 let _ = writeln!(out);
287 let _ = write!(out, "{}", self.original_headers);
288 if !self.original_headers.ends_with('\n') {
289 let _ = writeln!(out);
290 }
291 let _ = writeln!(out);
292
293 let _ = writeln!(out, "--{}--", boundary);
295
296 out
297 }
298}
299
300pub fn should_generate_failure_report(
304 failure_options: &[FailureOption],
305 dkim_aligned: bool,
306 spf_aligned: bool,
307) -> bool {
308 for opt in failure_options {
309 match opt {
310 FailureOption::Zero => {
311 if !dkim_aligned && !spf_aligned {
313 return true;
314 }
315 }
316 FailureOption::One => {
317 if !dkim_aligned || !spf_aligned {
319 return true;
320 }
321 }
322 FailureOption::D => {
323 if !dkim_aligned {
325 return true;
326 }
327 }
328 FailureOption::S => {
329 if !spf_aligned {
331 return true;
332 }
333 }
334 }
335 }
336 false
337}
338
339fn write_xml_element(xml: &mut String, indent: &str, tag: &str, value: &str) {
342 let _ = write!(xml, "{}<{}>{}</{}>\n", indent, tag, escape_xml(value), tag);
343}
344
345fn escape_xml(s: &str) -> String {
346 s.replace('&', "&")
347 .replace('<', "<")
348 .replace('>', ">")
349 .replace('"', """)
350 .replace('\'', "'")
351}
352
353fn alignment_str(mode: AlignmentMode) -> &'static str {
354 match mode {
355 AlignmentMode::Relaxed => "r",
356 AlignmentMode::Strict => "s",
357 }
358}
359
360fn policy_str(policy: Policy) -> &'static str {
361 match policy {
362 Policy::None => "none",
363 Policy::Quarantine => "quarantine",
364 Policy::Reject => "reject",
365 }
366}
367
368fn disposition_str(d: ReportDisposition) -> &'static str {
369 match d {
370 ReportDisposition::None => "none",
371 ReportDisposition::Quarantine => "quarantine",
372 ReportDisposition::Reject => "reject",
373 }
374}
375
376fn auth_result_str(r: ReportAuthResult) -> &'static str {
377 match r {
378 ReportAuthResult::Pass => "pass",
379 ReportAuthResult::Fail => "fail",
380 ReportAuthResult::None => "none",
381 }
382}
383
384#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::common::dns::mock::MockResolver;
390 use crate::common::dns::DnsError;
391 use crate::dmarc::types::FailureOption;
392
393 fn sample_policy() -> PublishedPolicy {
394 PublishedPolicy {
395 domain: "example.com".to_string(),
396 adkim: AlignmentMode::Relaxed,
397 aspf: AlignmentMode::Relaxed,
398 policy: Policy::Reject,
399 subdomain_policy: Policy::Quarantine,
400 percent: 100,
401 }
402 }
403
404 fn sample_record(ip: &str, header_from: &str) -> ReportRecord {
405 ReportRecord {
406 source_ip: ip.parse().unwrap(),
407 count: 1,
408 disposition: ReportDisposition::Reject,
409 dkim_result: ReportAuthResult::Fail,
410 spf_result: ReportAuthResult::Pass,
411 dkim_domain: Some("example.com".to_string()),
412 spf_domain: Some("example.com".to_string()),
413 envelope_from: Some("sender@example.com".to_string()),
414 header_from: header_from.to_string(),
415 }
416 }
417
418 #[test]
421 fn aggregate_report_xml_structure() {
422 let mut builder = AggregateReportBuilder::new(
423 "Test Org", "dmarc@test.org", "report-001",
424 1700000000, 1700086400, sample_policy(),
425 );
426 builder.add_record(sample_record("192.0.2.1", "example.com"));
427 let report = builder.build();
428 let xml = report.to_xml();
429
430 assert!(xml.starts_with("<?xml version=\"1.0\""));
431 assert!(xml.contains("<feedback>"));
432 assert!(xml.contains("</feedback>"));
433 assert!(xml.contains("<report_metadata>"));
434 assert!(xml.contains("<policy_published>"));
435 assert!(xml.contains("<record>"));
436 }
437
438 #[test]
441 fn aggregate_report_metadata() {
442 let builder = AggregateReportBuilder::new(
443 "My Org", "admin@org.com", "rpt-42",
444 1700000000, 1700086400, sample_policy(),
445 );
446 let xml = builder.build().to_xml();
447
448 assert!(xml.contains("<org_name>My Org</org_name>"));
449 assert!(xml.contains("<email>admin@org.com</email>"));
450 assert!(xml.contains("<report_id>rpt-42</report_id>"));
451 assert!(xml.contains("<begin>1700000000</begin>"));
452 assert!(xml.contains("<end>1700086400</end>"));
453 }
454
455 #[test]
458 fn aggregate_report_policy_published() {
459 let builder = AggregateReportBuilder::new(
460 "Org", "e@o.com", "r1", 0, 0, sample_policy(),
461 );
462 let xml = builder.build().to_xml();
463
464 assert!(xml.contains("<domain>example.com</domain>"));
465 assert!(xml.contains("<adkim>r</adkim>"));
466 assert!(xml.contains("<aspf>r</aspf>"));
467 assert!(xml.contains("<p>reject</p>"));
468 assert!(xml.contains("<sp>quarantine</sp>"));
469 assert!(xml.contains("<pct>100</pct>"));
470 }
471
472 #[test]
475 fn aggregate_report_multiple_records() {
476 let mut builder = AggregateReportBuilder::new(
477 "Org", "e@o.com", "r1", 0, 0, sample_policy(),
478 );
479 builder.add_record(sample_record("192.0.2.1", "a.com"));
480 builder.add_record(sample_record("192.0.2.2", "b.com"));
481 builder.add_record(sample_record("192.0.2.3", "c.com"));
482 let xml = builder.build().to_xml();
483
484 let record_count = xml.matches("<record>").count();
485 assert_eq!(record_count, 3);
486 }
487
488 #[test]
491 fn aggregate_report_empty() {
492 let builder = AggregateReportBuilder::new(
493 "Org", "e@o.com", "r1", 0, 0, sample_policy(),
494 );
495 let xml = builder.build().to_xml();
496
497 assert!(xml.contains("<feedback>"));
498 assert!(xml.contains("</feedback>"));
499 assert!(!xml.contains("<record>"));
500 }
501
502 #[tokio::test]
505 async fn external_uri_same_domain() {
506 let resolver = MockResolver::new(); let result = verify_external_report_uri(
508 &resolver, "example.com", "dmarc@example.com",
509 ).await;
510 assert!(result);
511 }
512
513 #[tokio::test]
516 async fn external_uri_cross_domain_authorized() {
517 let mut resolver = MockResolver::new();
518 resolver.add_txt(
519 "example.com._report._dmarc.thirdparty.com",
520 vec!["v=DMARC1".to_string()],
521 );
522 let result = verify_external_report_uri(
523 &resolver, "example.com", "reports@thirdparty.com",
524 ).await;
525 assert!(result);
526 }
527
528 #[tokio::test]
531 async fn external_uri_cross_domain_unauthorized() {
532 let resolver = MockResolver::new(); let result = verify_external_report_uri(
534 &resolver, "example.com", "reports@thirdparty.com",
535 ).await;
536 assert!(!result);
537 }
538
539 #[tokio::test]
542 async fn external_uri_cross_domain_tempfail() {
543 let mut resolver = MockResolver::new();
544 resolver.add_txt_err(
545 "example.com._report._dmarc.thirdparty.com",
546 DnsError::TempFail,
547 );
548 let result = verify_external_report_uri(
549 &resolver, "example.com", "reports@thirdparty.com",
550 ).await;
551 assert!(!result); }
553
554 #[test]
557 fn failure_report_afrf_format() {
558 let report = FailureReport {
559 original_headers: "From: bad@example.com\r\nSubject: test\r\n".to_string(),
560 auth_failure: "dmarc=fail".to_string(),
561 from_domain: "example.com".to_string(),
562 source_ip: Some("192.0.2.1".parse().unwrap()),
563 reporting_domain: "receiver.org".to_string(),
564 };
565 let afrf = report.to_afrf();
566
567 assert!(afrf.contains("Feedback-Type: auth-failure"));
568 assert!(afrf.contains("multipart/report"));
569 assert!(afrf.contains("feedback-report"));
570 assert!(afrf.contains("Auth-Failure: dmarc"));
571 assert!(afrf.contains("Reported-Domain: example.com"));
572 assert!(afrf.contains("Source-IP: 192.0.2.1"));
573 assert!(afrf.contains("text/rfc822-headers"));
574 assert!(afrf.contains("From: bad@example.com"));
575 }
576
577 #[test]
580 fn fo_0_both_fail_generate() {
581 assert!(should_generate_failure_report(
582 &[FailureOption::Zero],
583 false, false,
584 ));
585 }
586
587 #[test]
590 fn fo_0_dkim_aligns_no_report() {
591 assert!(!should_generate_failure_report(
592 &[FailureOption::Zero],
593 true, false,
594 ));
595 }
596
597 #[test]
600 fn fo_1_any_fails_generate() {
601 assert!(should_generate_failure_report(
603 &[FailureOption::One],
604 true, false,
605 ));
606 assert!(should_generate_failure_report(
608 &[FailureOption::One],
609 false, true,
610 ));
611 }
612
613 #[test]
616 fn fo_d_dkim_fails_generate() {
617 assert!(should_generate_failure_report(
618 &[FailureOption::D],
619 false, true, ));
621 }
622
623 #[test]
626 fn fo_d_dkim_passes_no_report() {
627 assert!(!should_generate_failure_report(
628 &[FailureOption::D],
629 true, false, ));
631 }
632
633 #[test]
636 fn fo_s_spf_fails_generate() {
637 assert!(should_generate_failure_report(
638 &[FailureOption::S],
639 true, false, ));
641 }
642
643 #[test]
646 fn fo_s_spf_passes_no_report() {
647 assert!(!should_generate_failure_report(
648 &[FailureOption::S],
649 false, true, ));
651 }
652
653 #[test]
656 fn xml_escaping() {
657 let builder = AggregateReportBuilder::new(
658 "Org <&>", "e@o.com", "r1", 0, 0, sample_policy(),
659 );
660 let xml = builder.build().to_xml();
661 assert!(xml.contains("<org_name>Org <&></org_name>"));
662 }
663
664 #[test]
667 fn fo_0_both_align_no_report() {
668 assert!(!should_generate_failure_report(
669 &[FailureOption::Zero],
670 true, true,
671 ));
672 }
673
674 #[test]
677 fn fo_1_both_align_no_report() {
678 assert!(!should_generate_failure_report(
679 &[FailureOption::One],
680 true, true,
681 ));
682 }
683
684 #[test]
687 fn multiple_fo_options_any_triggers() {
688 assert!(should_generate_failure_report(
690 &[FailureOption::Zero, FailureOption::D],
691 false, true,
692 ));
693 }
694
695 #[tokio::test]
698 async fn external_uri_auth_record_with_tags() {
699 let mut resolver = MockResolver::new();
700 resolver.add_txt(
701 "example.com._report._dmarc.thirdparty.com",
702 vec!["v=DMARC1; rua=mailto:reports@thirdparty.com".to_string()],
703 );
704 let result = verify_external_report_uri(
705 &resolver, "example.com", "reports@thirdparty.com",
706 ).await;
707 assert!(result); }
709
710 #[test]
713 fn aggregate_report_ipv6_source() {
714 let mut builder = AggregateReportBuilder::new(
715 "Org", "e@o.com", "r1", 0, 0, sample_policy(),
716 );
717 builder.add_record(ReportRecord {
718 source_ip: "2001:db8::1".parse().unwrap(),
719 count: 5,
720 disposition: ReportDisposition::None,
721 dkim_result: ReportAuthResult::Pass,
722 spf_result: ReportAuthResult::Pass,
723 dkim_domain: Some("example.com".to_string()),
724 spf_domain: Some("example.com".to_string()),
725 envelope_from: None,
726 header_from: "example.com".to_string(),
727 });
728 let xml = builder.build().to_xml();
729 assert!(xml.contains("<source_ip>2001:db8::1</source_ip>"));
730 assert!(xml.contains("<count>5</count>"));
731 assert!(xml.contains("<disposition>none</disposition>"));
732 }
733}