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
107pub struct CheckHostParams {
108    /// Domain that provides the sought-after authorization information.
109    ///
110    /// Initially, the domain portion of the "MAIL FROM" or "HELO" identity.
111    pub domain: String,
112
113    /// The "MAIL FROM" email address if available.
114    pub sender: Option<String>,
115
116    /// IP address of the SMTP client that is emitting the mail (v4 or v6).
117    pub client_ip: IpAddr,
118}
119
120impl CheckHostParams {
121    pub async fn check(self, resolver: &dyn Resolver) -> SpfResult {
122        let Self {
123            domain,
124            sender,
125            client_ip,
126        } = self;
127
128        let sender = match sender {
129            Some(sender) => sender,
130            None => format!("postmaster@{domain}"),
131        };
132
133        match SpfContext::new(&sender, &domain, client_ip) {
134            Ok(cx) => cx.check(resolver, true).await,
135            Err(result) => result,
136        }
137    }
138}
139
140struct SpfContext<'a> {
141    pub(crate) sender: &'a str,
142    pub(crate) local_part: &'a str,
143    pub(crate) sender_domain: &'a str,
144    pub(crate) domain: &'a str,
145    pub(crate) client_ip: IpAddr,
146    pub(crate) now: SystemTime,
147}
148
149impl<'a> SpfContext<'a> {
150    /// Create a new evaluation context.
151    ///
152    /// - `sender` is the "MAIL FROM" or "HELO" identity
153    /// - `domain` is the domain that provides the sought-after authorization information;
154    ///   initially, the domain portion of the "MAIL FROM" or "HELO" identity
155    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
156    fn new(sender: &'a str, domain: &'a str, client_ip: IpAddr) -> Result<Self, SpfResult> {
157        let Some((local_part, sender_domain)) = sender.split_once('@') else {
158            return Err(SpfResult {
159                disposition: SpfDisposition::PermError,
160                context:
161                    "input sender parameter '{sender}' is missing @ sign to delimit local part and domain".to_owned(),
162            });
163        };
164
165        Ok(Self {
166            sender,
167            local_part,
168            sender_domain,
169            domain,
170            client_ip,
171            now: SystemTime::now(),
172        })
173    }
174
175    pub(crate) fn with_domain(&self, domain: &'a str) -> Self {
176        Self { domain, ..*self }
177    }
178
179    pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult {
180        let name = match Name::from_utf8(self.domain) {
181            Ok(name) => name,
182            Err(_) => {
183                // Per <https://www.rfc-editor.org/rfc/rfc7208#section-4.3>, invalid
184                // domain names yield a "none" result during initial processing.
185                let context = format!("invalid domain name: {}", self.domain);
186                return if initial {
187                    SpfResult {
188                        disposition: SpfDisposition::None,
189                        context,
190                    }
191                } else {
192                    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) => {
202                if answer.records.is_empty() || answer.nxdomain {
203                    return SpfResult {
204                        disposition: SpfDisposition::None,
205                        context: if answer.records.is_empty() {
206                            format!("no SPF records found for {}", &self.domain)
207                        } else {
208                            format!("domain {} not found", &self.domain)
209                        },
210                    };
211                } else {
212                    answer.as_txt()
213                }
214            }
215            Err(err) => {
216                return SpfResult {
217                    disposition: match err {
218                        DnsError::InvalidName(_) => SpfDisposition::PermError,
219                        DnsError::ResolveFailed(_) => SpfDisposition::TempError,
220                    },
221                    context: format!("{err}"),
222                };
223            }
224        };
225
226        // TXT records can contain all sorts of stuff, let's walk through
227        // the set that we retrieved and take the first one that parses
228        for txt in initial_txt {
229            // a little bit of a layering violation: we need to know
230            // whether we had an SPF record candidate or not to be
231            // able to return an appropriate disposition if they have
232            // TXT records but no SPF records.
233            if txt.starts_with("v=spf1 ") {
234                match Record::parse(&txt) {
235                    Ok(record) => return record.evaluate(self, resolver).await,
236                    Err(err) => {
237                        return SpfResult {
238                            disposition: SpfDisposition::PermError,
239                            context: format!("failed to parse spf record: {err}"),
240                        };
241                    }
242                }
243            }
244        }
245        SpfResult {
246            disposition: SpfDisposition::None,
247            context: format!("no SPF records found for {}", &self.domain),
248        }
249    }
250
251    pub(crate) fn domain(&self, spec: Option<&MacroSpec>) -> Result<String, SpfResult> {
252        let Some(spec) = spec else {
253            return Ok(self.domain.to_owned());
254        };
255
256        spec.expand(self).map_err(|err| SpfResult {
257            disposition: SpfDisposition::TempError,
258            context: format!("error evaluating domain spec: {err}"),
259        })
260    }
261}