1use 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 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(""");
398 }
399 '\'' => {
400 escaped.push_str("'");
401 }
402 '<' => {
403 escaped.push_str("<");
404 }
405 '>' => {
406 escaped.push_str(">");
407 }
408 '&' => {
409 escaped.push_str("&");
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}