Commit
Author: Mauro D [mauro@stalw.art]
Hash: d8089100633dd148c399394b167bf2cb526a7543
Timestamp: Tue, 29 Nov 2022 17:16:34 +0000 (2 years ago)

+3393 -640 +/-29 browse
DMARC aggregate reports parsing and generation.
1diff --git a/Cargo.toml b/Cargo.toml
2index 984215d..2ae36a2 100644
3--- a/Cargo.toml
4+++ b/Cargo.toml
5 @@ -17,6 +17,10 @@ lru-cache = "0.1.2"
6 parking_lot = "0.12.0"
7 ahash = "0.8.0"
8 quick-xml = "0.26.0"
9+ serde = { version = "1.0", features = ["derive"] }
10+ zip = "0.6.3"
11+ flate2 = "1.0.25"
12
13 [dev-dependencies]
14 tokio = { version = "1.16", features = ["net", "io-util", "time", "rt-multi-thread", "macros"] }
15+ serde_json = "1.0"
16 diff --git a/resources/dmarc-agg-report/001.xml b/resources/dmarc-agg-report/001.xml
17deleted file mode 100644
18index dad8479..0000000
19--- a/resources/dmarc-agg-report/001.xml
20+++ /dev/null
21 @@ -1,49 +0,0 @@
22- <?xml version="1.0" encoding="UTF-8" ?>
23- <feedback>
24- <report_metadata>
25- <version>2.0</version>
26- <org_name>Sample Reporter</org_name>
27- <email>report_sender@example-reporter.com</email>
28- <extra_contact_info>...</extra_contact_info>
29- <report_id>3v98abbp8ya9n3va8yr8oa3ya</report_id>
30- <date_range>
31- <begin>161212415</begin>
32- <end>161221511</end>
33- </date_range>
34- </report_metadata>
35- <policy_published>
36- <domain>example.com</domain>
37- <p>quarantine</p>
38- <sp>none</sp>
39- <testing>n</testing>
40- </policy_published>
41- <record>
42- <row>
43- <source_ip>192.168.4.4</source_ip>
44- <count>123</count>
45- <policy_evaluated>
46- <disposition>quarantine</disposition>
47- <dkim>pass</dkim>
48- <spf>fail</spf>
49- </policy_evaluated>
50- </row>
51- <identifiers>
52- <header_from>example.com</header_from>
53- </identifiers>
54- <auth_results>
55- <dkim>
56- <domain>example.com</domain>
57- <result>pass</result>
58- <selector>abc123</selector>
59- </dkim>
60- <spf>
61- <domain>example.com</domain>
62- <result>fail</result>
63- </spf>
64- </auth_results>
65- <extensions>
66- </extensions>
67- </record>
68- <extensions>
69- </extensions>
70- </feedback>
71 diff --git a/resources/dmarc-feedback/001.json b/resources/dmarc-feedback/001.json
72new file mode 100644
73index 0000000..ac141e4
74--- /dev/null
75+++ b/resources/dmarc-feedback/001.json
76 @@ -0,0 +1,77 @@
77+ {
78+ "version": 2.0,
79+ "report_metadata": {
80+ "org_name": "Sample Reporter",
81+ "email": "report_sender@example-reporter.com",
82+ "extra_contact_info": "...",
83+ "report_id": "3v98abbp8ya9n3va8yr8oa3ya",
84+ "date_range": {
85+ "begin": 161212415,
86+ "end": 161221511
87+ },
88+ "error": []
89+ },
90+ "policy_published": {
91+ "domain": "example.com",
92+ "version_published": null,
93+ "adkim": "Unspecified",
94+ "aspf": "Unspecified",
95+ "p": "Quarantine",
96+ "sp": "None",
97+ "testing": false,
98+ "fo": null
99+ },
100+ "record": [
101+ {
102+ "row": {
103+ "source_ip": "192.168.4.4",
104+ "count": 123,
105+ "policy_evaluated": {
106+ "disposition": "Quarantine",
107+ "dkim": "Pass",
108+ "spf": "Fail",
109+ "reason": []
110+ }
111+ },
112+ "identifiers": {
113+ "envelope_to": null,
114+ "envelope_from": "",
115+ "header_from": "example.com"
116+ },
117+ "auth_results": {
118+ "dkim": [
119+ {
120+ "domain": "example.com",
121+ "selector": "abc123",
122+ "result": "Pass",
123+ "human_result": null
124+ }
125+ ],
126+ "spf": [
127+ {
128+ "domain": "example.com",
129+ "scope": "Unspecified",
130+ "result": "Fail",
131+ "human_result": null
132+ }
133+ ]
134+ },
135+ "extensions": [
136+ {
137+ "name": "extensionName",
138+ "definition": "https://path/to/spec"
139+ },
140+ {
141+ "name": "extensionName2",
142+ "definition": "https://path/to/spec2"
143+ }
144+ ]
145+ }
146+ ],
147+ "extensions": [
148+ {
149+ "name": "otherExtension",
150+ "definition": "https://path/to/spec"
151+ }
152+ ]
153+ }
154\ No newline at end of file
155 diff --git a/resources/dmarc-feedback/001.xml b/resources/dmarc-feedback/001.xml
156new file mode 100644
157index 0000000..9cce0f6
158--- /dev/null
159+++ b/resources/dmarc-feedback/001.xml
160 @@ -0,0 +1,60 @@
161+ <?xml version="1.0" encoding="UTF-8" ?>
162+ <feedback>
163+ <version>2.0</version>
164+ <report_metadata>
165+ <org_name>Sample Reporter</org_name>
166+ <email>report_sender@example-reporter.com</email>
167+ <extra_contact_info>...</extra_contact_info>
168+ <report_id>3v98abbp8ya9n3va8yr8oa3ya</report_id>
169+ <date_range>
170+ <begin>161212415</begin>
171+ <end>161221511</end>
172+ <unknown-tag1><unknown-tag2></unknown-tag2></unknown-tag1>
173+ </date_range>
174+ </report_metadata>
175+ <policy_published>
176+ <domain>example.com</domain>
177+ <p>quarantine</p>
178+ <sp>none</sp>
179+ <testing>n</testing>
180+ <unknown-tag1><unknown-tag2></unknown-tag2></unknown-tag1>
181+ </policy_published>
182+ <record>
183+ <row>
184+ <source_ip>192.168.4.4</source_ip>
185+ <count>123</count>
186+ <policy_evaluated>
187+ <disposition>quarantine</disposition>
188+ <dkim>pass</dkim>
189+ <spf>fail</spf>
190+ </policy_evaluated>
191+ </row>
192+ <identifiers>
193+ <header_from>example.com</header_from>
194+ </identifiers>
195+ <auth_results>
196+ <dkim>
197+ <domain>example.com</domain>
198+ <result>pass</result>
199+ <selector>abc123</selector>
200+ </dkim>
201+ <spf>
202+ <domain>example.com</domain>
203+ <result>fail</result>
204+ </spf>
205+ </auth_results>
206+ <extensions>
207+ <extension name="extensionName" definition="https://path/to/spec">
208+ <data>...</data>
209+ </extension>
210+ <extension name="extensionName2" definition="https://path/to/spec2">
211+ <data>...</data>
212+ </extension>
213+ </extensions>
214+ </record>
215+ <extensions>
216+ <extension name="otherExtension" definition="https://path/to/spec">
217+ <data>...</data>
218+ </extension>
219+ </extensions>
220+ </feedback>
221 diff --git a/resources/dmarc-feedback/002.json b/resources/dmarc-feedback/002.json
222new file mode 100644
223index 0000000..7a36f04
224--- /dev/null
225+++ b/resources/dmarc-feedback/002.json
226 @@ -0,0 +1,63 @@
227+ {
228+ "version": 0.0,
229+ "report_metadata": {
230+ "org_name": "google.com",
231+ "email": "noreply-dmarc-support@google.com",
232+ "extra_contact_info": "https://support.google.com/a/answer/2466580",
233+ "report_id": "2122885654478337555",
234+ "date_range": {
235+ "begin": 1661558400,
236+ "end": 1661644799
237+ },
238+ "error": []
239+ },
240+ "policy_published": {
241+ "domain": "example.org",
242+ "version_published": null,
243+ "adkim": "Relaxed",
244+ "aspf": "Relaxed",
245+ "p": "None",
246+ "sp": "None",
247+ "testing": false,
248+ "fo": null
249+ },
250+ "record": [
251+ {
252+ "row": {
253+ "source_ip": "209.85.220.41",
254+ "count": 2,
255+ "policy_evaluated": {
256+ "disposition": "None",
257+ "dkim": "Fail",
258+ "spf": "Pass",
259+ "reason": []
260+ }
261+ },
262+ "identifiers": {
263+ "envelope_to": null,
264+ "envelope_from": "",
265+ "header_from": "example.org"
266+ },
267+ "auth_results": {
268+ "dkim": [
269+ {
270+ "domain": "example-org.20210112.gappssmtp.com",
271+ "selector": "20210112",
272+ "result": "Pass",
273+ "human_result": null
274+ }
275+ ],
276+ "spf": [
277+ {
278+ "domain": "example.org",
279+ "scope": "Unspecified",
280+ "result": "Pass",
281+ "human_result": null
282+ }
283+ ]
284+ },
285+ "extensions": []
286+ }
287+ ],
288+ "extensions": []
289+ }
290\ No newline at end of file
291 diff --git a/resources/dmarc-feedback/002.xml b/resources/dmarc-feedback/002.xml
292new file mode 100644
293index 0000000..638e46c
294--- /dev/null
295+++ b/resources/dmarc-feedback/002.xml
296 @@ -0,0 +1,46 @@
297+ <?xml version="1.0" encoding="UTF-8" ?>
298+ <feedback>
299+ <report_metadata>
300+ <org_name>google.com</org_name>
301+ <email>noreply-dmarc-support@google.com</email>
302+ <extra_contact_info>https://support.google.com/a/answer/2466580</extra_contact_info>
303+ <report_id>2122885654478337555</report_id>
304+ <date_range>
305+ <begin>1661558400</begin>
306+ <end>1661644799</end>
307+ </date_range>
308+ </report_metadata>
309+ <policy_published>
310+ <domain>example.org</domain>
311+ <adkim>r</adkim>
312+ <aspf>r</aspf>
313+ <p>none</p>
314+ <sp>none</sp>
315+ <pct>100</pct>
316+ </policy_published>
317+ <record>
318+ <row>
319+ <source_ip>209.85.220.41</source_ip>
320+ <count>2</count>
321+ <policy_evaluated>
322+ <disposition>none</disposition>
323+ <dkim>fail</dkim>
324+ <spf>pass</spf>
325+ </policy_evaluated>
326+ </row>
327+ <identifiers>
328+ <header_from>example.org</header_from>
329+ </identifiers>
330+ <auth_results>
331+ <dkim>
332+ <domain>example-org.20210112.gappssmtp.com</domain>
333+ <result>pass</result>
334+ <selector>20210112</selector>
335+ </dkim>
336+ <spf>
337+ <domain>example.org</domain>
338+ <result>pass</result>
339+ </spf>
340+ </auth_results>
341+ </record>
342+ </feedback>
343\ No newline at end of file
344 diff --git a/resources/dmarc-feedback/003.json b/resources/dmarc-feedback/003.json
345new file mode 100644
346index 0000000..066e91e
347--- /dev/null
348+++ b/resources/dmarc-feedback/003.json
349 @@ -0,0 +1,192 @@
350+ {
351+ "version": 1.0,
352+ "report_metadata": {
353+ "org_name": "Fastmail Pty Ltd",
354+ "email": "reports@fastmaildmarc.com",
355+ "extra_contact_info": "https://fastmail.com/",
356+ "report_id": "758848224",
357+ "date_range": {
358+ "begin": 1667347200,
359+ "end": 1667433599
360+ },
361+ "error": []
362+ },
363+ "policy_published": {
364+ "domain": "stalw.art",
365+ "version_published": null,
366+ "adkim": "Unspecified",
367+ "aspf": "Unspecified",
368+ "p": "None",
369+ "sp": "None",
370+ "testing": false,
371+ "fo": "0"
372+ },
373+ "record": [
374+ {
375+ "row": {
376+ "source_ip": "64.147.108.117",
377+ "count": 3,
378+ "policy_evaluated": {
379+ "disposition": "None",
380+ "dkim": "Fail",
381+ "spf": "Fail",
382+ "reason": [
383+ {
384+ "type_": "TrustedForwarder",
385+ "comment": "Policy ignored due to local white list"
386+ }
387+ ]
388+ }
389+ },
390+ "identifiers": {
391+ "envelope_to": null,
392+ "envelope_from": "jmap.bounce.topicbox.com",
393+ "header_from": "stalw.art"
394+ },
395+ "auth_results": {
396+ "dkim": [
397+ {
398+ "domain": "jmap.topicbox.com",
399+ "selector": "dkim-1",
400+ "result": "Pass",
401+ "human_result": "pass"
402+ }
403+ ],
404+ "spf": [
405+ {
406+ "domain": "jmap.bounce.topicbox.com",
407+ "scope": "MailFrom",
408+ "result": "Pass",
409+ "human_result": null
410+ }
411+ ]
412+ },
413+ "extensions": []
414+ },
415+ {
416+ "row": {
417+ "source_ip": "173.228.157.66",
418+ "count": 4,
419+ "policy_evaluated": {
420+ "disposition": "None",
421+ "dkim": "Fail",
422+ "spf": "Fail",
423+ "reason": [
424+ {
425+ "type_": "TrustedForwarder",
426+ "comment": "Policy ignored due to local white list"
427+ }
428+ ]
429+ }
430+ },
431+ "identifiers": {
432+ "envelope_to": null,
433+ "envelope_from": "jmap.bounce.topicbox.com",
434+ "header_from": "stalw.art"
435+ },
436+ "auth_results": {
437+ "dkim": [
438+ {
439+ "domain": "jmap.topicbox.com",
440+ "selector": "dkim-1",
441+ "result": "Pass",
442+ "human_result": "pass"
443+ }
444+ ],
445+ "spf": [
446+ {
447+ "domain": "jmap.bounce.topicbox.com",
448+ "scope": "MailFrom",
449+ "result": "Pass",
450+ "human_result": null
451+ }
452+ ]
453+ },
454+ "extensions": []
455+ },
456+ {
457+ "row": {
458+ "source_ip": "64.147.108.173",
459+ "count": 1,
460+ "policy_evaluated": {
461+ "disposition": "None",
462+ "dkim": "Fail",
463+ "spf": "Fail",
464+ "reason": [
465+ {
466+ "type_": "TrustedForwarder",
467+ "comment": "Policy ignored due to local white list"
468+ }
469+ ]
470+ }
471+ },
472+ "identifiers": {
473+ "envelope_to": null,
474+ "envelope_from": "jmap.bounce.topicbox.com",
475+ "header_from": "stalw.art"
476+ },
477+ "auth_results": {
478+ "dkim": [
479+ {
480+ "domain": "jmap.topicbox.com",
481+ "selector": "dkim-1",
482+ "result": "Pass",
483+ "human_result": "pass"
484+ }
485+ ],
486+ "spf": [
487+ {
488+ "domain": "jmap.bounce.topicbox.com",
489+ "scope": "MailFrom",
490+ "result": "Pass",
491+ "human_result": null
492+ }
493+ ]
494+ },
495+ "extensions": []
496+ },
497+ {
498+ "row": {
499+ "source_ip": "54.240.8.13",
500+ "count": 1,
501+ "policy_evaluated": {
502+ "disposition": "None",
503+ "dkim": "Pass",
504+ "spf": "Fail",
505+ "reason": []
506+ }
507+ },
508+ "identifiers": {
509+ "envelope_to": null,
510+ "envelope_from": "amazonses.com",
511+ "header_from": "stalw.art"
512+ },
513+ "auth_results": {
514+ "dkim": [
515+ {
516+ "domain": "stalw.art",
517+ "selector": "kvoujh5guu2jpweurhpt2ioscpkpukc3",
518+ "result": "Pass",
519+ "human_result": "pass"
520+ },
521+ {
522+ "domain": "amazonses.com",
523+ "selector": "6gbrjpgwjskckoa6a5zn6fwqkn67xbtw",
524+ "result": "Pass",
525+ "human_result": "pass"
526+ }
527+ ],
528+ "spf": [
529+ {
530+ "domain": "amazonses.com",
531+ "scope": "MailFrom",
532+ "result": "Pass",
533+ "human_result": null
534+ }
535+ ]
536+ },
537+ "extensions": []
538+ }
539+ ],
540+ "extensions": []
541+ }
542\ No newline at end of file
543 diff --git a/resources/dmarc-feedback/003.xml b/resources/dmarc-feedback/003.xml
544new file mode 100644
545index 0000000..6889e89
546--- /dev/null
547+++ b/resources/dmarc-feedback/003.xml
548 @@ -0,0 +1,151 @@
549+ <?xml version="1.0"?>
550+ <feedback>
551+ <version>1.0</version>
552+ <report_metadata>
553+ <org_name>Fastmail Pty Ltd</org_name>
554+ <email>reports@fastmaildmarc.com</email>
555+ <extra_contact_info>https://fastmail.com/</extra_contact_info>
556+ <report_id>758848224</report_id>
557+ <date_range>
558+ <begin>1667347200</begin>
559+ <end>1667433599</end>
560+ </date_range>
561+ </report_metadata>
562+ <policy_published>
563+ <domain>stalw.art</domain>
564+ <p>none</p>
565+ <sp>none</sp>
566+ <pct>100</pct>
567+ <fo>0</fo>
568+ </policy_published>
569+ <record>
570+ <row>
571+ <source_ip>64.147.108.117</source_ip>
572+ <count>3</count>
573+ <policy_evaluated>
574+ <disposition>none</disposition>
575+ <dkim>fail</dkim>
576+ <spf>fail</spf>
577+ <reason>
578+ <type>trusted_forwarder</type>
579+ <comment>Policy ignored due to local white list</comment>
580+ </reason>
581+ </policy_evaluated>
582+ </row>
583+ <identifiers>
584+ <envelope_from>jmap.bounce.topicbox.com</envelope_from>
585+ <header_from>stalw.art</header_from>
586+ </identifiers>
587+ <auth_results>
588+ <dkim>
589+ <domain>jmap.topicbox.com</domain>
590+ <selector>dkim-1</selector>
591+ <result>pass</result>
592+ <human_result>pass</human_result>
593+ </dkim>
594+ <spf>
595+ <domain>jmap.bounce.topicbox.com</domain>
596+ <scope>mfrom</scope>
597+ <result>pass</result>
598+ </spf>
599+ </auth_results>
600+ </record>
601+ <record>
602+ <row>
603+ <source_ip>173.228.157.66</source_ip>
604+ <count>4</count>
605+ <policy_evaluated>
606+ <disposition>none</disposition>
607+ <dkim>fail</dkim>
608+ <spf>fail</spf>
609+ <reason>
610+ <type>trusted_forwarder</type>
611+ <comment>Policy ignored due to local white list</comment>
612+ </reason>
613+ </policy_evaluated>
614+ </row>
615+ <identifiers>
616+ <envelope_from>jmap.bounce.topicbox.com</envelope_from>
617+ <header_from>stalw.art</header_from>
618+ </identifiers>
619+ <auth_results>
620+ <dkim>
621+ <domain>jmap.topicbox.com</domain>
622+ <selector>dkim-1</selector>
623+ <result>pass</result>
624+ <human_result>pass</human_result>
625+ </dkim>
626+ <spf>
627+ <domain>jmap.bounce.topicbox.com</domain>
628+ <scope>mfrom</scope>
629+ <result>pass</result>
630+ </spf>
631+ </auth_results>
632+ </record>
633+ <record>
634+ <row>
635+ <source_ip>64.147.108.173</source_ip>
636+ <count>1</count>
637+ <policy_evaluated>
638+ <disposition>none</disposition>
639+ <dkim>fail</dkim>
640+ <spf>fail</spf>
641+ <reason>
642+ <type>trusted_forwarder</type>
643+ <comment>Policy ignored due to local white list</comment>
644+ </reason>
645+ </policy_evaluated>
646+ </row>
647+ <identifiers>
648+ <envelope_from>jmap.bounce.topicbox.com</envelope_from>
649+ <header_from>stalw.art</header_from>
650+ </identifiers>
651+ <auth_results>
652+ <dkim>
653+ <domain>jmap.topicbox.com</domain>
654+ <selector>dkim-1</selector>
655+ <result>pass</result>
656+ <human_result>pass</human_result>
657+ </dkim>
658+ <spf>
659+ <domain>jmap.bounce.topicbox.com</domain>
660+ <scope>mfrom</scope>
661+ <result>pass</result>
662+ </spf>
663+ </auth_results>
664+ </record>
665+ <record>
666+ <row>
667+ <source_ip>54.240.8.13</source_ip>
668+ <count>1</count>
669+ <policy_evaluated>
670+ <disposition>none</disposition>
671+ <dkim>pass</dkim>
672+ <spf>fail</spf>
673+ </policy_evaluated>
674+ </row>
675+ <identifiers>
676+ <envelope_from>amazonses.com</envelope_from>
677+ <header_from>stalw.art</header_from>
678+ </identifiers>
679+ <auth_results>
680+ <dkim>
681+ <domain>stalw.art</domain>
682+ <selector>kvoujh5guu2jpweurhpt2ioscpkpukc3</selector>
683+ <result>pass</result>
684+ <human_result>pass</human_result>
685+ </dkim>
686+ <dkim>
687+ <domain>amazonses.com</domain>
688+ <selector>6gbrjpgwjskckoa6a5zn6fwqkn67xbtw</selector>
689+ <result>pass</result>
690+ <human_result>pass</human_result>
691+ </dkim>
692+ <spf>
693+ <domain>amazonses.com</domain>
694+ <scope>mfrom</scope>
695+ <result>pass</result>
696+ </spf>
697+ </auth_results>
698+ </record>
699+ </feedback>
700 diff --git a/resources/dmarc-feedback/100.eml b/resources/dmarc-feedback/100.eml
701new file mode 100644
702index 0000000..9a2ed1d
703--- /dev/null
704+++ b/resources/dmarc-feedback/100.eml
705 @@ -0,0 +1,66 @@
706+ Received: from mail.stalw.art ([mail.stalw.art])
707+ by 127.0.0.1 (Stalwart JMAP) with LMTP;
708+ Mon, 28 Nov 2022 10:51:56 +0000
709+ Received: from mail-qv1-xf4a.google.com (mail-qv1-xf4a.google.com [IPv6:2607:f8b0:4864:20::f4a])
710+ (using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits)
711+ key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256)
712+ (No client certificate requested)
713+ by mail.stalw.art (Postfix) with ESMTPS id 1145E7CC0B
714+ for <domains@stalw.art>; Mon, 28 Nov 2022 10:51:53 +0000 (UTC)
715+ Received: by mail-qv1-xf4a.google.com with SMTP id 71-20020a0c804d000000b004b2fb260447so12985969qva.10
716+ for <domains@stalw.art>; Mon, 28 Nov 2022 02:51:52 -0800 (PST)
717+ DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
718+ d=google.com; s=20210112;
719+ h=content-transfer-encoding:content-disposition:to:from:subject
720+ :message-id:date:mime-version:from:to:cc:subject:date:message-id
721+ :reply-to;
722+ bh=sMF/38UFRhmUYFRJST4vLBu/U1BXgsdCUE02HF8nXx8=;
723+ b=I7WONP7tMsULp4eKjJeeKtM+nDYqMSIYMxqNHqCP1bTsnUiW2xM278I2+F8EjtFNYf
724+ XOgusNn8kqbSnA4w1+q4G87zTF4K3tGnxNpuUMQ7GzcofBKtr7VPv9XFqvTPJ+N8YSwe
725+ 926ec7xi71BpSHAgqp5Wqocj8ruIVjcCZ37hYrG0C4s+FVBtbaU3EeyPpkESaaY2vE5y
726+ Qa2KsrMsyJXlbyW/sFJ7AGDDuXwyGkTa+btP/xIiQM2HlBKy7vNOFZKkxInOuQsXJgZy
727+ 3H7ivlpD3hMrszwU77o5jBArVwN0RIkUSosAPQf+pzgvRlkseRlDrmzKQutvYWIaTP3/
728+ FHPA==
729+ X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
730+ d=1e100.net; s=20210112;
731+ h=content-transfer-encoding:content-disposition:to:from:subject
732+ :message-id:date:mime-version:x-gm-message-state:from:to:cc:subject
733+ :date:message-id:reply-to;
734+ bh=sMF/38UFRhmUYFRJST4vLBu/U1BXgsdCUE02HF8nXx8=;
735+ b=D3oClvT5AKcTpEjjffHQqPPQ9j5mmtExiviSq7iBYkoq+322LtR2hGqGxtvlAwRDsQ
736+ VIfuKVExygw3c9bckjzKtJYX128HGK35gHnmsrzqvCC93JlRaC/55kcM9Bhks0xJnl7i
737+ yNFHPZ0DY/jasdUdQ1QqnI+8qiPy+/12JvD+/TGlaDuS+RWYFU4/ky46S3vMXwXmRt6D
738+ IGggXoW7snSaM4s88DzMUl0U7DH823UPQrUxnA5Oxscwn9M1ENJUWD/3EJo5ZEUMw0ll
739+ Y8AlyhjWFgVqs1Y4V/LVWeXdF10fpm78+jm8QyIZYZJjh4I33AekdsWVM71ZNNYGL0+8
740+ +GDg==
741+ X-Gm-Message-State: ANoB5pn0zRZSWXdFXd9G0tawbSeUYuhxToVkIYoLf8OJzoBLcIc0wKcB
742+ AfU9Coz5vAuiM1mASWJhbg==
743+ X-Google-Smtp-Source: AA0mqf6WWsnqMD4cHE40jB89/zblmT7yNKeHKlsvvCmlYANKmpKLTQTaCm5qCA0mVmxR/PTQogsNndWH/qe0ug==
744+ MIME-Version: 1.0
745+ X-Received: by 2002:ac8:5182:0:b0:39c:cb6a:300b with SMTP id
746+ c2-20020ac85182000000b0039ccb6a300bmr48409299qtn.181.1669632711968; Mon, 28
747+ Nov 2022 02:51:51 -0800 (PST)
748+ Date: Sun, 27 Nov 2022 15:59:59 -0800
749+ Message-ID: <5264580628977113351@google.com>
750+ Subject: Report domain: stalw.art Submitter: google.com Report-ID: 5264580628977113351
751+ From: noreply-dmarc-support@google.com
752+ To: domains@stalw.art
753+ Content-Type: application/zip;
754+ name="google.com!stalw.art!1669507200!1669593599.zip"
755+ Content-Disposition: attachment;
756+ filename="google.com!stalw.art!1669507200!1669593599.zip"
757+ Content-Transfer-Encoding: base64
758+
759+ UEsDBAoAAAAIAHFUfFWAeOSU8QEAAKkEAAAuAAAAZ29vZ2xlLmNvbSFzdGFsdy5hcnQhMTY2OTUw
760+ NzIwMCExNjY5NTkzNTk5LnhtbKVUwZKjIBC9z1ekck9Qk5hoMcye9gt2zxbB1lBBoACTmb9fHNCw
761+ ma257El83f2632sUv70PYnUDY7mSr+t8m61XIJlquexf179//dyc1qs38oI7gPZM2ZW8rFbYgFbG
762+ NQM42lJHJ8yjyvSNpAOQXqlewJapAaMFDDkwUC6IVJ5BfGzagRq2saOe6H6kZSEv1rw7QxumpKPM
763+ NVx2ilyc07ZGKJZuH6WIIirtHQwq9mV5OGWe62t9II4yeEsORbn3uWVxqo7HPN/tDjlGj3BI91Kh
764+ MVT2UYyHztBzSfKyrA7Zsch8s4DMcZBtiFa7Q1X5UeRMhv5mW7qlnmKtBGcfjR7PgtsLLIMo744k
765+ 1lFx31LjPFlAQpi2Vz4Qg1E4RNDq7hObngHSfg8SMNLx3c6AnRHNHMknVdPhc8p/TeR9ZMrMwxl1
766+ X+RbNRoGDdekoFle77uqZlme1+f9jtW1t/iRMJcwNUrfFKNwmOHYF25UjN64dg5MbnCrleXOX+A4
767+ f4okeZMZmlrrExZfovAuBhZzEq1PPf2mZoWYtyAd77j/fJayC9AWTNMZNaQbSuHI86Ua09FdGgN2
768+ FO5B+DTs98uP93piiJLiS6IWBDCnDLmB4FdujaayKLz2GV8MSDvjxJr/niIx2t/IJ9FTcrhPGD3+
769+ On8AUEsBAgoACgAAAAgAcVR8VYB45JTxAQAAqQQAAC4AAAAAAAAAAAAAAAAAAAAAAGdvb2dsZS5j
770+ b20hc3RhbHcuYXJ0ITE2Njk1MDcyMDAhMTY2OTU5MzU5OS54bWxQSwUGAAAAAAEAAQBcAAAAPQIA
771+ AAAA
772\ No newline at end of file
773 diff --git a/resources/dmarc-feedback/100.json b/resources/dmarc-feedback/100.json
774new file mode 100644
775index 0000000..36b48c7
776--- /dev/null
777+++ b/resources/dmarc-feedback/100.json
778 @@ -0,0 +1,63 @@
779+ {
780+ "version": 0.0,
781+ "report_metadata": {
782+ "org_name": "google.com",
783+ "email": "noreply-dmarc-support@google.com",
784+ "extra_contact_info": "https://support.google.com/a/answer/2466580",
785+ "report_id": "5264580628977113351",
786+ "date_range": {
787+ "begin": 1669507200,
788+ "end": 1669593599
789+ },
790+ "error": []
791+ },
792+ "policy_published": {
793+ "domain": "stalw.art",
794+ "version_published": null,
795+ "adkim": "Relaxed",
796+ "aspf": "Relaxed",
797+ "p": "None",
798+ "sp": "None",
799+ "testing": false,
800+ "fo": null
801+ },
802+ "record": [
803+ {
804+ "row": {
805+ "source_ip": "2a01:4f9:c011:b43c::1",
806+ "count": 1,
807+ "policy_evaluated": {
808+ "disposition": "None",
809+ "dkim": "Pass",
810+ "spf": "Pass",
811+ "reason": []
812+ }
813+ },
814+ "identifiers": {
815+ "envelope_to": null,
816+ "envelope_from": "",
817+ "header_from": "stalw.art"
818+ },
819+ "auth_results": {
820+ "dkim": [
821+ {
822+ "domain": "stalw.art",
823+ "selector": "velikisrpan22",
824+ "result": "Pass",
825+ "human_result": null
826+ }
827+ ],
828+ "spf": [
829+ {
830+ "domain": "stalw.art",
831+ "scope": "Unspecified",
832+ "result": "Pass",
833+ "human_result": null
834+ }
835+ ]
836+ },
837+ "extensions": []
838+ }
839+ ],
840+ "extensions": []
841+ }
842\ No newline at end of file
843 diff --git a/resources/dmarc-feedback/101.eml b/resources/dmarc-feedback/101.eml
844new file mode 100644
845index 0000000..3a66174
846--- /dev/null
847+++ b/resources/dmarc-feedback/101.eml
848 @@ -0,0 +1,68 @@
849+ Received: from mail.stalw.art ([mail.stalw.art])
850+ by 127.0.0.1 (Stalwart JMAP) with LMTP;
851+ Thu, 10 Nov 2022 03:27:19 +0000
852+ Received: from mx0.backschues.net (lnxs001.backschues.net [85.183.142.13])
853+ (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
854+ key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256)
855+ (No client certificate requested)
856+ by mail.stalw.art (Postfix) with ESMTPS id 6DD117CC0B
857+ for <domains@stalw.art>; Thu, 10 Nov 2022 03:27:16 +0000 (UTC)
858+ Received: from mx0.backschues.net (localhost [127.0.0.1])
859+ by mx0.backschues.net with SMTP id 4N76hg4lNgz9ryP
860+ for <domains@stalw.art>; Thu, 10 Nov 2022 04:27:15 +0100 (CET)
861+ DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=backschues.net;
862+ s=mail-2014-01; t=1668050835;
863+ h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
864+ to:to:cc:mime-version:mime-version:content-type:content-type;
865+ bh=LTj1tdFz9JQFL/mVJASN0b9hGcolcCtY5v0bhnChJYY=;
866+ b=AtRegYc51PTYqDOy/6fB4xETTWAbVc2ivf8AfF4ygu3+6+oqBPyloTuOnEt7xYmjLFnll/
867+ SMZFFpRETsMlkiVg/1O0VpPRpIpiTbh4dwtUrRyo1Uw/cDJv5auz4rBMxcRNnDKypHwUKs
868+ BUahHWsVKH/TL5SzV79kqyjlYAs1HdJvS+wRINYBaptkeT6UeHGZakL21NnQUdOGt0fj4y
869+ eJvWVtCYHZ5DUJ8K8h2W1NlTAWP8nTBoQVVDQrI5Zi1AEvnUWw+H7E8d/q2cF756/IBYso
870+ rT56D3PYo2iuSt3aIBth1wL7/GJwc6N4JHcNpJ9XPV6xQbt+lm2b3+W59osL0Q==
871+ DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=backschues.net;
872+ s=ed25519-mail-2018-10; t=1668050835;
873+ h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
874+ to:to:cc:mime-version:mime-version:content-type:content-type;
875+ bh=LTj1tdFz9JQFL/mVJASN0b9hGcolcCtY5v0bhnChJYY=;
876+ b=y7d79OWWCrDX40k91FoBdGnUcrjN7xvWYyqskPfQmMoaSqFNSlTHH8gMXC/vXwiYIP3Oxp
877+ d/hVvEuIIQBlwMDQ==
878+ From: "DMARC Aggregate Report" <noreply-dmarc-support@backschues.net>
879+ To: domains@stalw.art
880+ Subject: Report Domain: stalw.art
881+ Submitter: backschues.net
882+ Report-ID: stalw.art.1667948400.1668034800
883+ Date: Thu, 10 Nov 2022 03:27:02 GMT
884+ MIME-Version: 1.0
885+ Message-ID: <afe541eab3ec091f@backschues.net>
886+ Content-Type: multipart/mixed;
887+ boundary="----=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303"
888+
889+ This is a multipart message in MIME format.
890+
891+ ------=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303
892+ Content-Type: text/plain; charset="us-ascii"
893+ Content-Transfer-Encoding: 7bit
894+
895+ This is an aggregate report from backschues.net.
896+
897+ Report domain: stalw.art
898+ Submitter: backschues.net
899+ Report ID: stalw.art.1667948400.1668034800
900+
901+ ------=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303
902+ Content-Type: application/gzip
903+ Content-Transfer-Encoding: base64
904+ Content-Disposition: attachment;
905+ filename="backschues.net!stalw.art!1667948400!1668034800.xml.gz"
906+
907+ H4sIAAAAAAAAA5VUsXLbMAzd/RU6D9ksSo6b2heG6dKOndJZR5OQzYtEsiSVNH9fUqQoqXWHT
908+ gIfgAfgASf8/KvvijcwVij5tK3LaluAZIoLeXna/nj5tjtui2eywS0AP1P2SjZFgQ1oZVzTg6
909+ OcOhowjypzaSTtgdz9HJR7DNGWXQew5fevLxhld4yGnoqOSOW5uo8d76lhOzvoQPxlkSrBYRR
910+ jY16qLTixjnbvJTWurB8ePp8Ox0NVBfNY3R+OVYXRHBpTfa/QGCovqQcPneEiJJnzMYrI5AfJ
911+ yZIyvCMZWrPlaktRsFadYB+NHs6dsFfIjSg/kJwH8GQRiW7KX0VPDEbRSKDV7YiFb4S0l08CR
912+ jq97QTYCdHMkTr0HYyxy7878ooyZXhcrHqfuNRgGDRCk09Vud/fl/X+VNangyfPnhjJ1CB9FY
913+ yikQrHMvBGu8HrxLOgXFitrHD+3FKzSyRHhblbv3TvzhKME7YJnlVAt2r5dcRRsOAgnWiFP/G
914+ UcAXKwTStUf1yBUt4ZPgjE9PBXRsDdujcRLVqLu1QgGtLf+zrpY6XG1KJptaGaxkf0y0Fnpts
915+ /7iRmS7KcYNukxX7zwbjWtaMicaf30qEEBaPB6P8h/gNNHLX4VQEAAA=
916+ ------=_NextPart_84e1fdd0-b285-4922-9fc7-88b070204303--
917 diff --git a/resources/dmarc-feedback/101.json b/resources/dmarc-feedback/101.json
918new file mode 100644
919index 0000000..53d7e63
920--- /dev/null
921+++ b/resources/dmarc-feedback/101.json
922 @@ -0,0 +1,69 @@
923+ {
924+ "version": 0.0,
925+ "report_metadata": {
926+ "org_name": "\"backschues.NET",
927+ "email": "noreply-dmarc-support@backschues.net",
928+ "extra_contact_info": null,
929+ "report_id": "stalw.art.1667948400.1668034800",
930+ "date_range": {
931+ "begin": 1667948400,
932+ "end": 1668034800
933+ },
934+ "error": []
935+ },
936+ "policy_published": {
937+ "domain": "stalw.art",
938+ "version_published": null,
939+ "adkim": "Relaxed",
940+ "aspf": "Relaxed",
941+ "p": "None",
942+ "sp": "None",
943+ "testing": false,
944+ "fo": null
945+ },
946+ "record": [
947+ {
948+ "row": {
949+ "source_ip": "50.223.129.194",
950+ "count": 1,
951+ "policy_evaluated": {
952+ "disposition": "None",
953+ "dkim": "Fail",
954+ "spf": "Fail",
955+ "reason": []
956+ }
957+ },
958+ "identifiers": {
959+ "envelope_to": null,
960+ "envelope_from": "",
961+ "header_from": "stalw.art"
962+ },
963+ "auth_results": {
964+ "dkim": [
965+ {
966+ "domain": "ietf.org",
967+ "selector": "",
968+ "result": "Pass",
969+ "human_result": null
970+ },
971+ {
972+ "domain": "stalw.art",
973+ "selector": "",
974+ "result": "Fail",
975+ "human_result": null
976+ }
977+ ],
978+ "spf": [
979+ {
980+ "domain": "ietf.org",
981+ "scope": "Unspecified",
982+ "result": "None",
983+ "human_result": null
984+ }
985+ ]
986+ },
987+ "extensions": []
988+ }
989+ ],
990+ "extensions": []
991+ }
992\ No newline at end of file
993 diff --git a/resources/dmarc-feedback/102.eml b/resources/dmarc-feedback/102.eml
994new file mode 100644
995index 0000000..7acd93e
996--- /dev/null
997+++ b/resources/dmarc-feedback/102.eml
998 @@ -0,0 +1,52 @@
999+ Received: from mail.stalw.art ([mail.stalw.art])
1000+ by 127.0.0.1 (Stalwart JMAP) with LMTP;
1001+ Tue, 08 Nov 2022 23:26:41 +0000
1002+ Received: from relay7.m.smailru.net (relay7.m.smailru.net [94.100.178.51])
1003+ (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
1004+ (No client certificate requested)
1005+ by mail.stalw.art (Postfix) with ESMTPS id DD4337CC09
1006+ for <domains@stalw.art>; Tue, 8 Nov 2022 23:26:38 +0000 (UTC)
1007+ DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=corp.mail.ru; s=mail4;
1008+ h=Date:Message-ID:To:From:Subject:MIME-Version:Content-Type:From:Subject:Content-Type:Content-Transfer-Encoding:To:Cc; bh=fooa0+RBCZvyV2mP8Nx/UsLQ5RhazFg+SPGNtxZrCX0=;
1009+ t=1667950001;x=1668040001;
1010+ b=J9aMEkY9eVdOxjkNxaPFJ2Yk+/NCux9uOZl3iJXI0hEFaeYj9g7l+WtmXczk+YvgH3yhVhtvONUEYFsValRWWCAfmePm429N3mSuclVktk7t6RPJ4O5EcMjwrD9882vmX1xpI7ecPOzd5AD67HPt5SIA1RIa5injaOI5CWUXBBa5c0zDfmciyANAiDw0gm1axEMK4AUc61txPsX7H1qRq/FxGNITnnpYdqkkT2lR8sTl5HPwTjEsw4sYGKr5SiMpROhhbLZTM8RpojkP73bmw3UBZ9FI8iKApJUFB8i9tu0hjzHkev4uoDXgOXFYs/RAI1JkCWEp2Rjb3LpTSHT6cA==;
1011+ Received: from [10.161.4.115] (port=60844 helo=60)
1012+ by relay7.m.smailru.net with esmtp (envelope-from <dmarc_support@corp.mail.ru>)
1013+ id 1osXzK-0007VC-BD
1014+ for domains@stalw.art; Wed, 09 Nov 2022 02:26:38 +0300
1015+ Content-Type: multipart/mixed; boundary="===============5640625649776607409=="
1016+ MIME-Version: 1.0
1017+ Subject: Report Domain: stalw.art; Submitter: Mail.Ru;
1018+ Report-ID: 28551467700969547611667865600
1019+ From: dmarc_support@corp.mail.ru
1020+ To: domains@stalw.art
1021+ Message-ID: <dmarc-1667949998@corp.mail.ru>
1022+ Date: Wed, 09 Nov 2022 02:26:38 +0300
1023+ Auto-Submitted: auto-generated
1024+ Authentication-Results: relay7.m.smailru.net; auth=pass smtp.auth=dmarc_support@corp.mail.ru smtp.mailfrom=dmarc_support@corp.mail.ru; iprev=pass policy.iprev=10.161.4.115
1025+
1026+ --===============5640625649776607409==
1027+ MIME-Version: 1.0
1028+ Content-Type: text/plain; charset="utf-8"
1029+ Content-Transfer-Encoding: base64
1030+
1031+ VGhpcyBpcyBhbiBhZ2dyZWdhdGUgcmVwb3J0IGZyb20gTWFpbC5SdS4=
1032+
1033+ --===============5640625649776607409==
1034+ Content-Type: application/gzip
1035+ MIME-Version: 1.0
1036+ Content-Transfer-Encoding: base64
1037+ Content-Disposition: attachment;
1038+ filename="mail.ru!stalw.art!1667865600!1667952000.xml.gz"
1039+
1040+ H4sICK7lamMC/21haWwucnUhc3RhbHcuYXJ0ITE2Njc4NjU2MDAhMTY2Nzk1MjAwMC54bWwAdVNB
1041+ cqMwELzvK3LzKQhYg01qouwHctkPULIYjMogqSThJL/fEQSC18kFzbRmWt0jAS/vQ/9wReeV0c+7
1042+ LEl3D6ilaZQ+P+/G0D4edy/8F7SIzUnICweH1rhQDxhEI4LgYNy51mJA/ipUn/wdga0I4EAYbwbh
1043+ ZO1HGzv/SONsEvHEUe1cAfgenKil0UHIUCvdGt6FYJ8Y67Bfy1lcHyNCjfcdizbV8PxYFNm+PBzS
1044+ tCqrYn8os6wsD8eyKNMU2FchkAmsndBnknvCs9J8WzgjgLqZ4KrI0wjHHNi2ld3NxZpeyY/ajqde
1045+ +Q7jUYb0a+6D6N8S4QIxzAiI5qIG7oDNAQhv2ymNK1iujUZgloNfYgrAysCzKCcG9L070CENO67m
1046+ jVrN6CTWyvIiTfL8d5LlVZJVe+Jad0CaURMpsDlYTOBV9CO5jSaUt8arQO/lU8oWgUl/S9dE+GQl
1047+ OpjzyQu7Z2STPNWgDqpV9BQ5dCgadHXrzLAd1xYGdtMhxtDVDv3YB/+pYpm3wtAm9Ca/xu2xRxmM
1048+ m7bI7JrDzMCt8D5e6ZQsTm5Iv7nEleWKvboo76zQef4N+zyO/9in6fysWBqLfIjOiXBKftA6T/l2
1049+ HGx5CGz9j/8BQWPZIPkDAAA=
1050+ --===============5640625649776607409==--
1051\ No newline at end of file
1052 diff --git a/resources/dmarc-feedback/102.json b/resources/dmarc-feedback/102.json
1053new file mode 100644
1054index 0000000..3c604e0
1055--- /dev/null
1056+++ b/resources/dmarc-feedback/102.json
1057 @@ -0,0 +1,69 @@
1058+ {
1059+ "version": 0.0,
1060+ "report_metadata": {
1061+ "org_name": "Mail.Ru",
1062+ "email": "dmarc_support@corp.mail.ru",
1063+ "extra_contact_info": "http://help.mail.ru/mail-help",
1064+ "report_id": "28551467700969547611667865600",
1065+ "date_range": {
1066+ "begin": 1667865600,
1067+ "end": 1667952000
1068+ },
1069+ "error": []
1070+ },
1071+ "policy_published": {
1072+ "domain": "stalw.art",
1073+ "version_published": null,
1074+ "adkim": "Relaxed",
1075+ "aspf": "Relaxed",
1076+ "p": "None",
1077+ "sp": "None",
1078+ "testing": false,
1079+ "fo": null
1080+ },
1081+ "record": [
1082+ {
1083+ "row": {
1084+ "source_ip": "50.223.129.194",
1085+ "count": 1,
1086+ "policy_evaluated": {
1087+ "disposition": "None",
1088+ "dkim": "Fail",
1089+ "spf": "Fail",
1090+ "reason": []
1091+ }
1092+ },
1093+ "identifiers": {
1094+ "envelope_to": null,
1095+ "envelope_from": "",
1096+ "header_from": "stalw.art"
1097+ },
1098+ "auth_results": {
1099+ "dkim": [
1100+ {
1101+ "domain": "ietf.org",
1102+ "selector": "ietf1",
1103+ "result": "Pass",
1104+ "human_result": null
1105+ },
1106+ {
1107+ "domain": "stalw.art",
1108+ "selector": "velikisrpan22",
1109+ "result": "Fail",
1110+ "human_result": null
1111+ }
1112+ ],
1113+ "spf": [
1114+ {
1115+ "domain": "ietf.org",
1116+ "scope": "MailFrom",
1117+ "result": "Pass",
1118+ "human_result": null
1119+ }
1120+ ]
1121+ },
1122+ "extensions": []
1123+ }
1124+ ],
1125+ "extensions": []
1126+ }
1127\ No newline at end of file
1128 diff --git a/resources/dmarc-feedback/103.eml b/resources/dmarc-feedback/103.eml
1129new file mode 100644
1130index 0000000..2fe512f
1131--- /dev/null
1132+++ b/resources/dmarc-feedback/103.eml
1133 @@ -0,0 +1,126 @@
1134+ Received: from mail.stalw.art ([mail.stalw.art]) by 127.0.0.1 (Stalwart JMAP) with LMTP; Tue, 25 Oct 2022 04:08:22 +0000
1135+ Received: from NAM12-MW2-obe.outbound.protection.outlook.com (mail-mw2nam12on2073.outbound.protection.outlook.com [40.107.244.73])
1136+ (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
1137+ (No client certificate requested)
1138+ by mail.stalw.art (Postfix) with ESMTPS id A24107CC0A
1139+ for <domains@stalw.art>; Tue, 25 Oct 2022 04:08:22 +0000 (UTC)
1140+ ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none;
1141+ b=SvolQ1oIEgdfCI6dbwmJ1jS0ovWmprW6kT3q9NgrbX+CMhIsdrqyS3Q1sO16KT2wCQAyNofiEZ5tKY0e1PzzMqeR29jUWvEye9T43fCfUeLFx9b45YrfkGYwqLeDIq0Ywl+ggVmsm7X83XqI6+9EC6qMukCb0cbLazu3rW/Rbyc6d5+fq6QTFZovATGRvHz71H9t7e//hYI23XjU5Q3Enw0Qq3xPSyusWDi3t7CfGXn9i2120XlNLnPxef5PCmwy4E+OTJ5qC5WtMthOskKKuFvx8onOYmc/JjJ3VrtZwALx9C+ulzix5US6H7pFvZ2jtDbMnW4U7ir/hp5xn5adFw==
1142+ ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com;
1143+ s=arcselector9901;
1144+ h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1;
1145+ bh=65M39Uvc5w8zbmK6TxEdoblSMXlIyqTXJHNalJ80wk4=;
1146+ b=PN7QPeXJLr6tmH2CxydbDQjHqBtFKNN9HjGimeUHaIeSr82WHf4R295QbVX7gxw6sFE7Z9lZMTrMSqbRVI7rhbx+SEkxCfAothf9207FDX6t37Zt0wd/5EwR6dzfbcNJBL+U0/iG4J03L5b1geWY+e68mHKYH4/ybGcr+SBKuv/LgfZNtOfbQ3ioiKvFcpSDqd/qGUs4U9l2tVlXgbcKkct04sCuPciqgLEuIGirPLLbDUaBRJc51ZZB6CeporySRdHp6uFXyy3VBvvLVuwDNnnPrW4BUL05AuutzK7rc8ZQEpWf7r0gUEg2ArSrvs6Znnfe97oRa01L2SeFwuZsMA==
1147+ ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none
1148+ action=none header.from=microsoft.com; dkim=none (message not signed);
1149+ arc=none
1150+ DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
1151+ d=notification.microsoft.com; s=selector1;
1152+ h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
1153+ bh=65M39Uvc5w8zbmK6TxEdoblSMXlIyqTXJHNalJ80wk4=;
1154+ b=NjqsA7D6sEq1WgCZ/E1f5/B+XUXe5F4uv6CF2KQYVuyRnxItdox09LCWqZQ+fNQ6BbJ4Ne05Cb1BPbPP9yvb8Y6B1s2QvuxkUb69UFbAhoFgsRT6A4K76ykKQQyiPoYpxlO6FEyy+gel4y7c9XRLiWW6OxMIBcjBGB5ziP7mGFaJx4qXJ2mROfO7uZfrCu5pzOimkjPw6extWv4i0Kl3XKvBtXZnsr9eoC10mJvEAp7E2cpnaZnP46RQc9cmXzlmvhKPvCQCUWipJN9f1BTTvFjJ9ff6ehmN9RSzCckj3SZGw9XAnd0WYqh4evt6Y1RxQ4iQDSaZHNRpyMOtmkWc/w==
1155+ Authentication-Results: dkim=none (message not signed)
1156+ header.d=none;dmarc=none action=none header.from=microsoft.com;
1157+ Received: from BN9PR03CA0046.namprd03.prod.outlook.com (2603:10b6:408:fb::21)
1158+ by SJ0PR18MB3916.namprd18.prod.outlook.com (2603:10b6:a03:2c9::21) with
1159+ Microsoft SMTP Server (version=TLS1_2,
1160+ cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5746.21; Tue, 25 Oct
1161+ 2022 04:08:19 +0000
1162+ Received: from BN7NAM10FT048.eop-nam10.prod.protection.outlook.com
1163+ (2603:10b6:408:fb:cafe::d7) by BN9PR03CA0046.outlook.office365.com
1164+ (2603:10b6:408:fb::21) with Microsoft SMTP Server (version=TLS1_2,
1165+ cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5746.27 via Frontend
1166+ Transport; Tue, 25 Oct 2022 04:08:19 +0000
1167+ Received: from nam10.map.protection.outlook.com (2a01:111:f400:fe53::30) by
1168+ BN7NAM10FT048.mail.protection.outlook.com (2a01:111:e400:7e8f::199) with
1169+ Microsoft SMTP Server (version=TLS1_2,
1170+ cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5746.16 via Frontend
1171+ Transport; Tue, 25 Oct 2022 04:08:19 +0000
1172+ Message-ID: <725cbfbe133940149987cfc528387235@microsoft.com>
1173+ X-Sender: <dmarcreport@microsoft.com> XATTRDIRECT=Originating XATTRORGID=xorgid:96f9e21d-a1c4-44a3-99e4-37191ac61848
1174+ MIME-Version: 1.0
1175+ From: "DMARC Aggregate Report" <dmarcreport@microsoft.com>
1176+ To: <domains@stalw.art>
1177+ Subject: =?utf-8?B?UmVwb3J0IERvbWFpbjogc3RhbHcuYXJ0IFN1Ym1pdHRlcjogcHJvdGVjdGlvbi5vdXRsb29rLmNvbSBSZXBvcnQtSUQ6IDcyNWNiZmJlMTMzOTQwMTQ5OTg3Y2ZjNTI4Mzg3MjM1?=
1178+ Content-Type: multipart/mixed;
1179+ boundary="_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_"
1180+ Date: Tue, 25 Oct 2022 04:08:19 +0000
1181+ X-EOPAttributedMessage: 0
1182+ X-MS-PublicTrafficType: Email
1183+ X-MS-TrafficTypeDiagnostic: BN7NAM10FT048:EE_|SJ0PR18MB3916:EE_
1184+ X-MS-Office365-Filtering-Correlation-Id: 7f843e40-ccc6-4c17-1ce7-08dab63e8cd1
1185+ X-MS-Exchange-SenderADCheck: 2
1186+ X-MS-Exchange-AntiSpam-Relay: 0
1187+ X-Microsoft-Antispam: BCL:0;
1188+ X-Microsoft-Antispam-Message-Info:
1189+ PSiI3L4DKj/cyRBl8/bmbyQMrr1DvsYEB1+aTn/3Y39oHnyJ5HXcxu6jNUl32WcPW6Gfqmhc6P1RFE5L/9ev0cWnqh4GgIs2qmHicLexPmMjP8viPdjb1N7TSSOv1hhXMT+gVLx889X5sltd4qpfIAWhoxNonjQpVIgt4VOVnbCWTu1hyOjOVplq0rKqIF04BQGHZnBRfkcD1No+mZrvx8RLWIwInU3fpPeGz77Wn3TIvHtzypR/d22WpZ8eHk3aIxxdjwp5WLg4unpiJaieyQN7BRhD/v6b3pLFVJP8Ii2+FGjTsKASczEL4dHnIoIrHYE0wwaFFPcSNzovLhzYguDV42EGS8Fm7soiew4ch+hICM0LPNTGTZIDe7wm2eSwhN2tkJK4QCfh1DON39jXninVr88ZlzMcDXnXpgvWHHiur8az7Gvs9zHH/1tFMsPVSh7BS+8fHEcBYpdtihrP22GcjbOd98IiTAs/dVzSy0TUg6WEgJO6oUklGjqVbi99CrNZI1BtLP4vH4aSlz9JYg4et6SxiJlKyoSzqUr2NN9/pyFdQ//5d/EEjKJz8CAcQmCjjPEObGFttT3maY2+zsa2THodZgpfMyDbA3WUKxE=
1190+ X-Forefront-Antispam-Report:
1191+ CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:nam10.map.protection.outlook.com;PTR:;CAT:NONE;SFS:(13230022)(396003)(39860400002)(346002)(34036004)(366004)(376002)(136003)(47540400005)(451199015)(2616005)(52230400001)(121820200001)(83380400001)(166002)(86362001)(41300700001)(2906002)(4001150100001)(8936002)(316002)(5660300002)(235185007)(41320700001)(508600001)(6486002)(6512007)(6506007)(24736004)(108616005)(68406010)(85236043)(8676002)(10290500003)(6916009)(36736006)(36756003)(66899015);DIR:OUT;SFP:1101;
1192+ X-OriginatorOrg: dmarcrep.onmicrosoft.com
1193+ X-MS-Exchange-CrossTenant-OriginalArrivalTime: 25 Oct 2022 04:08:19.1682
1194+ (UTC)
1195+ X-MS-Exchange-CrossTenant-Network-Message-Id: 7f843e40-ccc6-4c17-1ce7-08dab63e8cd1
1196+ X-MS-Exchange-CrossTenant-AuthSource: BN7NAM10FT048.eop-nam10.prod.protection.outlook.com
1197+ X-MS-Exchange-CrossTenant-AuthAs: Internal
1198+ X-MS-Exchange-CrossTenant-Id: 96f9e21d-a1c4-44a3-99e4-37191ac61848
1199+ X-MS-Exchange-CrossTenant-FromEntityHeader: Internet
1200+ X-MS-Exchange-Transport-CrossTenantHeadersStamped: SJ0PR18MB3916
1201+
1202+ This is a multi-part message in MIME format.
1203+
1204+ --_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_
1205+ Content-Type: multipart/related;
1206+ boundary="_rv_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_"
1207+
1208+ --_rv_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_
1209+ Content-Type: multipart/alternative;
1210+ boundary="_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_"
1211+
1212+ --_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_
1213+
1214+
1215+ --_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_
1216+ Content-Type: text/html; charset=us-ascii
1217+ Content-Transfer-Encoding: base64
1218+
1219+ PGRpdiBzdHlsZSA9ImZvbnQtZmFtaWx5OlNlZ29lIFVJOyBmb250LXNpemU6MTRweDsiPlRoaXMgaX
1220+ MgYSBETUFSQyBhZ2dyZWdhdGUgcmVwb3J0IGZyb20gTWljcm9zb2Z0IENvcnBvcmF0aW9uLiBGb3Ig
1221+ RW1haWxzIHJlY2VpdmVkIGJldHdlZW4gMjAyMi0xMC0yMyAwMDowMDowMCBVVEMgdG8gMjAyMi0xMC
1222+ 0yNCAwMDowMDowMCBVVEMuPC8gZGl2PjxiciAvPjxiciAvPllvdSdyZSByZWNlaXZpbmcgdGhpcyBl
1223+ bWFpbCBiZWNhdXNlIHlvdSBoYXZlIGluY2x1ZGVkIHlvdXIgZW1haWwgYWRkcmVzcyBpbiB0aGUgJ3
1224+ J1YScgdGFnIG9mIHlvdXIgRE1BUkMgcmVjb3JkIGluIEROUyBmb3Igc3RhbHcuYXJ0LiBQbGVhc2Ug
1225+ cmVtb3ZlIHlvdXIgZW1haWwgYWRkcmVzcyBmcm9tIHRoZSAncnVhJyB0YWcgaWYgeW91IGRvbid0IH
1226+ dhbnQgdG8gcmVjZWl2ZSB0aGlzIGVtYWlsLjxiciAvPjxiciAvPjxkaXYgc3R5bGUgPSJmb250LWZh
1227+ bWlseTpTZWdvZSBVSTsgZm9udC1zaXplOjEycHg7IGNvbG9yOiM2NjY2NjY7Ij5QbGVhc2UgZG8gbm
1228+ 90IHJlc3BvbmQgdG8gdGhpcyBlLW1haWwuIFRoaXMgbWFpbGJveCBpcyBub3QgbW9uaXRvcmVkIGFu
1229+ ZCB5b3Ugd2lsbCBub3QgcmVjZWl2ZSBhIHJlc3BvbnNlLiBGb3IgYW55IGZlZWRiYWNrL3N1Z2dlc3
1230+ Rpb25zLCBraW5kbHkgbWFpbCB0byBkbWFyY3JlcG9ydGZlZWRiYWNrQG1pY3Jvc29mdC5jb20uPGJy
1231+ IC8+PGJyIC8+TWljcm9zb2Z0IHJlc3BlY3RzIHlvdXIgcHJpdmFjeS4gUmV2aWV3IG91ciBPbmxpbm
1232+ UgU2VydmljZXMgPGEgaHJlZiA9Imh0dHBzOi8vcHJpdmFjeS5taWNyb3NvZnQuY29tL2VuLXVzL3By
1233+ aXZhY3lzdGF0ZW1lbnQiPlByaXZhY3kgU3RhdGVtZW50PC9hPi48YnIgLz5PbmUgTWljcm9zb2Z0IF
1234+ dheSwgUmVkbW9uZCwgV0EsIFVTQSA5ODA1Mi48LyBkaXYgPg==
1235+
1236+ --_av_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_--
1237+
1238+ --_rv_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_--
1239+
1240+ --_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_
1241+ Content-Type: application/gzip
1242+ Content-Transfer-Encoding: base64
1243+ Content-ID: <3ff45643-7977-4f3c-a97d-14b9e7faa5e7>
1244+ Content-Description: protection.outlook.com!stalw.art!1666483200!1666569600.xml.gz
1245+ Content-Disposition: attachment; filename="protection.outlook.com!stalw.art!1666483200!1666569600.xml.gz";
1246+
1247+ H4sIAAAAAAAEAM1VzY7bIBi8V+o7RLnXxHZ+VyzbB2jVQy+9WQTjBMUGBDjZvn0/G4JJsu3usZcE5h
1248+ vzDcNg45fXrp2dubFCyed5ni3mL+TzJ9xwXu8pO82gLO3Tq62f50fn9BNCl8slu5SZMgdULBY5+vX9
1249+ 20925B2dR7J4n/xFSOuoZHwO7WYzHCQQUIDRdTJWDNfKuKrjjtbU0REEGJasJO04+dG7VqlTxlSHUU
1250+ QDCzqJltQdNcyv87UTzCirGucf8ITADq1ETTbFiu2bPc/Lcrdc5MvdbrthDVsV23K7KcoVRhM3PAzi
1251+ eGWoPFybA7bnBwF7Wq/Xy20JBmDkkUjgsh7Lq/VuPZSHeVgP3S0YW944gbVqBftd6X7fCnvkkxwFO5
1252+ METG4vGTUO1vNIqNP6JDpiMPKDK2p1M4LDf8A0kUpyjPQVsFfERkgzR/JhA8MgYI0iAMCvV/+mULCc
1253+ KRNFG3WZvLGqN4xXQpPVIiuKMsuLXZbvltA3ViKZqV6CBIz8IOKhKz/Ttgc/61gZLBJWKyvcEDW/oR
1254+ RJiYNDDQQFGJNZwYsmVCbHkt3e94VDjFvEoubSiUZA2tNEnHmrNK+cIiqNdlp4ZDdGdURw1wx3LSGP
1255+ eKQfOa258WCSjBS+6nwUh2nvjpXhtm9dIvjekRCzSctN7rxpvOXMKTOS4MziPOH4PkRTa4fkj5PJ3p
1256+ um/7GEv12/Ww1wVuIkrNFUFsU/tfiovaMlTeIH3WCQFdINAYD24+TDPiRvCvSQkIEfLji8CsJHhfwB
1257+ wJC79XYGAAA=
1258+
1259+ --_mpm_a4bcd9a515b44b9d8eceb05d7333675fpiotk5m200exchangecorpm_--
1260 diff --git a/resources/dmarc-feedback/103.json b/resources/dmarc-feedback/103.json
1261new file mode 100644
1262index 0000000..79d03a1
1263--- /dev/null
1264+++ b/resources/dmarc-feedback/103.json
1265 @@ -0,0 +1,75 @@
1266+ {
1267+ "version": 1.0,
1268+ "report_metadata": {
1269+ "org_name": "Outlook.com",
1270+ "email": "dmarcreport@microsoft.com",
1271+ "extra_contact_info": null,
1272+ "report_id": "725cbfbe133940149987cfc528387235",
1273+ "date_range": {
1274+ "begin": 1666483200,
1275+ "end": 1666569600
1276+ },
1277+ "error": []
1278+ },
1279+ "policy_published": {
1280+ "domain": "stalw.art",
1281+ "version_published": null,
1282+ "adkim": "Relaxed",
1283+ "aspf": "Relaxed",
1284+ "p": "None",
1285+ "sp": "None",
1286+ "testing": false,
1287+ "fo": "0"
1288+ },
1289+ "record": [
1290+ {
1291+ "row": {
1292+ "source_ip": "50.223.129.194",
1293+ "count": 1,
1294+ "policy_evaluated": {
1295+ "disposition": "None",
1296+ "dkim": "Fail",
1297+ "spf": "Fail",
1298+ "reason": []
1299+ }
1300+ },
1301+ "identifiers": {
1302+ "envelope_to": "outlook.com",
1303+ "envelope_from": "ietf.org",
1304+ "header_from": "stalw.art"
1305+ },
1306+ "auth_results": {
1307+ "dkim": [
1308+ {
1309+ "domain": "ietf.org",
1310+ "selector": "ietf1",
1311+ "result": "Pass",
1312+ "human_result": null
1313+ },
1314+ {
1315+ "domain": "ietf.org",
1316+ "selector": "ietf1",
1317+ "result": "Pass",
1318+ "human_result": null
1319+ },
1320+ {
1321+ "domain": "stalw.art",
1322+ "selector": "velikisrpan22",
1323+ "result": "Fail",
1324+ "human_result": null
1325+ }
1326+ ],
1327+ "spf": [
1328+ {
1329+ "domain": "ietf.org",
1330+ "scope": "MailFrom",
1331+ "result": "Pass",
1332+ "human_result": null
1333+ }
1334+ ]
1335+ },
1336+ "extensions": []
1337+ }
1338+ ],
1339+ "extensions": []
1340+ }
1341\ No newline at end of file
1342 diff --git a/resources/dmarc-feedback/104.eml b/resources/dmarc-feedback/104.eml
1343new file mode 100644
1344index 0000000..3463d8b
1345--- /dev/null
1346+++ b/resources/dmarc-feedback/104.eml
1347 @@ -0,0 +1,54 @@
1348+ Received: from mail.stalw.art ([mail.stalw.art]) by 127.0.0.1 (Stalwart JMAP) with LMTP; Tue, 20 Sep 2022 10:28:19 +0000
1349+ Received: from a14-92.smtp-out.amazonses.com (a14-92.smtp-out.amazonses.com [54.240.14.92])
1350+ (using TLSv1.2 with cipher ECDHE-RSA-AES128-SHA256 (128/128 bits))
1351+ (No client certificate requested)
1352+ by mail.stalw.art (Postfix) with ESMTPS id 1337D7E19D
1353+ for <domains@stalw.art>; Tue, 20 Sep 2022 10:28:18 +0000 (UTC)
1354+ DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple;
1355+ s=a66wkfbz3zwxdt2n5p6d7lj2ja7sdwuc; d=amazonses.com; t=1663669697;
1356+ h=From:To:Message-ID:Subject:MIME-Version:Content-Type:Date;
1357+ bh=h9v7dueDYfUxVokuKSLTqLuOwisdgdDRQ6TLwJOzXes=;
1358+ b=dHR5EJhoY9s8g2/Y4K4rHdz44k67r7fyC4wr2AWZmemrVBoxYHJPwa295S2VJQtY
1359+ kxTxppN2GEcNxhUMw8TXBrRwNKdoOLU38ZtrAN1a4hWVxmlwky1dtjXETQ/qJ257Nzg
1360+ bsXkAo4S1RABFmkQQJ0zSPZGkMW+lpZTBCDzlOHU=
1361+ DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple;
1362+ s=6gbrjpgwjskckoa6a5zn6fwqkn67xbtw; d=amazonses.com; t=1663669697;
1363+ h=From:To:Message-ID:Subject:MIME-Version:Content-Type:Date:Feedback-ID;
1364+ bh=h9v7dueDYfUxVokuKSLTqLuOwisdgdDRQ6TLwJOzXes=;
1365+ b=UDIvc6rvbihyGbzGRsmSSSzVNFgpfb3V3j0UivcNjlX2y63vjLinol463Z/+3Xh3
1366+ BmxAOiLHF/DbVnqqNg5ygdxsa7MBHXEJ5we3W8vQr37xNk5DqhV7HPBSFttWP5sy0dg
1367+ rdjyfMIjqJ1J/2+aM4opFA/6EWif7TGmjo7N1KKM=
1368+ From: postmaster@amazonses.com
1369+ To: domains@stalw.art
1370+ Message-ID: <010001835a70fc8d-a3d7eff5-7adb-41cc-87bd-a646d9776a69-000000@email.amazonses.com>
1371+ Subject: Dmarc Aggregate Report Domain: {stalw.art} Submitter: {Amazon SES}
1372+ Date: {2022-09-19} Report-ID: {6b06c366-0631-4ca0-8337-f5aecf137918}
1373+ MIME-Version: 1.0
1374+ Content-Type: multipart/mixed;
1375+ boundary="----=_Part_42492_694130218.1663669697673"
1376+ Date: Tue, 20 Sep 2022 10:28:17 +0000
1377+ Feedback-ID: 1.us-east-1.CTa/CO4t1eWkL0VlHBu5/eINCZhxZraAIsQC/FZHIgk=:AmazonSES
1378+ X-SES-Outgoing: 2022.09.20-54.240.14.92
1379+
1380+ ------=_Part_42492_694130218.1663669697673
1381+ Content-Type: text/plain; charset=us-ascii
1382+ Content-Transfer-Encoding: 7bit
1383+
1384+ This MIME email was sent through Amazon SES.
1385+ ------=_Part_42492_694130218.1663669697673
1386+ Content-Type: application/octet-stream;
1387+ name=amazonses.com!stalw.art!1663545600!1663632000.xml.gz
1388+ Content-Transfer-Encoding: base64
1389+ Content-Disposition: attachment;
1390+ filename=amazonses.com!stalw.art!1663545600!1663632000.xml.gz
1391+
1392+ H4sIAAAAAAAAAG1TwXLbIBA9O1/RyV1CWLHszlDSHHJMe8itFw1GK5uJBAwgp+3XlwXJVjK9SOzb
1393+ 1b59vBV7/D0OXy7gvDL62z0tq/tHfsd6gO4o5Bu/27A5yauSMrIEEXdgjQvtCEF0IogIbZhxp1aL
1394+ EfjTy9Ovnz+K1+dXRq4gVsAo1MCt8WEUPoD7Lkbx12gPvpRmZCTnsXLurzreHKtG1k1TVE1Niwcp
1395+ quJQ1/ui3wmQPa33X+mBkVs9fh1HgtYJfUq0G3aEk9KcNk29e9g1VcVIRlISdJdSTb2tMIUxNiEf
1396+ ulwpVpKZNYOSf1o7HQflzzCTm6hCcx/E8F4KF2KjjGBSdG9q5I6RfEiQt31C8I2A5dpoYMSmyC+h
1397+ z7GVgVOcEw8I9IbHKD5xyP9MFO9SGpdnc+Y9i/ZmchJaZfm22pd0T0t6OJRb7HtLpUppJh0ZGcmH
1398+ hM0scBHDFC8p9UblykdvVcAdyTOvkbkGZffR5picbyCJ7GdwvoSblA8k0YWsgKkOdFC9iiu52HiB
1399+ wVhoe2dGnher1AP6uU6k2jOIDlwGVj6t4UT2iYSJKZxbB34awsy6jHu1fUV8sz0tNH7FrfAeVykF
1400+ WediO/nUHcuycdHd5Zf8BxMenbqzAwAA
1401+ ------=_Part_42492_694130218.1663669697673--
1402 diff --git a/resources/dmarc-feedback/104.json b/resources/dmarc-feedback/104.json
1403new file mode 100644
1404index 0000000..602ec66
1405--- /dev/null
1406+++ b/resources/dmarc-feedback/104.json
1407 @@ -0,0 +1,56 @@
1408+ {
1409+ "version": 0.1,
1410+ "report_metadata": {
1411+ "org_name": "AMAZON-SES",
1412+ "email": "postmaster@amazonses.com",
1413+ "extra_contact_info": null,
1414+ "report_id": "6b06c366-0631-4ca0-8337-f5aecf137918",
1415+ "date_range": {
1416+ "begin": 1663545600,
1417+ "end": 1663632000
1418+ },
1419+ "error": []
1420+ },
1421+ "policy_published": {
1422+ "domain": "stalw.art",
1423+ "version_published": null,
1424+ "adkim": "Relaxed",
1425+ "aspf": "Relaxed",
1426+ "p": "None",
1427+ "sp": "None",
1428+ "testing": false,
1429+ "fo": "0"
1430+ },
1431+ "record": [
1432+ {
1433+ "row": {
1434+ "source_ip": "207.171.188.200",
1435+ "count": 1,
1436+ "policy_evaluated": {
1437+ "disposition": "None",
1438+ "dkim": "Fail",
1439+ "spf": "Fail",
1440+ "reason": []
1441+ }
1442+ },
1443+ "identifiers": {
1444+ "envelope_to": null,
1445+ "envelope_from": "amazon.nl",
1446+ "header_from": "stalw.art"
1447+ },
1448+ "auth_results": {
1449+ "dkim": [],
1450+ "spf": [
1451+ {
1452+ "domain": "amazon.nl",
1453+ "scope": "Unspecified",
1454+ "result": "Pass",
1455+ "human_result": null
1456+ }
1457+ ]
1458+ },
1459+ "extensions": []
1460+ }
1461+ ],
1462+ "extensions": []
1463+ }
1464\ No newline at end of file
1465 diff --git a/src/common/auth_results.rs b/src/common/auth_results.rs
1466index 0b4fbcf..ca7575e 100644
1467--- a/src/common/auth_results.rs
1468+++ b/src/common/auth_results.rs
1469 @@ -88,21 +88,14 @@ impl<'x> AuthenticationResults<'x> {
1470
1471 pub fn with_dmarc_result(mut self, dmarc: &DMARCOutput) -> Self {
1472 self.auth_results.push_str(";\r\n\tdmarc=");
1473- match &dmarc.result {
1474- DMARCResult::Pass => self.auth_results.push_str("pass"),
1475- DMARCResult::Fail(err) => {
1476- self.auth_results.push_str("fail");
1477- err.as_auth_result(&mut self.auth_results);
1478- }
1479- DMARCResult::PermError(err) => {
1480- self.auth_results.push_str("permerror");
1481- err.as_auth_result(&mut self.auth_results);
1482- }
1483- DMARCResult::TempError(err) => {
1484- self.auth_results.push_str("temperror");
1485- err.as_auth_result(&mut self.auth_results);
1486- }
1487- DMARCResult::None => self.auth_results.push_str("none"),
1488+ if dmarc.spf_result == DMARCResult::Pass || dmarc.dkim_result == DMARCResult::Pass {
1489+ DMARCResult::Pass.as_auth_result(&mut self.auth_results);
1490+ } else if dmarc.spf_result != DMARCResult::None {
1491+ dmarc.spf_result.as_auth_result(&mut self.auth_results);
1492+ } else if dmarc.dkim_result != DMARCResult::None {
1493+ dmarc.dkim_result.as_auth_result(&mut self.auth_results);
1494+ } else {
1495+ DMARCResult::None.as_auth_result(&mut self.auth_results);
1496 }
1497 write!(
1498 self.auth_results,
1499 @@ -211,6 +204,27 @@ pub trait AsAuthResult {
1500 fn as_auth_result(&self, header: &mut String);
1501 }
1502
1503+ impl AsAuthResult for DMARCResult {
1504+ fn as_auth_result(&self, header: &mut String) {
1505+ match &self {
1506+ DMARCResult::Pass => header.push_str("pass"),
1507+ DMARCResult::Fail(err) => {
1508+ header.push_str("fail");
1509+ err.as_auth_result(header);
1510+ }
1511+ DMARCResult::PermError(err) => {
1512+ header.push_str("permerror");
1513+ err.as_auth_result(header);
1514+ }
1515+ DMARCResult::TempError(err) => {
1516+ header.push_str("temperror");
1517+ err.as_auth_result(header);
1518+ }
1519+ DMARCResult::None => header.push_str("none"),
1520+ }
1521+ }
1522+ }
1523+
1524 impl AsAuthResult for DKIMResult {
1525 fn as_auth_result(&self, header: &mut String) {
1526 match &self {
1527 @@ -407,6 +421,7 @@ mod test {
1528 auth_results = auth_results.with_spf_result(
1529 &SPFOutput {
1530 result,
1531+ domain: "".to_string(),
1532 report: None,
1533 explanation: None,
1534 },
1535 @@ -417,6 +432,7 @@ mod test {
1536 let received_spf = ReceivedSPF::new(
1537 &SPFOutput {
1538 result,
1539+ domain: "".to_string(),
1540 report: None,
1541 explanation: None,
1542 },
1543 @@ -436,7 +452,8 @@ mod test {
1544 (
1545 "dmarc=pass header.from=example.org policy.dmarc=none",
1546 DMARCOutput {
1547- result: DMARCResult::Pass,
1548+ spf_result: DMARCResult::Pass,
1549+ dkim_result: DMARCResult::None,
1550 domain: "example.org".to_string(),
1551 policy: Policy::None,
1552 record: None,
1553 @@ -445,7 +462,8 @@ mod test {
1554 (
1555 "dmarc=fail (dmarc not aligned) header.from=example.com policy.dmarc=quarantine",
1556 DMARCOutput {
1557- result: DMARCResult::Fail(Error::DMARCNotAligned),
1558+ dkim_result: DMARCResult::Fail(Error::DMARCNotAligned),
1559+ spf_result: DMARCResult::None,
1560 domain: "example.com".to_string(),
1561 policy: Policy::Quarantine,
1562 record: None,
1563 diff --git a/src/dmarc/mod.rs b/src/dmarc/mod.rs
1564index 7e275d3..5723446 100644
1565--- a/src/dmarc/mod.rs
1566+++ b/src/dmarc/mod.rs
1567 @@ -7,20 +7,20 @@ pub mod verify;
1568
1569 #[derive(Debug, Clone, PartialEq, Eq)]
1570 pub struct DMARC {
1571- v: Version,
1572- adkim: Alignment,
1573- aspf: Alignment,
1574- fo: Report,
1575- np: Policy,
1576- p: Policy,
1577- psd: Psd,
1578- pct: u8,
1579- rf: u8,
1580- ri: u32,
1581- rua: Vec<URI>,
1582- ruf: Vec<URI>,
1583- sp: Policy,
1584- t: bool,
1585+ pub(crate) v: Version,
1586+ pub(crate) adkim: Alignment,
1587+ pub(crate) aspf: Alignment,
1588+ pub(crate) fo: Report,
1589+ pub(crate) np: Policy,
1590+ pub(crate) p: Policy,
1591+ pub(crate) psd: Psd,
1592+ pub(crate) pct: u8,
1593+ pub(crate) rf: u8,
1594+ pub(crate) ri: u32,
1595+ pub(crate) rua: Vec<URI>,
1596+ pub(crate) ruf: Vec<URI>,
1597+ pub(crate) sp: Policy,
1598+ pub(crate) t: bool,
1599 }
1600
1601 #[derive(Debug, Clone, PartialEq, Eq)]
1602 @@ -95,10 +95,11 @@ impl From<Error> for DMARCResult {
1603 impl Default for DMARCOutput {
1604 fn default() -> Self {
1605 Self {
1606- result: DMARCResult::None,
1607 domain: String::new(),
1608 policy: Policy::None,
1609 record: None,
1610+ spf_result: DMARCResult::None,
1611+ dkim_result: DMARCResult::None,
1612 }
1613 }
1614 }
1615 @@ -109,13 +110,13 @@ impl DMARCOutput {
1616 self
1617 }
1618
1619- pub(crate) fn with_result(mut self, result: DMARCResult) -> Self {
1620- self.result = result;
1621+ pub(crate) fn with_spf_result(mut self, result: DMARCResult) -> Self {
1622+ self.spf_result = result;
1623 self
1624 }
1625
1626- pub(crate) fn with_policy(mut self, policy: Policy) -> Self {
1627- self.policy = policy;
1628+ pub(crate) fn with_dkim_result(mut self, result: DMARCResult) -> Self {
1629+ self.dkim_result = result;
1630 self
1631 }
1632
1633 @@ -127,14 +128,10 @@ impl DMARCOutput {
1634
1635 impl Display for Policy {
1636 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1637- write!(
1638- f,
1639- "{}",
1640- match self {
1641- Policy::Quarantine => "quarantine",
1642- Policy::Reject => "reject",
1643- Policy::None | Policy::Unspecified => "none",
1644- }
1645- )
1646+ f.write_str(match self {
1647+ Policy::Quarantine => "quarantine",
1648+ Policy::Reject => "reject",
1649+ Policy::None | Policy::Unspecified => "none",
1650+ })
1651 }
1652 }
1653 diff --git a/src/dmarc/verify.rs b/src/dmarc/verify.rs
1654index 46a81e2..50d5ccf 100644
1655--- a/src/dmarc/verify.rs
1656+++ b/src/dmarc/verify.rs
1657 @@ -29,10 +29,8 @@ impl Resolver {
1658 }
1659 }
1660
1661- if from_domain.is_empty()
1662- || (spf_output.result != SPFResult::Pass
1663- && !dkim_output.iter().any(|o| o.result == DKIMResult::Pass))
1664- {
1665+ let has_dkim_pass = dkim_output.iter().any(|o| o.result == DKIMResult::Pass);
1666+ if from_domain.is_empty() || (spf_output.result != SPFResult::Pass && !has_dkim_pass) {
1667 // No domain found or no mechanism passed, skip DMARC.
1668 return DMARCOutput::default().with_domain(from_domain);
1669 }
1670 @@ -42,55 +40,60 @@ impl Resolver {
1671 Ok(Some(dmarc)) => dmarc,
1672 Ok(None) => return DMARCOutput::default().with_domain(from_domain),
1673 Err(err) => {
1674+ let err = DMARCResult::from(err);
1675 return DMARCOutput::default()
1676 .with_domain(from_domain)
1677- .with_result(err.into());
1678+ .with_dkim_result(err.clone())
1679+ .with_spf_result(err);
1680 }
1681 };
1682
1683- let output = DMARCOutput {
1684- result: DMARCResult::None,
1685+ let mut output = DMARCOutput {
1686+ spf_result: DMARCResult::None,
1687+ dkim_result: DMARCResult::None,
1688 domain: from_domain.to_string(),
1689 policy: dmarc.p,
1690 record: None,
1691 };
1692
1693- // Check SPF and DKIM strict alignment
1694- if (spf_output.result == SPFResult::Pass && mail_from_domain == from_domain)
1695- || (dkim_output.iter().any(|o| {
1696+ // Check SPF alignment
1697+ let from_subdomain = format!(".{}", from_domain);
1698+ if spf_output.result == SPFResult::Pass {
1699+ output.spf_result = if mail_from_domain == from_domain {
1700+ DMARCResult::Pass
1701+ } else if dmarc.aspf == Alignment::Relaxed
1702+ && mail_from_domain.ends_with(&from_subdomain)
1703+ || from_domain.ends_with(&format!(".{}", mail_from_domain))
1704+ {
1705+ output.policy = dmarc.sp;
1706+ DMARCResult::Pass
1707+ } else {
1708+ DMARCResult::Fail(Error::DMARCNotAligned)
1709+ };
1710+ }
1711+
1712+ // Check DKIM alignment
1713+ if has_dkim_pass {
1714+ output.dkim_result = if dkim_output.iter().any(|o| {
1715 o.result == DKIMResult::Pass && o.signature.as_ref().unwrap().d.eq(from_domain)
1716- }))
1717- {
1718- output.with_record(dmarc).with_result(DMARCResult::Pass)
1719- } else if dmarc.adkim == Alignment::Strict && dmarc.aspf == Alignment::Strict {
1720- output
1721- .with_record(dmarc)
1722- .with_result(DMARCResult::Fail(Error::DMARCNotAligned))
1723- } else {
1724- // Check SPF relaxed alignment
1725- let from_subdomain = format!(".{}", from_domain);
1726- if (spf_output.result == SPFResult::Pass
1727- && dmarc.aspf == Alignment::Relaxed
1728- && (mail_from_domain.ends_with(&from_subdomain)
1729- || from_domain.ends_with(&format!(".{}", mail_from_domain))))
1730- || (dmarc.adkim == Alignment::Relaxed
1731- && dkim_output.iter().any(|o| {
1732- o.result == DKIMResult::Pass
1733- && (o.signature.as_ref().unwrap().d.ends_with(&from_subdomain)
1734- || from_domain
1735- .ends_with(&format!(".{}", o.signature.as_ref().unwrap().d)))
1736- }))
1737+ }) {
1738+ DMARCResult::Pass
1739+ } else if dmarc.adkim == Alignment::Relaxed
1740+ && dkim_output.iter().any(|o| {
1741+ o.result == DKIMResult::Pass
1742+ && (o.signature.as_ref().unwrap().d.ends_with(&from_subdomain)
1743+ || from_domain
1744+ .ends_with(&format!(".{}", o.signature.as_ref().unwrap().d)))
1745+ })
1746 {
1747- output
1748- .with_policy(dmarc.sp)
1749- .with_record(dmarc)
1750- .with_result(DMARCResult::Pass)
1751+ output.policy = dmarc.sp;
1752+ DMARCResult::Pass
1753 } else {
1754- output
1755- .with_record(dmarc)
1756- .with_result(DMARCResult::Fail(Error::DMARCNotAligned))
1757- }
1758+ DMARCResult::Fail(Error::DMARCNotAligned)
1759+ };
1760 }
1761+
1762+ output.with_record(dmarc)
1763 }
1764
1765 async fn dmarc_tree_walk(&self, domain: &str) -> crate::Result<Option<Arc<DMARC>>> {
1766 diff --git a/src/lib.rs b/src/lib.rs
1767index e92eec7..f6bb344 100644
1768--- a/src/lib.rs
1769+++ b/src/lib.rs
1770 @@ -137,13 +137,15 @@ pub enum SPFResult {
1771 #[derive(Debug, PartialEq, Eq, Clone)]
1772 pub struct SPFOutput {
1773 result: SPFResult,
1774+ domain: String,
1775 report: Option<String>,
1776 explanation: Option<String>,
1777 }
1778
1779 #[derive(Debug, PartialEq, Eq, Clone)]
1780 pub struct DMARCOutput {
1781- result: DMARCResult,
1782+ spf_result: DMARCResult,
1783+ dkim_result: DMARCResult,
1784 domain: String,
1785 policy: dmarc::Policy,
1786 record: Option<Arc<DMARC>>,
1787 diff --git a/src/report/agg_parse.rs b/src/report/agg_parse.rs
1788deleted file mode 100644
1789index 144cd69..0000000
1790--- a/src/report/agg_parse.rs
1791+++ /dev/null
1792 @@ -1,452 +0,0 @@
1793- use std::io::BufRead;
1794- use std::str::FromStr;
1795-
1796- use quick_xml::events::{BytesStart, Event};
1797- use quick_xml::reader::Reader;
1798-
1799- use super::{
1800- Alignment, DateRange, Disposition, Extension, Feedback, PolicyPublished, Record, ReportMetadata,
1801- };
1802-
1803- impl Feedback {
1804- pub fn parse(report: &[u8]) -> Result<Self, String> {
1805- let mut version = 0;
1806- let mut report_metadata = None;
1807- let mut policy_published = None;
1808- let mut record = Vec::new();
1809- let mut extensions = Vec::new();
1810-
1811- let mut reader = Reader::from_reader(report);
1812- reader.trim_text(true);
1813-
1814- let mut buf = Vec::with_capacity(128);
1815- let mut found_feedback = false;
1816-
1817- while let Some(tag) = reader.next_tag(&mut buf)? {
1818- match tag.name().as_ref() {
1819- b"feedback" if !found_feedback => {
1820- found_feedback = true;
1821- }
1822- b"version" if found_feedback => {
1823- version = reader.next_value(&mut buf)?.unwrap_or(0);
1824- }
1825- b"report_metadata" if found_feedback => {
1826- report_metadata = ReportMetadata::parse(&mut reader, &mut buf)?.into();
1827- }
1828- b"policy_published" if found_feedback => {
1829- policy_published = PolicyPublished::parse(&mut reader, &mut buf)?.into();
1830- }
1831- b"record" if found_feedback => {
1832- record.push(Record::parse(&mut reader, &mut buf)?);
1833- }
1834- b"extensions" if found_feedback => {
1835- if let Some(extension) = Extension::parse(&mut reader, &mut buf)? {
1836- extensions.push(extension);
1837- }
1838- }
1839- b"" => {}
1840- other if !found_feedback => {
1841- return Err(format!(
1842- "Unexpected tag {} at position {}.",
1843- String::from_utf8_lossy(other),
1844- reader.buffer_position()
1845- ));
1846- }
1847- _ => (),
1848- }
1849- }
1850-
1851- Ok(Feedback {
1852- version,
1853- report_metadata: report_metadata.ok_or("Missing feedback/report_metadata tag.")?,
1854- policy_published: policy_published.ok_or("Missing feedback/policy_published tag.")?,
1855- record,
1856- extensions,
1857- })
1858- }
1859- }
1860-
1861- impl ReportMetadata {
1862- pub(crate) fn parse<R: BufRead>(
1863- reader: &mut Reader<R>,
1864- buf: &mut Vec<u8>,
1865- ) -> Result<Self, String> {
1866- let mut org_name = String::new();
1867- let mut email = String::new();
1868- let mut extra_contact_info = None;
1869- let mut report_id = String::new();
1870- let mut date_range = None;
1871- let mut error = Vec::new();
1872-
1873- while let Some(tag) = reader.next_tag(buf)? {
1874- match tag.name().as_ref() {
1875- b"org_name" => {
1876- org_name = reader.next_value::<String>(buf)?.unwrap_or_default();
1877- }
1878- b"email" => {
1879- email = reader.next_value::<String>(buf)?.unwrap_or_default();
1880- }
1881- b"extra_contact_info" => {
1882- extra_contact_info = reader.next_value::<String>(buf)?;
1883- }
1884- b"report_id" => {
1885- report_id = reader.next_value::<String>(buf)?.unwrap_or_default();
1886- }
1887- b"date_range" => {
1888- date_range = DateRange::parse(reader, buf)?.into();
1889- }
1890- b"error" => {
1891- if let Some(err) = reader.next_value::<String>(buf)? {
1892- error.push(err);
1893- }
1894- }
1895- b"" => (),
1896- _ => {
1897- reader.skip_tag(buf)?;
1898- }
1899- }
1900- }
1901-
1902- Ok(ReportMetadata {
1903- org_name,
1904- email,
1905- extra_contact_info,
1906- report_id,
1907- date_range: date_range.unwrap_or_default(),
1908- error,
1909- })
1910- }
1911- }
1912-
1913- impl DateRange {
1914- pub(crate) fn parse<R: BufRead>(
1915- reader: &mut Reader<R>,
1916- buf: &mut Vec<u8>,
1917- ) -> Result<Self, String> {
1918- let mut begin = 0;
1919- let mut end = 0;
1920-
1921- while let Some(tag) = reader.next_tag(buf)? {
1922- match tag.name().as_ref() {
1923- b"begin" => {
1924- begin = reader.next_value(buf)?.unwrap_or_default();
1925- }
1926- b"end" => {
1927- end = reader.next_value(buf)?.unwrap_or_default();
1928- }
1929- b"" => (),
1930- _ => {
1931- reader.skip_tag(buf)?;
1932- }
1933- }
1934- }
1935-
1936- Ok(DateRange { begin, end })
1937- }
1938- }
1939-
1940- impl PolicyPublished {
1941- pub(crate) fn parse<R: BufRead>(
1942- reader: &mut Reader<R>,
1943- buf: &mut Vec<u8>,
1944- ) -> Result<Self, String> {
1945- let mut domain = String::new();
1946- let mut version_published = None;
1947- let mut adkim = Alignment::Unspecified;
1948- let mut aspf = Alignment::Unspecified;
1949- let mut p = Disposition::Unspecified;
1950- let mut sp = Disposition::Unspecified;
1951- let mut testing = false;
1952- let mut fo = None;
1953-
1954- while let Some(tag) = reader.next_tag(buf)? {
1955- match tag.name().as_ref() {
1956- b"domain" => {
1957- domain = reader.next_value::<String>(buf)?.unwrap_or_default();
1958- }
1959- b"version_published" => {
1960- version_published = reader.next_value(buf)?;
1961- }
1962- b"adkim" => {
1963- adkim = reader.next_value(buf)?.unwrap_or(Alignment::Unspecified);
1964- }
1965- b"aspf" => {
1966- aspf = reader.next_value(buf)?.unwrap_or(Alignment::Unspecified);
1967- }
1968- b"p" => {
1969- p = reader.next_value(buf)?.unwrap_or(Disposition::Unspecified);
1970- }
1971- b"sp" => {
1972- sp = reader.next_value(buf)?.unwrap_or(Disposition::Unspecified);
1973- }
1974- b"testing" => {
1975- testing = reader
1976- .next_value::<String>(buf)?
1977- .map_or(false, |s| s.eq_ignore_ascii_case("y"));
1978- }
1979- b"fo" => {
1980- fo = reader.next_value::<String>(buf)?;
1981- }
1982- b"" => (),
1983- _ => {
1984- reader.skip_tag(buf)?;
1985- }
1986- }
1987- }
1988-
1989- Ok(PolicyPublished {
1990- domain,
1991- version_published,
1992- adkim,
1993- aspf,
1994- p,
1995- sp,
1996- testing,
1997- fo,
1998- })
1999- }
2000- }
2001-
2002- impl Extension {
2003- pub(crate) fn parse<R: BufRead>(
2004- reader: &mut Reader<R>,
2005- buf: &mut Vec<u8>,
2006- ) -> Result<Option<Self>, String> {
2007- let mut name = String::new();
2008- let mut definition = String::new();
2009- let mut extension = None;
2010-
2011- while let Some(tag) = reader.next_tag(buf)? {
2012- match tag.name().as_ref() {
2013- b"extension" => {
2014- if let Ok(Some(attr)) = tag.try_get_attribute("name") {
2015- if let Ok(attr) = attr.unescape_value() {
2016- name = attr.to_string();
2017- }
2018- }
2019- if let Ok(Some(attr)) = tag.try_get_attribute("definition") {
2020- if let Ok(attr) = attr.unescape_value() {
2021- definition = attr.to_string();
2022- }
2023- }
2024-
2025- extension = reader.next_value(buf)?;
2026- }
2027- b"" => (),
2028- _ => {
2029- reader.skip_tag(buf)?;
2030- }
2031- }
2032- }
2033-
2034- Ok(if !name.is_empty() {
2035- Some(Extension {
2036- extension,
2037- name,
2038- definition,
2039- })
2040- } else {
2041- None
2042- })
2043- }
2044- }
2045-
2046- impl Record {
2047- pub(crate) fn parse<R: BufRead>(
2048- reader: &mut Reader<R>,
2049- buf: &mut Vec<u8>,
2050- ) -> Result<Self, String> {
2051- let mut begin = 0;
2052- let mut end = 0;
2053-
2054- while let Some(tag) = reader.next_tag(buf)? {
2055- match tag.name().as_ref() {
2056- b"begin" => {
2057- begin = reader.next_value(buf)?.unwrap_or_default();
2058- }
2059- b"end" => {
2060- end = reader.next_value(buf)?.unwrap_or_default();
2061- }
2062- b"" => (),
2063- _ => {
2064- reader.skip_tag(buf)?;
2065- }
2066- }
2067- }
2068-
2069- Ok(Record {
2070- row: todo!(),
2071- identifiers: todo!(),
2072- auth_results: todo!(),
2073- })
2074- }
2075- }
2076-
2077- impl FromStr for Disposition {
2078- type Err = ();
2079-
2080- fn from_str(s: &str) -> Result<Self, Self::Err> {
2081- Ok(match s.as_bytes() {
2082- b"none" => Disposition::None,
2083- b"quarantine" => Disposition::Quarantine,
2084- b"reject" => Disposition::Reject,
2085- _ => Disposition::Unspecified,
2086- })
2087- }
2088- }
2089-
2090- impl FromStr for Alignment {
2091- type Err = ();
2092-
2093- fn from_str(s: &str) -> Result<Self, Self::Err> {
2094- Ok(match s.as_bytes().first() {
2095- Some(b'r') => Alignment::Relaxed,
2096- Some(b's') => Alignment::Simple,
2097- _ => Alignment::Unspecified,
2098- })
2099- }
2100- }
2101-
2102- trait ReaderHelper {
2103- fn next_tag<'x>(&mut self, buf: &'x mut Vec<u8>) -> Result<Option<BytesStart<'x>>, String>;
2104- fn next_value<T: FromStr>(&mut self, buf: &mut Vec<u8>) -> Result<Option<T>, String>;
2105- fn skip_tag(&mut self, buf: &mut Vec<u8>) -> Result<(), String>;
2106- }
2107-
2108- impl<R: BufRead> ReaderHelper for Reader<R> {
2109- fn next_tag<'x>(&mut self, buf: &'x mut Vec<u8>) -> Result<Option<BytesStart<'x>>, String> {
2110- match self.read_event_into(buf) {
2111- Ok(Event::Start(e)) => Ok(Some(e)),
2112- Ok(Event::End(_)) | Ok(Event::Eof) => Ok(None),
2113- Err(e) => Err(format!(
2114- "Error at position {}: {:?}",
2115- self.buffer_position(),
2116- e
2117- )),
2118- _ => Ok(Some(BytesStart::new(""))),
2119- }
2120- }
2121-
2122- fn next_value<T: FromStr>(&mut self, buf: &mut Vec<u8>) -> Result<Option<T>, String> {
2123- let mut value = None;
2124- loop {
2125- match self.read_event_into(buf) {
2126- Ok(Event::Text(e)) => {
2127- let value_ = e.unescape().map_err(|err| {
2128- format!(
2129- "Failed to unescape value at {}: {}",
2130- self.buffer_position(),
2131- err
2132- )
2133- })?;
2134-
2135- value = T::from_str(value_.as_ref())
2136- .map_err(|_| {
2137- format!(
2138- "Failed to parse value {:?} at {}.",
2139- value_,
2140- self.buffer_position(),
2141- )
2142- })?
2143- .into();
2144- }
2145- Ok(Event::End(_)) => {
2146- break;
2147- }
2148- Ok(Event::Start(e)) => {
2149- return Err(format!(
2150- "Expected value, found unexpected tag {} at position {}.",
2151- String::from_utf8_lossy(e.name().as_ref()),
2152- self.buffer_position()
2153- ));
2154- }
2155- Ok(Event::Eof) => {
2156- return Err(format!(
2157- "Expected value, found unexpected EOF at position {}.",
2158- self.buffer_position()
2159- ))
2160- }
2161- _ => (),
2162- }
2163- }
2164-
2165- Ok(value)
2166- }
2167-
2168- fn skip_tag(&mut self, buf: &mut Vec<u8>) -> Result<(), String> {
2169- let mut tag_count = 0;
2170- loop {
2171- match self.read_event_into(buf) {
2172- Ok(Event::End(_)) => {
2173- if tag_count == 0 {
2174- break;
2175- } else {
2176- tag_count -= 1;
2177- }
2178- }
2179- Ok(Event::Start(_)) => {
2180- tag_count += 1;
2181- }
2182- Ok(Event::Eof) => {
2183- return Err(format!(
2184- "Expected value, found unexpected EOF at position {}.",
2185- self.buffer_position()
2186- ))
2187- }
2188- _ => (),
2189- }
2190- }
2191- Ok(())
2192- }
2193- }
2194-
2195- #[cfg(test)]
2196- mod test {
2197- use std::{fs, path::PathBuf};
2198-
2199- #[test]
2200- fn dmarc_aggregate_report_parse() {
2201- use quick_xml::events::Event;
2202- use quick_xml::reader::Reader;
2203-
2204- let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2205- test_dir.push("resources");
2206- test_dir.push("dmarc-agg-report");
2207-
2208- for file_name in fs::read_dir(&test_dir).unwrap() {
2209- let file_name = file_name.unwrap().path();
2210- println!("file {}", file_name.to_str().unwrap());
2211- let mut reader = Reader::from_file(file_name).unwrap();
2212- reader.trim_text(true);
2213-
2214- let mut count = 0;
2215- let mut txt = Vec::new();
2216- let mut buf = Vec::new();
2217-
2218- // The `Reader` does not implement `Iterator` because it outputs borrowed data (`Cow`s)
2219- loop {
2220- // NOTE: this is the generic case when we don't know about the input BufRead.
2221- // when the input is a &str or a &[u8], we don't actually need to use another
2222- // buffer, we could directly call `reader.read_event()`
2223- match reader.read_event_into(&mut buf) {
2224- Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e),
2225- // exits the loop when reaching end of file
2226- Ok(Event::Eof) => break,
2227-
2228- Ok(Event::Start(e)) => {
2229- println!("start: {}", std::str::from_utf8(e.name().as_ref()).unwrap())
2230- }
2231- Ok(Event::End(e)) => {
2232- println!("end: {}", std::str::from_utf8(e.name().as_ref()).unwrap())
2233- }
2234- Ok(Event::Text(e)) => txt.push(e.unescape().unwrap().into_owned()),
2235-
2236- // There are several other `Event`s we do not consider here
2237- _ => (),
2238- }
2239- // if we don't keep a borrow elsewhere, we can clear the buffer to keep memory usage low
2240- buf.clear();
2241- }
2242- }
2243- }
2244- }
2245 diff --git a/src/report/feedback/generate.rs b/src/report/feedback/generate.rs
2246new file mode 100644
2247index 0000000..8381b74
2248--- /dev/null
2249+++ b/src/report/feedback/generate.rs
2250 @@ -0,0 +1,515 @@
2251+ use flate2::{write::GzEncoder, Compression};
2252+ use mail_builder::{headers::HeaderType, MessageBuilder};
2253+
2254+ use crate::report::{
2255+ ActionDisposition, Alignment, AuthResult, DKIMAuthResult, DKIMResult, DMARCResult, DateRange,
2256+ Disposition, Feedback, Identifier, PolicyEvaluated, PolicyOverride, PolicyOverrideReason,
2257+ PolicyPublished, Record, ReportMetadata, Row, SPFAuthResult, SPFDomainScope, SPFResult,
2258+ };
2259+
2260+ use std::{
2261+ borrow::Cow,
2262+ fmt::{Display, Formatter, Write},
2263+ };
2264+
2265+ impl Feedback {
2266+ pub fn write_rfc5322<'x>(
2267+ &self,
2268+ receiver_domain: &'x str,
2269+ submitter: &'x str,
2270+ from: &'x str,
2271+ to: &'x str,
2272+ writer: impl std::io::Write,
2273+ ) -> std::io::Result<()> {
2274+ // Compress XML report
2275+ let xml = self.as_xnl();
2276+ let mut e = GzEncoder::new(Vec::with_capacity(xml.len()), Compression::default());
2277+ std::io::Write::write_all(&mut e, xml.as_bytes())?;
2278+ let compressed_bytes = e.finish()?;
2279+
2280+ MessageBuilder::new()
2281+ .header("From", HeaderType::Text(from.into()))
2282+ .header("To", HeaderType::Text(to.into()))
2283+ .header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
2284+ .subject(format!(
2285+ "Report Domain: {} Submitter: {} Report-ID: <{}>",
2286+ self.domain(),
2287+ submitter,
2288+ self.report_id()
2289+ ))
2290+ .text_body(format!(
2291+ concat!(
2292+ "DMARC aggregate report from {}\r\n\r\n",
2293+ "Report Domain: {}\r\n",
2294+ "Submitter: {}\r\n",
2295+ "Report-ID: {}\r\n",
2296+ ),
2297+ receiver_domain,
2298+ self.domain(),
2299+ submitter,
2300+ self.report_id()
2301+ ))
2302+ .binary_attachment(
2303+ "application/gzip",
2304+ format!(
2305+ "{}!{}!{}!{}.xml.gz",
2306+ receiver_domain,
2307+ self.domain(),
2308+ self.date_range_begin(),
2309+ self.date_range_end()
2310+ ),
2311+ compressed_bytes,
2312+ )
2313+ .write_to(writer)
2314+ }
2315+
2316+ pub fn as_rfc5322<'x>(
2317+ &self,
2318+ receiver_domain: &'x str,
2319+ submitter: &'x str,
2320+ from: &'x str,
2321+ to: &'x str,
2322+ ) -> std::io::Result<String> {
2323+ let mut buf = Vec::new();
2324+ self.write_rfc5322(receiver_domain, submitter, from, to, &mut buf)?;
2325+ String::from_utf8(buf).map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
2326+ }
2327+
2328+ pub fn as_xnl(&self) -> String {
2329+ let mut xml = String::with_capacity(128);
2330+ writeln!(&mut xml, "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>").ok();
2331+ writeln!(&mut xml, "<feedback>").ok();
2332+ if self.version != 0.0 {
2333+ writeln!(&mut xml, "\t<version>{}</version>", self.version).ok();
2334+ }
2335+ self.report_metadata.as_xml(&mut xml);
2336+ self.policy_published.as_xml(&mut xml);
2337+ for record in &self.record {
2338+ record.as_xml(&mut xml);
2339+ }
2340+ writeln!(&mut xml, "</feedback>").ok();
2341+ xml
2342+ }
2343+ }
2344+
2345+ impl ReportMetadata {
2346+ pub(crate) fn as_xml(&self, xml: &mut String) {
2347+ writeln!(xml, "\t<report_metadata>").ok();
2348+ writeln!(
2349+ xml,
2350+ "\t\t<org_name>{}</org_name>",
2351+ escape_xml(&self.org_name)
2352+ )
2353+ .ok();
2354+ writeln!(xml, "\t\t<email>{}</email>", escape_xml(&self.email)).ok();
2355+ if let Some(eci) = &self.extra_contact_info {
2356+ writeln!(
2357+ xml,
2358+ "\t\t<extra_contact_info>{}</extra_contact_info>",
2359+ escape_xml(eci)
2360+ )
2361+ .ok();
2362+ }
2363+ writeln!(
2364+ xml,
2365+ "\t\t<report_id>{}</report_id>",
2366+ escape_xml(&self.report_id)
2367+ )
2368+ .ok();
2369+ self.date_range.as_xml(xml);
2370+ for error in &self.error {
2371+ writeln!(xml, "\t\t<error>{}</error>", escape_xml(error)).ok();
2372+ }
2373+ writeln!(xml, "\t</report_metadata>").ok();
2374+ }
2375+ }
2376+
2377+ impl PolicyPublished {
2378+ pub(crate) fn as_xml(&self, xml: &mut String) {
2379+ writeln!(xml, "\t<policy_published>").ok();
2380+ writeln!(xml, "\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
2381+ if let Some(vp) = &self.version_published {
2382+ writeln!(xml, "\t\t<version_published>{}</version_published>", vp).ok();
2383+ }
2384+ writeln!(xml, "\t\t<adkim>{}</adkim>", &self.adkim).ok();
2385+ writeln!(xml, "\t\t<aspf>{}</aspf>", &self.aspf).ok();
2386+ writeln!(xml, "\t\t<p>{}</p>", &self.p).ok();
2387+ writeln!(xml, "\t\t<sp>{}</sp>", &self.sp).ok();
2388+ if self.testing {
2389+ writeln!(xml, "\t\t<testing>y</testing>").ok();
2390+ }
2391+ if let Some(fo) = &self.fo {
2392+ writeln!(xml, "\t\t<fo>{}</fo>", escape_xml(fo)).ok();
2393+ }
2394+ writeln!(xml, "\t</policy_published>").ok();
2395+ }
2396+ }
2397+
2398+ impl DateRange {
2399+ pub(crate) fn as_xml(&self, xml: &mut String) {
2400+ writeln!(xml, "\t\t<date_range>").ok();
2401+ writeln!(xml, "\t\t\t<begin>{}</begin>", self.begin).ok();
2402+ writeln!(xml, "\t\t\t<end>{}</end>", self.end).ok();
2403+ writeln!(xml, "\t\t</date_range>").ok();
2404+ }
2405+ }
2406+
2407+ impl Record {
2408+ pub(crate) fn as_xml(&self, xml: &mut String) {
2409+ writeln!(xml, "\t<record>").ok();
2410+ self.row.as_xml(xml);
2411+ self.identifiers.as_xml(xml);
2412+ self.auth_results.as_xml(xml);
2413+ writeln!(xml, "\t</record>").ok();
2414+ }
2415+ }
2416+
2417+ impl Row {
2418+ pub(crate) fn as_xml(&self, xml: &mut String) {
2419+ writeln!(xml, "\t\t<row>").ok();
2420+ writeln!(xml, "\t\t\t<source_ip>{}</source_ip>", self.source_ip).ok();
2421+ writeln!(xml, "\t\t\t<count>{}</count>", self.count).ok();
2422+ self.policy_evaluated.as_xml(xml);
2423+ writeln!(xml, "\t\t</row>").ok();
2424+ }
2425+ }
2426+
2427+ impl PolicyEvaluated {
2428+ pub(crate) fn as_xml(&self, xml: &mut String) {
2429+ writeln!(xml, "\t\t\t<policy_evaluated>").ok();
2430+ writeln!(
2431+ xml,
2432+ "\t\t\t\t<disposition>{}</disposition>",
2433+ self.disposition
2434+ )
2435+ .ok();
2436+ writeln!(xml, "\t\t\t\t<dkim>{}</dkim>", self.dkim).ok();
2437+ writeln!(xml, "\t\t\t\t<spf>{}</spf>", self.spf).ok();
2438+ for reason in &self.reason {
2439+ reason.as_xml(xml);
2440+ }
2441+ writeln!(xml, "\t\t\t</policy_evaluated>").ok();
2442+ }
2443+ }
2444+
2445+ impl PolicyOverrideReason {
2446+ pub(crate) fn as_xml(&self, xml: &mut String) {
2447+ writeln!(xml, "\t\t\t\t<reason>").ok();
2448+ writeln!(xml, "\t\t\t\t\t<type>{}</type>", self.type_).ok();
2449+ if let Some(comment) = &self.comment {
2450+ writeln!(xml, "\t\t\t\t\t<comment>{}</comment>", escape_xml(comment)).ok();
2451+ }
2452+ writeln!(xml, "\t\t\t\t</reason>").ok();
2453+ }
2454+ }
2455+
2456+ impl Identifier {
2457+ pub(crate) fn as_xml(&self, xml: &mut String) {
2458+ writeln!(xml, "\t\t<identifiers>").ok();
2459+ if let Some(envelope_to) = &self.envelope_to {
2460+ writeln!(
2461+ xml,
2462+ "\t\t\t<envelope_to>{}</envelope_to>",
2463+ escape_xml(envelope_to)
2464+ )
2465+ .ok();
2466+ }
2467+ writeln!(
2468+ xml,
2469+ "\t\t\t<envelope_from>{}</envelope_from>",
2470+ escape_xml(&self.envelope_from)
2471+ )
2472+ .ok();
2473+ writeln!(
2474+ xml,
2475+ "\t\t\t<header_from>{}</header_from>",
2476+ escape_xml(&self.header_from)
2477+ )
2478+ .ok();
2479+ writeln!(xml, "\t\t</identifiers>").ok();
2480+ }
2481+ }
2482+
2483+ impl AuthResult {
2484+ pub(crate) fn as_xml(&self, xml: &mut String) {
2485+ writeln!(xml, "\t\t<auth_results>").ok();
2486+ for dkim in &self.dkim {
2487+ dkim.as_xml(xml);
2488+ }
2489+ for spf in &self.spf {
2490+ spf.as_xml(xml);
2491+ }
2492+ writeln!(xml, "\t\t</auth_results>").ok();
2493+ }
2494+ }
2495+
2496+ impl DKIMAuthResult {
2497+ pub(crate) fn as_xml(&self, xml: &mut String) {
2498+ writeln!(xml, "\t\t\t<dkim>").ok();
2499+ writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
2500+ writeln!(
2501+ xml,
2502+ "\t\t\t\t<selector>{}</selector>",
2503+ escape_xml(&self.selector)
2504+ )
2505+ .ok();
2506+ writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
2507+ if let Some(result) = &self.human_result {
2508+ writeln!(
2509+ xml,
2510+ "\t\t\t\t<human_result>{}</human_result>",
2511+ escape_xml(result)
2512+ )
2513+ .ok();
2514+ }
2515+ writeln!(xml, "\t\t\t</dkim>").ok();
2516+ }
2517+ }
2518+
2519+ impl SPFAuthResult {
2520+ pub(crate) fn as_xml(&self, xml: &mut String) {
2521+ writeln!(xml, "\t\t\t<spf>").ok();
2522+ writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
2523+ writeln!(xml, "\t\t\t\t<scope>{}</scope>", self.scope).ok();
2524+ writeln!(xml, "\t\t\t\t<result>{}</result>", self.result).ok();
2525+ if let Some(result) = &self.human_result {
2526+ writeln!(
2527+ xml,
2528+ "\t\t\t\t<human_result>{}</human_result>",
2529+ escape_xml(result)
2530+ )
2531+ .ok();
2532+ }
2533+ writeln!(xml, "\t\t\t</spf>").ok();
2534+ }
2535+ }
2536+
2537+ impl Display for Alignment {
2538+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2539+ f.write_str(match self {
2540+ Alignment::Strict => "s",
2541+ _ => "r",
2542+ })
2543+ }
2544+ }
2545+
2546+ impl Display for Disposition {
2547+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2548+ f.write_str(match self {
2549+ Disposition::None | Disposition::Unspecified => "none",
2550+ Disposition::Quarantine => "quarantine",
2551+ Disposition::Reject => "reject",
2552+ })
2553+ }
2554+ }
2555+
2556+ impl Display for ActionDisposition {
2557+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2558+ f.write_str(match self {
2559+ ActionDisposition::None | ActionDisposition::Unspecified => "none",
2560+ ActionDisposition::Pass => "pass",
2561+ ActionDisposition::Quarantine => "quarantine",
2562+ ActionDisposition::Reject => "reject",
2563+ })
2564+ }
2565+ }
2566+
2567+ impl Display for DMARCResult {
2568+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2569+ f.write_str(match self {
2570+ DMARCResult::Pass => "pass",
2571+ DMARCResult::Fail => "fail",
2572+ DMARCResult::Unspecified => "",
2573+ })
2574+ }
2575+ }
2576+
2577+ impl Display for PolicyOverride {
2578+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2579+ f.write_str(match self {
2580+ PolicyOverride::Forwarded => "forwarded",
2581+ PolicyOverride::SampledOut => "sampled_out",
2582+ PolicyOverride::TrustedForwarder => "trusted_forwarder",
2583+ PolicyOverride::MailingList => "mailing_list",
2584+ PolicyOverride::LocalPolicy => "local_policy",
2585+ PolicyOverride::Other => "other",
2586+ })
2587+ }
2588+ }
2589+
2590+ impl Display for DKIMResult {
2591+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2592+ f.write_str(match self {
2593+ DKIMResult::None => "none",
2594+ DKIMResult::Pass => "pass",
2595+ DKIMResult::Fail => "fail",
2596+ DKIMResult::Policy => "policy",
2597+ DKIMResult::Neutral => "neutral",
2598+ DKIMResult::TempError => "temperror",
2599+ DKIMResult::PermError => "permerror",
2600+ })
2601+ }
2602+ }
2603+
2604+ impl Display for SPFDomainScope {
2605+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2606+ f.write_str(match self {
2607+ SPFDomainScope::Helo => "helo",
2608+ SPFDomainScope::MailFrom | SPFDomainScope::Unspecified => "mfrom",
2609+ })
2610+ }
2611+ }
2612+
2613+ impl Display for SPFResult {
2614+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2615+ f.write_str(match self {
2616+ SPFResult::None => "none",
2617+ SPFResult::Neutral => "neutral",
2618+ SPFResult::Pass => "pass",
2619+ SPFResult::Fail => "fail",
2620+ SPFResult::SoftFail => "softfail",
2621+ SPFResult::TempError => "temperror",
2622+ SPFResult::PermError => "permerror",
2623+ })
2624+ }
2625+ }
2626+
2627+ fn escape_xml(text: &str) -> Cow<'_, str> {
2628+ for ch in text.as_bytes() {
2629+ if [b'"', b'\'', b'<', b'>', b'&'].contains(ch) {
2630+ let mut escaped = String::with_capacity(text.len());
2631+ for ch in text.chars() {
2632+ match ch {
2633+ '"' => {
2634+ escaped.push_str("&quot;");
2635+ }
2636+ '\'' => {
2637+ escaped.push_str("&apos;");
2638+ }
2639+ '<' => {
2640+ escaped.push_str("&lt;");
2641+ }
2642+ '>' => {
2643+ escaped.push_str("&gt;");
2644+ }
2645+ '&' => {
2646+ escaped.push_str("&amp;");
2647+ }
2648+ _ => {
2649+ escaped.push(ch);
2650+ }
2651+ }
2652+ }
2653+
2654+ return escaped.into();
2655+ }
2656+ }
2657+ text.into()
2658+ }
2659+
2660+ #[cfg(test)]
2661+ mod test {
2662+ use crate::report::{
2663+ ActionDisposition, Alignment, DKIMAuthResult, DKIMResult, DMARCResult, Disposition,
2664+ Feedback, PolicyOverride, PolicyOverrideReason, Record, SPFAuthResult, SPFDomainScope,
2665+ SPFResult,
2666+ };
2667+
2668+ #[test]
2669+ fn dmarc_aggregate_report_generate() {
2670+ let feedback = Feedback::new()
2671+ .with_version(2.0)
2672+ .with_org_name("Initech Industries Incorporated")
2673+ .with_email("dmarc@initech.net")
2674+ .with_extra_contact_info("XMPP:dmarc@initech.net")
2675+ .with_report_id("abc-123")
2676+ .with_date_range_begin(12345)
2677+ .with_date_range_end(12346)
2678+ .with_error("Did not include TPS report cover.")
2679+ .with_domain("example.org")
2680+ .with_version_published(1.0)
2681+ .with_adkim(Alignment::Relaxed)
2682+ .with_aspf(Alignment::Strict)
2683+ .with_p(Disposition::Quarantine)
2684+ .with_sp(Disposition::Reject)
2685+ .with_testing(true)
2686+ .with_record(
2687+ Record::new()
2688+ .with_source_ip("192.168.1.2".parse().unwrap())
2689+ .with_count(3)
2690+ .with_action_disposition(ActionDisposition::Pass)
2691+ .with_dmarc_dkim_result(DMARCResult::Pass)
2692+ .with_dmarc_spf_result(DMARCResult::Fail)
2693+ .with_policy_override_reason(
2694+ PolicyOverrideReason::new(PolicyOverride::Forwarded)
2695+ .with_comment("it was forwarded"),
2696+ )
2697+ .with_policy_override_reason(
2698+ PolicyOverrideReason::new(PolicyOverride::MailingList)
2699+ .with_comment("sent from mailing list"),
2700+ )
2701+ .with_envelope_from("hello@example.org")
2702+ .with_envelope_to("other@example.org")
2703+ .with_header_from("bye@example.org")
2704+ .with_dkim_auth_result(
2705+ DKIMAuthResult::new()
2706+ .with_domain("test.org")
2707+ .with_selector("my-selector")
2708+ .with_result(DKIMResult::PermError)
2709+ .with_human_result("failed to parse record"),
2710+ )
2711+ .with_spf_auth_result(
2712+ SPFAuthResult::new()
2713+ .with_domain("test.org")
2714+ .with_scope(SPFDomainScope::Helo)
2715+ .with_result(SPFResult::SoftFail)
2716+ .with_human_result("dns timed out"),
2717+ ),
2718+ )
2719+ .with_record(
2720+ Record::new()
2721+ .with_source_ip("a:b:c::e:f".parse().unwrap())
2722+ .with_count(99)
2723+ .with_action_disposition(ActionDisposition::Reject)
2724+ .with_dmarc_dkim_result(DMARCResult::Fail)
2725+ .with_dmarc_spf_result(DMARCResult::Pass)
2726+ .with_policy_override_reason(
2727+ PolicyOverrideReason::new(PolicyOverride::LocalPolicy)
2728+ .with_comment("on the white list"),
2729+ )
2730+ .with_policy_override_reason(
2731+ PolicyOverrideReason::new(PolicyOverride::SampledOut)
2732+ .with_comment("it was sampled out"),
2733+ )
2734+ .with_envelope_from("hello2example.org")
2735+ .with_envelope_to("other2@example.org")
2736+ .with_header_from("bye2@example.org")
2737+ .with_dkim_auth_result(
2738+ DKIMAuthResult::new()
2739+ .with_domain("test2.org")
2740+ .with_selector("my-other-selector")
2741+ .with_result(DKIMResult::Neutral)
2742+ .with_human_result("something went wrong"),
2743+ )
2744+ .with_spf_auth_result(
2745+ SPFAuthResult::new()
2746+ .with_domain("test.org")
2747+ .with_scope(SPFDomainScope::MailFrom)
2748+ .with_result(SPFResult::None)
2749+ .with_human_result("no policy found"),
2750+ ),
2751+ );
2752+
2753+ let message = feedback
2754+ .as_rfc5322(
2755+ "initech.net",
2756+ "Initech Industries",
2757+ "noreply-dmarc@initech.net",
2758+ "dmarc-reports@example.org",
2759+ )
2760+ .unwrap();
2761+ let parsed_feedback = Feedback::parse_rfc5322(message.as_bytes()).unwrap();
2762+
2763+ assert_eq!(feedback, parsed_feedback);
2764+ }
2765+ }
2766 diff --git a/src/report/feedback/mod.rs b/src/report/feedback/mod.rs
2767new file mode 100644
2768index 0000000..6caf68a
2769--- /dev/null
2770+++ b/src/report/feedback/mod.rs
2771 @@ -0,0 +1,507 @@
2772+ pub mod generate;
2773+ pub mod parse;
2774+
2775+ use std::fmt::Write;
2776+ use std::net::IpAddr;
2777+
2778+ use crate::{
2779+ dmarc::DMARC,
2780+ report::{
2781+ ActionDisposition, Alignment, DKIMAuthResult, DKIMResult, DMARCResult, Disposition,
2782+ Feedback, PolicyOverride, PolicyOverrideReason, Record, SPFAuthResult, SPFDomainScope,
2783+ SPFResult,
2784+ },
2785+ ARCOutput, DKIMOutput, DMARCOutput, SPFOutput,
2786+ };
2787+
2788+ impl Feedback {
2789+ pub fn new() -> Self {
2790+ Self::default()
2791+ }
2792+
2793+ pub fn version(&self) -> f32 {
2794+ self.version
2795+ }
2796+
2797+ pub fn with_version(mut self, version: f32) -> Self {
2798+ self.version = version;
2799+ self
2800+ }
2801+
2802+ pub fn org_name(&self) -> &str {
2803+ &self.report_metadata.org_name
2804+ }
2805+
2806+ pub fn with_org_name(mut self, org_name: impl Into<String>) -> Self {
2807+ self.report_metadata.org_name = org_name.into();
2808+ self
2809+ }
2810+
2811+ pub fn email(&self) -> &str {
2812+ &self.report_metadata.email
2813+ }
2814+
2815+ pub fn with_email(mut self, email: impl Into<String>) -> Self {
2816+ self.report_metadata.email = email.into();
2817+ self
2818+ }
2819+
2820+ pub fn extra_contact_info(&self) -> Option<&str> {
2821+ self.report_metadata.extra_contact_info.as_deref()
2822+ }
2823+
2824+ pub fn with_extra_contact_info(mut self, extra_contact_info: impl Into<String>) -> Self {
2825+ self.report_metadata.extra_contact_info = Some(extra_contact_info.into());
2826+ self
2827+ }
2828+
2829+ pub fn report_id(&self) -> &str {
2830+ &self.report_metadata.report_id
2831+ }
2832+
2833+ pub fn with_report_id(mut self, report_id: impl Into<String>) -> Self {
2834+ self.report_metadata.report_id = report_id.into();
2835+ self
2836+ }
2837+
2838+ pub fn date_range_begin(&self) -> u64 {
2839+ self.report_metadata.date_range.begin
2840+ }
2841+
2842+ pub fn with_date_range_begin(mut self, date_range_begin: u64) -> Self {
2843+ self.report_metadata.date_range.begin = date_range_begin;
2844+ self
2845+ }
2846+
2847+ pub fn date_range_end(&self) -> u64 {
2848+ self.report_metadata.date_range.end
2849+ }
2850+
2851+ pub fn with_date_range_end(mut self, date_range_end: u64) -> Self {
2852+ self.report_metadata.date_range.end = date_range_end;
2853+ self
2854+ }
2855+
2856+ pub fn error(&self) -> &[String] {
2857+ &self.report_metadata.error
2858+ }
2859+
2860+ pub fn with_error(mut self, error: impl Into<String>) -> Self {
2861+ self.report_metadata.error.push(error.into());
2862+ self
2863+ }
2864+
2865+ pub fn domain(&self) -> &str {
2866+ &self.policy_published.domain
2867+ }
2868+
2869+ pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
2870+ self.policy_published.domain = domain.into();
2871+ self
2872+ }
2873+
2874+ pub fn fo(&self) -> Option<&str> {
2875+ self.policy_published.fo.as_deref()
2876+ }
2877+
2878+ pub fn with_fo(mut self, fo: impl Into<String>) -> Self {
2879+ self.policy_published.fo = Some(fo.into());
2880+ self
2881+ }
2882+
2883+ pub fn version_published(&self) -> Option<f32> {
2884+ self.policy_published.version_published
2885+ }
2886+
2887+ pub fn with_version_published(mut self, version_published: f32) -> Self {
2888+ self.policy_published.version_published = Some(version_published);
2889+ self
2890+ }
2891+
2892+ pub fn adkim(&self) -> Alignment {
2893+ self.policy_published.adkim
2894+ }
2895+
2896+ pub fn with_adkim(mut self, adkim: Alignment) -> Self {
2897+ self.policy_published.adkim = adkim;
2898+ self
2899+ }
2900+
2901+ pub fn aspf(&self) -> Alignment {
2902+ self.policy_published.aspf
2903+ }
2904+
2905+ pub fn with_aspf(mut self, aspf: Alignment) -> Self {
2906+ self.policy_published.aspf = aspf;
2907+ self
2908+ }
2909+
2910+ pub fn p(&self) -> Disposition {
2911+ self.policy_published.p
2912+ }
2913+
2914+ pub fn with_p(mut self, p: Disposition) -> Self {
2915+ self.policy_published.p = p;
2916+ self
2917+ }
2918+
2919+ pub fn sp(&self) -> Disposition {
2920+ self.policy_published.sp
2921+ }
2922+
2923+ pub fn with_sp(mut self, sp: Disposition) -> Self {
2924+ self.policy_published.sp = sp;
2925+ self
2926+ }
2927+
2928+ pub fn testing(&self) -> bool {
2929+ self.policy_published.testing
2930+ }
2931+
2932+ pub fn with_testing(mut self, testing: bool) -> Self {
2933+ self.policy_published.testing = testing;
2934+ self
2935+ }
2936+
2937+ pub fn records(&self) -> &[Record] {
2938+ &self.record
2939+ }
2940+
2941+ pub fn with_record(mut self, record: Record) -> Self {
2942+ self.record.push(record);
2943+ self
2944+ }
2945+
2946+ pub fn with_policy_published(mut self, dmarc: &DMARC) -> Self {
2947+ self.policy_published.adkim = (&dmarc.adkim).into();
2948+ self.policy_published.aspf = (&dmarc.aspf).into();
2949+ self.policy_published.p = (&dmarc.p).into();
2950+ self.policy_published.sp = (&dmarc.sp).into();
2951+ self.policy_published.testing = dmarc.t;
2952+ self.policy_published.fo = match &dmarc.fo {
2953+ crate::dmarc::Report::All => "0",
2954+ crate::dmarc::Report::Any => "1",
2955+ crate::dmarc::Report::Dkim => "d",
2956+ crate::dmarc::Report::Spf => "s",
2957+ crate::dmarc::Report::DkimSpf => "d:s",
2958+ }
2959+ .to_string()
2960+ .into();
2961+ self
2962+ }
2963+ }
2964+
2965+ impl Record {
2966+ pub fn new() -> Self {
2967+ Record::default()
2968+ }
2969+
2970+ pub fn with_dkim_output(mut self, dkim_output: &[DKIMOutput]) {
2971+ for dkim in dkim_output {
2972+ if let Some(signature) = &dkim.signature {
2973+ let (result, human_result) = match &dkim.result {
2974+ crate::DKIMResult::Pass => (DKIMResult::Pass, None),
2975+ crate::DKIMResult::Neutral(err) => {
2976+ (DKIMResult::Neutral, err.to_string().into())
2977+ }
2978+ crate::DKIMResult::Fail(err) => (DKIMResult::Fail, err.to_string().into()),
2979+ crate::DKIMResult::PermError(err) => {
2980+ (DKIMResult::PermError, err.to_string().into())
2981+ }
2982+ crate::DKIMResult::TempError(err) => {
2983+ (DKIMResult::TempError, err.to_string().into())
2984+ }
2985+ crate::DKIMResult::None => (DKIMResult::None, None),
2986+ };
2987+
2988+ self.auth_results.dkim.push(DKIMAuthResult {
2989+ domain: signature.d.to_string(),
2990+ selector: signature.s.to_string(),
2991+ result,
2992+ human_result,
2993+ });
2994+ }
2995+ }
2996+ }
2997+
2998+ pub fn with_spf_output(mut self, spf_output: &SPFOutput, scope: SPFDomainScope) {
2999+ self.auth_results.spf.push(SPFAuthResult {
3000+ domain: spf_output.domain.to_string(),
3001+ scope,
3002+ result: match spf_output.result {
3003+ crate::SPFResult::Pass => SPFResult::Pass,
3004+ crate::SPFResult::Fail => SPFResult::Fail,
3005+ crate::SPFResult::SoftFail => SPFResult::SoftFail,
3006+ crate::SPFResult::Neutral => SPFResult::Neutral,
3007+ crate::SPFResult::TempError => SPFResult::TempError,
3008+ crate::SPFResult::PermError => SPFResult::PermError,
3009+ crate::SPFResult::None => SPFResult::None,
3010+ },
3011+ human_result: None,
3012+ });
3013+ }
3014+
3015+ pub fn with_dmarc_output(mut self, dmarc_output: &DMARCOutput) {
3016+ self.row.policy_evaluated.disposition = match dmarc_output.policy {
3017+ crate::dmarc::Policy::None => ActionDisposition::None,
3018+ crate::dmarc::Policy::Quarantine => ActionDisposition::Quarantine,
3019+ crate::dmarc::Policy::Reject => ActionDisposition::Reject,
3020+ crate::dmarc::Policy::Unspecified => ActionDisposition::None,
3021+ };
3022+ self.row.policy_evaluated.dkim = (&dmarc_output.dkim_result).into();
3023+ self.row.policy_evaluated.spf = (&dmarc_output.spf_result).into();
3024+ }
3025+
3026+ pub fn with_arc_output(mut self, arc_output: &ARCOutput) {
3027+ if arc_output.result == crate::DKIMResult::Pass {
3028+ let mut comment = "arc=pass".to_string();
3029+ for set in arc_output.set.iter().rev() {
3030+ let seal = &set.seal.header;
3031+ write!(
3032+ &mut comment,
3033+ " as[{}].d={} as[{}].s={}",
3034+ seal.i, seal.d, seal.i, seal.s
3035+ )
3036+ .ok();
3037+ }
3038+ self.row
3039+ .policy_evaluated
3040+ .reason
3041+ .push(PolicyOverrideReason::new(PolicyOverride::LocalPolicy).with_comment(comment));
3042+ }
3043+ }
3044+
3045+ pub fn source_ip(&self) -> IpAddr {
3046+ self.row.source_ip
3047+ }
3048+
3049+ pub fn with_source_ip(mut self, source_ip: IpAddr) -> Self {
3050+ self.row.source_ip = source_ip;
3051+ self
3052+ }
3053+
3054+ pub fn count(&self) -> u32 {
3055+ self.row.count
3056+ }
3057+
3058+ pub fn with_count(mut self, count: u32) -> Self {
3059+ self.row.count = count;
3060+ self
3061+ }
3062+
3063+ pub fn action_disposition(&self) -> ActionDisposition {
3064+ self.row.policy_evaluated.disposition
3065+ }
3066+
3067+ pub fn with_action_disposition(mut self, disposition: ActionDisposition) -> Self {
3068+ self.row.policy_evaluated.disposition = disposition;
3069+ self
3070+ }
3071+
3072+ pub fn dmarc_dkim_result(&self) -> DMARCResult {
3073+ self.row.policy_evaluated.dkim
3074+ }
3075+
3076+ pub fn with_dmarc_dkim_result(mut self, dkim: DMARCResult) -> Self {
3077+ self.row.policy_evaluated.dkim = dkim;
3078+ self
3079+ }
3080+
3081+ pub fn dmarc_spf_result(&self) -> DMARCResult {
3082+ self.row.policy_evaluated.spf
3083+ }
3084+
3085+ pub fn with_dmarc_spf_result(mut self, spf: DMARCResult) -> Self {
3086+ self.row.policy_evaluated.spf = spf;
3087+ self
3088+ }
3089+
3090+ pub fn policy_override_reason(&self) -> &[PolicyOverrideReason] {
3091+ &self.row.policy_evaluated.reason
3092+ }
3093+
3094+ pub fn with_policy_override_reason(mut self, reason: PolicyOverrideReason) -> Self {
3095+ self.row.policy_evaluated.reason.push(reason);
3096+ self
3097+ }
3098+
3099+ pub fn envelope_from(&self) -> &str {
3100+ &self.identifiers.envelope_from
3101+ }
3102+
3103+ pub fn with_envelope_from(mut self, envelope_from: impl Into<String>) -> Self {
3104+ self.identifiers.envelope_from = envelope_from.into();
3105+ self
3106+ }
3107+
3108+ pub fn header_from(&self) -> &str {
3109+ &self.identifiers.header_from
3110+ }
3111+
3112+ pub fn with_header_from(mut self, header_from: impl Into<String>) -> Self {
3113+ self.identifiers.header_from = header_from.into();
3114+ self
3115+ }
3116+
3117+ pub fn envelope_to(&self) -> Option<&str> {
3118+ self.identifiers.envelope_to.as_deref()
3119+ }
3120+
3121+ pub fn with_envelope_to(mut self, envelope_to: impl Into<String>) -> Self {
3122+ self.identifiers.envelope_to = Some(envelope_to.into());
3123+ self
3124+ }
3125+
3126+ pub fn dkim_auth_result(&self) -> &[DKIMAuthResult] {
3127+ &self.auth_results.dkim
3128+ }
3129+
3130+ pub fn with_dkim_auth_result(mut self, auth_result: DKIMAuthResult) -> Self {
3131+ self.auth_results.dkim.push(auth_result);
3132+ self
3133+ }
3134+
3135+ pub fn spf_auth_result(&self) -> &[SPFAuthResult] {
3136+ &self.auth_results.spf
3137+ }
3138+
3139+ pub fn with_spf_auth_result(mut self, auth_result: SPFAuthResult) -> Self {
3140+ self.auth_results.spf.push(auth_result);
3141+ self
3142+ }
3143+ }
3144+
3145+ impl DKIMAuthResult {
3146+ pub fn new() -> Self {
3147+ DKIMAuthResult::default()
3148+ }
3149+
3150+ pub fn domain(&self) -> &str {
3151+ &self.domain
3152+ }
3153+
3154+ pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
3155+ self.domain = domain.into();
3156+ self
3157+ }
3158+
3159+ pub fn selector(&self) -> &str {
3160+ &self.selector
3161+ }
3162+
3163+ pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
3164+ self.selector = selector.into();
3165+ self
3166+ }
3167+
3168+ pub fn result(&self) -> DKIMResult {
3169+ self.result
3170+ }
3171+
3172+ pub fn with_result(mut self, result: DKIMResult) -> Self {
3173+ self.result = result;
3174+ self
3175+ }
3176+
3177+ pub fn human_result(&self) -> Option<&str> {
3178+ self.human_result.as_deref()
3179+ }
3180+
3181+ pub fn with_human_result(mut self, human_result: impl Into<String>) -> Self {
3182+ self.human_result = Some(human_result.into());
3183+ self
3184+ }
3185+ }
3186+
3187+ impl SPFAuthResult {
3188+ pub fn new() -> Self {
3189+ SPFAuthResult::default()
3190+ }
3191+
3192+ pub fn domain(&self) -> &str {
3193+ &self.domain
3194+ }
3195+
3196+ pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
3197+ self.domain = domain.into();
3198+ self
3199+ }
3200+
3201+ pub fn scope(&self) -> SPFDomainScope {
3202+ self.scope
3203+ }
3204+
3205+ pub fn with_scope(mut self, scope: SPFDomainScope) -> Self {
3206+ self.scope = scope;
3207+ self
3208+ }
3209+
3210+ pub fn result(&self) -> SPFResult {
3211+ self.result
3212+ }
3213+
3214+ pub fn with_result(mut self, result: SPFResult) -> Self {
3215+ self.result = result;
3216+ self
3217+ }
3218+
3219+ pub fn human_result(&self) -> Option<&str> {
3220+ self.human_result.as_deref()
3221+ }
3222+
3223+ pub fn with_human_result(mut self, human_result: impl Into<String>) -> Self {
3224+ self.human_result = Some(human_result.into());
3225+ self
3226+ }
3227+ }
3228+
3229+ impl PolicyOverrideReason {
3230+ pub fn new(type_: PolicyOverride) -> Self {
3231+ PolicyOverrideReason {
3232+ type_,
3233+ comment: None,
3234+ }
3235+ }
3236+
3237+ pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
3238+ self.comment = Some(comment.into());
3239+ self
3240+ }
3241+
3242+ pub fn comment(&self) -> Option<&str> {
3243+ self.comment.as_deref()
3244+ }
3245+
3246+ pub fn policy_override(&self) -> PolicyOverride {
3247+ self.type_
3248+ }
3249+ }
3250+
3251+ impl From<&crate::DMARCResult> for DMARCResult {
3252+ fn from(result: &crate::DMARCResult) -> Self {
3253+ match result {
3254+ crate::DMARCResult::Pass => DMARCResult::Pass,
3255+ _ => DMARCResult::Fail,
3256+ }
3257+ }
3258+ }
3259+
3260+ impl From<&crate::dmarc::Alignment> for Alignment {
3261+ fn from(aligment: &crate::dmarc::Alignment) -> Self {
3262+ match aligment {
3263+ crate::dmarc::Alignment::Relaxed => Alignment::Relaxed,
3264+ crate::dmarc::Alignment::Strict => Alignment::Strict,
3265+ }
3266+ }
3267+ }
3268+
3269+ impl From<&crate::dmarc::Policy> for Disposition {
3270+ fn from(policy: &crate::dmarc::Policy) -> Self {
3271+ match policy {
3272+ crate::dmarc::Policy::None => Disposition::None,
3273+ crate::dmarc::Policy::Quarantine => Disposition::Quarantine,
3274+ crate::dmarc::Policy::Reject => Disposition::Reject,
3275+ crate::dmarc::Policy::Unspecified => Disposition::None,
3276+ }
3277+ }
3278+ }
3279 diff --git a/src/report/feedback/parse.rs b/src/report/feedback/parse.rs
3280new file mode 100644
3281index 0000000..5252fdf
3282--- /dev/null
3283+++ b/src/report/feedback/parse.rs
3284 @@ -0,0 +1,821 @@
3285+ use std::io::{BufRead, Cursor, Read};
3286+ use std::str::FromStr;
3287+
3288+ use flate2::read::GzDecoder;
3289+ use mail_parser::{Message, MimeHeaders, PartType};
3290+ use quick_xml::events::{BytesStart, Event};
3291+ use quick_xml::reader::Reader;
3292+
3293+ use crate::report::{
3294+ ActionDisposition, Alignment, AuthResult, DKIMAuthResult, DKIMResult, DMARCResult, DateRange,
3295+ Disposition, Error, Extension, Feedback, Identifier, PolicyEvaluated, PolicyOverride,
3296+ PolicyOverrideReason, PolicyPublished, Record, ReportMetadata, Row, SPFAuthResult,
3297+ SPFDomainScope, SPFResult,
3298+ };
3299+
3300+ impl Feedback {
3301+ pub fn parse_rfc5322(report: &[u8]) -> Result<Self, Error> {
3302+ let message = Message::parse(report).ok_or(Error::MailParseError)?;
3303+ let mut error = Error::NoReportsFound;
3304+
3305+ for part in &message.parts {
3306+ match &part.body {
3307+ PartType::Text(report)
3308+ if part
3309+ .content_type()
3310+ .and_then(|ct| ct.subtype())
3311+ .map_or(false, |t| t.eq_ignore_ascii_case("xml"))
3312+ || part
3313+ .attachment_name()
3314+ .and_then(|n| n.rsplit_once('.'))
3315+ .map_or(false, |(_, e)| e.eq_ignore_ascii_case("xml")) =>
3316+ {
3317+ match Feedback::parse_xml(report.as_bytes()) {
3318+ Ok(feedback) => return Ok(feedback),
3319+ Err(err) => {
3320+ error = err.into();
3321+ }
3322+ }
3323+ }
3324+ PartType::Binary(report) | PartType::InlineBinary(report) => {
3325+ enum ReportType {
3326+ Xml,
3327+ Gzip,
3328+ Zip,
3329+ }
3330+
3331+ let (_, ext) = part
3332+ .attachment_name()
3333+ .unwrap_or("file.none")
3334+ .rsplit_once('.')
3335+ .unwrap_or(("file", "none"));
3336+ let subtype = part
3337+ .content_type()
3338+ .and_then(|ct| ct.subtype())
3339+ .unwrap_or("none");
3340+ let rt = if subtype.eq_ignore_ascii_case("gzip") {
3341+ ReportType::Gzip
3342+ } else if subtype.eq_ignore_ascii_case("zip") {
3343+ ReportType::Zip
3344+ } else if subtype.eq_ignore_ascii_case("xml") {
3345+ ReportType::Xml
3346+ } else if ext.eq_ignore_ascii_case("gz") {
3347+ ReportType::Gzip
3348+ } else if ext.eq_ignore_ascii_case("zip") {
3349+ ReportType::Zip
3350+ } else if ext.eq_ignore_ascii_case("xml") {
3351+ ReportType::Xml
3352+ } else {
3353+ continue;
3354+ };
3355+
3356+ match rt {
3357+ ReportType::Gzip => {
3358+ let mut file = GzDecoder::new(report.as_ref());
3359+ let mut buf = Vec::new();
3360+ file.read_to_end(&mut buf)
3361+ .map_err(|err| Error::UncompressError(err.to_string()))?;
3362+
3363+ match Feedback::parse_xml(&buf) {
3364+ Ok(feedback) => return Ok(feedback),
3365+ Err(err) => {
3366+ error = err.into();
3367+ }
3368+ }
3369+ }
3370+ ReportType::Zip => {
3371+ let mut archive = zip::ZipArchive::new(Cursor::new(report.as_ref()))
3372+ .map_err(|err| Error::UncompressError(err.to_string()))?;
3373+ for i in 0..archive.len() {
3374+ match archive.by_index(i) {
3375+ Ok(mut file) => {
3376+ let mut buf =
3377+ Vec::with_capacity(file.compressed_size() as usize);
3378+ file.read_to_end(&mut buf).map_err(|err| {
3379+ Error::UncompressError(err.to_string())
3380+ })?;
3381+ match Feedback::parse_xml(&buf) {
3382+ Ok(feedback) => return Ok(feedback),
3383+ Err(err) => {
3384+ error = err.into();
3385+ }
3386+ }
3387+ }
3388+ Err(err) => {
3389+ error = Error::UncompressError(err.to_string());
3390+ }
3391+ }
3392+ }
3393+ }
3394+ ReportType::Xml => match Feedback::parse_xml(report) {
3395+ Ok(feedback) => return Ok(feedback),
3396+ Err(err) => {
3397+ error = err.into();
3398+ }
3399+ },
3400+ }
3401+ }
3402+ _ => (),
3403+ }
3404+ }
3405+
3406+ Err(error)
3407+ }
3408+
3409+ pub fn parse_xml(report: &[u8]) -> Result<Self, String> {
3410+ let mut version: f32 = 0.0;
3411+ let mut report_metadata = None;
3412+ let mut policy_published = None;
3413+ let mut record = Vec::new();
3414+ let mut extensions = Vec::new();
3415+
3416+ let mut reader = Reader::from_reader(report);
3417+ reader.trim_text(true);
3418+
3419+ let mut buf = Vec::with_capacity(128);
3420+ let mut found_feedback = false;
3421+
3422+ while let Some(tag) = reader.next_tag(&mut buf)? {
3423+ match tag.name().as_ref() {
3424+ b"feedback" if !found_feedback => {
3425+ found_feedback = true;
3426+ }
3427+ b"version" if found_feedback => {
3428+ version = reader.next_value(&mut buf)?.unwrap_or(0.0);
3429+ }
3430+ b"report_metadata" if found_feedback => {
3431+ report_metadata = ReportMetadata::parse(&mut reader, &mut buf)?.into();
3432+ }
3433+ b"policy_published" if found_feedback => {
3434+ policy_published = PolicyPublished::parse(&mut reader, &mut buf)?.into();
3435+ }
3436+ b"record" if found_feedback => {
3437+ record.push(Record::parse(&mut reader, &mut buf)?);
3438+ }
3439+ b"extensions" if found_feedback => {
3440+ Extension::parse(&mut reader, &mut buf, &mut extensions)?;
3441+ }
3442+ b"" => {}
3443+ other if !found_feedback => {
3444+ return Err(format!(
3445+ "Unexpected tag {} at position {}.",
3446+ String::from_utf8_lossy(other),
3447+ reader.buffer_position()
3448+ ));
3449+ }
3450+ _ => (),
3451+ }
3452+ }
3453+
3454+ Ok(Feedback {
3455+ version,
3456+ report_metadata: report_metadata.ok_or("Missing feedback/report_metadata tag.")?,
3457+ policy_published: policy_published.ok_or("Missing feedback/policy_published tag.")?,
3458+ record,
3459+ extensions,
3460+ })
3461+ }
3462+ }
3463+
3464+ impl ReportMetadata {
3465+ pub(crate) fn parse<R: BufRead>(
3466+ reader: &mut Reader<R>,
3467+ buf: &mut Vec<u8>,
3468+ ) -> Result<Self, String> {
3469+ let mut rm = ReportMetadata::default();
3470+
3471+ while let Some(tag) = reader.next_tag(buf)? {
3472+ match tag.name().as_ref() {
3473+ b"org_name" => {
3474+ rm.org_name = reader.next_value::<String>(buf)?.unwrap_or_default();
3475+ }
3476+ b"email" => {
3477+ rm.email = reader.next_value::<String>(buf)?.unwrap_or_default();
3478+ }
3479+ b"extra_contact_info" => {
3480+ rm.extra_contact_info = reader.next_value::<String>(buf)?;
3481+ }
3482+ b"report_id" => {
3483+ rm.report_id = reader.next_value::<String>(buf)?.unwrap_or_default();
3484+ }
3485+ b"date_range" => {
3486+ rm.date_range = DateRange::parse(reader, buf)?;
3487+ }
3488+ b"error" => {
3489+ if let Some(err) = reader.next_value::<String>(buf)? {
3490+ rm.error.push(err);
3491+ }
3492+ }
3493+ b"" => (),
3494+ _ => {
3495+ reader.skip_tag(buf)?;
3496+ }
3497+ }
3498+ }
3499+
3500+ Ok(rm)
3501+ }
3502+ }
3503+
3504+ impl DateRange {
3505+ pub(crate) fn parse<R: BufRead>(
3506+ reader: &mut Reader<R>,
3507+ buf: &mut Vec<u8>,
3508+ ) -> Result<Self, String> {
3509+ let mut dr = DateRange::default();
3510+
3511+ while let Some(tag) = reader.next_tag(buf)? {
3512+ match tag.name().as_ref() {
3513+ b"begin" => {
3514+ dr.begin = reader.next_value(buf)?.unwrap_or_default();
3515+ }
3516+ b"end" => {
3517+ dr.end = reader.next_value(buf)?.unwrap_or_default();
3518+ }
3519+ b"" => (),
3520+ _ => {
3521+ reader.skip_tag(buf)?;
3522+ }
3523+ }
3524+ }
3525+
3526+ Ok(dr)
3527+ }
3528+ }
3529+
3530+ impl PolicyPublished {
3531+ pub(crate) fn parse<R: BufRead>(
3532+ reader: &mut Reader<R>,
3533+ buf: &mut Vec<u8>,
3534+ ) -> Result<Self, String> {
3535+ let mut p = PolicyPublished::default();
3536+
3537+ while let Some(tag) = reader.next_tag(buf)? {
3538+ match tag.name().as_ref() {
3539+ b"domain" => {
3540+ p.domain = reader.next_value::<String>(buf)?.unwrap_or_default();
3541+ }
3542+ b"version_published" => {
3543+ p.version_published = reader.next_value(buf)?;
3544+ }
3545+ b"adkim" => {
3546+ p.adkim = reader.next_value(buf)?.unwrap_or_default();
3547+ }
3548+ b"aspf" => {
3549+ p.aspf = reader.next_value(buf)?.unwrap_or_default();
3550+ }
3551+ b"p" => {
3552+ p.p = reader.next_value(buf)?.unwrap_or_default();
3553+ }
3554+ b"sp" => {
3555+ p.sp = reader.next_value(buf)?.unwrap_or_default();
3556+ }
3557+ b"testing" => {
3558+ p.testing = reader
3559+ .next_value::<String>(buf)?
3560+ .map_or(false, |s| s.eq_ignore_ascii_case("y"));
3561+ }
3562+ b"fo" => {
3563+ p.fo = reader.next_value::<String>(buf)?;
3564+ }
3565+ b"" => (),
3566+ _ => {
3567+ reader.skip_tag(buf)?;
3568+ }
3569+ }
3570+ }
3571+
3572+ Ok(p)
3573+ }
3574+ }
3575+
3576+ impl Extension {
3577+ pub(crate) fn parse<R: BufRead>(
3578+ reader: &mut Reader<R>,
3579+ buf: &mut Vec<u8>,
3580+ extensions: &mut Vec<Extension>,
3581+ ) -> Result<(), String> {
3582+ while let Some(tag) = reader.next_tag(buf)? {
3583+ match tag.name().as_ref() {
3584+ b"extension" => {
3585+ let mut e = Extension::default();
3586+ if let Ok(Some(attr)) = tag.try_get_attribute("name") {
3587+ if let Ok(attr) = attr.unescape_value() {
3588+ e.name = attr.to_string();
3589+ }
3590+ }
3591+ if let Ok(Some(attr)) = tag.try_get_attribute("definition") {
3592+ if let Ok(attr) = attr.unescape_value() {
3593+ e.definition = attr.to_string();
3594+ }
3595+ }
3596+ extensions.push(e);
3597+ reader.skip_tag(buf)?;
3598+ }
3599+ b"" => (),
3600+ _ => {
3601+ reader.skip_tag(buf)?;
3602+ }
3603+ }
3604+ }
3605+
3606+ Ok(())
3607+ }
3608+ }
3609+
3610+ impl Record {
3611+ pub(crate) fn parse<R: BufRead>(
3612+ reader: &mut Reader<R>,
3613+ buf: &mut Vec<u8>,
3614+ ) -> Result<Self, String> {
3615+ let mut r = Record::default();
3616+
3617+ while let Some(tag) = reader.next_tag(buf)? {
3618+ match tag.name().as_ref() {
3619+ b"row" => {
3620+ r.row = Row::parse(reader, buf)?;
3621+ }
3622+ b"identifiers" => {
3623+ r.identifiers = Identifier::parse(reader, buf)?;
3624+ }
3625+ b"auth_results" => {
3626+ r.auth_results = AuthResult::parse(reader, buf)?;
3627+ }
3628+ b"extensions" => {
3629+ Extension::parse(reader, buf, &mut r.extensions)?;
3630+ }
3631+ b"" => (),
3632+ _ => {
3633+ reader.skip_tag(buf)?;
3634+ }
3635+ }
3636+ }
3637+
3638+ Ok(r)
3639+ }
3640+ }
3641+
3642+ impl Row {
3643+ pub(crate) fn parse<R: BufRead>(
3644+ reader: &mut Reader<R>,
3645+ buf: &mut Vec<u8>,
3646+ ) -> Result<Self, String> {
3647+ let mut r = Row::default();
3648+
3649+ while let Some(tag) = reader.next_tag(buf)? {
3650+ match tag.name().as_ref() {
3651+ b"source_ip" => {
3652+ if let Some(ip) = reader.next_value(buf)? {
3653+ r.source_ip = ip;
3654+ }
3655+ }
3656+ b"count" => {
3657+ r.count = reader.next_value(buf)?.unwrap_or_default();
3658+ }
3659+ b"policy_evaluated" => {
3660+ r.policy_evaluated = PolicyEvaluated::parse(reader, buf)?;
3661+ }
3662+ b"" => (),
3663+ _ => {
3664+ reader.skip_tag(buf)?;
3665+ }
3666+ }
3667+ }
3668+
3669+ Ok(r)
3670+ }
3671+ }
3672+
3673+ impl PolicyEvaluated {
3674+ pub(crate) fn parse<R: BufRead>(
3675+ reader: &mut Reader<R>,
3676+ buf: &mut Vec<u8>,
3677+ ) -> Result<Self, String> {
3678+ let mut pe = PolicyEvaluated::default();
3679+
3680+ while let Some(tag) = reader.next_tag(buf)? {
3681+ match tag.name().as_ref() {
3682+ b"disposition" => {
3683+ pe.disposition = reader.next_value(buf)?.unwrap_or_default();
3684+ }
3685+ b"dkim" => {
3686+ pe.dkim = reader.next_value(buf)?.unwrap_or_default();
3687+ }
3688+ b"spf" => {
3689+ pe.spf = reader.next_value(buf)?.unwrap_or_default();
3690+ }
3691+ b"reason" => {
3692+ pe.reason.push(PolicyOverrideReason::parse(reader, buf)?);
3693+ }
3694+ b"" => (),
3695+ _ => {
3696+ reader.skip_tag(buf)?;
3697+ }
3698+ }
3699+ }
3700+
3701+ Ok(pe)
3702+ }
3703+ }
3704+
3705+ impl PolicyOverrideReason {
3706+ pub(crate) fn parse<R: BufRead>(
3707+ reader: &mut Reader<R>,
3708+ buf: &mut Vec<u8>,
3709+ ) -> Result<Self, String> {
3710+ let mut por = PolicyOverrideReason::default();
3711+
3712+ while let Some(tag) = reader.next_tag(buf)? {
3713+ match tag.name().as_ref() {
3714+ b"type" => {
3715+ por.type_ = reader.next_value(buf)?.unwrap_or_default();
3716+ }
3717+ b"comment" => {
3718+ por.comment = reader.next_value(buf)?;
3719+ }
3720+ b"" => (),
3721+ _ => {
3722+ reader.skip_tag(buf)?;
3723+ }
3724+ }
3725+ }
3726+
3727+ Ok(por)
3728+ }
3729+ }
3730+
3731+ impl Identifier {
3732+ pub(crate) fn parse<R: BufRead>(
3733+ reader: &mut Reader<R>,
3734+ buf: &mut Vec<u8>,
3735+ ) -> Result<Self, String> {
3736+ let mut i = Identifier::default();
3737+
3738+ while let Some(tag) = reader.next_tag(buf)? {
3739+ match tag.name().as_ref() {
3740+ b"envelope_to" => {
3741+ i.envelope_to = reader.next_value(buf)?;
3742+ }
3743+ b"envelope_from" => {
3744+ i.envelope_from = reader.next_value(buf)?.unwrap_or_default();
3745+ }
3746+ b"header_from" => {
3747+ i.header_from = reader.next_value(buf)?.unwrap_or_default();
3748+ }
3749+ b"" => (),
3750+ _ => {
3751+ reader.skip_tag(buf)?;
3752+ }
3753+ }
3754+ }
3755+
3756+ Ok(i)
3757+ }
3758+ }
3759+
3760+ impl AuthResult {
3761+ pub(crate) fn parse<R: BufRead>(
3762+ reader: &mut Reader<R>,
3763+ buf: &mut Vec<u8>,
3764+ ) -> Result<Self, String> {
3765+ let mut ar = AuthResult::default();
3766+
3767+ while let Some(tag) = reader.next_tag(buf)? {
3768+ match tag.name().as_ref() {
3769+ b"dkim" => {
3770+ ar.dkim.push(DKIMAuthResult::parse(reader, buf)?);
3771+ }
3772+ b"spf" => {
3773+ ar.spf.push(SPFAuthResult::parse(reader, buf)?);
3774+ }
3775+ b"" => (),
3776+ _ => {
3777+ reader.skip_tag(buf)?;
3778+ }
3779+ }
3780+ }
3781+
3782+ Ok(ar)
3783+ }
3784+ }
3785+
3786+ impl DKIMAuthResult {
3787+ pub(crate) fn parse<R: BufRead>(
3788+ reader: &mut Reader<R>,
3789+ buf: &mut Vec<u8>,
3790+ ) -> Result<Self, String> {
3791+ let mut dar = DKIMAuthResult::default();
3792+
3793+ while let Some(tag) = reader.next_tag(buf)? {
3794+ match tag.name().as_ref() {
3795+ b"domain" => {
3796+ dar.domain = reader.next_value(buf)?.unwrap_or_default();
3797+ }
3798+ b"selector" => {
3799+ dar.selector = reader.next_value(buf)?.unwrap_or_default();
3800+ }
3801+ b"result" => {
3802+ dar.result = reader.next_value(buf)?.unwrap_or_default();
3803+ }
3804+ b"human_result" => {
3805+ dar.human_result = reader.next_value(buf)?;
3806+ }
3807+ b"" => (),
3808+ _ => {
3809+ reader.skip_tag(buf)?;
3810+ }
3811+ }
3812+ }
3813+
3814+ Ok(dar)
3815+ }
3816+ }
3817+
3818+ impl SPFAuthResult {
3819+ pub(crate) fn parse<R: BufRead>(
3820+ reader: &mut Reader<R>,
3821+ buf: &mut Vec<u8>,
3822+ ) -> Result<Self, String> {
3823+ let mut sar = SPFAuthResult::default();
3824+
3825+ while let Some(tag) = reader.next_tag(buf)? {
3826+ match tag.name().as_ref() {
3827+ b"domain" => {
3828+ sar.domain = reader.next_value(buf)?.unwrap_or_default();
3829+ }
3830+ b"scope" => {
3831+ sar.scope = reader.next_value(buf)?.unwrap_or_default();
3832+ }
3833+ b"result" => {
3834+ sar.result = reader.next_value(buf)?.unwrap_or_default();
3835+ }
3836+ b"human_result" => {
3837+ sar.human_result = reader.next_value(buf)?;
3838+ }
3839+ b"" => (),
3840+ _ => {
3841+ reader.skip_tag(buf)?;
3842+ }
3843+ }
3844+ }
3845+
3846+ Ok(sar)
3847+ }
3848+ }
3849+
3850+ impl FromStr for PolicyOverride {
3851+ type Err = ();
3852+
3853+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3854+ Ok(match s.as_bytes() {
3855+ b"forwarded" => PolicyOverride::Forwarded,
3856+ b"sampled_out" => PolicyOverride::SampledOut,
3857+ b"trusted_forwarder" => PolicyOverride::TrustedForwarder,
3858+ b"mailing_list" => PolicyOverride::MailingList,
3859+ b"local_policy" => PolicyOverride::LocalPolicy,
3860+ b"other" => PolicyOverride::Other,
3861+ _ => PolicyOverride::Other,
3862+ })
3863+ }
3864+ }
3865+
3866+ impl FromStr for DMARCResult {
3867+ type Err = ();
3868+
3869+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3870+ Ok(match s.as_bytes() {
3871+ b"pass" => DMARCResult::Pass,
3872+ b"fail" => DMARCResult::Fail,
3873+ _ => DMARCResult::Unspecified,
3874+ })
3875+ }
3876+ }
3877+
3878+ impl FromStr for DKIMResult {
3879+ type Err = ();
3880+
3881+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3882+ Ok(match s.as_bytes() {
3883+ b"none" => DKIMResult::None,
3884+ b"pass" => DKIMResult::Pass,
3885+ b"fail" => DKIMResult::Fail,
3886+ b"policy" => DKIMResult::Policy,
3887+ b"neutral" => DKIMResult::Neutral,
3888+ b"temperror" => DKIMResult::TempError,
3889+ b"permerror" => DKIMResult::PermError,
3890+ _ => DKIMResult::None,
3891+ })
3892+ }
3893+ }
3894+
3895+ impl FromStr for SPFResult {
3896+ type Err = ();
3897+
3898+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3899+ Ok(match s.as_bytes() {
3900+ b"none" => SPFResult::None,
3901+ b"pass" => SPFResult::Pass,
3902+ b"fail" => SPFResult::Fail,
3903+ b"softfail" => SPFResult::SoftFail,
3904+ b"neutral" => SPFResult::Neutral,
3905+ b"temperror" => SPFResult::TempError,
3906+ b"permerror" => SPFResult::PermError,
3907+ _ => SPFResult::None,
3908+ })
3909+ }
3910+ }
3911+
3912+ impl FromStr for SPFDomainScope {
3913+ type Err = ();
3914+
3915+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3916+ Ok(match s.as_bytes() {
3917+ b"helo" => SPFDomainScope::Helo,
3918+ b"mfrom" => SPFDomainScope::MailFrom,
3919+ _ => SPFDomainScope::Unspecified,
3920+ })
3921+ }
3922+ }
3923+
3924+ impl FromStr for ActionDisposition {
3925+ type Err = ();
3926+
3927+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3928+ Ok(match s.as_bytes() {
3929+ b"none" => ActionDisposition::None,
3930+ b"pass" => ActionDisposition::Pass,
3931+ b"quarantine" => ActionDisposition::Quarantine,
3932+ b"reject" => ActionDisposition::Reject,
3933+ _ => ActionDisposition::Unspecified,
3934+ })
3935+ }
3936+ }
3937+
3938+ impl FromStr for Disposition {
3939+ type Err = ();
3940+
3941+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3942+ Ok(match s.as_bytes() {
3943+ b"none" => Disposition::None,
3944+ b"quarantine" => Disposition::Quarantine,
3945+ b"reject" => Disposition::Reject,
3946+ _ => Disposition::Unspecified,
3947+ })
3948+ }
3949+ }
3950+
3951+ impl FromStr for Alignment {
3952+ type Err = ();
3953+
3954+ fn from_str(s: &str) -> Result<Self, Self::Err> {
3955+ Ok(match s.as_bytes().first() {
3956+ Some(b'r') => Alignment::Relaxed,
3957+ Some(b's') => Alignment::Strict,
3958+ _ => Alignment::Unspecified,
3959+ })
3960+ }
3961+ }
3962+
3963+ trait ReaderHelper {
3964+ fn next_tag<'x>(&mut self, buf: &'x mut Vec<u8>) -> Result<Option<BytesStart<'x>>, String>;
3965+ fn next_value<T: FromStr>(&mut self, buf: &mut Vec<u8>) -> Result<Option<T>, String>;
3966+ fn skip_tag(&mut self, buf: &mut Vec<u8>) -> Result<(), String>;
3967+ }
3968+
3969+ impl<R: BufRead> ReaderHelper for Reader<R> {
3970+ fn next_tag<'x>(&mut self, buf: &'x mut Vec<u8>) -> Result<Option<BytesStart<'x>>, String> {
3971+ match self.read_event_into(buf) {
3972+ Ok(Event::Start(e)) => Ok(Some(e)),
3973+ Ok(Event::End(_)) | Ok(Event::Eof) => Ok(None),
3974+ Err(e) => Err(format!(
3975+ "Error at position {}: {:?}",
3976+ self.buffer_position(),
3977+ e
3978+ )),
3979+ _ => Ok(Some(BytesStart::new(""))),
3980+ }
3981+ }
3982+
3983+ fn next_value<T: FromStr>(&mut self, buf: &mut Vec<u8>) -> Result<Option<T>, String> {
3984+ let mut value = None;
3985+ loop {
3986+ match self.read_event_into(buf) {
3987+ Ok(Event::Text(e)) => {
3988+ value = e.unescape().ok().and_then(|v| T::from_str(v.as_ref()).ok());
3989+ }
3990+ Ok(Event::End(_)) => {
3991+ break;
3992+ }
3993+ Ok(Event::Start(e)) => {
3994+ return Err(format!(
3995+ "Expected value, found unexpected tag {} at position {}.",
3996+ String::from_utf8_lossy(e.name().as_ref()),
3997+ self.buffer_position()
3998+ ));
3999+ }
4000+ Ok(Event::Eof) => {
4001+ return Err(format!(
4002+ "Expected value, found unexpected EOF at position {}.",
4003+ self.buffer_position()
4004+ ))
4005+ }
4006+ _ => (),
4007+ }
4008+ }
4009+
4010+ Ok(value)
4011+ }
4012+
4013+ fn skip_tag(&mut self, buf: &mut Vec<u8>) -> Result<(), String> {
4014+ let mut tag_count = 0;
4015+ loop {
4016+ match self.read_event_into(buf) {
4017+ Ok(Event::End(_)) => {
4018+ if tag_count == 0 {
4019+ break;
4020+ } else {
4021+ tag_count -= 1;
4022+ }
4023+ }
4024+ Ok(Event::Start(_)) => {
4025+ tag_count += 1;
4026+ }
4027+ Ok(Event::Eof) => {
4028+ return Err(format!(
4029+ "Expected value, found unexpected EOF at position {}.",
4030+ self.buffer_position()
4031+ ))
4032+ }
4033+ _ => (),
4034+ }
4035+ }
4036+ Ok(())
4037+ }
4038+ }
4039+
4040+ #[cfg(test)]
4041+ mod test {
4042+ use std::{fs, path::PathBuf};
4043+
4044+ use crate::report::Feedback;
4045+
4046+ #[test]
4047+ fn dmarc_aggregate_report_parse() {
4048+ let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4049+ test_dir.push("resources");
4050+ test_dir.push("dmarc-feedback");
4051+
4052+ for file_name in fs::read_dir(&test_dir).unwrap() {
4053+ let mut file_name = file_name.unwrap().path();
4054+ if !file_name.extension().unwrap().to_str().unwrap().eq("xml") {
4055+ continue;
4056+ }
4057+ println!("Parsing DMARC feedback {}", file_name.to_str().unwrap());
4058+
4059+ let feedback = Feedback::parse_xml(&fs::read(&file_name).unwrap()).unwrap();
4060+
4061+ file_name.set_extension("json");
4062+
4063+ let expected_feedback =
4064+ serde_json::from_slice::<Feedback>(&fs::read(&file_name).unwrap()).unwrap();
4065+
4066+ assert_eq!(expected_feedback, feedback);
4067+
4068+ /*fs::write(
4069+ &file_name,
4070+ serde_json::to_string_pretty(&feedback).unwrap().as_bytes(),
4071+ )
4072+ .unwrap();*/
4073+ }
4074+ }
4075+
4076+ #[test]
4077+ fn dmarc_aggregate_report_eml_parse() {
4078+ let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4079+ test_dir.push("resources");
4080+ test_dir.push("dmarc-feedback");
4081+
4082+ for file_name in fs::read_dir(&test_dir).unwrap() {
4083+ let mut file_name = file_name.unwrap().path();
4084+ if !file_name.extension().unwrap().to_str().unwrap().eq("eml") {
4085+ continue;
4086+ }
4087+ println!("Parsing DMARC feedback {}", file_name.to_str().unwrap());
4088+
4089+ let feedback = Feedback::parse_rfc5322(&fs::read(&file_name).unwrap()).unwrap();
4090+
4091+ file_name.set_extension("json");
4092+
4093+ let expected_feedback =
4094+ serde_json::from_slice::<Feedback>(&fs::read(&file_name).unwrap()).unwrap();
4095+
4096+ assert_eq!(expected_feedback, feedback);
4097+
4098+ /*fs::write(
4099+ &file_name,
4100+ serde_json::to_string_pretty(&feedback).unwrap().as_bytes(),
4101+ )
4102+ .unwrap();*/
4103+ }
4104+ }
4105+ }
4106 diff --git a/src/report/mod.rs b/src/report/mod.rs
4107index 75054cb..ffad6d4 100644
4108--- a/src/report/mod.rs
4109+++ b/src/report/mod.rs
4110 @@ -1,13 +1,16 @@
4111- mod agg_parse;
4112+ mod feedback;
4113
4114- use std::net::IpAddr;
4115+ use std::net::{IpAddr, Ipv4Addr};
4116
4117- #[derive(Default)]
4118+ use serde::{Deserialize, Serialize};
4119+
4120+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4121 pub struct DateRange {
4122- begin: u32,
4123- end: u32,
4124+ begin: u64,
4125+ end: u64,
4126 }
4127
4128+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4129 pub struct ReportMetadata {
4130 org_name: String,
4131 email: String,
4132 @@ -17,12 +20,14 @@ pub struct ReportMetadata {
4133 error: Vec<String>,
4134 }
4135
4136+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4137 pub enum Alignment {
4138 Relaxed,
4139- Simple,
4140+ Strict,
4141 Unspecified,
4142 }
4143
4144+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4145 pub enum Disposition {
4146 None,
4147 Quarantine,
4148 @@ -30,16 +35,19 @@ pub enum Disposition {
4149 Unspecified,
4150 }
4151
4152+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4153 pub enum ActionDisposition {
4154 None,
4155 Pass,
4156 Quarantine,
4157 Reject,
4158+ Unspecified,
4159 }
4160
4161+ #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
4162 pub struct PolicyPublished {
4163 domain: String,
4164- version_published: Option<u32>,
4165+ version_published: Option<f32>,
4166 adkim: Alignment,
4167 aspf: Alignment,
4168 p: Disposition,
4169 @@ -48,11 +56,16 @@ pub struct PolicyPublished {
4170 fo: Option<String>,
4171 }
4172
4173+ impl Eq for PolicyPublished {}
4174+
4175+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4176 pub enum DMARCResult {
4177 Pass,
4178 Fail,
4179+ Unspecified,
4180 }
4181
4182+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4183 pub enum PolicyOverride {
4184 Forwarded,
4185 SampledOut,
4186 @@ -62,11 +75,13 @@ pub enum PolicyOverride {
4187 Other,
4188 }
4189
4190+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4191 pub struct PolicyOverrideReason {
4192 type_: PolicyOverride,
4193 comment: Option<String>,
4194 }
4195
4196+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4197 pub struct PolicyEvaluated {
4198 disposition: ActionDisposition,
4199 dkim: DMARCResult,
4200 @@ -74,25 +89,27 @@ pub struct PolicyEvaluated {
4201 reason: Vec<PolicyOverrideReason>,
4202 }
4203
4204+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4205 pub struct Row {
4206 source_ip: IpAddr,
4207 count: u32,
4208 policy_evaluated: PolicyEvaluated,
4209- extensions: Vec<Extension>,
4210 }
4211
4212+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4213 pub struct Extension {
4214- extension: Option<String>,
4215 name: String,
4216 definition: String,
4217 }
4218
4219+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4220 pub struct Identifier {
4221 envelope_to: Option<String>,
4222 envelope_from: String,
4223 header_from: String,
4224 }
4225
4226+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4227 pub enum DKIMResult {
4228 None,
4229 Pass,
4230 @@ -103,6 +120,7 @@ pub enum DKIMResult {
4231 PermError,
4232 }
4233
4234+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4235 pub struct DKIMAuthResult {
4236 domain: String,
4237 selector: String,
4238 @@ -110,12 +128,14 @@ pub struct DKIMAuthResult {
4239 human_result: Option<String>,
4240 }
4241
4242+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4243 pub enum SPFDomainScope {
4244 Helo,
4245 MailFrom,
4246- Undefined,
4247+ Unspecified,
4248 }
4249
4250+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4251 pub enum SPFResult {
4252 None,
4253 Neutral,
4254 @@ -126,6 +146,7 @@ pub enum SPFResult {
4255 PermError,
4256 }
4257
4258+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4259 pub struct SPFAuthResult {
4260 domain: String,
4261 scope: SPFDomainScope,
4262 @@ -133,21 +154,99 @@ pub struct SPFAuthResult {
4263 human_result: Option<String>,
4264 }
4265
4266+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4267 pub struct AuthResult {
4268 dkim: Vec<DKIMAuthResult>,
4269 spf: Vec<SPFAuthResult>,
4270 }
4271
4272+ #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4273 pub struct Record {
4274 row: Row,
4275 identifiers: Identifier,
4276 auth_results: AuthResult,
4277+ extensions: Vec<Extension>,
4278 }
4279
4280+ #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
4281 pub struct Feedback {
4282- version: u32,
4283+ version: f32,
4284 report_metadata: ReportMetadata,
4285 policy_published: PolicyPublished,
4286 record: Vec<Record>,
4287 extensions: Vec<Extension>,
4288 }
4289+
4290+ impl Eq for Feedback {}
4291+
4292+ impl Default for Row {
4293+ fn default() -> Self {
4294+ Self {
4295+ source_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
4296+ count: 0,
4297+ policy_evaluated: PolicyEvaluated::default(),
4298+ }
4299+ }
4300+ }
4301+
4302+ impl Default for Alignment {
4303+ fn default() -> Self {
4304+ Alignment::Unspecified
4305+ }
4306+ }
4307+
4308+ impl Default for Disposition {
4309+ fn default() -> Self {
4310+ Disposition::Unspecified
4311+ }
4312+ }
4313+
4314+ impl Default for ActionDisposition {
4315+ fn default() -> Self {
4316+ ActionDisposition::None
4317+ }
4318+ }
4319+
4320+ impl Default for DMARCResult {
4321+ fn default() -> Self {
4322+ DMARCResult::Unspecified
4323+ }
4324+ }
4325+
4326+ impl Default for PolicyOverride {
4327+ fn default() -> Self {
4328+ PolicyOverride::Other
4329+ }
4330+ }
4331+
4332+ impl Default for DKIMResult {
4333+ fn default() -> Self {
4334+ DKIMResult::None
4335+ }
4336+ }
4337+
4338+ impl Default for SPFResult {
4339+ fn default() -> Self {
4340+ SPFResult::None
4341+ }
4342+ }
4343+
4344+ impl Default for SPFDomainScope {
4345+ fn default() -> Self {
4346+ SPFDomainScope::Unspecified
4347+ }
4348+ }
4349+
4350+ #[derive(Debug, Clone, PartialEq, Eq)]
4351+ pub enum Error {
4352+ MailParseError,
4353+ ReportParseError(String),
4354+ UncompressError(String),
4355+ NoReportsFound,
4356+ }
4357+
4358+ impl From<String> for Error {
4359+ fn from(err: String) -> Self {
4360+ Error::ReportParseError(err)
4361+ }
4362+ }
4363 diff --git a/src/spf/mod.rs b/src/spf/mod.rs
4364index ce613d2..e3fc194 100644
4365--- a/src/spf/mod.rs
4366+++ b/src/spf/mod.rs
4367 @@ -193,11 +193,12 @@ impl TryFrom<String> for SPFResult {
4368 }
4369
4370 impl SPFOutput {
4371- pub(crate) fn new() -> Self {
4372+ pub(crate) fn new(domain: String) -> Self {
4373 SPFOutput {
4374 result: SPFResult::None,
4375 report: None,
4376 explanation: None,
4377+ domain,
4378 }
4379 }
4380
4381 @@ -206,7 +207,7 @@ impl SPFOutput {
4382 self
4383 }
4384
4385- pub(crate) fn with_report(mut self, spf: &SPF, domain: &str) -> Self {
4386+ pub(crate) fn with_report(mut self, spf: &SPF) -> Self {
4387 match &spf.ra {
4388 Some(ra) if is_within_pct(spf.rp) => {
4389 if match self.result {
4390 @@ -218,7 +219,7 @@ impl SPFOutput {
4391 }
4392 SPFResult::Pass => false,
4393 } {
4394- self.report = format!("{}@{}", String::from_utf8_lossy(ra), domain).into();
4395+ self.report = format!("{}@{}", String::from_utf8_lossy(ra), self.domain).into();
4396 }
4397 }
4398 _ => (),
4399 diff --git a/src/spf/verify.rs b/src/spf/verify.rs
4400index 08f0c09..c0f43eb 100644
4401--- a/src/spf/verify.rs
4402+++ b/src/spf/verify.rs
4403 @@ -18,7 +18,7 @@ impl Resolver {
4404 )
4405 .await
4406 } else {
4407- SPFOutput::new().with_result(SPFResult::None)
4408+ SPFOutput::new(helo_domain.to_string()).with_result(SPFResult::None)
4409 }
4410 }
4411
4412 @@ -58,10 +58,10 @@ impl Resolver {
4413 helo_domain: &str,
4414 sender: &str,
4415 ) -> SPFOutput {
4416+ let output = SPFOutput::new(domain.to_string());
4417 if domain.is_empty() || domain.len() > 63 || !domain.has_labels() {
4418- return SPFOutput::new().with_result(SPFResult::None);
4419+ return output.with_result(SPFResult::None);
4420 }
4421- let base_domain = domain;
4422 let mut vars = Variables::new();
4423 let mut has_p_var = false;
4424 vars.set_ip(&ip);
4425 @@ -77,7 +77,7 @@ impl Resolver {
4426 let mut lookup_limit = LookupLimit::new();
4427 let mut spf_record = match self.txt_lookup::<SPF>(domain).await {
4428 Ok(spf_record) => spf_record,
4429- Err(err) => return SPFOutput::new().with_result(err.into()),
4430+ Err(err) => return output.with_result(err.into()),
4431 };
4432
4433 let mut domain = domain.to_string();
4434 @@ -90,9 +90,9 @@ impl Resolver {
4435 while let Some((pos, directive)) = directives.next() {
4436 if !has_p_var && directive.mechanism.needs_ptr() {
4437 if !lookup_limit.can_lookup() {
4438- return SPFOutput::new()
4439+ return output
4440 .with_result(SPFResult::PermError)
4441- .with_report(&spf_record, base_domain);
4442+ .with_report(&spf_record);
4443 }
4444 if let Some(ptr) = self
4445 .ptr_lookup(ip)
4446 @@ -115,9 +115,9 @@ impl Resolver {
4447 ip6_mask,
4448 } => {
4449 if !lookup_limit.can_lookup() {
4450- return SPFOutput::new()
4451+ return output
4452 .with_result(SPFResult::PermError)
4453- .with_report(&spf_record, base_domain);
4454+ .with_report(&spf_record);
4455 }
4456 match self
4457 .ip_matches(
4458 @@ -131,9 +131,9 @@ impl Resolver {
4459 Ok(true) => true,
4460 Ok(false) | Err(Error::DNSRecordNotFound(_)) => false,
4461 Err(_) => {
4462- return SPFOutput::new()
4463+ return output
4464 .with_result(SPFResult::TempError)
4465- .with_report(&spf_record, base_domain);
4466+ .with_report(&spf_record);
4467 }
4468 }
4469 }
4470 @@ -143,9 +143,9 @@ impl Resolver {
4471 ip6_mask,
4472 } => {
4473 if !lookup_limit.can_lookup() {
4474- return SPFOutput::new()
4475+ return output
4476 .with_result(SPFResult::PermError)
4477- .with_report(&spf_record, base_domain);
4478+ .with_report(&spf_record);
4479 }
4480
4481 let mut matches = false;
4482 @@ -156,9 +156,9 @@ impl Resolver {
4483 Ok(records) => {
4484 for record in records.iter() {
4485 if !lookup_limit.can_lookup() {
4486- return SPFOutput::new()
4487+ return output
4488 .with_result(SPFResult::PermError)
4489- .with_report(&spf_record, base_domain);
4490+ .with_report(&spf_record);
4491 }
4492
4493 match self
4494 @@ -171,27 +171,27 @@ impl Resolver {
4495 }
4496 Ok(false) | Err(Error::DNSRecordNotFound(_)) => (),
4497 Err(_) => {
4498- return SPFOutput::new()
4499+ return output
4500 .with_result(SPFResult::TempError)
4501- .with_report(&spf_record, base_domain);
4502+ .with_report(&spf_record);
4503 }
4504 }
4505 }
4506 }
4507 Err(Error::DNSRecordNotFound(_)) => (),
4508 Err(_) => {
4509- return SPFOutput::new()
4510+ return output
4511 .with_result(SPFResult::TempError)
4512- .with_report(&spf_record, base_domain);
4513+ .with_report(&spf_record);
4514 }
4515 }
4516 matches
4517 }
4518 Mechanism::Include { macro_string } => {
4519 if !lookup_limit.can_lookup() {
4520- return SPFOutput::new()
4521+ return output
4522 .with_result(SPFResult::PermError)
4523- .with_report(&spf_record, base_domain);
4524+ .with_report(&spf_record);
4525 }
4526
4527 let target_name = macro_string.eval(&vars, &domain, true);
4528 @@ -213,22 +213,22 @@ impl Resolver {
4529 | Error::InvalidRecordType
4530 | Error::ParseError,
4531 ) => {
4532- return SPFOutput::new()
4533+ return output
4534 .with_result(SPFResult::PermError)
4535- .with_report(&spf_record, base_domain)
4536+ .with_report(&spf_record)
4537 }
4538 Err(_) => {
4539- return SPFOutput::new()
4540+ return output
4541 .with_result(SPFResult::TempError)
4542- .with_report(&spf_record, base_domain)
4543+ .with_report(&spf_record)
4544 }
4545 }
4546 }
4547 Mechanism::Ptr { macro_string } => {
4548 if !lookup_limit.can_lookup() {
4549- return SPFOutput::new()
4550+ return output
4551 .with_result(SPFResult::PermError)
4552- .with_report(&spf_record, base_domain);
4553+ .with_report(&spf_record);
4554 }
4555
4556 let target_addr = macro_string.eval(&vars, &domain, true).to_lowercase();
4557 @@ -254,9 +254,9 @@ impl Resolver {
4558 }
4559 Mechanism::Exists { macro_string } => {
4560 if !lookup_limit.can_lookup() {
4561- return SPFOutput::new()
4562+ return output
4563 .with_result(SPFResult::PermError)
4564- .with_report(&spf_record, base_domain);
4565+ .with_report(&spf_record);
4566 }
4567
4568 if let Ok(result) = self
4569 @@ -265,9 +265,9 @@ impl Resolver {
4570 {
4571 result
4572 } else {
4573- return SPFOutput::new()
4574+ return output
4575 .with_result(SPFResult::TempError)
4576- .with_report(&spf_record, base_domain);
4577+ .with_report(&spf_record);
4578 }
4579 }
4580 };
4581 @@ -295,9 +295,9 @@ impl Resolver {
4582 // Follow redirect
4583 if let (Some(macro_string), None) = (&spf_record.redirect, &result) {
4584 if !lookup_limit.can_lookup() {
4585- return SPFOutput::new()
4586+ return output
4587 .with_result(SPFResult::PermError)
4588- .with_report(&spf_record, base_domain);
4589+ .with_report(&spf_record);
4590 }
4591
4592 let target_name = macro_string.eval(&vars, &domain, true);
4593 @@ -315,14 +315,14 @@ impl Resolver {
4594 | Error::InvalidRecordType
4595 | Error::ParseError,
4596 ) => {
4597- return SPFOutput::new()
4598+ return output
4599 .with_result(SPFResult::PermError)
4600- .with_report(&spf_record, base_domain)
4601+ .with_report(&spf_record)
4602 }
4603 Err(_) => {
4604- return SPFOutput::new()
4605+ return output
4606 .with_result(SPFResult::TempError)
4607- .with_report(&spf_record, base_domain)
4608+ .with_report(&spf_record)
4609 }
4610 }
4611 }
4612 @@ -337,16 +337,16 @@ impl Resolver {
4613 .txt_lookup::<Macro>(macro_string.eval(&vars, &domain, true).to_string())
4614 .await
4615 {
4616- return SPFOutput::new()
4617+ return output
4618 .with_result(SPFResult::Fail)
4619 .with_explanation(macro_string.eval(&vars, &domain, false).to_string())
4620- .with_report(&spf_record, base_domain);
4621+ .with_report(&spf_record);
4622 }
4623 }
4624
4625- SPFOutput::new()
4626+ output
4627 .with_result(result.unwrap_or(SPFResult::Neutral))
4628- .with_report(&spf_record, base_domain)
4629+ .with_report(&spf_record)
4630 }
4631
4632 async fn ip_matches(