Skip to main content

use_dmarc/
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 DMARC metadata is invalid.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum DmarcError {
10    /// The supplied value was empty.
11    Empty,
12    /// The supplied value was invalid for this primitive.
13    InvalidValue,
14    /// The supplied percentage was outside 0..=100.
15    InvalidPercentage,
16    /// The supplied label was not recognized.
17    UnknownLabel,
18}
19
20impl fmt::Display for DmarcError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Empty => formatter.write_str("DMARC value cannot be empty"),
24            Self::InvalidValue => formatter.write_str("invalid DMARC value"),
25            Self::InvalidPercentage => {
26                formatter.write_str("DMARC percentage must be between 0 and 100")
27            }
28            Self::UnknownLabel => formatter.write_str("unknown DMARC label"),
29        }
30    }
31}
32
33impl Error for DmarcError {}
34
35/// DMARC policy label.
36#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub enum DmarcPolicy {
38    /// Monitor only.
39    #[default]
40    None,
41    /// Quarantine failing mail.
42    Quarantine,
43    /// Reject failing mail.
44    Reject,
45}
46
47impl DmarcPolicy {
48    /// Returns the policy label.
49    #[must_use]
50    pub const fn as_str(self) -> &'static str {
51        match self {
52            Self::None => "none",
53            Self::Quarantine => "quarantine",
54            Self::Reject => "reject",
55        }
56    }
57}
58
59impl fmt::Display for DmarcPolicy {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str(self.as_str())
62    }
63}
64
65impl FromStr for DmarcPolicy {
66    type Err = DmarcError;
67
68    fn from_str(value: &str) -> Result<Self, Self::Err> {
69        match value.trim().to_ascii_lowercase().as_str() {
70            "none" => Ok(Self::None),
71            "quarantine" => Ok(Self::Quarantine),
72            "reject" => Ok(Self::Reject),
73            "" => Err(DmarcError::Empty),
74            _ => Err(DmarcError::UnknownLabel),
75        }
76    }
77}
78
79/// DMARC subdomain policy wrapper.
80#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct DmarcSubdomainPolicy(DmarcPolicy);
82
83impl DmarcSubdomainPolicy {
84    /// Creates a subdomain policy.
85    #[must_use]
86    pub const fn new(policy: DmarcPolicy) -> Self {
87        Self(policy)
88    }
89
90    /// Returns the policy.
91    #[must_use]
92    pub const fn policy(self) -> DmarcPolicy {
93        self.0
94    }
95}
96
97impl fmt::Display for DmarcSubdomainPolicy {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(formatter, "{}", self.0)
100    }
101}
102
103/// DMARC alignment mode.
104#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
105pub enum DmarcAlignmentMode {
106    /// Relaxed alignment.
107    #[default]
108    Relaxed,
109    /// Strict alignment.
110    Strict,
111}
112
113impl DmarcAlignmentMode {
114    /// Returns the DMARC tag label.
115    #[must_use]
116    pub const fn as_tag_value(self) -> &'static str {
117        match self {
118            Self::Relaxed => "r",
119            Self::Strict => "s",
120        }
121    }
122}
123
124impl fmt::Display for DmarcAlignmentMode {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.write_str(self.as_tag_value())
127    }
128}
129
130/// DMARC reporting URI metadata.
131#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub struct DmarcReportUri(String);
133
134impl DmarcReportUri {
135    /// Creates a report URI metadata value.
136    pub fn new(value: impl AsRef<str>) -> Result<Self, DmarcError> {
137        validate_dmarc_text(value.as_ref()).map(|value| Self(value.to_owned()))
138    }
139
140    /// Returns the report URI text.
141    #[must_use]
142    pub fn as_str(&self) -> &str {
143        &self.0
144    }
145}
146
147impl fmt::Display for DmarcReportUri {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        formatter.write_str(self.as_str())
150    }
151}
152
153/// DMARC failure reporting option.
154#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
155pub enum DmarcFailureOption {
156    /// Report all failures.
157    #[default]
158    All,
159    /// Report any failure.
160    Any,
161    /// Report DKIM failures.
162    Dkim,
163    /// Report SPF failures.
164    Spf,
165}
166
167impl DmarcFailureOption {
168    /// Returns the DMARC failure option tag value.
169    #[must_use]
170    pub const fn as_tag_value(self) -> &'static str {
171        match self {
172            Self::All => "0",
173            Self::Any => "1",
174            Self::Dkim => "d",
175            Self::Spf => "s",
176        }
177    }
178}
179
180impl fmt::Display for DmarcFailureOption {
181    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182        formatter.write_str(self.as_tag_value())
183    }
184}
185
186/// DMARC result label.
187#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
188pub enum DmarcResult {
189    /// DMARC passed.
190    Pass,
191    /// DMARC failed.
192    Fail,
193    /// Temporary error.
194    TempError,
195    /// Permanent error.
196    PermError,
197}
198
199impl DmarcResult {
200    /// Returns the result label.
201    #[must_use]
202    pub const fn as_str(self) -> &'static str {
203        match self {
204            Self::Pass => "pass",
205            Self::Fail => "fail",
206            Self::TempError => "temperror",
207            Self::PermError => "permerror",
208        }
209    }
210}
211
212impl fmt::Display for DmarcResult {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        formatter.write_str(self.as_str())
215    }
216}
217
218/// DMARC percentage tag value.
219#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
220pub struct DmarcPercentage(u8);
221
222impl DmarcPercentage {
223    /// Creates a percentage in the inclusive range 0..=100.
224    pub const fn new(value: u8) -> Result<Self, DmarcError> {
225        if value <= 100 {
226            Ok(Self(value))
227        } else {
228            Err(DmarcError::InvalidPercentage)
229        }
230    }
231
232    /// Returns the numeric percentage.
233    #[must_use]
234    pub const fn value(self) -> u8 {
235        self.0
236    }
237}
238
239impl fmt::Display for DmarcPercentage {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        write!(formatter, "{}", self.0)
242    }
243}
244
245/// DMARC record metadata. This type does not enforce policy.
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub struct DmarcRecord {
248    policy: DmarcPolicy,
249    subdomain_policy: Option<DmarcSubdomainPolicy>,
250    dkim_alignment: DmarcAlignmentMode,
251    spf_alignment: DmarcAlignmentMode,
252    report_uris: Vec<DmarcReportUri>,
253    failure_options: Vec<DmarcFailureOption>,
254    percentage: Option<DmarcPercentage>,
255}
256
257impl DmarcRecord {
258    /// Creates a DMARC record with the requested policy.
259    #[must_use]
260    pub const fn new(policy: DmarcPolicy) -> Self {
261        Self {
262            policy,
263            subdomain_policy: None,
264            dkim_alignment: DmarcAlignmentMode::Relaxed,
265            spf_alignment: DmarcAlignmentMode::Relaxed,
266            report_uris: Vec::new(),
267            failure_options: Vec::new(),
268            percentage: None,
269        }
270    }
271
272    /// Sets the subdomain policy.
273    #[must_use]
274    pub const fn with_subdomain_policy(mut self, policy: DmarcSubdomainPolicy) -> Self {
275        self.subdomain_policy = Some(policy);
276        self
277    }
278
279    /// Sets DKIM alignment.
280    #[must_use]
281    pub const fn with_dkim_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
282        self.dkim_alignment = alignment;
283        self
284    }
285
286    /// Sets SPF alignment.
287    #[must_use]
288    pub const fn with_spf_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
289        self.spf_alignment = alignment;
290        self
291    }
292
293    /// Adds a reporting URI.
294    #[must_use]
295    pub fn with_report_uri(mut self, uri: DmarcReportUri) -> Self {
296        self.report_uris.push(uri);
297        self
298    }
299
300    /// Adds a failure option.
301    #[must_use]
302    pub fn with_failure_option(mut self, option: DmarcFailureOption) -> Self {
303        self.failure_options.push(option);
304        self
305    }
306
307    /// Sets the percentage tag.
308    #[must_use]
309    pub const fn with_percentage(mut self, percentage: DmarcPercentage) -> Self {
310        self.percentage = Some(percentage);
311        self
312    }
313
314    /// Returns the policy.
315    #[must_use]
316    pub const fn policy(&self) -> DmarcPolicy {
317        self.policy
318    }
319}
320
321impl fmt::Display for DmarcRecord {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        write!(formatter, "v=DMARC1; p={}", self.policy)?;
324        if let Some(policy) = self.subdomain_policy {
325            write!(formatter, "; sp={policy}")?;
326        }
327        if self.dkim_alignment != DmarcAlignmentMode::Relaxed {
328            write!(formatter, "; adkim={}", self.dkim_alignment)?;
329        }
330        if self.spf_alignment != DmarcAlignmentMode::Relaxed {
331            write!(formatter, "; aspf={}", self.spf_alignment)?;
332        }
333        if let Some(percentage) = self.percentage {
334            write!(formatter, "; pct={percentage}")?;
335        }
336        if !self.report_uris.is_empty() {
337            formatter.write_str("; rua=")?;
338            for (index, uri) in self.report_uris.iter().enumerate() {
339                if index > 0 {
340                    formatter.write_str(",")?;
341                }
342                write!(formatter, "{uri}")?;
343            }
344        }
345        if !self.failure_options.is_empty() {
346            formatter.write_str("; fo=")?;
347            for (index, option) in self.failure_options.iter().enumerate() {
348                if index > 0 {
349                    formatter.write_str(":")?;
350                }
351                write!(formatter, "{option}")?;
352            }
353        }
354        Ok(())
355    }
356}
357
358fn validate_dmarc_text(value: &str) -> Result<&str, DmarcError> {
359    let trimmed = value.trim();
360    if trimmed.is_empty() {
361        return Err(DmarcError::Empty);
362    }
363    if trimmed
364        .chars()
365        .any(|character| character.is_control() || character.is_whitespace())
366    {
367        return Err(DmarcError::InvalidValue);
368    }
369    Ok(trimmed)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::{
375        DmarcAlignmentMode, DmarcError, DmarcPercentage, DmarcPolicy, DmarcRecord, DmarcReportUri,
376    };
377
378    #[test]
379    fn renders_policy_records() -> Result<(), DmarcError> {
380        let record = DmarcRecord::new(DmarcPolicy::Quarantine)
381            .with_spf_alignment(DmarcAlignmentMode::Strict)
382            .with_percentage(DmarcPercentage::new(50)?)
383            .with_report_uri(DmarcReportUri::new("mailto:dmarc@example.com")?);
384
385        assert_eq!(
386            record.to_string(),
387            "v=DMARC1; p=quarantine; aspf=s; pct=50; rua=mailto:dmarc@example.com"
388        );
389        Ok(())
390    }
391
392    #[test]
393    fn parses_policy_labels() -> Result<(), DmarcError> {
394        assert_eq!("reject".parse::<DmarcPolicy>()?, DmarcPolicy::Reject);
395        assert_eq!(
396            DmarcPercentage::new(101),
397            Err(DmarcError::InvalidPercentage)
398        );
399        Ok(())
400    }
401}