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