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>{}</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        for spf in &self.spf {
289            spf.to_xml(xml);
290        }
291        writeln!(xml, "\t\t</auth_results>").ok();
292    }
293}
294
295impl DKIMAuthResult {
296    pub(crate) fn to_xml(&self, xml: &mut String) {
297        writeln!(xml, "\t\t\t<dkim>").ok();
298        writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
299        writeln!(
300            xml,
301            "\t\t\t\t<selector>{}</selector>",
302            escape_xml(&self.selector)
303        )
304        .ok();
305        writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
306        if let Some(result) = &self.human_result {
307            writeln!(
308                xml,
309                "\t\t\t\t<human_result>{}</human_result>",
310                escape_xml(result)
311            )
312            .ok();
313        }
314        writeln!(xml, "\t\t\t</dkim>").ok();
315    }
316}
317
318impl SPFAuthResult {
319    pub(crate) fn to_xml(&self, xml: &mut String) {
320        writeln!(xml, "\t\t\t<spf>").ok();
321        writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
322        if self.scope == SPFDomainScope::MailFrom {
323            writeln!(xml, "\t\t\t\t<scope>{}</scope>", self.scope).ok();
324        }
325        writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
326        if let Some(result) = &self.human_result {
327            writeln!(
328                xml,
329                "\t\t\t\t<human_result>{}</human_result>",
330                escape_xml(result)
331            )
332            .ok();
333        }
334        writeln!(xml, "\t\t\t</spf>").ok();
335    }
336}
337
338impl Display for Alignment {
339    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
340        f.write_str(match self {
341            Alignment::Strict => "s",
342            _ => "r",
343        })
344    }
345}
346
347impl Display for Disposition {
348    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
349        f.write_str(match self {
350            Disposition::None | Disposition::Unspecified => "none",
351            Disposition::Quarantine => "quarantine",
352            Disposition::Reject => "reject",
353        })
354    }
355}
356
357impl Display for ActionDisposition {
358    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
359        f.write_str(match self {
360            ActionDisposition::None | ActionDisposition::Unspecified => "none",
361            ActionDisposition::Pass => "pass",
362            ActionDisposition::Quarantine => "quarantine",
363            ActionDisposition::Reject => "reject",
364        })
365    }
366}
367
368impl Display for DmarcResult {
369    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
370        f.write_str(match self {
371            DmarcResult::Pass => "pass",
372            DmarcResult::Fail => "fail",
373            DmarcResult::Unspecified => "",
374        })
375    }
376}
377
378impl Display for PolicyOverride {
379    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
380        f.write_str(match self {
381            PolicyOverride::TrustedForwarder => "trusted_forwarder",
382            PolicyOverride::MailingList => "mailing_list",
383            PolicyOverride::LocalPolicy => "local_policy",
384            PolicyOverride::PolicyTestMode => "policy_test_mode",
385            PolicyOverride::Other => "other",
386        })
387    }
388}
389
390impl Display for Discovery {
391    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
392        f.write_str(match self {
393            Discovery::Psl => "psl",
394            Discovery::Treewalk => "treewalk",
395            Discovery::Unspecified => "",
396        })
397    }
398}
399
400impl Display for DkimResult {
401    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
402        f.write_str(match self {
403            DkimResult::None => "none",
404            DkimResult::Pass => "pass",
405            DkimResult::Fail => "fail",
406            DkimResult::Policy => "policy",
407            DkimResult::Neutral => "neutral",
408            DkimResult::TempError => "temperror",
409            DkimResult::PermError => "permerror",
410        })
411    }
412}
413
414impl Display for SPFDomainScope {
415    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
416        f.write_str(match self {
417            SPFDomainScope::Helo => "helo",
418            SPFDomainScope::MailFrom | SPFDomainScope::Unspecified => "mfrom",
419        })
420    }
421}
422
423impl Display for SpfResult {
424    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
425        f.write_str(match self {
426            SpfResult::None => "none",
427            SpfResult::Neutral => "neutral",
428            SpfResult::Pass => "pass",
429            SpfResult::Fail => "fail",
430            SpfResult::SoftFail => "softfail",
431            SpfResult::TempError => "temperror",
432            SpfResult::PermError => "permerror",
433        })
434    }
435}
436
437fn escape_xml(text: &str) -> Cow<'_, str> {
438    for ch in text.as_bytes() {
439        if [b'"', b'\'', b'<', b'>', b'&'].contains(ch) {
440            let mut escaped = String::with_capacity(text.len());
441            for ch in text.chars() {
442                match ch {
443                    '"' => {
444                        escaped.push_str("&quot;");
445                    }
446                    '\'' => {
447                        escaped.push_str("&apos;");
448                    }
449                    '<' => {
450                        escaped.push_str("&lt;");
451                    }
452                    '>' => {
453                        escaped.push_str("&gt;");
454                    }
455                    '&' => {
456                        escaped.push_str("&amp;");
457                    }
458                    _ => {
459                        escaped.push(ch);
460                    }
461                }
462            }
463
464            return escaped.into();
465        }
466    }
467    text.into()
468}
469
470#[cfg(test)]
471mod test {
472    use crate::report::{
473        ActionDisposition, Alignment, DKIMAuthResult, Discovery, Disposition, DkimResult,
474        DmarcResult, PolicyOverride, PolicyOverrideReason, Record, Report, SPFAuthResult,
475        SPFDomainScope, SpfResult,
476    };
477
478    #[test]
479    fn dmarc_report_generate() {
480        let report = Report::new()
481            .with_version(1.0)
482            .with_org_name("Initech Industries Incorporated")
483            .with_email("dmarc@initech.net")
484            .with_extra_contact_info("XMPP:dmarc@initech.net")
485            .with_report_id("abc-123")
486            .with_date_range_begin(12345)
487            .with_date_range_end(12346)
488            .with_error("Did not include TPS report cover.")
489            .with_generator("Initech DMARC Reporter v1.0")
490            .with_domain("example.org")
491            .with_adkim(Alignment::Relaxed)
492            .with_aspf(Alignment::Strict)
493            .with_p(Disposition::Quarantine)
494            .with_sp(Disposition::Reject)
495            .with_np(Disposition::None)
496            .with_discovery_method(Discovery::Treewalk)
497            .with_testing(true)
498            .with_record(
499                Record::new()
500                    .with_source_ip("192.168.1.2".parse().unwrap())
501                    .with_count(3)
502                    .with_action_disposition(ActionDisposition::Pass)
503                    .with_dmarc_dkim_result(DmarcResult::Pass)
504                    .with_dmarc_spf_result(DmarcResult::Fail)
505                    .with_policy_override_reason(
506                        PolicyOverrideReason::new(PolicyOverride::TrustedForwarder)
507                            .with_comment("it was forwarded"),
508                    )
509                    .with_policy_override_reason(
510                        PolicyOverrideReason::new(PolicyOverride::MailingList)
511                            .with_comment("sent from mailing list"),
512                    )
513                    .with_envelope_from("hello@example.org")
514                    .with_envelope_to("other@example.org")
515                    .with_header_from("bye@example.org")
516                    .with_dkim_auth_result(
517                        DKIMAuthResult::new()
518                            .with_domain("test.org")
519                            .with_selector("my-selector")
520                            .with_result(DkimResult::PermError)
521                            .with_human_result("failed to parse record"),
522                    )
523                    .with_spf_auth_result(
524                        SPFAuthResult::new()
525                            .with_domain("test.org")
526                            .with_scope(SPFDomainScope::MailFrom)
527                            .with_result(SpfResult::SoftFail)
528                            .with_human_result("dns timed out"),
529                    ),
530            )
531            .with_record(
532                Record::new()
533                    .with_source_ip("a:b:c::e:f".parse().unwrap())
534                    .with_count(99)
535                    .with_action_disposition(ActionDisposition::Reject)
536                    .with_dmarc_dkim_result(DmarcResult::Fail)
537                    .with_dmarc_spf_result(DmarcResult::Pass)
538                    .with_policy_override_reason(
539                        PolicyOverrideReason::new(PolicyOverride::LocalPolicy)
540                            .with_comment("on the white list"),
541                    )
542                    .with_policy_override_reason(
543                        PolicyOverrideReason::new(PolicyOverride::PolicyTestMode)
544                            .with_comment("policy in test mode"),
545                    )
546                    .with_envelope_from("hello2example.org")
547                    .with_envelope_to("other2@example.org")
548                    .with_header_from("bye2@example.org")
549                    .with_dkim_auth_result(
550                        DKIMAuthResult::new()
551                            .with_domain("test2.org")
552                            .with_selector("my-other-selector")
553                            .with_result(DkimResult::Neutral)
554                            .with_human_result("something went wrong"),
555                    )
556                    .with_spf_auth_result(
557                        SPFAuthResult::new()
558                            .with_domain("test.org")
559                            .with_scope(SPFDomainScope::MailFrom)
560                            .with_result(SpfResult::None)
561                            .with_human_result("no policy found"),
562                    ),
563            );
564
565        let message = report
566            .to_rfc5322(
567                "initech.net",
568                ("Initech Industries", "noreply-dmarc@initech.net"),
569                ["dmarc-reports@example.org"].iter().copied(),
570            )
571            .unwrap();
572        let parsed_report = Report::parse_rfc5322(message.as_bytes()).unwrap();
573
574        assert_eq!(report, parsed_report);
575    }
576}