1use 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 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 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(""");
449 }
450 '\'' => {
451 escaped.push_str("'");
452 }
453 '<' => {
454 escaped.push_str("<");
455 }
456 '>' => {
457 escaped.push_str(">");
458 }
459 '&' => {
460 escaped.push_str("&");
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}