Skip to main content

mail_auth/spf/
verify.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 super::{Macro, Mechanism, Qualifier, Spf, Variables};
8use crate::DnsError;
9use crate::Instant;
10use crate::{
11    Error, MX, MessageAuthenticator, Parameters, RecordSet, ResolverCache, SpfOutput, SpfResult,
12    Txt, common::cache::NoCache,
13};
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
15
16pub struct SpfParameters<'x> {
17    ip: IpAddr,
18    domain: &'x str,
19    helo_domain: &'x str,
20    host_domain: &'x str,
21    sender: Sender<'x>,
22}
23
24enum Sender<'x> {
25    Ehlo(String),
26    MailFrom(&'x str),
27    Full(&'x str),
28}
29
30#[allow(clippy::iter_skip_zero)]
31impl MessageAuthenticator {
32    /// Verifies the SPF record of a domain
33    pub async fn verify_spf<'x, TXT, MXX, IPV4, IPV6, PTR>(
34        &self,
35        params: impl Into<Parameters<'x, SpfParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
36    ) -> SpfOutput
37    where
38        TXT: ResolverCache<Box<str>, Txt> + 'x,
39        MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
40        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
41        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
42        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
43    {
44        let params = params.into();
45        match &params.params.sender {
46            Sender::Full(sender) => {
47                // Verify HELO identity
48                let output = self
49                    .check_host(params.clone_with(SpfParameters::verify_ehlo(
50                        params.params.ip,
51                        params.params.helo_domain,
52                        params.params.host_domain,
53                    )))
54                    .await;
55                if matches!(output.result(), SpfResult::Pass) {
56                    // Verify MAIL FROM identity
57                    self.check_host(params.clone_with(SpfParameters::verify_mail_from(
58                        params.params.ip,
59                        params.params.helo_domain,
60                        params.params.host_domain,
61                        sender,
62                    )))
63                    .await
64                } else {
65                    output
66                }
67            }
68            _ => self.check_host(params).await,
69        }
70    }
71
72    #[allow(clippy::while_let_on_iterator)]
73    #[allow(clippy::iter_skip_zero)]
74    pub async fn check_host<'x, TXT, MXX, IPV4, IPV6, PTR>(
75        &self,
76        params: Parameters<'x, SpfParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>,
77    ) -> SpfOutput
78    where
79        TXT: ResolverCache<Box<str>, Txt>,
80        MXX: ResolverCache<Box<str>, RecordSet<MX>>,
81        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>>,
82        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>>,
83        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>>,
84    {
85        let domain = params.params.domain;
86        let ip = params.params.ip;
87        let helo_domain = params.params.helo_domain;
88        let host_domain = params.params.host_domain;
89        let sender = match &params.params.sender {
90            Sender::Ehlo(sender) => sender.as_str(),
91            Sender::MailFrom(sender) => sender,
92            Sender::Full(sender) => sender,
93        };
94
95        let output = SpfOutput::new(domain.to_string());
96        if domain.is_empty() || domain.len() > 255 || !domain.has_valid_labels() {
97            return output.with_result(SpfResult::None);
98        }
99        let mut vars = Variables::new();
100        let mut has_p_var = false;
101        vars.set_ip(&ip);
102        if !sender.is_empty() {
103            vars.set_sender(sender.as_bytes());
104        } else {
105            vars.set_sender(format!("postmaster@{domain}").into_bytes());
106        }
107        vars.set_domain(domain.as_bytes());
108        vars.set_host_domain(host_domain.as_bytes());
109        vars.set_helo_domain(helo_domain.as_bytes());
110
111        let mut lookup_limit = LookupLimit::new();
112        let mut spf_record = match self.txt_lookup::<Spf>(domain, params.cache_txt).await {
113            Ok(spf_record) => spf_record,
114            Err(err) => return output.with_result(err.into()),
115        };
116
117        let mut domain = domain.to_string();
118        let mut include_stack = Vec::new();
119
120        let mut result = None;
121        let mut directives = spf_record.directives.iter().enumerate().skip(0);
122
123        loop {
124            while let Some((pos, directive)) = directives.next() {
125                if !has_p_var && directive.mechanism.needs_ptr() {
126                    if !lookup_limit.can_lookup() {
127                        return output
128                            .with_result(SpfResult::PermError)
129                            .with_report(&spf_record);
130                    }
131                    if let Some(ptr) = self
132                        .ptr_lookup(ip, params.cache_ptr)
133                        .await
134                        .ok()
135                        .and_then(|ptrs| ptrs.rrset.first().map(|ptr| ptr.as_bytes().to_vec()))
136                    {
137                        vars.set_validated_domain(ptr);
138                    }
139                    has_p_var = true;
140                }
141
142                let matches = match &directive.mechanism {
143                    Mechanism::All => true,
144                    Mechanism::Ip4 { addr, mask } => ip.matches_ipv4_mask(addr, *mask),
145                    Mechanism::Ip6 { addr, mask } => ip.matches_ipv6_mask(addr, *mask),
146                    Mechanism::A {
147                        macro_string,
148                        ip4_mask,
149                        ip6_mask,
150                    } => {
151                        if !lookup_limit.can_lookup() {
152                            return output
153                                .with_result(SpfResult::PermError)
154                                .with_report(&spf_record);
155                        }
156                        match self
157                            .ip_matches(
158                                macro_string.eval(&vars, &domain, true).as_ref(),
159                                ip,
160                                *ip4_mask,
161                                *ip6_mask,
162                                params.cache_ipv4,
163                                params.cache_ipv6,
164                            )
165                            .await
166                        {
167                            Ok(true) => true,
168                            Ok(false) | Err(Error::Dns(DnsError::RecordNotFound(_))) => false,
169                            Err(_) => {
170                                return output
171                                    .with_result(SpfResult::TempError)
172                                    .with_report(&spf_record);
173                            }
174                        }
175                    }
176                    Mechanism::Mx {
177                        macro_string,
178                        ip4_mask,
179                        ip6_mask,
180                    } => {
181                        if !lookup_limit.can_lookup() {
182                            return output
183                                .with_result(SpfResult::PermError)
184                                .with_report(&spf_record);
185                        }
186
187                        let mut matches = false;
188                        match self
189                            .mx_lookup(&*macro_string.eval(&vars, &domain, true), params.cache_mx)
190                            .await
191                        {
192                            Ok(records) => {
193                                for (mx_num, exchange) in records
194                                    .rrset
195                                    .iter()
196                                    .flat_map(|mx| mx.exchanges.iter())
197                                    .enumerate()
198                                {
199                                    if mx_num > 9 {
200                                        return output
201                                            .with_result(SpfResult::PermError)
202                                            .with_report(&spf_record);
203                                    }
204
205                                    match self
206                                        .ip_matches(
207                                            exchange,
208                                            ip,
209                                            *ip4_mask,
210                                            *ip6_mask,
211                                            params.cache_ipv4,
212                                            params.cache_ipv6,
213                                        )
214                                        .await
215                                    {
216                                        Ok(true) => {
217                                            matches = true;
218                                            break;
219                                        }
220                                        Ok(false)
221                                        | Err(Error::Dns(DnsError::RecordNotFound(_))) => (),
222                                        Err(_) => {
223                                            return output
224                                                .with_result(SpfResult::TempError)
225                                                .with_report(&spf_record);
226                                        }
227                                    }
228                                }
229                            }
230                            Err(Error::Dns(DnsError::RecordNotFound(_))) => (),
231                            Err(_) => {
232                                return output
233                                    .with_result(SpfResult::TempError)
234                                    .with_report(&spf_record);
235                            }
236                        }
237                        matches
238                    }
239                    Mechanism::Include { macro_string } => {
240                        if !lookup_limit.can_lookup() {
241                            return output
242                                .with_result(SpfResult::PermError)
243                                .with_report(&spf_record);
244                        }
245
246                        let target_name = macro_string.eval(&vars, &domain, true);
247                        match self
248                            .txt_lookup::<Spf>(&*target_name, params.cache_txt)
249                            .await
250                        {
251                            Ok(included_spf) => {
252                                let new_domain = target_name.to_string();
253                                include_stack.push((
254                                    std::mem::replace(&mut spf_record, included_spf),
255                                    pos,
256                                    domain,
257                                ));
258                                directives = spf_record.directives.iter().enumerate().skip(0);
259                                domain = new_domain;
260                                vars.set_domain(domain.as_bytes().to_vec());
261                                continue;
262                            }
263                            Err(
264                                Error::Dns(DnsError::RecordNotFound(_))
265                                | Error::Dns(DnsError::InvalidRecordType)
266                                | Error::ParseError,
267                            ) => {
268                                return output
269                                    .with_result(SpfResult::PermError)
270                                    .with_report(&spf_record);
271                            }
272                            Err(_) => {
273                                return output
274                                    .with_result(SpfResult::TempError)
275                                    .with_report(&spf_record);
276                            }
277                        }
278                    }
279                    Mechanism::Ptr { macro_string } => {
280                        if !lookup_limit.can_lookup() {
281                            return output
282                                .with_result(SpfResult::PermError)
283                                .with_report(&spf_record);
284                        }
285
286                        let target_addr = macro_string.eval(&vars, &domain, true).to_lowercase();
287                        let target_sub_addr = format!(".{target_addr}");
288                        let mut matches = false;
289
290                        if let Ok(records) = self.ptr_lookup(ip, params.cache_ptr).await {
291                            for record in records.rrset.iter() {
292                                if lookup_limit.can_lookup()
293                                    && let Ok(true) = self
294                                        .ip_matches(
295                                            record,
296                                            ip,
297                                            u32::MAX,
298                                            u128::MAX,
299                                            params.cache_ipv4,
300                                            params.cache_ipv6,
301                                        )
302                                        .await
303                                {
304                                    matches = record.as_ref() == target_addr.as_str()
305                                        || record
306                                            .strip_suffix('.')
307                                            .unwrap_or(record.as_ref())
308                                            .ends_with(&target_sub_addr);
309                                    if matches {
310                                        break;
311                                    }
312                                }
313                            }
314                        }
315                        matches
316                    }
317                    Mechanism::Exists { macro_string } => {
318                        if !lookup_limit.can_lookup() {
319                            return output
320                                .with_result(SpfResult::PermError)
321                                .with_report(&spf_record);
322                        }
323
324                        if let Ok(result) = self
325                            .exists(
326                                &*macro_string.eval(&vars, &domain, true),
327                                params.cache_ipv4,
328                                params.cache_ipv6,
329                            )
330                            .await
331                        {
332                            result
333                        } else {
334                            return output
335                                .with_result(SpfResult::TempError)
336                                .with_report(&spf_record);
337                        }
338                    }
339                };
340
341                if matches {
342                    result = Some((&directive.qualifier).into());
343                    break;
344                }
345            }
346
347            // Follow redirect
348            if let (Some(macro_string), None) = (&spf_record.redirect, &result) {
349                if !lookup_limit.can_lookup() {
350                    return output
351                        .with_result(SpfResult::PermError)
352                        .with_report(&spf_record);
353                }
354
355                let target_name = macro_string.eval(&vars, &domain, true);
356                match self
357                    .txt_lookup::<Spf>(&*target_name, params.cache_txt)
358                    .await
359                {
360                    Ok(redirect_spf) => {
361                        let new_domain = target_name.to_string();
362                        spf_record = redirect_spf;
363                        directives = spf_record.directives.iter().enumerate().skip(0);
364                        domain = new_domain;
365                        vars.set_domain(domain.as_bytes().to_vec());
366                        continue;
367                    }
368                    Err(
369                        Error::Dns(DnsError::RecordNotFound(_))
370                        | Error::Dns(DnsError::InvalidRecordType)
371                        | Error::ParseError,
372                    ) => {
373                        return output
374                            .with_result(SpfResult::PermError)
375                            .with_report(&spf_record);
376                    }
377                    Err(_) => {
378                        return output
379                            .with_result(SpfResult::TempError)
380                            .with_report(&spf_record);
381                    }
382                }
383            }
384
385            if let Some((prev_record, prev_pos, prev_domain)) = include_stack.pop() {
386                spf_record = prev_record;
387                directives = spf_record.directives.iter().enumerate().skip(prev_pos);
388                let (_, directive) = directives.next().unwrap();
389
390                if matches!(result, Some(SpfResult::Pass)) {
391                    result = Some((&directive.qualifier).into());
392                    break;
393                } else {
394                    vars.set_domain(prev_domain.as_bytes().to_vec());
395                    domain = prev_domain;
396                    result = None;
397                }
398            } else {
399                break;
400            }
401        }
402
403        // Evaluate explain
404        if let (Some(macro_string), Some(SpfResult::Fail)) = (&spf_record.exp, &result)
405            && let Ok(macro_string) = self
406                .txt_lookup::<Macro>(
407                    macro_string.eval(&vars, &domain, true).to_string(),
408                    params.cache_txt,
409                )
410                .await
411        {
412            return output
413                .with_result(SpfResult::Fail)
414                .with_explanation(macro_string.eval(&vars, &domain, false).to_string())
415                .with_report(&spf_record);
416        }
417
418        output
419            .with_result(result.unwrap_or(SpfResult::Neutral))
420            .with_report(&spf_record)
421    }
422
423    async fn ip_matches(
424        &self,
425        target_name: &str,
426        ip: IpAddr,
427        ip4_mask: u32,
428        ip6_mask: u128,
429        cache_ipv4: Option<&impl ResolverCache<Box<str>, RecordSet<Ipv4Addr>>>,
430        cache_ipv6: Option<&impl ResolverCache<Box<str>, RecordSet<Ipv6Addr>>>,
431    ) -> crate::Result<bool> {
432        Ok(match ip {
433            IpAddr::V4(ip) => self
434                .ipv4_lookup(target_name, cache_ipv4)
435                .await?
436                .rrset
437                .iter()
438                .any(|addr| ip.matches_ipv4_mask(addr, ip4_mask)),
439            IpAddr::V6(ip) => self
440                .ipv6_lookup(target_name, cache_ipv6)
441                .await?
442                .rrset
443                .iter()
444                .any(|addr| ip.matches_ipv6_mask(addr, ip6_mask)),
445        })
446    }
447}
448
449impl<'x> SpfParameters<'x> {
450    /// Verifies the SPF EHLO identity
451    pub fn verify_ehlo(
452        ip: IpAddr,
453        helo_domain: &'x str,
454        host_domain: &'x str,
455    ) -> SpfParameters<'x> {
456        SpfParameters {
457            ip,
458            domain: helo_domain,
459            helo_domain,
460            host_domain,
461            sender: Sender::Ehlo(format!("postmaster@{helo_domain}")),
462        }
463    }
464
465    /// Verifies the SPF MAIL FROM identity
466    pub fn verify_mail_from(
467        ip: IpAddr,
468        helo_domain: &'x str,
469        host_domain: &'x str,
470        sender: &'x str,
471    ) -> SpfParameters<'x> {
472        SpfParameters {
473            ip,
474            domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
475            helo_domain,
476            host_domain,
477            sender: Sender::MailFrom(sender),
478        }
479    }
480
481    /// Verifies both the SPF EHLO and MAIL FROM identities
482    pub fn verify(
483        ip: IpAddr,
484        helo_domain: &'x str,
485        host_domain: &'x str,
486        sender: &'x str,
487    ) -> SpfParameters<'x> {
488        SpfParameters {
489            ip,
490            domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
491            helo_domain,
492            host_domain,
493            sender: Sender::Full(sender),
494        }
495    }
496
497    pub fn new(
498        ip: IpAddr,
499        domain: &'x str,
500        helo_domain: &'x str,
501        host_domain: &'x str,
502        sender: &'x str,
503    ) -> Self {
504        SpfParameters {
505            ip,
506            domain,
507            helo_domain,
508            host_domain,
509            sender: Sender::Full(sender),
510        }
511    }
512}
513
514impl<'x> From<SpfParameters<'x>>
515    for Parameters<
516        'x,
517        SpfParameters<'x>,
518        NoCache<Box<str>, Txt>,
519        NoCache<Box<str>, RecordSet<MX>>,
520        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
521        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
522        NoCache<IpAddr, RecordSet<Box<str>>>,
523    >
524{
525    fn from(params: SpfParameters<'x>) -> Self {
526        Parameters::new(params)
527    }
528}
529
530trait IpMask {
531    fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool;
532    fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool;
533}
534
535impl IpMask for IpAddr {
536    fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
537        u32::from_be_bytes(match &self {
538            IpAddr::V4(ip) => ip.octets(),
539            IpAddr::V6(ip) => {
540                if let Some(ip) = ip.to_ipv4_mapped() {
541                    ip.octets()
542                } else {
543                    return false;
544                }
545            }
546        }) & mask
547            == u32::from_be_bytes(addr.octets()) & mask
548    }
549
550    fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
551        u128::from_be_bytes(match &self {
552            IpAddr::V6(ip) => ip.octets(),
553            IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(),
554        }) & mask
555            == u128::from_be_bytes(addr.octets()) & mask
556    }
557}
558
559impl IpMask for Ipv6Addr {
560    fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
561        u128::from_be_bytes(self.octets()) & mask == u128::from_be_bytes(addr.octets()) & mask
562    }
563
564    fn matches_ipv4_mask(&self, _addr: &Ipv4Addr, _mask: u32) -> bool {
565        unimplemented!()
566    }
567}
568
569impl IpMask for Ipv4Addr {
570    fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
571        u32::from_be_bytes(self.octets()) & mask == u32::from_be_bytes(addr.octets()) & mask
572    }
573
574    fn matches_ipv6_mask(&self, _addr: &Ipv6Addr, _mask: u128) -> bool {
575        unimplemented!()
576    }
577}
578
579impl From<&Qualifier> for SpfResult {
580    fn from(q: &Qualifier) -> Self {
581        match q {
582            Qualifier::Pass => SpfResult::Pass,
583            Qualifier::Fail => SpfResult::Fail,
584            Qualifier::SoftFail => SpfResult::SoftFail,
585            Qualifier::Neutral => SpfResult::Neutral,
586        }
587    }
588}
589
590impl From<Error> for SpfResult {
591    fn from(err: Error) -> Self {
592        match err {
593            Error::Dns(DnsError::RecordNotFound(_)) | Error::Dns(DnsError::InvalidRecordType) => {
594                SpfResult::None
595            }
596            Error::ParseError => SpfResult::PermError,
597            _ => SpfResult::TempError,
598        }
599    }
600}
601
602struct LookupLimit {
603    num_lookups: u32,
604    timer: Instant,
605}
606
607impl LookupLimit {
608    pub fn new() -> Self {
609        LookupLimit {
610            num_lookups: 1,
611            timer: Instant::now(),
612        }
613    }
614
615    #[inline(always)]
616    fn can_lookup(&mut self) -> bool {
617        if self.num_lookups <= 10 && self.timer.elapsed().as_secs() < 20 {
618            self.num_lookups += 1;
619            true
620        } else {
621            false
622        }
623    }
624}
625
626pub trait HasValidLabels {
627    fn has_valid_labels(&self) -> bool;
628}
629
630impl HasValidLabels for &str {
631    fn has_valid_labels(&self) -> bool {
632        let mut has_dots = false;
633        let mut has_chars = false;
634        let mut label_len = 0;
635        for ch in self.chars() {
636            label_len += 1;
637
638            if ch.is_alphanumeric() {
639                has_chars = true;
640            } else if ch == '.' {
641                has_dots = true;
642                label_len = 0;
643            }
644
645            if label_len > 63 {
646                return false;
647            }
648        }
649        if has_chars && has_dots {
650            return true;
651        }
652        false
653    }
654}
655
656#[cfg(test)]
657#[allow(unused)]
658mod test {
659
660    use std::{
661        fs,
662        net::{IpAddr, Ipv4Addr, Ipv6Addr},
663        path::PathBuf,
664        time::{Duration, Instant},
665    };
666
667    use crate::{
668        MX, MessageAuthenticator, SpfResult,
669        common::{cache::test::DummyCaches, parse::TxtRecordParser},
670        spf::{Macro, Spf},
671    };
672
673    use super::SpfParameters;
674
675    #[tokio::test]
676    async fn spf_verify() {
677        let resolver = MessageAuthenticator::new_system_conf().unwrap();
678        let valid_until = Instant::now() + Duration::from_secs(30);
679        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
680        test_dir.push("resources");
681        test_dir.push("spf");
682
683        for file_name in fs::read_dir(&test_dir).unwrap() {
684            let file_name = file_name.unwrap().path();
685            println!("===== {} =====", file_name.display());
686            let test_suite = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
687            let caches = DummyCaches::new();
688
689            for test in test_suite.split("---\n") {
690                let mut test_name = "";
691                let mut last_test_name = "";
692                let mut helo = "";
693                let mut mail_from = "";
694                let mut client_ip = "127.0.0.1".parse::<IpAddr>().unwrap();
695                let mut test_num = 1;
696
697                for line in test.split('\n') {
698                    let line = line.trim();
699                    let line = if let Some(line) = line.strip_prefix('-') {
700                        line.trim()
701                    } else {
702                        line
703                    };
704
705                    if let Some(name) = line.strip_prefix("name:") {
706                        test_name = name.trim();
707                    } else if let Some(record) = line.strip_prefix("spf:") {
708                        let (name, record) = record.trim().split_once(' ').unwrap();
709                        caches.txt_add(
710                            name.trim().to_string(),
711                            Spf::parse(record.as_bytes()),
712                            valid_until,
713                        );
714                    } else if let Some(record) = line.strip_prefix("exp:") {
715                        let (name, record) = record.trim().split_once(' ').unwrap();
716                        caches.txt_add(
717                            name.trim().to_string(),
718                            Macro::parse(record.as_bytes()),
719                            valid_until,
720                        );
721                    } else if let Some(record) = line.strip_prefix("a:") {
722                        let (name, record) = record.trim().split_once(' ').unwrap();
723                        caches.ipv4_add(
724                            name.trim().to_string(),
725                            record
726                                .split(',')
727                                .map(|item| item.trim().parse::<Ipv4Addr>().unwrap())
728                                .collect(),
729                            valid_until,
730                        );
731                    } else if let Some(record) = line.strip_prefix("aaaa:") {
732                        let (name, record) = record.trim().split_once(' ').unwrap();
733                        caches.ipv6_add(
734                            name.trim().to_string(),
735                            record
736                                .split(',')
737                                .map(|item| item.trim().parse::<Ipv6Addr>().unwrap())
738                                .collect(),
739                            valid_until,
740                        );
741                    } else if let Some(record) = line.strip_prefix("ptr:") {
742                        let (name, record) = record.trim().split_once(' ').unwrap();
743                        caches.ptr_add(
744                            name.trim().parse::<IpAddr>().unwrap(),
745                            record
746                                .split(',')
747                                .map(|item| Box::from(item.trim()))
748                                .collect(),
749                            valid_until,
750                        );
751                    } else if let Some(record) = line.strip_prefix("mx:") {
752                        let (name, record) = record.trim().split_once(' ').unwrap();
753                        let mut mxs = Vec::new();
754                        for (pos, item) in record.split(',').enumerate() {
755                            let ip = item.trim().parse::<IpAddr>().unwrap();
756                            let mx_name = format!("mx.{ip}.{pos}");
757                            match ip {
758                                IpAddr::V4(ip) => {
759                                    caches.ipv4_add(mx_name.clone(), vec![ip], valid_until)
760                                }
761                                IpAddr::V6(ip) => {
762                                    caches.ipv6_add(mx_name.clone(), vec![ip], valid_until)
763                                }
764                            }
765                            mxs.push(MX {
766                                exchanges: Box::new([mx_name.into_boxed_str()]),
767                                preference: (pos + 1) as u16,
768                            });
769                        }
770                        caches.mx_add(name.trim().to_string(), mxs, valid_until);
771                    } else if let Some(value) = line.strip_prefix("domain:") {
772                        helo = value.trim();
773                    } else if let Some(value) = line.strip_prefix("sender:") {
774                        mail_from = value.trim();
775                    } else if let Some(value) = line.strip_prefix("ip:") {
776                        client_ip = value.trim().parse().unwrap();
777                    } else if let Some(value) = line.strip_prefix("expect:") {
778                        let value = value.trim();
779                        let (result, exp): (SpfResult, &str) =
780                            if let Some((result, exp)) = value.split_once(' ') {
781                                (result.trim().try_into().unwrap(), exp.trim())
782                            } else {
783                                (value.try_into().unwrap(), "")
784                            };
785                        let output = resolver
786                            .verify_spf(caches.parameters(SpfParameters::verify(
787                                client_ip,
788                                helo,
789                                "localdomain.org",
790                                mail_from,
791                            )))
792                            .await;
793                        assert_eq!(
794                            output.result(),
795                            result,
796                            "Failed for {test_name:?}, test {test_num}, ehlo: {helo}, mail-from: {mail_from}.",
797                        );
798
799                        if !exp.is_empty() {
800                            assert_eq!(Some(exp.to_string()).as_deref(), output.explanation());
801                        }
802                        test_num += 1;
803                        if test_name != last_test_name {
804                            println!("Passed test {test_name:?}");
805                            last_test_name = test_name;
806                        }
807                    }
808                }
809            }
810        }
811    }
812}