Skip to main content

mailrs_spf/
error.rs

1//! Error + result types per RFC 7208 §2.6.
2
3use std::fmt;
4
5/// SPF verification outcome (RFC 7208 §2.6).
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum SpfResult {
8    /// No SPF record at the domain — `Result.None`.
9    None,
10    /// Mechanism produced an explicit `+` match.
11    Pass,
12    /// Hard fail (`-` qualifier).
13    Fail,
14    /// Soft fail (`~` qualifier) — accept but mark suspicious.
15    SoftFail,
16    /// Neutral (`?` qualifier) — no policy statement.
17    Neutral,
18    /// Permanent error: malformed SPF record or per-record limit
19    /// (10 DNS lookups, max recursion, etc.) — never going to work,
20    /// reject or quarantine.
21    PermError,
22    /// Temporary error: DNS lookup failure (SERVFAIL, timeout) —
23    /// retry later.
24    TempError,
25}
26
27impl SpfResult {
28    /// Lowercase wire form per RFC 7001 §2.7.2 (used in
29    /// `Authentication-Results: spf=...` headers).
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            SpfResult::None => "none",
33            SpfResult::Pass => "pass",
34            SpfResult::Fail => "fail",
35            SpfResult::SoftFail => "softfail",
36            SpfResult::Neutral => "neutral",
37            SpfResult::PermError => "permerror",
38            SpfResult::TempError => "temperror",
39        }
40    }
41}
42
43impl fmt::Display for SpfResult {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49/// Internal error category from the evaluator / parser.
50///
51/// These are NOT the public verification result — that's [`SpfResult`].
52/// `SpfError` is for things that go wrong *inside* the verifier
53/// (DNS lookup failed, record malformed, limits hit). Most callers
54/// just want [`SpfResult`] and don't need to distinguish further.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum SpfError {
57    /// DNS lookup failed transiently (timeout, SERVFAIL, ...).
58    DnsTempError(String),
59    /// DNS lookup failed permanently (NXDOMAIN, etc.) — usually
60    /// the absence of a TXT record is normal and yields
61    /// [`SpfResult::None`], not this.
62    DnsPermError(String),
63    /// SPF record TXT string couldn't be parsed.
64    InvalidRecord(String),
65    /// Exceeded 10 DNS lookups per RFC 7208 §4.6.4.
66    TooManyLookups,
67    /// Recursion in `include:` chains exceeded sane depth.
68    TooMuchRecursion,
69    /// Multiple `v=spf1` TXT records found for the same domain
70    /// (RFC 7208 §4.5). Per spec → permerror.
71    MultipleRecords,
72}
73
74impl fmt::Display for SpfError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            SpfError::DnsTempError(s) => write!(f, "DNS temporary error: {s}"),
78            SpfError::DnsPermError(s) => write!(f, "DNS permanent error: {s}"),
79            SpfError::InvalidRecord(s) => write!(f, "invalid SPF record: {s}"),
80            SpfError::TooManyLookups => f.write_str("too many DNS lookups (>10)"),
81            SpfError::TooMuchRecursion => f.write_str("too much include: recursion"),
82            SpfError::MultipleRecords => f.write_str("multiple v=spf1 records at domain"),
83        }
84    }
85}
86
87impl std::error::Error for SpfError {}
88
89impl SpfError {
90    /// Map an error to the appropriate public `SpfResult` per
91    /// RFC 7208 §2.6.5 / §2.6.6.
92    pub fn to_result(&self) -> SpfResult {
93        match self {
94            SpfError::DnsTempError(_) => SpfResult::TempError,
95            SpfError::DnsPermError(_)
96            | SpfError::InvalidRecord(_)
97            | SpfError::TooManyLookups
98            | SpfError::TooMuchRecursion
99            | SpfError::MultipleRecords => SpfResult::PermError,
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn spf_result_as_str_matches_rfc_7001() {
110        assert_eq!(SpfResult::None.as_str(), "none");
111        assert_eq!(SpfResult::Pass.as_str(), "pass");
112        assert_eq!(SpfResult::Fail.as_str(), "fail");
113        assert_eq!(SpfResult::SoftFail.as_str(), "softfail");
114        assert_eq!(SpfResult::Neutral.as_str(), "neutral");
115        assert_eq!(SpfResult::PermError.as_str(), "permerror");
116        assert_eq!(SpfResult::TempError.as_str(), "temperror");
117    }
118
119    #[test]
120    fn spf_result_display_matches_as_str() {
121        assert_eq!(format!("{}", SpfResult::Pass), "pass");
122        assert_eq!(format!("{}", SpfResult::SoftFail), "softfail");
123    }
124
125    #[test]
126    fn spf_error_to_result_classification() {
127        assert_eq!(
128            SpfError::DnsTempError("timeout".into()).to_result(),
129            SpfResult::TempError
130        );
131        assert_eq!(
132            SpfError::DnsPermError("nxdomain".into()).to_result(),
133            SpfResult::PermError
134        );
135        assert_eq!(
136            SpfError::InvalidRecord("bad mechanism".into()).to_result(),
137            SpfResult::PermError
138        );
139        assert_eq!(SpfError::TooManyLookups.to_result(), SpfResult::PermError);
140        assert_eq!(SpfError::TooMuchRecursion.to_result(), SpfResult::PermError);
141        assert_eq!(SpfError::MultipleRecords.to_result(), SpfResult::PermError);
142    }
143
144    #[test]
145    fn spf_error_display_includes_context() {
146        let e = SpfError::DnsTempError("connection refused".into());
147        let s = format!("{e}");
148        assert!(s.contains("connection refused"));
149    }
150}