Skip to main content

mail_auth/report/dmarc/
generate.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use crate::report::{
8    ActionDisposition, Alignment, AuthResult, DKIMAuthResult, DateRange, Discovery, Disposition,
9    DkimResult, DmarcResult, Identifier, PolicyEvaluated, PolicyOverride, PolicyOverrideReason,
10    PolicyPublished, Record, Report, ReportMetadata, Row, SPFAuthResult, SPFDomainScope, SpfResult,
11};
12use flate2::{Compression, write::GzEncoder};
13use mail_builder::{
14    MessageBuilder,
15    headers::{HeaderType, address::Address},
16    mime::make_boundary,
17};
18use std::{
19    borrow::Cow,
20    fmt::{Display, Formatter, Write},
21    io,
22};
23
24impl Report {
25    pub fn write_rfc5322<'x>(
26        &self,
27        submitter: &'x str,
28        from: impl Into<Address<'x>>,
29        to: impl Iterator<Item = &'x str>,
30        writer: impl io::Write,
31    ) -> io::Result<()> {
32        // Compress XML report
33        let xml = self.to_xml();
34        let mut e = GzEncoder::new(Vec::with_capacity(xml.len()), Compression::default());
35        io::Write::write_all(&mut e, xml.as_bytes())?;
36        let compressed_bytes = e.finish()?;
37
38        MessageBuilder::new()
39            .from(from)
40            .header(
41                "To",
42                HeaderType::Address(Address::List(to.map(|to| (*to).into()).collect())),
43            )
44            .header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
45            .message_id(format!("{}@{}", make_boundary("."), submitter))
46            .subject(format!(
47                "Report Domain: {} Submitter: {} Report-ID: <{}>",
48                self.domain(),
49                submitter,
50                self.report_id()
51            ))
52            .text_body(format!(
53                concat!(
54                    "DMARC aggregate report from {}\r\n\r\n",
55                    "Report Domain: {}\r\n",
56                    "Submitter: {}\r\n",
57                    "Report-ID: {}\r\n",
58                ),
59                submitter,
60                self.domain(),
61                submitter,
62                self.report_id()
63            ))
64            .attachment(
65                "application/gzip",
66                format!(
67                    "{}!{}!{}!{}.xml.gz",
68                    submitter,
69                    self.domain(),
70                    self.date_range_begin(),
71                    self.date_range_end()
72                ),
73                compressed_bytes,
74            )
75            .write_to(writer)
76    }
77
78    pub fn to_rfc5322<'x>(
79        &self,
80        submitter: &'x str,
81        from: impl Into<Address<'x>>,
82        to: impl Iterator<Item = &'x str>,
83    ) -> io::Result<String> {
84        let mut buf = Vec::new();
85        self.write_rfc5322(submitter, from, to, &mut buf)?;
86        String::from_utf8(buf).map_err(io::Error::other)
87    }
88
89    pub fn to_xml(&self) -> String {
90        let mut xml = String::with_capacity(128);
91        writeln!(&mut xml, "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>").ok();
92        writeln!(
93            &mut xml,
94            "<feedback xmlns=\"urn:ietf:params:xml:ns:dmarc-2.0\">"
95        )
96        .ok();
97        if self.version != 0.0 {
98            // RFC 9990 Section 3.1.1.2: the report format version MUST be 1.0
99            writeln!(&mut xml, "\t<version>{:.1}</version>", self.version).ok();
100        }
101        self.report_metadata.to_xml(&mut xml);
102        self.policy_published.to_xml(&mut xml);
103        for record in &self.record {
104            record.to_xml(&mut xml);
105        }
106        writeln!(&mut xml, "</feedback>").ok();
107        xml
108    }
109}
110
111impl ReportMetadata {
112    pub(crate) fn to_xml(&self, xml: &mut String) {
113        writeln!(xml, "\t<report_metadata>").ok();
114        writeln!(
115            xml,
116            "\t\t<org_name>{}</org_name>",
117            escape_xml(&self.org_name)
118        )
119        .ok();
120        writeln!(xml, "\t\t<email>{}</email>", escape_xml(&self.email)).ok();
121        if let Some(eci) = &self.extra_contact_info {
122            writeln!(
123                xml,
124                "\t\t<extra_contact_info>{}</extra_contact_info>",
125                escape_xml(eci)
126            )
127            .ok();
128        }
129        writeln!(
130            xml,
131            "\t\t<report_id>{}</report_id>",
132            escape_xml(&self.report_id)
133        )
134        .ok();
135        self.date_range.to_xml(xml);
136        match self.error.len() {
137            0 => {}
138            1 => {
139                writeln!(xml, "\t\t<error>{}</error>", escape_xml(&self.error[0])).ok();
140            }
141            _ => {
142                writeln!(
143                    xml,
144                    "\t\t<error>{}</error>",
145                    escape_xml(&self.error.join("; "))
146                )
147                .ok();
148            }
149        }
150        if let Some(generator) = &self.generator {
151            writeln!(xml, "\t\t<generator>{}</generator>", escape_xml(generator)).ok();
152        }
153        writeln!(xml, "\t</report_metadata>").ok();
154    }
155}
156
157impl PolicyPublished {
158    pub(crate) fn to_xml(&self, xml: &mut String) {
159        writeln!(xml, "\t<policy_published>").ok();
160        writeln!(xml, "\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
161        writeln!(xml, "\t\t<p>{}</p>", self.p).ok();
162        if self.sp != Disposition::Unspecified {
163            writeln!(xml, "\t\t<sp>{}</sp>", self.sp).ok();
164        }
165        if self.np != Disposition::Unspecified {
166            writeln!(xml, "\t\t<np>{}</np>", self.np).ok();
167        }
168        if self.adkim != Alignment::Unspecified {
169            writeln!(xml, "\t\t<adkim>{}</adkim>", self.adkim).ok();
170        }
171        if self.aspf != Alignment::Unspecified {
172            writeln!(xml, "\t\t<aspf>{}</aspf>", self.aspf).ok();
173        }
174        if self.discovery_method != Discovery::Unspecified {
175            writeln!(
176                xml,
177                "\t\t<discovery_method>{}</discovery_method>",
178                self.discovery_method
179            )
180            .ok();
181        }
182        if let Some(fo) = &self.fo {
183            writeln!(xml, "\t\t<fo>{}</fo>", escape_xml(fo)).ok();
184        }
185        writeln!(
186            xml,
187            "\t\t<testing>{}</testing>",
188            if self.testing { "y" } else { "n" }
189        )
190        .ok();
191        writeln!(xml, "\t</policy_published>").ok();
192    }
193}
194
195impl DateRange {
196    pub(crate) fn to_xml(&self, xml: &mut String) {
197        writeln!(xml, "\t\t<date_range>").ok();
198        writeln!(xml, "\t\t\t<begin>{}</begin>", self.begin).ok();
199        writeln!(xml, "\t\t\t<end>{}</end>", self.end).ok();
200        writeln!(xml, "\t\t</date_range>").ok();
201    }
202}
203
204impl Record {
205    pub(crate) fn to_xml(&self, xml: &mut String) {
206        writeln!(xml, "\t<record>").ok();
207        self.row.to_xml(xml);
208        self.identifiers.to_xml(xml);
209        self.auth_results.to_xml(xml);
210        writeln!(xml, "\t</record>").ok();
211    }
212}
213
214impl Row {
215    pub(crate) fn to_xml(&self, xml: &mut String) {
216        writeln!(xml, "\t\t<row>").ok();
217        if let Some(source_ip) = &self.source_ip {
218            writeln!(xml, "\t\t\t<source_ip>{source_ip}</source_ip>").ok();
219        }
220        writeln!(xml, "\t\t\t<count>{}</count>", self.count).ok();
221        self.policy_evaluated.to_xml(xml);
222        writeln!(xml, "\t\t</row>").ok();
223    }
224}
225
226impl PolicyEvaluated {
227    pub(crate) fn to_xml(&self, xml: &mut String) {
228        writeln!(xml, "\t\t\t<policy_evaluated>").ok();
229        writeln!(
230            xml,
231            "\t\t\t\t<disposition>{}</disposition>",
232            self.disposition
233        )
234        .ok();
235        writeln!(xml, "\t\t\t\t<dkim>{}</dkim>", self.dkim).ok();
236        writeln!(xml, "\t\t\t\t<spf>{}</spf>", self.spf).ok();
237        for reason in &self.reason {
238            reason.to_xml(xml);
239        }
240        writeln!(xml, "\t\t\t</policy_evaluated>").ok();
241    }
242}
243
244impl PolicyOverrideReason {
245    pub(crate) fn to_xml(&self, xml: &mut String) {
246        writeln!(xml, "\t\t\t\t<reason>").ok();
247        writeln!(xml, "\t\t\t\t\t<type>{}</type>", self.type_).ok();
248        if let Some(comment) = &self.comment {
249            writeln!(xml, "\t\t\t\t\t<comment>{}</comment>", escape_xml(comment)).ok();
250        }
251        writeln!(xml, "\t\t\t\t</reason>").ok();
252    }
253}
254
255impl Identifier {
256    pub(crate) fn to_xml(&self, xml: &mut String) {
257        writeln!(xml, "\t\t<identifiers>").ok();
258        if let Some(envelope_to) = &self.envelope_to {
259            writeln!(
260                xml,
261                "\t\t\t<envelope_to>{}</envelope_to>",
262                escape_xml(envelope_to)
263            )
264            .ok();
265        }
266        writeln!(
267            xml,
268            "\t\t\t<envelope_from>{}</envelope_from>",
269            escape_xml(&self.envelope_from)
270        )
271        .ok();
272        writeln!(
273            xml,
274            "\t\t\t<header_from>{}</header_from>",
275            escape_xml(&self.header_from)
276        )
277        .ok();
278        writeln!(xml, "\t\t</identifiers>").ok();
279    }
280}
281
282impl AuthResult {
283    pub(crate) fn to_xml(&self, xml: &mut String) {
284        writeln!(xml, "\t\t<auth_results>").ok();
285        for dkim in &self.dkim {
286            dkim.to_xml(xml);
287        }
288        if let Some(spf) = self
289            .spf
290            .iter()
291            .find(|spf| spf.scope != SPFDomainScope::Helo)
292        {
293            spf.to_xml(xml);
294        }
295        writeln!(xml, "\t\t</auth_results>").ok();
296    }
297}
298
299impl DKIMAuthResult {
300    pub(crate) fn to_xml(&self, xml: &mut String) {
301        writeln!(xml, "\t\t\t<dkim>").ok();
302        writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
303        writeln!(
304            xml,
305            "\t\t\t\t<selector>{}</selector>",
306            escape_xml(&self.selector)
307        )
308        .ok();
309        writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
310        if let Some(result) = &self.human_result {
311            writeln!(
312                xml,
313                "\t\t\t\t<human_result>{}</human_result>",
314                escape_xml(result)
315            )
316            .ok();
317        }
318        writeln!(xml, "\t\t\t</dkim>").ok();
319    }
320}
321
322impl SPFAuthResult {
323    pub(crate) fn to_xml(&self, xml: &mut String) {
324        writeln!(xml, "\t\t\t<spf>").ok();
325        writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
326        if self.scope == SPFDomainScope::MailFrom {
327            writeln!(xml, "\t\t\t\t<scope>{}</scope>", self.scope).ok();
328        }
329        writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
330        if let Some(result) = &self.human_result {
331            writeln!(
332                xml,
333                "\t\t\t\t<human_result>{}</human_result>",
334                escape_xml(result)
335            )
336            .ok();
337        }
338        writeln!(xml, "\t\t\t</spf>").ok();
339    }
340}
341
342impl Display for Alignment {
343    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
344        f.write_str(match self {
345            Alignment::Strict => "s",
346            _ => "r",
347        })
348    }
349}
350
351impl Display for Disposition {
352    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
353        f.write_str(match self {
354            Disposition::None | Disposition::Unspecified => "none",
355            Disposition::Quarantine => "quarantine",
356            Disposition::Reject => "reject",
357        })
358    }
359}
360
361impl Display for ActionDisposition {
362    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
363        f.write_str(match self {
364            ActionDisposition::None | ActionDisposition::Unspecified => "none",
365            ActionDisposition::Pass => "pass",
366            ActionDisposition::Quarantine => "quarantine",
367            ActionDisposition::Reject => "reject",
368        })
369    }
370}
371
372impl Display for DmarcResult {
373    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
374        f.write_str(match self {
375            DmarcResult::Pass => "pass",
376            DmarcResult::Fail => "fail",
377            DmarcResult::Unspecified => "",
378        })
379    }
380}
381
382impl Display for PolicyOverride {
383    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
384        f.write_str(match self {
385            PolicyOverride::TrustedForwarder => "trusted_forwarder",
386            PolicyOverride::MailingList => "mailing_list",
387            PolicyOverride::LocalPolicy => "local_policy",
388            PolicyOverride::PolicyTestMode => "policy_test_mode",
389            PolicyOverride::Other => "other",
390        })
391    }
392}
393
394impl Display for Discovery {
395    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
396        f.write_str(match self {
397            Discovery::Psl => "psl",
398            Discovery::Treewalk => "treewalk",
399            Discovery::Unspecified => "",
400        })
401    }
402}
403
404impl Display for DkimResult {
405    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
406        f.write_str(match self {
407            DkimResult::None => "none",
408            DkimResult::Pass => "pass",
409            DkimResult::Fail => "fail",
410            DkimResult::Policy => "policy",
411            DkimResult::Neutral => "neutral",
412            DkimResult::TempError => "temperror",
413            DkimResult::PermError => "permerror",
414        })
415    }
416}
417
418impl Display for SPFDomainScope {
419    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
420        f.write_str(match self {
421            SPFDomainScope::Helo => "helo",
422            SPFDomainScope::MailFrom | SPFDomainScope::Unspecified => "mfrom",
423        })
424    }
425}
426
427impl Display for SpfResult {
428    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
429        f.write_str(match self {
430            SpfResult::None => "none",
431            SpfResult::Neutral => "neutral",
432            SpfResult::Pass => "pass",
433            SpfResult::Fail => "fail",
434            SpfResult::SoftFail => "softfail",
435            SpfResult::TempError => "temperror",
436            SpfResult::PermError => "permerror",
437        })
438    }
439}
440
441fn escape_xml(text: &str) -> Cow<'_, str> {
442    for ch in text.as_bytes() {
443        if b"\"'<>&".contains(ch) {
444            let mut escaped = String::with_capacity(text.len());
445            for ch in text.chars() {
446                match ch {
447                    '"' => {
448                        escaped.push_str("&quot;");
449                    }
450                    '\'' => {
451                        escaped.push_str("&apos;");
452                    }
453                    '<' => {
454                        escaped.push_str("&lt;");
455                    }
456                    '>' => {
457                        escaped.push_str("&gt;");
458                    }
459                    '&' => {
460                        escaped.push_str("&amp;");
461                    }
462                    _ => {
463                        escaped.push(ch);
464                    }
465                }
466            }
467
468            return escaped.into();
469        }
470    }
471    text.into()
472}
473
474#[cfg(test)]
475mod test {
476    use crate::report::{
477        ActionDisposition, Alignment, DKIMAuthResult, Discovery, Disposition, DkimResult,
478        DmarcResult, PolicyOverride, PolicyOverrideReason, Record, Report, SPFAuthResult,
479        SPFDomainScope, SpfResult,
480    };
481    const MAX_REPORT_SIZE: usize = 25 * 1024 * 1024;
482
483    #[test]
484    fn dmarc_report_generate() {
485        let report = Report::new()
486            .with_version(1.0)
487            .with_org_name("Initech Industries Incorporated")
488            .with_email("dmarc@initech.net")
489            .with_extra_contact_info("XMPP:dmarc@initech.net")
490            .with_report_id("abc-123")
491            .with_date_range_begin(12345)
492            .with_date_range_end(12346)
493            .with_error("Did not include TPS report cover.")
494            .with_generator("Initech DMARC Reporter v1.0")
495            .with_domain("example.org")
496            .with_adkim(Alignment::Relaxed)
497            .with_aspf(Alignment::Strict)
498            .with_p(Disposition::Quarantine)
499            .with_sp(Disposition::Reject)
500            .with_np(Disposition::None)
501            .with_discovery_method(Discovery::Treewalk)
502            .with_testing(true)
503            .with_record(
504                Record::new()
505                    .with_source_ip("192.168.1.2".parse().unwrap())
506                    .with_count(3)
507                    .with_action_disposition(ActionDisposition::Pass)
508                    .with_dmarc_dkim_result(DmarcResult::Pass)
509                    .with_dmarc_spf_result(DmarcResult::Fail)
510                    .with_policy_override_reason(
511                        PolicyOverrideReason::new(PolicyOverride::TrustedForwarder)
512                            .with_comment("it was forwarded"),
513                    )
514                    .with_policy_override_reason(
515                        PolicyOverrideReason::new(PolicyOverride::MailingList)
516                            .with_comment("sent from mailing list"),
517                    )
518                    .with_envelope_from("hello@example.org")
519                    .with_envelope_to("other@example.org")
520                    .with_header_from("bye@example.org")
521                    .with_dkim_auth_result(
522                        DKIMAuthResult::new()
523                            .with_domain("test.org")
524                            .with_selector("my-selector")
525                            .with_result(DkimResult::PermError)
526                            .with_human_result("failed to parse record"),
527                    )
528                    .with_spf_auth_result(
529                        SPFAuthResult::new()
530                            .with_domain("test.org")
531                            .with_scope(SPFDomainScope::MailFrom)
532                            .with_result(SpfResult::SoftFail)
533                            .with_human_result("dns timed out"),
534                    ),
535            )
536            .with_record(
537                Record::new()
538                    .with_source_ip("a:b:c::e:f".parse().unwrap())
539                    .with_count(99)
540                    .with_action_disposition(ActionDisposition::Reject)
541                    .with_dmarc_dkim_result(DmarcResult::Fail)
542                    .with_dmarc_spf_result(DmarcResult::Pass)
543                    .with_policy_override_reason(
544                        PolicyOverrideReason::new(PolicyOverride::LocalPolicy)
545                            .with_comment("on the white list"),
546                    )
547                    .with_policy_override_reason(
548                        PolicyOverrideReason::new(PolicyOverride::PolicyTestMode)
549                            .with_comment("policy in test mode"),
550                    )
551                    .with_envelope_from("hello2example.org")
552                    .with_envelope_to("other2@example.org")
553                    .with_header_from("bye2@example.org")
554                    .with_dkim_auth_result(
555                        DKIMAuthResult::new()
556                            .with_domain("test2.org")
557                            .with_selector("my-other-selector")
558                            .with_result(DkimResult::Neutral)
559                            .with_human_result("something went wrong"),
560                    )
561                    .with_spf_auth_result(
562                        SPFAuthResult::new()
563                            .with_domain("test.org")
564                            .with_scope(SPFDomainScope::MailFrom)
565                            .with_result(SpfResult::None)
566                            .with_human_result("no policy found"),
567                    ),
568            );
569
570        let message = report
571            .to_rfc5322(
572                "initech.net",
573                ("Initech Industries", "noreply-dmarc@initech.net"),
574                ["dmarc-reports@example.org"].iter().copied(),
575            )
576            .unwrap();
577        let parsed_report = Report::parse_rfc5322(message.as_bytes(), MAX_REPORT_SIZE).unwrap();
578
579        assert_eq!(report, parsed_report);
580    }
581
582    #[test]
583    fn dmarc_report_generate_single_spf_result() {
584        let xml = Report::new()
585            .with_version(1.0)
586            .with_org_name("Initech Industries Incorporated")
587            .with_email("dmarc@initech.net")
588            .with_report_id("abc-123")
589            .with_date_range_begin(12345)
590            .with_date_range_end(12346)
591            .with_domain("example.org")
592            .with_p(Disposition::Reject)
593            .with_record(
594                Record::new()
595                    .with_source_ip("192.168.1.2".parse().unwrap())
596                    .with_count(1)
597                    .with_action_disposition(ActionDisposition::Reject)
598                    .with_dmarc_dkim_result(DmarcResult::Fail)
599                    .with_dmarc_spf_result(DmarcResult::Fail)
600                    .with_envelope_from("example.org")
601                    .with_header_from("example.org")
602                    .with_spf_auth_result(
603                        SPFAuthResult::new()
604                            .with_domain("mail.example.org")
605                            .with_scope(SPFDomainScope::Helo)
606                            .with_result(SpfResult::Pass),
607                    )
608                    .with_spf_auth_result(
609                        SPFAuthResult::new()
610                            .with_domain("example.org")
611                            .with_scope(SPFDomainScope::MailFrom)
612                            .with_result(SpfResult::Fail),
613                    ),
614            )
615            .to_xml();
616
617        assert_eq!(xml.matches("\t\t\t<spf>\n").count(), 1, "{xml}");
618        assert!(xml.contains("<version>1.0</version>"), "{xml}");
619        assert!(
620            xml.contains(concat!(
621                "\t\t\t<spf>\n",
622                "\t\t\t\t<domain>example.org</domain>\n",
623                "\t\t\t\t<scope>mfrom</scope>\n",
624                "\t\t\t\t<result>fail</result>\n",
625                "\t\t\t</spf>\n"
626            )),
627            "{xml}"
628        );
629        assert!(!xml.contains("mail.example.org"), "{xml}");
630    }
631}