mail_auth/dmarc/
parse.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use crate::{
8    Error, Version,
9    common::parse::{ItemParser, N, T, TagParser, TxtRecordParser, V, Y},
10};
11use mail_parser::decoders::quoted_printable::quoted_printable_decode_char;
12use std::slice::Iter;
13
14use super::{Alignment, Dmarc, Format, Policy, Psd, Report, URI};
15
16impl TxtRecordParser for Dmarc {
17    fn parse(bytes: &[u8]) -> crate::Result<Self> {
18        let mut record = bytes.iter();
19        if record.key().unwrap_or(0) != V
20            || !record.match_bytes(b"DMARC1")
21            || !record.seek_tag_end()
22        {
23            return Err(Error::InvalidRecordType);
24        }
25
26        let mut dmarc = Dmarc {
27            adkim: Alignment::Relaxed,
28            aspf: Alignment::Relaxed,
29            fo: Report::All,
30            np: Policy::Unspecified,
31            p: Policy::Unspecified,
32            pct: 100,
33            rf: Format::Afrf as u8,
34            ri: 86400,
35            rua: vec![],
36            ruf: vec![],
37            sp: Policy::Unspecified,
38            v: Version::V1,
39            psd: Psd::Default,
40            t: false,
41        };
42
43        while let Some(key) = record.key() {
44            match key {
45                ADKIM => {
46                    dmarc.adkim = record.alignment()?;
47                }
48                ASPF => {
49                    dmarc.aspf = record.alignment()?;
50                }
51                FO => {
52                    dmarc.fo = record.report()?;
53                }
54                NP => {
55                    dmarc.np = record.policy()?;
56                }
57                P => {
58                    dmarc.p = record.policy()?;
59                }
60                PCT => {
61                    dmarc.pct = std::cmp::min(100, record.number().ok_or(Error::ParseError)?) as u8;
62                }
63                RF => {
64                    dmarc.rf = record.flags::<Format>() as u8;
65                }
66                RI => {
67                    dmarc.ri = record.number().ok_or(Error::ParseError)? as u32;
68                }
69                RUA => {
70                    dmarc.rua = record.uris()?;
71                }
72                RUF => {
73                    dmarc.ruf = record.uris()?;
74                }
75                SP => {
76                    dmarc.sp = record.policy()?;
77                }
78                PSD => {
79                    dmarc.psd = match record.value() {
80                        Y => Psd::Yes,
81                        N => Psd::No,
82                        _ => Psd::Default,
83                    };
84                }
85                T => {
86                    dmarc.t = record.value() == Y;
87                }
88                _ => {
89                    record.ignore();
90                }
91            }
92        }
93
94        if dmarc.sp == Policy::Unspecified {
95            dmarc.sp = dmarc.p;
96        }
97        if dmarc.np == Policy::Unspecified {
98            dmarc.np = dmarc.sp;
99        }
100
101        Ok(dmarc)
102    }
103}
104
105pub(crate) trait DMARCParser: Sized {
106    fn alignment(&mut self) -> crate::Result<Alignment>;
107    fn report(&mut self) -> crate::Result<Report>;
108    fn policy(&mut self) -> crate::Result<Policy>;
109    fn uris(&mut self) -> crate::Result<Vec<URI>>;
110}
111
112impl DMARCParser for Iter<'_, u8> {
113    fn alignment(&mut self) -> crate::Result<Alignment> {
114        let a = match self.next_skip_whitespaces().unwrap_or(0) {
115            b'r' | b'R' => Alignment::Relaxed,
116            b's' | b'S' => Alignment::Strict,
117            _ => return Err(Error::ParseError),
118        };
119        if self.seek_tag_end() {
120            Ok(a)
121        } else {
122            Err(Error::ParseError)
123        }
124    }
125
126    fn report(&mut self) -> crate::Result<Report> {
127        let mut r = Report::All;
128
129        loop {
130            r = match self.next_skip_whitespaces().unwrap_or(0) {
131                b'0' => Report::All,
132                b'1' => Report::Any,
133                b'd' | b'D' => {
134                    if r == Report::Spf {
135                        Report::DkimSpf
136                    } else {
137                        Report::Dkim
138                    }
139                }
140                b's' | b'S' => {
141                    if r == Report::Dkim {
142                        Report::DkimSpf
143                    } else {
144                        Report::Spf
145                    }
146                }
147                _ => return Err(Error::ParseError),
148            };
149            match self.next_skip_whitespaces().unwrap_or(0) {
150                b':' => (),
151                b';' | 0 => return Ok(r),
152                _ => return Err(Error::ParseError),
153            }
154        }
155    }
156
157    fn policy(&mut self) -> crate::Result<Policy> {
158        let p = match self.next_skip_whitespaces().unwrap_or(0) {
159            b'n' | b'N' if self.match_bytes(b"one") => Policy::None,
160            b'q' | b'Q' if self.match_bytes(b"uarantine") => Policy::Quarantine,
161            b'r' | b'R' if self.match_bytes(b"eject") => Policy::Reject,
162            _ => return Err(Error::ParseError),
163        };
164        if self.seek_tag_end() {
165            Ok(p)
166        } else {
167            Err(Error::ParseError)
168        }
169    }
170
171    #[allow(clippy::while_let_on_iterator)]
172    fn uris(&mut self) -> crate::Result<Vec<URI>> {
173        let mut uris = Vec::new();
174        let mut uri = Vec::with_capacity(16);
175        let mut found_uri = false;
176        let mut found_at = false;
177        let mut size: usize = 0;
178
179        'outer: while let Some(&ch) = self.next() {
180            match ch {
181                b'%' => {
182                    let mut hex1 = 0;
183
184                    while let Some(&ch) = self.next() {
185                        if ch.is_ascii_hexdigit() {
186                            if hex1 != 0 {
187                                if let Some(ch) = quoted_printable_decode_char(hex1, ch) {
188                                    match ch {
189                                        b'@' => {
190                                            found_at = true;
191                                            uri.push(ch);
192                                        }
193                                        _ => {
194                                            if !ch.is_ascii_whitespace() {
195                                                uri.push(ch);
196                                            }
197                                        }
198                                    }
199                                }
200                                break;
201                            } else {
202                                hex1 = ch;
203                            }
204                        } else if ch == b';' {
205                            break 'outer;
206                        } else if !ch.is_ascii_whitespace() {
207                            return Err(Error::ParseError);
208                        }
209                    }
210                }
211                b'!' => {
212                    let mut has_digits = false;
213                    let mut has_units = false;
214
215                    while let Some(&ch) = self.next() {
216                        match ch {
217                            b'0'..=b'9' if !has_units => {
218                                size =
219                                    (size.saturating_mul(10)).saturating_add((ch - b'0') as usize);
220                                has_digits = true;
221                            }
222                            b'k' | b'K' if !has_units && has_digits => {
223                                size = size.saturating_mul(1024);
224                                has_units = true;
225                            }
226                            b'm' | b'M' if !has_units && has_digits => {
227                                size = size.saturating_mul(1024 * 1024);
228                                has_units = true;
229                            }
230                            b'g' | b'G' if !has_units && has_digits => {
231                                size = size.saturating_mul(1024 * 1024 * 1024);
232                                has_units = true;
233                            }
234                            b't' | b'T' if !has_units && has_digits => {
235                                size = size.saturating_mul(1024 * 1024 * 1024 * 1024);
236                                has_units = true;
237                            }
238                            b';' => {
239                                break 'outer;
240                            }
241                            b',' => {
242                                if !uri.is_empty() {
243                                    if found_uri && found_at {
244                                        uris.push(URI {
245                                            uri: String::from_utf8_lossy(&uri).to_lowercase(),
246                                            max_size: size,
247                                        });
248                                    }
249                                    found_uri = false;
250                                    found_at = false;
251                                    uri.clear();
252                                }
253                                size = 0;
254                                break;
255                            }
256                            _ => {
257                                if !ch.is_ascii_whitespace() {
258                                    return Err(Error::ParseError);
259                                }
260                            }
261                        }
262                    }
263                }
264                b',' => {
265                    if !uri.is_empty() {
266                        if found_uri && found_at {
267                            uris.push(URI {
268                                uri: String::from_utf8_lossy(&uri).to_lowercase(),
269                                max_size: size,
270                            });
271                        }
272                        found_uri = false;
273                        found_at = false;
274                        uri.clear();
275                    }
276                    size = 0;
277                }
278                b':' if !found_uri => {
279                    found_uri = uri.eq_ignore_ascii_case(b"mailto");
280                    uri.clear();
281                }
282                b';' => {
283                    break;
284                }
285                b'@' => {
286                    found_at = true;
287                    uri.push(ch);
288                }
289                _ => {
290                    if !ch.is_ascii_whitespace() {
291                        uri.push(ch);
292                    }
293                }
294            }
295        }
296
297        if !uri.is_empty() && found_uri && found_at {
298            uris.push(URI {
299                uri: String::from_utf8_lossy(&uri).to_lowercase(),
300                max_size: size,
301            })
302        }
303
304        Ok(uris)
305    }
306}
307
308impl ItemParser for Format {
309    fn parse(bytes: &[u8]) -> Option<Self> {
310        if bytes.eq_ignore_ascii_case(b"afrf") {
311            Format::Afrf.into()
312        } else {
313            None
314        }
315    }
316}
317
318const ADKIM: u64 = (b'a' as u64)
319    | ((b'd' as u64) << 8)
320    | ((b'k' as u64) << 16)
321    | ((b'i' as u64) << 24)
322    | ((b'm' as u64) << 32);
323const ASPF: u64 =
324    (b'a' as u64) | ((b's' as u64) << 8) | ((b'p' as u64) << 16) | ((b'f' as u64) << 24);
325const FO: u64 = (b'f' as u64) | ((b'o' as u64) << 8);
326const NP: u64 = (b'n' as u64) | ((b'p' as u64) << 8);
327const P: u64 = b'p' as u64;
328const PCT: u64 = (b'p' as u64) | ((b'c' as u64) << 8) | ((b't' as u64) << 16);
329const RF: u64 = (b'r' as u64) | ((b'f' as u64) << 8);
330const RI: u64 = (b'r' as u64) | ((b'i' as u64) << 8);
331const RUA: u64 = (b'r' as u64) | ((b'u' as u64) << 8) | ((b'a' as u64) << 16);
332const RUF: u64 = (b'r' as u64) | ((b'u' as u64) << 8) | ((b'f' as u64) << 16);
333const SP: u64 = (b's' as u64) | ((b'p' as u64) << 8);
334const PSD: u64 = (b'p' as u64) | ((b's' as u64) << 8) | ((b'd' as u64) << 16);
335
336#[cfg(test)]
337mod test {
338    use crate::{
339        Version,
340        common::parse::TxtRecordParser,
341        dmarc::{Alignment, Dmarc, Format, Policy, Psd, Report, URI},
342    };
343
344    #[test]
345    fn parse_dmarc() {
346        for (record, expected_result) in [
347            (
348                "v=DMARC1; p=none; rua=mailto:dmarc-feedback@example.com",
349                Dmarc {
350                    adkim: Alignment::Relaxed,
351                    aspf: Alignment::Relaxed,
352                    fo: Report::All,
353                    np: Policy::None,
354                    p: Policy::None,
355                    pct: 100,
356                    rf: Format::Afrf as u8,
357                    ri: 86400,
358                    rua: vec![URI::new("dmarc-feedback@example.com", 0)],
359                    ruf: vec![],
360                    sp: Policy::None,
361                    psd: Psd::Default,
362                    t: false,
363                    v: Version::V1,
364                },
365            ),
366            (
367                concat!(
368                    "v=DMARC1; p=none; rua=mailto:dmarc-feedback@example.com;",
369                    "ruf=mailto:auth-reports@example.com"
370                ),
371                Dmarc {
372                    adkim: Alignment::Relaxed,
373                    aspf: Alignment::Relaxed,
374                    fo: Report::All,
375                    np: Policy::None,
376                    p: Policy::None,
377                    pct: 100,
378                    rf: Format::Afrf as u8,
379                    ri: 86400,
380                    rua: vec![URI::new("dmarc-feedback@example.com", 0)],
381                    ruf: vec![URI::new("auth-reports@example.com", 0)],
382                    sp: Policy::None,
383                    psd: Psd::Default,
384                    t: false,
385                    v: Version::V1,
386                },
387            ),
388            (
389                concat!(
390                    "v=DMARC1; p=quarantine; rua=mailto:dmarc-feedback@example.com,",
391                    "mailto:tld-test@thirdparty.example.net!10m; pct=25; fo=d:s"
392                ),
393                Dmarc {
394                    adkim: Alignment::Relaxed,
395                    aspf: Alignment::Relaxed,
396                    fo: Report::DkimSpf,
397                    np: Policy::Quarantine,
398                    p: Policy::Quarantine,
399                    pct: 25,
400                    rf: Format::Afrf as u8,
401                    ri: 86400,
402                    ruf: vec![],
403                    rua: vec![
404                        URI::new("dmarc-feedback@example.com", 0),
405                        URI::new("tld-test@thirdparty.example.net", 10 * 1024 * 1024),
406                    ],
407                    sp: Policy::Quarantine,
408                    psd: Psd::Default,
409                    t: false,
410                    v: Version::V1,
411                },
412            ),
413            (
414                concat!(
415                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo = 1;",
416                    "rua=mailto:dmarc-feedback@example.com"
417                ),
418                Dmarc {
419                    adkim: Alignment::Strict,
420                    aspf: Alignment::Strict,
421                    fo: Report::Any,
422                    np: Policy::None,
423                    p: Policy::Reject,
424                    pct: 100,
425                    rf: Format::Afrf as u8,
426                    ri: 86400,
427                    rua: vec![URI::new("dmarc-feedback@example.com", 0)],
428                    ruf: vec![],
429                    sp: Policy::Quarantine,
430                    psd: Psd::Default,
431                    t: false,
432                    v: Version::V1,
433                },
434            ),
435            (
436                concat!(
437                    "v=DMARC1; p=reject; ri = 3600; aspf=r; adkim =r; ",
438                    "rua=mailto:dmarc-feedback@example.com!10 K , mailto:user%20@example.com ! 2G;",
439                    "ignore_me= true; fo=s; rf = AfrF; ",
440                ),
441                Dmarc {
442                    adkim: Alignment::Relaxed,
443                    aspf: Alignment::Relaxed,
444                    fo: Report::Spf,
445                    np: Policy::Reject,
446                    p: Policy::Reject,
447                    pct: 100,
448                    rf: Format::Afrf as u8,
449                    ri: 3600,
450                    rua: vec![
451                        URI::new("dmarc-feedback@example.com", 10 * 1024),
452                        URI::new("user@example.com", 2 * 1024 * 1024 * 1024),
453                    ],
454                    ruf: vec![],
455                    sp: Policy::Reject,
456                    psd: Psd::Default,
457                    t: false,
458                    v: Version::V1,
459                },
460            ),
461            (
462                concat!(
463                    "v=DMARC1; p=quarantine; rua=mailto:dmarc-feedback@example.com,",
464                    "mailto:tld-test@thirdparty.example.net; fo=s:d; t=y; psd=y;;",
465                ),
466                Dmarc {
467                    adkim: Alignment::Relaxed,
468                    aspf: Alignment::Relaxed,
469                    fo: Report::DkimSpf,
470                    np: Policy::Quarantine,
471                    p: Policy::Quarantine,
472                    pct: 100,
473                    rf: Format::Afrf as u8,
474                    ri: 86400,
475                    rua: vec![
476                        URI::new("dmarc-feedback@example.com", 0),
477                        URI::new("tld-test@thirdparty.example.net", 0),
478                    ],
479                    ruf: vec![],
480                    sp: Policy::Quarantine,
481                    psd: Psd::Yes,
482                    t: true,
483                    v: Version::V1,
484                },
485            ),
486        ] {
487            assert_eq!(
488                Dmarc::parse(record.as_bytes())
489                    .unwrap_or_else(|err| panic!("{record:?} : {err:?}")),
490                expected_result,
491                "{record}"
492            );
493        }
494    }
495}