mailexchanger/
lib.rs

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
23/// Whether MX resolution consults MTA-STS policies. Defaults to true because
24/// honoring a destination's published MTA-STS policy is the correct default.
25/// Toggled via `kumo.dns.set_mta_sts_enabled`.
26static 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
36/// When a policy fetch fails transiently we don't want to pin a "no policy"
37/// result for the full DNS TTL, so we cap the cached entry to this interval
38/// to re-attempt the policy fetch sooner.
39const MTA_STS_FETCH_RETRY: Duration = Duration::from_secs(300);
40
41/// Maximum number of concurrent mx resolves permitted
42static 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
46/// 5 seconds in ms
47static MX_TIMEOUT_MS: AtomicUsize = AtomicUsize::new(5000);
48
49/// 5 minutes in ms
50static 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        // No MX records: the domain's own A/AAAA records act as the implicit
113        // MX. This implicit MX is secure exactly when the MX NODATA response
114        // was securely (DNSSEC) resolved, which is common for signed domains
115        // that publish no MX (e.g. many `.br` domains).
116        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    // Sort by preference
148    records.sort_unstable_by(|a, b| a.pref.cmp(&b.pref));
149
150    // Sort the hosts at each preference level to produce the
151    // overall ordered list of hosts for this site
152    for mx in &mut records {
153        mx.hosts.sort();
154    }
155
156    Ok((records, mx_lookup.expires))
157}
158
159/// Given a list of host names, produce a pseudo-regex style alternation list
160/// of the different elements of the hostnames.
161/// The goal is to produce a more compact representation of the name list
162/// with the common components factored out.
163fn 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    /// DNSSEC verified
245    pub is_secure: bool,
246    pub is_mx: bool,
247    /// The applicable MTA-STS policy mode (`PolicyMode::None` when no policy
248    /// applies). `hosts`/`by_pref` already exclude any hosts disallowed by the
249    /// policy, so this is consulted only for TLS posture, not host gating.
250    pub mta_sts: PolicyMode,
251    #[serde(skip)]
252    expires: Option<Instant>,
253}
254
255declare_cache! {
256/// Caches domain name to computed set of MailExchanger records
257static MX_CACHE: LruCacheWithTtl<(Name, Option<u16>), Result<Arc<MailExchanger>, String>>::new("dns_resolver_mx", 64 * 1024);
258}
259
260declare_metric! {
261/// number of `MailExchanger::resolve` calls currently in progress.
262static MX_IN_PROGRESS: IntGauge("dns_mx_resolve_in_progress");
263}
264
265declare_metric! {
266/// Total number of successful `MailExchanger::resolve` calls
267static MX_SUCCESS: IntCounter(
268        "dns_mx_resolve_status_ok");
269}
270
271declare_metric! {
272/// Total number of failed `MailExchanger::resolve` calls.
273///
274/// Spikes may indicate an issue with your DNS configuration
275/// or infrastructure, or may simply indicate that the traffic
276/// is destined for bogus addresses.
277static MX_FAIL: IntCounter("dns_mx_resolve_status_fail");
278}
279
280declare_metric! {
281/// Total number of MailExchanger::resolve calls satisfied by level 1 cache.
282///
283/// Redundant with the newer [lruttl_hit_count{cache_name="dns_resolver_mx"}](lruttl_hit_count.md)
284/// metric.
285static MX_CACHED: IntCounter("dns_mx_resolve_cache_hit");
286}
287
288declare_metric! {
289/// Total number of MailExchanger::resolve calls that resulted in an MX DNS request to the next level of cache
290///
291/// Redundant with the newer [lruttl_miss_count{cache_name="dns_resolver_mx"}](lruttl_miss_count.md)
292/// metric.
293static MX_QUERIES: IntCounter("dns_mx_resolve_cache_miss");
294}
295
296declare_metric! {
297/// Total number of MailExchanger::resolve calls that failed because the
298/// domain published an MTA-STS enforce policy that permits none of its own
299/// MX hosts. Such domains are undeliverable until they fix their policy.
300static MX_MTA_STS_IMPOSSIBLE: IntCounter("dns_mx_resolve_mta_sts_impossible");
301}
302
303/// The effect of an MTA-STS policy on a domain's resolved MX host set.
304#[derive(Debug, PartialEq, Eq)]
305enum StsEval {
306    /// No host pruning required; record this TLS posture.
307    Status(PolicyMode),
308    /// Enforce policy with partial coverage: keep only the hosts whose
309    /// index in the input is `true`.
310    Prune(Vec<bool>),
311    /// Enforce policy matches none of the hosts: the domain is undeliverable.
312    Impossible,
313}
314
315/// Evaluate an MTA-STS policy against `hosts` (in resolution order, lowercased,
316/// optionally `host:port`). Pure so it can be unit-tested without DNS/HTTP.
317fn 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
344/// Fetch and apply the MTA-STS policy for `name_fq` to the resolved MX set,
345/// updating `by_pref`/`hosts`/`expires` in place. Returns the resolved policy
346/// mode (when one applies), or `Err(message)` if the domain's enforce policy
347/// permits none of its MX hosts and is therefore undeliverable.
348async 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            // A transient fetch failure must not be treated as "impossible";
362            // proceed as no-policy but re-attempt sooner than the full DNS TTL.
363            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            // Partial coverage: prune the disallowed hosts so the site resolves
373            // to only the permitted set (and rolls up only with others sharing
374            // that set).
375            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    // Refresh holistically: re-resolve when either the MX records or the
403    // policy expire.
404    *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    /// Like [`resolve`](Self::resolve), but performs DNS via the supplied
414    /// `resolver` when one is provided. A supplied resolver bypasses the shared
415    /// MX cache so callers (such as tests using a fixture resolver) get
416    /// hermetic, order-independent results.
417    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        // A supplied resolver bypasses the shared MX cache so results are
456        // hermetic and order-independent.
457        if resolver.is_some() {
458            return Self::resolve_uncached(&name_fq, opt_port, domain_name, resolver)
459                .await?
460                .map_err(|err| anyhow::anyhow!("{err}"));
461        }
462
463        let lookup_result = MX_CACHE
464            .get_or_try_insert(
465                &(name_fq.clone(), opt_port),
466                |mx_result| {
467                    if let Ok(mx) = mx_result {
468                        if let Some(exp) = mx.expires {
469                            return exp
470                                .checked_duration_since(std::time::Instant::now())
471                                .unwrap_or_else(|| Duration::from_secs(10));
472                        }
473                    }
474                    get_mx_negative_ttl()
475                },
476                Self::resolve_uncached(&name_fq, opt_port, domain_name, None),
477            )
478            .await
479            .map_err(|err| anyhow::anyhow!("{err}"))?;
480
481        if !lookup_result.is_fresh {
482            MX_CACHED.inc();
483        }
484
485        lookup_result.item.map_err(|err| anyhow::anyhow!("{err}"))
486    }
487
488    async fn resolve_uncached(
489        name_fq: &Name,
490        opt_port: Option<u16>,
491        domain_name: &str,
492        resolver: Option<&dyn Resolver>,
493    ) -> anyhow::Result<Result<Arc<MailExchanger>, String>> {
494        MX_QUERIES.inc();
495        let start = Instant::now();
496        let (mut by_pref, mut expires) = match lookup_mx_record(name_fq, resolver).await {
497            Ok((by_pref, expires)) => (by_pref, expires),
498            Err(err) => {
499                let error = format!(
500                    "MX lookup for {domain_name} failed after {elapsed:?}: {err:#}",
501                    elapsed = start.elapsed()
502                );
503                return Ok(Err(error));
504            }
505        };
506
507        let mut hosts = vec![];
508        for pref in &mut by_pref {
509            for host in &mut pref.hosts {
510                if let Some(port) = opt_port {
511                    *host = format!("{host}:{port}");
512                };
513                hosts.push(host.to_string());
514            }
515        }
516
517        let is_secure = by_pref.iter().all(|p| p.is_secure);
518        let is_mx = by_pref.iter().all(|p| p.is_mx);
519
520        // Evaluate MTA-STS against this domain's own resolution, before
521        // site_name rollup. A domain whose enforce policy matches none of its
522        // MX hosts is undeliverable and fails resolution so that it
523        // self-isolates rather than affecting a shared site.
524        let mta_sts = if is_mx && is_mta_sts_enabled() {
525            match apply_mta_sts(name_fq, &mut by_pref, &mut hosts, &mut expires, resolver).await {
526                Ok(status) => status,
527                Err(error) => return Ok(Err(error)),
528            }
529        } else {
530            PolicyMode::None
531        };
532
533        let by_pref = by_pref
534            .into_iter()
535            .map(|pref| (pref.pref, pref.hosts))
536            .collect();
537
538        let site_name = factor_names(&hosts);
539        let mx = Self {
540            hosts,
541            domain_name: name_fq.to_ascii(),
542            site_name,
543            by_pref,
544            is_domain_literal: false,
545            is_secure,
546            is_mx,
547            mta_sts,
548            expires: Some(expires),
549        };
550
551        Ok(Ok(Arc::new(mx)))
552    }
553
554    pub fn has_expired(&self) -> bool {
555        match self.expires {
556            Some(deadline) => deadline <= Instant::now(),
557            None => false,
558        }
559    }
560
561    /// Returns the list of resolve MX hosts in *reverse* preference
562    /// order; the first one to try is the last element.
563    /// smtp_dispatcher.rs relies on this ordering, as it will pop
564    /// off candidates until it has exhausted its connection plan.
565    pub async fn resolve_addresses(
566        &self,
567        resolver: Option<&dyn Resolver>,
568        strategy: IpLookupStrategy,
569    ) -> ResolvedMxAddresses {
570        let mut result = vec![];
571
572        for hosts in self.by_pref.values().rev() {
573            let mut by_pref = vec![];
574
575            for mx_host in hosts {
576                // '.' is a null mx; skip trying to resolve it
577                if mx_host == "." {
578                    return ResolvedMxAddresses::NullMx;
579                }
580
581                // Handle the literal address case
582                let (mx_host, opt_port) = match has_colon_port(mx_host) {
583                    Some((domain_name, port)) => (domain_name, Some(port)),
584                    None => (mx_host.as_str(), None),
585                };
586                if let Ok(addr) = mx_host.parse::<IpAddr>() {
587                    let mut addr: HostOrSocketAddress = addr.into();
588                    if let Some(port) = opt_port {
589                        addr.set_port(port);
590                    }
591                    by_pref.push(ResolvedAddress {
592                        name: mx_host.to_string(),
593                        addr: addr.into(),
594                        is_secure: false,
595                    });
596                    continue;
597                }
598
599                match ip_lookup(mx_host, resolver, strategy).await {
600                    Err(err) => {
601                        tracing::error!("failed to resolve {mx_host}: {err:#}");
602                        continue;
603                    }
604                    Ok((result, _expires)) => {
605                        for addr in result.addrs.iter() {
606                            let mut addr: HostOrSocketAddress = (*addr).into();
607                            if let Some(port) = opt_port {
608                                addr.set_port(port);
609                            }
610                            by_pref.push(ResolvedAddress {
611                                name: mx_host.to_string(),
612                                addr,
613                                is_secure: result.secure,
614                            });
615                        }
616                    }
617                }
618            }
619
620            // Randomize the list of addresses within this preference
621            // level. This probablistically "load balances" outgoing
622            // traffic across MX hosts with equal preference value.
623            let mut rng = rand::thread_rng();
624            by_pref.shuffle(&mut rng);
625            result.append(&mut by_pref);
626        }
627        ResolvedMxAddresses::Addresses(result)
628    }
629}
630
631#[derive(Debug, Clone, Serialize)]
632pub enum ResolvedMxAddresses {
633    NullMx,
634    /// The list of addresses to which to connect, expressed
635    /// in LIFO order
636    Addresses(Vec<ResolvedAddress>),
637}
638
639#[cfg(test)]
640mod test {
641    use super::*;
642    use dns_resolver::TestResolver;
643
644    fn policy(mode: &str, mx: &[&str]) -> MtaStsPolicy {
645        let mut text = format!("version: STSv1\nmode: {mode}\nmax_age: 86400");
646        for m in mx {
647            text.push_str(&format!("\nmx: {m}"));
648        }
649        MtaStsPolicy::parse(&text).unwrap()
650    }
651
652    fn hosts(list: &[&str]) -> Vec<String> {
653        list.iter().map(|s| s.to_string()).collect()
654    }
655
656    #[test]
657    fn mta_sts_none_and_testing() {
658        assert_eq!(
659            evaluate_mta_sts(&hosts(&["mx01.mail.icloud.com."]), &policy("none", &[])),
660            StsEval::Status(PolicyMode::None)
661        );
662        assert_eq!(
663            evaluate_mta_sts(
664                &hosts(&["mx01.mail.icloud.com."]),
665                &policy("testing", &["*.mx.cloudflare.net"])
666            ),
667            StsEval::Status(PolicyMode::Testing)
668        );
669    }
670
671    #[test]
672    fn mta_sts_enforce_full_match() {
673        assert_eq!(
674            evaluate_mta_sts(
675                &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
676                &policy("enforce", &["*.mail.icloud.com"])
677            ),
678            StsEval::Status(PolicyMode::Enforce)
679        );
680    }
681
682    #[test]
683    fn mta_sts_enforce_partial_prunes() {
684        // Second host is not permitted; expect a prune mask, not failure.
685        assert_eq!(
686            evaluate_mta_sts(
687                &hosts(&["mx01.mail.icloud.com.", "backup.example.net."]),
688                &policy("enforce", &["*.mail.icloud.com"])
689            ),
690            StsEval::Prune(vec![true, false])
691        );
692    }
693
694    #[test]
695    fn mta_sts_enforce_impossible() {
696        // The icloud-hosted random domain whose policy only allows cloudflare:
697        // matches no host, so the domain is undeliverable.
698        assert_eq!(
699            evaluate_mta_sts(
700                &hosts(&["mx01.mail.icloud.com.", "mx02.mail.icloud.com."]),
701                &policy("enforce", &["*.mx.cloudflare.net"])
702            ),
703            StsEval::Impossible
704        );
705    }
706
707    #[test]
708    fn mta_sts_enforce_strips_port() {
709        assert_eq!(
710            evaluate_mta_sts(
711                &hosts(&["mx01.mail.icloud.com.:587"]),
712                &policy("enforce", &["*.mail.icloud.com"])
713            ),
714            StsEval::Status(PolicyMode::Enforce)
715        );
716    }
717
718    #[tokio::test]
719    async fn literal_resolve() {
720        let v4_loopback = MailExchanger::resolve("[127.0.0.1]").await.unwrap();
721        k9::snapshot!(
722            &v4_loopback,
723            r#"
724MailExchanger {
725    domain_name: "[127.0.0.1]",
726    hosts: [
727        "127.0.0.1",
728    ],
729    site_name: "127.0.0.1",
730    by_pref: {
731        1: [
732            "127.0.0.1",
733        ],
734    },
735    is_domain_literal: true,
736    is_secure: false,
737    is_mx: false,
738    mta_sts: None,
739    expires: None,
740}
741"#
742        );
743        k9::snapshot!(
744            v4_loopback
745                .resolve_addresses(None, IpLookupStrategy::default())
746                .await,
747            r#"
748Addresses(
749    [
750        ResolvedAddress {
751            name: "127.0.0.1",
752            addr: 127.0.0.1,
753            is_secure: false,
754        },
755    ],
756)
757"#
758        );
759
760        let v6_loopback_non_conforming = MailExchanger::resolve("[::1]").await.unwrap();
761        k9::snapshot!(
762            &v6_loopback_non_conforming,
763            r#"
764MailExchanger {
765    domain_name: "[::1]",
766    hosts: [
767        "::1",
768    ],
769    site_name: "::1",
770    by_pref: {
771        1: [
772            "::1",
773        ],
774    },
775    is_domain_literal: true,
776    is_secure: false,
777    is_mx: false,
778    mta_sts: None,
779    expires: None,
780}
781"#
782        );
783        k9::snapshot!(
784            v6_loopback_non_conforming
785                .resolve_addresses(None, IpLookupStrategy::default())
786                .await,
787            r#"
788Addresses(
789    [
790        ResolvedAddress {
791            name: "::1",
792            addr: ::1,
793            is_secure: false,
794        },
795    ],
796)
797"#
798        );
799
800        let v6_loopback = MailExchanger::resolve("[IPv6:::1]").await.unwrap();
801        k9::snapshot!(
802            &v6_loopback,
803            r#"
804MailExchanger {
805    domain_name: "[IPv6:::1]",
806    hosts: [
807        "::1",
808    ],
809    site_name: "::1",
810    by_pref: {
811        1: [
812            "::1",
813        ],
814    },
815    is_domain_literal: true,
816    is_secure: false,
817    is_mx: false,
818    mta_sts: None,
819    expires: None,
820}
821"#
822        );
823        k9::snapshot!(
824            v6_loopback
825                .resolve_addresses(None, IpLookupStrategy::default())
826                .await,
827            r#"
828Addresses(
829    [
830        ResolvedAddress {
831            name: "::1",
832            addr: ::1,
833            is_secure: false,
834        },
835    ],
836)
837"#
838        );
839    }
840
841    fn fixture_resolver(zones: &[&str]) -> TestResolver {
842        let mut resolver = TestResolver::default();
843        for zone in zones {
844            resolver = resolver.with_zone(zone).unwrap();
845        }
846        resolver
847    }
848
849    const GMAIL_ZONE: &str = r#"
850$ORIGIN gmail.com.
851@ 86400 MX 5 gmail-smtp-in.l.google.com.
852@ 86400 MX 10 alt1.gmail-smtp-in.l.google.com.
853@ 86400 MX 20 alt2.gmail-smtp-in.l.google.com.
854@ 86400 MX 30 alt3.gmail-smtp-in.l.google.com.
855@ 86400 MX 40 alt4.gmail-smtp-in.l.google.com.
856"#;
857
858    const GMAIL_HOSTS_ZONE: &str = r#"
859$ORIGIN l.google.com.
860gmail-smtp-in 300 A 142.251.2.26
861alt1.gmail-smtp-in 300 A 108.177.104.27
862alt2.gmail-smtp-in 300 A 74.125.126.27
863alt3.gmail-smtp-in 300 A 172.253.113.26
864alt4.gmail-smtp-in 300 A 173.194.77.27
865"#;
866
867    #[tokio::test]
868    async fn lookup_gmail_mx() {
869        let resolver = fixture_resolver(&[GMAIL_ZONE, GMAIL_HOSTS_ZONE]);
870        let mut gmail = (*MailExchanger::resolve_via("gmail.com", Some(&resolver))
871            .await
872            .unwrap())
873        .clone();
874        gmail.expires.take();
875        k9::snapshot!(
876            &gmail,
877            r#"
878MailExchanger {
879    domain_name: "gmail.com.",
880    hosts: [
881        "gmail-smtp-in.l.google.com.",
882        "alt1.gmail-smtp-in.l.google.com.",
883        "alt2.gmail-smtp-in.l.google.com.",
884        "alt3.gmail-smtp-in.l.google.com.",
885        "alt4.gmail-smtp-in.l.google.com.",
886    ],
887    site_name: "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com",
888    by_pref: {
889        5: [
890            "gmail-smtp-in.l.google.com.",
891        ],
892        10: [
893            "alt1.gmail-smtp-in.l.google.com.",
894        ],
895        20: [
896            "alt2.gmail-smtp-in.l.google.com.",
897        ],
898        30: [
899            "alt3.gmail-smtp-in.l.google.com.",
900        ],
901        40: [
902            "alt4.gmail-smtp-in.l.google.com.",
903        ],
904    },
905    is_domain_literal: false,
906    is_secure: false,
907    is_mx: true,
908    mta_sts: None,
909    expires: None,
910}
911"#
912        );
913
914        // The hosts are returned in reverse preference order (the last entry is
915        // tried first). With one address per host the per-preference-level
916        // shuffle in resolve_addresses is a no-op, so the order is stable.
917        k9::snapshot!(
918            gmail
919                .resolve_addresses(Some(&resolver), IpLookupStrategy::Ipv4Only)
920                .await,
921            r#"
922Addresses(
923    [
924        ResolvedAddress {
925            name: "alt4.gmail-smtp-in.l.google.com.",
926            addr: 173.194.77.27,
927            is_secure: false,
928        },
929        ResolvedAddress {
930            name: "alt3.gmail-smtp-in.l.google.com.",
931            addr: 172.253.113.26,
932            is_secure: false,
933        },
934        ResolvedAddress {
935            name: "alt2.gmail-smtp-in.l.google.com.",
936            addr: 74.125.126.27,
937            is_secure: false,
938        },
939        ResolvedAddress {
940            name: "alt1.gmail-smtp-in.l.google.com.",
941            addr: 108.177.104.27,
942            is_secure: false,
943        },
944        ResolvedAddress {
945            name: "gmail-smtp-in.l.google.com.",
946            addr: 142.251.2.26,
947            is_secure: false,
948        },
949    ],
950)
951"#
952        );
953    }
954
955    #[tokio::test]
956    async fn lookup_punycode_no_mx_only_a() {
957        let resolver = fixture_resolver(&[r#"
958$ORIGIN xn--bb-eka.at.
959@ 300 A 192.0.2.5
960"#]);
961        let mx = MailExchanger::resolve_via("xn--bb-eka.at", Some(&resolver))
962            .await
963            .unwrap();
964        assert_eq!(mx.domain_name, "xn--bb-eka.at.");
965        assert_eq!(mx.hosts[0], "xn--bb-eka.at.");
966    }
967
968    #[tokio::test]
969    async fn lookup_nxdomain() {
970        // The fixture has no zone covering this name, so the MX lookup is
971        // NXDOMAIN.
972        let resolver = fixture_resolver(&[]);
973        let name = fully_qualify("not-mairs.aasland.com").unwrap();
974        let err = match lookup_mx_record(&name, Some(&resolver)).await {
975            Ok(_) => panic!("expected NXDOMAIN"),
976            Err(err) => err,
977        };
978        k9::assert_equal!(err.to_string(), "NXDOMAIN");
979    }
980
981    #[tokio::test]
982    async fn lookup_null_mx() {
983        let resolver = fixture_resolver(&[r#"
984$ORIGIN example.com.
985@ 3600 MX 0 .
986"#]);
987        let mut mx = (*MailExchanger::resolve_via("example.com", Some(&resolver))
988            .await
989            .unwrap())
990        .clone();
991        mx.expires.take();
992        k9::snapshot!(
993            &mx,
994            r#"
995MailExchanger {
996    domain_name: "example.com.",
997    hosts: [
998        ".",
999    ],
1000    site_name: "",
1001    by_pref: {
1002        0: [
1003            ".",
1004        ],
1005    },
1006    is_domain_literal: false,
1007    is_secure: false,
1008    is_mx: true,
1009    mta_sts: None,
1010    expires: None,
1011}
1012"#
1013        );
1014    }
1015
1016    #[tokio::test]
1017    async fn lookup_single_mx() {
1018        let resolver = fixture_resolver(&[r#"
1019$ORIGIN do.havedane.net.
1020@ 300 MX 10 do.havedane.net.
1021"#]);
1022        let mut mx = (*MailExchanger::resolve_via("do.havedane.net", Some(&resolver))
1023            .await
1024            .unwrap())
1025        .clone();
1026        mx.expires.take();
1027        k9::snapshot!(
1028            &mx,
1029            r#"
1030MailExchanger {
1031    domain_name: "do.havedane.net.",
1032    hosts: [
1033        "do.havedane.net.",
1034    ],
1035    site_name: "do.havedane.net",
1036    by_pref: {
1037        10: [
1038            "do.havedane.net.",
1039        ],
1040    },
1041    is_domain_literal: false,
1042    is_secure: false,
1043    is_mx: true,
1044    mta_sts: None,
1045    expires: None,
1046}
1047"#
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn mx_lookup_no_mx_falls_back_to_a() {
1053        // The zone exists (so the lookup is not NXDOMAIN) but has no MX record
1054        // for www, so resolution falls back to the domain's own A record.
1055        let resolver = fixture_resolver(&[r#"
1056$ORIGIN example.com.
1057www 300 A 192.0.2.1
1058"#]);
1059        let mut mx = (*MailExchanger::resolve_via("www.example.com", Some(&resolver))
1060            .await
1061            .unwrap())
1062        .clone();
1063        mx.expires.take();
1064        k9::snapshot!(
1065            &mx,
1066            r#"
1067MailExchanger {
1068    domain_name: "www.example.com.",
1069    hosts: [
1070        "www.example.com.",
1071    ],
1072    site_name: "www.example.com",
1073    by_pref: {
1074        1: [
1075            "www.example.com.",
1076        ],
1077    },
1078    is_domain_literal: false,
1079    is_secure: false,
1080    is_mx: false,
1081    mta_sts: None,
1082    expires: None,
1083}
1084"#
1085        );
1086    }
1087
1088    #[test]
1089    fn name_factoring() {
1090        assert_eq!(
1091            factor_names(&[
1092                "mta5.am0.yahoodns.net",
1093                "mta6.am0.yahoodns.net",
1094                "mta7.am0.yahoodns.net"
1095            ]),
1096            "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1097        );
1098
1099        // Verify that the case is normalized to lowercase
1100        assert_eq!(
1101            factor_names(&[
1102                "mta5.AM0.yahoodns.net",
1103                "mta6.am0.yAHOodns.net",
1104                "mta7.am0.yahoodns.net"
1105            ]),
1106            "(mta5|mta6|mta7).am0.yahoodns.net".to_string()
1107        );
1108
1109        // When the names have mismatched lengths, do we produce
1110        // something reasonable?
1111        assert_eq!(
1112            factor_names(&[
1113                "gmail-smtp-in.l.google.com",
1114                "alt1.gmail-smtp-in.l.google.com",
1115                "alt2.gmail-smtp-in.l.google.com",
1116                "alt3.gmail-smtp-in.l.google.com",
1117                "alt4.gmail-smtp-in.l.google.com",
1118            ]),
1119            "(alt1|alt2|alt3|alt4)?.gmail-smtp-in.l.google.com".to_string()
1120        );
1121
1122        assert_eq!(
1123            factor_names(&[
1124                "mta5.am0.yahoodns.net:123",
1125                "mta6.am0.yahoodns.net:123",
1126                "mta7.am0.yahoodns.net:123"
1127            ]),
1128            "(mta5|mta6|mta7).am0.yahoodns.net:123".to_string()
1129        );
1130        assert_eq!(
1131            factor_names(&[
1132                "mta5.am0.yahoodns.net:123",
1133                "mta6.am0.yahoodns.net:456",
1134                "mta7.am0.yahoodns.net:123"
1135            ]),
1136            "(mta5|mta6|mta7).am0.yahoodns.(net:123|net:456)".to_string()
1137        );
1138    }
1139
1140    /// Verify that the order is preserved and that we treat these two
1141    /// examples of differently ordered sets of the same names as two
1142    /// separate site name strings
1143    #[test]
1144    fn mx_order_name_factor() {
1145        assert_eq!(
1146            factor_names(&[
1147                "example-com.mail.protection.outlook.com.",
1148                "mx-biz.mail.am0.yahoodns.net.",
1149                "mx-biz.mail.am0.yahoodns.net.",
1150            ]),
1151            "(example-com|mx-biz).mail.(protection|am0).(outlook|yahoodns).(com|net)".to_string()
1152        );
1153        assert_eq!(
1154            factor_names(&[
1155                "mx-biz.mail.am0.yahoodns.net.",
1156                "mx-biz.mail.am0.yahoodns.net.",
1157                "example-com.mail.protection.outlook.com.",
1158            ]),
1159            "(mx-biz|example-com).mail.(am0|protection).(yahoodns|outlook).(net|com)".to_string()
1160        );
1161    }
1162}