Skip to main content

mailrs_dkim/
error.rs

1//! Error + result types per RFC 6376 §3.9 / RFC 8601 §2.7.1.
2
3use std::fmt;
4
5/// Verification outcome (RFC 8601 §2.7.1 — same vocabulary as
6/// `Authentication-Results: dkim=...`).
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum DkimResult {
9    /// No DKIM-Signature header present.
10    None,
11    /// Signature verified successfully.
12    Pass,
13    /// Signature was present but verification failed (wrong hash,
14    /// expired, body modified, etc.).
15    Fail,
16    /// Policy explicitly rejects the message; rarely produced by
17    /// raw verification but reserved.
18    Policy,
19    /// Signature is malformed — `b=` not base64, tag missing, etc.
20    /// Per RFC 8601 this is distinct from Fail.
21    Neutral,
22    /// Temporary error during verification (DNS lookup timeout).
23    TempError,
24    /// Permanent error (DNS NXDOMAIN for selector, key revoked,
25    /// unsupported algorithm).
26    PermError,
27}
28
29impl DkimResult {
30    /// RFC 8601 wire-form string (lowercase).
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            DkimResult::None => "none",
34            DkimResult::Pass => "pass",
35            DkimResult::Fail => "fail",
36            DkimResult::Policy => "policy",
37            DkimResult::Neutral => "neutral",
38            DkimResult::TempError => "temperror",
39            DkimResult::PermError => "permerror",
40        }
41    }
42}
43
44impl fmt::Display for DkimResult {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.write_str(self.as_str())
47    }
48}
49
50/// Internal error category from the parser + verifier.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum DkimError {
53    /// DKIM-Signature header missing or malformed.
54    MissingHeader,
55    /// Required tag (v, a, b, bh, d, h, s) missing.
56    MissingTag(String),
57    /// Tag value couldn't be parsed.
58    InvalidTag(String),
59    /// Base64 decode failed for b= or bh=.
60    InvalidBase64(String),
61    /// DNS lookup for selector failed.
62    DnsTempError(String),
63    /// DNS returned NXDOMAIN or unparseable TXT for selector.
64    DnsPermError(String),
65    /// Public key TXT record malformed.
66    InvalidKey(String),
67    /// Unsupported algorithm (we support rsa-sha256 in 1.0).
68    UnsupportedAlgorithm(String),
69    /// Unsupported canonicalization combination.
70    UnsupportedCanon(String),
71    /// Body hash (bh=) mismatch — body was modified in transit.
72    BodyHashMismatch,
73    /// Header signature failed RSA verify — body unchanged but
74    /// headers / signature don't match.
75    SignatureMismatch,
76    /// Signature expired (x= tag past).
77    Expired,
78}
79
80impl fmt::Display for DkimError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            DkimError::MissingHeader => f.write_str("no DKIM-Signature header"),
84            DkimError::MissingTag(t) => write!(f, "missing required tag {t}="),
85            DkimError::InvalidTag(t) => write!(f, "invalid tag: {t}"),
86            DkimError::InvalidBase64(t) => write!(f, "invalid base64 in tag {t}"),
87            DkimError::DnsTempError(s) => write!(f, "DNS temporary error: {s}"),
88            DkimError::DnsPermError(s) => write!(f, "DNS permanent error: {s}"),
89            DkimError::InvalidKey(s) => write!(f, "invalid public key: {s}"),
90            DkimError::UnsupportedAlgorithm(a) => write!(f, "unsupported algorithm: {a}"),
91            DkimError::UnsupportedCanon(c) => write!(f, "unsupported canonicalization: {c}"),
92            DkimError::BodyHashMismatch => f.write_str("body hash mismatch"),
93            DkimError::SignatureMismatch => f.write_str("signature mismatch"),
94            DkimError::Expired => f.write_str("signature expired"),
95        }
96    }
97}
98
99impl std::error::Error for DkimError {}
100
101impl DkimError {
102    /// Map an error to the public [`DkimResult`].
103    pub fn to_result(&self) -> DkimResult {
104        match self {
105            DkimError::MissingHeader => DkimResult::None,
106            DkimError::DnsTempError(_) => DkimResult::TempError,
107            DkimError::DnsPermError(_)
108            | DkimError::InvalidKey(_)
109            | DkimError::UnsupportedAlgorithm(_)
110            | DkimError::UnsupportedCanon(_)
111            | DkimError::Expired => DkimResult::PermError,
112            DkimError::MissingTag(_) | DkimError::InvalidTag(_) | DkimError::InvalidBase64(_) => {
113                DkimResult::Neutral
114            }
115            DkimError::BodyHashMismatch | DkimError::SignatureMismatch => DkimResult::Fail,
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn result_as_str_matches_rfc_8601() {
126        assert_eq!(DkimResult::None.as_str(), "none");
127        assert_eq!(DkimResult::Pass.as_str(), "pass");
128        assert_eq!(DkimResult::Fail.as_str(), "fail");
129        assert_eq!(DkimResult::Neutral.as_str(), "neutral");
130        assert_eq!(DkimResult::TempError.as_str(), "temperror");
131        assert_eq!(DkimResult::PermError.as_str(), "permerror");
132    }
133
134    #[test]
135    fn error_to_result_classification() {
136        assert_eq!(DkimError::MissingHeader.to_result(), DkimResult::None);
137        assert_eq!(
138            DkimError::DnsTempError("x".into()).to_result(),
139            DkimResult::TempError
140        );
141        assert_eq!(
142            DkimError::DnsPermError("x".into()).to_result(),
143            DkimResult::PermError
144        );
145        assert_eq!(
146            DkimError::MissingTag("a".into()).to_result(),
147            DkimResult::Neutral
148        );
149        assert_eq!(DkimError::BodyHashMismatch.to_result(), DkimResult::Fail);
150        assert_eq!(DkimError::SignatureMismatch.to_result(), DkimResult::Fail);
151        assert_eq!(DkimError::Expired.to_result(), DkimResult::PermError);
152    }
153
154    #[test]
155    fn display_contains_context() {
156        let e = DkimError::MissingTag("a".into());
157        let s = format!("{e}");
158        assert!(s.contains('a'));
159    }
160}