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 tracing::debug!(
504 target: "mx_resolve",
505 domain = domain_name,
506 %error,
507 "MX lookup failed; domain drops out of any site_name rollup"
508 );
509 return Ok(Err(error));
510 }
511 };
512
513 let mut hosts = vec![];
514 for pref in &mut by_pref {
515 for host in &mut pref.hosts {
516 if let Some(port) = opt_port {
517 *host = format!("{host}:{port}");
518 };
519 hosts.push(host.to_string());
520 }
521 }
522
523 let is_secure = by_pref.iter().all(|p| p.is_secure);
524 let is_mx = by_pref.iter().all(|p| p.is_mx);
525
526 let mta_sts = if is_mx && is_mta_sts_enabled() {
531 match apply_mta_sts(name_fq, &mut by_pref, &mut hosts, &mut expires, resolver).await {
532 Ok(status) => status,
533 Err(error) => {
534 tracing::debug!(
535 target: "mx_resolve",
536 domain = domain_name,
537 %error,
538 "MTA-STS evaluation failed; domain drops out of any site_name rollup"
539 );
540 return Ok(Err(error));
541 }
542 }
543 } else {
544 PolicyMode::None
545 };
546
547 let by_pref = by_pref
548 .into_iter()
549 .map(|pref| (pref.pref, pref.hosts))
550 .collect();
551
552 let site_name = factor_names(&hosts);
553 tracing::debug!(
554 target: "mx_resolve",
555 domain = domain_name,
556 %site_name,
557 ?hosts,
558 is_mx,
559 is_secure,
560 elapsed_ms = start.elapsed().as_millis() as u64,
561 "resolved MX to site_name"
562 );
563 let mx = Self {
564 hosts,
565 domain_name: name_fq.to_ascii(),
566 site_name,
567 by_pref,
568 is_domain_literal: false,
569 is_secure,
570 is_mx,
571 mta_sts,
572 expires: Some(expires),
573 };
574
575 Ok(Ok(Arc::new(mx)))
576 }
577
578 pub fn has_expired(&self) -> bool {
579 match self.expires {
580 Some(deadline) => deadline <= Instant::now(),
581 None => false,
582 }
583 }
584
585 pub async fn resolve_addresses(
590 &self,
591 resolver: Option<&dyn Resolver>,
592 strategy: IpLookupStrategy,
593 ) -> ResolvedMxAddresses {
594 let mut result = vec![];
595
596 for hosts in self.by_pref.values().rev() {
597 let mut by_pref = vec![];
598
599 for mx_host in hosts {
600 if mx_host == "." {
602 return ResolvedMxAddresses::NullMx;
603 }
604
605 let (mx_host, opt_port) = match has_colon_port(mx_host) {
607 Some((domain_name, port)) => (domain_name, Some(port)),
608 None => (mx_host.as_str(), None),
609 };
610 if let Ok(addr) = mx_host.parse::<IpAddr>() {
611 let mut addr: HostOrSocketAddress = addr.into();
612 if let Some(port) = opt_port {
613 addr.set_port(port);
614 }
615 by_pref.push(ResolvedAddress {
616 name: mx_host.to_string(),
617 addr,
618 is_secure: false,
619 });
620 continue;
621 }
622
623 match ip_lookup(mx_host, resolver, strategy).await {
624 Err(err) => {
625 tracing::error!("failed to resolve {mx_host}: {err:#}");
626 continue;
627 }
628 Ok((result, _expires)) => {
629 for addr in result.addrs.iter() {
630 let mut addr: HostOrSocketAddress = (*addr).into();
631 if let Some(port) = opt_port {
632 addr.set_port(port);
633 }
634 by_pref.push(ResolvedAddress {
635 name: mx_host.to_string(),
636 addr,
637 is_secure: result.secure,
638 });
639 }
640 }
641 }
642 }
643
644 let mut rng = rand::thread_rng();
648 by_pref.shuffle(&mut rng);
649 result.append(&mut by_pref);
650 }
651 ResolvedMxAddresses::Addresses(result)
652 }
653}
654
655#[derive(Debug, Clone, Serialize)]
656pub enum ResolvedMxAddresses {
657 NullMx,
658 Addresses(Vec<ResolvedAddress>),
661}
662
663#[cfg(test)]
664mod test {
665 use super::*;
666 use dns_resolver::TestResolver;
667
668 fn policy(mode: &str, mx: &[&str]) -> MtaStsPolicy {
669 let mut text = format!("version: STSv1\nmode: {mode}\nmax_age: 86400");
670 for m in mx {
671 text.push_str(&format!("\nmx: {m}"));
672 }
673 MtaStsPolicy::parse(&text).unwrap()
674 }
675
676 fn hosts(list: &[&str]) -> Vec<String> {
677 list.iter().map(|s| s.to_string()).collect()
678 }
679
680 #[test]
681 fn mta_sts_none_and_testing() {
682 assert_eq!(
683 evaluate_mta_sts(&hosts(&["mx01.mail.icloud.com."]), &policy("none", &[])),
684 StsEval::Status(PolicyMode::None)
685 );
686 assert_eq!(
687 evaluate_mta_sts(
688 &hosts(&["mx01.mail.icloud.com."]),
689 &policy("testing", &["*.mx.cloudflare.net"])
690 ),
691 StsEval::Status(PolicyMode::Testing)
692 );
693 }
694
695 #[test]
696 fn mta_sts_enforce_full_match() {
697 assert_eq!(
698 evaluate_mta_sts(
699 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
700 &policy("enforce", &["*.mail.icloud.com"])
701 ),
702 StsEval::Status(PolicyMode::Enforce)
703 );
704 }
705
706 #[test]
707 fn mta_sts_enforce_partial_prunes() {
708 assert_eq!(
710 evaluate_mta_sts(
711 &hosts(&["mx01.mail.icloud.com.", "backup.example.net."]),
712 &policy("enforce", &["*.mail.icloud.com"])
713 ),
714 StsEval::Prune(vec![true, false])
715 );
716 }
717
718 #[test]
719 fn mta_sts_enforce_impossible() {
720 assert_eq!(
723 evaluate_mta_sts(
724 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
725 &policy("enforce", &["*.mx.cloudflare.net"])
726 ),
727 StsEval::Impossible
728 );
729 }
730
731 #[test]
732 fn mta_sts_enforce_strips_port() {
733 assert_eq!(
734 evaluate_mta_sts(
735 &hosts(&["mx01.mail.icloud.com.:587"]),
736 &policy("enforce", &["*.mail.icloud.com"])
737 ),
738 StsEval::Status(PolicyMode::Enforce)
739 );
740 }
741
742 #[tokio::test]
743 async fn literal_resolve() {
744 let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
745 k9::snapshot!(
746 &v4_loopback,
747 r#"
748MailExchanger {
749 domain_name: "[127.0.0.1]",
750 hosts: [
751 "127.0.0.1",
752 ],
753 site_name: "127.0.0.1",
754 by_pref: {
755 1: [
756 "127.0.0.1",
757 ],
758 },
759 is_domain_literal: true,
760 is_secure: false,
761 is_mx: false,
762 mta_sts: None,
763 expires: None,
764}
765"#
766 );
767 k9::snapshot!(
768 v4_loopback
769 .resolve_addresses(None, IpLookupStrategy::default())
770 .await,
771 r#"
772Addresses(
773 [
774 ResolvedAddress {
775 name: "127.0.0.1",
776 addr: 127.0.0.1,
777 is_secure: false,
778 },
779 ],
780)
781"#
782 );
783
784 let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
785 k9::snapshot!(
786 &v6_loopback_non_conforming,
787 r#"
788MailExchanger {
789 domain_name: "[::1]",
790 hosts: [
791 "::1",
792 ],
793 site_name: "::1",
794 by_pref: {
795 1: [
796 "::1",
797 ],
798 },
799 is_domain_literal: true,
800 is_secure: false,
801 is_mx: false,
802 mta_sts: None,
803 expires: None,
804}
805"#
806 );
807 k9::snapshot!(
808 v6_loopback_non_conforming
809 .resolve_addresses(None, IpLookupStrategy::default())
810 .await,
811 r#"
812Addresses(
813 [
814 ResolvedAddress {
815 name: "::1",
816 addr: ::1,
817 is_secure: false,
818 },
819 ],
820)
821"#
822 );
823
824 let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
825 k9::snapshot!(
826 &v6_loopback,
827 r#"
828MailExchanger {
829 domain_name: "[IPv6:::1]",
830 hosts: [
831 "::1",
832 ],
833 site_name: "::1",
834 by_pref: {
835 1: [
836 "::1",
837 ],
838 },
839 is_domain_literal: true,
840 is_secure: false,
841 is_mx: false,
842 mta_sts: None,
843 expires: None,
844}
845"#
846 );
847 k9::snapshot!(
848 v6_loopback
849 .resolve_addresses(None, IpLookupStrategy::default())
850 .await,
851 r#"
852Addresses(
853 [
854 ResolvedAddress {
855 name: "::1",
856 addr: ::1,
857 is_secure: false,
858 },
859 ],
860)
861"#
862 );
863 }
864
865 fn fixture_resolver(zones: &[&str]) -> TestResolver {
866 let mut resolver = TestResolver::default();
867 for zone in zones {
868 resolver = resolver.with_zone(zone).unwrap();
869 }
870 resolver
871 }
872
873 const GMAIL_ZONE: &str = r#"
874$ORIGIN gmail.com.
875@ 86400 MX 5 gmail-smtp-in.l.google.com.
876@ 86400 MX 10 alt1.gmail-smtp-in.l.google.com.
877@ 86400 MX 20 alt2.gmail-smtp-in.l.google.com.
878@ 86400 MX 30 alt3.gmail-smtp-in.l.google.com.
879@ 86400 MX 40 alt4.gmail-smtp-in.l.google.com.
880"#;
881
882 const GMAIL_HOSTS_ZONE: &str = r#"
883$ORIGIN l.google.com.
884gmail-smtp-in 300 A 142.251.2.26
885alt1.gmail-smtp-in 300 A 108.177.104.27
886alt2.gmail-smtp-in 300 A 74.125.126.27
887alt3.gmail-smtp-in 300 A 172.253.113.26
888alt4.gmail-smtp-in 300 A 173.194.77.27
889"#;
890
891 #[tokio::test]
892 async fn lookup_gmail_mx() {
893 let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
894 let mut gmail = (*MailExchanger::resolve_via("gmail.com", Some(&resolver))
895 .await
896 .unwrap())
897 .clone();
898 gmail.expires.take();
899 k9::snapshot!(
900 &gmail,
901 r#"
902MailExchanger {
903 domain_name: "gmail.com.",
904 hosts: [
905 "gmail-smtp-in.l.google.com.",
906 "alt1.gmail-smtp-in.l.google.com.",
907 "alt2.gmail-smtp-in.l.google.com.",
908 "alt3.gmail-smtp-in.l.google.com.",
909 "alt4.gmail-smtp-in.l.google.com.",
910 ],
911 site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
912 by_pref: {
913 5: [
914 "gmail-smtp-in.l.google.com.",
915 ],
916 10: [
917 "alt1.gmail-smtp-in.l.google.com.",
918 ],
919 20: [
920 "alt2.gmail-smtp-in.l.google.com.",
921 ],
922 30: [
923 "alt3.gmail-smtp-in.l.google.com.",
924 ],
925 40: [
926 "alt4.gmail-smtp-in.l.google.com.",
927 ],
928 },
929 is_domain_literal: false,
930 is_secure: false,
931 is_mx: true,
932 mta_sts: None,
933 expires: None,
934}
935"#
936 );
937
938 k9::snapshot!(
942 gmail
943 .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only)
944 .await,
945 r#"
946Addresses(
947 [
948 ResolvedAddress {
949 name: "alt4.gmail-smtp-in.l.google.com.",
950 addr: 173.194.77.27,
951 is_secure: false,
952 },
953 ResolvedAddress {
954 name: "alt3.gmail-smtp-in.l.google.com.",
955 addr: 172.253.113.26,
956 is_secure: false,
957 },
958 ResolvedAddress {
959 name: "alt2.gmail-smtp-in.l.google.com.",
960 addr: 74.125.126.27,
961 is_secure: false,
962 },
963 ResolvedAddress {
964 name: "alt1.gmail-smtp-in.l.google.com.",
965 addr: 108.177.104.27,
966 is_secure: false,
967 },
968 ResolvedAddress {
969 name: "gmail-smtp-in.l.google.com.",
970 addr: 142.251.2.26,
971 is_secure: false,
972 },
973 ],
974)
975"#
976 );
977 }
978
979 #[tokio::test]
980 async fn lookup_punycode_no_mx_only_a() {
981 let resolver = fixture_resolver(&[r#"
982$ORIGIN xn--bb-eka.at.
983@ 300 A 192.0.2.5
984"#]);
985 let mx = MailExchanger::resolve_via("xn--bb-eka.at", Some(&resolver))
986 .await
987 .unwrap();
988 assert_eq!(mx.domain_name, "xn--bb-eka.at.");
989 assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
990 }
991
992 #[tokio::test]
993 async fn lookup_nxdomain() {
994 let resolver = fixture_resolver(&[]);
997 let name = fully_qualify("not-mairs.aasland.com").unwrap();
998 let err = match lookup_mx_record(&name, Some(&resolver)).await {
999 Ok(_) => panic!("expected NXDOMAIN"),
1000 Err(err) => err,
1001 };
1002 k9::assert_equal!(err.to_string(), "NXDOMAIN");
1003 }
1004
1005 #[tokio::test]
1006 async fn lookup_null_mx() {
1007 let resolver = fixture_resolver(&[r#"
1008$ORIGIN example.com.
1009@ 3600 MX 0 .
1010"#]);
1011 let mut mx = (*MailExchanger::resolve_via("example.com", Some(&resolver))
1012 .await
1013 .unwrap())
1014 .clone();
1015 mx.expires.take();
1016 k9::snapshot!(
1017 &mx,
1018 r#"
1019MailExchanger {
1020 domain_name: "example.com.",
1021 hosts: [
1022 ".",
1023 ],
1024 site_name: "",
1025 by_pref: {
1026 0: [
1027 ".",
1028 ],
1029 },
1030 is_domain_literal: false,
1031 is_secure: false,
1032 is_mx: true,
1033 mta_sts: None,
1034 expires: None,
1035}
1036"#
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn lookup_single_mx() {
1042 let resolver = fixture_resolver(&[r#"
1043$ORIGIN do.havedane.net.
1044@ 300 MX 10 do.havedane.net.
1045"#]);
1046 let mut mx = (*MailExchanger::resolve_via("do.havedane.net", Some(&resolver))
1047 .await
1048 .unwrap())
1049 .clone();
1050 mx.expires.take();
1051 k9::snapshot!(
1052 &mx,
1053 r#"
1054MailExchanger {
1055 domain_name: "do.havedane.net.",
1056 hosts: [
1057 "do.havedane.net.",
1058 ],
1059 site_name: "do.havedane.net",
1060 by_pref: {
1061 10: [
1062 "do.havedane.net.",
1063 ],
1064 },
1065 is_domain_literal: false,
1066 is_secure: false,
1067 is_mx: true,
1068 mta_sts: None,
1069 expires: None,
1070}
1071"#
1072 );
1073 }
1074
1075 #[tokio::test]
1076 async fn mx_lookup_no_mx_falls_back_to_a() {
1077 let resolver = fixture_resolver(&[r#"
1080$ORIGIN example.com.
1081www 300 A 192.0.2.1
1082"#]);
1083 let mut mx = (*MailExchanger::resolve_via("www.example.com", Some(&resolver))
1084 .await
1085 .unwrap())
1086 .clone();
1087 mx.expires.take();
1088 k9::snapshot!(
1089 &mx,
1090 r#"
1091MailExchanger {
1092 domain_name: "www.example.com.",
1093 hosts: [
1094 "www.example.com.",
1095 ],
1096 site_name: "www.example.com",
1097 by_pref: {
1098 1: [
1099 "www.example.com.",
1100 ],
1101 },
1102 is_domain_literal: false,
1103 is_secure: false,
1104 is_mx: false,
1105 mta_sts: None,
1106 expires: None,
1107}
1108"#
1109 );
1110 }
1111
1112 #[test]
1113 fn name_factoring() {
1114 assert_eq!(
1115 factor_names(&[
1116 "mta5.am0.yahoodns.net",
1117 "mta6.am0.yahoodns.net",
1118 "mta7.am0.yahoodns.net"
1119 ]),
1120 "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1121 );
1122
1123 assert_eq!(
1125 factor_names(&[
1126 "mta5.AM0.yahoodns.net",
1127 "mta6.am0.yAHOodns.net",
1128 "mta7.am0.yahoodns.net"
1129 ]),
1130 "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1131 );
1132
1133 assert_eq!(
1136 factor_names(&[
1137 "gmail-smtp-in.l.google.com",
1138 "alt1.gmail-smtp-in.l.google.com",
1139 "alt2.gmail-smtp-in.l.google.com",
1140 "alt3.gmail-smtp-in.l.google.com",
1141 "alt4.gmail-smtp-in.l.google.com",
1142 ]),
1143 "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com".to_string()
1144 );
1145
1146 assert_eq!(
1147 factor_names(&[
1148 "mta5.am0.yahoodns.net:123",
1149 "mta6.am0.yahoodns.net:123",
1150 "mta7.am0.yahoodns.net:123"
1151 ]),
1152 "(mta5|mta6|mta7).am0.yahoodns.net:123".to_string()
1153 );
1154 assert_eq!(
1155 factor_names(&[
1156 "mta5.am0.yahoodns.net:123",
1157 "mta6.am0.yahoodns.net:456",
1158 "mta7.am0.yahoodns.net:123"
1159 ]),
1160 "(mta5|mta6|mta7).am0.yahoodns.(net:123|net:456)".to_string()
1161 );
1162 }
1163
1164 #[test]
1168 fn mx_order_name_factor() {
1169 assert_eq!(
1170 factor_names(&[
1171 "example-com.mail.protection.outlook.com.",
1172 "mx-biz.mail.am0.yahoodns.net.",
1173 "mx-biz.mail.am0.yahoodns.net.",
1174 ]),
1175 "(example-com|mx-biz).mail.(protection|am0).(outlook|yahoodns).(com|net)".to_string()
1176 );
1177 assert_eq!(
1178 factor_names(&[
1179 "mx-biz.mail.am0.yahoodns.net.",
1180 "mx-biz.mail.am0.yahoodns.net.",
1181 "example-com.mail.protection.outlook.com.",
1182 ]),
1183 "(mx-biz|example-com).mail.(am0|protection).(yahoodns|outlook).(net|com)".to_string()
1184 );
1185 }
1186}