Commit
Author: Mauro D [mauro@stalw.art]
Hash: 0f4dd6212d058ff1da17cc76357eba2981944e28
Timestamp: Wed, 04 Jan 2023 17:38:15 +0000 (1 year ago)

+939 -45 +/-21 browse
MTA-STS and SMTP TLS Reporting support.
1diff --git a/Cargo.toml b/Cargo.toml
2index 6bdf063..18c3f76 100644
3--- a/Cargo.toml
4+++ b/Cargo.toml
5 @@ -28,9 +28,9 @@ parking_lot = "0.12.0"
6 ahash = "0.8.0"
7 quick-xml = "0.27.1"
8 serde = { version = "1.0", features = ["derive"] }
9+ serde_json = "1.0"
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/README.md b/README.md
17index 963779c..934c564 100644
18--- a/README.md
19+++ b/README.md
20 @@ -26,7 +26,8 @@ Features:
21 - **Abuse Reporting Format (ARF)**:
22 - Abuse and Authentication failure reporting.
23 - Feedback report parsing and generation.
24-
25+ - **SMTP TLS Reporting**:
26+ - Report parsing and generation.
27
28 ## Usage examples
29
30 @@ -228,6 +229,9 @@ To fuzz the library with `cargo-fuzz`:
31 - [RFC 6591 - Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6591)
32 - [RFC 6650 - Creation and Use of Email Feedback Reports: An Applicability Statement for the Abuse Reporting Format (ARF)](https://datatracker.ietf.org/doc/html/rfc6650)
33
34+ ### SMTP TLS Reporting
35+ - [RFC 8460 - SMTP TLS Reporting](https://datatracker.ietf.org/doc/html/rfc8460)
36+
37 ## License
38
39 Licensed under either of
40 diff --git a/examples/report_arf_generate.rs b/examples/report_arf_generate.rs
41index c37ec71..fa33503 100644
42--- a/examples/report_arf_generate.rs
43+++ b/examples/report_arf_generate.rs
44 @@ -39,7 +39,7 @@ fn main() {
45 .with_spf_dns("v=spf1")
46 .with_identity_alignment(IdentityAlignment::DkimSpf)
47 .with_message(&b"From: hello@world.org\r\nTo: ciao@mundo.org\r\n\r\n"[..])
48- .as_rfc5322(
49+ .to_rfc5322(
50 "no-reply@example.org",
51 "ruf@otherdomain.com",
52 "DMARC Authentication Failure Report",
53 diff --git a/examples/report_dmarc_generate.rs b/examples/report_dmarc_generate.rs
54index b730eb8..51b3b14 100644
55--- a/examples/report_dmarc_generate.rs
56+++ b/examples/report_dmarc_generate.rs
57 @@ -97,7 +97,7 @@ fn main() {
58 .with_human_result("no policy found"),
59 ),
60 )
61- .as_rfc5322(
62+ .to_rfc5322(
63 "initech.net",
64 "Initech Industries",
65 "noreply-dmarc@initech.net",
66 diff --git a/resources/tlsrpt/rpt01.eml b/resources/tlsrpt/rpt01.eml
67new file mode 100644
68index 0000000..e54c9e8
69--- /dev/null
70+++ b/resources/tlsrpt/rpt01.eml
71 @@ -0,0 +1,41 @@
72+ From: tlsrpt@mail.sender.example.com
73+ Date: Fri, May 09 2017 16:54:30 -0800
74+ To: mts-sts-tlsrpt@example.net
75+ Subject: Report Domain: example.net
76+ Submitter: mail.sender.example.com
77+ Report-ID: <735ff.e317+bf22029@example.net>
78+ TLS-Report-Domain: example.net
79+ TLS-Report-Submitter: mail.sender.example.com
80+ MIME-Version: 1.0
81+ Content-Type: multipart/report; report-type="tlsrpt";
82+ boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00"
83+ Content-Language: en-us
84+
85+ This is a multipart message in MIME format.
86+
87+ ------=_NextPart_000_024E_01CC9B0A.AFE54C00
88+ Content-Type: text/plain; charset="us-ascii"
89+ Content-Transfer-Encoding: 7bit
90+
91+ This is an aggregate TLS report from mail.sender.example.com
92+
93+ ------=_NextPart_000_024E_01CC9B0A.AFE54C00
94+ Content-Type: application/tlsrpt+gzip
95+ Content-Transfer-Encoding: base64
96+ Content-Disposition: attachment;
97+ filename="mail.sender.example!example.com!1013662812!1013749130.json.gz"
98+
99+ H4sICCpFtWMAA3JwdDAxLmpzb24uMQCtVVtr2zAYfe+vEN7bmFzZjt3EMLbRhu1hdCUJI+soRpGU
100+ VMy2jCSHZCX/fZIvzVLHpc0WDDHSOfqOznfxwxkwP0fIFc75b6y5yGGOM+bEwLkUWYHzLZw772oU
101+ xZpBifOV3X6o1qp1pbHU0O5qXlN95EUQDSDyZgjF1XPbnFIxWE778H4QhyPz3DoVfNfEJiLXmGjI
102+ 86WwDKUVlKwQUvN89ZE0Ujcu2+CsSFkruYZATi0nRFE48C8I9AMawMEFwXARMQRHg4hhxIZksHgk
103+ FiLlhDNleD8fde/vvMdsD7x4sgf1tmCN3L/u/xSltDS3OAh1AFszqUxmYjCdTdfekYMqVCYoi4Fm
104+ ylrSC9rE4K2bYZ66rWnbJ6Z1OXiT4JU5exgNEHI6oLv+m1FhQuXWgZdEM+rgvVC634le6V1RByu7
105+ w2COKrMMy57caaFxClVJCFNqWZpX8287g4gyt+LCwI+OqK95SyOwlKxDClDwrKSWR5k2b+qoB12x
106+ FVUyVab6sdgIM12x5MS2K9sUXDLal1plOtFUC8w0hryoWxF5MV0MY7wg1PSt58dxb8lJRhhfVwfU
107+ mWtnR7bxXllk9vqMdlzzEOrgd90jXmZMNah0qmAutMlvYWfDP3kTnOaN/0pv9ke1OgIXuZ4XuGH0
108+ Sj/NFXoImFJu578pYTtkZVZ9DWy4e60LFZ+f18NUuZ1p2+wklgc+AE7Be9AMW0AABH4AaPAGTBv7
109+ r4WetuaDbueenN41Tjmtv2FNM708td5o6Iaea8rNjfwT8jA8oQzgApNfZfF/GiV4Bm7HimRYVXBa
110+ RZ+HaJR8T8aTSXIz+Tb/kdx8mn1Jvo6vP5u/8fxyPL4aXx3JzcHKfsbW63dnuz+8byfQUQgAAA==
111+
112+ ------=_NextPart_000_024E_01CC9B0A.AFE54C00--
113 diff --git a/resources/tlsrpt/rpt01.json b/resources/tlsrpt/rpt01.json
114new file mode 100644
115index 0000000..cc27eae
116--- /dev/null
117+++ b/resources/tlsrpt/rpt01.json
118 @@ -0,0 +1,54 @@
119+ {
120+ "organization-name": "Company-X",
121+ "date-range": {
122+ "start-datetime": "2016-04-01T00:00:00Z",
123+ "end-datetime": "2016-04-01T23:59:59Z"
124+ },
125+ "contact-info": "sts-reporting@company-x.example",
126+ "report-id": "5065427c-23d3-47ca-b6e0-946ea0e8c4be",
127+ "policies": [
128+ {
129+ "policy": {
130+ "policy-type": "sts",
131+ "policy-string": [
132+ "version: STSv1",
133+ "mode: testing",
134+ "mx: *.mail.company-y.example",
135+ "max_age: 86400"
136+ ],
137+ "policy-domain": "company-y.example",
138+ "mx-host": [
139+ "*.mail.company-y.example"
140+ ]
141+ },
142+ "summary": {
143+ "total-successful-session-count": 5326,
144+ "total-failure-session-count": 303
145+ },
146+ "failure-details": [
147+ {
148+ "result-type": "certificate-expired",
149+ "sending-mta-ip": "2001:db8:abcd:0012::1",
150+ "receiving-mx-hostname": "mx1.mail.company-y.example",
151+ "failed-session-count": 100
152+ },
153+ {
154+ "result-type": "starttls-not-supported",
155+ "sending-mta-ip": "2001:db8:abcd:0013::1",
156+ "receiving-mx-hostname": "mx2.mail.company-y.example",
157+ "receiving-ip": "203.0.113.56",
158+ "failed-session-count": 200,
159+ "additional-information": "https://reports.company-x.example/report_info ? id = 5065427 c - 23 d3# StarttlsNotSupported "
160+ },
161+ {
162+ "result-type": "validation-failure",
163+ "sending-mta-ip": "198.51.100.62",
164+ "receiving-ip": "203.0.113.58",
165+ "receiving-mx-hostname": "mx-backup.mail.company-y.example",
166+ "failed-session-count": 3,
167+ "failure-reason-code": "X509_V_ERR_PROXY_PATH_LENGTH_EXCEEDED"
168+ }
169+ ]
170+ }
171+ ]
172+ }
173\ No newline at end of file
174 diff --git a/resources/tlsrpt/rpt02.eml b/resources/tlsrpt/rpt02.eml
175new file mode 100644
176index 0000000..afb8687
177--- /dev/null
178+++ b/resources/tlsrpt/rpt02.eml
179 @@ -0,0 +1,64 @@
180+ From: tlsrpt@mail.sender.example.com
181+ Date: Fri, May 09 2017 16:54:30 -0800
182+ To: mts-sts-tlsrpt@example.net
183+ Subject: Report Domain: example.net
184+ Submitter: mail.sender.example.com
185+ Report-ID: <735ff.e317+bf22029@example.net>
186+ TLS-Report-Domain: example.net
187+ TLS-Report-Submitter: mail.sender.example.com
188+ MIME-Version: 1.0
189+ Content-Type: multipart/report; report-type="tlsrpt";
190+ boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00"
191+ Content-Language: en-us
192+
193+ This is a multipart message in MIME format.
194+
195+ ------=_NextPart_000_024E_01CC9B0A.AFE54C00
196+ Content-Type: text/plain; charset="us-ascii"
197+ Content-Transfer-Encoding: 7bit
198+
199+ This is an aggregate TLS report from mail.sender.example.com
200+
201+ ------=_NextPart_000_024E_01CC9B0A.AFE54C00
202+ Content-Type: application/tlsrpt
203+ Content-Disposition: attachment;
204+ filename="mail.sender.example!example.com!1013662812!1013749130.json"
205+
206+ {
207+ "report-id": "2020-01-01T00:00:00Z_example.com",
208+ "date-range": {
209+ "start-datetime": "2020-01-01T00:00:00Z",
210+ "end-datetime": "2020-01-07T23:59:59Z"
211+ },
212+ "organization-name": "Google Inc.",
213+ "contact-info": "smtp-tls-reporting@google.com",
214+ "policies": [
215+ {
216+ "policy": {
217+ "policy-type": "sts",
218+ "policy-string": [
219+ "version: STSv1",
220+ "mode: enforce",
221+ "mx: demo.example.com",
222+ "max_age: 604800"
223+ ],
224+ "policy-domain": "example.com"
225+ },
226+ "summary": {
227+ "total-successful-session-count": 23,
228+ "total-failure-session-count": 1
229+ },
230+ "failure-details": [
231+ {
232+ "result-type": "certificate-host-mismatch",
233+ "sending-mta-ip": "123.123.123.123",
234+ "receiving-ip": "234.234.234.234",
235+ "receiving-mx-hostname": "demo.example.com",
236+ "failed-session-count": 1
237+ }
238+ ]
239+ }
240+ ]
241+ }
242+
243+ ------=_NextPart_000_024E_01CC9B0A.AFE54C00--
244 diff --git a/resources/tlsrpt/rpt02.json b/resources/tlsrpt/rpt02.json
245new file mode 100644
246index 0000000..b8c0b47
247--- /dev/null
248+++ b/resources/tlsrpt/rpt02.json
249 @@ -0,0 +1,36 @@
250+ {
251+ "report-id": "2020-01-01T00:00:00Z_example.com",
252+ "date-range": {
253+ "start-datetime": "2020-01-01T00:00:00Z",
254+ "end-datetime": "2020-01-07T23:59:59Z"
255+ },
256+ "organization-name": "Google Inc.",
257+ "contact-info": "smtp-tls-reporting@google.com",
258+ "policies": [
259+ {
260+ "policy": {
261+ "policy-type": "sts",
262+ "policy-string": [
263+ "version: STSv1",
264+ "mode: enforce",
265+ "mx: demo.example.com",
266+ "max_age: 604800"
267+ ],
268+ "policy-domain": "example.com"
269+ },
270+ "summary": {
271+ "total-successful-session-count": 23,
272+ "total-failure-session-count": 1
273+ },
274+ "failure-details": [
275+ {
276+ "result-type": "certificate-host-mismatch",
277+ "sending-mta-ip": "123.123.123.123",
278+ "receiving-ip": "234.234.234.234",
279+ "receiving-mx-hostname": "demo.example.com",
280+ "failed-session-count": 1
281+ }
282+ ]
283+ }
284+ ]
285+ }
286\ No newline at end of file
287 diff --git a/resources/tlsrpt/rpt03.json b/resources/tlsrpt/rpt03.json
288new file mode 100644
289index 0000000..b6e7285
290--- /dev/null
291+++ b/resources/tlsrpt/rpt03.json
292 @@ -0,0 +1,27 @@
293+ {
294+ "organization-name": "Google Inc.",
295+ "date-range": {
296+ "start-datetime": "2019-04-14T00:00:00Z",
297+ "end-datetime": "2019-04-14T23:59:59Z"
298+ },
299+ "contact-info": "smtp-tls-reporting@google.com",
300+ "report-id": "2019-04-14T00:00:00Z_example.org",
301+ "policies": [
302+ {
303+ "policy": {
304+ "policy-type": "no-policy-found"
305+ },
306+ "summary": {
307+ "total-successful-session-count": 1
308+ }
309+ },
310+ {
311+ "policy": {
312+ "policy-type": "invalid-policy-type"
313+ },
314+ "summary": {
315+ "total-successful-session-count": 1
316+ }
317+ }
318+ ]
319+ }
320\ No newline at end of file
321 diff --git a/src/common/parse.rs b/src/common/parse.rs
322index 0022afc..adf75ba 100644
323--- a/src/common/parse.rs
324+++ b/src/common/parse.rs
325 @@ -42,7 +42,7 @@ pub(crate) trait TagParser: Sized {
326 fn key(&mut self) -> Option<u64>;
327 fn value(&mut self) -> u64;
328 fn text(&mut self, to_lower: bool) -> String;
329- fn text_qp(&mut self, to_lower: bool) -> String;
330+ fn text_qp(&mut self, base: Vec<u8>, to_lower: bool, stop_comma: bool) -> String;
331 fn headers_qp<T: ItemParser>(&mut self) -> Vec<T>;
332 fn number(&mut self) -> Option<u64>;
333 fn items<T: ItemParser>(&mut self) -> Vec<T>;
334 @@ -187,10 +187,9 @@ impl TagParser for Iter<'_, u8> {
335
336 #[inline(always)]
337 #[allow(clippy::while_let_on_iterator)]
338- fn text_qp(&mut self, to_lower: bool) -> String {
339- let mut tag = Vec::with_capacity(20);
340+ fn text_qp(&mut self, mut tag: Vec<u8>, to_lower: bool, stop_comma: bool) -> String {
341 'outer: while let Some(&ch) = self.next() {
342- if ch == b';' {
343+ if ch == b';' || (stop_comma && ch == b',') {
344 break;
345 } else if ch == b'=' {
346 let mut hex1 = 0;
347 diff --git a/src/common/resolver.rs b/src/common/resolver.rs
348index 57bd983..2ecad3a 100644
349--- a/src/common/resolver.rs
350+++ b/src/common/resolver.rs
351 @@ -25,6 +25,7 @@ use trust_dns_resolver::{
352 use crate::{
353 dkim::{Atps, DomainKeyReport},
354 dmarc::Dmarc,
355+ mta_sts::{MtaSts, TlsRpt},
356 spf::{Macro, Spf},
357 Error, Resolver, Txt, MX,
358 };
359 @@ -99,7 +100,7 @@ impl Resolver {
360 })
361 }
362
363- pub(crate) async fn txt_lookup<'x, T: TxtRecordParser + Into<Txt> + UnwrapTxtRecord>(
364+ pub async fn txt_lookup<'x, T: TxtRecordParser + Into<Txt> + UnwrapTxtRecord>(
365 &self,
366 key: impl IntoFqdn<'x>,
367 ) -> crate::Result<Arc<T>> {
368 @@ -396,6 +397,18 @@ impl From<Dmarc> for Txt {
369 }
370 }
371
372+ impl From<MtaSts> for Txt {
373+ fn from(v: MtaSts) -> Self {
374+ Txt::MtaSts(v.into())
375+ }
376+ }
377+
378+ impl From<TlsRpt> for Txt {
379+ fn from(v: TlsRpt) -> Self {
380+ Txt::TlsRpt(v.into())
381+ }
382+ }
383+
384 impl<T: Into<Txt>> From<crate::Result<T>> for Txt {
385 fn from(v: crate::Result<T>) -> Self {
386 match v {
387 @@ -405,7 +418,7 @@ impl<T: Into<Txt>> From<crate::Result<T>> for Txt {
388 }
389 }
390
391- pub(crate) trait UnwrapTxtRecord: Sized {
392+ pub trait UnwrapTxtRecord: Sized {
393 fn unwrap_txt(txt: Txt) -> crate::Result<Arc<Self>>;
394 }
395
396 @@ -469,6 +482,26 @@ impl UnwrapTxtRecord for Dmarc {
397 }
398 }
399
400+ impl UnwrapTxtRecord for MtaSts {
401+ fn unwrap_txt(txt: Txt) -> crate::Result<Arc<Self>> {
402+ match txt {
403+ Txt::MtaSts(a) => Ok(a),
404+ Txt::Error(err) => Err(err),
405+ _ => Err(Error::Io("Invalid record type".to_string())),
406+ }
407+ }
408+ }
409+
410+ impl UnwrapTxtRecord for TlsRpt {
411+ fn unwrap_txt(txt: Txt) -> crate::Result<Arc<Self>> {
412+ match txt {
413+ Txt::TlsRpt(a) => Ok(a),
414+ Txt::Error(err) => Err(err),
415+ _ => Err(Error::Io("Invalid record type".to_string())),
416+ }
417+ }
418+ }
419+
420 pub trait IntoFqdn<'x> {
421 fn into_fqdn(self) -> Cow<'x, str>;
422 }
423 diff --git a/src/dkim/parse.rs b/src/dkim/parse.rs
424index 33ccfe7..4ae0f15 100644
425--- a/src/dkim/parse.rs
426+++ b/src/dkim/parse.rs
427 @@ -94,7 +94,7 @@ impl<'x> Signature<'x> {
428 }
429 D => signature.d = header.text(true).into(),
430 H => signature.h = header.items(),
431- I => signature.i = header.text_qp(true).into(),
432+ I => signature.i = header.text_qp(Vec::with_capacity(20), true, false).into(),
433 L => signature.l = header.number().unwrap_or(0),
434 S => signature.s = header.text(true).into(),
435 T => signature.t = header.number().unwrap_or(0),
436 @@ -318,13 +318,13 @@ impl TxtRecordParser for DomainKeyReport {
437 while let Some(key) = header.key() {
438 match key {
439 RA => {
440- record.ra = header.text_qp(true);
441+ record.ra = header.text_qp(Vec::with_capacity(20), true, false);
442 }
443 RP => {
444 record.rp = std::cmp::min(header.number().unwrap_or(0), 100) as u8;
445 }
446 RS => {
447- record.rs = header.text_qp(false).into();
448+ record.rs = header.text_qp(Vec::with_capacity(20), false, false).into();
449 }
450 RR => {
451 record.rr = 0;
452 diff --git a/src/lib.rs b/src/lib.rs
453index 878e878..4ad1018 100644
454--- a/src/lib.rs
455+++ b/src/lib.rs
456 @@ -36,7 +36,8 @@
457 //! - **Abuse Reporting Format (ARF)**:
458 //! - Abuse and Authentication failure reporting.
459 //! - Feedback report parsing and generation.
460- //!
461+ //! - **SMTP TLS Reporting**:
462+ //! - Report parsing and generation.
463 //!
464 //! ## Usage examples
465 //!
466 @@ -238,6 +239,9 @@
467 //! - [RFC 6591 - Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6591)
468 //! - [RFC 6650 - Creation and Use of Email Feedback Reports: An Applicability Statement for the Abuse Reporting Format (ARF)](https://datatracker.ietf.org/doc/html/rfc6650)
469 //!
470+ //! ### SMTP TLS Reporting
471+ //! - [RFC 8460 - SMTP TLS Reporting](https://datatracker.ietf.org/doc/html/rfc8460)
472+ //!
473 //! ## License
474 //!
475 //! Licensed under either of
476 @@ -265,6 +269,7 @@ use arc::Set;
477 use common::{crypto::HashAlgorithm, headers::Header, lru::LruCache, verify::DomainKey};
478 use dkim::{Atps, Canonicalization, DomainKeyReport};
479 use dmarc::Dmarc;
480+ use mta_sts::{MtaSts, TlsRpt};
481 use spf::{Macro, Spf};
482 use trust_dns_resolver::{proto::op::ResponseCode, TokioAsyncResolver};
483
484 @@ -272,6 +277,7 @@ pub mod arc;
485 pub mod common;
486 pub mod dkim;
487 pub mod dmarc;
488+ pub mod mta_sts;
489 pub mod report;
490 pub mod spf;
491
492 @@ -289,13 +295,15 @@ pub struct Resolver {
493 }
494
495 #[derive(Clone)]
496- pub(crate) enum Txt {
497+ pub enum Txt {
498 Spf(Arc<Spf>),
499 SpfMacro(Arc<Macro>),
500 DomainKey(Arc<DomainKey>),
501 DomainKeyReport(Arc<DomainKeyReport>),
502 Dmarc(Arc<Dmarc>),
503 Atps(Arc<Atps>),
504+ MtaSts(Arc<MtaSts>),
505+ TlsRpt(Arc<TlsRpt>),
506 Error(Error),
507 }
508
509 diff --git a/src/mta_sts/mod.rs b/src/mta_sts/mod.rs
510new file mode 100644
511index 0000000..b090406
512--- /dev/null
513+++ b/src/mta_sts/mod.rs
514 @@ -0,0 +1,17 @@
515+ pub mod parse;
516+
517+ #[derive(Debug, PartialEq, Eq)]
518+ pub struct MtaSts {
519+ pub id: String,
520+ }
521+
522+ #[derive(Debug, PartialEq, Eq)]
523+ pub struct TlsRpt {
524+ rua: Vec<ReportUri>,
525+ }
526+
527+ #[derive(Debug, PartialEq, Eq)]
528+ pub enum ReportUri {
529+ Mail(String),
530+ Http(String),
531+ }
532 diff --git a/src/mta_sts/parse.rs b/src/mta_sts/parse.rs
533new file mode 100644
534index 0000000..be8ede3
535--- /dev/null
536+++ b/src/mta_sts/parse.rs
537 @@ -0,0 +1,162 @@
538+ use crate::common::parse::{TagParser, TxtRecordParser, V};
539+
540+ use super::{MtaSts, ReportUri, TlsRpt};
541+
542+ const ID: u64 = (b'i' as u64) | ((b'd' as u64) << 8);
543+ const RUA: u64 = (b'r' as u64) | (b'u' as u64) << 8 | (b'a' as u64) << 16;
544+
545+ const MAILTO: u64 = (b'm' as u64)
546+ | (b'a' as u64) << 8
547+ | (b'i' as u64) << 16
548+ | (b'l' as u64) << 24
549+ | (b't' as u64) << 32
550+ | (b'o' as u64) << 40;
551+ const HTTPS: u64 = (b'h' as u64)
552+ | (b't' as u64) << 8
553+ | (b't' as u64) << 16
554+ | (b'p' as u64) << 24
555+ | (b's' as u64) << 32;
556+
557+ impl TxtRecordParser for MtaSts {
558+ #[allow(clippy::while_let_on_iterator)]
559+ fn parse(record: &[u8]) -> crate::Result<Self> {
560+ let mut record = record.iter();
561+ let mut id = None;
562+ let mut has_version = false;
563+
564+ while let Some(key) = record.key() {
565+ match key {
566+ V => {
567+ if !record.match_bytes(b"STSv1") || !record.seek_tag_end() {
568+ return Err(crate::Error::InvalidRecordType);
569+ }
570+ has_version = true;
571+ }
572+ ID => {
573+ id = record.text(false).into();
574+ }
575+ _ => {
576+ record.ignore();
577+ }
578+ }
579+ }
580+
581+ if let Some(id) = id {
582+ if has_version {
583+ return Ok(MtaSts { id });
584+ }
585+ }
586+ Err(crate::Error::InvalidRecordType)
587+ }
588+ }
589+
590+ impl TxtRecordParser for TlsRpt {
591+ #[allow(clippy::while_let_on_iterator)]
592+ fn parse(record: &[u8]) -> crate::Result<Self> {
593+ let mut record = record.iter();
594+
595+ if record.key().unwrap_or(0) != V
596+ || !record.match_bytes(b"TLSRPTv1")
597+ || !record.seek_tag_end()
598+ {
599+ return Err(crate::Error::InvalidRecordType);
600+ }
601+
602+ let mut rua = Vec::new();
603+
604+ while let Some(key) = record.key() {
605+ match key {
606+ RUA => loop {
607+ match record.flag_value() {
608+ (MAILTO, b':') => {
609+ let mail_to = record.text_qp(Vec::with_capacity(20), false, true);
610+ if !mail_to.is_empty() {
611+ rua.push(ReportUri::Mail(mail_to));
612+ }
613+ }
614+ (HTTPS, b':') => {
615+ let mut url = Vec::with_capacity(20);
616+ url.extend_from_slice(b"https:");
617+ let url = record.text_qp(url, false, true);
618+ if !url.is_empty() {
619+ rua.push(ReportUri::Http(url));
620+ }
621+ }
622+ _ => {
623+ record.ignore();
624+ break;
625+ }
626+ }
627+ },
628+ _ => {
629+ record.ignore();
630+ }
631+ }
632+ }
633+
634+ if !rua.is_empty() {
635+ Ok(TlsRpt { rua })
636+ } else {
637+ Err(crate::Error::InvalidRecordType)
638+ }
639+ }
640+ }
641+
642+ #[cfg(test)]
643+ mod tests {
644+ use crate::{
645+ common::parse::TxtRecordParser,
646+ mta_sts::{MtaSts, ReportUri, TlsRpt},
647+ };
648+
649+ #[test]
650+ fn mta_sts_record_parse() {
651+ for (mta_sts, expected_mta_sts) in [
652+ (
653+ "v=STSv1; id=20160831085700Z;",
654+ MtaSts {
655+ id: "20160831085700Z".to_string(),
656+ },
657+ ),
658+ (
659+ "v=STSv1; id=20190429T010101",
660+ MtaSts {
661+ id: "20190429T010101".to_string(),
662+ },
663+ ),
664+ ] {
665+ assert_eq!(MtaSts::parse(mta_sts.as_bytes()).unwrap(), expected_mta_sts);
666+ }
667+ }
668+
669+ #[test]
670+ fn tlsrpt_parse() {
671+ for (tls_rpt, expected_tls_rpt) in [
672+ (
673+ "v=TLSRPTv1;rua=mailto:reports@example.com",
674+ TlsRpt {
675+ rua: vec![ReportUri::Mail("reports@example.com".to_string())],
676+ },
677+ ),
678+ (
679+ "v=TLSRPTv1; rua=https://reporting.example.com/v1/tlsrpt",
680+ TlsRpt {
681+ rua: vec![ReportUri::Http(
682+ "https://reporting.example.com/v1/tlsrpt".to_string(),
683+ )],
684+ },
685+ ),
686+ (
687+ "v=TLSRPTv1; rua=mailto:tlsrpt@mydomain.com,https://tlsrpt.mydomain.com/v1",
688+ TlsRpt {
689+ rua: vec![
690+ ReportUri::Mail("tlsrpt@mydomain.com".to_string()),
691+ ReportUri::Http("https://tlsrpt.mydomain.com/v1".to_string()),
692+ ],
693+ },
694+ ),
695+ ] {
696+ assert_eq!(TlsRpt::parse(tls_rpt.as_bytes()).unwrap(), expected_tls_rpt);
697+ }
698+ }
699+ }
700 diff --git a/src/report/arf/generate.rs b/src/report/arf/generate.rs
701index 75a2309..68fad46 100644
702--- a/src/report/arf/generate.rs
703+++ b/src/report/arf/generate.rs
704 @@ -29,7 +29,7 @@ impl<'x> Feedback<'x> {
705 ) -> io::Result<()> {
706 // Generate ARF
707
708- let arf = self.as_arf();
709+ let arf = self.to_arf();
710
711 // Generate text/plain body
712 let mut text_body = String::with_capacity(128);
713 @@ -101,13 +101,13 @@ impl<'x> Feedback<'x> {
714 .write_to(writer)
715 }
716
717- pub fn as_rfc5322(&self, from: &str, to: &str, subject: &str) -> io::Result<String> {
718+ pub fn to_rfc5322(&self, from: &str, to: &str, subject: &str) -> io::Result<String> {
719 let mut buf = Vec::new();
720 self.write_rfc5322(from, to, subject, &mut buf)?;
721 String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::Other, err))
722 }
723
724- pub fn as_arf(&self) -> String {
725+ pub fn to_arf(&self) -> String {
726 let mut arf = String::with_capacity(128);
727
728 write!(&mut arf, "Version: {}\r\n", self.version).ok();
729 @@ -275,7 +275,7 @@ mod test {
730 .with_message(&b"From: hello@world.org\r\nTo: ciao@mundo.org\r\n\r\n"[..]);
731
732 let message = feedback
733- .as_rfc5322(
734+ .to_rfc5322(
735 "no-reply@example.org",
736 "ruf@otherdomain.com",
737 "DMARC Authentication Failure Report",
738 diff --git a/src/report/dmarc/generate.rs b/src/report/dmarc/generate.rs
739index e35f00e..1d53148 100644
740--- a/src/report/dmarc/generate.rs
741+++ b/src/report/dmarc/generate.rs
742 @@ -33,7 +33,7 @@ impl Report {
743 writer: impl io::Write,
744 ) -> io::Result<()> {
745 // Compress XML report
746- let xml = self.as_xnl();
747+ let xml = self.to_xml();
748 let mut e = GzEncoder::new(Vec::with_capacity(xml.len()), Compression::default());
749 io::Write::write_all(&mut e, xml.as_bytes())?;
750 let compressed_bytes = e.finish()?;
751 @@ -74,7 +74,7 @@ impl Report {
752 .write_to(writer)
753 }
754
755- pub fn as_rfc5322<'x>(
756+ pub fn to_rfc5322<'x>(
757 &self,
758 receiver_domain: &'x str,
759 submitter: &'x str,
760 @@ -86,17 +86,17 @@ impl Report {
761 String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::Other, err))
762 }
763
764- pub fn as_xnl(&self) -> String {
765+ pub fn to_xml(&self) -> String {
766 let mut xml = String::with_capacity(128);
767 writeln!(&mut xml, "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>").ok();
768 writeln!(&mut xml, "<feedback>").ok();
769 if self.version != 0.0 {
770 writeln!(&mut xml, "\t<version>{}</version>", self.version).ok();
771 }
772- self.report_metadata.as_xml(&mut xml);
773- self.policy_published.as_xml(&mut xml);
774+ self.report_metadata.to_xml(&mut xml);
775+ self.policy_published.to_xml(&mut xml);
776 for record in &self.record {
777- record.as_xml(&mut xml);
778+ record.to_xml(&mut xml);
779 }
780 writeln!(&mut xml, "</feedback>").ok();
781 xml
782 @@ -104,7 +104,7 @@ impl Report {
783 }
784
785 impl ReportMetadata {
786- pub(crate) fn as_xml(&self, xml: &mut String) {
787+ pub(crate) fn to_xml(&self, xml: &mut String) {
788 writeln!(xml, "\t<report_metadata>").ok();
789 writeln!(
790 xml,
791 @@ -127,7 +127,7 @@ impl ReportMetadata {
792 escape_xml(&self.report_id)
793 )
794 .ok();
795- self.date_range.as_xml(xml);
796+ self.date_range.to_xml(xml);
797 for error in &self.error {
798 writeln!(xml, "\t\t<error>{}</error>", escape_xml(error)).ok();
799 }
800 @@ -136,7 +136,7 @@ impl ReportMetadata {
801 }
802
803 impl PolicyPublished {
804- pub(crate) fn as_xml(&self, xml: &mut String) {
805+ pub(crate) fn to_xml(&self, xml: &mut String) {
806 writeln!(xml, "\t<policy_published>").ok();
807 writeln!(xml, "\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
808 if let Some(vp) = &self.version_published {
809 @@ -157,7 +157,7 @@ impl PolicyPublished {
810 }
811
812 impl DateRange {
813- pub(crate) fn as_xml(&self, xml: &mut String) {
814+ pub(crate) fn to_xml(&self, xml: &mut String) {
815 writeln!(xml, "\t\t<date_range>").ok();
816 writeln!(xml, "\t\t\t<begin>{}</begin>", self.begin).ok();
817 writeln!(xml, "\t\t\t<end>{}</end>", self.end).ok();
818 @@ -166,27 +166,27 @@ impl DateRange {
819 }
820
821 impl Record {
822- pub(crate) fn as_xml(&self, xml: &mut String) {
823+ pub(crate) fn to_xml(&self, xml: &mut String) {
824 writeln!(xml, "\t<record>").ok();
825- self.row.as_xml(xml);
826- self.identifiers.as_xml(xml);
827- self.auth_results.as_xml(xml);
828+ self.row.to_xml(xml);
829+ self.identifiers.to_xml(xml);
830+ self.auth_results.to_xml(xml);
831 writeln!(xml, "\t</record>").ok();
832 }
833 }
834
835 impl Row {
836- pub(crate) fn as_xml(&self, xml: &mut String) {
837+ pub(crate) fn to_xml(&self, xml: &mut String) {
838 writeln!(xml, "\t\t<row>").ok();
839 writeln!(xml, "\t\t\t<source_ip>{}</source_ip>", self.source_ip).ok();
840 writeln!(xml, "\t\t\t<count>{}</count>", self.count).ok();
841- self.policy_evaluated.as_xml(xml);
842+ self.policy_evaluated.to_xml(xml);
843 writeln!(xml, "\t\t</row>").ok();
844 }
845 }
846
847 impl PolicyEvaluated {
848- pub(crate) fn as_xml(&self, xml: &mut String) {
849+ pub(crate) fn to_xml(&self, xml: &mut String) {
850 writeln!(xml, "\t\t\t<policy_evaluated>").ok();
851 writeln!(
852 xml,
853 @@ -197,14 +197,14 @@ impl PolicyEvaluated {
854 writeln!(xml, "\t\t\t\t<dkim>{}</dkim>", self.dkim).ok();
855 writeln!(xml, "\t\t\t\t<spf>{}</spf>", self.spf).ok();
856 for reason in &self.reason {
857- reason.as_xml(xml);
858+ reason.to_xml(xml);
859 }
860 writeln!(xml, "\t\t\t</policy_evaluated>").ok();
861 }
862 }
863
864 impl PolicyOverrideReason {
865- pub(crate) fn as_xml(&self, xml: &mut String) {
866+ pub(crate) fn to_xml(&self, xml: &mut String) {
867 writeln!(xml, "\t\t\t\t<reason>").ok();
868 writeln!(xml, "\t\t\t\t\t<type>{}</type>", self.type_).ok();
869 if let Some(comment) = &self.comment {
870 @@ -215,7 +215,7 @@ impl PolicyOverrideReason {
871 }
872
873 impl Identifier {
874- pub(crate) fn as_xml(&self, xml: &mut String) {
875+ pub(crate) fn to_xml(&self, xml: &mut String) {
876 writeln!(xml, "\t\t<identifiers>").ok();
877 if let Some(envelope_to) = &self.envelope_to {
878 writeln!(
879 @@ -242,20 +242,20 @@ impl Identifier {
880 }
881
882 impl AuthResult {
883- pub(crate) fn as_xml(&self, xml: &mut String) {
884+ pub(crate) fn to_xml(&self, xml: &mut String) {
885 writeln!(xml, "\t\t<auth_results>").ok();
886 for dkim in &self.dkim {
887- dkim.as_xml(xml);
888+ dkim.to_xml(xml);
889 }
890 for spf in &self.spf {
891- spf.as_xml(xml);
892+ spf.to_xml(xml);
893 }
894 writeln!(xml, "\t\t</auth_results>").ok();
895 }
896 }
897
898 impl DKIMAuthResult {
899- pub(crate) fn as_xml(&self, xml: &mut String) {
900+ pub(crate) fn to_xml(&self, xml: &mut String) {
901 writeln!(xml, "\t\t\t<dkim>").ok();
902 writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
903 writeln!(
904 @@ -278,7 +278,7 @@ impl DKIMAuthResult {
905 }
906
907 impl SPFAuthResult {
908- pub(crate) fn as_xml(&self, xml: &mut String) {
909+ pub(crate) fn to_xml(&self, xml: &mut String) {
910 writeln!(xml, "\t\t\t<spf>").ok();
911 writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok();
912 writeln!(xml, "\t\t\t\t<scope>{}</scope>", self.scope).ok();
913 @@ -512,7 +512,7 @@ mod test {
914 );
915
916 let message = report
917- .as_rfc5322(
918+ .to_rfc5322(
919 "initech.net",
920 "Initech Industries",
921 "noreply-dmarc@initech.net",
922 diff --git a/src/report/mod.rs b/src/report/mod.rs
923index 242e8cf..487cf59 100644
924--- a/src/report/mod.rs
925+++ b/src/report/mod.rs
926 @@ -10,6 +10,7 @@
927
928 pub mod arf;
929 pub mod dmarc;
930+ pub mod tlsrpt;
931
932 use std::{
933 borrow::Cow,
934 diff --git a/src/report/tlsrpt/generate.rs b/src/report/tlsrpt/generate.rs
935new file mode 100644
936index 0000000..fc0163b
937--- /dev/null
938+++ b/src/report/tlsrpt/generate.rs
939 @@ -0,0 +1,132 @@
940+ use std::io;
941+
942+ use flate2::{write::GzEncoder, Compression};
943+ use mail_builder::{
944+ headers::{content_type::ContentType, HeaderType},
945+ mime::{BodyPart, MimePart},
946+ MessageBuilder,
947+ };
948+
949+ use super::TlsReport;
950+
951+ impl TlsReport {
952+ pub fn write_rfc5322<'x>(
953+ &self,
954+ report_domain: &'x str,
955+ receiver_domain: &'x str,
956+ submitter: &'x str,
957+ from: &'x str,
958+ to: &'x str,
959+ writer: impl io::Write,
960+ ) -> io::Result<()> {
961+ // Compress JSON report
962+ let json = self.to_json();
963+ let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default());
964+ io::Write::write_all(&mut e, json.as_bytes())?;
965+ let compressed_bytes = e.finish()?;
966+
967+ MessageBuilder::new()
968+ .header("From", HeaderType::Text(from.into()))
969+ .header("To", HeaderType::Text(to.into()))
970+ .header("TLS-Report-Domain", HeaderType::Text(report_domain.into()))
971+ .header("TLS-Report-Submitter", HeaderType::Text(submitter.into()))
972+ .header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
973+ .subject(format!(
974+ "Report Domain: {} Submitter: {} Report-ID: <{}>",
975+ report_domain, submitter, self.report_id
976+ ))
977+ .body(MimePart::new(
978+ ContentType::new("multipart/report").attribute("report-type", "tlsrpt"),
979+ BodyPart::Multipart(vec![
980+ MimePart::new(
981+ ContentType::new("text/plain"),
982+ BodyPart::Text(
983+ format!(
984+ concat!(
985+ "TLS report from {}\r\n\r\n",
986+ "Report Domain: {}\r\n",
987+ "Submitter: {}\r\n",
988+ "Report-ID: {}\r\n",
989+ ),
990+ receiver_domain, report_domain, submitter, self.report_id
991+ )
992+ .into(),
993+ ),
994+ ),
995+ MimePart::new(
996+ ContentType::new("application/tlsrpt+gzip"),
997+ BodyPart::Binary(compressed_bytes.into()),
998+ )
999+ .attachment(format!(
1000+ "{}!{}!{}!{}.json.gz",
1001+ receiver_domain,
1002+ report_domain,
1003+ self.date_range.start_datetime.to_timestamp(),
1004+ self.date_range.end_datetime.to_timestamp()
1005+ )),
1006+ ]),
1007+ ))
1008+ .write_to(writer)
1009+ }
1010+
1011+ pub fn to_rfc5322<'x>(
1012+ &self,
1013+ report_domain: &'x str,
1014+ receiver_domain: &'x str,
1015+ submitter: &'x str,
1016+ from: &'x str,
1017+ to: &'x str,
1018+ ) -> io::Result<String> {
1019+ let mut buf = Vec::new();
1020+ self.write_rfc5322(
1021+ report_domain,
1022+ receiver_domain,
1023+ submitter,
1024+ from,
1025+ to,
1026+ &mut buf,
1027+ )?;
1028+ String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::Other, err))
1029+ }
1030+
1031+ pub fn to_json(&self) -> String {
1032+ serde_json::to_string(self).unwrap_or_default()
1033+ }
1034+ }
1035+
1036+ #[cfg(test)]
1037+ mod test {
1038+ use mail_parser::DateTime;
1039+
1040+ use crate::report::tlsrpt::{DateRange, TlsReport};
1041+
1042+ #[test]
1043+ fn tlsrpt_generate() {
1044+ let report = TlsReport {
1045+ organization_name: "Hello World, Inc.".to_string(),
1046+ date_range: DateRange {
1047+ start_datetime: DateTime::from_timestamp(49823749),
1048+ end_datetime: DateTime::from_timestamp(49823899),
1049+ },
1050+ contact_info: "tls-report@hello-world.inc".to_string(),
1051+ report_id: "abc-123".to_string(),
1052+ policies: vec![],
1053+ };
1054+
1055+ let message = report
1056+ .to_rfc5322(
1057+ "hello-world.inc",
1058+ "example.org",
1059+ "mx.example.org",
1060+ "no-reply@example.org",
1061+ "tls-reports@hello-world.inc",
1062+ )
1063+ .unwrap();
1064+
1065+ println!("{}", message);
1066+
1067+ let parsed_report = TlsReport::parse_rfc5322(message.as_bytes()).unwrap();
1068+
1069+ assert_eq!(report, parsed_report);
1070+ }
1071+ }
1072 diff --git a/src/report/tlsrpt/mod.rs b/src/report/tlsrpt/mod.rs
1073new file mode 100644
1074index 0000000..a301d6e
1075--- /dev/null
1076+++ b/src/report/tlsrpt/mod.rs
1077 @@ -0,0 +1,170 @@
1078+ use std::net::IpAddr;
1079+
1080+ use mail_parser::DateTime;
1081+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
1082+
1083+ pub mod generate;
1084+ pub mod parse;
1085+
1086+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1087+ pub struct TlsReport {
1088+ #[serde(rename = "organization-name")]
1089+ #[serde(default)]
1090+ pub organization_name: String,
1091+
1092+ #[serde(rename = "date-range")]
1093+ pub date_range: DateRange,
1094+
1095+ #[serde(rename = "contact-info")]
1096+ #[serde(default)]
1097+ pub contact_info: String,
1098+
1099+ #[serde(rename = "report-id")]
1100+ #[serde(default)]
1101+ pub report_id: String,
1102+
1103+ #[serde(rename = "policies")]
1104+ #[serde(default)]
1105+ pub policies: Vec<Policy>,
1106+ }
1107+
1108+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1109+ pub struct Policy {
1110+ #[serde(rename = "policy")]
1111+ pub policy: PolicyDetails,
1112+
1113+ #[serde(rename = "summary")]
1114+ pub summary: Summary,
1115+
1116+ #[serde(rename = "failure-details")]
1117+ #[serde(default)]
1118+ pub failure_details: Vec<FailureDetails>,
1119+ }
1120+
1121+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1122+ pub struct PolicyDetails {
1123+ #[serde(rename = "policy-type")]
1124+ pub policy_type: PolicyType,
1125+
1126+ #[serde(rename = "policy-string")]
1127+ #[serde(default)]
1128+ pub policy_string: Vec<String>,
1129+
1130+ #[serde(rename = "policy-domain")]
1131+ #[serde(default)]
1132+ pub policy_domain: String,
1133+
1134+ #[serde(rename = "mx-host")]
1135+ #[serde(default)]
1136+ pub mx_host: Vec<String>,
1137+ }
1138+
1139+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1140+ pub struct Summary {
1141+ #[serde(rename = "total-successful-session-count")]
1142+ #[serde(default)]
1143+ pub total_success: u32,
1144+
1145+ #[serde(rename = "total-failure-session-count")]
1146+ #[serde(default)]
1147+ pub total_failure: u32,
1148+ }
1149+
1150+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1151+ pub struct FailureDetails {
1152+ #[serde(rename = "result-type")]
1153+ pub result_type: ResultType,
1154+
1155+ #[serde(rename = "sending-mta-ip")]
1156+ pub sending_mta_ip: Option<IpAddr>,
1157+
1158+ #[serde(rename = "receiving-mx-hostname")]
1159+ #[serde(default)]
1160+ pub receiving_mx_hostname: String,
1161+
1162+ #[serde(rename = "receiving-mx-helo")]
1163+ pub receiving_mx_helo: Option<String>,
1164+
1165+ #[serde(rename = "receiving-ip")]
1166+ pub receiving_ip: Option<IpAddr>,
1167+
1168+ #[serde(rename = "failed-session-count")]
1169+ #[serde(default)]
1170+ pub failed_session_count: u32,
1171+
1172+ #[serde(rename = "additional-information")]
1173+ pub additional_information: Option<String>,
1174+
1175+ #[serde(rename = "failure-reason-code")]
1176+ #[serde(default)]
1177+ pub failure_reason_code: String,
1178+ }
1179+
1180+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1181+ pub struct DateRange {
1182+ #[serde(rename = "start-datetime")]
1183+ #[serde(serialize_with = "serialize_datetime")]
1184+ #[serde(deserialize_with = "deserialize_datetime")]
1185+ pub start_datetime: DateTime,
1186+ #[serde(rename = "end-datetime")]
1187+ #[serde(serialize_with = "serialize_datetime")]
1188+ #[serde(deserialize_with = "deserialize_datetime")]
1189+ pub end_datetime: DateTime,
1190+ }
1191+
1192+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1193+ pub enum PolicyType {
1194+ #[serde(rename = "tlsa")]
1195+ Tlsa,
1196+ #[serde(rename = "sts")]
1197+ Sts,
1198+ #[serde(rename = "no-policy-found")]
1199+ NoPolicyFound,
1200+ #[serde(other)]
1201+ Other,
1202+ }
1203+
1204+ #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1205+ pub enum ResultType {
1206+ #[serde(rename = "starttls-not-supported")]
1207+ StartTlsNotSupported,
1208+ #[serde(rename = "certificate-host-mismatch")]
1209+ CertificateHostMismatch,
1210+ #[serde(rename = "certificate-expired")]
1211+ CertificateExpired,
1212+ #[serde(rename = "certificate-not-trusted")]
1213+ CertificateNotTrusted,
1214+ #[serde(rename = "validation-failure")]
1215+ ValidationFailure,
1216+ #[serde(rename = "tlsa-invalid")]
1217+ TlsaInvalid,
1218+ #[serde(rename = "dnssec-invalid")]
1219+ DnssecInvalid,
1220+ #[serde(rename = "dane-required")]
1221+ DaneRequired,
1222+ #[serde(rename = "sts-policy-fetch-error")]
1223+ StsPolicyFetchError,
1224+ #[serde(rename = "sts-policy-invalid")]
1225+ StsPolicyInvalid,
1226+ #[serde(rename = "sts-webpki-invalid")]
1227+ StsWebpkiInvalid,
1228+ #[serde(other)]
1229+ Other,
1230+ }
1231+
1232+ fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
1233+ where
1234+ D: Deserializer<'de>,
1235+ {
1236+ Ok(
1237+ DateTime::parse_rfc3339(Deserialize::deserialize(deserializer)?)
1238+ .unwrap_or_else(|| DateTime::from_timestamp(0)),
1239+ )
1240+ }
1241+
1242+ fn serialize_datetime<S>(datetime: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
1243+ where
1244+ S: Serializer,
1245+ {
1246+ serializer.serialize_str(&datetime.to_rfc3339())
1247+ }
1248 diff --git a/src/report/tlsrpt/parse.rs b/src/report/tlsrpt/parse.rs
1249new file mode 100644
1250index 0000000..8ed7cc0
1251--- /dev/null
1252+++ b/src/report/tlsrpt/parse.rs
1253 @@ -0,0 +1,146 @@
1254+ use std::io::{Cursor, Read};
1255+
1256+ use flate2::read::GzDecoder;
1257+ use mail_parser::{Message, MimeHeaders, PartType};
1258+ use zip::ZipArchive;
1259+
1260+ use crate::report::Error;
1261+
1262+ use super::TlsReport;
1263+
1264+ impl TlsReport {
1265+ pub fn parse_json(report: &[u8]) -> Result<Self, Error> {
1266+ serde_json::from_slice(report).map_err(|err| Error::ReportParseError(err.to_string()))
1267+ }
1268+
1269+ pub fn parse_rfc5322(report: &[u8]) -> Result<Self, Error> {
1270+ let message = Message::parse(report).ok_or(Error::MailParseError)?;
1271+ let mut error = Error::NoReportsFound;
1272+
1273+ for part in &message.parts {
1274+ match &part.body {
1275+ PartType::Binary(report) | PartType::InlineBinary(report) => {
1276+ enum ReportType {
1277+ Json,
1278+ Gzip,
1279+ Zip,
1280+ }
1281+
1282+ let (_, ext) = part
1283+ .attachment_name()
1284+ .unwrap_or("file.none")
1285+ .rsplit_once('.')
1286+ .unwrap_or(("file", "none"));
1287+ let subtype = part
1288+ .content_type()
1289+ .and_then(|ct| ct.subtype())
1290+ .unwrap_or("none");
1291+ let rt = if subtype.eq_ignore_ascii_case("tlsrpt+gzip") {
1292+ ReportType::Gzip
1293+ } else if subtype.eq_ignore_ascii_case("tlsrpt+zip") {
1294+ ReportType::Zip
1295+ } else if subtype.eq_ignore_ascii_case("tlsrpt+json") {
1296+ ReportType::Json
1297+ } else if ext.eq_ignore_ascii_case("gz") {
1298+ ReportType::Gzip
1299+ } else if ext.eq_ignore_ascii_case("zip") {
1300+ ReportType::Zip
1301+ } else if ext.eq_ignore_ascii_case("json") {
1302+ ReportType::Json
1303+ } else {
1304+ continue;
1305+ };
1306+
1307+ match rt {
1308+ ReportType::Gzip => {
1309+ let mut file = GzDecoder::new(report.as_ref());
1310+ let mut buf = Vec::new();
1311+ file.read_to_end(&mut buf)
1312+ .map_err(|err| Error::UncompressError(err.to_string()))?;
1313+
1314+ match Self::parse_json(&buf) {
1315+ Ok(report) => return Ok(report),
1316+ Err(err) => {
1317+ error = err;
1318+ }
1319+ }
1320+ }
1321+ ReportType::Zip => {
1322+ let mut archive = ZipArchive::new(Cursor::new(report.as_ref()))
1323+ .map_err(|err| Error::UncompressError(err.to_string()))?;
1324+ for i in 0..archive.len() {
1325+ match archive.by_index(i) {
1326+ Ok(mut file) => {
1327+ let mut buf =
1328+ Vec::with_capacity(file.compressed_size() as usize);
1329+ file.read_to_end(&mut buf).map_err(|err| {
1330+ Error::UncompressError(err.to_string())
1331+ })?;
1332+ match Self::parse_json(&buf) {
1333+ Ok(report) => return Ok(report),
1334+ Err(err) => {
1335+ error = err;
1336+ }
1337+ }
1338+ }
1339+ Err(err) => {
1340+ error = Error::UncompressError(err.to_string());
1341+ }
1342+ }
1343+ }
1344+ }
1345+ ReportType::Json => match Self::parse_json(report) {
1346+ Ok(report) => return Ok(report),
1347+ Err(err) => {
1348+ error = err;
1349+ }
1350+ },
1351+ }
1352+ }
1353+ _ => (),
1354+ }
1355+ }
1356+
1357+ Err(error)
1358+ }
1359+ }
1360+
1361+ #[cfg(test)]
1362+ mod tests {
1363+ use std::{fs, path::PathBuf};
1364+
1365+ use crate::report::tlsrpt::TlsReport;
1366+
1367+ #[test]
1368+ fn tlsrpt_parse() {
1369+ // Add dns entries
1370+ let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1371+ path.push("resources");
1372+ path.push("tlsrpt");
1373+
1374+ for file in fs::read_dir(&path).unwrap() {
1375+ let file = file.as_ref().unwrap().path();
1376+ if !file.extension().map_or(false, |e| e == "json") {
1377+ continue;
1378+ }
1379+ let rpt = TlsReport::parse_json(&fs::read(&file).unwrap())
1380+ .unwrap_or_else(|err| panic!("Failed to parse {}: {:?}", file.display(), err));
1381+ let rpt_check: TlsReport =
1382+ serde_json::from_str(&serde_json::to_string(&rpt).unwrap()).unwrap();
1383+ assert_eq!(rpt, rpt_check);
1384+ }
1385+
1386+ for file in fs::read_dir(&path).unwrap() {
1387+ let mut file = file.as_ref().unwrap().path();
1388+ if !file.extension().map_or(false, |e| e == "eml") {
1389+ continue;
1390+ }
1391+ let rpt = TlsReport::parse_rfc5322(&fs::read(&file).unwrap())
1392+ .unwrap_or_else(|err| panic!("Failed to parse {}: {:?}", file.display(), err));
1393+ file.set_extension("json");
1394+ let rpt_check = TlsReport::parse_json(&fs::read(&file).unwrap())
1395+ .unwrap_or_else(|err| panic!("Failed to parse {}: {:?}", file.display(), err));
1396+ assert_eq!(rpt, rpt_check);
1397+ }
1398+ }
1399+ }