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