Skip to main content

use_spf/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when SPF metadata is invalid.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum SpfError {
10    /// The supplied value was empty.
11    Empty,
12    /// The supplied label was not recognized.
13    UnknownLabel,
14    /// The supplied term was invalid.
15    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/// SPF record version marker.
31#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub enum SpfVersion {
33    /// SPF version 1.
34    #[default]
35    V1,
36}
37
38impl SpfVersion {
39    /// Returns the record version label.
40    #[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/// SPF mechanism qualifier.
65#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
66pub enum SpfQualifier {
67    /// Positive pass qualifier. This is omitted when rendered.
68    #[default]
69    Pass,
70    /// Fail qualifier.
71    Fail,
72    /// Soft-fail qualifier.
73    SoftFail,
74    /// Neutral qualifier.
75    Neutral,
76}
77
78impl SpfQualifier {
79    /// Returns the SPF qualifier prefix.
80    #[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/// SPF mechanism metadata.
98#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub enum SpfMechanism {
100    /// `all` mechanism.
101    All,
102    /// `include:<domain>` mechanism.
103    Include(String),
104    /// `a` mechanism.
105    A,
106    /// `mx` mechanism.
107    Mx,
108    /// `ip4:<cidr>` mechanism.
109    Ip4(String),
110    /// `ip6:<cidr>` mechanism.
111    Ip6(String),
112    /// `exists:<domain>` mechanism.
113    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/// SPF modifier metadata.
149#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
150pub struct SpfModifier {
151    name: String,
152    value: String,
153}
154
155impl SpfModifier {
156    /// Creates an SPF modifier.
157    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    /// Returns the modifier name.
170    #[must_use]
171    pub fn name(&self) -> &str {
172        &self.name
173    }
174
175    /// Returns the modifier value.
176    #[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/// One SPF term with qualifier and mechanism.
189#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
190pub struct SpfTerm {
191    qualifier: SpfQualifier,
192    mechanism: SpfMechanism,
193}
194
195impl SpfTerm {
196    /// Creates an SPF term.
197    #[must_use]
198    pub const fn new(qualifier: SpfQualifier, mechanism: SpfMechanism) -> Self {
199        Self {
200            qualifier,
201            mechanism,
202        }
203    }
204
205    /// Returns the qualifier.
206    #[must_use]
207    pub const fn qualifier(&self) -> SpfQualifier {
208        self.qualifier
209    }
210
211    /// Returns the mechanism.
212    #[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/// SPF record metadata.
241#[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    /// Creates an empty SPF v1 record.
250    #[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    /// Adds a term and returns the updated record.
260    #[must_use]
261    pub fn with_term(mut self, term: SpfTerm) -> Self {
262        self.terms.push(term);
263        self
264    }
265
266    /// Adds a modifier and returns the updated record.
267    #[must_use]
268    pub fn with_modifier(mut self, modifier: SpfModifier) -> Self {
269        self.modifiers.push(modifier);
270        self
271    }
272
273    /// Returns the version.
274    #[must_use]
275    pub const fn version(&self) -> SpfVersion {
276        self.version
277    }
278
279    /// Returns terms.
280    #[must_use]
281    pub fn terms(&self) -> &[SpfTerm] {
282        &self.terms
283    }
284
285    /// Returns modifiers.
286    #[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/// Possible SPF result labels.
306#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
307pub enum SpfResult {
308    /// SPF pass.
309    Pass,
310    /// SPF fail.
311    Fail,
312    /// SPF soft fail.
313    SoftFail,
314    /// SPF neutral.
315    Neutral,
316    /// No SPF policy was available.
317    None,
318    /// Temporary error.
319    TempError,
320    /// Permanent error.
321    PermError,
322}
323
324impl SpfResult {
325    /// Returns the result label.
326    #[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}