1use crate::site_name::factor_names;
2use anyhow::Context;
3use dns_resolver::{
4 get_resolver, has_colon_port, ip_lookup, DnsError, DomainClassification, IpLookupStrategy,
5 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
40const MTA_STS_FETCH_RETRY: Duration = Duration::from_secs(300);
44
45static MX_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(128);
47static MX_CONCURRENCY_SEMA: LazyLock<Semaphore> =
48 LazyLock::new(|| Semaphore::new(MX_MAX_CONCURRENCY.load(Ordering::SeqCst)));
49
50static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
52
53static MX_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(300 * 1000);
55
56static MX_TRANSIENT_NEGATIVE_TTL: AtomicUsize = AtomicUsize::new(30 * 1000);
58
59pub fn set_mx_concurrency_limit(n: usize) {
60 MX_MAX_CONCURRENCY.store(n, Ordering::SeqCst);
61}
62
63pub fn set_mx_timeout(duration: Duration) -> anyhow::Result<()> {
64 let ms = duration
65 .as_millis()
66 .try_into()
67 .context("set_mx_timeout: duration is too large")?;
68 MX_TIMEOUT_MS.store(ms, Ordering::Relaxed);
69 Ok(())
70}
71
72pub fn get_mx_timeout() -> Duration {
73 Duration::from_millis(MX_TIMEOUT_MS.load(Ordering::Relaxed) as u64)
74}
75
76pub fn set_mx_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
77 let ms = duration
78 .as_millis()
79 .try_into()
80 .context("set_mx_negative_cache_ttl: duration is too large")?;
81 MX_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
82 Ok(())
83}
84
85pub fn get_mx_negative_ttl() -> Duration {
86 Duration::from_millis(MX_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
87}
88
89pub fn set_mx_transient_negative_cache_ttl(duration: Duration) -> anyhow::Result<()> {
90 let ms = duration
91 .as_millis()
92 .try_into()
93 .context("set_mx_transient_negative_cache_ttl: duration is too large")?;
94 MX_TRANSIENT_NEGATIVE_TTL.store(ms, Ordering::Relaxed);
95 Ok(())
96}
97
98pub fn get_mx_transient_negative_ttl() -> Duration {
99 Duration::from_millis(MX_TRANSIENT_NEGATIVE_TTL.load(Ordering::Relaxed) as u64)
100}
101
102fn mx_cache_ttl(mx_result: &Result<Arc<MailExchanger>, MxResolveError>) -> Duration {
104 match mx_result {
105 Ok(mx) => match mx.expires {
106 Some(exp) => exp
107 .checked_duration_since(std::time::Instant::now())
108 .unwrap_or_else(|| Duration::from_secs(10)),
109 None => get_mx_negative_ttl(),
110 },
111 Err(err) => err.kind.negative_ttl(),
112 }
113}
114
115#[derive(Debug)]
116struct ByPreference {
117 pub hosts: Vec<String>,
118 pub pref: u16,
119 pub is_secure: bool,
120 pub is_mx: bool,
121}
122
123async fn lookup_mx_record(
124 domain_name: &Name,
125 resolver: Option<&dyn Resolver>,
126) -> Result<(Vec<ByPreference>, Instant), MxResolveError> {
127 lookup_mx_record_limited(
128 domain_name,
129 resolver,
130 &MX_CONCURRENCY_SEMA,
131 get_mx_timeout(),
132 )
133 .await
134}
135
136struct PermitWaitGuard;
140
141impl PermitWaitGuard {
142 fn enter() -> Self {
143 MX_PERMIT_WAITERS.inc();
144 Self
145 }
146}
147
148impl Drop for PermitWaitGuard {
149 fn drop(&mut self) {
150 MX_PERMIT_WAITERS.dec();
151 }
152}
153
154async fn lookup_mx_record_limited(
158 domain_name: &Name,
159 resolver: Option<&dyn Resolver>,
160 concurrency: &Semaphore,
161 timeout_duration: Duration,
162) -> Result<(Vec<ByPreference>, Instant), MxResolveError> {
163 let permit_acquired = AtomicBool::new(false);
166 let mx_lookup = match timeout(timeout_duration, async {
167 let wait_guard = PermitWaitGuard::enter();
168 let _permit = concurrency.acquire().await;
169 drop(wait_guard);
173 permit_acquired.store(true, Ordering::Relaxed);
177 match resolver {
178 Some(r) => r.resolve(domain_name.clone(), RecordType::MX).await,
179 None => {
180 get_resolver()
181 .resolve(domain_name.clone(), RecordType::MX)
182 .await
183 }
184 }
185 })
186 .await
187 {
188 Ok(Ok(answer)) => answer,
189 Ok(Err(dns_err)) => {
190 return Err(MxResolveError {
191 kind: classify_dns_error(&dns_err),
192 message: dns_err.to_string(),
193 });
194 }
195 Err(_elapsed) => {
196 let (kind, message) = if permit_acquired.load(Ordering::Relaxed) {
197 MX_QUERY_TIMEOUT.inc();
198 (
199 MxResolveFailure::Indeterminate,
200 format!("MX query timed out after {timeout_duration:?}"),
201 )
202 } else {
203 MX_PERMIT_TIMEOUT.inc();
204 (
205 MxResolveFailure::NeverQueried,
206 format!(
207 "timed out after {timeout_duration:?} waiting for an MX concurrency \
208 permit; no query was sent"
209 ),
210 )
211 };
212 return Err(MxResolveError { kind, message });
213 }
214 };
215 if mx_lookup.bogus {
223 return Err(MxResolveError {
224 kind: MxResolveFailure::Indeterminate,
225 message: format!(
226 "MX records for {domain_name} are bogus: {}",
227 mx_lookup
228 .why_bogus
229 .as_deref()
230 .unwrap_or("DNSSEC validation failed")
231 ),
232 });
233 }
234 match mx_lookup.response_code {
235 ResponseCode::NoError | ResponseCode::NXDomain => {}
236 rcode => {
237 return Err(MxResolveError {
238 kind: MxResolveFailure::Indeterminate,
239 message: format!("MX lookup for {domain_name} returned {rcode}"),
240 });
241 }
242 }
243
244 let mx_records = mx_lookup.records;
245
246 if mx_records.is_empty() {
247 if mx_lookup.nxdomain {
248 return Err(MxResolveError {
249 kind: MxResolveFailure::NxDomain,
250 message: "NXDOMAIN".to_string(),
251 });
252 }
253
254 return Ok((
260 vec![ByPreference {
261 hosts: vec![domain_name.to_lowercase().to_ascii()],
262 pref: 1,
263 is_secure: mx_lookup.secure,
264 is_mx: false,
265 }],
266 mx_lookup.expires,
267 ));
268 }
269
270 let mut records: Vec<ByPreference> = Vec::with_capacity(mx_records.len());
271
272 for mx_record in mx_records {
273 if let RData::MX(mx) = mx_record {
274 let pref = mx.preference;
275 let host = mx.exchange.to_lowercase().to_string();
276
277 if let Some(record) = records.iter_mut().find(|r| r.pref == pref) {
278 record.hosts.push(host);
279 } else {
280 records.push(ByPreference {
281 hosts: vec![host],
282 pref,
283 is_secure: mx_lookup.secure,
284 is_mx: true,
285 });
286 }
287 }
288 }
289
290 records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
292
293 for mx in &mut records {
296 mx.hosts.sort();
297 }
298
299 Ok((records, mx_lookup.expires))
300}
301
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
307pub enum MxResolveFailure {
308 NxDomain,
314 Indeterminate,
319 NeverQueried,
323 PolicyRejected,
327}
328
329impl MxResolveFailure {
330 fn negative_ttl(self) -> Duration {
337 match self {
338 MxResolveFailure::NxDomain | MxResolveFailure::PolicyRejected => get_mx_negative_ttl(),
339 MxResolveFailure::Indeterminate => get_mx_transient_negative_ttl(),
340 MxResolveFailure::NeverQueried => Duration::ZERO,
341 }
342 }
343}
344
345#[derive(Clone, Debug)]
352pub struct MxResolveError {
353 pub kind: MxResolveFailure,
354 pub message: String,
355}
356
357impl MxResolveError {
358 pub fn is_nxdomain(&self) -> bool {
361 self.kind == MxResolveFailure::NxDomain
362 }
363}
364
365impl std::fmt::Display for MxResolveError {
366 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
367 f.write_str(&self.message)
368 }
369}
370
371impl std::error::Error for MxResolveError {}
372
373fn classify_dns_error(err: &DnsError) -> MxResolveFailure {
377 match err {
378 DnsError::ResolveFailed(_) => MxResolveFailure::Indeterminate,
381 DnsError::InvalidName(_) => MxResolveFailure::Indeterminate,
387 }
388}
389
390#[derive(Clone, Debug, Serialize)]
391pub struct MailExchanger {
392 pub domain_name: String,
393 pub hosts: Vec<String>,
394 pub site_name: String,
395 pub by_pref: BTreeMap<u16, Vec<String>>,
396 pub is_domain_literal: bool,
397 pub is_secure: bool,
399 pub is_mx: bool,
400 pub mta_sts: PolicyMode,
404 #[serde(skip)]
405 expires: Option<Instant>,
406}
407
408declare_cache! {
409static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, MxResolveError>>::new("dns_resolver_mx", 64 * 1024);
411}
412
413declare_metric! {
414static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
416}
417
418declare_metric! {
419static MX_SUCCESS: IntCounter(
421 "dns_mx_resolve_status_ok");
422}
423
424declare_metric! {
425static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
431}
432
433declare_metric! {
434static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
439}
440
441declare_metric! {
442static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
447}
448
449declare_metric! {
450static MX_PERMIT_TIMEOUT: IntCounter("dns_mx_resolve_permit_timeout");
454}
455
456declare_metric! {
457static MX_QUERY_TIMEOUT: IntCounter("dns_mx_resolve_query_timeout");
460}
461
462declare_metric! {
463static MX_PERMIT_WAITERS: IntGauge("dns_mx_resolve_permit_waiters");
466}
467
468declare_metric! {
469static MX_MTA_STS_IMPOSSIBLE: IntCounter("dns_mx_resolve_mta_sts_impossible");
473}
474
475#[derive(Debug, PartialEq, Eq)]
477enum StsEval {
478 Status(PolicyMode),
480 Prune(Vec<bool>),
483 Impossible,
485}
486
487fn evaluate_mta_sts(hosts: &[String], policy: &MtaStsPolicy) -> StsEval {
490 match policy.mode {
491 PolicyMode::None => StsEval::Status(PolicyMode::None),
492 PolicyMode::Testing => StsEval::Status(PolicyMode::Testing),
493 PolicyMode::Enforce => {
494 let matched: Vec<bool> = hosts
495 .iter()
496 .map(|h| {
497 let label = match has_colon_port(h) {
498 Some((label, _)) => label,
499 None => h.as_str(),
500 };
501 policy.mx_name_matches(label)
502 })
503 .collect();
504 let match_count = matched.iter().filter(|m| **m).count();
505 if match_count == 0 {
506 StsEval::Impossible
507 } else if match_count == hosts.len() {
508 StsEval::Status(PolicyMode::Enforce)
509 } else {
510 StsEval::Prune(matched)
511 }
512 }
513 }
514}
515
516async fn apply_mta_sts(
521 name_fq: &Name,
522 by_pref: &mut Vec<ByPreference>,
523 hosts: &mut Vec<String>,
524 expires: &mut Instant,
525 resolver: Option<&dyn Resolver>,
526) -> Result<PolicyMode, String> {
527 let policy_domain = name_fq.to_ascii();
528 let policy_domain = policy_domain.trim_end_matches('.');
529
530 let policy = match mta_sts::get_policy_for_domain(policy_domain, resolver).await {
531 Ok(policy) => policy,
532 Err(err) => {
533 tracing::debug!("MTA-STS policy fetch for {policy_domain} failed: {err:#}");
536 *expires = (*expires).min(Instant::now() + MTA_STS_FETCH_RETRY);
537 return Ok(PolicyMode::None);
538 }
539 };
540
541 let mta_sts = match evaluate_mta_sts(hosts, &policy) {
542 StsEval::Status(status) => status,
543 StsEval::Prune(matched) => {
544 let mut idx = 0;
548 for pref in by_pref.iter_mut() {
549 pref.hosts.retain(|_| {
550 let keep = matched[idx];
551 idx += 1;
552 keep
553 });
554 }
555 by_pref.retain(|p| !p.hosts.is_empty());
556 *hosts = by_pref
557 .iter()
558 .flat_map(|p| p.hosts.iter().cloned())
559 .collect();
560 PolicyMode::Enforce
561 }
562 StsEval::Impossible => {
563 MX_MTA_STS_IMPOSSIBLE.inc();
564 return Err(format!(
565 "MTA-STS enforce policy for {policy_domain} permits none of its \
566 MX hosts {hosts:?}; allowed mx patterns: {patterns:?}. The \
567 destination is undeliverable until its MTA-STS policy is \
568 corrected.",
569 patterns = policy.mx
570 ));
571 }
572 };
573
574 let policy_expires = Instant::now()
578 .checked_add(Duration::from_secs(policy.max_age))
579 .unwrap_or_else(Instant::now);
580 *expires = (*expires).min(policy_expires);
581 Ok(mta_sts)
582}
583
584impl MailExchanger {
585 pub async fn resolve(domain_name: &str) -> anyhow::Result<Arc<Self>> {
586 Self::resolve_via(domain_name, None).await
587 }
588
589 pub async fn resolve_via(
594 domain_name: &str,
595 resolver: Option<&dyn Resolver>,
596 ) -> anyhow::Result<Arc<Self>> {
597 MX_IN_PROGRESS.inc();
598 let result = Self::resolve_impl(domain_name, resolver).await;
599 MX_IN_PROGRESS.dec();
600 if result.is_ok() {
601 MX_SUCCESS.inc();
602 } else {
603 MX_FAIL.inc();
604 }
605 result
606 }
607
608 async fn resolve_impl(
609 domain_name: &str,
610 resolver: Option<&dyn Resolver>,
611 ) -> anyhow::Result<Arc<Self>> {
612 let (name_fq, opt_port) = match DomainClassification::classify(domain_name)? {
613 DomainClassification::Literal(addr) => {
614 let mut by_pref = BTreeMap::new();
615 by_pref.insert(1, vec![addr.to_string()]);
616 return Ok(Arc::new(Self {
617 domain_name: domain_name.to_string(),
618 hosts: vec![addr.to_string()],
619 site_name: addr.to_string(),
620 by_pref,
621 is_domain_literal: true,
622 is_secure: false,
623 is_mx: false,
624 mta_sts: PolicyMode::None,
625 expires: None,
626 }));
627 }
628 DomainClassification::Domain(name_fq, opt_port) => (name_fq, opt_port),
629 };
630
631 if resolver.is_some() {
634 return Self::resolve_uncached(&name_fq, opt_port, domain_name, resolver)
635 .await?
636 .map_err(anyhow::Error::new);
637 }
638
639 let lookup_result = MX_CACHE
640 .get_or_try_insert(
641 &(name_fq.clone(), opt_port),
642 mx_cache_ttl,
643 Self::resolve_uncached(&name_fq, opt_port, domain_name, None),
644 )
645 .await
646 .map_err(|err| anyhow::anyhow!("{err}"))?;
647
648 if !lookup_result.is_fresh {
649 MX_CACHED.inc();
650 }
651
652 lookup_result.item.map_err(anyhow::Error::new)
653 }
654
655 async fn resolve_uncached(
656 name_fq: &Name,
657 opt_port: Option<u16>,
658 domain_name: &str,
659 resolver: Option<&dyn Resolver>,
660 ) -> anyhow::Result<Result<Arc<MailExchanger>, MxResolveError>> {
661 MX_QUERIES.inc();
662 let start = Instant::now();
663 let (mut by_pref, mut expires) = match lookup_mx_record(name_fq, resolver).await {
664 Ok((by_pref, expires)) => (by_pref, expires),
665 Err(err) => {
666 let error = format!(
667 "MX lookup for {domain_name} failed after {elapsed:?}: {err}",
668 elapsed = start.elapsed()
669 );
670 tracing::debug!(
671 target: "mx_resolve",
672 domain = domain_name,
673 %error,
674 "MX lookup failed; domain drops out of any site_name rollup"
675 );
676 return Ok(Err(MxResolveError {
677 kind: err.kind,
678 message: error,
679 }));
680 }
681 };
682
683 let mut hosts = vec![];
684 for pref in &mut by_pref {
685 for host in &mut pref.hosts {
686 if let Some(port) = opt_port {
687 *host = format!("{host}:{port}");
688 };
689 hosts.push(host.to_string());
690 }
691 }
692
693 let is_secure = by_pref.iter().all(|p| p.is_secure);
694 let is_mx = by_pref.iter().all(|p| p.is_mx);
695
696 let mta_sts = if is_mx && is_mta_sts_enabled() {
701 match apply_mta_sts(name_fq, &mut by_pref, &mut hosts, &mut expires, resolver).await {
702 Ok(status) => status,
703 Err(error) => {
704 tracing::debug!(
705 target: "mx_resolve",
706 domain = domain_name,
707 %error,
708 "MTA-STS evaluation failed; domain drops out of any site_name rollup"
709 );
710 return Ok(Err(MxResolveError {
711 kind: MxResolveFailure::PolicyRejected,
712 message: error,
713 }));
714 }
715 }
716 } else {
717 PolicyMode::None
718 };
719
720 let by_pref = by_pref
721 .into_iter()
722 .map(|pref| (pref.pref, pref.hosts))
723 .collect();
724
725 let site_name = factor_names(&hosts);
726 tracing::debug!(
727 target: "mx_resolve",
728 domain = domain_name,
729 %site_name,
730 ?hosts,
731 is_mx,
732 is_secure,
733 elapsed_ms = start.elapsed().as_millis() as u64,
734 "resolved MX to site_name"
735 );
736 let mx = Self {
737 hosts,
738 domain_name: name_fq.to_ascii(),
739 site_name,
740 by_pref,
741 is_domain_literal: false,
742 is_secure,
743 is_mx,
744 mta_sts,
745 expires: Some(expires),
746 };
747
748 Ok(Ok(Arc::new(mx)))
749 }
750
751 pub fn has_expired(&self) -> bool {
752 match self.expires {
753 Some(deadline) => deadline <= Instant::now(),
754 None => false,
755 }
756 }
757
758 pub async fn resolve_addresses(
767 &self,
768 resolver: Option<&dyn Resolver>,
769 strategy: IpLookupStrategy,
770 max_plan_size: usize,
771 max_addresses_per_host: usize,
772 ) -> ResolvedMxAddresses {
773 let mut result = vec![];
774
775 'by_pref: for hosts in self.by_pref.values() {
778 let mut by_pref = vec![];
779
780 for mx_host in hosts {
781 if mx_host == "." {
783 return ResolvedMxAddresses::NullMx;
784 }
785
786 let (mx_host, opt_port) = match has_colon_port(mx_host) {
788 Some((domain_name, port)) => (domain_name, Some(port)),
789 None => (mx_host.as_str(), None),
790 };
791 if let Ok(addr) = mx_host.parse::<IpAddr>() {
792 let mut addr: HostOrSocketAddress = addr.into();
793 if let Some(port) = opt_port {
794 addr.set_port(port);
795 }
796 by_pref.push(ResolvedAddress {
797 name: mx_host.to_string(),
798 addr,
799 is_secure: false,
800 });
801 continue;
802 }
803
804 match ip_lookup(mx_host, resolver, strategy).await {
805 Err(err) => {
806 tracing::error!("failed to resolve {mx_host}: {err:#}");
807 continue;
808 }
809 Ok((lookup, _expires)) => {
810 for addr in lookup.addrs.iter().take(max_addresses_per_host) {
811 let mut addr: HostOrSocketAddress = (*addr).into();
812 if let Some(port) = opt_port {
813 addr.set_port(port);
814 }
815 by_pref.push(ResolvedAddress {
816 name: mx_host.to_string(),
817 addr,
818 is_secure: lookup.secure,
819 });
820 }
821 }
822 }
823 }
824
825 {
829 let mut rng = rand::thread_rng();
830 by_pref.shuffle(&mut rng);
831 }
832
833 for addr in by_pref {
834 if result.len() == max_plan_size {
835 break 'by_pref;
838 }
839 result.push(addr);
840 }
841 }
842
843 result.reverse();
846 ResolvedMxAddresses::Addresses(result)
847 }
848}
849
850#[derive(Debug, Clone, Serialize)]
851pub enum ResolvedMxAddresses {
852 NullMx,
853 Addresses(Vec<ResolvedAddress>),
856}
857
858#[cfg(test)]
859mod test {
860 use super::*;
861 use dns_resolver::{fully_qualify, TestResolver};
862
863 fn policy(mode: &str, mx: &[&str]) -> MtaStsPolicy {
864 let mut text = format!("version: STSv1\nmode: {mode}\nmax_age: 86400");
865 for m in mx {
866 text.push_str(&format!("\nmx: {m}"));
867 }
868 MtaStsPolicy::parse(&text).unwrap()
869 }
870
871 fn hosts(list: &[&str]) -> Vec<String> {
872 list.iter().map(|s| s.to_string()).collect()
873 }
874
875 #[test]
876 fn mta_sts_none_and_testing() {
877 assert_eq!(
878 evaluate_mta_sts(&hosts(&["mx01.mail.icloud.com."]), &policy("none", &[])),
879 StsEval::Status(PolicyMode::None)
880 );
881 assert_eq!(
882 evaluate_mta_sts(
883 &hosts(&["mx01.mail.icloud.com."]),
884 &policy("testing", &["*.mx.cloudflare.net"])
885 ),
886 StsEval::Status(PolicyMode::Testing)
887 );
888 }
889
890 #[test]
891 fn mta_sts_enforce_full_match() {
892 assert_eq!(
893 evaluate_mta_sts(
894 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
895 &policy("enforce", &["*.mail.icloud.com"])
896 ),
897 StsEval::Status(PolicyMode::Enforce)
898 );
899 }
900
901 #[test]
902 fn mta_sts_enforce_partial_prunes() {
903 assert_eq!(
905 evaluate_mta_sts(
906 &hosts(&["mx01.mail.icloud.com.", "backup.example.net."]),
907 &policy("enforce", &["*.mail.icloud.com"])
908 ),
909 StsEval::Prune(vec![true, false])
910 );
911 }
912
913 #[test]
914 fn mta_sts_enforce_impossible() {
915 assert_eq!(
918 evaluate_mta_sts(
919 &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
920 &policy("enforce", &["*.mx.cloudflare.net"])
921 ),
922 StsEval::Impossible
923 );
924 }
925
926 #[test]
927 fn mta_sts_enforce_strips_port() {
928 assert_eq!(
929 evaluate_mta_sts(
930 &hosts(&["mx01.mail.icloud.com.:587"]),
931 &policy("enforce", &["*.mail.icloud.com"])
932 ),
933 StsEval::Status(PolicyMode::Enforce)
934 );
935 }
936
937 #[tokio::test]
938 async fn site_identity_does_not_reorder_connection_preferences() {
939 let resolver = TestResolver::default()
940 .with_zone("$ORIGIN priority-a.example.\n@ 600 MX 10 mx1.targets.test.\n@ 600 MX 20 mx2.targets.test.\n")
941 .unwrap()
942 .with_zone("$ORIGIN priority-b.example.\n@ 600 MX 10 mx2.targets.test.\n@ 600 MX 20 mx1.targets.test.\n")
943 .unwrap();
944 let a = MailExchanger::resolve_via("priority-a.example", Some(&resolver))
945 .await
946 .unwrap();
947 let b = MailExchanger::resolve_via("priority-b.example", Some(&resolver))
948 .await
949 .unwrap();
950 assert_eq!(a.site_name, b.site_name);
951 assert_eq!(a.hosts, hosts(&["mx1.targets.test.", "mx2.targets.test."]));
952 assert_eq!(b.hosts, hosts(&["mx2.targets.test.", "mx1.targets.test."]));
953 assert_eq!(a.by_pref[&10], hosts(&["mx1.targets.test."]));
954 assert_eq!(b.by_pref[&10], hosts(&["mx2.targets.test."]));
955 }
956
957 #[tokio::test]
958 async fn literal_resolve() {
959 let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
960 k9::snapshot!(
961 &v4_loopback,
962 r#"
963MailExchanger {
964 domain_name: "[127.0.0.1]",
965 hosts: [
966 "127.0.0.1",
967 ],
968 site_name: "127.0.0.1",
969 by_pref: {
970 1: [
971 "127.0.0.1",
972 ],
973 },
974 is_domain_literal: true,
975 is_secure: false,
976 is_mx: false,
977 mta_sts: None,
978 expires: None,
979}
980"#
981 );
982 k9::snapshot!(
983 v4_loopback
984 .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
985 .await,
986 r#"
987Addresses(
988 [
989 ResolvedAddress {
990 name: "127.0.0.1",
991 addr: 127.0.0.1,
992 is_secure: false,
993 },
994 ],
995)
996"#
997 );
998
999 let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
1000 k9::snapshot!(
1001 &v6_loopback_non_conforming,
1002 r#"
1003MailExchanger {
1004 domain_name: "[::1]",
1005 hosts: [
1006 "::1",
1007 ],
1008 site_name: "::1",
1009 by_pref: {
1010 1: [
1011 "::1",
1012 ],
1013 },
1014 is_domain_literal: true,
1015 is_secure: false,
1016 is_mx: false,
1017 mta_sts: None,
1018 expires: None,
1019}
1020"#
1021 );
1022 k9::snapshot!(
1023 v6_loopback_non_conforming
1024 .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1025 .await,
1026 r#"
1027Addresses(
1028 [
1029 ResolvedAddress {
1030 name: "::1",
1031 addr: ::1,
1032 is_secure: false,
1033 },
1034 ],
1035)
1036"#
1037 );
1038
1039 let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
1040 k9::snapshot!(
1041 &v6_loopback,
1042 r#"
1043MailExchanger {
1044 domain_name: "[IPv6:::1]",
1045 hosts: [
1046 "::1",
1047 ],
1048 site_name: "::1",
1049 by_pref: {
1050 1: [
1051 "::1",
1052 ],
1053 },
1054 is_domain_literal: true,
1055 is_secure: false,
1056 is_mx: false,
1057 mta_sts: None,
1058 expires: None,
1059}
1060"#
1061 );
1062 k9::snapshot!(
1063 v6_loopback
1064 .resolve_addresses(None, IpLookupStrategy::default(), 50, 10)
1065 .await,
1066 r#"
1067Addresses(
1068 [
1069 ResolvedAddress {
1070 name: "::1",
1071 addr: ::1,
1072 is_secure: false,
1073 },
1074 ],
1075)
1076"#
1077 );
1078 }
1079
1080 fn fixture_resolver(zones: &[&str]) -> TestResolver {
1081 let mut resolver = TestResolver::default();
1082 for zone in zones {
1083 resolver = resolver.with_zone(zone).unwrap();
1084 }
1085 resolver
1086 }
1087
1088 const GMAIL_ZONE: &str = r#"
1089$ORIGIN gmail.com.
1090@ 86400 MX 5 gmail-smtp-in.l.google.com.
1091@ 86400 MX 10 alt1.gmail-smtp-in.l.google.com.
1092@ 86400 MX 20 alt2.gmail-smtp-in.l.google.com.
1093@ 86400 MX 30 alt3.gmail-smtp-in.l.google.com.
1094@ 86400 MX 40 alt4.gmail-smtp-in.l.google.com.
1095"#;
1096
1097 const GMAIL_HOSTS_ZONE: &str = r#"
1098$ORIGIN l.google.com.
1099gmail-smtp-in 300 A 142.251.2.26
1100alt1.gmail-smtp-in 300 A 108.177.104.27
1101alt2.gmail-smtp-in 300 A 74.125.126.27
1102alt3.gmail-smtp-in 300 A 172.253.113.26
1103alt4.gmail-smtp-in 300 A 173.194.77.27
1104"#;
1105
1106 #[tokio::test]
1107 async fn lookup_gmail_mx() {
1108 let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
1109 let mut gmail = (*MailExchanger::resolve_via("gmail.com", Some(&resolver))
1110 .await
1111 .unwrap())
1112 .clone();
1113 gmail.expires.take();
1114 k9::snapshot!(
1115 &gmail,
1116 r#"
1117MailExchanger {
1118 domain_name: "gmail.com.",
1119 hosts: [
1120 "gmail-smtp-in.l.google.com.",
1121 "alt1.gmail-smtp-in.l.google.com.",
1122 "alt2.gmail-smtp-in.l.google.com.",
1123 "alt3.gmail-smtp-in.l.google.com.",
1124 "alt4.gmail-smtp-in.l.google.com.",
1125 ],
1126 site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
1127 by_pref: {
1128 5: [
1129 "gmail-smtp-in.l.google.com.",
1130 ],
1131 10: [
1132 "alt1.gmail-smtp-in.l.google.com.",
1133 ],
1134 20: [
1135 "alt2.gmail-smtp-in.l.google.com.",
1136 ],
1137 30: [
1138 "alt3.gmail-smtp-in.l.google.com.",
1139 ],
1140 40: [
1141 "alt4.gmail-smtp-in.l.google.com.",
1142 ],
1143 },
1144 is_domain_literal: false,
1145 is_secure: false,
1146 is_mx: true,
1147 mta_sts: None,
1148 expires: None,
1149}
1150"#
1151 );
1152
1153 k9::snapshot!(
1157 gmail
1158 .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only, 50, 10)
1159 .await,
1160 r#"
1161Addresses(
1162 [
1163 ResolvedAddress {
1164 name: "alt4.gmail-smtp-in.l.google.com.",
1165 addr: 173.194.77.27,
1166 is_secure: false,
1167 },
1168 ResolvedAddress {
1169 name: "alt3.gmail-smtp-in.l.google.com.",
1170 addr: 172.253.113.26,
1171 is_secure: false,
1172 },
1173 ResolvedAddress {
1174 name: "alt2.gmail-smtp-in.l.google.com.",
1175 addr: 74.125.126.27,
1176 is_secure: false,
1177 },
1178 ResolvedAddress {
1179 name: "alt1.gmail-smtp-in.l.google.com.",
1180 addr: 108.177.104.27,
1181 is_secure: false,
1182 },
1183 ResolvedAddress {
1184 name: "gmail-smtp-in.l.google.com.",
1185 addr: 142.251.2.26,
1186 is_secure: false,
1187 },
1188 ],
1189)
1190"#
1191 );
1192 }
1193
1194 #[tokio::test]
1195 async fn resolve_addresses_caps_plan() {
1196 let hosts = 32;
1201 let addrs_per_host = 32;
1202 let mut zone = String::from("$ORIGIN example.com.\n");
1203 for h in 0..hosts {
1204 zone.push_str(&format!("@ 86400 MX 10 host{h}.example.com.\n"));
1205 }
1206 zone.push_str("@ 86400 MX 20 backup.example.com.\n");
1207 for h in 0..hosts {
1208 for i in 1..=addrs_per_host {
1209 zone.push_str(&format!("host{h} 300 A 10.0.{h}.{i}\n"));
1210 }
1211 }
1212 for i in 1..=addrs_per_host {
1213 zone.push_str(&format!("backup 300 A 10.1.0.{i}\n"));
1214 }
1215 let resolver = fixture_resolver(&[&zone]);
1216 let mx = MailExchanger::resolve_via("example.com", Some(&resolver))
1217 .await
1218 .unwrap();
1219
1220 let uncapped = match mx
1223 .resolve_addresses(
1224 Some(&resolver),
1225 IpLookupStrategy::Ipv4Only,
1226 usize::MAX,
1227 usize::MAX,
1228 )
1229 .await
1230 {
1231 ResolvedMxAddresses::Addresses(a) => a,
1232 other => panic!("expected addresses, got {other:?}"),
1233 };
1234 k9::assert_equal!(uncapped.len(), (hosts + 1) * addrs_per_host);
1235 k9::assert_equal!(
1236 uncapped
1237 .iter()
1238 .filter(|a| a.name == "backup.example.com.")
1239 .count(),
1240 addrs_per_host
1241 );
1242
1243 let capped = match mx
1248 .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only, 50, 10)
1249 .await
1250 {
1251 ResolvedMxAddresses::Addresses(a) => a,
1252 other => panic!("expected addresses, got {other:?}"),
1253 };
1254 k9::assert_equal!(capped.len(), 50);
1255 k9::assert_equal!(
1256 capped
1257 .iter()
1258 .filter(|a| a.name == "backup.example.com.")
1259 .count(),
1260 0
1261 );
1262
1263 let mut per_host = std::collections::HashMap::new();
1264 for addr in &capped {
1265 *per_host.entry(addr.name.clone()).or_insert(0usize) += 1;
1266 }
1267 let max_from_one_host = per_host.values().copied().max().unwrap();
1268 assert!(
1269 max_from_one_host <= 10,
1270 "a single host contributed {max_from_one_host} addresses, exceeding the per-host cap"
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn lookup_punycode_no_mx_only_a() {
1276 let resolver = fixture_resolver(&[r#"
1277$ORIGIN xn--bb-eka.at.
1278@ 300 A 192.0.2.5
1279"#]);
1280 let mx = MailExchanger::resolve_via("xn--bb-eka.at", Some(&resolver))
1281 .await
1282 .unwrap();
1283 assert_eq!(mx.domain_name, "xn--bb-eka.at.");
1284 assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
1285 }
1286
1287 #[tokio::test]
1288 async fn lookup_nxdomain() {
1289 let resolver = fixture_resolver(&[]);
1292 let name = fully_qualify("not-mairs.aasland.com").unwrap();
1293 let err = match lookup_mx_record(&name, Some(&resolver)).await {
1294 Ok(_) => panic!("expected NXDOMAIN"),
1295 Err(err) => err,
1296 };
1297 k9::assert_equal!(err.to_string(), "NXDOMAIN");
1298 k9::assert_equal!(err.kind, MxResolveFailure::NxDomain);
1299 assert!(err.is_nxdomain());
1300 }
1301
1302 #[tokio::test]
1303 async fn nxdomain_is_recoverable_through_anyhow() {
1304 let resolver = fixture_resolver(&[]);
1307 let err = MailExchanger::resolve_via("not-mairs.aasland.com", Some(&resolver))
1308 .await
1309 .unwrap_err();
1310 let mx_err = err
1311 .downcast_ref::<MxResolveError>()
1312 .expect("the typed MX error survives the anyhow conversion");
1313 assert!(mx_err.is_nxdomain());
1314 k9::assert_equal!(mx_err.kind, MxResolveFailure::NxDomain);
1315 }
1316
1317 #[test]
1318 fn classify_resolver_error_is_indeterminate() {
1319 k9::assert_equal!(
1322 classify_dns_error(&DnsError::ResolveFailed("SERVFAIL".to_string())),
1323 MxResolveFailure::Indeterminate
1324 );
1325 k9::assert_equal!(
1330 classify_dns_error(&DnsError::InvalidName("bad".to_string())),
1331 MxResolveFailure::Indeterminate
1332 );
1333 }
1334
1335 struct DelayResolver {
1338 delay: Duration,
1339 }
1340
1341 #[async_trait::async_trait]
1342 impl Resolver for DelayResolver {
1343 async fn resolve_ip(&self, _host: &str) -> Result<Vec<IpAddr>, DnsError> {
1344 unreachable!()
1345 }
1346 async fn resolve_mx(&self, _host: &str) -> Result<Vec<Name>, DnsError> {
1347 unreachable!()
1348 }
1349 async fn resolve_ptr(&self, _ip: IpAddr) -> Result<Vec<Name>, DnsError> {
1350 unreachable!()
1351 }
1352 async fn resolve(
1353 &self,
1354 _name: Name,
1355 _rrtype: RecordType,
1356 ) -> Result<dns_resolver::Answer, DnsError> {
1357 tokio::time::sleep(self.delay).await;
1358 Err(DnsError::ResolveFailed("delay elapsed".to_string()))
1359 }
1360 }
1361
1362 #[tokio::test]
1363 async fn deadline_before_permit_is_never_queried() {
1364 let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
1368 let name = fully_qualify("gmail.com").unwrap();
1369 let no_permits = Semaphore::new(0);
1370 let before = MX_PERMIT_TIMEOUT.get();
1371 let err = match lookup_mx_record_limited(
1372 &name,
1373 Some(&resolver),
1374 &no_permits,
1375 Duration::from_millis(50),
1376 )
1377 .await
1378 {
1379 Ok(_) => panic!("expected a timeout before the permit was acquired"),
1380 Err(err) => err,
1381 };
1382 k9::assert_equal!(err.kind, MxResolveFailure::NeverQueried);
1383 k9::assert_equal!(
1384 err.message,
1385 "timed out after 50ms waiting for an MX concurrency permit; no query was sent"
1386 );
1387 k9::assert_equal!(MX_PERMIT_TIMEOUT.get() - before, 1);
1388 }
1389
1390 #[tokio::test]
1391 async fn query_timeout_is_indeterminate() {
1392 let resolver = DelayResolver {
1393 delay: Duration::from_secs(60),
1394 };
1395 let name = fully_qualify("gmail.com").unwrap();
1396 let one_permit = Semaphore::new(1);
1400 let before = MX_QUERY_TIMEOUT.get();
1401 let err = match lookup_mx_record_limited(
1402 &name,
1403 Some(&resolver),
1404 &one_permit,
1405 Duration::from_millis(50),
1406 )
1407 .await
1408 {
1409 Ok(_) => panic!("expected the query to exceed the timeout"),
1410 Err(err) => err,
1411 };
1412 k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1413 k9::assert_equal!(err.message, "MX query timed out after 50ms");
1414 k9::assert_equal!(MX_QUERY_TIMEOUT.get() - before, 1);
1415 }
1416
1417 const EXAMPLE_MX_ZONE: &str = r#"
1418$ORIGIN example.com.
1419@ 86400 MX 10 mx.example.com.
1420"#;
1421
1422 #[tokio::test]
1423 async fn servfail_mx_is_indeterminate_not_implicit_mx() {
1424 let resolver = TestResolver::default().with_servfail("example.com");
1428 let name = fully_qualify("example.com").unwrap();
1429 let err = lookup_mx_record_limited(
1430 &name,
1431 Some(&resolver),
1432 &Semaphore::new(1),
1433 Duration::from_secs(5),
1434 )
1435 .await
1436 .unwrap_err();
1437 k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1438 k9::assert_equal!(
1439 err.message,
1440 "MX lookup for example.com. returned Server Failure"
1441 );
1442 }
1443
1444 #[tokio::test]
1445 async fn bogus_mx_is_rejected_not_used() {
1446 let name = fully_qualify("example.com").unwrap();
1447
1448 let ok = TestResolver::default().with_zone(EXAMPLE_MX_ZONE).unwrap();
1450 let (records, _) =
1451 lookup_mx_record_limited(&name, Some(&ok), &Semaphore::new(1), Duration::from_secs(5))
1452 .await
1453 .unwrap();
1454 k9::assert_equal!(records[0].hosts, vec!["mx.example.com.".to_string()]);
1455
1456 let resolver = TestResolver::default()
1459 .with_zone(EXAMPLE_MX_ZONE)
1460 .unwrap()
1461 .with_bogus("example.com");
1462 let err = lookup_mx_record_limited(
1463 &name,
1464 Some(&resolver),
1465 &Semaphore::new(1),
1466 Duration::from_secs(5),
1467 )
1468 .await
1469 .unwrap_err();
1470 k9::assert_equal!(err.kind, MxResolveFailure::Indeterminate);
1471 k9::assert_equal!(
1472 err.message,
1473 "MX records for example.com. are bogus: test bogus"
1474 );
1475 }
1476
1477 #[test]
1478 fn negative_ttl_classifies_by_kind() {
1479 k9::assert_equal!(
1482 MxResolveFailure::NxDomain.negative_ttl(),
1483 get_mx_negative_ttl()
1484 );
1485 k9::assert_equal!(
1486 MxResolveFailure::PolicyRejected.negative_ttl(),
1487 get_mx_negative_ttl()
1488 );
1489 k9::assert_equal!(
1490 MxResolveFailure::Indeterminate.negative_ttl(),
1491 get_mx_transient_negative_ttl()
1492 );
1493 k9::assert_equal!(
1494 MxResolveFailure::NeverQueried.negative_ttl(),
1495 Duration::ZERO
1496 );
1497 }
1498
1499 fn fresh_mx(domain: &str) -> Arc<MailExchanger> {
1503 Arc::new(MailExchanger {
1504 domain_name: domain.to_string(),
1505 hosts: vec![format!("mx.{domain}.")],
1506 site_name: format!("mx.{domain}"),
1507 by_pref: BTreeMap::new(),
1508 is_domain_literal: false,
1509 is_secure: false,
1510 is_mx: true,
1511 mta_sts: PolicyMode::None,
1512 expires: None,
1513 })
1514 }
1515
1516 #[tokio::test]
1517 async fn indeterminate_failure_cached_then_requeries() {
1518 tokio::time::pause();
1523 let key = (fully_qualify("transient-ttl-test.invalid").unwrap(), None);
1524 let transient = get_mx_transient_negative_ttl();
1525
1526 let first = MX_CACHE
1527 .get_or_try_insert(&key, mx_cache_ttl, async {
1528 Ok::<_, anyhow::Error>(Err(MxResolveError {
1529 kind: MxResolveFailure::Indeterminate,
1530 message: "SERVFAIL".to_string(),
1531 }))
1532 })
1533 .await
1534 .unwrap();
1535 assert!(first.is_fresh);
1536 assert!(first.item.is_err());
1537
1538 tokio::time::advance(transient / 2).await;
1540 let cached = MX_CACHE
1541 .get_or_try_insert(&key, mx_cache_ttl, async {
1542 panic!("must not re-query before the transient TTL elapses");
1545 #[allow(unreachable_code)]
1546 Ok::<Result<Arc<MailExchanger>, MxResolveError>, anyhow::Error>(Ok(fresh_mx(
1547 "transient-ttl-test.invalid",
1548 )))
1549 })
1550 .await
1551 .unwrap();
1552 assert!(!cached.is_fresh);
1553 assert!(cached.item.is_err());
1554
1555 tokio::time::advance(transient).await;
1557 let refreshed = MX_CACHE
1558 .get_or_try_insert(&key, mx_cache_ttl, async {
1559 Ok::<_, anyhow::Error>(Ok(fresh_mx("transient-ttl-test.invalid")))
1560 })
1561 .await
1562 .unwrap();
1563 assert!(refreshed.is_fresh);
1564 assert!(refreshed.item.is_ok());
1565 }
1566
1567 #[tokio::test]
1568 async fn never_queried_failure_is_not_cached() {
1569 let key = (fully_qualify("never-queried-test.invalid").unwrap(), None);
1573
1574 let first = MX_CACHE
1575 .get_or_try_insert(&key, mx_cache_ttl, async {
1576 Ok::<_, anyhow::Error>(Err(MxResolveError {
1577 kind: MxResolveFailure::NeverQueried,
1578 message: "permit wait timed out".to_string(),
1579 }))
1580 })
1581 .await
1582 .unwrap();
1583 assert!(first.is_fresh);
1584 assert!(first.item.is_err());
1585
1586 let refreshed = MX_CACHE
1587 .get_or_try_insert(&key, mx_cache_ttl, async {
1588 Ok::<_, anyhow::Error>(Ok(fresh_mx("never-queried-test.invalid")))
1589 })
1590 .await
1591 .unwrap();
1592 assert!(refreshed.is_fresh);
1593 assert!(refreshed.item.is_ok());
1594 }
1595
1596 #[tokio::test]
1597 async fn lookup_null_mx() {
1598 let resolver = fixture_resolver(&[r#"
1599$ORIGIN example.com.
1600@ 3600 MX 0 .
1601"#]);
1602 let mut mx = (*MailExchanger::resolve_via("example.com", Some(&resolver))
1603 .await
1604 .unwrap())
1605 .clone();
1606 mx.expires.take();
1607 k9::snapshot!(
1608 &mx,
1609 r#"
1610MailExchanger {
1611 domain_name: "example.com.",
1612 hosts: [
1613 ".",
1614 ],
1615 site_name: "",
1616 by_pref: {
1617 0: [
1618 ".",
1619 ],
1620 },
1621 is_domain_literal: false,
1622 is_secure: false,
1623 is_mx: true,
1624 mta_sts: None,
1625 expires: None,
1626}
1627"#
1628 );
1629 }
1630
1631 #[tokio::test]
1632 async fn lookup_single_mx() {
1633 let resolver = fixture_resolver(&[r#"
1634$ORIGIN do.havedane.net.
1635@ 300 MX 10 do.havedane.net.
1636"#]);
1637 let mut mx = (*MailExchanger::resolve_via("do.havedane.net", Some(&resolver))
1638 .await
1639 .unwrap())
1640 .clone();
1641 mx.expires.take();
1642 k9::snapshot!(
1643 &mx,
1644 r#"
1645MailExchanger {
1646 domain_name: "do.havedane.net.",
1647 hosts: [
1648 "do.havedane.net.",
1649 ],
1650 site_name: "do.havedane.net",
1651 by_pref: {
1652 10: [
1653 "do.havedane.net.",
1654 ],
1655 },
1656 is_domain_literal: false,
1657 is_secure: false,
1658 is_mx: true,
1659 mta_sts: None,
1660 expires: None,
1661}
1662"#
1663 );
1664 }
1665
1666 #[tokio::test]
1667 async fn mx_lookup_no_mx_falls_back_to_a() {
1668 let resolver = fixture_resolver(&[r#"
1671$ORIGIN example.com.
1672www 300 A 192.0.2.1
1673"#]);
1674 let mut mx = (*MailExchanger::resolve_via("www.example.com", Some(&resolver))
1675 .await
1676 .unwrap())
1677 .clone();
1678 mx.expires.take();
1679 k9::snapshot!(
1680 &mx,
1681 r#"
1682MailExchanger {
1683 domain_name: "www.example.com.",
1684 hosts: [
1685 "www.example.com.",
1686 ],
1687 site_name: "www.example.com",
1688 by_pref: {
1689 1: [
1690 "www.example.com.",
1691 ],
1692 },
1693 is_domain_literal: false,
1694 is_secure: false,
1695 is_mx: false,
1696 mta_sts: None,
1697 expires: None,
1698}
1699"#
1700 );
1701 }
1702}