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