Skip to main content

mail_auth/report/
mod.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7pub mod arf;
8pub mod dmarc;
9pub mod tlsrpt;
10use serde::{Deserialize, Serialize};
11use std::{borrow::Cow, io::Read, net::IpAddr};
12
13#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
14#[cfg_attr(
15    feature = "rkyv",
16    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
17)]
18pub struct DateRange {
19    pub begin: u64,
20    pub end: u64,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[cfg_attr(
25    feature = "rkyv",
26    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
27)]
28pub struct ReportMetadata {
29    pub org_name: String,
30    pub email: String,
31    pub extra_contact_info: Option<String>,
32    pub report_id: String,
33    pub date_range: DateRange,
34    pub error: Vec<String>,
35    #[serde(default)]
36    pub generator: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[cfg_attr(
41    feature = "rkyv",
42    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
43)]
44pub enum Alignment {
45    Relaxed,
46    Strict,
47    #[default]
48    Unspecified,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
52#[cfg_attr(
53    feature = "rkyv",
54    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
55)]
56pub enum Disposition {
57    None,
58    Quarantine,
59    Reject,
60    #[default]
61    Unspecified,
62}
63
64#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[cfg_attr(
66    feature = "rkyv",
67    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
68)]
69pub enum ActionDisposition {
70    None,
71    Pass,
72    Quarantine,
73    Reject,
74    #[default]
75    Unspecified,
76}
77
78#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
79#[cfg_attr(
80    feature = "rkyv",
81    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
82)]
83pub struct PolicyPublished {
84    pub domain: String,
85    pub version_published: Option<f32>,
86    pub adkim: Alignment,
87    pub aspf: Alignment,
88    pub p: Disposition,
89    pub sp: Disposition,
90    #[serde(default)]
91    pub np: Disposition,
92    pub testing: bool,
93    #[serde(default)]
94    pub discovery_method: Discovery,
95    pub fo: Option<String>,
96}
97
98impl Eq for PolicyPublished {}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[cfg_attr(
102    feature = "rkyv",
103    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
104)]
105pub enum Discovery {
106    Psl,
107    Treewalk,
108    #[default]
109    Unspecified,
110}
111
112#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[cfg_attr(
114    feature = "rkyv",
115    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
116)]
117pub enum DmarcResult {
118    Pass,
119    Fail,
120    #[default]
121    Unspecified,
122}
123
124#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
125#[cfg_attr(
126    feature = "rkyv",
127    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
128)]
129pub enum PolicyOverride {
130    LocalPolicy,
131    MailingList,
132    PolicyTestMode,
133    TrustedForwarder,
134    #[default]
135    Other,
136}
137
138#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
139#[cfg_attr(
140    feature = "rkyv",
141    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
142)]
143pub struct PolicyOverrideReason {
144    pub type_: PolicyOverride,
145    pub comment: Option<String>,
146}
147
148#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
149#[cfg_attr(
150    feature = "rkyv",
151    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
152)]
153pub struct PolicyEvaluated {
154    pub disposition: ActionDisposition,
155    pub dkim: DmarcResult,
156    pub spf: DmarcResult,
157    pub reason: Vec<PolicyOverrideReason>,
158}
159
160#[derive(Debug, Clone, Hash, Default, PartialEq, Eq, Serialize, Deserialize)]
161#[cfg_attr(
162    feature = "rkyv",
163    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
164)]
165pub struct Row {
166    pub source_ip: Option<IpAddr>,
167    pub count: u32,
168    pub policy_evaluated: PolicyEvaluated,
169}
170
171#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
172#[cfg_attr(
173    feature = "rkyv",
174    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
175)]
176pub struct Extension {
177    pub name: String,
178    pub definition: String,
179}
180
181#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
182#[cfg_attr(
183    feature = "rkyv",
184    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
185)]
186pub struct Identifier {
187    pub envelope_to: Option<String>,
188    pub envelope_from: String,
189    pub header_from: String,
190}
191
192#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
193#[cfg_attr(
194    feature = "rkyv",
195    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
196)]
197pub enum DkimResult {
198    #[default]
199    None,
200    Pass,
201    Fail,
202    Policy,
203    Neutral,
204    TempError,
205    PermError,
206}
207
208#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
209#[cfg_attr(
210    feature = "rkyv",
211    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
212)]
213pub struct DKIMAuthResult {
214    pub domain: String,
215    pub selector: String,
216    pub result: DkimResult,
217    pub human_result: Option<String>,
218}
219
220#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
221#[cfg_attr(
222    feature = "rkyv",
223    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
224)]
225pub enum SPFDomainScope {
226    Helo,
227    MailFrom,
228    #[default]
229    Unspecified,
230}
231
232#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
233#[cfg_attr(
234    feature = "rkyv",
235    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
236)]
237pub enum SpfResult {
238    #[default]
239    None,
240    Neutral,
241    Pass,
242    Fail,
243    SoftFail,
244    TempError,
245    PermError,
246}
247
248#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
249#[cfg_attr(
250    feature = "rkyv",
251    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
252)]
253pub struct SPFAuthResult {
254    pub domain: String,
255    pub scope: SPFDomainScope,
256    pub result: SpfResult,
257    pub human_result: Option<String>,
258}
259
260#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
261#[cfg_attr(
262    feature = "rkyv",
263    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
264)]
265pub struct AuthResult {
266    pub dkim: Vec<DKIMAuthResult>,
267    pub spf: Vec<SPFAuthResult>,
268}
269
270#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
271#[cfg_attr(
272    feature = "rkyv",
273    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
274)]
275pub struct Record {
276    pub row: Row,
277    pub identifiers: Identifier,
278    pub auth_results: AuthResult,
279    pub extensions: Vec<Extension>,
280}
281
282#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
283#[cfg_attr(
284    feature = "rkyv",
285    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
286)]
287pub struct Report {
288    pub version: f32,
289    pub report_metadata: ReportMetadata,
290    pub policy_published: PolicyPublished,
291    pub record: Vec<Record>,
292    pub extensions: Vec<Extension>,
293}
294
295impl Eq for Report {}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum Error {
299    MailParseError,
300    ReportParseError(String),
301    UncompressError(String),
302    ReportTooLarge,
303    NoReportsFound,
304}
305
306const MAX_SIZE_RESERVATION: u64 = 64 * 1024;
307
308pub(crate) fn read_capped(
309    reader: impl Read,
310    size_hint: u64,
311    max_size: usize,
312) -> Result<Vec<u8>, Error> {
313    let max_size = max_size as u64;
314    if size_hint > max_size {
315        return Err(Error::ReportTooLarge);
316    }
317
318    let mut buf = Vec::with_capacity(size_hint.min(MAX_SIZE_RESERVATION) as usize);
319    reader
320        .take(max_size.saturating_add(1))
321        .read_to_end(&mut buf)
322        .map_err(|err| Error::UncompressError(err.to_string()))?;
323
324    if buf.len() as u64 > max_size {
325        return Err(Error::ReportTooLarge);
326    }
327
328    Ok(buf)
329}
330
331impl From<String> for Error {
332    fn from(err: String) -> Self {
333        Error::ReportParseError(err)
334    }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
338#[cfg_attr(
339    feature = "rkyv",
340    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
341)]
342pub struct Feedback<'x> {
343    pub feedback_type: FeedbackType,
344    pub arrival_date: Option<i64>,
345    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
346    pub authentication_results: Vec<Cow<'x, str>>,
347    pub incidents: u32,
348    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
349    pub original_envelope_id: Option<Cow<'x, str>>,
350    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
351    pub original_mail_from: Option<Cow<'x, str>>,
352    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
353    pub original_rcpt_to: Option<Cow<'x, str>>,
354    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
355    pub reported_domain: Vec<Cow<'x, str>>,
356    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
357    pub reported_uri: Vec<Cow<'x, str>>,
358    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
359    pub reporting_mta: Option<Cow<'x, str>>,
360    pub source_ip: Option<IpAddr>,
361    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
362    pub user_agent: Option<Cow<'x, str>>,
363    pub version: u32,
364    pub source_port: u32,
365
366    // Auth-Failure keys
367    pub auth_failure: AuthFailureType,
368    pub delivery_result: DeliveryResult,
369    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
370    pub dkim_adsp_dns: Option<Cow<'x, str>>,
371    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
372    pub dkim_canonicalized_body: Option<Cow<'x, str>>,
373    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
374    pub dkim_canonicalized_header: Option<Cow<'x, str>>,
375    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
376    pub dkim_domain: Option<Cow<'x, str>>,
377    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
378    pub dkim_identity: Option<Cow<'x, str>>,
379    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
380    pub dkim_selector: Option<Cow<'x, str>>,
381    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
382    pub dkim_selector_dns: Option<Cow<'x, str>>,
383    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
384    pub spf_dns: Option<Cow<'x, str>>,
385    pub identity_alignment: IdentityAlignment,
386
387    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
388    pub message: Option<Cow<'x, str>>,
389    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<rkyv::with::AsOwned>))]
390    pub headers: Option<Cow<'x, str>>,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Copy, Serialize, Deserialize, Default)]
394#[cfg_attr(
395    feature = "rkyv",
396    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
397)]
398pub enum AuthFailureType {
399    Adsp,
400    BodyHash,
401    Revoked,
402    Signature,
403    Spf,
404    Dmarc,
405    #[default]
406    Unspecified,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Copy, Serialize, Deserialize, Default)]
410#[cfg_attr(
411    feature = "rkyv",
412    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
413)]
414pub enum IdentityAlignment {
415    None,
416    Spf,
417    Dkim,
418    DkimSpf,
419    #[default]
420    Unspecified,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Copy, Serialize, Deserialize, Default)]
424#[cfg_attr(
425    feature = "rkyv",
426    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
427)]
428pub enum DeliveryResult {
429    Delivered,
430    Spam,
431    Policy,
432    Reject,
433    Other,
434    #[default]
435    Unspecified,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Copy, Serialize, Deserialize, Default)]
439#[cfg_attr(
440    feature = "rkyv",
441    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
442)]
443pub enum FeedbackType {
444    Abuse,
445    AuthFailure,
446    Fraud,
447    NotSpam,
448    #[default]
449    Other,
450    Virus,
451}
452
453impl From<&crate::DkimResult> for AuthFailureType {
454    fn from(value: &crate::DkimResult) -> Self {
455        match value {
456            crate::DkimResult::Neutral(err)
457            | crate::DkimResult::Fail(err)
458            | crate::DkimResult::PermError(err)
459            | crate::DkimResult::TempError(err) => match err {
460                crate::Error::Dkim(crate::dkim::DkimError::FailedBodyHashMatch) => {
461                    AuthFailureType::BodyHash
462                }
463                #[cfg(feature = "arc")]
464                crate::Error::Arc(crate::arc::ArcError::FailedBodyHashMatch) => {
465                    AuthFailureType::BodyHash
466                }
467                crate::Error::Dkim(crate::dkim::DkimError::RevokedPublicKey) => {
468                    AuthFailureType::Revoked
469                }
470                _ => AuthFailureType::Signature,
471            },
472            crate::DkimResult::Pass | crate::DkimResult::None => AuthFailureType::Signature,
473        }
474    }
475}
476
477#[cfg(test)]
478mod test {
479    const MAX_REPORT_SIZE: usize = 25 * 1024 * 1024;
480    use super::{Error, read_capped, test_util::gzip};
481
482    #[test]
483    fn read_capped_rejects_forged_size_hint() {
484        assert_eq!(
485            read_capped(&b"hello"[..], u32::MAX as u64, MAX_REPORT_SIZE),
486            Err(Error::ReportTooLarge)
487        );
488        assert_eq!(
489            read_capped(&b"hello"[..], u64::MAX, MAX_REPORT_SIZE),
490            Err(Error::ReportTooLarge)
491        );
492    }
493
494    #[test]
495    fn read_capped_rejects_oversized_output() {
496        assert_eq!(
497            read_capped(&[0u8; 1024][..], 0, 1023),
498            Err(Error::ReportTooLarge)
499        );
500        assert_eq!(read_capped(&b"hello"[..], 0, 0), Err(Error::ReportTooLarge));
501    }
502
503    #[test]
504    fn read_capped_accepts_within_limit() {
505        assert_eq!(read_capped(&b"hello"[..], 5, 5), Ok(b"hello".to_vec()));
506        assert_eq!(read_capped(&b""[..], 0, 0), Ok(Vec::new()));
507        assert_eq!(
508            read_capped(&b"hello"[..], u32::MAX as u64, u32::MAX as usize),
509            Ok(b"hello".to_vec())
510        );
511    }
512
513    #[test]
514    fn read_capped_bounds_decompressed_output() {
515        let bomb = gzip(&vec![b'a'; 1024 * 1024]);
516        assert!(bomb.len() < 8192);
517
518        assert_eq!(
519            read_capped(flate2::read::GzDecoder::new(&bomb[..]), 0, 64 * 1024),
520            Err(Error::ReportTooLarge)
521        );
522        assert_eq!(
523            read_capped(flate2::read::GzDecoder::new(&bomb[..]), 0, MAX_REPORT_SIZE)
524                .map(|buf| buf.len()),
525            Ok(1024 * 1024)
526        );
527    }
528}
529
530#[cfg(test)]
531pub(crate) mod test_util {
532    use flate2::{Compression, Crc, write::GzEncoder};
533    use mail_builder::{
534        MessageBuilder,
535        mime::{BodyPart, MimePart},
536    };
537    use std::io::Write;
538
539    pub(crate) fn gzip(data: &[u8]) -> Vec<u8> {
540        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
541        encoder.write_all(data).unwrap();
542        encoder.finish().unwrap()
543    }
544
545    pub(crate) fn zip(
546        name: &str,
547        data: &[u8],
548        compressed_size: Option<u32>,
549        uncompressed_size: Option<u32>,
550    ) -> Vec<u8> {
551        let mut crc = Crc::new();
552        crc.update(data);
553        let crc = crc.sum();
554        let compressed_size = compressed_size.unwrap_or(data.len() as u32);
555        let uncompressed_size = uncompressed_size.unwrap_or(data.len() as u32);
556        let name = name.as_bytes();
557
558        let mut out = Vec::new();
559        out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
560        out.extend_from_slice(&20u16.to_le_bytes());
561        out.extend_from_slice(&0u16.to_le_bytes());
562        out.extend_from_slice(&0u16.to_le_bytes());
563        out.extend_from_slice(&0u16.to_le_bytes());
564        out.extend_from_slice(&0u16.to_le_bytes());
565        out.extend_from_slice(&crc.to_le_bytes());
566        out.extend_from_slice(&compressed_size.to_le_bytes());
567        out.extend_from_slice(&uncompressed_size.to_le_bytes());
568        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
569        out.extend_from_slice(&0u16.to_le_bytes());
570        out.extend_from_slice(name);
571        out.extend_from_slice(data);
572
573        let central_offset = out.len() as u32;
574        out.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
575        out.extend_from_slice(&20u16.to_le_bytes());
576        out.extend_from_slice(&20u16.to_le_bytes());
577        out.extend_from_slice(&0u16.to_le_bytes());
578        out.extend_from_slice(&0u16.to_le_bytes());
579        out.extend_from_slice(&0u16.to_le_bytes());
580        out.extend_from_slice(&0u16.to_le_bytes());
581        out.extend_from_slice(&crc.to_le_bytes());
582        out.extend_from_slice(&compressed_size.to_le_bytes());
583        out.extend_from_slice(&uncompressed_size.to_le_bytes());
584        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
585        out.extend_from_slice(&0u16.to_le_bytes());
586        out.extend_from_slice(&0u16.to_le_bytes());
587        out.extend_from_slice(&0u16.to_le_bytes());
588        out.extend_from_slice(&0u16.to_le_bytes());
589        out.extend_from_slice(&0u32.to_le_bytes());
590        out.extend_from_slice(&0u32.to_le_bytes());
591        out.extend_from_slice(name);
592
593        let central_size = out.len() as u32 - central_offset;
594        out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
595        out.extend_from_slice(&0u16.to_le_bytes());
596        out.extend_from_slice(&0u16.to_le_bytes());
597        out.extend_from_slice(&1u16.to_le_bytes());
598        out.extend_from_slice(&1u16.to_le_bytes());
599        out.extend_from_slice(&central_size.to_le_bytes());
600        out.extend_from_slice(&central_offset.to_le_bytes());
601        out.extend_from_slice(&0u16.to_le_bytes());
602
603        out
604    }
605
606    pub(crate) fn message_with_attachment(
607        content_type: &str,
608        file_name: &str,
609        contents: &[u8],
610    ) -> Vec<u8> {
611        MessageBuilder::new()
612            .from(("Mail Delivery System", "postmaster@example.org"))
613            .to("postmaster@example.com")
614            .subject("Report Domain: example.com")
615            .body(MimePart::new(
616                "multipart/report",
617                BodyPart::Multipart(vec![
618                    MimePart::new("text/plain", BodyPart::Text("Report attached.".into())),
619                    MimePart::new(content_type, BodyPart::Binary(contents.into()))
620                        .attachment(file_name),
621                ]),
622            ))
623            .write_to_vec()
624            .unwrap()
625    }
626}