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