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            // Follow redirect
353            if let (Some(macro_string), None) = (&spf_record.redirect, &result) {
354                if !lookup_limit.can_lookup() {
355                    return output
356                        .with_result(SpfResult::PermError)
357                        .with_report(&spf_record);
358                }
359
360                let target_name = macro_string.eval(&vars, &domain, true);
361                match self
362                    .txt_lookup::<Spf>(target_name.as_ref(), params.cache_txt)
363                    .await
364                {
365                    Ok(redirect_spf) => {
366                        let new_domain = target_name.to_string();
367                        spf_record = redirect_spf;
368                        directives = spf_record.directives.iter().enumerate().skip(0);
369                        domain = new_domain;
370                        vars.set_domain(domain.as_bytes().to_vec());
371                        continue;
372                    }
373                    Err(
374                        Error::DnsRecordNotFound(_) | Error::InvalidRecordType | Error::ParseError,
375                    ) => {
376                        return output
377                            .with_result(SpfResult::PermError)
378                            .with_report(&spf_record);
379                    }
380                    Err(_) => {
381                        return output
382                            .with_result(SpfResult::TempError)
383                            .with_report(&spf_record);
384                    }
385                }
386            }
387
388            if let Some((prev_record, prev_pos, prev_domain)) = include_stack.pop() {
389                spf_record = prev_record;
390                directives = spf_record.directives.iter().enumerate().skip(prev_pos);
391                let (_, directive) = directives.next().unwrap();
392
393                if matches!(result, Some(SpfResult::Pass)) {
394                    result = Some((&directive.qualifier).into());
395                    break;
396                } else {
397                    vars.set_domain(prev_domain.as_bytes().to_vec());
398                    domain = prev_domain;
399                    result = None;
400                }
401            } else {
402                break;
403            }
404        }
405
406        // Evaluate explain
407        if let (Some(macro_string), Some(SpfResult::Fail)) = (&spf_record.exp, &result)
408            && let Ok(macro_string) = self
409                .txt_lookup::<Macro>(
410                    macro_string.eval(&vars, &domain, true).to_string(),
411                    params.cache_txt,
412                )
413                .await
414        {
415            return output
416                .with_result(SpfResult::Fail)
417                .with_explanation(macro_string.eval(&vars, &domain, false).to_string())
418                .with_report(&spf_record);
419        }
420
421        output
422            .with_result(result.unwrap_or(SpfResult::Neutral))
423            .with_report(&spf_record)
424    }
425
426    async fn ip_matches(
427        &self,
428        target_name: &str,
429        ip: IpAddr,
430        ip4_mask: u32,
431        ip6_mask: u128,
432        cache_ipv4: Option<&impl ResolverCache<String, Arc<Vec<Ipv4Addr>>>>,
433        cache_ipv6: Option<&impl ResolverCache<String, Arc<Vec<Ipv6Addr>>>>,
434    ) -> crate::Result<bool> {
435        Ok(match ip {
436            IpAddr::V4(ip) => self
437                .ipv4_lookup(target_name, cache_ipv4)
438                .await?
439                .iter()
440                .any(|addr| ip.matches_ipv4_mask(addr, ip4_mask)),
441            IpAddr::V6(ip) => self
442                .ipv6_lookup(target_name, cache_ipv6)
443                .await?
444                .iter()
445                .any(|addr| ip.matches_ipv6_mask(addr, ip6_mask)),
446        })
447    }
448}
449
450impl<'x> SpfParameters<'x> {
451    /// Verifies the SPF EHLO identity
452    pub fn verify_ehlo(
453        ip: IpAddr,
454        helo_domain: &'x str,
455        host_domain: &'x str,
456    ) -> SpfParameters<'x> {
457        SpfParameters {
458            ip,
459            domain: helo_domain,
460            helo_domain,
461            host_domain,
462            sender: Sender::Ehlo(format!("postmaster@{helo_domain}")),
463        }
464    }
465
466    /// Verifies the SPF MAIL FROM identity
467    pub fn verify_mail_from(
468        ip: IpAddr,
469        helo_domain: &'x str,
470        host_domain: &'x str,
471        sender: &'x str,
472    ) -> SpfParameters<'x> {
473        SpfParameters {
474            ip,
475            domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
476            helo_domain,
477            host_domain,
478            sender: Sender::MailFrom(sender),
479        }
480    }
481
482    /// Verifies both the SPF EHLO and MAIL FROM identities
483    pub fn verify(
484        ip: IpAddr,
485        helo_domain: &'x str,
486        host_domain: &'x str,
487        sender: &'x str,
488    ) -> SpfParameters<'x> {
489        SpfParameters {
490            ip,
491            domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
492            helo_domain,
493            host_domain,
494            sender: Sender::Full(sender),
495        }
496    }
497
498    pub fn new(
499        ip: IpAddr,
500        domain: &'x str,
501        helo_domain: &'x str,
502        host_domain: &'x str,
503        sender: &'x str,
504    ) -> Self {
505        SpfParameters {
506            ip,
507            domain,
508            helo_domain,
509            host_domain,
510            sender: Sender::Full(sender),
511        }
512    }
513}
514
515impl<'x> From<SpfParameters<'x>>
516    for Parameters<
517        'x,
518        SpfParameters<'x>,
519        NoCache<String, Txt>,
520        NoCache<String, Arc<Vec<MX>>>,
521        NoCache<String, Arc<Vec<Ipv4Addr>>>,
522        NoCache<String, Arc<Vec<Ipv6Addr>>>,
523        NoCache<IpAddr, Arc<Vec<String>>>,
524    >
525{
526    fn from(params: SpfParameters<'x>) -> Self {
527        Parameters::new(params)
528    }
529}
530
531trait IpMask {
532    fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool;
533    fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool;
534}
535
536impl IpMask for IpAddr {
537    fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
538        u32::from_be_bytes(match &self {
539            IpAddr::V4(ip) => ip.octets(),
540            IpAddr::V6(ip) => {
541                if let Some(ip) = ip.to_ipv4_mapped() {
542                    ip.octets()
543                } else {
544                    return false;
545                }
546            }
547        }) & mask
548            == u32::from_be_bytes(addr.octets()) & mask
549    }
550
551    fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
552        u128::from_be_bytes(match &self {
553            IpAddr::V6(ip) => ip.octets(),
554            IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(),
555        }) & mask
556            == u128::from_be_bytes(addr.octets()) & mask
557    }
558}
559
560impl IpMask for Ipv6Addr {
561    fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
562        u128::from_be_bytes(self.octets()) & mask == u128::from_be_bytes(addr.octets()) & mask
563    }
564
565    fn matches_ipv4_mask(&self, _addr: &Ipv4Addr, _mask: u32) -> bool {
566        unimplemented!()
567    }
568}
569
570impl IpMask for Ipv4Addr {
571    fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
572        u32::from_be_bytes(self.octets()) & mask == u32::from_be_bytes(addr.octets()) & mask
573    }
574
575    fn matches_ipv6_mask(&self, _addr: &Ipv6Addr, _mask: u128) -> bool {
576        unimplemented!()
577    }
578}
579
580impl From<&Qualifier> for SpfResult {
581    fn from(q: &Qualifier) -> Self {
582        match q {
583            Qualifier::Pass => SpfResult::Pass,
584            Qualifier::Fail => SpfResult::Fail,
585            Qualifier::SoftFail => SpfResult::SoftFail,
586            Qualifier::Neutral => SpfResult::Neutral,
587        }
588    }
589}
590
591impl From<Error> for SpfResult {
592    fn from(err: Error) -> Self {
593        match err {
594            Error::DnsRecordNotFound(_) | Error::InvalidRecordType => SpfResult::None,
595            Error::ParseError => SpfResult::PermError,
596            _ => SpfResult::TempError,
597        }
598    }
599}
600
601struct LookupLimit {
602    num_lookups: u32,
603    timer: Instant,
604}
605
606impl LookupLimit {
607    pub fn new() -> Self {
608        LookupLimit {
609            num_lookups: 1,
610            timer: Instant::now(),
611        }
612    }
613
614    #[inline(always)]
615    fn can_lookup(&mut self) -> bool {
616        if self.num_lookups <= 10 && self.timer.elapsed().as_secs() < 20 {
617            self.num_lookups += 1;
618            true
619        } else {
620            false
621        }
622    }
623}
624
625pub trait HasValidLabels {
626    fn has_valid_labels(&self) -> bool;
627}
628
629impl HasValidLabels for &str {
630    fn has_valid_labels(&self) -> bool {
631        let mut has_dots = false;
632        let mut has_chars = false;
633        let mut label_len = 0;
634        for ch in self.chars() {
635            label_len += 1;
636
637            if ch.is_alphanumeric() {
638                has_chars = true;
639            } else if ch == '.' {
640                has_dots = true;
641                label_len = 0;
642            }
643
644            if label_len > 63 {
645                return false;
646            }
647        }
648        if has_chars && has_dots {
649            return true;
650        }
651        false
652    }
653}
654
655#[cfg(test)]
656#[allow(unused)]
657mod test {
658
659    use std::{
660        fs,
661        net::{IpAddr, Ipv4Addr, Ipv6Addr},
662        path::PathBuf,
663        time::{Duration, Instant},
664    };
665
666    use crate::{
667        MX, MessageAuthenticator, SpfResult,
668        common::{cache::test::DummyCaches, parse::TxtRecordParser},
669        spf::{Macro, Spf},
670    };
671
672    use super::SpfParameters;
673
674    #[tokio::test]
675    async fn spf_verify() {
676        let resolver = MessageAuthenticator::new_system_conf().unwrap();
677        let valid_until = Instant::now() + Duration::from_secs(30);
678        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
679        test_dir.push("resources");
680        test_dir.push("spf");
681
682        for file_name in fs::read_dir(&test_dir).unwrap() {
683            let file_name = file_name.unwrap().path();
684            println!("===== {} =====", file_name.display());
685            let test_suite = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
686            let caches = DummyCaches::new();
687
688            for test in test_suite.split("---\n") {
689                let mut test_name = "";
690                let mut last_test_name = "";
691                let mut helo = "";
692                let mut mail_from = "";
693                let mut client_ip = "127.0.0.1".parse::<IpAddr>().unwrap();
694                let mut test_num = 1;
695
696                for line in test.split('\n') {
697                    let line = line.trim();
698                    let line = if let Some(line) = line.strip_prefix('-') {
699                        line.trim()
700                    } else {
701                        line
702                    };
703
704                    if let Some(name) = line.strip_prefix("name:") {
705                        test_name = name.trim();
706                    } else if let Some(record) = line.strip_prefix("spf:") {
707                        let (name, record) = record.trim().split_once(' ').unwrap();
708                        caches.txt_add(
709                            name.trim().to_string(),
710                            Spf::parse(record.as_bytes()),
711                            valid_until,
712                        );
713                    } else if let Some(record) = line.strip_prefix("exp:") {
714                        let (name, record) = record.trim().split_once(' ').unwrap();
715                        caches.txt_add(
716                            name.trim().to_string(),
717                            Macro::parse(record.as_bytes()),
718                            valid_until,
719                        );
720                    } else if let Some(record) = line.strip_prefix("a:") {
721                        let (name, record) = record.trim().split_once(' ').unwrap();
722                        caches.ipv4_add(
723                            name.trim().to_string(),
724                            record
725                                .split(',')
726                                .map(|item| item.trim().parse::<Ipv4Addr>().unwrap())
727                                .collect(),
728                            valid_until,
729                        );
730                    } else if let Some(record) = line.strip_prefix("aaaa:") {
731                        let (name, record) = record.trim().split_once(' ').unwrap();
732                        caches.ipv6_add(
733                            name.trim().to_string(),
734                            record
735                                .split(',')
736                                .map(|item| item.trim().parse::<Ipv6Addr>().unwrap())
737                                .collect(),
738                            valid_until,
739                        );
740                    } else if let Some(record) = line.strip_prefix("ptr:") {
741                        let (name, record) = record.trim().split_once(' ').unwrap();
742                        caches.ptr_add(
743                            name.trim().parse::<IpAddr>().unwrap(),
744                            record
745                                .split(',')
746                                .map(|item| item.trim().to_string())
747                                .collect(),
748                            valid_until,
749                        );
750                    } else if let Some(record) = line.strip_prefix("mx:") {
751                        let (name, record) = record.trim().split_once(' ').unwrap();
752                        let mut mxs = Vec::new();
753                        for (pos, item) in record.split(',').enumerate() {
754                            let ip = item.trim().parse::<IpAddr>().unwrap();
755                            let mx_name = format!("mx.{ip}.{pos}");
756                            match ip {
757                                IpAddr::V4(ip) => {
758                                    caches.ipv4_add(mx_name.clone(), vec![ip], valid_until)
759                                }
760                                IpAddr::V6(ip) => {
761                                    caches.ipv6_add(mx_name.clone(), vec![ip], valid_until)
762                                }
763                            }
764                            mxs.push(MX {
765                                exchanges: vec![mx_name],
766                                preference: (pos + 1) as u16,
767                            });
768                        }
769                        caches.mx_add(name.trim().to_string(), mxs, valid_until);
770                    } else if let Some(value) = line.strip_prefix("domain:") {
771                        helo = value.trim();
772                    } else if let Some(value) = line.strip_prefix("sender:") {
773                        mail_from = value.trim();
774                    } else if let Some(value) = line.strip_prefix("ip:") {
775                        client_ip = value.trim().parse().unwrap();
776                    } else if let Some(value) = line.strip_prefix("expect:") {
777                        let value = value.trim();
778                        let (result, exp): (SpfResult, &str) =
779                            if let Some((result, exp)) = value.split_once(' ') {
780                                (result.trim().try_into().unwrap(), exp.trim())
781                            } else {
782                                (value.try_into().unwrap(), "")
783                            };
784                        let output = resolver
785                            .verify_spf(caches.parameters(SpfParameters::verify(
786                                client_ip,
787                                helo,
788                                "localdomain.org",
789                                mail_from,
790                            )))
791                            .await;
792                        assert_eq!(
793                            output.result(),
794                            result,
795                            "Failed for {test_name:?}, test {test_num}, ehlo: {helo}, mail-from: {mail_from}.",
796                        );
797
798                        if !exp.is_empty() {
799                            assert_eq!(Some(exp.to_string()).as_deref(), output.explanation());
800                        }
801                        test_num += 1;
802                        if test_name != last_test_name {
803                            println!("Passed test {test_name:?}");
804                            last_test_name = test_name;
805                        }
806                    }
807                }
808            }
809        }
810    }
811}