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