Skip to main content

email_auth/dmarc/
report.rs

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// ─── Aggregate Report Types ─────────────────────────────────────────
11
12/// DMARC aggregate report per RFC 7489 Appendix C.
13#[derive(Debug, Clone)]
14pub struct AggregateReport {
15    /// Reporting organization name.
16    pub org_name: String,
17    /// Contact email for the reporting organization.
18    pub email: String,
19    /// Unique report identifier.
20    pub report_id: String,
21    /// Date range begin (UNIX timestamp).
22    pub date_range_begin: u64,
23    /// Date range end (UNIX timestamp).
24    pub date_range_end: u64,
25    /// Published policy details.
26    pub policy: PublishedPolicy,
27    /// Individual authentication result records.
28    pub records: Vec<ReportRecord>,
29}
30
31/// Policy published by the domain owner (as discovered).
32#[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/// A single row in the aggregate report.
43#[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    /// DKIM auth domain (d= from signature).
51    pub dkim_domain: Option<String>,
52    /// SPF auth domain (MAIL FROM domain).
53    pub spf_domain: Option<String>,
54    /// Envelope From (MAIL FROM).
55    pub envelope_from: Option<String>,
56    /// RFC5322.From header domain.
57    pub header_from: String,
58}
59
60/// Disposition reported in aggregate report.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ReportDisposition {
63    None,
64    Quarantine,
65    Reject,
66}
67
68/// Auth result for a single mechanism in reporting.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ReportAuthResult {
71    Pass,
72    Fail,
73    None,
74}
75
76impl AggregateReport {
77    /// Serialize to XML matching RFC 7489 Appendix C schema.
78    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        // Report metadata
84        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        // Policy published
95        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        // Records
105        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
144// ─── AggregateReportBuilder ─────────────────────────────────────────
145
146/// Builder that accumulates auth results and produces an AggregateReport.
147pub 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
194// ─── External Report URI Verification ────────────────────────────────
195
196/// Verify that a cross-domain report URI is authorized.
197/// If sender domain and target domain differ, queries
198/// `<sender-domain>._report._dmarc.<target-domain>` for a TXT record containing "v=DMARC1".
199pub 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    // Same domain → no verification needed
213    if domain::domains_equal(&sender_norm, &target_norm) {
214        return true;
215    }
216
217    // Cross-domain → query _report._dmarc
218    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, // TempFail or NxDomain → drop URI
222    };
223
224    // Look for any record starting with "v=DMARC1"
225    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// ─── Failure Report Types ────────────────────────────────────────────
234
235/// DMARC failure report per RFC 6591 (AFRF format).
236#[derive(Debug, Clone)]
237pub struct FailureReport {
238    /// Original message headers (or relevant subset).
239    pub original_headers: String,
240    /// Authentication failure details.
241    pub auth_failure: String,
242    /// RFC5322.From domain.
243    pub from_domain: String,
244    /// Source IP of the message.
245    pub source_ip: Option<IpAddr>,
246    /// Reporting domain.
247    pub reporting_domain: String,
248}
249
250impl FailureReport {
251    /// Generate AFRF-formatted failure report.
252    /// Returns a MIME multipart/report message body.
253    pub fn to_afrf(&self) -> String {
254        let boundary = "----=_DMARC_AFRF_Boundary";
255        let mut out = String::new();
256        // MIME headers
257        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        // Part 1: Human-readable description
262        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        // Part 2: Machine-readable feedback report
269        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        // Part 3: Original headers
284        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        // Closing boundary
294        let _ = writeln!(out, "--{}--", boundary);
295
296        out
297    }
298}
299
300// ─── Failure Option Filtering ────────────────────────────────────────
301
302/// Determine whether a failure report should be generated based on fo= options.
303pub 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                // Report when ALL mechanisms fail (neither DKIM nor SPF aligned)
312                if !dkim_aligned && !spf_aligned {
313                    return true;
314                }
315            }
316            FailureOption::One => {
317                // Report when ANY mechanism fails
318                if !dkim_aligned || !spf_aligned {
319                    return true;
320                }
321            }
322            FailureOption::D => {
323                // Report when DKIM fails
324                if !dkim_aligned {
325                    return true;
326                }
327            }
328            FailureOption::S => {
329                // Report when SPF fails
330                if !spf_aligned {
331                    return true;
332                }
333            }
334        }
335    }
336    false
337}
338
339// ─── Helpers ─────────────────────────────────────────────────────────
340
341fn 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('&', "&amp;")
347        .replace('<', "&lt;")
348        .replace('>', "&gt;")
349        .replace('"', "&quot;")
350        .replace('\'', "&apos;")
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// ─── Tests ───────────────────────────────────────────────────────────
385
386#[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    // ─── CHK-733: Build → XML → verify structure ─────────────────────
419
420    #[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    // ─── CHK-734: Report metadata present ────────────────────────────
439
440    #[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    // ─── CHK-735: Policy published fields ────────────────────────────
456
457    #[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    // ─── CHK-736: Multiple records ───────────────────────────────────
473
474    #[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    // ─── CHK-737: Empty report ───────────────────────────────────────
489
490    #[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    // ─── CHK-738: Same domain, no _report._dmarc query ──────────────
503
504    #[tokio::test]
505    async fn external_uri_same_domain() {
506        let resolver = MockResolver::new(); // no DNS entries needed
507        let result = verify_external_report_uri(
508            &resolver, "example.com", "dmarc@example.com",
509        ).await;
510        assert!(result);
511    }
512
513    // ─── CHK-739: Cross-domain authorized ────────────────────────────
514
515    #[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    // ─── CHK-740: Cross-domain unauthorized ──────────────────────────
529
530    #[tokio::test]
531    async fn external_uri_cross_domain_unauthorized() {
532        let resolver = MockResolver::new(); // no authorization record
533        let result = verify_external_report_uri(
534            &resolver, "example.com", "reports@thirdparty.com",
535        ).await;
536        assert!(!result);
537    }
538
539    // ─── CHK-741: Cross-domain TempFail ──────────────────────────────
540
541    #[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); // Safe default: drop URI
552    }
553
554    // ─── CHK-742: AFRF format ────────────────────────────────────────
555
556    #[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    // ─── CHK-743: fo=0 both fail → report ───────────────────────────
578
579    #[test]
580    fn fo_0_both_fail_generate() {
581        assert!(should_generate_failure_report(
582            &[FailureOption::Zero],
583            false, false,
584        ));
585    }
586
587    // ─── CHK-744: fo=0 dkim aligns, spf fails → NO report ───────────
588
589    #[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    // ─── CHK-745: fo=1 any fails → report ───────────────────────────
598
599    #[test]
600    fn fo_1_any_fails_generate() {
601        // DKIM aligns, SPF fails → report (any failed)
602        assert!(should_generate_failure_report(
603            &[FailureOption::One],
604            true, false,
605        ));
606        // SPF aligns, DKIM fails → report
607        assert!(should_generate_failure_report(
608            &[FailureOption::One],
609            false, true,
610        ));
611    }
612
613    // ─── CHK-746: fo=d dkim fails → report ──────────────────────────
614
615    #[test]
616    fn fo_d_dkim_fails_generate() {
617        assert!(should_generate_failure_report(
618            &[FailureOption::D],
619            false, true, // DKIM fails, SPF aligns
620        ));
621    }
622
623    // ─── CHK-747: fo=d dkim passes → NO report ──────────────────────
624
625    #[test]
626    fn fo_d_dkim_passes_no_report() {
627        assert!(!should_generate_failure_report(
628            &[FailureOption::D],
629            true, false, // DKIM passes, SPF fails
630        ));
631    }
632
633    // ─── CHK-748: fo=s spf fails → report ───────────────────────────
634
635    #[test]
636    fn fo_s_spf_fails_generate() {
637        assert!(should_generate_failure_report(
638            &[FailureOption::S],
639            true, false, // DKIM passes, SPF fails
640        ));
641    }
642
643    // ─── CHK-749: fo=s spf passes → NO report ───────────────────────
644
645    #[test]
646    fn fo_s_spf_passes_no_report() {
647        assert!(!should_generate_failure_report(
648            &[FailureOption::S],
649            false, true, // DKIM fails, SPF passes
650        ));
651    }
652
653    // ─── Additional: XML escaping ────────────────────────────────────
654
655    #[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 &lt;&amp;&gt;</org_name>"));
662    }
663
664    // ─── Additional: fo=0 both align → NO report ─────────────────────
665
666    #[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    // ─── Additional: fo=1 both align → NO report ─────────────────────
675
676    #[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    // ─── Additional: multiple fo options ──────────────────────────────
685
686    #[test]
687    fn multiple_fo_options_any_triggers() {
688        // fo=0:d — DKIM fails, SPF passes → fo=0 doesn't trigger but fo=d does
689        assert!(should_generate_failure_report(
690            &[FailureOption::Zero, FailureOption::D],
691            false, true,
692        ));
693    }
694
695    // ─── Additional: verify_external auth record with tags ───────────
696
697    #[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); // v=DMARC1; ... is valid
708    }
709
710    // ─── Additional: report record with IPv6 ─────────────────────────
711
712    #[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}