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 DkimError {
10 Empty,
12 InvalidValue,
14 UnknownLabel,
16}
17
18impl fmt::Display for DkimError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("DKIM value cannot be empty"),
22 Self::InvalidValue => formatter.write_str("invalid DKIM value"),
23 Self::UnknownLabel => formatter.write_str("unknown DKIM label"),
24 }
25 }
26}
27
28impl Error for DkimError {}
29
30macro_rules! dkim_text_newtype {
31 ($name:ident, $doc:literal) => {
32 #[doc = $doc]
33 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34 pub struct $name(String);
35
36 impl $name {
37 pub fn new(value: impl AsRef<str>) -> Result<Self, DkimError> {
39 validate_dkim_text(value.as_ref()).map(|value| Self(value.to_owned()))
40 }
41
42 #[must_use]
44 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47 }
48
49 impl fmt::Display for $name {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 formatter.write_str(self.as_str())
52 }
53 }
54
55 impl FromStr for $name {
56 type Err = DkimError;
57
58 fn from_str(value: &str) -> Result<Self, Self::Err> {
59 Self::new(value)
60 }
61 }
62 };
63}
64
65dkim_text_newtype!(DkimSelector, "DKIM selector metadata.");
66dkim_text_newtype!(DkimDomain, "DKIM signing domain metadata.");
67dkim_text_newtype!(DkimHeaderTag, "DKIM signed header tag metadata.");
68dkim_text_newtype!(DkimBodyHash, "DKIM body hash metadata.");
69
70#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
72pub enum DkimAlgorithm {
73 #[default]
75 RsaSha256,
76 Ed25519Sha256,
78 RsaSha1,
80}
81
82impl DkimAlgorithm {
83 #[must_use]
85 pub const fn as_str(self) -> &'static str {
86 match self {
87 Self::RsaSha256 => "rsa-sha256",
88 Self::Ed25519Sha256 => "ed25519-sha256",
89 Self::RsaSha1 => "rsa-sha1",
90 }
91 }
92}
93
94impl fmt::Display for DkimAlgorithm {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 formatter.write_str(self.as_str())
97 }
98}
99
100impl FromStr for DkimAlgorithm {
101 type Err = DkimError;
102
103 fn from_str(value: &str) -> Result<Self, Self::Err> {
104 match value.trim().to_ascii_lowercase().as_str() {
105 "rsa-sha256" => Ok(Self::RsaSha256),
106 "ed25519-sha256" => Ok(Self::Ed25519Sha256),
107 "rsa-sha1" => Ok(Self::RsaSha1),
108 "" => Err(DkimError::Empty),
109 _ => Err(DkimError::UnknownLabel),
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
116pub enum DkimCanonicalization {
117 Simple,
119 #[default]
121 Relaxed,
122}
123
124impl DkimCanonicalization {
125 #[must_use]
127 pub const fn as_str(self) -> &'static str {
128 match self {
129 Self::Simple => "simple",
130 Self::Relaxed => "relaxed",
131 }
132 }
133}
134
135impl fmt::Display for DkimCanonicalization {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 formatter.write_str(self.as_str())
138 }
139}
140
141impl FromStr for DkimCanonicalization {
142 type Err = DkimError;
143
144 fn from_str(value: &str) -> Result<Self, Self::Err> {
145 match value.trim().to_ascii_lowercase().as_str() {
146 "simple" => Ok(Self::Simple),
147 "relaxed" => Ok(Self::Relaxed),
148 "" => Err(DkimError::Empty),
149 _ => Err(DkimError::UnknownLabel),
150 }
151 }
152}
153
154#[derive(Clone, Debug, Default, Eq, PartialEq)]
156pub struct DkimSignedHeaders {
157 headers: Vec<DkimHeaderTag>,
158}
159
160impl DkimSignedHeaders {
161 #[must_use]
163 pub const fn new() -> Self {
164 Self {
165 headers: Vec::new(),
166 }
167 }
168
169 pub fn from_names<I, S>(names: I) -> Result<Self, DkimError>
171 where
172 I: IntoIterator<Item = S>,
173 S: AsRef<str>,
174 {
175 let mut headers = Self::new();
176 for name in names {
177 headers.headers.push(DkimHeaderTag::new(name)?);
178 }
179 Ok(headers)
180 }
181
182 #[must_use]
184 pub fn as_slice(&self) -> &[DkimHeaderTag] {
185 &self.headers
186 }
187
188 #[must_use]
190 pub fn is_empty(&self) -> bool {
191 self.headers.is_empty()
192 }
193}
194
195impl fmt::Display for DkimSignedHeaders {
196 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197 for (index, header) in self.headers.iter().enumerate() {
198 if index > 0 {
199 formatter.write_str(":")?;
200 }
201 write!(formatter, "{header}")?;
202 }
203 Ok(())
204 }
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
209pub struct DkimSignature {
210 selector: DkimSelector,
211 domain: DkimDomain,
212 algorithm: DkimAlgorithm,
213 canonicalization: DkimCanonicalization,
214 signed_headers: DkimSignedHeaders,
215 body_hash: Option<DkimBodyHash>,
216}
217
218impl DkimSignature {
219 pub fn new(selector: DkimSelector, domain: impl AsRef<str>) -> Result<Self, DkimError> {
221 Ok(Self {
222 selector,
223 domain: DkimDomain::new(domain)?,
224 algorithm: DkimAlgorithm::default(),
225 canonicalization: DkimCanonicalization::default(),
226 signed_headers: DkimSignedHeaders::new(),
227 body_hash: None,
228 })
229 }
230
231 #[must_use]
233 pub const fn with_algorithm(mut self, algorithm: DkimAlgorithm) -> Self {
234 self.algorithm = algorithm;
235 self
236 }
237
238 #[must_use]
240 pub const fn with_canonicalization(mut self, canonicalization: DkimCanonicalization) -> Self {
241 self.canonicalization = canonicalization;
242 self
243 }
244
245 #[must_use]
247 pub fn with_signed_headers(mut self, signed_headers: DkimSignedHeaders) -> Self {
248 self.signed_headers = signed_headers;
249 self
250 }
251
252 #[must_use]
254 pub fn with_body_hash(mut self, body_hash: DkimBodyHash) -> Self {
255 self.body_hash = Some(body_hash);
256 self
257 }
258
259 #[must_use]
261 pub const fn selector(&self) -> &DkimSelector {
262 &self.selector
263 }
264
265 #[must_use]
267 pub const fn domain(&self) -> &DkimDomain {
268 &self.domain
269 }
270}
271
272impl fmt::Display for DkimSignature {
273 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274 write!(
275 formatter,
276 "v=1; a={}; c={}; d={}; s={}",
277 self.algorithm, self.canonicalization, self.domain, self.selector
278 )?;
279 if !self.signed_headers.is_empty() {
280 write!(formatter, "; h={}", self.signed_headers)?;
281 }
282 if let Some(body_hash) = &self.body_hash {
283 write!(formatter, "; bh={body_hash}")?;
284 }
285 Ok(())
286 }
287}
288
289fn validate_dkim_text(value: &str) -> Result<&str, DkimError> {
290 let trimmed = value.trim();
291 if trimmed.is_empty() {
292 return Err(DkimError::Empty);
293 }
294 if trimmed.chars().any(|character| {
295 character.is_control() || character.is_whitespace() || matches!(character, ';' | '=')
296 }) {
297 return Err(DkimError::InvalidValue);
298 }
299 Ok(trimmed)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::{
305 DkimAlgorithm, DkimBodyHash, DkimCanonicalization, DkimError, DkimSelector, DkimSignature,
306 DkimSignedHeaders,
307 };
308
309 #[test]
310 fn builds_signature_metadata() -> Result<(), DkimError> {
311 let signature = DkimSignature::new(DkimSelector::new("mail")?, "example.com")?
312 .with_algorithm(DkimAlgorithm::RsaSha256)
313 .with_canonicalization(DkimCanonicalization::Relaxed)
314 .with_signed_headers(DkimSignedHeaders::from_names(["from", "subject"])?)
315 .with_body_hash(DkimBodyHash::new("abc123")?);
316
317 assert_eq!(signature.selector().as_str(), "mail");
318 assert!(signature.to_string().contains("h=from:subject"));
319 assert!(signature.to_string().contains("bh=abc123"));
320 Ok(())
321 }
322
323 #[test]
324 fn parses_labels() -> Result<(), DkimError> {
325 assert_eq!(
326 "ed25519-sha256".parse::<DkimAlgorithm>()?,
327 DkimAlgorithm::Ed25519Sha256
328 );
329 assert_eq!(
330 "simple".parse::<DkimCanonicalization>()?,
331 DkimCanonicalization::Simple
332 );
333 Ok(())
334 }
335}