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