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>{}</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"\"'<>&".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(""");
445 }
446 '\'' => {
447 escaped.push_str("'");
448 }
449 '<' => {
450 escaped.push_str("<");
451 }
452 '>' => {
453 escaped.push_str(">");
454 }
455 '&' => {
456 escaped.push_str("&");
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}