1use anyhow::Context;
2use dns_resolver::{
3 fully_qualify, get_resolver, has_colon_port, ip_lookup, DomainClassification, IpLookupStrategy,
4 Name, Resolver,
5};
6use hickory_resolver::proto::rr::{RData, RecordType};
7use kumo_address::host_or_socket::HostOrSocketAddress;
8use kumo_log_types::ResolvedAddress;
9use kumo_prometheus::declare_metric;
10use lruttl::declare_cache;
11use mta_sts::policy::MtaStsPolicy;
12pub use mta_sts::policy::PolicyMode;
13use rand::prelude::SliceRandom;
14use serde::Serialize;
15use std::collections::BTreeMap;
16use std::net::IpAddr;
17use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
18use std::sync::{Arc, LazyLock};
19use std::time::{Duration, Instant};
20use tokio::sync::Semaphore;
21use tokio::time::timeout;
22
23static MTA_STS_ENABLED: AtomicBool = AtomicBool::new(true);
27
28pub fn set_mta_sts_enabled(enabled: bool) {
29 MTA_STS_ENABLED.store(enabled, Ordering::Relaxed);
30}
31
32pub fn is_mta_sts_enabled() -> bool {
33 MTA_STS_ENABLED.load(Ordering::Relaxed)
34}
35
36const MTA_STS_FETCH_RETRY: Duration = Duration::from_secs(300);
40
41static MX_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(128);
43static MX_CONCURRENCY_SEMA: LazyLock<Semaphore> =
44 LazyLock::new(|| Semaphore::new(MX_MAX_CONCURRENCY.load(Ordering::SeqCst)));
45
46static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
48
49static MX_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(300 * 1000);
51
52pub fn set_mx_concurrency_limit(n: usize) {
53 MX_MAX_CONCURRENCY.store(n, Ordering::SeqCst);
54}
55
56pub fn set_mx_timeout(duration: Duration) -> anyhow::Result<()> {
57 let ms = duration
58 .as_millis()
59 .try_into()
60 .context("set_mx_timeout: duration is too large")?;
61 MX_TIMEOUT_MS.store(ms, Ordering::Relaxed);
62 Ok(())
63}
64
65pub fn get_mx_timeout() -> Duration {
66 Duration::from_millis(MX_TIMEOUT_MS.load(Ordering::Relaxed) as u64)
67}
68
69pub fn set_mx_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
70 let ms = duration
71 .as_millis()
72 .try_into()
73 .context("set_mx_negative_cache_ttl: duration is too large")?;
74 MX_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
75 Ok(())
76}
77
78pub fn get_mx_negative_ttl() -> Duration {
79 Duration::from_millis(MX_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
80}
81
82struct ByPreference {
83 pub hosts: Vec<String>,
84 pub pref: u16,
85 pub is_secure: bool,
86 pub is_mx: bool,
87}
88
89async fn lookup_mx_record(
90 domain_name: &Name,
91 resolver: Option<&dyn Resolver>,
92) -> anyhow::Result<(Vec<ByPreference>, Instant)> {
93 let mx_lookup = timeout(get_mx_timeout(), async {
94 let _permit = MX_CONCURRENCY_SEMA.acquire().await;
95 match resolver {
96 Some(r) => r.resolve(domain_name.clone(), RecordType::MX).await,
97 None => {
98 get_resolver()
99 .resolve(domain_name.clone(), RecordType::MX)
100 .await
101 }
102 }
103 })
104 .await??;
105 let mx_records = mx_lookup.records;
106
107 if mx_records.is_empty() {
108 if mx_lookup.nxdomain {
109 anyhow::bail!("NXDOMAIN");
110 }
111
112 return Ok((
117 vec![ByPreference {
118 hosts: vec![domain_name.to_lowercase().to_ascii()],
119 pref: 1,
120 is_secure: mx_lookup.secure,
121 is_mx: false,
122 }],
123 mx_lookup.expires,
124 ));
125 }
126
127 let mut records: Vec<ByPreference> = Vec::with_capacity(mx_records.len());
128
129 for mx_record in mx_records {
130 if let RData::MX(mx) = mx_record {
131 let pref = mx.preference;
132 let host = mx.exchange.to_lowercase().to_string();
133
134 if let Some(record) = records.iter_mut().find(|r| r.pref == pref) {
135 record.hosts.push(host);
136 } else {
137 records.push(ByPreference {
138 hosts: vec![host],
139 pref,
140 is_secure: mx_lookup.secure,
141 is_mx: true,
142 });
143 }
144 }
145 }
146
147 records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
149
150 for mx in &mut records {
153 mx.hosts.sort();
154 }
155
156 Ok((records, mx_lookup.expires))
157}
158
159fn factor_names<S: AsRef<str>>(name_strings: &[S]) -> String {
164 let mut max_element_count = 0;
165
166 let mut names = vec![];
167
168 for name in name_strings {
169 let (name, opt_port) = match has_colon_port(name.as_ref()) {
170 Some((name, port)) => (name, Some(port)),
171 None => (name.as_ref(), None),
172 };
173 if let Ok(name) = fully_qualify(name) {
174 names.push((name.to_lowercase(), opt_port));
175 }
176 }
177
178 let mut elements: Vec<Vec<&str>> = vec![];
179
180 let mut split_names = vec![];
181 for (name, opt_port) in names {
182 let mut fields: Vec<_> = name
183 .iter()
184 .map(|s| String::from_utf8_lossy(s).to_string())
185 .collect();
186 if let Some(port) = opt_port {
187 fields.last_mut().map(|s| {
188 s.push_str(&format!(":{port}"));
189 });
190 }
191 fields.reverse();
192 max_element_count = max_element_count.max(fields.len());
193 split_names.push(fields);
194 }
195
196 fn add_element<'a>(elements: &mut Vec<Vec<&'a str>>, field: &'a str, i: usize) {
197 match elements.get_mut(i) {
198 Some(ele) => {
199 if !ele.contains(&field) {
200 ele.push(field);
201 }
202 }
203 None => {
204 elements.push(vec![field]);
205 }
206 }
207 }
208
209 for fields in &split_names {
210 for (i, field) in fields.iter().enumerate() {
211 add_element(&mut elements, field, i);
212 }
213 for i in fields.len()..max_element_count {
214 add_element(&mut elements, "?", i);
215 }
216 }
217
218 let mut result = vec![];
219 for mut ele in elements {
220 let has_q = ele.contains(&"?");
221 ele.retain(|&e| e != "?");
222 let mut item_text = if ele.len() == 1 {
223 ele[0].to_string()
224 } else {
225 format!("({})", ele.join("|"))
226 };
227 if has_q {
228 item_text.push('?');
229 }
230 result.push(item_text);
231 }
232 result.reverse();
233
234 result.join(".")
235}
236
237#[derive(Clone, Debug, Serialize)]
238pub struct MailExchanger {
239 pub domain_name: String,
240 pub hosts: Vec<String>,
241 pub site_name: String,
242 pub by_pref: BTreeMap<u16, Vec<String>>,
243 pub is_domain_literal: bool,
244 pub is_secure: bool,
246 pub is_mx: bool,
247 pub mta_sts: PolicyMode,
251 #[serde(skip)]
252 expires: Option<Instant>,
253}
254
255declare_cache! {
256static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, String>>::new("dns_resolver_mx", 64 * 1024);
258}
259
260declare_metric! {
261static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
263}
264
265declare_metric! {
266static MX_SUCCESS: IntCounter(
268 "dns_mx_resolve_status_ok");
269}
270
271declare_metric! {
272static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
278}
279
280declare_metric! {
281static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
286}
287
288declare_metric! {
289static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
294}
295
296declare_metric! {
297static MX_MTA_STS_IMPOSSIBLE: IntCounter("dns_mx_resolve_mta_sts_impossible");
301}
302
303#[derive(Debug, PartialEq, Eq)]
305enum StsEval {
306 Status(PolicyMode),
308 Prune(Vec<bool>),
311 Impossible,
313}
314
315fn evaluate_mta_sts(hosts: &[String], policy: &MtaStsPolicy) -> StsEval {
318 match policy.mode {
319 PolicyMode::None => StsEval::Status(PolicyMode::None),
320 PolicyMode::Testing => StsEval::Status(PolicyMode::Testing),
321 PolicyMode::Enforce => {
322 let matched: Vec<bool> = hosts
323 .iter()
324 .map(|h| {
325 let label = match has_colon_port(h) {
326 Some((label, _)) => label,
327 None => h.as_str(),
328 };
329 policy.mx_name_matches(label)
330 })
331 .collect();
332 let match_count = matched.iter().filter(|m| **m).count();
333 if match_count == 0 {
334 StsEval::Impossible
335 } else if match_count == hosts.len() {
336 StsEval::Status(PolicyMode::Enforce)
337 } else {
338 StsEval::Prune(matched)
339 }
340 }
341 }
342}
343
344async fn apply_mta_sts(
349 name_fq: &Name,
350 by_pref: &mut Vec<ByPreference>,
351 hosts: &mut Vec<String>,
352 expires: &mut Instant,
353 resolver: Option<&dyn Resolver>,
354) -> Result<PolicyMode, String> {
355 let policy_domain = name_fq.to_ascii();
356 let policy_domain = policy_domain.trim_end_matches('.');
357
358 let policy = match mta_sts::get_policy_for_domain(policy_domain, resolver).await {
359 Ok(policy) => policy,
360 Err(err) => {
361 tracing::debug!("MTA-STS policy fetch for {policy_domain} failed: {err:#}");
364 *expires = (*expires).min(Instant::now() + MTA_STS_FETCH_RETRY);
365 return Ok(PolicyMode::None);
366 }
367 };
368
369 let mta_sts = match evaluate_mta_sts(hosts, &policy) {
370 StsEval::Status(status) => status,
371 StsEval::Prune(matched) => {
372 let mut idx = 0;
376 for pref in by_pref.iter_mut() {
377 pref.hosts.retain(|_| {
378 let keep = matched[idx];
379 idx += 1;
380 keep
381 });
382 }
383 by_pref.retain(|p| !p.hosts.is_empty());
384 *hosts = by_pref
385 .iter()
386 .flat_map(|p| p.hosts.iter().cloned())
387 .collect();
388 PolicyMode::Enforce
389 }
390 StsEval::Impossible => {
391 MX_MTA_STS_IMPOSSIBLE.inc();
392 return Err(format!(
393 "MTA-STS enforce policy for {policy_domain} permits none of its \
394 MX hosts {hosts:?}; allowed mx patterns: {patterns:?}. The \
395 destination is undeliverable until its MTA-STS policy is \
396 corrected.",
397 patterns = policy.mx
398 ));
399 }
400 };
401
402 *expires = (*expires).min(Instant::now() + Duration::from_secs(policy.max_age));
405 Ok(mta_sts)
406}
407
408impl MailExchanger {
409 pub async fn resolve(domain_name: &str) -> anyhow::Result<Arc<Self>> {
410 Self::resolve_via(domain_name, None).await
411 }
412
413 pub async fn resolve_via(
418 domain_name: &str,
419 resolver: Option<&dyn Resolver>,
420 ) -> anyhow::Result<Arc<Self>> {
421 MX_IN_PROGRESS.inc();
422 let result = Self::resolve_impl(domain_name, resolver).await;
423 MX_IN_PROGRESS.dec();
424 if result.is_ok() {
425 MX_SUCCESS.inc();
426 } else {
427 MX_FAIL.inc();
428 }
429 result
430 }
431
432 async fn resolve_impl(
433 domain_name: &str,
434 resolver: Option<&dyn Resolver>,
435 ) -> anyhow::Result<Arc<Self>> {
436 let (name_fq, opt_port) = match DomainClassification::classify(domain_name)? {
437 DomainClassification::Literal(addr) => {
438 let mut by_pref = BTreeMap::new();
439 by_pref.insert(1, vec![addr.to_string()]);
440 return Ok(Arc::new(Self {
441 domain_name: domain_name.to_string(),
442 hosts: vec![addr.to_string()],
443 site_name: addr.to_string(),
444 by_pref,
445 is_domain_literal: true,
446 is_secure: false,
447 is_mx: false,
448 mta_sts: PolicyMode::None,
449 expires: None,
450 }));
451 }
452 DomainClassification::Domain(name_fq, opt_port) => (name_fq, opt_port),
453 };
454
455 if resolver.is_some() {
458 return Self::resolve_uncached(&name_fq, opt_port, domain_name, resolver)
459 .await?
460 .map_err(|err| anyhow::anyhow!("{err}"));
461 }
462
463 let lookup_result = MX_CACHE
464 .get_or_try_insert(
465 &(name_fq.clone(), opt_port),
466 |mx_result| {
467 if let Ok(mx) = mx_result {
468 if let Some(exp) = mx.expires {
469 return exp
470 .checked_duration_since(std::time::Instant::now())
471 .unwrap_or_else(|| Duration::from_secs(10));
472 }
473 }
474 get_mx_negative_ttl()
475 },
476 Self::resolve_uncached(&name_fq, opt_port, domain_name, None),
477 )
478 .await
479 .map_err(|err| anyhow::anyhow!("{err}"))?;
480
481 if !lookup_result.is_fresh {
482 MX_CACHED.inc();
483 }
484
485 lookup_result.item.map_err(|err| anyhow::anyhow!("{err}"))
486 }
487
488 async fn resolve_uncached(
489 name_fq: &Name,
490 opt_port: Option<u16>,
491 domain_name: &str,
492 resolver: Option<&dyn Resolver>,
493 ) -> anyhow::Result<Result<Arc<MailExchanger>, String>> {
494 MX_QUERIES.inc();
495 let start = Instant::now();
496 let (mut by_pref, mut expires) = match lookup_mx_record(name_fq, resolver).await {
497 Ok((by_pref, expires)) => (by_pref, expires),
498 Err(err) => {
499 let error = format!(
500 "MX lookup for {domain_name} failed after {elapsed:?}: {err:#}",
501 elapsed = start.elapsed()
502 );
503 return Ok(Err(error));
504 }
505 };
506
507 let mut hosts = vec![];
508 for pref in &mut by_pref {
509 for host in &mut pref.hosts {
510 if let Some(port) = opt_port {
511 *host = format!("{host}:{port}");
512 };
513 hosts.push(host.to_string());
514 }
515 }
516
517 let is_secure = by_pref.iter().all(|p| p.is_secure);
518 let is_mx = by_pref.iter().all(|p| p.is_mx);
519
520 let mta_sts = if is_mx && is_mta_sts_enabled() {
525 match apply_mta_sts(name_fq, &mut by_pref, &mut hosts, &mut expires, resolver).await {
526 Ok(status) => status,
527 Err(error) => return Ok(Err(error)),
528 }
529 } else {
530 PolicyMode::None
531 };
532
533 let by_pref = by_pref
534 .into_iter()
535 .map(|pref| (pref.pref, pref.hosts))
536 .collect();
537
538 let site_name = factor_names(&hosts);
539 let mx = Self {
540 hosts,
541 domain_name: name_fq.to_ascii(),
542 site_name,
543 by_pref,
544 is_domain_literal: false,
545 is_secure,
546 is_mx,
547 mta_sts,
548 expires: Some(expires),
549 };
550
551 Ok(Ok(Arc::new(mx)))
552 }
553
554 pub fn has_expired(&self) -> bool {
555 match self.expires {
556 Some(deadline) => deadline <= Instant::now(),
557 None => false,
558 }
559 }
560
561 pub async fn resolve_addresses(
566 &self,
567 resolver: Option<&dyn Resolver>,
568 strategy: IpLookupStrategy,
569 ) -> ResolvedMxAddresses {
570 let mut result = vec![];
571
572 for hosts in self.by_pref.values().rev() {
573 let mut by_pref = vec![];
574
575 for mx_host in hosts {
576 if mx_host == "." {
578 return ResolvedMxAddresses::NullMx;
579 }
580
581 let (mx_host, opt_port) = match has_colon_port(mx_host) {
583 Some((domain_name, port)) => (domain_name, Some(port)),
584 None => (mx_host.as_str(), None),
585 };
586 if let Ok(addr) = mx_host.parse::<IpAddr>() {
587 let mut addr: HostOrSocketAddress = addr.into();
588 if let Some(port) = opt_port {
589 addr.set_port(port);
590 }
591 by_pref.push(ResolvedAddress {
592 name: mx_host.to_string(),
593 addr: addr.into(),
594 is_secure: false,
595 });
596 continue;
597 }
598
599 match ip_lookup(mx_host, resolver, strategy).await {
600 Err(err) => {
601 tracing::error!("failed to resolve {mx_host}: {err:#}");
602 continue;
603 }
604 Ok((result, _expires)) => {
605 for addr in result.addrs.iter() {
606 let mut addr: HostOrSocketAddress = (*addr).into();
607 if let Some(port) = opt_port {
608 addr.set_port(port);
609 }
610 by_pref.push(ResolvedAddress {
611 name: mx_host.to_string(),
612 addr,
613 is_secure: result.secure,
614 });
615 }
616 }
617 }
618 }
619
620 let mut rng = rand::thread_rng();
624 by_pref.shuffle(&mut rng);
625 result.append(&mut by_pref);
626 }
627 ResolvedMxAddresses::Addresses(result)
628 }
629}
630
631#[derive(Debug, Clone, Serialize)]
632pub enum ResolvedMxAddresses {
633 NullMx,
634 Addresses(Vec<ResolvedAddress>),
637}
638
639#[cfg(test)]
640mod test {
641 use super::*;
642 use dns_resolver::TestResolver;
643
644 fn policy(mode: &str, mx: &[&str]) -> MtaStsPolicy {
645 let mut text = format!("version: STSv1\nmode: {mode}\nmax_age: 86400");
646 for m in mx {
647 text.push_str(&format!("\nmx: {m}"));
648 }
649 MtaStsPolicy::parse(&text).unwrap()
650 }
651
652 fn hosts(list: &[&str]) -> Vec<String> {
653 list.iter().map(|s| s.to_string()).collect()
654 }
655
656 #[test]
657 fn mta_sts_none_and_testing() {
658 assert_eq!(
659 evaluate_mta_sts(&hosts(&["mx01.mail.icloud.com."]), &policy("none", &[])),
660 StsEval::Status(PolicyMode::None)
661 );
662 assert_eq!(
663 evaluate_mta_sts(
664 &hosts(&["mx01.mail.icloud.com."]),
665 &policy("testing", &["*.mx.cloudflare.net"])
666 ),
667 StsEval::Status(PolicyMode::Testing)
668 );
669 }
670
671 #[test]
672 fn mta_sts_enforce_full_match() {
673 assert_eq!(
674 evaluate_mta_sts(
675 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
676 &policy("enforce", &["*.mail.icloud.com"])
677 ),
678 StsEval::Status(PolicyMode::Enforce)
679 );
680 }
681
682 #[test]
683 fn mta_sts_enforce_partial_prunes() {
684 assert_eq!(
686 evaluate_mta_sts(
687 &hosts(&["mx01.mail.icloud.com.", "backup.example.net."]),
688 &policy("enforce", &["*.mail.icloud.com"])
689 ),
690 StsEval::Prune(vec![true, false])
691 );
692 }
693
694 #[test]
695 fn mta_sts_enforce_impossible() {
696 assert_eq!(
699 evaluate_mta_sts(
700 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
701 &policy("enforce", &["*.mx.cloudflare.net"])
702 ),
703 StsEval::Impossible
704 );
705 }
706
707 #[test]
708 fn mta_sts_enforce_strips_port() {
709 assert_eq!(
710 evaluate_mta_sts(
711 &hosts(&["mx01.mail.icloud.com.:587"]),
712 &policy("enforce", &["*.mail.icloud.com"])
713 ),
714 StsEval::Status(PolicyMode::Enforce)
715 );
716 }
717
718 #[tokio::test]
719 async fn literal_resolve() {
720 let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
721 k9::snapshot!(
722 &v4_loopback,
723 r#"
724MailExchanger {
725 domain_name: "[127.0.0.1]",
726 hosts: [
727 "127.0.0.1",
728 ],
729 site_name: "127.0.0.1",
730 by_pref: {
731 1: [
732 "127.0.0.1",
733 ],
734 },
735 is_domain_literal: true,
736 is_secure: false,
737 is_mx: false,
738 mta_sts: None,
739 expires: None,
740}
741"#
742 );
743 k9::snapshot!(
744 v4_loopback
745 .resolve_addresses(None, IpLookupStrategy::default())
746 .await,
747 r#"
748Addresses(
749 [
750 ResolvedAddress {
751 name: "127.0.0.1",
752 addr: 127.0.0.1,
753 is_secure: false,
754 },
755 ],
756)
757"#
758 );
759
760 let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
761 k9::snapshot!(
762 &v6_loopback_non_conforming,
763 r#"
764MailExchanger {
765 domain_name: "[::1]",
766 hosts: [
767 "::1",
768 ],
769 site_name: "::1",
770 by_pref: {
771 1: [
772 "::1",
773 ],
774 },
775 is_domain_literal: true,
776 is_secure: false,
777 is_mx: false,
778 mta_sts: None,
779 expires: None,
780}
781"#
782 );
783 k9::snapshot!(
784 v6_loopback_non_conforming
785 .resolve_addresses(None, IpLookupStrategy::default())
786 .await,
787 r#"
788Addresses(
789 [
790 ResolvedAddress {
791 name: "::1",
792 addr: ::1,
793 is_secure: false,
794 },
795 ],
796)
797"#
798 );
799
800 let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
801 k9::snapshot!(
802 &v6_loopback,
803 r#"
804MailExchanger {
805 domain_name: "[IPv6:::1]",
806 hosts: [
807 "::1",
808 ],
809 site_name: "::1",
810 by_pref: {
811 1: [
812 "::1",
813 ],
814 },
815 is_domain_literal: true,
816 is_secure: false,
817 is_mx: false,
818 mta_sts: None,
819 expires: None,
820}
821"#
822 );
823 k9::snapshot!(
824 v6_loopback
825 .resolve_addresses(None, IpLookupStrategy::default())
826 .await,
827 r#"
828Addresses(
829 [
830 ResolvedAddress {
831 name: "::1",
832 addr: ::1,
833 is_secure: false,
834 },
835 ],
836)
837"#
838 );
839 }
840
841 fn fixture_resolver(zones: &[&str]) -> TestResolver {
842 let mut resolver = TestResolver::default();
843 for zone in zones {
844 resolver = resolver.with_zone(zone).unwrap();
845 }
846 resolver
847 }
848
849 const GMAIL_ZONE: &str = r#"
850$ORIGIN gmail.com.
851@ 86400 MX 5 gmail-smtp-in.l.google.com.
852@ 86400 MX 10 alt1.gmail-smtp-in.l.google.com.
853@ 86400 MX 20 alt2.gmail-smtp-in.l.google.com.
854@ 86400 MX 30 alt3.gmail-smtp-in.l.google.com.
855@ 86400 MX 40 alt4.gmail-smtp-in.l.google.com.
856"#;
857
858 const GMAIL_HOSTS_ZONE: &str = r#"
859$ORIGIN l.google.com.
860gmail-smtp-in 300 A 142.251.2.26
861alt1.gmail-smtp-in 300 A 108.177.104.27
862alt2.gmail-smtp-in 300 A 74.125.126.27
863alt3.gmail-smtp-in 300 A 172.253.113.26
864alt4.gmail-smtp-in 300 A 173.194.77.27
865"#;
866
867 #[tokio::test]
868 async fn lookup_gmail_mx() {
869 let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
870 let mut gmail = (*MailExchanger::resolve_via("gmail.com", Some(&resolver))
871 .await
872 .unwrap())
873 .clone();
874 gmail.expires.take();
875 k9::snapshot!(
876 &gmail,
877 r#"
878MailExchanger {
879 domain_name: "gmail.com.",
880 hosts: [
881 "gmail-smtp-in.l.google.com.",
882 "alt1.gmail-smtp-in.l.google.com.",
883 "alt2.gmail-smtp-in.l.google.com.",
884 "alt3.gmail-smtp-in.l.google.com.",
885 "alt4.gmail-smtp-in.l.google.com.",
886 ],
887 site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
888 by_pref: {
889 5: [
890 "gmail-smtp-in.l.google.com.",
891 ],
892 10: [
893 "alt1.gmail-smtp-in.l.google.com.",
894 ],
895 20: [
896 "alt2.gmail-smtp-in.l.google.com.",
897 ],
898 30: [
899 "alt3.gmail-smtp-in.l.google.com.",
900 ],
901 40: [
902 "alt4.gmail-smtp-in.l.google.com.",
903 ],
904 },
905 is_domain_literal: false,
906 is_secure: false,
907 is_mx: true,
908 mta_sts: None,
909 expires: None,
910}
911"#
912 );
913
914 k9::snapshot!(
918 gmail
919 .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only)
920 .await,
921 r#"
922Addresses(
923 [
924 ResolvedAddress {
925 name: "alt4.gmail-smtp-in.l.google.com.",
926 addr: 173.194.77.27,
927 is_secure: false,
928 },
929 ResolvedAddress {
930 name: "alt3.gmail-smtp-in.l.google.com.",
931 addr: 172.253.113.26,
932 is_secure: false,
933 },
934 ResolvedAddress {
935 name: "alt2.gmail-smtp-in.l.google.com.",
936 addr: 74.125.126.27,
937 is_secure: false,
938 },
939 ResolvedAddress {
940 name: "alt1.gmail-smtp-in.l.google.com.",
941 addr: 108.177.104.27,
942 is_secure: false,
943 },
944 ResolvedAddress {
945 name: "gmail-smtp-in.l.google.com.",
946 addr: 142.251.2.26,
947 is_secure: false,
948 },
949 ],
950)
951"#
952 );
953 }
954
955 #[tokio::test]
956 async fn lookup_punycode_no_mx_only_a() {
957 let resolver = fixture_resolver(&[r#"
958$ORIGIN xn--bb-eka.at.
959@ 300 A 192.0.2.5
960"#]);
961 let mx = MailExchanger::resolve_via("xn--bb-eka.at", Some(&resolver))
962 .await
963 .unwrap();
964 assert_eq!(mx.domain_name, "xn--bb-eka.at.");
965 assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
966 }
967
968 #[tokio::test]
969 async fn lookup_nxdomain() {
970 let resolver = fixture_resolver(&[]);
973 let name = fully_qualify("not-mairs.aasland.com").unwrap();
974 let err = match lookup_mx_record(&name, Some(&resolver)).await {
975 Ok(_) => panic!("expected NXDOMAIN"),
976 Err(err) => err,
977 };
978 k9::assert_equal!(err.to_string(), "NXDOMAIN");
979 }
980
981 #[tokio::test]
982 async fn lookup_null_mx() {
983 let resolver = fixture_resolver(&[r#"
984$ORIGIN example.com.
985@ 3600 MX 0 .
986"#]);
987 let mut mx = (*MailExchanger::resolve_via("example.com", Some(&resolver))
988 .await
989 .unwrap())
990 .clone();
991 mx.expires.take();
992 k9::snapshot!(
993 &mx,
994 r#"
995MailExchanger {
996 domain_name: "example.com.",
997 hosts: [
998 ".",
999 ],
1000 site_name: "",
1001 by_pref: {
1002 0: [
1003 ".",
1004 ],
1005 },
1006 is_domain_literal: false,
1007 is_secure: false,
1008 is_mx: true,
1009 mta_sts: None,
1010 expires: None,
1011}
1012"#
1013 );
1014 }
1015
1016 #[tokio::test]
1017 async fn lookup_single_mx() {
1018 let resolver = fixture_resolver(&[r#"
1019$ORIGIN do.havedane.net.
1020@ 300 MX 10 do.havedane.net.
1021"#]);
1022 let mut mx = (*MailExchanger::resolve_via("do.havedane.net", Some(&resolver))
1023 .await
1024 .unwrap())
1025 .clone();
1026 mx.expires.take();
1027 k9::snapshot!(
1028 &mx,
1029 r#"
1030MailExchanger {
1031 domain_name: "do.havedane.net.",
1032 hosts: [
1033 "do.havedane.net.",
1034 ],
1035 site_name: "do.havedane.net",
1036 by_pref: {
1037 10: [
1038 "do.havedane.net.",
1039 ],
1040 },
1041 is_domain_literal: false,
1042 is_secure: false,
1043 is_mx: true,
1044 mta_sts: None,
1045 expires: None,
1046}
1047"#
1048 );
1049 }
1050
1051 #[tokio::test]
1052 async fn mx_lookup_no_mx_falls_back_to_a() {
1053 let resolver = fixture_resolver(&[r#"
1056$ORIGIN example.com.
1057www 300 A 192.0.2.1
1058"#]);
1059 let mut mx = (*MailExchanger::resolve_via("www.example.com", Some(&resolver))
1060 .await
1061 .unwrap())
1062 .clone();
1063 mx.expires.take();
1064 k9::snapshot!(
1065 &mx,
1066 r#"
1067MailExchanger {
1068 domain_name: "www.example.com.",
1069 hosts: [
1070 "www.example.com.",
1071 ],
1072 site_name: "www.example.com",
1073 by_pref: {
1074 1: [
1075 "www.example.com.",
1076 ],
1077 },
1078 is_domain_literal: false,
1079 is_secure: false,
1080 is_mx: false,
1081 mta_sts: None,
1082 expires: None,
1083}
1084"#
1085 );
1086 }
1087
1088 #[test]
1089 fn name_factoring() {
1090 assert_eq!(
1091 factor_names(&[
1092 "mta5.am0.yahoodns.net",
1093 "mta6.am0.yahoodns.net",
1094 "mta7.am0.yahoodns.net"
1095 ]),
1096 "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1097 );
1098
1099 assert_eq!(
1101 factor_names(&[
1102 "mta5.AM0.yahoodns.net",
1103 "mta6.am0.yAHOodns.net",
1104 "mta7.am0.yahoodns.net"
1105 ]),
1106 "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1107 );
1108
1109 assert_eq!(
1112 factor_names(&[
1113 "gmail-smtp-in.l.google.com",
1114 "alt1.gmail-smtp-in.l.google.com",
1115 "alt2.gmail-smtp-in.l.google.com",
1116 "alt3.gmail-smtp-in.l.google.com",
1117 "alt4.gmail-smtp-in.l.google.com",
1118 ]),
1119 "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com".to_string()
1120 );
1121
1122 assert_eq!(
1123 factor_names(&[
1124 "mta5.am0.yahoodns.net:123",
1125 "mta6.am0.yahoodns.net:123",
1126 "mta7.am0.yahoodns.net:123"
1127 ]),
1128 "(mta5|mta6|mta7).am0.yahoodns.net:123".to_string()
1129 );
1130 assert_eq!(
1131 factor_names(&[
1132 "mta5.am0.yahoodns.net:123",
1133 "mta6.am0.yahoodns.net:456",
1134 "mta7.am0.yahoodns.net:123"
1135 ]),
1136 "(mta5|mta6|mta7).am0.yahoodns.(net:123|net:456)".to_string()
1137 );
1138 }
1139
1140 #[test]
1144 fn mx_order_name_factor() {
1145 assert_eq!(
1146 factor_names(&[
1147 "example-com.mail.protection.outlook.com.",
1148 "mx-biz.mail.am0.yahoodns.net.",
1149 "mx-biz.mail.am0.yahoodns.net.",
1150 ]),
1151 "(example-com|mx-biz).mail.(protection|am0).(outlook|yahoodns).(com|net)".to_string()
1152 );
1153 assert_eq!(
1154 factor_names(&[
1155 "mx-biz.mail.am0.yahoodns.net.",
1156 "mx-biz.mail.am0.yahoodns.net.",
1157 "example-com.mail.protection.outlook.com.",
1158 ]),
1159 "(mx-biz|example-com).mail.(am0|protection).(yahoodns|outlook).(net|com)".to_string()
1160 );
1161 }
1162}