Skip to main content

mailrs_dns/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::fmt;
6use std::net::IpAddr;
7
8use async_trait::async_trait;
9
10/// Errors from a DNS lookup.
11///
12/// Implementors map NXDOMAIN to `Ok(Vec::new())` (not error) — "no
13/// record" is a normal answer in email-auth flows. Reserve
14/// [`DnsError::Temp`] for transient failures (timeout, SERVFAIL) and
15/// [`DnsError::Perm`] for actual protocol/decode failures.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum DnsError {
18    /// Transient: timeout, SERVFAIL, network glitch. Caller should retry.
19    Temp(String),
20    /// Permanent: malformed response, refused, configuration error.
21    Perm(String),
22}
23
24impl fmt::Display for DnsError {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            DnsError::Temp(s) => write!(f, "dns temp error: {s}"),
28            DnsError::Perm(s) => write!(f, "dns perm error: {s}"),
29        }
30    }
31}
32
33impl std::error::Error for DnsError {}
34
35/// The five DNS query types email infrastructure actually uses.
36///
37/// All async fns return `Ok(Vec::new())` for NXDOMAIN (consistent
38/// across implementors). `Err(_)` is reserved for actual lookup
39/// failures.
40#[async_trait]
41pub trait DnsResolver: Send + Sync {
42    /// TXT records (DKIM public keys, SPF policies, DMARC, …).
43    async fn lookup_txt(&self, domain: &str) -> Result<Vec<String>, DnsError>;
44    /// IPv4 A records.
45    async fn lookup_a(&self, domain: &str) -> Result<Vec<IpAddr>, DnsError>;
46    /// IPv6 AAAA records.
47    async fn lookup_aaaa(&self, domain: &str) -> Result<Vec<IpAddr>, DnsError>;
48    /// MX records, returned as `(preference, exchange-hostname)` pairs.
49    async fn lookup_mx(&self, domain: &str) -> Result<Vec<(u16, String)>, DnsError>;
50    /// PTR records (reverse DNS).
51    async fn lookup_ptr(&self, ip: IpAddr) -> Result<Vec<String>, DnsError>;
52}
53
54/// Ready-made [`DnsResolver`] over `hickory_resolver::TokioResolver`.
55/// Enabled by the default `hickory` feature.
56#[cfg(feature = "hickory")]
57pub mod hickory {
58    use super::*;
59    use hickory_resolver::proto::rr::RData;
60    use hickory_resolver::TokioResolver;
61
62    /// Wrap a `TokioResolver` for use as a [`DnsResolver`].
63    pub struct HickoryResolver {
64        inner: TokioResolver,
65    }
66
67    impl HickoryResolver {
68        /// Construct from an existing `TokioResolver`.
69        pub fn new(resolver: TokioResolver) -> Self {
70            Self { inner: resolver }
71        }
72    }
73
74    /// hickory signals "no records found" via its error message;
75    /// the safest cross-version check is the error text. We treat
76    /// no-records as `Ok(empty)` rather than an error.
77    fn is_no_records<E: std::fmt::Display>(e: &E) -> bool {
78        let s = e.to_string();
79        s.contains("no record")
80            || s.contains("NXDOMAIN")
81            || s.contains("no records found")
82            || s.contains("NoRecordsFound")
83    }
84
85    #[async_trait]
86    impl DnsResolver for HickoryResolver {
87        async fn lookup_txt(&self, domain: &str) -> Result<Vec<String>, DnsError> {
88            match self.inner.txt_lookup(domain).await {
89                Ok(resp) => {
90                    let mut out = Vec::new();
91                    for record in resp.answers() {
92                        if let RData::TXT(txt) = &record.data {
93                            out.push(txt.to_string());
94                        }
95                    }
96                    Ok(out)
97                }
98                Err(e) if is_no_records(&e) => Ok(Vec::new()),
99                Err(e) => Err(DnsError::Temp(e.to_string())),
100            }
101        }
102
103        async fn lookup_a(&self, domain: &str) -> Result<Vec<IpAddr>, DnsError> {
104            match self.inner.ipv4_lookup(domain).await {
105                Ok(resp) => {
106                    let mut out = Vec::new();
107                    for record in resp.answers() {
108                        if let RData::A(a) = &record.data {
109                            out.push(IpAddr::V4(a.0));
110                        }
111                    }
112                    Ok(out)
113                }
114                Err(e) if is_no_records(&e) => Ok(Vec::new()),
115                Err(e) => Err(DnsError::Temp(e.to_string())),
116            }
117        }
118
119        async fn lookup_aaaa(&self, domain: &str) -> Result<Vec<IpAddr>, DnsError> {
120            match self.inner.ipv6_lookup(domain).await {
121                Ok(resp) => {
122                    let mut out = Vec::new();
123                    for record in resp.answers() {
124                        if let RData::AAAA(a) = &record.data {
125                            out.push(IpAddr::V6(a.0));
126                        }
127                    }
128                    Ok(out)
129                }
130                Err(e) if is_no_records(&e) => Ok(Vec::new()),
131                Err(e) => Err(DnsError::Temp(e.to_string())),
132            }
133        }
134
135        async fn lookup_mx(&self, domain: &str) -> Result<Vec<(u16, String)>, DnsError> {
136            match self.inner.mx_lookup(domain).await {
137                Ok(resp) => {
138                    let mut out = Vec::new();
139                    for record in resp.answers() {
140                        if let RData::MX(mx) = &record.data {
141                            out.push((mx.preference, mx.exchange.to_utf8()));
142                        }
143                    }
144                    Ok(out)
145                }
146                Err(e) if is_no_records(&e) => Ok(Vec::new()),
147                Err(e) => Err(DnsError::Temp(e.to_string())),
148            }
149        }
150
151        async fn lookup_ptr(&self, ip: IpAddr) -> Result<Vec<String>, DnsError> {
152            match self.inner.reverse_lookup(ip).await {
153                Ok(resp) => {
154                    let mut out = Vec::new();
155                    for record in resp.answers() {
156                        if let RData::PTR(ptr) = &record.data {
157                            out.push(ptr.to_utf8());
158                        }
159                    }
160                    Ok(out)
161                }
162                Err(e) if is_no_records(&e) => Ok(Vec::new()),
163                Err(e) => Err(DnsError::Temp(e.to_string())),
164            }
165        }
166    }
167}
168
169#[cfg(feature = "hickory")]
170pub use crate::hickory::HickoryResolver;
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn dns_error_display_includes_context() {
178        let e = DnsError::Temp("connection refused".into());
179        let s = format!("{e}");
180        assert!(s.contains("connection refused"));
181        assert!(s.contains("temp"));
182    }
183
184    #[test]
185    fn dns_error_eq_works() {
186        assert_eq!(
187            DnsError::Perm("nxdomain".into()),
188            DnsError::Perm("nxdomain".into())
189        );
190        assert_ne!(
191            DnsError::Temp("x".into()),
192            DnsError::Perm("x".into())
193        );
194    }
195}