spf_milter/config/
model.rs

1// SPF Milter – milter for SPF verification
2// Copyright © 2020–2023 David Bürgin <dbuergin@gluet.ch>
3//
4// This program is free software: you can redistribute it and/or modify it under
5// the terms of the GNU General Public License as published by the Free Software
6// Foundation, either version 3 of the License, or (at your option) any later
7// version.
8//
9// This program is distributed in the hope that it will be useful, but WITHOUT
10// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12// details.
13//
14// You should have received a copy of the GNU General Public License along with
15// this program. If not, see <https://www.gnu.org/licenses/>.
16
17use ipnet::IpNet;
18use log::LevelFilter;
19use std::{
20    collections::HashSet,
21    error::Error,
22    fmt::{self, Display, Formatter},
23    net::IpAddr,
24    str::FromStr,
25};
26use syslog::Facility;
27use viaspf::{record::ExplainString, DomainName, ParseParamError, SpfResult};
28
29/// An error indicating that a socket could not be parsed.
30#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
31pub struct ParseSocketError;
32
33impl Error for ParseSocketError {}
34
35impl Display for ParseSocketError {
36    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
37        write!(f, "failed to parse socket")
38    }
39}
40
41/// A socket specification.
42#[derive(Clone, Debug, Eq, Hash, PartialEq)]
43pub enum Socket {
44    Inet(String),
45    Unix(String),
46}
47
48impl Display for Socket {
49    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::Inet(s) => write!(f, "inet:{s}"),
52            Self::Unix(s) => write!(f, "unix:{s}"),
53        }
54    }
55}
56
57impl FromStr for Socket {
58    type Err = ParseSocketError;
59
60    fn from_str(s: &str) -> Result<Self, Self::Err> {
61        if let Some(s) = s.strip_prefix("inet:") {
62            Ok(Self::Inet(s.into()))
63        } else if let Some(s) = s.strip_prefix("unix:") {
64            Ok(Self::Unix(s.into()))
65        } else {
66            Err(ParseSocketError)
67        }
68    }
69}
70
71#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
72pub struct ParseStatusCodeError;
73
74impl Error for ParseStatusCodeError {}
75
76impl Display for ParseStatusCodeError {
77    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
78        write!(f, "failed to parse status code")
79    }
80}
81
82#[derive(Clone, Debug, Eq, Hash, PartialEq)]
83pub enum ReplyCode {
84    Transient(String),
85    Permanent(String),
86}
87
88impl AsRef<str> for ReplyCode {
89    fn as_ref(&self) -> &str {
90        match self {
91            Self::Transient(s) | Self::Permanent(s) => s,
92        }
93    }
94}
95
96impl Display for ReplyCode {
97    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
98        self.as_ref().fmt(f)
99    }
100}
101
102impl FromStr for ReplyCode {
103    type Err = ParseStatusCodeError;
104
105    // See RFC 5321, section 4.2.
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        match s.as_bytes() {
108            [x, y, z]
109                if matches!(x, b'4'..=b'5')
110                    && matches!(y, b'0'..=b'5')
111                    && matches!(z, b'0'..=b'9') =>
112            {
113                Ok(match x {
114                    b'4' => Self::Transient(s.into()),
115                    b'5' => Self::Permanent(s.into()),
116                    _ => unreachable!(),
117                })
118            }
119            _ => Err(ParseStatusCodeError),
120        }
121    }
122}
123
124#[derive(Clone, Debug, Eq, Hash, PartialEq)]
125pub enum EnhancedStatusCode {
126    Transient(String),
127    Permanent(String),
128}
129
130impl EnhancedStatusCode {
131    pub fn is_compatible_with(&self, reply_code: &ReplyCode) -> bool {
132        matches!(
133            (self, reply_code),
134            (Self::Transient(_), ReplyCode::Transient(_))
135                | (Self::Permanent(_), ReplyCode::Permanent(_))
136        )
137    }
138}
139
140impl AsRef<str> for EnhancedStatusCode {
141    fn as_ref(&self) -> &str {
142        match self {
143            Self::Transient(s) | Self::Permanent(s) => s,
144        }
145    }
146}
147
148impl Display for EnhancedStatusCode {
149    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
150        self.as_ref().fmt(f)
151    }
152}
153
154impl FromStr for EnhancedStatusCode {
155    type Err = ParseStatusCodeError;
156
157    // See RFC 3463, section 2.
158    fn from_str(s: &str) -> Result<Self, Self::Err> {
159        fn is_three_digits(s: &str) -> bool {
160            s == "0"
161                || matches!(s.len(), 1..=3)
162                    && s.chars().all(|c| c.is_ascii_digit())
163                    && !s.starts_with('0')
164        }
165
166        let mut iter = s.splitn(3, '.');
167        match (iter.next(), iter.next(), iter.next()) {
168            (Some(class), Some(subject), Some(detail))
169                if matches!(class, "4" | "5")
170                    && is_three_digits(subject)
171                    && is_three_digits(detail) =>
172            {
173                Ok(match class {
174                    "4" => Self::Transient(s.into()),
175                    "5" => Self::Permanent(s.into()),
176                    _ => unreachable!(),
177                })
178            }
179            _ => Err(ParseStatusCodeError),
180        }
181    }
182}
183
184#[derive(Clone, Debug, Eq, Hash, PartialEq)]
185pub struct Header(Vec<HeaderType>);
186
187impl Header {
188    pub fn new(header_types: Vec<HeaderType>) -> Result<Self, Vec<HeaderType>> {
189        // Each header type must not appear more than once (ordered set).
190        if header_types.len() == header_types.iter().collect::<HashSet<_>>().len() {
191            Ok(Self(header_types))
192        } else {
193            Err(header_types)
194        }
195    }
196
197    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &HeaderType> {
198        self.0.iter()
199    }
200}
201
202impl Default for Header {
203    fn default() -> Self {
204        HeaderType::ReceivedSpf.into()
205    }
206}
207
208impl From<Vec<HeaderType>> for Header {
209    fn from(header_types: Vec<HeaderType>) -> Self {
210        Self(header_types)
211    }
212}
213
214impl From<HeaderType> for Header {
215    fn from(header_type: HeaderType) -> Self {
216        vec![header_type].into()
217    }
218}
219
220#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
221pub struct ParseHeaderTypeError;
222
223impl Error for ParseHeaderTypeError {}
224
225impl Display for ParseHeaderTypeError {
226    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
227        write!(f, "failed to parse header type")
228    }
229}
230
231#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
232pub enum HeaderType {
233    ReceivedSpf,
234    AuthenticationResults,
235}
236
237impl Display for HeaderType {
238    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
239        match self {
240            Self::ReceivedSpf => write!(f, "Received-SPF"),
241            Self::AuthenticationResults => write!(f, "Authentication-Results"),
242        }
243    }
244}
245
246impl FromStr for HeaderType {
247    type Err = ParseHeaderTypeError;
248
249    fn from_str(s: &str) -> Result<Self, Self::Err> {
250        // Ignore case, simply because of conventions around header names.
251        if s.eq_ignore_ascii_case("Received-SPF") {
252            Ok(HeaderType::ReceivedSpf)
253        } else if s.eq_ignore_ascii_case("Authentication-Results") {
254            Ok(HeaderType::AuthenticationResults)
255        } else {
256            Err(ParseHeaderTypeError)
257        }
258    }
259}
260
261#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
262pub struct ParseResultKindError;
263
264impl Error for ParseResultKindError {}
265
266impl Display for ParseResultKindError {
267    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
268        write!(f, "failed to parse SPF result kind")
269    }
270}
271
272#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
273pub enum RejectResultKind {
274    Fail,
275    Softfail,
276    Temperror,
277    Permerror,
278}
279
280impl RejectResultKind {
281    fn from_spf_result(spf_result: &SpfResult) -> Option<Self> {
282        use SpfResult::*;
283        match spf_result {
284            None | Neutral | Pass => Option::None,
285            Fail(_) => Some(Self::Fail),
286            Softfail => Some(Self::Softfail),
287            Temperror => Some(Self::Temperror),
288            Permerror => Some(Self::Permerror),
289        }
290    }
291}
292
293impl FromStr for RejectResultKind {
294    type Err = ParseResultKindError;
295
296    fn from_str(s: &str) -> Result<Self, Self::Err> {
297        match s {
298            "fail" => Ok(Self::Fail),
299            "softfail" => Ok(Self::Softfail),
300            "temperror" => Ok(Self::Temperror),
301            "permerror" => Ok(Self::Permerror),
302            _ => Err(ParseResultKindError),
303        }
304    }
305}
306
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub struct RejectResults(HashSet<RejectResultKind>);
309
310impl RejectResults {
311    pub fn includes(&self, result: &SpfResult) -> bool {
312        matches!(RejectResultKind::from_spf_result(result), Some(k) if self.0.contains(&k))
313    }
314}
315
316impl Default for RejectResults {
317    fn default() -> Self {
318        HashSet::from([
319            RejectResultKind::Fail,
320            RejectResultKind::Temperror,
321            RejectResultKind::Permerror,
322        ])
323        .into()
324    }
325}
326
327impl From<HashSet<RejectResultKind>> for RejectResults {
328    fn from(results: HashSet<RejectResultKind>) -> Self {
329        Self(results)
330    }
331}
332
333#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
334pub enum DefinitiveHeloResultKind {
335    Pass,
336    Fail,
337    Softfail,
338    Temperror,
339    Permerror,
340}
341
342impl DefinitiveHeloResultKind {
343    fn from_spf_result(spf_result: &SpfResult) -> Option<Self> {
344        use SpfResult::*;
345        match spf_result {
346            None | Neutral => Option::None,
347            Pass => Some(Self::Pass),
348            Fail(_) => Some(Self::Fail),
349            Softfail => Some(Self::Softfail),
350            Temperror => Some(Self::Temperror),
351            Permerror => Some(Self::Permerror),
352        }
353    }
354}
355
356impl FromStr for DefinitiveHeloResultKind {
357    type Err = ParseResultKindError;
358
359    fn from_str(s: &str) -> Result<Self, Self::Err> {
360        match s {
361            "pass" => Ok(Self::Pass),
362            "fail" => Ok(Self::Fail),
363            "softfail" => Ok(Self::Softfail),
364            "temperror" => Ok(Self::Temperror),
365            "permerror" => Ok(Self::Permerror),
366            _ => Err(ParseResultKindError),
367        }
368    }
369}
370
371#[derive(Clone, Debug, Default, Eq, PartialEq)]
372pub struct DefinitiveHeloResults(HashSet<DefinitiveHeloResultKind>);
373
374impl DefinitiveHeloResults {
375    pub fn includes(&self, result: &SpfResult) -> bool {
376        matches!(DefinitiveHeloResultKind::from_spf_result(result), Some(k) if self.0.contains(&k))
377    }
378}
379
380impl From<HashSet<DefinitiveHeloResultKind>> for DefinitiveHeloResults {
381    fn from(results: HashSet<DefinitiveHeloResultKind>) -> Self {
382        Self(results)
383    }
384}
385
386#[derive(Clone, Debug, Eq, Hash, PartialEq)]
387pub enum ExplainStringMod {
388    Substitute(ExplainString),
389    Decorate {
390        prefix: ExplainString,
391        suffix: ExplainString,
392    },
393}
394
395#[derive(Clone, Debug, Eq, Hash, PartialEq)]
396pub struct ExpExplainString(pub ExplainStringMod);
397
398impl AsRef<ExplainStringMod> for ExpExplainString {
399    fn as_ref(&self) -> &ExplainStringMod {
400        &self.0
401    }
402}
403
404impl From<ExplainStringMod> for ExpExplainString {
405    fn from(m: ExplainStringMod) -> Self {
406        Self(m)
407    }
408}
409
410#[derive(Clone, Debug, Eq, Hash, PartialEq)]
411pub struct ReasonExplainString(pub ExplainStringMod);
412
413impl AsRef<ExplainStringMod> for ReasonExplainString {
414    fn as_ref(&self) -> &ExplainStringMod {
415        &self.0
416    }
417}
418
419impl From<ExplainStringMod> for ReasonExplainString {
420    fn from(m: ExplainStringMod) -> Self {
421        Self(m)
422    }
423}
424
425#[derive(Clone, Debug, Eq, PartialEq)]
426pub struct TrustedNetworks {
427    pub trust_loopback: bool,
428    pub networks: HashSet<IpNet>,
429}
430
431impl TrustedNetworks {
432    pub fn contains(&self, addr: IpAddr) -> bool {
433        self.trust_loopback && addr.is_loopback() || self.networks.iter().any(|n| n.contains(&addr))
434    }
435}
436
437impl Default for TrustedNetworks {
438    fn default() -> Self {
439        Self {
440            trust_loopback: true,
441            networks: Default::default(),
442        }
443    }
444}
445
446#[derive(Clone, Debug, Eq, Hash, PartialEq)]
447pub struct SkipEntry {
448    pub local_part: Option<String>,
449    pub domain: DomainName,
450    pub match_subdomains: bool,
451}
452
453impl SkipEntry {
454    fn matches(&self, sender: &str) -> bool {
455        fn matches_domain(entry: &SkipEntry, domain: &DomainName) -> bool {
456            if entry.match_subdomains {
457                domain.as_ref().is_subdomain_of(entry.domain.as_ref())
458            } else {
459                domain == &entry.domain
460            }
461        }
462
463        fn matches_local_part(entry: &SkipEntry, local_part: Option<&str>) -> bool {
464            match (&local_part, &entry.local_part) {
465                (Some(lp1), Some(lp2)) => {
466                    // Case-insensitive comparison of local-parts, despite case
467                    // being theoretically significant according to RFCs.
468                    lp1.eq_ignore_ascii_case(lp2)
469                }
470                (None, Some(_)) => false,
471                (_, None) => true,
472            }
473        }
474
475        let (local_part, domain) = match sender.rsplit_once('@') {
476            Some((local_part, domain)) => (Some(local_part), domain),
477            None => (None, sender),
478        };
479
480        match DomainName::new(domain) {
481            Ok(domain) => matches_domain(self, &domain) && matches_local_part(self, local_part),
482            Err(_) => false,
483        }
484    }
485}
486
487impl FromStr for SkipEntry {
488    type Err = ParseParamError;
489
490    fn from_str(s: &str) -> Result<Self, Self::Err> {
491        let mut local_part = None;
492        let mut match_subdomains = false;
493
494        let domain = match s.rsplit_once('@') {
495            Some((l, d)) => {
496                // Use local-part as-is without further validation.
497                local_part = Some(l.into());
498                d
499            }
500            None => match s.strip_prefix('.') {
501                Some(s) => {
502                    match_subdomains = true;
503                    s
504                }
505                None => s,
506            },
507        };
508
509        let domain = DomainName::new(domain)?;
510
511        Ok(Self {
512            local_part,
513            domain,
514            match_subdomains,
515        })
516    }
517}
518
519#[derive(Clone, Debug, Default, Eq, PartialEq)]
520pub struct SkipSenders(HashSet<SkipEntry>);
521
522impl SkipSenders {
523    pub fn extended_with(mut self, other: Self) -> Self {
524        self.0.extend(other.0);
525        self
526    }
527
528    pub fn includes(&self, sender: &str) -> bool {
529        self.0.iter().any(|e| e.matches(sender))
530    }
531}
532
533impl From<HashSet<SkipEntry>> for SkipSenders {
534    fn from(results: HashSet<SkipEntry>) -> Self {
535        Self(results)
536    }
537}
538
539/// An error indicating that a log destination could not be parsed.
540#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
541pub struct ParseLogDestinationError;
542
543impl Error for ParseLogDestinationError {}
544
545impl Display for ParseLogDestinationError {
546    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
547        write!(f, "failed to parse log destination")
548    }
549}
550
551/// The log destination.
552#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
553pub enum LogDestination {
554    #[default]
555    Syslog,
556    Stderr,
557}
558
559impl FromStr for LogDestination {
560    type Err = ParseLogDestinationError;
561
562    fn from_str(s: &str) -> Result<Self, Self::Err> {
563        match s {
564            "syslog" => Ok(Self::Syslog),
565            "stderr" => Ok(Self::Stderr),
566            _ => Err(ParseLogDestinationError),
567        }
568    }
569}
570
571/// An error indicating that a log level could not be parsed.
572#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
573pub struct ParseLogLevelError;
574
575impl Error for ParseLogLevelError {}
576
577impl Display for ParseLogLevelError {
578    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
579        write!(f, "failed to parse log level")
580    }
581}
582
583/// The log level.
584#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
585pub enum LogLevel {
586    Error,
587    Warn,
588    #[default]
589    Info,
590    Debug,
591}
592
593impl FromStr for LogLevel {
594    type Err = ParseLogLevelError;
595
596    fn from_str(s: &str) -> Result<Self, Self::Err> {
597        match s {
598            "error" => Ok(Self::Error),
599            "warn" => Ok(Self::Warn),
600            "info" => Ok(Self::Info),
601            "debug" => Ok(Self::Debug),
602            _ => Err(ParseLogLevelError),
603        }
604    }
605}
606
607impl From<LogLevel> for LevelFilter {
608    fn from(log_level: LogLevel) -> Self {
609        match log_level {
610            LogLevel::Error => Self::Error,
611            LogLevel::Warn => Self::Warn,
612            LogLevel::Info => Self::Info,
613            LogLevel::Debug => Self::Debug,
614        }
615    }
616}
617
618/// An error indicating that a syslog facility could not be parsed.
619#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
620pub struct ParseSyslogFacilityError;
621
622impl Error for ParseSyslogFacilityError {}
623
624impl Display for ParseSyslogFacilityError {
625    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
626        write!(f, "failed to parse syslog facility")
627    }
628}
629
630/// The syslog facility.
631#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
632pub enum SyslogFacility {
633    Auth,
634    Authpriv,
635    Cron,
636    Daemon,
637    Ftp,
638    Kern,
639    Local0,
640    Local1,
641    Local2,
642    Local3,
643    Local4,
644    Local5,
645    Local6,
646    Local7,
647    Lpr,
648    #[default]
649    Mail,
650    News,
651    Syslog,
652    User,
653    Uucp,
654}
655
656impl FromStr for SyslogFacility {
657    type Err = ParseSyslogFacilityError;
658
659    fn from_str(s: &str) -> Result<Self, Self::Err> {
660        match s {
661            "auth" => Ok(Self::Auth),
662            "authpriv" => Ok(Self::Authpriv),
663            "cron" => Ok(Self::Cron),
664            "daemon" => Ok(Self::Daemon),
665            "ftp" => Ok(Self::Ftp),
666            "kern" => Ok(Self::Kern),
667            "local0" => Ok(Self::Local0),
668            "local1" => Ok(Self::Local1),
669            "local2" => Ok(Self::Local2),
670            "local3" => Ok(Self::Local3),
671            "local4" => Ok(Self::Local4),
672            "local5" => Ok(Self::Local5),
673            "local6" => Ok(Self::Local6),
674            "local7" => Ok(Self::Local7),
675            "lpr" => Ok(Self::Lpr),
676            "mail" => Ok(Self::Mail),
677            "news" => Ok(Self::News),
678            "syslog" => Ok(Self::Syslog),
679            "user" => Ok(Self::User),
680            "uucp" => Ok(Self::Uucp),
681            _ => Err(ParseSyslogFacilityError),
682        }
683    }
684}
685
686impl From<SyslogFacility> for Facility {
687    fn from(syslog_facility: SyslogFacility) -> Self {
688        match syslog_facility {
689            SyslogFacility::Auth => Self::LOG_AUTH,
690            SyslogFacility::Authpriv => Self::LOG_AUTHPRIV,
691            SyslogFacility::Cron => Self::LOG_CRON,
692            SyslogFacility::Daemon => Self::LOG_DAEMON,
693            SyslogFacility::Ftp => Self::LOG_FTP,
694            SyslogFacility::Kern => Self::LOG_KERN,
695            SyslogFacility::Local0 => Self::LOG_LOCAL0,
696            SyslogFacility::Local1 => Self::LOG_LOCAL1,
697            SyslogFacility::Local2 => Self::LOG_LOCAL2,
698            SyslogFacility::Local3 => Self::LOG_LOCAL3,
699            SyslogFacility::Local4 => Self::LOG_LOCAL4,
700            SyslogFacility::Local5 => Self::LOG_LOCAL5,
701            SyslogFacility::Local6 => Self::LOG_LOCAL6,
702            SyslogFacility::Local7 => Self::LOG_LOCAL7,
703            SyslogFacility::Lpr => Self::LOG_LPR,
704            SyslogFacility::Mail => Self::LOG_MAIL,
705            SyslogFacility::News => Self::LOG_NEWS,
706            SyslogFacility::Syslog => Self::LOG_SYSLOG,
707            SyslogFacility::User => Self::LOG_USER,
708            SyslogFacility::Uucp => Self::LOG_UUCP,
709        }
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use ipnet::Ipv4Net;
717    use std::net::Ipv6Addr;
718
719    #[test]
720    fn reply_code_parse_ok() {
721        let code = "441".parse();
722        assert_eq!(code, Ok(ReplyCode::Transient("441".into())));
723        let code = "499".parse::<ReplyCode>();
724        assert_eq!(code, Err(ParseStatusCodeError));
725    }
726
727    #[test]
728    fn enhanced_status_code_parse_ok() {
729        let code = "4.1.23".parse();
730        assert_eq!(code, Ok(EnhancedStatusCode::Transient("4.1.23".into())));
731        let code = "4.0.23".parse();
732        assert_eq!(code, Ok(EnhancedStatusCode::Transient("4.0.23".into())));
733        let code = "4.01.23".parse::<EnhancedStatusCode>();
734        assert_eq!(code, Err(ParseStatusCodeError));
735    }
736
737    #[test]
738    fn trusted_networks_loopback_ok() {
739        let trusted_networks = TrustedNetworks::default();
740
741        assert!(trusted_networks.contains(IpAddr::from([127, 0, 1, 0])));
742        assert!(trusted_networks.contains(Ipv6Addr::LOCALHOST.into()));
743    }
744
745    #[test]
746    fn trusted_networks_subnet_ok() {
747        let net = Ipv4Net::new([43, 5, 0, 0].into(), 16).unwrap();
748        let trusted_networks = TrustedNetworks {
749            networks: HashSet::from([net.into()]),
750            ..Default::default()
751        };
752
753        assert!(trusted_networks.contains(IpAddr::from([43, 5, 117, 8])));
754    }
755
756    #[test]
757    fn skip_senders_ok() {
758        let skip_senders = SkipSenders::from(HashSet::from([
759            SkipEntry {
760                local_part: None,
761                domain: DomainName::new("example.com").unwrap(),
762                match_subdomains: false,
763            },
764            SkipEntry {
765                local_part: None,
766                domain: DomainName::new("super.example.com").unwrap(),
767                match_subdomains: true,
768            },
769            SkipEntry {
770                local_part: Some("from".into()),
771                domain: DomainName::new("example.org").unwrap(),
772                match_subdomains: false,
773            },
774        ]));
775
776        assert!(skip_senders.includes("Example.Com"));
777        assert!(skip_senders.includes("Me@Example.Com"));
778        assert!(!skip_senders.includes("super.example.com"));
779        assert!(skip_senders.includes("mx1.super.example.com"));
780        assert!(!skip_senders.includes("me@super.example.com"));
781        assert!(skip_senders.includes("me@mx1.super.example.com"));
782        assert!(!skip_senders.includes("example.org"));
783        assert!(!skip_senders.includes("mail.example.org"));
784        assert!(!skip_senders.includes("to@example.org"));
785        assert!(skip_senders.includes("FROM@example.org"));
786    }
787}