1#![doc = include_str!("../docs/library-usage.md")]
7#![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 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
61pub fn parse(xml: &str) -> Result<Report, Error> {
72 quick_xml::de::from_str(xml).map_err(Error::from)
73}
74
75pub fn parse_bytes(bytes: &[u8]) -> Result<Report, Error> {
82 let xml = std::str::from_utf8(bytes)?;
83 parse(xml)
84}
85
86#[derive(Debug, Clone, PartialEq, Deserialize)]
94#[serde(rename = "feedback")]
95pub struct Report {
96 #[serde(default)]
98 pub version: Option<String>,
99
100 pub report_metadata: ReportMetadata,
102
103 pub policy_published: PolicyPublished,
105
106 #[serde(rename = "record")]
108 pub records: Vec<Record>,
109}
110
111#[derive(Debug, Clone, PartialEq, Deserialize)]
113pub struct ReportMetadata {
114 pub org_name: String,
116
117 pub email: String,
119
120 #[serde(default)]
122 pub extra_contact_info: Option<String>,
123
124 pub report_id: String,
126
127 pub date_range: DateRange,
129
130 #[serde(rename = "error", default)]
132 pub errors: Vec<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Deserialize)]
137pub struct DateRange {
138 pub begin: i64,
140
141 pub end: i64,
143}
144
145#[derive(Debug, Clone, PartialEq, Deserialize)]
147pub struct PolicyPublished {
148 pub domain: String,
150
151 #[serde(default, deserialize_with = "deserialize_optional_alignment")]
153 pub adkim: Option<AlignmentMode>,
154
155 #[serde(default, deserialize_with = "deserialize_optional_alignment")]
157 pub aspf: Option<AlignmentMode>,
158
159 pub p: Disposition,
161
162 pub sp: Disposition,
164
165 pub pct: u32,
167
168 #[serde(default)]
170 pub fo: Option<String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
175pub enum AlignmentMode {
176 #[serde(rename = "r")]
178 Relaxed,
179
180 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum Disposition {
198 None,
200 Quarantine,
202 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#[derive(Debug, Clone, PartialEq, Deserialize)]
218pub struct Record {
219 pub row: Row,
221
222 pub identifiers: Identifiers,
224
225 pub auth_results: AuthResults,
227}
228
229#[derive(Debug, Clone, PartialEq, Deserialize)]
231pub struct Row {
232 pub source_ip: String,
234
235 pub count: u64,
237
238 pub policy_evaluated: PolicyEvaluated,
240}
241
242#[derive(Debug, Clone, PartialEq, Deserialize)]
244pub struct PolicyEvaluated {
245 pub disposition: Disposition,
247
248 pub dkim: DmarcResult,
250
251 pub spf: DmarcResult,
253
254 #[serde(rename = "reason", default)]
256 pub reasons: Vec<PolicyOverrideReason>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
261#[serde(rename_all = "lowercase")]
262pub enum DmarcResult {
263 Pass,
265 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#[derive(Debug, Clone, PartialEq, Deserialize)]
281pub struct PolicyOverrideReason {
282 #[serde(rename = "type")]
284 pub reason_type: PolicyOverride,
285
286 #[serde(default)]
288 pub comment: Option<String>,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum PolicyOverride {
295 Forwarded,
297 SampledOut,
299 TrustedForwarder,
301 MailingList,
303 LocalPolicy,
305 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#[derive(Debug, Clone, PartialEq, Deserialize)]
324pub struct Identifiers {
325 #[serde(default)]
327 pub envelope_to: Option<String>,
328
329 #[serde(default)]
331 pub envelope_from: Option<String>,
332
333 pub header_from: String,
335}
336
337#[derive(Debug, Clone, PartialEq, Deserialize)]
339pub struct AuthResults {
340 #[serde(rename = "dkim", default)]
342 pub dkim: Vec<DkimAuthResult>,
343
344 #[serde(rename = "spf")]
346 pub spf: Vec<SpfAuthResult>,
347}
348
349#[derive(Debug, Clone, PartialEq, Deserialize)]
351pub struct DkimAuthResult {
352 pub domain: String,
354
355 #[serde(default)]
357 pub selector: Option<String>,
358
359 pub result: DkimResult,
361
362 #[serde(default)]
364 pub human_result: Option<String>,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
369#[serde(rename_all = "lowercase")]
370pub enum DkimResult {
371 None,
373 Pass,
375 Fail,
377 Policy,
379 Neutral,
381 Temperror,
383 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#[derive(Debug, Clone, PartialEq, Deserialize)]
403pub struct SpfAuthResult {
404 pub domain: String,
406
407 #[serde(default)]
409 pub scope: Option<SpfDomainScope>,
410
411 pub result: SpfResult,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
417#[serde(rename_all = "lowercase")]
418pub enum SpfDomainScope {
419 Helo,
421 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
436#[serde(rename_all = "lowercase")]
437pub enum SpfResult {
438 None,
440 Neutral,
442 Pass,
444 Fail,
446 Softfail,
448 Temperror,
450 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#[derive(Debug, Clone, PartialEq)]
480pub struct Aggregate {
481 pub reports: Vec<Report>,
483}
484
485impl Aggregate {
486 pub fn from_reports(reports: Vec<Report>) -> Self {
488 Self { reports }
489 }
490
491 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 pub fn total_messages(&self) -> u64 {
501 self.records().map(|(_, rec)| rec.row.count).sum()
502 }
503
504 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
527impl 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#[cfg(doctest)]
559#[doc = include_str!("../README.md")]
560struct ReadmeExamples;
561
562#[cfg(test)]
567mod tests {
568 use super::*;
569
570 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 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 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 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 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 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 assert_eq!(report.version, Some("1.0".to_string()));
722
723 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 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 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 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 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 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 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}