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