Skip to main content

dmarc_report_parser/
lib.rs

1//! DMARC aggregate report parser (RFC 7489).
2//!
3//! Parse DMARC aggregate feedback reports from their XML representation as
4//! defined in [RFC 7489 Appendix C](https://www.rfc-editor.org/rfc/rfc7489#appendix-C).
5//!
6#![doc = include_str!("../docs/library-usage.md")]
7//!
8//! # CLI
9//!
10#![doc = include_str!("../docs/cli-usage.md")]
11#![warn(missing_docs)]
12
13mod error;
14pub use error::Error;
15
16use serde::Deserialize;
17
18fn deserialize_optional_alignment<'de, D>(
19    deserializer: D,
20) -> Result<Option<AlignmentMode>, D::Error>
21where
22    D: serde::Deserializer<'de>,
23{
24    struct Visitor;
25
26    impl<'de> serde::de::Visitor<'de> for Visitor {
27        type Value = Option<AlignmentMode>;
28
29        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30            f.write_str("alignment mode 'r', 's', or empty")
31        }
32
33        fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
34            match v {
35                "" => Ok(None),
36                "r" => Ok(Some(AlignmentMode::Relaxed)),
37                "s" => Ok(Some(AlignmentMode::Strict)),
38                other => Err(E::unknown_variant(other, &["r", "s"])),
39            }
40        }
41
42        fn visit_map<A: serde::de::MapAccess<'de>>(
43            self,
44            mut map: A,
45        ) -> Result<Self::Value, A::Error> {
46            // quick-xml represents <adkim></adkim> as {"$text": ""} rather than a plain string
47            let mut text = String::new();
48            while let Some(key) = map.next_key::<String>()? {
49                let val: String = map.next_value()?;
50                if key == "$text" {
51                    text = val;
52                }
53            }
54            self.visit_str(&text)
55        }
56    }
57
58    deserializer.deserialize_any(Visitor)
59}
60
61// ──────────────────────────────────────────────────────────────────────────────
62// Public API
63// ──────────────────────────────────────────────────────────────────────────────
64
65/// Parse a DMARC aggregate report from an XML string.
66///
67/// # Errors
68///
69/// Returns [`Error::Parse`] if the XML is invalid or does not conform to the
70/// DMARC aggregate report schema (RFC 7489 Appendix C).
71pub fn parse(xml: &str) -> Result<Report, Error> {
72    quick_xml::de::from_str(xml).map_err(Error::from)
73}
74
75/// Parse a DMARC aggregate report from a byte slice.
76///
77/// # Errors
78///
79/// Returns [`Error::Utf8`] if the bytes are not valid UTF-8, or
80/// [`Error::Parse`] if the XML is invalid or non-conformant.
81pub fn parse_bytes(bytes: &[u8]) -> Result<Report, Error> {
82    let xml = std::str::from_utf8(bytes)?;
83    parse(xml)
84}
85
86// ──────────────────────────────────────────────────────────────────────────────
87// RFC 7489 Appendix C — DMARC XML schema types
88// ──────────────────────────────────────────────────────────────────────────────
89
90/// Top-level DMARC aggregate feedback report (`<feedback>`).
91///
92/// Defined as the root element in RFC 7489 Appendix C.
93#[derive(Debug, Clone, PartialEq, Deserialize)]
94#[serde(rename = "feedback")]
95pub struct Report {
96    /// Report format version (optional, xs:decimal).
97    #[serde(default)]
98    pub version: Option<String>,
99
100    /// Metadata about the report generator.
101    pub report_metadata: ReportMetadata,
102
103    /// The DMARC policy published for the domain covered by this report.
104    pub policy_published: PolicyPublished,
105
106    /// One or more individual message records.
107    #[serde(rename = "record")]
108    pub records: Vec<Record>,
109}
110
111/// Report generator metadata (`ReportMetadataType`).
112#[derive(Debug, Clone, PartialEq, Deserialize)]
113pub struct ReportMetadata {
114    /// The name of the organization generating the report.
115    pub org_name: String,
116
117    /// Contact email address for the report generator.
118    pub email: String,
119
120    /// Additional contact information (optional).
121    #[serde(default)]
122    pub extra_contact_info: Option<String>,
123
124    /// Unique identifier for this report.
125    pub report_id: String,
126
127    /// The UTC time range covered by the messages in this report.
128    pub date_range: DateRange,
129
130    /// Any errors encountered during report generation.
131    #[serde(rename = "error", default)]
132    pub errors: Vec<String>,
133}
134
135/// UTC time range covered by a report, expressed as Unix timestamps.
136#[derive(Debug, Clone, PartialEq, Deserialize)]
137pub struct DateRange {
138    /// Start of the time range (seconds since Unix epoch).
139    pub begin: i64,
140
141    /// End of the time range (seconds since Unix epoch).
142    pub end: i64,
143}
144
145/// The DMARC policy published for the organizational domain (`PolicyPublishedType`).
146#[derive(Debug, Clone, PartialEq, Deserialize)]
147pub struct PolicyPublished {
148    /// The domain to which the DMARC policy applies.
149    pub domain: String,
150
151    /// DKIM alignment mode (`r` = relaxed, `s` = strict). Defaults to relaxed when absent.
152    #[serde(default, deserialize_with = "deserialize_optional_alignment")]
153    pub adkim: Option<AlignmentMode>,
154
155    /// SPF alignment mode (`r` = relaxed, `s` = strict). Defaults to relaxed when absent.
156    #[serde(default, deserialize_with = "deserialize_optional_alignment")]
157    pub aspf: Option<AlignmentMode>,
158
159    /// Domain-level policy action.
160    pub p: Disposition,
161
162    /// Subdomain policy action.
163    pub sp: Disposition,
164
165    /// Percentage of messages to which the policy is applied (0–100).
166    pub pct: u32,
167
168    /// Failure reporting options (colon-separated list of `0`, `1`, `d`, `s`).
169    #[serde(default)]
170    pub fo: Option<String>,
171}
172
173/// DKIM / SPF alignment mode (`AlignmentType`).
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
175pub enum AlignmentMode {
176    /// Relaxed alignment (default). Organisational domain match is sufficient.
177    #[serde(rename = "r")]
178    Relaxed,
179
180    /// Strict alignment. Exact domain match is required.
181    #[serde(rename = "s")]
182    Strict,
183}
184
185impl std::fmt::Display for AlignmentMode {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        match self {
188            AlignmentMode::Relaxed => f.write_str("r"),
189            AlignmentMode::Strict => f.write_str("s"),
190        }
191    }
192}
193
194/// Policy action applied to a message (`DispositionType`).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum Disposition {
198    /// No action taken; the message is delivered normally.
199    None,
200    /// The message is treated as suspicious and may be quarantined.
201    Quarantine,
202    /// The message is rejected.
203    Reject,
204}
205
206impl std::fmt::Display for Disposition {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            Disposition::None => f.write_str("none"),
210            Disposition::Quarantine => f.write_str("quarantine"),
211            Disposition::Reject => f.write_str("reject"),
212        }
213    }
214}
215
216/// A single message record within a feedback report (`RecordType`).
217#[derive(Debug, Clone, PartialEq, Deserialize)]
218pub struct Record {
219    /// Per-message row data.
220    pub row: Row,
221
222    /// Identifiers extracted from the message.
223    pub identifiers: Identifiers,
224
225    /// Authentication results for the message.
226    pub auth_results: AuthResults,
227}
228
229/// Per-message data row (`RowType`).
230#[derive(Debug, Clone, PartialEq, Deserialize)]
231pub struct Row {
232    /// The IP address of the sending mail server.
233    pub source_ip: String,
234
235    /// The number of messages covered by this row.
236    pub count: u64,
237
238    /// The applied DMARC policy evaluation results.
239    pub policy_evaluated: PolicyEvaluated,
240}
241
242/// Results of applying DMARC to the messages in this row (`PolicyEvaluatedType`).
243#[derive(Debug, Clone, PartialEq, Deserialize)]
244pub struct PolicyEvaluated {
245    /// The final policy action applied.
246    pub disposition: Disposition,
247
248    /// Whether the message passed DKIM alignment.
249    pub dkim: DmarcResult,
250
251    /// Whether the message passed SPF alignment.
252    pub spf: DmarcResult,
253
254    /// Reasons that may have altered the evaluated disposition.
255    #[serde(rename = "reason", default)]
256    pub reasons: Vec<PolicyOverrideReason>,
257}
258
259/// The DMARC-aligned authentication result (`DMARCResultType`).
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
261#[serde(rename_all = "lowercase")]
262pub enum DmarcResult {
263    /// Authentication passed.
264    Pass,
265    /// Authentication failed.
266    Fail,
267}
268
269impl std::fmt::Display for DmarcResult {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        match self {
272            DmarcResult::Pass => f.write_str("pass"),
273            DmarcResult::Fail => f.write_str("fail"),
274        }
275    }
276}
277
278/// A reason why the applied policy may differ from the published policy
279/// (`PolicyOverrideReasonType`).
280#[derive(Debug, Clone, PartialEq, Deserialize)]
281pub struct PolicyOverrideReason {
282    /// The type of policy override.
283    #[serde(rename = "type")]
284    pub reason_type: PolicyOverride,
285
286    /// An optional human-readable comment about the override.
287    #[serde(default)]
288    pub comment: Option<String>,
289}
290
291/// Reason type for a policy override (`PolicyOverrideType`).
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum PolicyOverride {
295    /// Message was forwarded and could not be authenticated.
296    Forwarded,
297    /// Message was sampled out of the policy percentage.
298    SampledOut,
299    /// Message was from a trusted forwarder.
300    TrustedForwarder,
301    /// Message was processed by a mailing list.
302    MailingList,
303    /// Local policy overrode the published DMARC policy.
304    LocalPolicy,
305    /// Some other reason.
306    Other,
307}
308
309impl std::fmt::Display for PolicyOverride {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        match self {
312            PolicyOverride::Forwarded => f.write_str("forwarded"),
313            PolicyOverride::SampledOut => f.write_str("sampled_out"),
314            PolicyOverride::TrustedForwarder => f.write_str("trusted_forwarder"),
315            PolicyOverride::MailingList => f.write_str("mailing_list"),
316            PolicyOverride::LocalPolicy => f.write_str("local_policy"),
317            PolicyOverride::Other => f.write_str("other"),
318        }
319    }
320}
321
322/// Message identifiers (`IdentifierType`).
323#[derive(Debug, Clone, PartialEq, Deserialize)]
324pub struct Identifiers {
325    /// The RFC 5321 `RCPT TO` domain, if available.
326    #[serde(default)]
327    pub envelope_to: Option<String>,
328
329    /// The RFC 5321 `MAIL FROM` domain, if available.
330    #[serde(default)]
331    pub envelope_from: Option<String>,
332
333    /// The RFC 5322 `From:` header domain.
334    pub header_from: String,
335}
336
337/// Authentication results for a message (`AuthResultType`).
338#[derive(Debug, Clone, PartialEq, Deserialize)]
339pub struct AuthResults {
340    /// DKIM signature evaluation results (zero or more).
341    #[serde(rename = "dkim", default)]
342    pub dkim: Vec<DkimAuthResult>,
343
344    /// SPF evaluation results (one or more per RFC 7489).
345    #[serde(rename = "spf")]
346    pub spf: Vec<SpfAuthResult>,
347}
348
349/// Result of evaluating a single DKIM signature (`DKIMAuthResultType`).
350#[derive(Debug, Clone, PartialEq, Deserialize)]
351pub struct DkimAuthResult {
352    /// The `d=` domain from the DKIM signature.
353    pub domain: String,
354
355    /// The `s=` selector from the DKIM signature.
356    #[serde(default)]
357    pub selector: Option<String>,
358
359    /// The DKIM verification result.
360    pub result: DkimResult,
361
362    /// A human-readable result string.
363    #[serde(default)]
364    pub human_result: Option<String>,
365}
366
367/// DKIM verification result (`DKIMResultType`), per RFC 5451.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
369#[serde(rename_all = "lowercase")]
370pub enum DkimResult {
371    /// No DKIM signature was found.
372    None,
373    /// The DKIM signature verified successfully.
374    Pass,
375    /// The DKIM signature failed verification.
376    Fail,
377    /// The DKIM signature was rejected for policy reasons.
378    Policy,
379    /// The DKIM verification result was neutral.
380    Neutral,
381    /// A transient error occurred during DKIM verification.
382    Temperror,
383    /// A permanent error occurred during DKIM verification.
384    Permerror,
385}
386
387impl std::fmt::Display for DkimResult {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        match self {
390            DkimResult::None => f.write_str("none"),
391            DkimResult::Pass => f.write_str("pass"),
392            DkimResult::Fail => f.write_str("fail"),
393            DkimResult::Policy => f.write_str("policy"),
394            DkimResult::Neutral => f.write_str("neutral"),
395            DkimResult::Temperror => f.write_str("temperror"),
396            DkimResult::Permerror => f.write_str("permerror"),
397        }
398    }
399}
400
401/// Result of an SPF check (`SPFAuthResultType`).
402#[derive(Debug, Clone, PartialEq, Deserialize)]
403pub struct SpfAuthResult {
404    /// The domain used for SPF evaluation.
405    pub domain: String,
406
407    /// The identity that was checked (HELO or MAIL FROM).
408    #[serde(default)]
409    pub scope: Option<SpfDomainScope>,
410
411    /// The SPF evaluation result.
412    pub result: SpfResult,
413}
414
415/// SPF identity scope (`SPFDomainScope`).
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
417#[serde(rename_all = "lowercase")]
418pub enum SpfDomainScope {
419    /// The SMTP `HELO`/`EHLO` identity.
420    Helo,
421    /// The SMTP `MAIL FROM` identity.
422    Mfrom,
423}
424
425impl std::fmt::Display for SpfDomainScope {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        match self {
428            SpfDomainScope::Helo => f.write_str("helo"),
429            SpfDomainScope::Mfrom => f.write_str("mfrom"),
430        }
431    }
432}
433
434/// SPF evaluation result (`SPFResultType`), per RFC 7208.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
436#[serde(rename_all = "lowercase")]
437pub enum SpfResult {
438    /// No SPF record was found.
439    None,
440    /// The SPF check returned a neutral result.
441    Neutral,
442    /// The SPF check passed.
443    Pass,
444    /// The SPF check failed.
445    Fail,
446    /// The SPF check returned a soft-fail result.
447    Softfail,
448    /// A transient error occurred during SPF evaluation.
449    Temperror,
450    /// A permanent error occurred during SPF evaluation.
451    Permerror,
452}
453
454impl std::fmt::Display for SpfResult {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        match self {
457            SpfResult::None => f.write_str("none"),
458            SpfResult::Neutral => f.write_str("neutral"),
459            SpfResult::Pass => f.write_str("pass"),
460            SpfResult::Fail => f.write_str("fail"),
461            SpfResult::Softfail => f.write_str("softfail"),
462            SpfResult::Temperror => f.write_str("temperror"),
463            SpfResult::Permerror => f.write_str("permerror"),
464        }
465    }
466}
467
468// ──────────────────────────────────────────────────────────────────────────────
469// Aggregate view across multiple reports
470// ──────────────────────────────────────────────────────────────────────────────
471
472/// A combined view across multiple DMARC aggregate reports.
473///
474/// Each underlying [`Report`] retains its own metadata and `policy_published`
475/// — there is intentionally no synthetic merged report, since fields like
476/// `org_name`, `report_id`, and `date_range` cannot be honestly combined.
477/// Use [`Aggregate::records`] to iterate every record paired with the report
478/// it came from.
479#[derive(Debug, Clone, PartialEq)]
480pub struct Aggregate {
481    /// The reports that make up the aggregate, in the order they were added.
482    pub reports: Vec<Report>,
483}
484
485impl Aggregate {
486    /// Build an aggregate from a collection of reports.
487    pub fn from_reports(reports: Vec<Report>) -> Self {
488        Self { reports }
489    }
490
491    /// Iterator over every record across every report, paired with the
492    /// [`Report`] it came from.
493    pub fn records(&self) -> impl Iterator<Item = (&Report, &Record)> {
494        self.reports
495            .iter()
496            .flat_map(|r| r.records.iter().map(move |rec| (r, rec)))
497    }
498
499    /// Sum of `row.count` across every record.
500    pub fn total_messages(&self) -> u64 {
501        self.records().map(|(_, rec)| rec.row.count).sum()
502    }
503
504    /// Earliest `begin` and latest `end` across all reports' date ranges.
505    /// Returns `None` if the aggregate contains no reports.
506    pub fn date_span(&self) -> Option<(i64, i64)> {
507        let begin = self
508            .reports
509            .iter()
510            .map(|r| r.report_metadata.date_range.begin)
511            .min()?;
512        let end = self
513            .reports
514            .iter()
515            .map(|r| r.report_metadata.date_range.end)
516            .max()?;
517        Some((begin, end))
518    }
519}
520
521impl From<Vec<Report>> for Aggregate {
522    fn from(reports: Vec<Report>) -> Self {
523        Self::from_reports(reports)
524    }
525}
526
527// ──────────────────────────────────────────────────────────────────────────────
528// Trait implementations for idiomatic Rust usage
529// ──────────────────────────────────────────────────────────────────────────────
530
531impl std::str::FromStr for Report {
532    type Err = Error;
533
534    fn from_str(s: &str) -> Result<Self, Self::Err> {
535        parse(s)
536    }
537}
538
539impl TryFrom<&str> for Report {
540    type Error = Error;
541
542    fn try_from(s: &str) -> Result<Self, Self::Error> {
543        parse(s)
544    }
545}
546
547impl TryFrom<&[u8]> for Report {
548    type Error = Error;
549
550    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
551        parse_bytes(bytes)
552    }
553}
554
555/// The README shows the same calls as the documentation above. This anchor gives
556/// its code blocks to rustdoc as doctests, so a change to the API that the README
557/// misses breaks the test run. It is not part of the public documentation.
558#[cfg(doctest)]
559#[doc = include_str!("../README.md")]
560struct ReadmeExamples;
561
562// ──────────────────────────────────────────────────────────────────────────────
563// Unit tests
564// ──────────────────────────────────────────────────────────────────────────────
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    // Minimal valid report — only required fields present
571    const MINIMAL_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
572<feedback>
573  <report_metadata>
574    <org_name>Acme</org_name>
575    <email>postmaster@acme.example</email>
576    <report_id>20130901.r.acme.example</report_id>
577    <date_range>
578      <begin>1377993600</begin>
579      <end>1378080000</end>
580    </date_range>
581  </report_metadata>
582  <policy_published>
583    <domain>acme.example</domain>
584    <p>none</p>
585    <sp>none</sp>
586    <pct>100</pct>
587  </policy_published>
588  <record>
589    <row>
590      <source_ip>192.0.2.1</source_ip>
591      <count>2</count>
592      <policy_evaluated>
593        <disposition>none</disposition>
594        <dkim>pass</dkim>
595        <spf>pass</spf>
596      </policy_evaluated>
597    </row>
598    <identifiers>
599      <envelope_from>acme.example</envelope_from>
600      <header_from>acme.example</header_from>
601    </identifiers>
602    <auth_results>
603      <spf>
604        <domain>acme.example</domain>
605        <result>pass</result>
606      </spf>
607    </auth_results>
608  </record>
609</feedback>"#;
610
611    #[test]
612    fn parse_minimal_report() {
613        let report = parse(MINIMAL_XML).unwrap();
614
615        // metadata
616        assert_eq!(report.report_metadata.org_name, "Acme");
617        assert_eq!(report.report_metadata.email, "postmaster@acme.example");
618        assert_eq!(report.report_metadata.report_id, "20130901.r.acme.example");
619        assert_eq!(report.report_metadata.date_range.begin, 1_377_993_600);
620        assert_eq!(report.report_metadata.date_range.end, 1_378_080_000);
621        assert!(report.report_metadata.extra_contact_info.is_none());
622        assert!(report.report_metadata.errors.is_empty());
623
624        // policy published
625        assert_eq!(report.policy_published.domain, "acme.example");
626        assert_eq!(report.policy_published.p, Disposition::None);
627        assert_eq!(report.policy_published.sp, Disposition::None);
628        assert_eq!(report.policy_published.pct, 100);
629        assert!(report.policy_published.adkim.is_none());
630        assert!(report.policy_published.aspf.is_none());
631
632        // records
633        assert_eq!(report.records.len(), 1);
634        let record = &report.records[0];
635        assert_eq!(record.row.source_ip, "192.0.2.1");
636        assert_eq!(record.row.count, 2);
637        assert_eq!(record.row.policy_evaluated.disposition, Disposition::None);
638        assert_eq!(record.row.policy_evaluated.dkim, DmarcResult::Pass);
639        assert_eq!(record.row.policy_evaluated.spf, DmarcResult::Pass);
640        assert!(record.row.policy_evaluated.reasons.is_empty());
641
642        // identifiers
643        assert!(record.identifiers.envelope_to.is_none());
644        assert_eq!(
645            record.identifiers.envelope_from.as_deref(),
646            Some("acme.example")
647        );
648        assert_eq!(record.identifiers.header_from, "acme.example");
649
650        // auth results
651        assert!(record.auth_results.dkim.is_empty());
652        assert_eq!(record.auth_results.spf.len(), 1);
653        assert_eq!(record.auth_results.spf[0].domain, "acme.example");
654        assert_eq!(record.auth_results.spf[0].result, SpfResult::Pass);
655    }
656
657    #[test]
658    fn parse_full_report_all_optional_fields() {
659        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
660<feedback>
661  <version>1.0</version>
662  <report_metadata>
663    <org_name>Mail Service Provider</org_name>
664    <email>dmarc-reports@msp.example</email>
665    <extra_contact_info>https://msp.example/dmarc-info</extra_contact_info>
666    <report_id>report-001</report_id>
667    <date_range>
668      <begin>1609459200</begin>
669      <end>1609545600</end>
670    </date_range>
671    <error>Lookup for example.com failed transiently</error>
672    <error>DNS timeout for subdomain.example.com</error>
673  </report_metadata>
674  <policy_published>
675    <domain>example.com</domain>
676    <adkim>s</adkim>
677    <aspf>s</aspf>
678    <p>reject</p>
679    <sp>quarantine</sp>
680    <pct>50</pct>
681    <fo>1</fo>
682  </policy_published>
683  <record>
684    <row>
685      <source_ip>198.51.100.42</source_ip>
686      <count>10</count>
687      <policy_evaluated>
688        <disposition>reject</disposition>
689        <dkim>fail</dkim>
690        <spf>fail</spf>
691        <reason>
692          <type>forwarded</type>
693          <comment>Known forwarder</comment>
694        </reason>
695      </policy_evaluated>
696    </row>
697    <identifiers>
698      <envelope_to>example.com</envelope_to>
699      <envelope_from>sender.example</envelope_from>
700      <header_from>example.com</header_from>
701    </identifiers>
702    <auth_results>
703      <dkim>
704        <domain>example.com</domain>
705        <selector>selector1</selector>
706        <result>fail</result>
707        <human_result>signature did not verify</human_result>
708      </dkim>
709      <spf>
710        <domain>sender.example</domain>
711        <scope>mfrom</scope>
712        <result>fail</result>
713      </spf>
714    </auth_results>
715  </record>
716</feedback>"#;
717
718        let report = parse(xml).unwrap();
719
720        // optional version
721        assert_eq!(report.version, Some("1.0".to_string()));
722
723        // metadata extras
724        assert_eq!(
725            report.report_metadata.extra_contact_info,
726            Some("https://msp.example/dmarc-info".to_string())
727        );
728        assert_eq!(report.report_metadata.errors.len(), 2);
729        assert_eq!(
730            report.report_metadata.errors[0],
731            "Lookup for example.com failed transiently"
732        );
733
734        // policy published optional fields
735        assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
736        assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
737        assert_eq!(report.policy_published.p, Disposition::Reject);
738        assert_eq!(report.policy_published.sp, Disposition::Quarantine);
739        assert_eq!(report.policy_published.pct, 50);
740        assert_eq!(report.policy_published.fo, Some("1".to_string()));
741
742        let record = &report.records[0];
743
744        // policy override reason
745        assert_eq!(record.row.policy_evaluated.reasons.len(), 1);
746        let reason = &record.row.policy_evaluated.reasons[0];
747        assert_eq!(reason.reason_type, PolicyOverride::Forwarded);
748        assert_eq!(reason.comment, Some("Known forwarder".to_string()));
749
750        // identifiers with envelope_to
751        assert_eq!(
752            record.identifiers.envelope_to,
753            Some("example.com".to_string())
754        );
755        assert_eq!(
756            record.identifiers.envelope_from.as_deref(),
757            Some("sender.example")
758        );
759
760        // DKIM auth result
761        assert_eq!(record.auth_results.dkim.len(), 1);
762        let dkim = &record.auth_results.dkim[0];
763        assert_eq!(dkim.domain, "example.com");
764        assert_eq!(dkim.selector, Some("selector1".to_string()));
765        assert_eq!(dkim.result, DkimResult::Fail);
766        assert_eq!(
767            dkim.human_result,
768            Some("signature did not verify".to_string())
769        );
770
771        // SPF auth result with scope
772        assert_eq!(
773            record.auth_results.spf[0].scope,
774            Some(SpfDomainScope::Mfrom)
775        );
776        assert_eq!(record.auth_results.spf[0].result, SpfResult::Fail);
777    }
778
779    #[test]
780    fn parse_multiple_records() {
781        let xml = r#"<?xml version="1.0"?>
782<feedback>
783  <report_metadata>
784    <org_name>Reporter</org_name>
785    <email>r@reporter.example</email>
786    <report_id>multi-001</report_id>
787    <date_range><begin>0</begin><end>86400</end></date_range>
788  </report_metadata>
789  <policy_published>
790    <domain>sender.example</domain>
791    <p>quarantine</p>
792    <sp>quarantine</sp>
793    <pct>100</pct>
794  </policy_published>
795  <record>
796    <row>
797      <source_ip>192.0.2.1</source_ip>
798      <count>1</count>
799      <policy_evaluated>
800        <disposition>none</disposition>
801        <dkim>pass</dkim>
802        <spf>pass</spf>
803      </policy_evaluated>
804    </row>
805    <identifiers>
806      <envelope_from>sender.example</envelope_from>
807      <header_from>sender.example</header_from>
808    </identifiers>
809    <auth_results>
810      <spf>
811        <domain>sender.example</domain>
812        <result>pass</result>
813      </spf>
814    </auth_results>
815  </record>
816  <record>
817    <row>
818      <source_ip>203.0.113.7</source_ip>
819      <count>3</count>
820      <policy_evaluated>
821        <disposition>quarantine</disposition>
822        <dkim>fail</dkim>
823        <spf>fail</spf>
824      </policy_evaluated>
825    </row>
826    <identifiers>
827      <envelope_from>attacker.example</envelope_from>
828      <header_from>sender.example</header_from>
829    </identifiers>
830    <auth_results>
831      <spf>
832        <domain>attacker.example</domain>
833        <result>fail</result>
834      </spf>
835    </auth_results>
836  </record>
837</feedback>"#;
838
839        let report = parse(xml).unwrap();
840
841        assert_eq!(report.records.len(), 2);
842        assert_eq!(report.records[0].row.source_ip, "192.0.2.1");
843        assert_eq!(report.records[0].row.count, 1);
844        assert_eq!(
845            report.records[0].row.policy_evaluated.disposition,
846            Disposition::None
847        );
848
849        assert_eq!(report.records[1].row.source_ip, "203.0.113.7");
850        assert_eq!(report.records[1].row.count, 3);
851        assert_eq!(
852            report.records[1].row.policy_evaluated.disposition,
853            Disposition::Quarantine
854        );
855        assert_eq!(
856            report.records[1].row.policy_evaluated.dkim,
857            DmarcResult::Fail
858        );
859    }
860
861    #[test]
862    fn parse_multiple_dkim_spf_auth_results() {
863        let xml = r#"<?xml version="1.0"?>
864<feedback>
865  <report_metadata>
866    <org_name>Reporter</org_name>
867    <email>r@reporter.example</email>
868    <report_id>multi-auth-001</report_id>
869    <date_range><begin>0</begin><end>86400</end></date_range>
870  </report_metadata>
871  <policy_published>
872    <domain>example.com</domain>
873    <p>none</p>
874    <sp>none</sp>
875    <pct>100</pct>
876  </policy_published>
877  <record>
878    <row>
879      <source_ip>192.0.2.1</source_ip>
880      <count>1</count>
881      <policy_evaluated>
882        <disposition>none</disposition>
883        <dkim>pass</dkim>
884        <spf>pass</spf>
885      </policy_evaluated>
886    </row>
887    <identifiers>
888      <envelope_from>example.com</envelope_from>
889      <header_from>example.com</header_from>
890    </identifiers>
891    <auth_results>
892      <dkim>
893        <domain>example.com</domain>
894        <selector>key1</selector>
895        <result>pass</result>
896      </dkim>
897      <dkim>
898        <domain>example.com</domain>
899        <selector>key2</selector>
900        <result>fail</result>
901      </dkim>
902      <spf>
903        <domain>example.com</domain>
904        <scope>helo</scope>
905        <result>pass</result>
906      </spf>
907      <spf>
908        <domain>example.com</domain>
909        <scope>mfrom</scope>
910        <result>pass</result>
911      </spf>
912    </auth_results>
913  </record>
914</feedback>"#;
915
916        let report = parse(xml).unwrap();
917        let auth = &report.records[0].auth_results;
918
919        assert_eq!(auth.dkim.len(), 2);
920        assert_eq!(auth.dkim[0].selector, Some("key1".to_string()));
921        assert_eq!(auth.dkim[0].result, DkimResult::Pass);
922        assert_eq!(auth.dkim[1].selector, Some("key2".to_string()));
923        assert_eq!(auth.dkim[1].result, DkimResult::Fail);
924
925        assert_eq!(auth.spf.len(), 2);
926        assert_eq!(auth.spf[0].scope, Some(SpfDomainScope::Helo));
927        assert_eq!(auth.spf[1].scope, Some(SpfDomainScope::Mfrom));
928    }
929
930    #[test]
931    fn parse_alignment_modes() {
932        let xml_relaxed = r#"<?xml version="1.0"?>
933<feedback>
934  <report_metadata>
935    <org_name>R</org_name><email>r@r.example</email>
936    <report_id>r1</report_id>
937    <date_range><begin>0</begin><end>1</end></date_range>
938  </report_metadata>
939  <policy_published>
940    <domain>example.com</domain>
941    <adkim>r</adkim>
942    <aspf>r</aspf>
943    <p>none</p><sp>none</sp><pct>100</pct>
944  </policy_published>
945  <record>
946    <row><source_ip>192.0.2.1</source_ip><count>1</count>
947      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
948    </row>
949    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
950    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
951  </record>
952</feedback>"#;
953
954        let report = parse(xml_relaxed).unwrap();
955        assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Relaxed));
956        assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Relaxed));
957
958        let xml_strict = xml_relaxed
959            .replace("<adkim>r</adkim>", "<adkim>s</adkim>")
960            .replace("<aspf>r</aspf>", "<aspf>s</aspf>");
961
962        let report = parse(&xml_strict).unwrap();
963        assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
964        assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
965    }
966
967    #[test]
968    fn parse_empty_alignment_modes() {
969        let xml = r#"<?xml version="1.0"?>
970<feedback>
971  <report_metadata>
972    <org_name>R</org_name><email>r@r.example</email>
973    <report_id>r1</report_id>
974    <date_range><begin>0</begin><end>1</end></date_range>
975  </report_metadata>
976  <policy_published>
977    <domain>example.com</domain>
978    <adkim></adkim>
979    <aspf></aspf>
980    <p>none</p><sp>none</sp><pct>100</pct>
981  </policy_published>
982  <record>
983    <row><source_ip>192.0.2.1</source_ip><count>1</count>
984      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
985    </row>
986    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
987    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
988  </record>
989</feedback>"#;
990
991        let report = parse(xml).unwrap();
992        assert!(report.policy_published.adkim.is_none());
993        assert!(report.policy_published.aspf.is_none());
994    }
995
996    #[test]
997    fn parse_all_dkim_results() {
998        let results = [
999            ("none", DkimResult::None),
1000            ("pass", DkimResult::Pass),
1001            ("fail", DkimResult::Fail),
1002            ("policy", DkimResult::Policy),
1003            ("neutral", DkimResult::Neutral),
1004            ("temperror", DkimResult::Temperror),
1005            ("permerror", DkimResult::Permerror),
1006        ];
1007
1008        for (s, expected) in results {
1009            let xml = format!(
1010                r#"<?xml version="1.0"?>
1011<feedback>
1012  <report_metadata>
1013    <org_name>R</org_name><email>r@r.example</email>
1014    <report_id>r1</report_id>
1015    <date_range><begin>0</begin><end>1</end></date_range>
1016  </report_metadata>
1017  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1018  <record>
1019    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1020      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1021    </row>
1022    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1023    <auth_results>
1024      <dkim><domain>example.com</domain><result>{s}</result></dkim>
1025      <spf><domain>example.com</domain><result>pass</result></spf>
1026    </auth_results>
1027  </record>
1028</feedback>"#
1029            );
1030            let report = parse(&xml).unwrap();
1031            assert_eq!(
1032                report.records[0].auth_results.dkim[0].result, expected,
1033                "failed for DKIM result '{s}'"
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn parse_all_spf_results() {
1040        let results = [
1041            ("none", SpfResult::None),
1042            ("neutral", SpfResult::Neutral),
1043            ("pass", SpfResult::Pass),
1044            ("fail", SpfResult::Fail),
1045            ("softfail", SpfResult::Softfail),
1046            ("temperror", SpfResult::Temperror),
1047            ("permerror", SpfResult::Permerror),
1048        ];
1049
1050        for (s, expected) in results {
1051            let xml = format!(
1052                r#"<?xml version="1.0"?>
1053<feedback>
1054  <report_metadata>
1055    <org_name>R</org_name><email>r@r.example</email>
1056    <report_id>r1</report_id>
1057    <date_range><begin>0</begin><end>1</end></date_range>
1058  </report_metadata>
1059  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1060  <record>
1061    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1062      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1063    </row>
1064    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1065    <auth_results>
1066      <spf><domain>example.com</domain><result>{s}</result></spf>
1067    </auth_results>
1068  </record>
1069</feedback>"#
1070            );
1071            let report = parse(&xml).unwrap();
1072            assert_eq!(
1073                report.records[0].auth_results.spf[0].result, expected,
1074                "failed for SPF result '{s}'"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn parse_all_policy_overrides() {
1081        let overrides = [
1082            ("forwarded", PolicyOverride::Forwarded),
1083            ("sampled_out", PolicyOverride::SampledOut),
1084            ("trusted_forwarder", PolicyOverride::TrustedForwarder),
1085            ("mailing_list", PolicyOverride::MailingList),
1086            ("local_policy", PolicyOverride::LocalPolicy),
1087            ("other", PolicyOverride::Other),
1088        ];
1089
1090        for (s, expected) in overrides {
1091            let xml = format!(
1092                r#"<?xml version="1.0"?>
1093<feedback>
1094  <report_metadata>
1095    <org_name>R</org_name><email>r@r.example</email>
1096    <report_id>r1</report_id>
1097    <date_range><begin>0</begin><end>1</end></date_range>
1098  </report_metadata>
1099  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1100  <record>
1101    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1102      <policy_evaluated>
1103        <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1104        <reason><type>{s}</type></reason>
1105      </policy_evaluated>
1106    </row>
1107    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1108    <auth_results>
1109      <spf><domain>example.com</domain><result>pass</result></spf>
1110    </auth_results>
1111  </record>
1112</feedback>"#
1113            );
1114            let report = parse(&xml).unwrap();
1115            assert_eq!(
1116                report.records[0].row.policy_evaluated.reasons[0].reason_type, expected,
1117                "failed for policy override '{s}'"
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn parse_all_dispositions() {
1124        for (s, expected) in [
1125            ("none", Disposition::None),
1126            ("quarantine", Disposition::Quarantine),
1127            ("reject", Disposition::Reject),
1128        ] {
1129            let xml = format!(
1130                r#"<?xml version="1.0"?>
1131<feedback>
1132  <report_metadata>
1133    <org_name>R</org_name><email>r@r.example</email>
1134    <report_id>r1</report_id>
1135    <date_range><begin>0</begin><end>1</end></date_range>
1136  </report_metadata>
1137  <policy_published><domain>example.com</domain><p>{s}</p><sp>{s}</sp><pct>100</pct></policy_published>
1138  <record>
1139    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1140      <policy_evaluated><disposition>{s}</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1141    </row>
1142    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1143    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1144  </record>
1145</feedback>"#
1146            );
1147            let report = parse(&xml).unwrap();
1148            assert_eq!(report.policy_published.p, expected, "failed for '{s}'");
1149            assert_eq!(
1150                report.records[0].row.policy_evaluated.disposition, expected,
1151                "failed for '{s}'"
1152            );
1153        }
1154    }
1155
1156    #[test]
1157    fn parse_multiple_policy_override_reasons() {
1158        let xml = r#"<?xml version="1.0"?>
1159<feedback>
1160  <report_metadata>
1161    <org_name>R</org_name><email>r@r.example</email>
1162    <report_id>r1</report_id>
1163    <date_range><begin>0</begin><end>1</end></date_range>
1164  </report_metadata>
1165  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1166  <record>
1167    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1168      <policy_evaluated>
1169        <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1170        <reason><type>forwarded</type><comment>via list</comment></reason>
1171        <reason><type>mailing_list</type></reason>
1172      </policy_evaluated>
1173    </row>
1174    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1175    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1176  </record>
1177</feedback>"#;
1178
1179        let report = parse(xml).unwrap();
1180        let reasons = &report.records[0].row.policy_evaluated.reasons;
1181        assert_eq!(reasons.len(), 2);
1182        assert_eq!(reasons[0].reason_type, PolicyOverride::Forwarded);
1183        assert_eq!(reasons[0].comment, Some("via list".to_string()));
1184        assert_eq!(reasons[1].reason_type, PolicyOverride::MailingList);
1185        assert!(reasons[1].comment.is_none());
1186    }
1187
1188    #[test]
1189    fn parse_ipv6_source_ip() {
1190        let xml = r#"<?xml version="1.0"?>
1191<feedback>
1192  <report_metadata>
1193    <org_name>R</org_name><email>r@r.example</email>
1194    <report_id>r1</report_id>
1195    <date_range><begin>0</begin><end>1</end></date_range>
1196  </report_metadata>
1197  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1198  <record>
1199    <row><source_ip>2001:db8::1</source_ip><count>1</count>
1200      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1201    </row>
1202    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1203    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1204  </record>
1205</feedback>"#;
1206
1207        let report = parse(xml).unwrap();
1208        assert_eq!(report.records[0].row.source_ip, "2001:db8::1");
1209    }
1210
1211    #[test]
1212    fn parse_missing_envelope_from() {
1213        let xml = r#"<?xml version="1.0"?>
1214<feedback>
1215  <report_metadata>
1216    <org_name>R</org_name><email>r@r.example</email>
1217    <report_id>r1</report_id>
1218    <date_range><begin>0</begin><end>1</end></date_range>
1219  </report_metadata>
1220  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1221  <record>
1222    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1223      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1224    </row>
1225    <identifiers><header_from>example.com</header_from></identifiers>
1226    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1227  </record>
1228</feedback>"#;
1229
1230        let report = parse(xml).unwrap();
1231        assert!(report.records[0].identifiers.envelope_from.is_none());
1232        assert_eq!(report.records[0].identifiers.header_from, "example.com");
1233    }
1234
1235    #[test]
1236    fn from_str_trait() {
1237        let report: Report = MINIMAL_XML.parse().unwrap();
1238        assert_eq!(report.report_metadata.org_name, "Acme");
1239    }
1240
1241    #[test]
1242    fn try_from_str_trait() {
1243        let report = Report::try_from(MINIMAL_XML).unwrap();
1244        assert_eq!(report.report_metadata.org_name, "Acme");
1245    }
1246
1247    #[test]
1248    fn try_from_bytes_trait() {
1249        let report = Report::try_from(MINIMAL_XML.as_bytes()).unwrap();
1250        assert_eq!(report.report_metadata.org_name, "Acme");
1251    }
1252
1253    #[test]
1254    fn parse_bytes_function() {
1255        let report = parse_bytes(MINIMAL_XML.as_bytes()).unwrap();
1256        assert_eq!(report.report_metadata.org_name, "Acme");
1257    }
1258
1259    #[test]
1260    fn error_on_invalid_xml() {
1261        let result = parse("<not-valid-dmarc/>");
1262        assert!(result.is_err());
1263    }
1264
1265    #[test]
1266    fn error_on_invalid_utf8_bytes() {
1267        let result = parse_bytes(&[0xFF, 0xFE]);
1268        assert!(matches!(result, Err(Error::Utf8(_))));
1269    }
1270
1271    #[test]
1272    fn display_alignment_mode() {
1273        assert_eq!(AlignmentMode::Relaxed.to_string(), "r");
1274        assert_eq!(AlignmentMode::Strict.to_string(), "s");
1275    }
1276
1277    #[test]
1278    fn display_disposition() {
1279        assert_eq!(Disposition::None.to_string(), "none");
1280        assert_eq!(Disposition::Quarantine.to_string(), "quarantine");
1281        assert_eq!(Disposition::Reject.to_string(), "reject");
1282    }
1283
1284    #[test]
1285    fn display_dmarc_result() {
1286        assert_eq!(DmarcResult::Pass.to_string(), "pass");
1287        assert_eq!(DmarcResult::Fail.to_string(), "fail");
1288    }
1289
1290    #[test]
1291    fn display_dkim_result() {
1292        assert_eq!(DkimResult::None.to_string(), "none");
1293        assert_eq!(DkimResult::Pass.to_string(), "pass");
1294        assert_eq!(DkimResult::Fail.to_string(), "fail");
1295        assert_eq!(DkimResult::Policy.to_string(), "policy");
1296        assert_eq!(DkimResult::Neutral.to_string(), "neutral");
1297        assert_eq!(DkimResult::Temperror.to_string(), "temperror");
1298        assert_eq!(DkimResult::Permerror.to_string(), "permerror");
1299    }
1300
1301    #[test]
1302    fn display_spf_result() {
1303        assert_eq!(SpfResult::None.to_string(), "none");
1304        assert_eq!(SpfResult::Neutral.to_string(), "neutral");
1305        assert_eq!(SpfResult::Pass.to_string(), "pass");
1306        assert_eq!(SpfResult::Fail.to_string(), "fail");
1307        assert_eq!(SpfResult::Softfail.to_string(), "softfail");
1308        assert_eq!(SpfResult::Temperror.to_string(), "temperror");
1309        assert_eq!(SpfResult::Permerror.to_string(), "permerror");
1310    }
1311
1312    #[test]
1313    fn display_spf_domain_scope() {
1314        assert_eq!(SpfDomainScope::Helo.to_string(), "helo");
1315        assert_eq!(SpfDomainScope::Mfrom.to_string(), "mfrom");
1316    }
1317
1318    // ──────────────────────────────────────────────────────────────────────────
1319    // Aggregate
1320    // ──────────────────────────────────────────────────────────────────────────
1321
1322    fn report_with(report_id: &str, begin: i64, end: i64, counts: &[u64]) -> Report {
1323        let records: String = counts
1324            .iter()
1325            .map(|c| {
1326                format!(
1327                    r#"<record>
1328    <row><source_ip>192.0.2.1</source_ip><count>{c}</count>
1329      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1330    </row>
1331    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1332    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1333  </record>"#
1334                )
1335            })
1336            .collect();
1337
1338        let xml = format!(
1339            r#"<?xml version="1.0"?>
1340<feedback>
1341  <report_metadata>
1342    <org_name>R</org_name><email>r@r.example</email>
1343    <report_id>{report_id}</report_id>
1344    <date_range><begin>{begin}</begin><end>{end}</end></date_range>
1345  </report_metadata>
1346  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1347  {records}
1348</feedback>"#
1349        );
1350        parse(&xml).unwrap()
1351    }
1352
1353    #[test]
1354    fn aggregate_empty() {
1355        let agg = Aggregate::from_reports(vec![]);
1356        assert_eq!(agg.records().count(), 0);
1357        assert_eq!(agg.total_messages(), 0);
1358        assert_eq!(agg.date_span(), None);
1359    }
1360
1361    #[test]
1362    fn aggregate_single_report() {
1363        let agg = Aggregate::from_reports(vec![report_with("r1", 100, 200, &[3, 5])]);
1364        assert_eq!(agg.records().count(), 2);
1365        assert_eq!(agg.total_messages(), 8);
1366        assert_eq!(agg.date_span(), Some((100, 200)));
1367    }
1368
1369    #[test]
1370    fn aggregate_total_messages_sums_across_reports() {
1371        let agg = Aggregate::from_reports(vec![
1372            report_with("r1", 0, 1, &[1, 2]),
1373            report_with("r2", 0, 1, &[4]),
1374            report_with("r3", 0, 1, &[10, 20, 30]),
1375        ]);
1376        assert_eq!(agg.total_messages(), 1 + 2 + 4 + 10 + 20 + 30);
1377    }
1378
1379    #[test]
1380    fn aggregate_date_span_picks_earliest_begin_and_latest_end() {
1381        let agg = Aggregate::from_reports(vec![
1382            report_with("r1", 500, 600, &[1]),
1383            report_with("r2", 100, 200, &[1]),
1384            report_with("r3", 300, 900, &[1]),
1385        ]);
1386        assert_eq!(agg.date_span(), Some((100, 900)));
1387    }
1388
1389    #[test]
1390    fn aggregate_records_pair_with_source_report() {
1391        let agg = Aggregate::from_reports(vec![
1392            report_with("r1", 0, 1, &[1, 2]),
1393            report_with("r2", 0, 1, &[3]),
1394        ]);
1395        let pairs: Vec<(&str, u64)> = agg
1396            .records()
1397            .map(|(r, rec)| (r.report_metadata.report_id.as_str(), rec.row.count))
1398            .collect();
1399        assert_eq!(pairs, vec![("r1", 1), ("r1", 2), ("r2", 3)]);
1400    }
1401
1402    #[test]
1403    fn aggregate_from_vec_via_into() {
1404        let reports = vec![report_with("r1", 0, 1, &[7])];
1405        let agg: Aggregate = reports.into();
1406        assert_eq!(agg.total_messages(), 7);
1407    }
1408
1409    #[test]
1410    fn display_policy_override() {
1411        assert_eq!(PolicyOverride::Forwarded.to_string(), "forwarded");
1412        assert_eq!(PolicyOverride::SampledOut.to_string(), "sampled_out");
1413        assert_eq!(
1414            PolicyOverride::TrustedForwarder.to_string(),
1415            "trusted_forwarder"
1416        );
1417        assert_eq!(PolicyOverride::MailingList.to_string(), "mailing_list");
1418        assert_eq!(PolicyOverride::LocalPolicy.to_string(), "local_policy");
1419        assert_eq!(PolicyOverride::Other.to_string(), "other");
1420    }
1421}