kumo_spf/
lib.rs

1use crate::record::Record;
2use crate::spec::MacroSpec;
3use dns_resolver::{DnsError, Resolver};
4use hickory_resolver::proto::rr::RecordType;
5use hickory_resolver::Name;
6use instant_xml::{FromXml, ToXml};
7use serde::{Deserialize, Serialize, Serializer};
8use std::fmt;
9use std::net::IpAddr;
10use std::time::SystemTime;
11
12pub mod record;
13mod spec;
14use record::Qualifier;
15#[cfg(test)]
16mod tests;
17
18#[derive(Debug, Clone, Copy, Eq, FromXml, PartialEq, ToXml)]
19#[xml(scalar, rename_all = "lowercase")]
20pub enum SpfDisposition {
21    /// A result of "none" means either (a) no syntactically valid DNS domain
22    /// name was extracted from the SMTP session that could be used as the
23    /// one to be authorized, or (b) no SPF records were retrieved from
24    /// the DNS.
25    None,
26
27    /// A "neutral" result means the ADMD has explicitly stated that it is
28    /// not asserting whether the IP address is authorized.
29    Neutral,
30
31    /// A "pass" result is an explicit statement that the client is
32    /// authorized to inject mail with the given identity.
33    Pass,
34
35    /// A "fail" result is an explicit statement that the client is not
36    /// authorized to use the domain in the given identity.
37    Fail,
38
39    /// A "softfail" result is a weak statement by the publishing ADMD that
40    /// the host is probably not authorized.  It has not published a
41    /// stronger, more definitive policy that results in a "fail".
42    SoftFail,
43
44    /// A "temperror" result means the SPF verifier encountered a transient
45    /// (generally DNS) error while performing the check.  A later retry may
46    /// succeed without further DNS operator action.
47    TempError,
48
49    /// A "permerror" result means the domain's published records could not
50    /// be correctly interpreted.  This signals an error condition that
51    /// definitely requires DNS operator intervention to be resolved.
52    PermError,
53}
54
55impl SpfDisposition {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::None => "none",
59            Self::Neutral => "neutral",
60            Self::Pass => "pass",
61            Self::Fail => "fail",
62            Self::SoftFail => "softfail",
63            Self::TempError => "temperror",
64            Self::PermError => "permerror",
65        }
66    }
67}
68
69impl Serialize for SpfDisposition {
70    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
71        serializer.serialize_str(self.as_str())
72    }
73}
74
75impl From<Qualifier> for SpfDisposition {
76    fn from(qualifier: Qualifier) -> Self {
77        match qualifier {
78            Qualifier::Pass => Self::Pass,
79            Qualifier::Fail => Self::Fail,
80            Qualifier::SoftFail => Self::SoftFail,
81            Qualifier::Neutral => Self::Neutral,
82        }
83    }
84}
85
86impl fmt::Display for SpfDisposition {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(f, "{}", self.as_str())
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SpfResult {
94    pub disposition: SpfDisposition,
95    pub context: String,
96}
97
98impl SpfResult {
99    fn fail(context: String) -> Self {
100        Self {
101            disposition: SpfDisposition::Fail,
102            context,
103        }
104    }
105}
106
107#[derive(Debug, Deserialize)]
108pub struct CheckHostParams {
109    /// Domain that provides the sought-after authorization information.
110    ///
111    /// Initially, the domain portion of the "MAIL FROM" or "HELO" identity.
112    pub domain: String,
113
114    /// The "MAIL FROM" email address if available.
115    pub sender: Option<String>,
116
117    /// IP address of the SMTP client that is emitting the mail (v4 or v6).
118    pub client_ip: IpAddr,
119}
120
121impl CheckHostParams {
122    pub async fn check(self, resolver: &dyn Resolver) -> SpfResult {
123        let Self {
124            domain,
125            sender,
126            client_ip,
127        } = self;
128
129        let sender = match sender {
130            Some(sender) => sender,
131            None => format!("postmaster@{domain}"),
132        };
133
134        match SpfContext::new(&sender, &domain, client_ip) {
135            Ok(cx) => cx.check(resolver, true).await,
136            Err(result) => result,
137        }
138    }
139}
140
141struct SpfContext<'a> {
142    pub(crate) sender: &'a str,
143    pub(crate) local_part: &'a str,
144    pub(crate) sender_domain: &'a str,
145    pub(crate) domain: &'a str,
146    pub(crate) client_ip: IpAddr,
147    pub(crate) now: SystemTime,
148}
149
150impl<'a> SpfContext<'a> {
151    /// Create a new evaluation context.
152    ///
153    /// - `sender` is the "MAIL FROM" or "HELO" identity
154    /// - `domain` is the domain that provides the sought-after authorization information;
155    ///   initially, the domain portion of the "MAIL FROM" or "HELO" identity
156    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
157    fn new(sender: &'a str, domain: &'a str, client_ip: IpAddr) -> Result<Self, SpfResult> {
158        let Some((local_part, sender_domain)) = sender.split_once('@') else {
159            return Err(SpfResult {
160                disposition: SpfDisposition::PermError,
161                context:
162                    "input sender parameter '{sender}' is missing @ sign to delimit local part and domain".to_owned(),
163            });
164        };
165
166        Ok(Self {
167            sender,
168            local_part,
169            sender_domain,
170            domain,
171            client_ip,
172            now: SystemTime::now(),
173        })
174    }
175
176    pub(crate) fn with_domain(&self, domain: &'a str) -> Self {
177        Self { domain, ..*self }
178    }
179
180    pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult {
181        let name = match Name::from_utf8(self.domain) {
182            Ok(name) => name,
183            Err(_) => {
184                // Per <https://www.rfc-editor.org/rfc/rfc7208#section-4.3>, invalid
185                // domain names yield a "none" result during initial processing.
186                let context = format!("invalid domain name: {}", self.domain);
187                return match initial {
188                    true => SpfResult {
189                        disposition: SpfDisposition::None,
190                        context,
191                    },
192                    false => SpfResult {
193                        disposition: SpfDisposition::TempError,
194                        context,
195                    },
196                };
197            }
198        };
199
200        let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
201            Ok(answer) => match answer.records.is_empty() || answer.nxdomain {
202                true => {
203                    return SpfResult {
204                        disposition: SpfDisposition::None,
205                        context: match answer.records.is_empty() {
206                            true => format!("no SPF records found for {}", &self.domain),
207                            false => format!("domain {} not found", &self.domain),
208                        },
209                    }
210                }
211                false => answer.as_txt(),
212            },
213            Err(err) => {
214                return SpfResult {
215                    disposition: match err {
216                        DnsError::InvalidName(_) => SpfDisposition::PermError,
217                        DnsError::ResolveFailed(_) => SpfDisposition::TempError,
218                    },
219                    context: format!("{err}"),
220                };
221            }
222        };
223
224        // TXT records can contain all sorts of stuff, let's walk through
225        // the set that we retrieved and take the first one that parses
226        for txt in initial_txt {
227            // a little bit of a layering violation: we need to know
228            // whether we had an SPF record candidate or not to be
229            // able to return an appropriate disposition if they have
230            // TXT records but no SPF records.
231            if txt.starts_with("v=spf1 ") {
232                match Record::parse(&txt) {
233                    Ok(record) => return record.evaluate(self, resolver).await,
234                    Err(err) => {
235                        return SpfResult {
236                            disposition: SpfDisposition::PermError,
237                            context: format!("failed to parse spf record: {err}"),
238                        };
239                    }
240                }
241            }
242        }
243        SpfResult {
244            disposition: SpfDisposition::None,
245            context: format!("no SPF records found for {}", &self.domain),
246        }
247    }
248
249    pub(crate) fn domain(&self, spec: Option<&MacroSpec>) -> Result<String, SpfResult> {
250        let Some(spec) = spec else {
251            return Ok(self.domain.to_owned());
252        };
253
254        spec.expand(self).map_err(|err| SpfResult {
255            disposition: SpfDisposition::TempError,
256            context: format!("error evaluating domain spec: {err}"),
257        })
258    }
259}