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, Disposition, DkimResult,
9    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!(&mut xml, "<feedback>").ok();
93        if self.version != 0.0 {
94            writeln!(&mut xml, "\t<version>{}</version>", self.version).ok();
95        }
96        self.report_metadata.to_xml(&mut xml);
97        self.policy_published.to_xml(&mut xml);
98        for record in &self.record {
99            record.to_xml(&mut xml);
100        }
101        writeln!(&mut xml, "</feedback>").ok();
102        xml
103    }
104}
105
106impl ReportMetadata {
107    pub(crate) fn to_xml(&self, xml: &mut String) {
108        writeln!(xml, "\t<report_metadata>").ok();
109        writeln!(
110            xml,
111            "\t\t<org_name>{}</org_name>",
112            escape_xml(&self.org_name)
113        )
114        .ok();
115        writeln!(xml, "\t\t<email>{}</email>", escape_xml(&self.email)).ok();
116        if let Some(eci) = &self.extra_contact_info {
117            writeln!(
118                xml,
119                "\t\t<extra_contact_info>{}</extra_contact_info>",
120                escape_xml(eci)
121            )
122            .ok();
123        }
124        writeln!(
125            xml,
126            "\t\t<report_id>{}</report_id>",
127            escape_xml(&self.report_id)
128        )
129        .ok();
130        self.date_range.to_xml(xml);
131        for error in &self.error {
132            writeln!(xml, "\t\t<error>{}</error>", escape_xml(error)).ok();
133        }
134        writeln!(xml, "\t</report_metadata>").ok();
135    }
136}
137
138impl PolicyPublished {
139    pub(crate) fn to_xml(&self, xml: &mut String) {
140        writeln!(xml, "\t<policy_published>").ok();
141        writeln!(xml, "\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
142        if let Some(vp) = &self.version_published {
143            writeln!(xml, "\t\t<version_published>{vp}</version_published>").ok();
144        }
145        writeln!(xml, "\t\t<adkim>{}</adkim>", &self.adkim).ok();
146        writeln!(xml, "\t\t<aspf>{}</aspf>", &self.aspf).ok();
147        writeln!(xml, "\t\t<p>{}</p>", &self.p).ok();
148        writeln!(xml, "\t\t<sp>{}</sp>", &self.sp).ok();
149        if self.testing {
150            writeln!(xml, "\t\t<testing>y</testing>").ok();
151        }
152        if let Some(fo) = &self.fo {
153            writeln!(xml, "\t\t<fo>{}</fo>", escape_xml(fo)).ok();
154        }
155        writeln!(xml, "\t</policy_published>").ok();
156    }
157}
158
159impl DateRange {
160    pub(crate) fn to_xml(&self, xml: &mut String) {
161        writeln!(xml, "\t\t<date_range>").ok();
162        writeln!(xml, "\t\t\t<begin>{}</begin>", self.begin).ok();
163        writeln!(xml, "\t\t\t<end>{}</end>", self.end).ok();
164        writeln!(xml, "\t\t</date_range>").ok();
165    }
166}
167
168impl Record {
169    pub(crate) fn to_xml(&self, xml: &mut String) {
170        writeln!(xml, "\t<record>").ok();
171        self.row.to_xml(xml);
172        self.identifiers.to_xml(xml);
173        self.auth_results.to_xml(xml);
174        writeln!(xml, "\t</record>").ok();
175    }
176}
177
178impl Row {
179    pub(crate) fn to_xml(&self, xml: &mut String) {
180        writeln!(xml, "\t\t<row>").ok();
181        if let Some(source_ip) = &self.source_ip {
182            writeln!(xml, "\t\t\t<source_ip>{source_ip}</source_ip>").ok();
183        }
184        writeln!(xml, "\t\t\t<count>{}</count>", self.count).ok();
185        self.policy_evaluated.to_xml(xml);
186        writeln!(xml, "\t\t</row>").ok();
187    }
188}
189
190impl PolicyEvaluated {
191    pub(crate) fn to_xml(&self, xml: &mut String) {
192        writeln!(xml, "\t\t\t<policy_evaluated>").ok();
193        writeln!(
194            xml,
195            "\t\t\t\t<disposition>{}</disposition>",
196            self.disposition
197        )
198        .ok();
199        writeln!(xml, "\t\t\t\t<dkim>{}</dkim>", self.dkim).ok();
200        writeln!(xml, "\t\t\t\t<spf>{}</spf>", self.spf).ok();
201        for reason in &self.reason {
202            reason.to_xml(xml);
203        }
204        writeln!(xml, "\t\t\t</policy_evaluated>").ok();
205    }
206}
207
208impl PolicyOverrideReason {
209    pub(crate) fn to_xml(&self, xml: &mut String) {
210        writeln!(xml, "\t\t\t\t<reason>").ok();
211        writeln!(xml, "\t\t\t\t\t<type>{}</type>", self.type_).ok();
212        if let Some(comment) = &self.comment {
213            writeln!(xml, "\t\t\t\t\t<comment>{}</comment>", escape_xml(comment)).ok();
214        }
215        writeln!(xml, "\t\t\t\t</reason>").ok();
216    }
217}
218
219impl Identifier {
220    pub(crate) fn to_xml(&self, xml: &mut String) {
221        writeln!(xml, "\t\t<identifiers>").ok();
222        if let Some(envelope_to) = &self.envelope_to {
223            writeln!(
224                xml,
225                "\t\t\t<envelope_to>{}</envelope_to>",
226                escape_xml(envelope_to)
227            )
228            .ok();
229        }
230        writeln!(
231            xml,
232            "\t\t\t<envelope_from>{}</envelope_from>",
233            escape_xml(&self.envelope_from)
234        )
235        .ok();
236        writeln!(
237            xml,
238            "\t\t\t<header_from>{}</header_from>",
239            escape_xml(&self.header_from)
240        )
241        .ok();
242        writeln!(xml, "\t\t</identifiers>").ok();
243    }
244}
245
246impl AuthResult {
247    pub(crate) fn to_xml(&self, xml: &mut String) {
248        writeln!(xml, "\t\t<auth_results>").ok();
249        for dkim in &self.dkim {
250            dkim.to_xml(xml);
251        }
252        for spf in &self.spf {
253            spf.to_xml(xml);
254        }
255        writeln!(xml, "\t\t</auth_results>").ok();
256    }
257}
258
259impl DKIMAuthResult {
260    pub(crate) fn to_xml(&self, xml: &mut String) {
261        writeln!(xml, "\t\t\t<dkim>").ok();
262        writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
263        writeln!(
264            xml,
265            "\t\t\t\t<selector>{}</selector>",
266            escape_xml(&self.selector)
267        )
268        .ok();
269        writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
270        if let Some(result) = &self.human_result {
271            writeln!(
272                xml,
273                "\t\t\t\t<human_result>{}</human_result>",
274                escape_xml(result)
275            )
276            .ok();
277        }
278        writeln!(xml, "\t\t\t</dkim>").ok();
279    }
280}
281
282impl SPFAuthResult {
283    pub(crate) fn to_xml(&self, xml: &mut String) {
284        writeln!(xml, "\t\t\t<spf>").ok();
285        writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
286        writeln!(xml, "\t\t\t\t<scope>{}</scope>", self.scope).ok();
287        writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
288        if let Some(result) = &self.human_result {
289            writeln!(
290                xml,
291                "\t\t\t\t<human_result>{}</human_result>",
292                escape_xml(result)
293            )
294            .ok();
295        }
296        writeln!(xml, "\t\t\t</spf>").ok();
297    }
298}
299
300impl Display for Alignment {
301    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
302        f.write_str(match self {
303            Alignment::Strict => "s",
304            _ => "r",
305        })
306    }
307}
308
309impl Display for Disposition {
310    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
311        f.write_str(match self {
312            Disposition::None | Disposition::Unspecified => "none",
313            Disposition::Quarantine => "quarantine",
314            Disposition::Reject => "reject",
315        })
316    }
317}
318
319impl Display for ActionDisposition {
320    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
321        f.write_str(match self {
322            ActionDisposition::None | ActionDisposition::Unspecified => "none",
323            ActionDisposition::Pass => "pass",
324            ActionDisposition::Quarantine => "quarantine",
325            ActionDisposition::Reject => "reject",
326        })
327    }
328}
329
330impl Display for DmarcResult {
331    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
332        f.write_str(match self {
333            DmarcResult::Pass => "pass",
334            DmarcResult::Fail => "fail",
335            DmarcResult::Unspecified => "",
336        })
337    }
338}
339
340impl Display for PolicyOverride {
341    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
342        f.write_str(match self {
343            PolicyOverride::Forwarded => "forwarded",
344            PolicyOverride::SampledOut => "sampled_out",
345            PolicyOverride::TrustedForwarder => "trusted_forwarder",
346            PolicyOverride::MailingList => "mailing_list",
347            PolicyOverride::LocalPolicy => "local_policy",
348            PolicyOverride::Other => "other",
349        })
350    }
351}
352
353impl Display for DkimResult {
354    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
355        f.write_str(match self {
356            DkimResult::None => "none",
357            DkimResult::Pass => "pass",
358            DkimResult::Fail => "fail",
359            DkimResult::Policy => "policy",
360            DkimResult::Neutral => "neutral",
361            DkimResult::TempError => "temperror",
362            DkimResult::PermError => "permerror",
363        })
364    }
365}
366
367impl Display for SPFDomainScope {
368    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
369        f.write_str(match self {
370            SPFDomainScope::Helo => "helo",
371            SPFDomainScope::MailFrom | SPFDomainScope::Unspecified => "mfrom",
372        })
373    }
374}
375
376impl Display for SpfResult {
377    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
378        f.write_str(match self {
379            SpfResult::None => "none",
380            SpfResult::Neutral => "neutral",
381            SpfResult::Pass => "pass",
382            SpfResult::Fail => "fail",
383            SpfResult::SoftFail => "softfail",
384            SpfResult::TempError => "temperror",
385            SpfResult::PermError => "permerror",
386        })
387    }
388}
389
390fn escape_xml(text: &str) -> Cow<'_, str> {
391    for ch in text.as_bytes() {
392        if [b'"', b'\'', b'<', b'>', b'&'].contains(ch) {
393            let mut escaped = String::with_capacity(text.len());
394            for ch in text.chars() {
395                match ch {
396                    '"' => {
397                        escaped.push_str("&quot;");
398                    }
399                    '\'' => {
400                        escaped.push_str("&apos;");
401                    }
402                    '<' => {
403                        escaped.push_str("&lt;");
404                    }
405                    '>' => {
406                        escaped.push_str("&gt;");
407                    }
408                    '&' => {
409                        escaped.push_str("&amp;");
410                    }
411                    _ => {
412                        escaped.push(ch);
413                    }
414                }
415            }
416
417            return escaped.into();
418        }
419    }
420    text.into()
421}
422
423#[cfg(test)]
424mod test {
425    use crate::report::{
426        ActionDisposition, Alignment, DKIMAuthResult, Disposition, DkimResult, DmarcResult,
427        PolicyOverride, PolicyOverrideReason, Record, Report, SPFAuthResult, SPFDomainScope,
428        SpfResult,
429    };
430
431    #[test]
432    fn dmarc_report_generate() {
433        let report = Report::new()
434            .with_version(2.0)
435            .with_org_name("Initech Industries Incorporated")
436            .with_email("dmarc@initech.net")
437            .with_extra_contact_info("XMPP:dmarc@initech.net")
438            .with_report_id("abc-123")
439            .with_date_range_begin(12345)
440            .with_date_range_end(12346)
441            .with_error("Did not include TPS report cover.")
442            .with_domain("example.org")
443            .with_version_published(1.0)
444            .with_adkim(Alignment::Relaxed)
445            .with_aspf(Alignment::Strict)
446            .with_p(Disposition::Quarantine)
447            .with_sp(Disposition::Reject)
448            .with_testing(true)
449            .with_record(
450                Record::new()
451                    .with_source_ip("192.168.1.2".parse().unwrap())
452                    .with_count(3)
453                    .with_action_disposition(ActionDisposition::Pass)
454                    .with_dmarc_dkim_result(DmarcResult::Pass)
455                    .with_dmarc_spf_result(DmarcResult::Fail)
456                    .with_policy_override_reason(
457                        PolicyOverrideReason::new(PolicyOverride::Forwarded)
458                            .with_comment("it was forwarded"),
459                    )
460                    .with_policy_override_reason(
461                        PolicyOverrideReason::new(PolicyOverride::MailingList)
462                            .with_comment("sent from mailing list"),
463                    )
464                    .with_envelope_from("hello@example.org")
465                    .with_envelope_to("other@example.org")
466                    .with_header_from("bye@example.org")
467                    .with_dkim_auth_result(
468                        DKIMAuthResult::new()
469                            .with_domain("test.org")
470                            .with_selector("my-selector")
471                            .with_result(DkimResult::PermError)
472                            .with_human_result("failed to parse record"),
473                    )
474                    .with_spf_auth_result(
475                        SPFAuthResult::new()
476                            .with_domain("test.org")
477                            .with_scope(SPFDomainScope::Helo)
478                            .with_result(SpfResult::SoftFail)
479                            .with_human_result("dns timed out"),
480                    ),
481            )
482            .with_record(
483                Record::new()
484                    .with_source_ip("a:b:c::e:f".parse().unwrap())
485                    .with_count(99)
486                    .with_action_disposition(ActionDisposition::Reject)
487                    .with_dmarc_dkim_result(DmarcResult::Fail)
488                    .with_dmarc_spf_result(DmarcResult::Pass)
489                    .with_policy_override_reason(
490                        PolicyOverrideReason::new(PolicyOverride::LocalPolicy)
491                            .with_comment("on the white list"),
492                    )
493                    .with_policy_override_reason(
494                        PolicyOverrideReason::new(PolicyOverride::SampledOut)
495                            .with_comment("it was sampled out"),
496                    )
497                    .with_envelope_from("hello2example.org")
498                    .with_envelope_to("other2@example.org")
499                    .with_header_from("bye2@example.org")
500                    .with_dkim_auth_result(
501                        DKIMAuthResult::new()
502                            .with_domain("test2.org")
503                            .with_selector("my-other-selector")
504                            .with_result(DkimResult::Neutral)
505                            .with_human_result("something went wrong"),
506                    )
507                    .with_spf_auth_result(
508                        SPFAuthResult::new()
509                            .with_domain("test.org")
510                            .with_scope(SPFDomainScope::MailFrom)
511                            .with_result(SpfResult::None)
512                            .with_human_result("no policy found"),
513                    ),
514            );
515
516        let message = report
517            .to_rfc5322(
518                "initech.net",
519                ("Initech Industries", "noreply-dmarc@initech.net"),
520                ["dmarc-reports@example.org"].iter().copied(),
521            )
522            .unwrap();
523        let parsed_report = Report::parse_rfc5322(message.as_bytes()).unwrap();
524
525        assert_eq!(report, parsed_report);
526    }
527}