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 None,
26
27 Neutral,
30
31 Pass,
34
35 Fail,
38
39 SoftFail,
43
44 TempError,
48
49 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 pub domain: String,
112
113 pub sender: Option<String>,
115
116 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 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 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 for txt in initial_txt {
229 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}