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