1use crate::site_name::factor_names;
2use anyhow::Context;
3use dns_resolver::{
4 get_resolver, has_colon_port, ip_lookup, resolve_dane, DaneStatus, DnsError,
5 DomainClassification, IpLookupStrategy, Name, Resolver,
6};
7use hickory_resolver::proto::op::ResponseCode;
8use hickory_resolver::proto::rr::{RData, RecordType};
9use kumo_address::host_or_socket::HostOrSocketAddress;
10use kumo_log_types::ResolvedAddress;
11use kumo_prometheus::declare_metric;
12use lruttl::declare_cache;
13use mta_sts::policy::MtaStsPolicy;
14pub use mta_sts::policy::PolicyMode;
15use rand::prelude::SliceRandom;
16use serde::Serialize;
17use std::collections::BTreeMap;
18use std::net::IpAddr;
19use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20use std::sync::{Arc, LazyLock};
21use std::time::{Duration, Instant};
22use tokio::sync::Semaphore;
23use tokio::time::timeout;
24
25mod site_name;
26
27static MTA_STS_ENABLED: AtomicBool = AtomicBool::new(true);
31
32pub fn set_mta_sts_enabled(enabled: bool) {
33 MTA_STS_ENABLED.store(enabled, Ordering::Relaxed);
34}
35
36pub fn is_mta_sts_enabled() -> bool {
37 MTA_STS_ENABLED.load(Ordering::Relaxed)
38}
39
40static DANE_ENABLED: AtomicBool = AtomicBool::new(false);
45
46pub fn set_dane_enabled(enabled: bool) {
50 DANE_ENABLED.store(enabled, Ordering::Relaxed);
51}
52
53pub fn is_dane_enabled() -> bool {
54 DANE_ENABLED.load(Ordering::Relaxed)
55}
56
57const MTA_STS_FETCH_RETRY: Duration = Duration::from_secs(300);
61
62static MX_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(128);
64static MX_CONCURRENCY_SEMA: LazyLock<Semaphore> =
65 LazyLock::new(|| Semaphore::new(MX_MAX_CONCURRENCY.load(Ordering::SeqCst)));
66
67static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
69
70static MX_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(300 * 1000);
72
73static MX_TRANSIENT_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(30 * 1000);
75
76pub fn set_mx_concurrency_limit(n: usize) {
77 MX_MAX_CONCURRENCY.store(n, Ordering::SeqCst);
78}
79
80pub fn set_mx_timeout(duration: Duration) -> anyhow::Result<()> {
81 let ms = duration
82 .as_millis()
83 .try_into()
84 .context("set_mx_timeout: duration is too large")?;
85 MX_TIMEOUT_MS.store(ms, Ordering::Relaxed);
86 Ok(())
87}
88
89pub fn get_mx_timeout() -> Duration {
90 Duration::from_millis(MX_TIMEOUT_MS.load(Ordering::Relaxed) as u64)
91}
92
93pub fn set_mx_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
94 let ms = duration
95 .as_millis()
96 .try_into()
97 .context("set_mx_negative_cache_ttl: duration is too large")?;
98 MX_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
99 Ok(())
100}
101
102pub fn get_mx_negative_ttl() -> Duration {
103 Duration::from_millis(MX_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
104}
105
106pub fn set_mx_transient_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
107 let ms = duration
108 .as_millis()
109 .try_into()
110 .context("set_mx_transient_negative_cache_ttl: duration is too large")?;
111 MX_TRANSIENT_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
112 Ok(())
113}
114
115pub fn get_mx_transient_negative_ttl() -> Duration {
116 Duration::from_millis(MX_TRANSIENT_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
117}
118
119fn mx_cache_ttl(mx_result: &Result<Arc<MailExchanger>, MxResolveError>) -> Duration {
121 match mx_result {
122 Ok(mx) => match mx.expires {
123 Some(exp) => exp
124 .checked_duration_since(std::time::Instant::now())
125 .unwrap_or_else(|| Duration::from_secs(10)),
126 None => get_mx_negative_ttl(),
127 },
128 Err(err) => err.kind.negative_ttl(),
129 }
130}
131
132#[derive(Debug)]
133struct ByPreference {
134 pub hosts: Vec<String>,
135 pub pref: u16,
136 pub is_secure: bool,
137 pub is_mx: bool,
138}
139
140async fn lookup_mx_record(
141 domain_name: &Name,
142 resolver: Option<&dyn Resolver>,
143) -> Result<(Vec<ByPreference>, Instant), MxResolveError> {
144 lookup_mx_record_limited(
145 domain_name,
146 resolver,
147 &MX_CONCURRENCY_SEMA,
148 get_mx_timeout(),
149 )
150 .await
151}
152
153struct PermitWaitGuard;
157
158impl PermitWaitGuard {
159 fn enter() -> Self {
160 MX_PERMIT_WAITERS.inc();
161 Self
162 }
163}
164
165impl Drop for PermitWaitGuard {
166 fn drop(&mut self) {
167 MX_PERMIT_WAITERS.dec();
168 }
169}
170
171async fn lookup_mx_record_limited(
175 domain_name: &Name,
176 resolver: Option<&dyn Resolver>,
177 concurrency: &Semaphore,
178 timeout_duration: Duration,
179) -> Result<(Vec<ByPreference>, Instant), MxResolveError> {
180 let permit_acquired = AtomicBool::new(false);
183 let mx_lookup = match timeout(timeout_duration, async {
184 let wait_guard = PermitWaitGuard::enter();
185 let _permit = concurrency.acquire().await;
186 drop(wait_guard);
190 permit_acquired.store(true, Ordering::Relaxed);
194 match resolver {
195 Some(r) => r.resolve(domain_name.clone(), RecordType::MX).await,
196 None => {
197 get_resolver()
198 .resolve(domain_name.clone(), RecordType::MX)
199 .await
200 }
201 }
202 })
203 .await
204 {
205 Ok(Ok(answer)) => answer,
206 Ok(Err(dns_err)) => {
207 return Err(MxResolveError {
208 kind: classify_dns_error(&dns_err),
209 message: dns_err.to_string(),
210 });
211 }
212 Err(_elapsed) => {
213 let (kind, message) = if permit_acquired.load(Ordering::Relaxed) {
214 MX_QUERY_TIMEOUT.inc();
215 (
216 MxResolveFailure::Indeterminate,
217 format!("MX query timed out after {timeout_duration:?}"),
218 )
219 } else {
220 MX_PERMIT_TIMEOUT.inc();
221 (
222 MxResolveFailure::NeverQueried,
223 format!(
224 "timed out after {timeout_duration:?} waiting for an MX concurrency \
225 permit; no query was sent"
226 ),
227 )
228 };
229 return Err(MxResolveError { kind, message });
230 }
231 };
232 if mx_lookup.bogus {
240 return Err(MxResolveError {
241 kind: MxResolveFailure::Indeterminate,
242 message: format!(
243 "MX records for {domain_name} are bogus: {}",
244 mx_lookup
245 .why_bogus
246 .as_deref()
247 .unwrap_or("DNSSEC validation failed")
248 ),
249 });
250 }
251 match mx_lookup.response_code {
252 ResponseCode::NoError | ResponseCode::NXDomain => {}
253 rcode => {
254 return Err(MxResolveError {
255 kind: MxResolveFailure::Indeterminate,
256 message: format!("MX lookup for {domain_name} returned {rcode}"),
257 });
258 }
259 }
260
261 let mx_records = mx_lookup.records;
262
263 if mx_records.is_empty() {
264 if mx_lookup.nxdomain {
265 return Err(MxResolveError {
266 kind: MxResolveFailure::NxDomain,
267 message: "NXDOMAIN".to_string(),
268 });
269 }
270
271 return Ok((
277 vec![ByPreference {
278 hosts: vec![domain_name.to_lowercase().to_ascii()],
279 pref: 1,
280 is_secure: mx_lookup.secure,
281 is_mx: false,
282 }],
283 mx_lookup.expires,
284 ));
285 }
286
287 let mut records: Vec<ByPreference> = Vec::with_capacity(mx_records.len());
288
289 for mx_record in mx_records {
290 if let RData::MX(mx) = mx_record {
291 let pref = mx.preference;
292 let host = mx.exchange.to_lowercase().to_string();
293
294 if let Some(record) = records.iter_mut().find(|r| r.pref == pref) {
295 record.hosts.push(host);
296 } else {
297 records.push(ByPreference {
298 hosts: vec![host],
299 pref,
300 is_secure: mx_lookup.secure,
301 is_mx: true,
302 });
303 }
304 }
305 }
306
307 records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
309
310 for mx in &mut records {
313 mx.hosts.sort();
314 }
315
316 Ok((records, mx_lookup.expires))
317}
318
319#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub enum MxResolveFailure {
325 NxDomain,
331 Indeterminate,
336 NeverQueried,
340 PolicyRejected,
344}
345
346impl MxResolveFailure {
347 fn negative_ttl(self) -> Duration {
354 match self {
355 MxResolveFailure::NxDomain | MxResolveFailure::PolicyRejected => get_mx_negative_ttl(),
356 MxResolveFailure::Indeterminate => get_mx_transient_negative_ttl(),
357 MxResolveFailure::NeverQueried => Duration::ZERO,
358 }
359 }
360}
361
362#[derive(Clone, Debug)]
369pub struct MxResolveError {
370 pub kind: MxResolveFailure,
371 pub message: String,
372}
373
374impl MxResolveError {
375 pub fn is_nxdomain(&self) -> bool {
378 self.kind == MxResolveFailure::NxDomain
379 }
380}
381
382impl std::fmt::Display for MxResolveError {
383 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
384 f.write_str(&self.message)
385 }
386}
387
388impl std::error::Error for MxResolveError {}
389
390fn classify_dns_error(err: &DnsError) -> MxResolveFailure {
394 match err {
395 DnsError::ResolveFailed(_) => MxResolveFailure::Indeterminate,
398 DnsError::InvalidName(_) => MxResolveFailure::Indeterminate,
404 }
405}
406
407#[derive(Clone, Debug, Serialize)]
408pub struct MailExchanger {
409 pub domain_name: String,
410 pub hosts: Vec<String>,
411 pub site_name: String,
412 pub by_pref: BTreeMap<u16, Vec<String>>,
413 pub is_domain_literal: bool,
414 pub is_secure: bool,
416 pub is_mx: bool,
417 pub mta_sts: PolicyMode,
421 #[serde(skip)]
422 expires: Option<Instant>,
423}
424
425declare_cache! {
426static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, MxResolveError>>::new("dns_resolver_mx", 64 * 1024);
428}
429
430declare_metric! {
431static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
433}
434
435declare_metric! {
436static MX_SUCCESS: IntCounter(
438 "dns_mx_resolve_status_ok");
439}
440
441declare_metric! {
442static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
448}
449
450declare_metric! {
451static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
456}
457
458declare_metric! {
459static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
464}
465
466declare_metric! {
467static MX_PERMIT_TIMEOUT: IntCounter("dns_mx_resolve_permit_timeout");
471}
472
473declare_metric! {
474static MX_QUERY_TIMEOUT: IntCounter("dns_mx_resolve_query_timeout");
477}
478
479declare_metric! {
480static MX_PERMIT_WAITERS: IntGauge("dns_mx_resolve_permit_waiters");
483}
484
485declare_metric! {
486static MX_MTA_STS_IMPOSSIBLE: IntCounter("dns_mx_resolve_mta_sts_impossible");
490}
491
492#[derive(Debug, PartialEq, Eq)]
494enum StsEval {
495 Status(PolicyMode),
497 Prune(Vec<bool>),
500 Impossible,
502}
503
504fn evaluate_mta_sts(hosts: &[String], policy: &MtaStsPolicy) -> StsEval {
507 match policy.mode {
508 PolicyMode::None => StsEval::Status(PolicyMode::None),
509 PolicyMode::Testing => StsEval::Status(PolicyMode::Testing),
510 PolicyMode::Enforce => {
511 let matched: Vec<bool> = hosts
512 .iter()
513 .map(|h| {
514 let label = match has_colon_port(h) {
515 Some((label, _)) => label,
516 None => h.as_str(),
517 };
518 policy.mx_name_matches(label)
519 })
520 .collect();
521 let match_count = matched.iter().filter(|m| **m).count();
522 if match_count == 0 {
523 StsEval::Impossible
524 } else if match_count == hosts.len() {
525 StsEval::Status(PolicyMode::Enforce)
526 } else {
527 StsEval::Prune(matched)
528 }
529 }
530 }
531}
532
533async fn apply_mta_sts(
538 name_fq: &Name,
539 by_pref: &mut Vec<ByPreference>,
540 hosts: &mut Vec<String>,
541 expires: &mut Instant,
542 resolver: Option<&dyn Resolver>,
543) -> Result<PolicyMode, String> {
544 let policy_domain = name_fq.to_ascii();
545 let policy_domain = policy_domain.trim_end_matches('.');
546
547 let policy = match mta_sts::get_policy_for_domain(policy_domain, resolver).await {
548 Ok(policy) => policy,
549 Err(err) => {
550 tracing::debug!("MTA-STS policy fetch for {policy_domain} failed: {err:#}");
553 *expires = (*expires).min(Instant::now() + MTA_STS_FETCH_RETRY);
554 return Ok(PolicyMode::None);
555 }
556 };
557
558 let mta_sts = match evaluate_mta_sts(hosts, &policy) {
559 StsEval::Status(status) => status,
560 StsEval::Prune(matched) => {
561 let mut idx = 0;
565 for pref in by_pref.iter_mut() {
566 pref.hosts.retain(|_| {
567 let keep = matched[idx];
568 idx += 1;
569 keep
570 });
571 }
572 by_pref.retain(|p| !p.hosts.is_empty());
573 *hosts = by_pref
574 .iter()
575 .flat_map(|p| p.hosts.iter().cloned())
576 .collect();
577 PolicyMode::Enforce
578 }
579 StsEval::Impossible => {
580 MX_MTA_STS_IMPOSSIBLE.inc();
581 return Err(format!(
582 "MTA-STS enforce policy for {policy_domain} permits none of its \
583 MX hosts {hosts:?}; allowed mx patterns: {patterns:?}. The \
584 destination is undeliverable until its MTA-STS policy is \
585 corrected.",
586 patterns = policy.mx
587 ));
588 }
589 };
590
591 let policy_expires = Instant::now()
595 .checked_add(Duration::from_secs(policy.max_age))
596 .unwrap_or_else(Instant::now);
597 *expires = (*expires).min(policy_expires);
598 Ok(mta_sts)
599}
600
601async fn host_set_publishes_dane(hosts: &[String]) -> bool {
606 for host in hosts {
607 let bare = match has_colon_port(host) {
608 Some((label, _)) => label,
609 None => host.as_str(),
610 };
611 if matches!(
612 resolve_dane(bare, 25).await,
613 Ok(DaneStatus::Records(_) | DaneStatus::Unusable)
614 ) {
615 return true;
616 }
617 }
618 false
619}
620
621impl MailExchanger {
622 pub async fn resolve(domain_name: &str) -> anyhow::Result<Arc<Self>> {
623 Self::resolve_via(domain_name, None).await
624 }
625
626 pub async fn resolve_via(
631 domain_name: &str,
632 resolver: Option<&dyn Resolver>,
633 ) -> anyhow::Result<Arc<Self>> {
634 MX_IN_PROGRESS.inc();
635 let result = Self::resolve_impl(domain_name, resolver).await;
636 MX_IN_PROGRESS.dec();
637 if result.is_ok() {
638 MX_SUCCESS.inc();
639 } else {
640 MX_FAIL.inc();
641 }
642 result
643 }
644
645 async fn resolve_impl(
646 domain_name: &str,
647 resolver: Option<&dyn Resolver>,
648 ) -> anyhow::Result<Arc<Self>> {
649 let (name_fq, opt_port) = match DomainClassification::classify(domain_name)? {
650 DomainClassification::Literal(addr) => {
651 let mut by_pref = BTreeMap::new();
652 by_pref.insert(1, vec![addr.to_string()]);
653 return Ok(Arc::new(Self {
654 domain_name: domain_name.to_string(),
655 hosts: vec![addr.to_string()],
656 site_name: addr.to_string(),
657 by_pref,
658 is_domain_literal: true,
659 is_secure: false,
660 is_mx: false,
661 mta_sts: PolicyMode::None,
662 expires: None,
663 }));
664 }
665 DomainClassification::Domain(name_fq, opt_port) => (name_fq, opt_port),
666 };
667
668 if resolver.is_some() {
671 return Self::resolve_uncached(&name_fq, opt_port, domain_name, resolver)
672 .await?
673 .map_err(anyhow::Error::new);
674 }
675
676 let lookup_result = MX_CACHE
677 .get_or_try_insert(
678 &(name_fq.clone(), opt_port),
679 mx_cache_ttl,
680 Self::resolve_uncached(&name_fq, opt_port, domain_name, None),
681 )
682 .await
683 .map_err(|err| anyhow::anyhow!("{err}"))?;
684
685 if !lookup_result.is_fresh {
686 MX_CACHED.inc();
687 }
688
689 lookup_result.item.map_err(anyhow::Error::new)
690 }
691
692 async fn resolve_uncached(
693 name_fq: &Name,
694 opt_port: Option<u16>,
695 domain_name: &str,
696 resolver: Option<&dyn Resolver>,
697 ) -> anyhow::Result<Result<Arc<MailExchanger>, MxResolveError>> {
698 MX_QUERIES.inc();
699 let start = Instant::now();
700 let (mut by_pref, mut expires) = match lookup_mx_record(name_fq, resolver).await {
701 Ok((by_pref, expires)) => (by_pref, expires),
702 Err(err) => {
703 let error = format!(
704 "MX lookup for {domain_name} failed after {elapsed:?}: {err}",
705 elapsed = start.elapsed()
706 );
707 tracing::debug!(
708 target: "mx_resolve",
709 domain = domain_name,
710 %error,
711 "MX lookup failed; domain drops out of any site_name rollup"
712 );
713 return Ok(Err(MxResolveError {
714 kind: err.kind,
715 message: error,
716 }));
717 }
718 };
719
720 let mut hosts = vec![];
721 for pref in &mut by_pref {
722 for host in &mut pref.hosts {
723 if let Some(port) = opt_port {
724 *host = format!("{host}:{port}");
725 };
726 hosts.push(host.to_string());
727 }
728 }
729
730 let is_secure = by_pref.iter().all(|p| p.is_secure);
731 let is_mx = by_pref.iter().all(|p| p.is_mx);
732
733 let mta_sts = if is_mx && is_mta_sts_enabled() {
738 match apply_mta_sts(name_fq, &mut by_pref, &mut hosts, &mut expires, resolver).await {
739 Ok(status) => status,
740 Err(error) => {
741 tracing::debug!(
742 target: "mx_resolve",
743 domain = domain_name,
744 %error,
745 "MTA-STS evaluation failed; domain drops out of any site_name rollup"
746 );
747 return Ok(Err(MxResolveError {
748 kind: MxResolveFailure::PolicyRejected,
749 message: error,
750 }));
751 }
752 }
753 } else {
754 PolicyMode::None
755 };
756
757 let by_pref = by_pref
758 .into_iter()
759 .map(|pref| (pref.pref, pref.hosts))
760 .collect();
761
762 let dane_insecure = is_mx
767 && !is_secure
768 && opt_port.is_none()
773 && is_dane_enabled()
774 && host_set_publishes_dane(&hosts).await;
775
776 let factored = factor_names(&hosts);
777 let site_name = if dane_insecure {
778 format!("[dane-insecure]{factored}")
779 } else {
780 factored
781 };
782 tracing::debug!(
783 target: "mx_resolve",
784 domain = domain_name,
785 %site_name,
786 ?hosts,
787 is_mx,
788 is_secure,
789 elapsed_ms = start.elapsed().as_millis() as u64,
790 "resolved MX to site_name"
791 );
792 let mx = Self {
793 hosts,
794 domain_name: name_fq.to_ascii(),
795 site_name,
796 by_pref,
797 is_domain_literal: false,
798 is_secure,
799 is_mx,
800 mta_sts,
801 expires: Some(expires),
802 };
803
804 Ok(Ok(Arc::new(mx)))
805 }
806
807 pub fn has_expired(&self) -> bool {
808 match self.expires {
809 Some(deadline) => deadline <= Instant::now(),
810 None => false,
811 }
812 }
813
814 pub async fn resolve_addresses(
823 &self,
824 resolver: Option<&dyn Resolver>,
825 strategy: IpLookupStrategy,
826 max_plan_size: usize,
827 max_addresses_per_host: usize,
828 ) -> ResolvedMxAddresses {
829 let mut result = vec![];
830
831 'by_pref: for hosts in self.by_pref.values() {
834 let mut by_pref = vec![];
835
836 for mx_host in hosts {
837 if mx_host == "." {
839 return ResolvedMxAddresses::NullMx;
840 }
841
842 let (mx_host, opt_port) = match has_colon_port(mx_host) {
844 Some((domain_name, port)) => (domain_name, Some(port)),
845 None => (mx_host.as_str(), None),
846 };
847 if let Ok(addr) = mx_host.parse::<IpAddr>() {
848 let mut addr: HostOrSocketAddress = addr.into();
849 if let Some(port) = opt_port {
850 addr.set_port(port);
851 }
852 by_pref.push(ResolvedAddress {
853 name: mx_host.to_string(),
854 addr,
855 is_secure: false,
856 });
857 continue;
858 }
859
860 match ip_lookup(mx_host, resolver, strategy).await {
861 Err(err) => {
862 tracing::error!("failed to resolve {mx_host}: {err:#}");
863 continue;
864 }
865 Ok((lookup, _expires)) => {
866 for addr in lookup.addrs.iter().take(max_addresses_per_host) {
867 let mut addr: HostOrSocketAddress = (*addr).into();
868 if let Some(port) = opt_port {
869 addr.set_port(port);
870 }
871 by_pref.push(ResolvedAddress {
872 name: mx_host.to_string(),
873 addr,
874 is_secure: lookup.secure,
875 });
876 }
877 }
878 }
879 }
880
881 {
885 let mut rng = rand::thread_rng();
886 by_pref.shuffle(&mut rng);
887 }
888
889 for addr in by_pref {
890 if result.len() == max_plan_size {
891 break 'by_pref;
894 }
895 result.push(addr);
896 }
897 }
898
899 result.reverse();
902 ResolvedMxAddresses::Addresses(result)
903 }
904}
905
906#[derive(Debug, Clone, Serialize)]
907pub enum ResolvedMxAddresses {
908 NullMx,
909 Addresses(Vec<ResolvedAddress>),
912}
913
914#[cfg(test)]
915mod test {
916 use super::*;
917 use dns_resolver::{fully_qualify, TestResolver};
918
919 fn policy(mode: &str, mx: &[&str]) -> MtaStsPolicy {
920 let mut text = format!("version: STSv1\nmode: {mode}\nmax_age: 86400");
921 for m in mx {
922 text.push_str(&format!("\nmx: {m}"));
923 }
924 MtaStsPolicy::parse(&text).unwrap()
925 }
926
927 fn hosts(list: &[&str]) -> Vec<String> {
928 list.iter().map(|s| s.to_string()).collect()
929 }
930
931 #[test]
932 fn mta_sts_none_and_testing() {
933 assert_eq!(
934 evaluate_mta_sts(&hosts(&["mx01.mail.icloud.com."]), &policy("none", &[])),
935 StsEval::Status(PolicyMode::None)
936 );
937 assert_eq!(
938 evaluate_mta_sts(
939 &hosts(&["mx01.mail.icloud.com."]),
940 &policy("testing", &["*.mx.cloudflare.net"])
941 ),
942 StsEval::Status(PolicyMode::Testing)
943 );
944 }
945
946 #[test]
947 fn mta_sts_enforce_full_match() {
948 assert_eq!(
949 evaluate_mta_sts(
950 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
951 &policy("enforce", &["*.mail.icloud.com"])
952 ),
953 StsEval::Status(PolicyMode::Enforce)
954 );
955 }
956
957 #[test]
958 fn mta_sts_enforce_partial_prunes() {
959 assert_eq!(
961 evaluate_mta_sts(
962 &hosts(&["mx01.mail.icloud.com.", "backup.example.net."]),
963 &policy("enforce", &["*.mail.icloud.com"])
964 ),
965 StsEval::Prune(vec![true, false])
966 );
967 }
968
969 #[test]
970 fn mta_sts_enforce_impossible() {
971 assert_eq!(
974 evaluate_mta_sts(
975 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
976 &policy("enforce", &["*.mx.cloudflare.net"])
977 ),
978 StsEval::Impossible
979 );
980 }
981
982 #[test]
983 fn mta_sts_enforce_strips_port() {
984 assert_eq!(
985 evaluate_mta_sts(
986 &hosts(&["mx01.mail.icloud.com.:587"]),
987 &policy("enforce", &["*.mail.icloud.com"])
988 ),
989 StsEval::Status(PolicyMode::Enforce)
990 );
991 }
992
993 #[tokio::test]
994 async fn site_identity_does_not_reorder_connection_preferences() {
995 let resolver = TestResolver::default()
996 .with_zone("$ORIGIN priority-a.example.\n@ 600 MX 10 mx1.targets.test.\n@ 600 MX 20 mx2.targets.test.\n")
997 .unwrap()
998 .with_zone("$ORIGIN priority-b.example.\n@ 600 MX 10 mx2.targets.test.\n@ 600 MX 20 mx1.targets.test.\n")
999 .unwrap();
1000 let a = MailExchanger::resolve_via("priority-a.example", Some(&resolver))
1001 .await
1002 .unwrap();
1003 let b = MailExchanger::resolve_via("priority-b.example", Some(&resolver))
1004 .await
1005 .unwrap();
1006 assert_eq!(a.site_name, b.site_name);
1007 assert_eq!(a.hosts, hosts(&["mx1.targets.test.", "mx2.targets.test."]));
1008 assert_eq!(b.hosts, hosts(&["mx2.targets.test.", "mx1.targets.test."]));
1009 assert_eq!(a.by_pref[&10], hosts(&["mx1.targets.test."]));
1010 assert_eq!(b.by_pref[&10], hosts(&["mx2.targets.test."]));
1011 }
1012
1013 #[tokio::test]
1014 async fn literal_resolve() {
1015 let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
1016 k9::snapshot!(
1017 &v4_loopback,
1018 r#"
1019MailExchanger {
1020 domain_name: "[127.0.0.1]",
1021 hosts: [
1022 "127.0.0.1",
1023 ],
1024 site_name: "127.0.0.1",
1025 by_pref: {
1026 1: [
1027 "127.0.0.1",
1028 ],
1029 },
1030 is_domain_literal: true,
1031 is_secure: false,
1032 is_mx: false,
1033 mta_sts: None,
1034 expires: None,
1035}
1036"#
1037 );
1038 k9::snapshot!(
1039 v4_loopback
1040 .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1041 .await,
1042 r#"
1043Addresses(
1044 [
1045 ResolvedAddress {
1046 name: "127.0.0.1",
1047 addr: 127.0.0.1,
1048 is_secure: false,
1049 },
1050 ],
1051)
1052"#
1053 );
1054
1055 let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
1056 k9::snapshot!(
1057 &v6_loopback_non_conforming,
1058 r#"
1059MailExchanger {
1060 domain_name: "[::1]",
1061 hosts: [
1062 "::1",
1063 ],
1064 site_name: "::1",
1065 by_pref: {
1066 1: [
1067 "::1",
1068 ],
1069 },
1070 is_domain_literal: true,
1071 is_secure: false,
1072 is_mx: false,
1073 mta_sts: None,
1074 expires: None,
1075}
1076"#
1077 );
1078 k9::snapshot!(
1079 v6_loopback_non_conforming
1080 .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1081 .await,
1082 r#"
1083Addresses(
1084 [
1085 ResolvedAddress {
1086 name: "::1",
1087 addr: ::1,
1088 is_secure: false,
1089 },
1090 ],
1091)
1092"#
1093 );
1094
1095 let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
1096 k9::snapshot!(
1097 &v6_loopback,
1098 r#"
1099MailExchanger {
1100 domain_name: "[IPv6:::1]",
1101 hosts: [
1102 "::1",
1103 ],
1104 site_name: "::1",
1105 by_pref: {
1106 1: [
1107 "::1",
1108 ],
1109 },
1110 is_domain_literal: true,
1111 is_secure: false,
1112 is_mx: false,
1113 mta_sts: None,
1114 expires: None,
1115}
1116"#
1117 );
1118 k9::snapshot!(
1119 v6_loopback
1120 .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1121 .await,
1122 r#"
1123Addresses(
1124 [
1125 ResolvedAddress {
1126 name: "::1",
1127 addr: ::1,
1128 is_secure: false,
1129 },
1130 ],
1131)
1132"#
1133 );
1134 }
1135
1136 fn fixture_resolver(zones: &[&str]) -> TestResolver {
1137 let mut resolver = TestResolver::default();
1138 for zone in zones {
1139 resolver = resolver.with_zone(zone).unwrap();
1140 }
1141 resolver
1142 }
1143
1144 const GMAIL_ZONE: &str = r#"
1145$ORIGIN gmail.com.
1146@ 86400 MX 5 gmail-smtp-in.l.google.com.
1147@ 86400 MX 10 alt1.gmail-smtp-in.l.google.com.
1148@ 86400 MX 20 alt2.gmail-smtp-in.l.google.com.
1149@ 86400 MX 30 alt3.gmail-smtp-in.l.google.com.
1150@ 86400 MX 40 alt4.gmail-smtp-in.l.google.com.
1151"#;
1152
1153 const GMAIL_HOSTS_ZONE: &str = r#"
1154$ORIGIN l.google.com.
1155gmail-smtp-in 300 A 142.251.2.26
1156alt1.gmail-smtp-in 300 A 108.177.104.27
1157alt2.gmail-smtp-in 300 A 74.125.126.27
1158alt3.gmail-smtp-in 300 A 172.253.113.26
1159alt4.gmail-smtp-in 300 A 173.194.77.27
1160"#;
1161
1162 #[tokio::test]
1163 async fn lookup_gmail_mx() {
1164 let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
1165 let mut gmail = (*MailExchanger::resolve_via("gmail.com", Some(&resolver))
1166 .await
1167 .unwrap())
1168 .clone();
1169 gmail.expires.take();
1170 k9::snapshot!(
1171 &gmail,
1172 r#"
1173MailExchanger {
1174 domain_name: "gmail.com.",
1175 hosts: [
1176 "gmail-smtp-in.l.google.com.",
1177 "alt1.gmail-smtp-in.l.google.com.",
1178 "alt2.gmail-smtp-in.l.google.com.",
1179 "alt3.gmail-smtp-in.l.google.com.",
1180 "alt4.gmail-smtp-in.l.google.com.",
1181 ],
1182 site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
1183 by_pref: {
1184 5: [
1185 "gmail-smtp-in.l.google.com.",
1186 ],
1187 10: [
1188 "alt1.gmail-smtp-in.l.google.com.",
1189 ],
1190 20: [
1191 "alt2.gmail-smtp-in.l.google.com.",
1192 ],
1193 30: [
1194 "alt3.gmail-smtp-in.l.google.com.",
1195 ],
1196 40: [
1197 "alt4.gmail-smtp-in.l.google.com.",
1198 ],
1199 },
1200 is_domain_literal: false,
1201 is_secure: false,
1202 is_mx: true,
1203 mta_sts: None,
1204 expires: None,
1205}
1206"#
1207 );
1208
1209 k9::snapshot!(
1213 gmail
1214 .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only, 50, 10)
1215 .await,
1216 r#"
1217Addresses(
1218 [
1219 ResolvedAddress {
1220 name: "alt4.gmail-smtp-in.l.google.com.",
1221 addr: 173.194.77.27,
1222 is_secure: false,
1223 },
1224 ResolvedAddress {
1225 name: "alt3.gmail-smtp-in.l.google.com.",
1226 addr: 172.253.113.26,
1227 is_secure: false,
1228 },
1229 ResolvedAddress {
1230 name: "alt2.gmail-smtp-in.l.google.com.",
1231 addr: 74.125.126.27,
1232 is_secure: false,
1233 },
1234 ResolvedAddress {
1235 name: "alt1.gmail-smtp-in.l.google.com.",
1236 addr: 108.177.104.27,
1237 is_secure: false,
1238 },
1239 ResolvedAddress {
1240 name: "gmail-smtp-in.l.google.com.",
1241 addr: 142.251.2.26,
1242 is_secure: false,
1243 },
1244 ],
1245)
1246"#
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn resolve_addresses_caps_plan() {
1252 let hosts = 32;
1257 let addrs_per_host = 32;
1258 let mut zone = String::from("$ORIGIN example.com.\n");
1259 for h in 0..hosts {
1260 zone.push_str(&format!("@ 86400 MX 10 host{h}.example.com.\n"));
1261 }
1262 zone.push_str("@ 86400 MX 20 backup.example.com.\n");
1263 for h in 0..hosts {
1264 for i in 1..=addrs_per_host {
1265 zone.push_str(&format!("host{h} 300 A 10.0.{h}.{i}\n"));
1266 }
1267 }
1268 for i in 1..=addrs_per_host {
1269 zone.push_str(&format!("backup 300 A 10.1.0.{i}\n"));
1270 }
1271 let resolver = fixture_resolver(&[&zone]);
1272 let mx = MailExchanger::resolve_via("example.com", Some(&resolver))
1273 .await
1274 .unwrap();
1275
1276 let uncapped = match mx
1279 .resolve_addresses(
1280 Some(&resolver),
1281 IpLookupStrategy::Ipv4Only,
1282 usize::MAX,
1283 usize::MAX,
1284 )
1285 .await
1286 {
1287 ResolvedMxAddresses::Addresses(a) => a,
1288 other => panic!("expected addresses, got {other:?}"),
1289 };
1290 k9::assert_equal!(uncapped.len(), (hosts + 1) * addrs_per_host);
1291 k9::assert_equal!(
1292 uncapped
1293 .iter()
1294 .filter(|a| a.name == "backup.example.com.")
1295 .count(),
1296 addrs_per_host
1297 );
1298
1299 let capped = match mx
1304 .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only, 50, 10)
1305 .await
1306 {
1307 ResolvedMxAddresses::Addresses(a) => a,
1308 other => panic!("expected addresses, got {other:?}"),
1309 };
1310 k9::assert_equal!(capped.len(), 50);
1311 k9::assert_equal!(
1312 capped
1313 .iter()
1314 .filter(|a| a.name == "backup.example.com.")
1315 .count(),
1316 0
1317 );
1318
1319 let mut per_host = std::collections::HashMap::new();
1320 for addr in &capped {
1321 *per_host.entry(addr.name.clone()).or_insert(0usize) += 1;
1322 }
1323 let max_from_one_host = per_host.values().copied().max().unwrap();
1324 assert!(
1325 max_from_one_host <= 10,
1326 "a single host contributed {max_from_one_host} addresses, exceeding the per-host cap"
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn lookup_punycode_no_mx_only_a() {
1332 let resolver = fixture_resolver(&[r#"
1333$ORIGIN xn--bb-eka.at.
1334@ 300 A 192.0.2.5
1335"#]);
1336 let mx = MailExchanger::resolve_via("xn--bb-eka.at", Some(&resolver))
1337 .await
1338 .unwrap();
1339 assert_eq!(mx.domain_name, "xn--bb-eka.at.");
1340 assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
1341 }
1342
1343 #[tokio::test]
1344 async fn lookup_nxdomain() {
1345 let resolver = fixture_resolver(&[]);
1348 let name = fully_qualify("not-mairs.aasland.com").unwrap();
1349 let err = match lookup_mx_record(&name, Some(&resolver)).await {
1350 Ok(_) => panic!("expected NXDOMAIN"),
1351 Err(err) => err,
1352 };
1353 k9::assert_equal!(err.to_string(), "NXDOMAIN");
1354 k9::assert_equal!(err.kind, MxResolveFailure::NxDomain);
1355 assert!(err.is_nxdomain());
1356 }
1357
1358 #[tokio::test]
1359 async fn nxdomain_is_recoverable_through_anyhow() {
1360 let resolver = fixture_resolver(&[]);
1363 let err = MailExchanger::resolve_via("not-mairs.aasland.com", Some(&resolver))
1364 .await
1365 .unwrap_err();
1366 let mx_err = err
1367 .downcast_ref::<MxResolveError>()
1368 .expect("the typed MX error survives the anyhow conversion");
1369 assert!(mx_err.is_nxdomain());
1370 k9::assert_equal!(mx_err.kind, MxResolveFailure::NxDomain);
1371 }
1372
1373 #[test]
1374 fn classify_resolver_error_is_indeterminate() {
1375 k9::assert_equal!(
1378 classify_dns_error(&DnsError::ResolveFailed("SERVFAIL".to_string())),
1379 MxResolveFailure::Indeterminate
1380 );
1381 k9::assert_equal!(
1386 classify_dns_error(&DnsError::InvalidName("bad".to_string())),
1387 MxResolveFailure::Indeterminate
1388 );
1389 }
1390
1391 struct DelayResolver {
1394 delay: Duration,
1395 }
1396
1397 #[async_trait::async_trait]
1398 impl Resolver for DelayResolver {
1399 async fn resolve_ip(&self, _host: &str) -> Result<Vec<IpAddr>, DnsError> {
1400 unreachable!()
1401 }
1402 async fn resolve_mx(&self, _host: &str) -> Result<Vec<Name>, DnsError> {
1403 unreachable!()
1404 }
1405 async fn resolve_ptr(&self, _ip: IpAddr) -> Result<Vec<Name>, DnsError> {
1406 unreachable!()
1407 }
1408 async fn resolve(
1409 &self,
1410 _name: Name,
1411 _rrtype: RecordType,
1412 ) -> Result<dns_resolver::Answer, DnsError> {
1413 tokio::time::sleep(self.delay).await;
1414 Err(DnsError::ResolveFailed("delay elapsed".to_string()))
1415 }
1416 }
1417
1418 #[tokio::test]
1419 async fn deadline_before_permit_is_never_queried() {
1420 let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
1424 let name = fully_qualify("gmail.com").unwrap();
1425 let no_permits = Semaphore::new(0);
1426 let before = MX_PERMIT_TIMEOUT.get();
1427 let err = match lookup_mx_record_limited(
1428 &name,
1429 Some(&resolver),
1430 &no_permits,
1431 Duration::from_millis(50),
1432 )
1433 .await
1434 {
1435 Ok(_) => panic!("expected a timeout before the permit was acquired"),
1436 Err(err) => err,
1437 };
1438 k9::assert_equal!(err.kind, MxResolveFailure::NeverQueried);
1439 k9::assert_equal!(
1440 err.message,
1441 "timed out after 50ms waiting for an MX concurrency permit; no query was sent"
1442 );
1443 k9::assert_equal!(MX_PERMIT_TIMEOUT.get() - before, 1);
1444 }
1445
1446 #[tokio::test]
1447 async fn query_timeout_is_indeterminate() {
1448 let resolver = DelayResolver {
1449 delay: Duration::from_secs(60),
1450 };
1451 let name = fully_qualify("gmail.com").unwrap();
1452 let one_permit = Semaphore::new(1);
1456 let before = MX_QUERY_TIMEOUT.get();
1457 let err = match lookup_mx_record_limited(
1458 &name,
1459 Some(&resolver),
1460 &one_permit,
1461 Duration::from_millis(50),
1462 )
1463 .await
1464 {
1465 Ok(_) => panic!("expected the query to exceed the timeout"),
1466 Err(err) => err,
1467 };
1468 k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1469 k9::assert_equal!(err.message, "MX query timed out after 50ms");
1470 k9::assert_equal!(MX_QUERY_TIMEOUT.get() - before, 1);
1471 }
1472
1473 const EXAMPLE_MX_ZONE: &str = r#"
1474$ORIGIN example.com.
1475@ 86400 MX 10 mx.example.com.
1476"#;
1477
1478 #[tokio::test]
1479 async fn servfail_mx_is_indeterminate_not_implicit_mx() {
1480 let resolver = TestResolver::default().with_servfail("example.com");
1484 let name = fully_qualify("example.com").unwrap();
1485 let err = lookup_mx_record_limited(
1486 &name,
1487 Some(&resolver),
1488 &Semaphore::new(1),
1489 Duration::from_secs(5),
1490 )
1491 .await
1492 .unwrap_err();
1493 k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1494 k9::assert_equal!(
1495 err.message,
1496 "MX lookup for example.com. returned Server Failure"
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn bogus_mx_is_rejected_not_used() {
1502 let name = fully_qualify("example.com").unwrap();
1503
1504 let ok = TestResolver::default().with_zone(EXAMPLE_MX_ZONE).unwrap();
1506 let (records, _) =
1507 lookup_mx_record_limited(&name, Some(&ok), &Semaphore::new(1), Duration::from_secs(5))
1508 .await
1509 .unwrap();
1510 k9::assert_equal!(records[0].hosts, vec!["mx.example.com.".to_string()]);
1511
1512 let resolver = TestResolver::default()
1515 .with_zone(EXAMPLE_MX_ZONE)
1516 .unwrap()
1517 .with_bogus("example.com");
1518 let err = lookup_mx_record_limited(
1519 &name,
1520 Some(&resolver),
1521 &Semaphore::new(1),
1522 Duration::from_secs(5),
1523 )
1524 .await
1525 .unwrap_err();
1526 k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1527 k9::assert_equal!(
1528 err.message,
1529 "MX records for example.com. are bogus: test bogus"
1530 );
1531 }
1532
1533 #[test]
1534 fn negative_ttl_classifies_by_kind() {
1535 k9::assert_equal!(
1538 MxResolveFailure::NxDomain.negative_ttl(),
1539 get_mx_negative_ttl()
1540 );
1541 k9::assert_equal!(
1542 MxResolveFailure::PolicyRejected.negative_ttl(),
1543 get_mx_negative_ttl()
1544 );
1545 k9::assert_equal!(
1546 MxResolveFailure::Indeterminate.negative_ttl(),
1547 get_mx_transient_negative_ttl()
1548 );
1549 k9::assert_equal!(
1550 MxResolveFailure::NeverQueried.negative_ttl(),
1551 Duration::ZERO
1552 );
1553 }
1554
1555 fn fresh_mx(domain: &str) -> Arc<MailExchanger> {
1559 Arc::new(MailExchanger {
1560 domain_name: domain.to_string(),
1561 hosts: vec![format!("mx.{domain}.")],
1562 site_name: format!("mx.{domain}"),
1563 by_pref: BTreeMap::new(),
1564 is_domain_literal: false,
1565 is_secure: false,
1566 is_mx: true,
1567 mta_sts: PolicyMode::None,
1568 expires: None,
1569 })
1570 }
1571
1572 #[tokio::test]
1573 async fn indeterminate_failure_cached_then_requeries() {
1574 tokio::time::pause();
1579 let key = (fully_qualify("transient-ttl-test.invalid").unwrap(), None);
1580 let transient = get_mx_transient_negative_ttl();
1581
1582 let first = MX_CACHE
1583 .get_or_try_insert(&key, mx_cache_ttl, async {
1584 Ok::<_, anyhow::Error>(Err(MxResolveError {
1585 kind: MxResolveFailure::Indeterminate,
1586 message: "SERVFAIL".to_string(),
1587 }))
1588 })
1589 .await
1590 .unwrap();
1591 assert!(first.is_fresh);
1592 assert!(first.item.is_err());
1593
1594 tokio::time::advance(transient / 2).await;
1596 let cached = MX_CACHE
1597 .get_or_try_insert(&key, mx_cache_ttl, async {
1598 panic!("must not re-query before the transient TTL elapses");
1601 #[allow(unreachable_code)]
1602 Ok::<Result<Arc<MailExchanger>, MxResolveError>, anyhow::Error>(Ok(fresh_mx(
1603 "transient-ttl-test.invalid",
1604 )))
1605 })
1606 .await
1607 .unwrap();
1608 assert!(!cached.is_fresh);
1609 assert!(cached.item.is_err());
1610
1611 tokio::time::advance(transient).await;
1613 let refreshed = MX_CACHE
1614 .get_or_try_insert(&key, mx_cache_ttl, async {
1615 Ok::<_, anyhow::Error>(Ok(fresh_mx("transient-ttl-test.invalid")))
1616 })
1617 .await
1618 .unwrap();
1619 assert!(refreshed.is_fresh);
1620 assert!(refreshed.item.is_ok());
1621 }
1622
1623 #[tokio::test]
1624 async fn never_queried_failure_is_not_cached() {
1625 let key = (fully_qualify("never-queried-test.invalid").unwrap(), None);
1629
1630 let first = MX_CACHE
1631 .get_or_try_insert(&key, mx_cache_ttl, async {
1632 Ok::<_, anyhow::Error>(Err(MxResolveError {
1633 kind: MxResolveFailure::NeverQueried,
1634 message: "permit wait timed out".to_string(),
1635 }))
1636 })
1637 .await
1638 .unwrap();
1639 assert!(first.is_fresh);
1640 assert!(first.item.is_err());
1641
1642 let refreshed = MX_CACHE
1643 .get_or_try_insert(&key, mx_cache_ttl, async {
1644 Ok::<_, anyhow::Error>(Ok(fresh_mx("never-queried-test.invalid")))
1645 })
1646 .await
1647 .unwrap();
1648 assert!(refreshed.is_fresh);
1649 assert!(refreshed.item.is_ok());
1650 }
1651
1652 #[tokio::test]
1653 async fn lookup_null_mx() {
1654 let resolver = fixture_resolver(&[r#"
1655$ORIGIN example.com.
1656@ 3600 MX 0 .
1657"#]);
1658 let mut mx = (*MailExchanger::resolve_via("example.com", Some(&resolver))
1659 .await
1660 .unwrap())
1661 .clone();
1662 mx.expires.take();
1663 k9::snapshot!(
1664 &mx,
1665 r#"
1666MailExchanger {
1667 domain_name: "example.com.",
1668 hosts: [
1669 ".",
1670 ],
1671 site_name: "",
1672 by_pref: {
1673 0: [
1674 ".",
1675 ],
1676 },
1677 is_domain_literal: false,
1678 is_secure: false,
1679 is_mx: true,
1680 mta_sts: None,
1681 expires: None,
1682}
1683"#
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn lookup_single_mx() {
1689 let resolver = fixture_resolver(&[r#"
1690$ORIGIN do.havedane.net.
1691@ 300 MX 10 do.havedane.net.
1692"#]);
1693 let mut mx = (*MailExchanger::resolve_via("do.havedane.net", Some(&resolver))
1694 .await
1695 .unwrap())
1696 .clone();
1697 mx.expires.take();
1698 k9::snapshot!(
1699 &mx,
1700 r#"
1701MailExchanger {
1702 domain_name: "do.havedane.net.",
1703 hosts: [
1704 "do.havedane.net.",
1705 ],
1706 site_name: "do.havedane.net",
1707 by_pref: {
1708 10: [
1709 "do.havedane.net.",
1710 ],
1711 },
1712 is_domain_literal: false,
1713 is_secure: false,
1714 is_mx: true,
1715 mta_sts: None,
1716 expires: None,
1717}
1718"#
1719 );
1720 }
1721
1722 #[tokio::test]
1723 async fn mx_lookup_no_mx_falls_back_to_a() {
1724 let resolver = fixture_resolver(&[r#"
1727$ORIGIN example.com.
1728www 300 A 192.0.2.1
1729"#]);
1730 let mut mx = (*MailExchanger::resolve_via("www.example.com", Some(&resolver))
1731 .await
1732 .unwrap())
1733 .clone();
1734 mx.expires.take();
1735 k9::snapshot!(
1736 &mx,
1737 r#"
1738MailExchanger {
1739 domain_name: "www.example.com.",
1740 hosts: [
1741 "www.example.com.",
1742 ],
1743 site_name: "www.example.com",
1744 by_pref: {
1745 1: [
1746 "www.example.com.",
1747 ],
1748 },
1749 is_domain_literal: false,
1750 is_secure: false,
1751 is_mx: false,
1752 mta_sts: None,
1753 expires: None,
1754}
1755"#
1756 );
1757 }
1758}