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 DmarcError {
10 Empty,
12 InvalidValue,
14 InvalidPercentage,
16 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub enum DmarcPolicy {
38 #[default]
40 None,
41 Quarantine,
43 Reject,
45}
46
47impl DmarcPolicy {
48 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct DmarcSubdomainPolicy(DmarcPolicy);
82
83impl DmarcSubdomainPolicy {
84 #[must_use]
86 pub const fn new(policy: DmarcPolicy) -> Self {
87 Self(policy)
88 }
89
90 #[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#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
105pub enum DmarcAlignmentMode {
106 #[default]
108 Relaxed,
109 Strict,
111}
112
113impl DmarcAlignmentMode {
114 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub struct DmarcReportUri(String);
133
134impl DmarcReportUri {
135 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 #[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#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
155pub enum DmarcFailureOption {
156 #[default]
158 All,
159 Any,
161 Dkim,
163 Spf,
165}
166
167impl DmarcFailureOption {
168 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
188pub enum DmarcResult {
189 Pass,
191 Fail,
193 TempError,
195 PermError,
197}
198
199impl DmarcResult {
200 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
220pub struct DmarcPercentage(u8);
221
222impl DmarcPercentage {
223 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 #[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#[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 #[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 #[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 #[must_use]
281 pub const fn with_dkim_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
282 self.dkim_alignment = alignment;
283 self
284 }
285
286 #[must_use]
288 pub const fn with_spf_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
289 self.spf_alignment = alignment;
290 self
291 }
292
293 #[must_use]
295 pub fn with_report_uri(mut self, uri: DmarcReportUri) -> Self {
296 self.report_uris.push(uri);
297 self
298 }
299
300 #[must_use]
302 pub fn with_failure_option(mut self, option: DmarcFailureOption) -> Self {
303 self.failure_options.push(option);
304 self
305 }
306
307 #[must_use]
309 pub const fn with_percentage(mut self, percentage: DmarcPercentage) -> Self {
310 self.percentage = Some(percentage);
311 self
312 }
313
314 #[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}