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
107#[derive(Debug, Deserialize)]
108pub struct CheckHostParams {
109 pub domain: String,
113
114 pub sender: Option<String>,
116
117 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 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 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 for txt in initial_txt {
227 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}