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