1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum SpfError {
10 Empty,
12 UnknownLabel,
14 InvalidTerm,
16}
17
18impl fmt::Display for SpfError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("SPF value cannot be empty"),
22 Self::UnknownLabel => formatter.write_str("unknown SPF label"),
23 Self::InvalidTerm => formatter.write_str("invalid SPF term"),
24 }
25 }
26}
27
28impl Error for SpfError {}
29
30#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub enum SpfVersion {
33 #[default]
35 V1,
36}
37
38impl SpfVersion {
39 #[must_use]
41 pub const fn as_str(self) -> &'static str {
42 "v=spf1"
43 }
44}
45
46impl fmt::Display for SpfVersion {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 formatter.write_str(self.as_str())
49 }
50}
51
52impl FromStr for SpfVersion {
53 type Err = SpfError;
54
55 fn from_str(value: &str) -> Result<Self, Self::Err> {
56 match value.trim().to_ascii_lowercase().as_str() {
57 "v=spf1" | "spf1" => Ok(Self::V1),
58 "" => Err(SpfError::Empty),
59 _ => Err(SpfError::UnknownLabel),
60 }
61 }
62}
63
64#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
66pub enum SpfQualifier {
67 #[default]
69 Pass,
70 Fail,
72 SoftFail,
74 Neutral,
76}
77
78impl SpfQualifier {
79 #[must_use]
81 pub const fn as_prefix(self) -> &'static str {
82 match self {
83 Self::Pass => "",
84 Self::Fail => "-",
85 Self::SoftFail => "~",
86 Self::Neutral => "?",
87 }
88 }
89}
90
91impl fmt::Display for SpfQualifier {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 formatter.write_str(self.as_prefix())
94 }
95}
96
97#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub enum SpfMechanism {
100 All,
102 Include(String),
104 A,
106 Mx,
108 Ip4(String),
110 Ip6(String),
112 Exists(String),
114}
115
116impl fmt::Display for SpfMechanism {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::All => formatter.write_str("all"),
120 Self::Include(domain) => write!(formatter, "include:{domain}"),
121 Self::A => formatter.write_str("a"),
122 Self::Mx => formatter.write_str("mx"),
123 Self::Ip4(cidr) => write!(formatter, "ip4:{cidr}"),
124 Self::Ip6(cidr) => write!(formatter, "ip6:{cidr}"),
125 Self::Exists(domain) => write!(formatter, "exists:{domain}"),
126 }
127 }
128}
129
130impl FromStr for SpfMechanism {
131 type Err = SpfError;
132
133 fn from_str(value: &str) -> Result<Self, Self::Err> {
134 let trimmed = validate_spf_text(value)?;
135 match trimmed.to_ascii_lowercase().as_str() {
136 "all" => Ok(Self::All),
137 "a" => Ok(Self::A),
138 "mx" => Ok(Self::Mx),
139 _ if trimmed.starts_with("include:") => Ok(Self::Include(trimmed[8..].to_owned())),
140 _ if trimmed.starts_with("ip4:") => Ok(Self::Ip4(trimmed[4..].to_owned())),
141 _ if trimmed.starts_with("ip6:") => Ok(Self::Ip6(trimmed[4..].to_owned())),
142 _ if trimmed.starts_with("exists:") => Ok(Self::Exists(trimmed[7..].to_owned())),
143 _ => Err(SpfError::UnknownLabel),
144 }
145 }
146}
147
148#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
150pub struct SpfModifier {
151 name: String,
152 value: String,
153}
154
155impl SpfModifier {
156 pub fn new(name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self, SpfError> {
158 let name = validate_spf_text(name.as_ref())?;
159 let value = validate_spf_text(value.as_ref())?;
160 if name.contains('=') {
161 return Err(SpfError::InvalidTerm);
162 }
163 Ok(Self {
164 name: name.to_owned(),
165 value: value.to_owned(),
166 })
167 }
168
169 #[must_use]
171 pub fn name(&self) -> &str {
172 &self.name
173 }
174
175 #[must_use]
177 pub fn value(&self) -> &str {
178 &self.value
179 }
180}
181
182impl fmt::Display for SpfModifier {
183 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184 write!(formatter, "{}={}", self.name, self.value)
185 }
186}
187
188#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
190pub struct SpfTerm {
191 qualifier: SpfQualifier,
192 mechanism: SpfMechanism,
193}
194
195impl SpfTerm {
196 #[must_use]
198 pub const fn new(qualifier: SpfQualifier, mechanism: SpfMechanism) -> Self {
199 Self {
200 qualifier,
201 mechanism,
202 }
203 }
204
205 #[must_use]
207 pub const fn qualifier(&self) -> SpfQualifier {
208 self.qualifier
209 }
210
211 #[must_use]
213 pub const fn mechanism(&self) -> &SpfMechanism {
214 &self.mechanism
215 }
216}
217
218impl fmt::Display for SpfTerm {
219 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220 write!(formatter, "{}{}", self.qualifier, self.mechanism)
221 }
222}
223
224impl FromStr for SpfTerm {
225 type Err = SpfError;
226
227 fn from_str(value: &str) -> Result<Self, Self::Err> {
228 let trimmed = validate_spf_text(value)?;
229 let (qualifier, mechanism_text) = match trimmed.as_bytes().first() {
230 Some(b'-') => (SpfQualifier::Fail, &trimmed[1..]),
231 Some(b'~') => (SpfQualifier::SoftFail, &trimmed[1..]),
232 Some(b'?') => (SpfQualifier::Neutral, &trimmed[1..]),
233 Some(b'+') => (SpfQualifier::Pass, &trimmed[1..]),
234 _ => (SpfQualifier::Pass, trimmed),
235 };
236 Ok(Self::new(qualifier, mechanism_text.parse()?))
237 }
238}
239
240#[derive(Clone, Debug, Default, Eq, PartialEq)]
242pub struct SpfRecord {
243 version: SpfVersion,
244 terms: Vec<SpfTerm>,
245 modifiers: Vec<SpfModifier>,
246}
247
248impl SpfRecord {
249 #[must_use]
251 pub const fn new() -> Self {
252 Self {
253 version: SpfVersion::V1,
254 terms: Vec::new(),
255 modifiers: Vec::new(),
256 }
257 }
258
259 #[must_use]
261 pub fn with_term(mut self, term: SpfTerm) -> Self {
262 self.terms.push(term);
263 self
264 }
265
266 #[must_use]
268 pub fn with_modifier(mut self, modifier: SpfModifier) -> Self {
269 self.modifiers.push(modifier);
270 self
271 }
272
273 #[must_use]
275 pub const fn version(&self) -> SpfVersion {
276 self.version
277 }
278
279 #[must_use]
281 pub fn terms(&self) -> &[SpfTerm] {
282 &self.terms
283 }
284
285 #[must_use]
287 pub fn modifiers(&self) -> &[SpfModifier] {
288 &self.modifiers
289 }
290}
291
292impl fmt::Display for SpfRecord {
293 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
294 write!(formatter, "{}", self.version)?;
295 for term in &self.terms {
296 write!(formatter, " {term}")?;
297 }
298 for modifier in &self.modifiers {
299 write!(formatter, " {modifier}")?;
300 }
301 Ok(())
302 }
303}
304
305#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
307pub enum SpfResult {
308 Pass,
310 Fail,
312 SoftFail,
314 Neutral,
316 None,
318 TempError,
320 PermError,
322}
323
324impl SpfResult {
325 #[must_use]
327 pub const fn as_str(self) -> &'static str {
328 match self {
329 Self::Pass => "pass",
330 Self::Fail => "fail",
331 Self::SoftFail => "softfail",
332 Self::Neutral => "neutral",
333 Self::None => "none",
334 Self::TempError => "temperror",
335 Self::PermError => "permerror",
336 }
337 }
338}
339
340impl fmt::Display for SpfResult {
341 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
342 formatter.write_str(self.as_str())
343 }
344}
345
346fn validate_spf_text(value: &str) -> Result<&str, SpfError> {
347 let trimmed = value.trim();
348 if trimmed.is_empty() {
349 return Err(SpfError::Empty);
350 }
351 if trimmed.chars().any(char::is_whitespace) {
352 return Err(SpfError::InvalidTerm);
353 }
354 Ok(trimmed)
355}
356
357#[cfg(test)]
358mod tests {
359 use super::{SpfMechanism, SpfQualifier, SpfRecord, SpfTerm};
360
361 #[test]
362 fn renders_spf_records() {
363 let record = SpfRecord::new()
364 .with_term(SpfTerm::new(SpfQualifier::Pass, SpfMechanism::Mx))
365 .with_term(SpfTerm::new(SpfQualifier::Fail, SpfMechanism::All));
366
367 assert_eq!(record.to_string(), "v=spf1 mx -all");
368 }
369
370 #[test]
371 fn parses_spf_terms() -> Result<(), super::SpfError> {
372 let term: SpfTerm = "~include:example.com".parse()?;
373
374 assert_eq!(term.qualifier(), SpfQualifier::SoftFail);
375 assert_eq!(term.to_string(), "~include:example.com");
376 Ok(())
377 }
378}