1use super::{Macro, Mechanism, Qualifier, SpfIdentity, SpfRecord, Variables};
8use crate::DnsError;
9use crate::Instant;
10use crate::dns::{DnsCache, Negative, has_valid_labels};
11use crate::{
12 Error, MessageAuthenticator, Parameters, RecordSet, ResolverCache, SpfOutput, SpfResult,
13 dns::cache::NoCache,
14};
15use std::borrow::Cow;
16use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct SpfParameters<'x> {
28 ip: IpAddr,
29 domain: &'x str,
30 helo_domain: &'x str,
31 host_domain: &'x str,
32 sender: Sender<'x>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36enum Sender<'x> {
37 Helo,
38 MailFrom(&'x str),
39 Full(&'x str),
40}
41
42#[allow(clippy::iter_skip_zero)]
43impl MessageAuthenticator {
44 pub async fn verify_spf<'x, C>(
80 &self,
81 params: impl Into<Parameters<'x, SpfParameters<'x>, C>>,
82 ) -> SpfOutput
83 where
84 C: DnsCache + 'x,
85 {
86 let params = params.into();
87 match ¶ms.input.sender {
88 Sender::Full(sender) => {
89 let helo_output = self
90 .check_host(params.clone_with(SpfParameters::helo(
91 params.input.ip,
92 params.input.helo_domain,
93 params.input.host_domain,
94 )))
95 .await;
96 if sender.is_empty() || helo_output.result() == SpfResult::Fail {
97 helo_output
98 } else {
99 self.check_host(params.clone_with(SpfParameters::mail_from(
100 params.input.ip,
101 params.input.helo_domain,
102 params.input.host_domain,
103 sender,
104 )))
105 .await
106 }
107 }
108 _ => self.check_host(params).await,
109 }
110 }
111
112 #[allow(clippy::while_let_on_iterator)]
161 #[allow(clippy::iter_skip_zero)]
162 pub async fn check_host<'x, C>(
163 &self,
164 params: impl Into<Parameters<'x, SpfParameters<'x>, C>>,
165 ) -> SpfOutput
166 where
167 C: DnsCache + 'x,
168 {
169 let params = params.into();
170 let domain = params.input.domain;
171 let ip = params.input.ip;
172 let helo_domain = params.input.helo_domain;
173 let host_domain = params.input.host_domain;
174 let (sender, identity) = match ¶ms.input.sender {
175 Sender::Helo => ("", SpfIdentity::Helo),
176 Sender::MailFrom(sender) | Sender::Full(sender) => (*sender, SpfIdentity::MailFrom),
177 };
178
179 let output = SpfOutput::new(domain.to_string()).with_identity(identity);
180 if domain.is_empty() || domain.len() > 255 || !has_valid_labels(domain) {
181 return output.with_result(SpfResult::None);
182 }
183 let postmaster;
184 let sender = if sender.is_empty() {
185 postmaster = postmaster_at(domain);
186 postmaster.as_str()
187 } else {
188 sender
189 };
190 let mut vars = Variables::new();
191 let mut has_p_var = false;
192 vars.set_ip(&ip);
193 vars.set_sender(sender.as_bytes());
194 vars.set_domain(domain.as_bytes());
195 vars.set_host_domain(host_domain.as_bytes());
196 vars.set_helo_domain(helo_domain.as_bytes());
197
198 let mut lookup_limit = LookupLimit::new();
199 let mut spf_record = match self
200 .txt_lookup::<SpfRecord>(domain, params.txt_cache())
201 .await
202 {
203 Ok(spf_record) => spf_record,
204 Err(err) => return output.with_result(err.into()),
205 };
206
207 let mut domain = Cow::Borrowed(domain);
208 let mut include_stack = Vec::new();
209
210 let mut result = None;
211 let mut directives = spf_record.directives.iter().enumerate().skip(0);
212
213 loop {
214 while let Some((pos, directive)) = directives.next() {
215 if !has_p_var && directive.mechanism.needs_ptr() {
216 if !lookup_limit.can_lookup() {
217 return output
218 .with_result(SpfResult::PermError)
219 .with_report(&spf_record);
220 }
221 if let Some(ptr) = self
222 .ptr_lookup(ip, params.ptr_cache())
223 .await
224 .ok()
225 .and_then(|ptrs| ptrs.records.first().map(|ptr| ptr.as_bytes().to_vec()))
226 {
227 vars.set_validated_domain(ptr);
228 }
229 has_p_var = true;
230 }
231
232 let matches = match &directive.mechanism {
233 Mechanism::All => true,
234 Mechanism::Ip4 { addr, mask } => ip.matches_ipv4_mask(addr, *mask),
235 Mechanism::Ip6 { addr, mask } => ip.matches_ipv6_mask(addr, *mask),
236 Mechanism::A {
237 macro_string,
238 ip4_mask,
239 ip6_mask,
240 } => {
241 if !lookup_limit.can_lookup() {
242 return output
243 .with_result(SpfResult::PermError)
244 .with_report(&spf_record);
245 }
246 match self
247 .ip_matches(
248 macro_string.eval(&vars, &domain, true).as_ref(),
249 ip,
250 *ip4_mask,
251 *ip6_mask,
252 params.ipv4_cache(),
253 params.ipv6_cache(),
254 )
255 .await
256 {
257 Ok(true) => true,
258 Ok(false) | Err(Error::Dns(DnsError::RecordNotFound(_))) => false,
259 Err(_) => {
260 return output
261 .with_result(SpfResult::TempError)
262 .with_report(&spf_record);
263 }
264 }
265 }
266 Mechanism::Mx {
267 macro_string,
268 ip4_mask,
269 ip6_mask,
270 } => {
271 if !lookup_limit.can_lookup() {
272 return output
273 .with_result(SpfResult::PermError)
274 .with_report(&spf_record);
275 }
276
277 let mut matches = false;
278 match self
279 .mx_lookup(&*macro_string.eval(&vars, &domain, true), params.mx_cache())
280 .await
281 {
282 Ok(records) => {
283 for (mx_num, exchange) in records
284 .records
285 .iter()
286 .flat_map(|mx| mx.exchanges.iter())
287 .enumerate()
288 {
289 if mx_num > 9 {
290 return output
291 .with_result(SpfResult::PermError)
292 .with_report(&spf_record);
293 }
294
295 match self
296 .ip_matches(
297 exchange,
298 ip,
299 *ip4_mask,
300 *ip6_mask,
301 params.ipv4_cache(),
302 params.ipv6_cache(),
303 )
304 .await
305 {
306 Ok(true) => {
307 matches = true;
308 break;
309 }
310 Ok(false)
311 | Err(Error::Dns(DnsError::RecordNotFound(_))) => (),
312 Err(_) => {
313 return output
314 .with_result(SpfResult::TempError)
315 .with_report(&spf_record);
316 }
317 }
318 }
319 }
320 Err(Error::Dns(DnsError::RecordNotFound(_))) => (),
321 Err(_) => {
322 return output
323 .with_result(SpfResult::TempError)
324 .with_report(&spf_record);
325 }
326 }
327 matches
328 }
329 Mechanism::Include { macro_string } => {
330 if !lookup_limit.can_lookup() {
331 return output
332 .with_result(SpfResult::PermError)
333 .with_report(&spf_record);
334 }
335
336 let target_name = macro_string.eval(&vars, &domain, true);
337 let included = self
338 .txt_lookup::<SpfRecord>(&*target_name, params.txt_cache())
339 .await;
340 match included {
341 Ok(included_spf) => {
342 let new_domain = target_name.into_owned();
343 include_stack.push((
344 std::mem::replace(&mut spf_record, included_spf),
345 pos,
346 domain,
347 ));
348 directives = spf_record.directives.iter().enumerate().skip(0);
349 vars.set_domain(new_domain.as_bytes().to_vec());
350 domain = Cow::Owned(new_domain);
351 continue;
352 }
353 Err(
354 Error::Dns(DnsError::RecordNotFound(_))
355 | Error::Dns(DnsError::InvalidRecordType)
356 | Error::Parse,
357 ) => {
358 return output
359 .with_result(SpfResult::PermError)
360 .with_report(&spf_record);
361 }
362 Err(_) => {
363 return output
364 .with_result(SpfResult::TempError)
365 .with_report(&spf_record);
366 }
367 }
368 }
369 Mechanism::Ptr { macro_string } => {
370 if !lookup_limit.can_lookup() {
371 return output
372 .with_result(SpfResult::PermError)
373 .with_report(&spf_record);
374 }
375
376 let target_name = macro_string.eval(&vars, &domain, true);
377 let target_addr = to_lowercase(target_name.as_ref());
378 let target_addr = target_addr.as_ref();
379 let mut matches = false;
380
381 if let Ok(records) = self.ptr_lookup(ip, params.ptr_cache()).await {
382 for record in records.records.iter() {
383 if lookup_limit.can_lookup()
384 && let Ok(true) = self
385 .ip_matches(
386 record,
387 ip,
388 u32::MAX,
389 u128::MAX,
390 params.ipv4_cache(),
391 params.ipv6_cache(),
392 )
393 .await
394 {
395 matches = record.as_ref() == target_addr
396 || record
397 .strip_suffix('.')
398 .unwrap_or(record.as_ref())
399 .strip_suffix(target_addr)
400 .is_some_and(|prefix| prefix.ends_with('.'));
401 if matches {
402 break;
403 }
404 }
405 }
406 }
407 matches
408 }
409 Mechanism::Exists { macro_string } => {
410 if !lookup_limit.can_lookup() {
411 return output
412 .with_result(SpfResult::PermError)
413 .with_report(&spf_record);
414 }
415
416 if let Ok(result) = self
417 .exists(
418 &*macro_string.eval(&vars, &domain, true),
419 params.ipv4_cache(),
420 params.ipv6_cache(),
421 )
422 .await
423 {
424 result
425 } else {
426 return output
427 .with_result(SpfResult::TempError)
428 .with_report(&spf_record);
429 }
430 }
431 };
432
433 if matches {
434 result = Some((&directive.qualifier).into());
435 break;
436 }
437 }
438
439 if let (Some(macro_string), None) = (&spf_record.redirect, &result) {
440 if !lookup_limit.can_lookup() {
441 return output
442 .with_result(SpfResult::PermError)
443 .with_report(&spf_record);
444 }
445
446 let target_name = macro_string.eval(&vars, &domain, true);
447 let redirect = self
448 .txt_lookup::<SpfRecord>(&*target_name, params.txt_cache())
449 .await;
450 match redirect {
451 Ok(redirect_spf) => {
452 let new_domain = target_name.into_owned();
453 spf_record = redirect_spf;
454 directives = spf_record.directives.iter().enumerate().skip(0);
455 vars.set_domain(new_domain.as_bytes().to_vec());
456 domain = Cow::Owned(new_domain);
457 continue;
458 }
459 Err(
460 Error::Dns(DnsError::RecordNotFound(_))
461 | Error::Dns(DnsError::InvalidRecordType)
462 | Error::Parse,
463 ) => {
464 return output
465 .with_result(SpfResult::PermError)
466 .with_report(&spf_record);
467 }
468 Err(_) => {
469 return output
470 .with_result(SpfResult::TempError)
471 .with_report(&spf_record);
472 }
473 }
474 }
475
476 if let Some((prev_record, prev_pos, prev_domain)) = include_stack.pop() {
477 spf_record = prev_record;
478 directives = spf_record.directives.iter().enumerate().skip(prev_pos);
479 let qualifier = directives.next().map(|(_, directive)| &directive.qualifier);
480
481 if matches!(result, Some(SpfResult::Pass)) {
482 if let Some(qualifier) = qualifier {
483 result = Some(qualifier.into());
484 }
485 break;
486 } else {
487 vars.set_domain(prev_domain.as_bytes().to_vec());
488 domain = prev_domain;
489 result = None;
490 }
491 } else {
492 break;
493 }
494 }
495
496 if let (Some(macro_string), Some(SpfResult::Fail)) = (&spf_record.exp, &result)
497 && let Ok(macro_string) = self
498 .txt_lookup::<Macro>(macro_string.eval(&vars, &domain, true), params.txt_cache())
499 .await
500 {
501 return output
502 .with_result(SpfResult::Fail)
503 .with_explanation(macro_string.eval(&vars, &domain, false).into_owned())
504 .with_report(&spf_record);
505 }
506
507 output
508 .with_result(result.unwrap_or(SpfResult::Neutral))
509 .with_report(&spf_record)
510 }
511
512 async fn ip_matches(
513 &self,
514 target_name: &str,
515 ip: IpAddr,
516 ip4_mask: u32,
517 ip6_mask: u128,
518 cache_ipv4: Option<&impl ResolverCache<Box<str>, Result<RecordSet<Ipv4Addr>, Negative>>>,
519 cache_ipv6: Option<&impl ResolverCache<Box<str>, Result<RecordSet<Ipv6Addr>, Negative>>>,
520 ) -> crate::Result<bool> {
521 Ok(match ip {
522 IpAddr::V4(ip) => self
523 .ipv4_lookup(target_name, cache_ipv4)
524 .await?
525 .records
526 .iter()
527 .any(|addr| ip.matches_ipv4_mask(addr, ip4_mask)),
528 IpAddr::V6(ip) => self
529 .ipv6_lookup(target_name, cache_ipv6)
530 .await?
531 .records
532 .iter()
533 .any(|addr| ip.matches_ipv6_mask(addr, ip6_mask)),
534 })
535 }
536}
537
538fn postmaster_at(domain: &str) -> String {
539 const POSTMASTER: &str = "postmaster@";
540 let mut sender = String::with_capacity(POSTMASTER.len() + domain.len());
541 sender.push_str(POSTMASTER);
542 sender.push_str(domain);
543 sender
544}
545
546fn to_lowercase(value: &str) -> Cow<'_, str> {
547 if value.is_ascii() {
548 if value.bytes().any(|byte| byte.is_ascii_uppercase()) {
549 Cow::Owned(value.to_ascii_lowercase())
550 } else {
551 Cow::Borrowed(value)
552 }
553 } else {
554 Cow::Owned(value.to_lowercase())
555 }
556}
557
558impl<'x> SpfParameters<'x> {
559 pub fn helo(ip: IpAddr, helo_domain: &'x str, host_domain: &'x str) -> SpfParameters<'x> {
565 SpfParameters {
566 ip,
567 domain: helo_domain,
568 helo_domain,
569 host_domain,
570 sender: Sender::Helo,
571 }
572 }
573
574 pub fn mail_from(
581 ip: IpAddr,
582 helo_domain: &'x str,
583 host_domain: &'x str,
584 sender: &'x str,
585 ) -> SpfParameters<'x> {
586 SpfParameters {
587 ip,
588 domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
589 helo_domain,
590 host_domain,
591 sender: Sender::MailFrom(sender),
592 }
593 }
594
595 pub fn helo_and_mail_from(
602 ip: IpAddr,
603 helo_domain: &'x str,
604 host_domain: &'x str,
605 sender: &'x str,
606 ) -> SpfParameters<'x> {
607 SpfParameters {
608 ip,
609 domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
610 helo_domain,
611 host_domain,
612 sender: Sender::Full(sender),
613 }
614 }
615
616 pub fn new(
628 ip: IpAddr,
629 domain: &'x str,
630 helo_domain: &'x str,
631 host_domain: &'x str,
632 sender: &'x str,
633 ) -> Self {
634 SpfParameters {
635 ip,
636 domain,
637 helo_domain,
638 host_domain,
639 sender: Sender::Full(sender),
640 }
641 }
642
643 pub(crate) fn ip(&self) -> IpAddr {
644 self.ip
645 }
646
647 pub(crate) fn helo_domain(&self) -> &'x str {
648 self.helo_domain
649 }
650
651 pub(crate) fn host_domain(&self) -> &'x str {
652 self.host_domain
653 }
654
655 pub(crate) fn mail_from_address(&self) -> Option<&'x str> {
656 match self.sender {
657 Sender::Helo => None,
658 Sender::MailFrom(sender) | Sender::Full(sender) => Some(sender),
659 }
660 }
661}
662
663impl<'x> From<SpfParameters<'x>> for Parameters<'x, SpfParameters<'x>, NoCache> {
664 fn from(params: SpfParameters<'x>) -> Self {
665 Parameters::new(params)
666 }
667}
668
669trait IpMask {
670 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool;
671 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool;
672}
673
674impl IpMask for IpAddr {
675 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
676 u32::from_be_bytes(match &self {
677 IpAddr::V4(ip) => ip.octets(),
678 IpAddr::V6(ip) => {
679 if let Some(ip) = ip.to_ipv4_mapped() {
680 ip.octets()
681 } else {
682 return false;
683 }
684 }
685 }) & mask
686 == u32::from_be_bytes(addr.octets()) & mask
687 }
688
689 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
690 u128::from_be_bytes(match &self {
691 IpAddr::V6(ip) => ip.octets(),
692 IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(),
693 }) & mask
694 == u128::from_be_bytes(addr.octets()) & mask
695 }
696}
697
698impl IpMask for Ipv6Addr {
699 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
700 u128::from_be_bytes(self.octets()) & mask == u128::from_be_bytes(addr.octets()) & mask
701 }
702
703 fn matches_ipv4_mask(&self, _addr: &Ipv4Addr, _mask: u32) -> bool {
704 unimplemented!()
705 }
706}
707
708impl IpMask for Ipv4Addr {
709 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
710 u32::from_be_bytes(self.octets()) & mask == u32::from_be_bytes(addr.octets()) & mask
711 }
712
713 fn matches_ipv6_mask(&self, _addr: &Ipv6Addr, _mask: u128) -> bool {
714 unimplemented!()
715 }
716}
717
718impl From<&Qualifier> for SpfResult {
719 fn from(q: &Qualifier) -> Self {
720 match q {
721 Qualifier::Pass => SpfResult::Pass,
722 Qualifier::Fail => SpfResult::Fail,
723 Qualifier::SoftFail => SpfResult::SoftFail,
724 Qualifier::Neutral => SpfResult::Neutral,
725 }
726 }
727}
728
729impl From<Error> for SpfResult {
730 fn from(err: Error) -> Self {
731 match err {
732 Error::Dns(DnsError::RecordNotFound(_)) | Error::Dns(DnsError::InvalidRecordType) => {
733 SpfResult::None
734 }
735 Error::Parse => SpfResult::PermError,
736 _ => SpfResult::TempError,
737 }
738 }
739}
740
741struct LookupLimit {
742 num_lookups: u32,
743 timer: Instant,
744}
745
746impl LookupLimit {
747 pub fn new() -> Self {
748 LookupLimit {
749 num_lookups: 1,
750 timer: Instant::now(),
751 }
752 }
753
754 #[inline(always)]
755 fn can_lookup(&mut self) -> bool {
756 if self.num_lookups <= 10 && self.timer.elapsed().as_secs() < 20 {
757 self.num_lookups += 1;
758 true
759 } else {
760 false
761 }
762 }
763}
764
765#[cfg(test)]
766#[allow(unused)]
767mod test {
768
769 use std::{
770 fs,
771 net::{IpAddr, Ipv4Addr, Ipv6Addr},
772 path::PathBuf,
773 time::{Duration, Instant},
774 };
775
776 use crate::{
777 MessageAuthenticator, Mx, SpfResult,
778 dns::cache::test::DummyCaches,
779 parse::TxtRecordParser,
780 spf::{Macro, SpfRecord},
781 };
782
783 use super::SpfParameters;
784
785 #[tokio::test]
786 async fn spf_verify() {
787 let resolver = MessageAuthenticator::new_system_conf().unwrap();
788 let valid_until = Instant::now() + Duration::from_secs(30);
789 let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
790 test_dir.push("resources");
791 test_dir.push("spf");
792
793 for file_name in fs::read_dir(&test_dir).unwrap() {
794 let file_name = file_name.unwrap().path();
795 println!("===== {} =====", file_name.display());
796 let test_suite = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
797 let caches = DummyCaches::new();
798
799 for test in test_suite.split("---\n") {
800 let mut test_name = "";
801 let mut last_test_name = "";
802 let mut helo = "";
803 let mut mail_from = "";
804 let mut client_ip = "127.0.0.1".parse::<IpAddr>().unwrap();
805 let mut test_num = 1;
806
807 for line in test.split('\n') {
808 let line = line.trim();
809 let line = if let Some(line) = line.strip_prefix('-') {
810 line.trim()
811 } else {
812 line
813 };
814
815 if let Some(name) = line.strip_prefix("name:") {
816 test_name = name.trim();
817 } else if let Some(record) = line.strip_prefix("spf:") {
818 let (name, record) = record.trim().split_once(' ').unwrap();
819 caches.txt_add(
820 name.trim().to_string(),
821 SpfRecord::parse(record.as_bytes()),
822 valid_until,
823 );
824 } else if let Some(record) = line.strip_prefix("exp:") {
825 let (name, record) = record.trim().split_once(' ').unwrap();
826 caches.txt_add(
827 name.trim().to_string(),
828 Macro::parse(record.as_bytes()),
829 valid_until,
830 );
831 } else if let Some(record) = line.strip_prefix("a:") {
832 let (name, record) = record.trim().split_once(' ').unwrap();
833 caches.ipv4_add(
834 name.trim().to_string(),
835 record
836 .split(',')
837 .map(|item| item.trim().parse::<Ipv4Addr>().unwrap())
838 .collect(),
839 valid_until,
840 );
841 } else if let Some(record) = line.strip_prefix("aaaa:") {
842 let (name, record) = record.trim().split_once(' ').unwrap();
843 caches.ipv6_add(
844 name.trim().to_string(),
845 record
846 .split(',')
847 .map(|item| item.trim().parse::<Ipv6Addr>().unwrap())
848 .collect(),
849 valid_until,
850 );
851 } else if let Some(record) = line.strip_prefix("ptr:") {
852 let (name, record) = record.trim().split_once(' ').unwrap();
853 caches.ptr_add(
854 name.trim().parse::<IpAddr>().unwrap(),
855 record
856 .split(',')
857 .map(|item| Box::from(item.trim()))
858 .collect(),
859 valid_until,
860 );
861 } else if let Some(record) = line.strip_prefix("mx:") {
862 let (name, record) = record.trim().split_once(' ').unwrap();
863 let mut mxs = Vec::new();
864 for (pos, item) in record.split(',').enumerate() {
865 let ip = item.trim().parse::<IpAddr>().unwrap();
866 let mx_name = format!("mx.{ip}.{pos}");
867 match ip {
868 IpAddr::V4(ip) => {
869 caches.ipv4_add(mx_name.clone(), vec![ip], valid_until)
870 }
871 IpAddr::V6(ip) => {
872 caches.ipv6_add(mx_name.clone(), vec![ip], valid_until)
873 }
874 }
875 mxs.push(Mx {
876 exchanges: Box::new([mx_name.into_boxed_str()]),
877 preference: (pos + 1) as u16,
878 });
879 }
880 caches.mx_add(name.trim().to_string(), mxs, valid_until);
881 } else if let Some(value) = line.strip_prefix("domain:") {
882 helo = value.trim();
883 } else if let Some(value) = line.strip_prefix("sender:") {
884 mail_from = value.trim();
885 } else if let Some(value) = line.strip_prefix("ip:") {
886 client_ip = value.trim().parse().unwrap();
887 } else if let Some(value) = line.strip_prefix("expect:") {
888 let value = value.trim();
889 let (result, exp): (SpfResult, &str) =
890 if let Some((result, exp)) = value.split_once(' ') {
891 (result.trim().parse::<SpfResult>().unwrap(), exp.trim())
892 } else {
893 (value.parse::<SpfResult>().unwrap(), "")
894 };
895 let output = resolver
896 .verify_spf(caches.parameters(SpfParameters::helo_and_mail_from(
897 client_ip,
898 helo,
899 "localdomain.org",
900 mail_from,
901 )))
902 .await;
903 assert_eq!(
904 output.result(),
905 result,
906 "Failed for {test_name:?}, test {test_num}, ehlo: {helo}, mail-from: {mail_from}.",
907 );
908
909 if !exp.is_empty() {
910 assert_eq!(Some(exp.to_string()).as_deref(), output.explanation());
911 }
912 test_num += 1;
913 if test_name != last_test_name {
914 println!("Passed test {test_name:?}");
915 last_test_name = test_name;
916 }
917 }
918 }
919 }
920 }
921 }
922}