mail_auth/dmarc/output.rs
1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{DmarcRecord, FailureOptions, Policy};
8use crate::{DnsError, Error};
9use std::{fmt::Display, sync::Arc};
10
11/// The outcome of DMARC evaluation.
12///
13/// Returned by
14/// [`MessageAuthenticator::verify_dmarc`](crate::MessageAuthenticator::verify_dmarc).
15/// It holds the Author Domain, the policy to apply, the aligned SPF and DKIM
16/// results and the DMARC Policy Record that was applied, if any.
17#[derive(Debug, PartialEq, Eq, Clone)]
18pub struct DmarcOutput {
19 pub(crate) spf_result: DmarcResult,
20 pub(crate) dkim_result: DmarcResult,
21 pub(crate) domain: String,
22 pub(crate) policy: Policy,
23 pub(crate) record: Option<Arc<DmarcRecord>>,
24}
25
26/// A DMARC result (RFC 9989, Section 4.10), either for one mechanism
27/// ([`DmarcOutput::spf_result`], [`DmarcOutput::dkim_result`]) or overall
28/// ([`DmarcOutput::result`]).
29///
30/// Displays as `pass`, `none`, or the variant name followed by the error
31/// (for example `fail; <error>`).
32#[derive(Debug, PartialEq, Eq, Clone)]
33pub enum DmarcResult {
34 /// The mechanism passed with an identifier aligned with the Author
35 /// Domain.
36 Pass,
37 /// The mechanism did not produce an aligned pass; usually carries
38 /// [`Error::NotAligned`].
39 Fail(crate::Error),
40 /// A transient DNS error prevented evaluation (RFC 9989, Section 5.3.6):
41 /// DMARC can neither pass nor fail.
42 TempError(crate::Error),
43 /// A permanent error while discovering the DMARC record.
44 PermError(crate::Error),
45 /// DMARC does not apply: no usable RFC5322.From domain, no DMARC record,
46 /// or no result for this mechanism.
47 None,
48}
49
50impl From<Error> for DmarcResult {
51 fn from(err: Error) -> Self {
52 if matches!(&err, Error::Dns(DnsError::Resolver(_))) {
53 DmarcResult::TempError(err)
54 } else {
55 DmarcResult::PermError(err)
56 }
57 }
58}
59
60impl Default for DmarcOutput {
61 fn default() -> Self {
62 Self {
63 domain: String::new(),
64 policy: Policy::None,
65 record: None,
66 spf_result: DmarcResult::None,
67 dkim_result: DmarcResult::None,
68 }
69 }
70}
71
72impl DmarcOutput {
73 pub(crate) fn with_domain(mut self, domain: &str) -> Self {
74 self.domain = domain.to_string();
75 self
76 }
77
78 pub(crate) fn with_spf_result(mut self, result: DmarcResult) -> Self {
79 self.spf_result = result;
80 self
81 }
82
83 pub(crate) fn with_dkim_result(mut self, result: DmarcResult) -> Self {
84 self.dkim_result = result;
85 self
86 }
87
88 pub(crate) fn with_record(mut self, record: Arc<DmarcRecord>) -> Self {
89 self.record = record.into();
90 self
91 }
92
93 /// Returns the Author Domain (the RFC5322.From domain, as an A-label), or
94 /// an empty string when none could be determined.
95 pub fn domain(&self) -> &str {
96 &self.domain
97 }
98
99 /// Consumes the output and returns the Author Domain.
100 pub fn into_domain(self) -> String {
101 self.domain
102 }
103
104 /// Returns the policy to apply to a failing message: `p`, `sp` or `np`
105 /// of the applied record depending on where it was found, lowered by one
106 /// level in test mode (`t=y`). [`Policy::None`] when DMARC does not
107 /// apply.
108 pub fn policy(&self) -> Policy {
109 self.policy
110 }
111
112 /// Returns the DKIM alignment result: `Pass` when a passing DKIM (or
113 /// DKIM2 instance 1) signature is aligned, `Fail` when signatures passed
114 /// but none aligned, `TempError` on a DNS error affecting an aligned
115 /// identifier, `None` when no signature passed.
116 pub fn dkim_result(&self) -> &DmarcResult {
117 &self.dkim_result
118 }
119
120 /// Returns the SPF alignment result: `Pass` when SPF passed for an
121 /// aligned MAIL FROM domain, `Fail` when SPF passed but the domain is not
122 /// aligned, `TempError` when SPF returned `temperror` for an aligned
123 /// domain or alignment hit a DNS error, `None` otherwise.
124 pub fn spf_result(&self) -> &DmarcResult {
125 &self.spf_result
126 }
127
128 /// Returns the overall DMARC result: the best of the SPF and DKIM
129 /// results, ranked `Pass`, `TempError`, `PermError`, `Fail`. When both
130 /// are `None`, returns `Fail` with [`Error::NotAligned`] if a record was
131 /// applied and `None` otherwise.
132 pub fn result(&self) -> DmarcResult {
133 match self.mechanism_result() {
134 Some(result) => result.clone(),
135 None if self.record.is_some() => DmarcResult::Fail(Error::NotAligned),
136 None => DmarcResult::None,
137 }
138 }
139
140 pub(crate) fn mechanism_result(&self) -> Option<&DmarcResult> {
141 [&self.spf_result, &self.dkim_result]
142 .into_iter()
143 .filter_map(|result| {
144 let rank = match result {
145 DmarcResult::Pass => 0,
146 DmarcResult::TempError(_) => 1,
147 DmarcResult::PermError(_) => 2,
148 DmarcResult::Fail(_) => 3,
149 DmarcResult::None => return None,
150 };
151 Some((rank, result))
152 })
153 .min_by_key(|(rank, _)| *rank)
154 .map(|(_, result)| result)
155 }
156
157 /// Returns the DMARC Policy Record that was applied, if any.
158 pub fn record(&self) -> Option<&Arc<DmarcRecord>> {
159 self.record.as_ref()
160 }
161
162 /// Returns `true` when the applied record has a `rua=` or `ruf=` tag
163 /// with at least one destination.
164 pub fn requests_reports(&self) -> bool {
165 self.record
166 .as_ref()
167 .is_some_and(|r| !r.rua.is_empty() || !r.ruf.is_empty())
168 }
169
170 /// Returns the `fo=` options of the applied record when a failure report
171 /// (RFC 9991) should be sent for this message: the record has `ruf=`
172 /// destinations, the result is not a temporary error, and the failed
173 /// mechanisms match `fo=`. Returns `None` otherwise.
174 pub fn failure_report(&self) -> Option<FailureOptions> {
175 match &self.record {
176 Some(record)
177 if !record.ruf.is_empty()
178 && !matches!(self.mechanism_result(), Some(DmarcResult::TempError(_)))
179 && ((self.dkim_result != DmarcResult::Pass
180 && matches!(
181 record.fo,
182 FailureOptions::Any | FailureOptions::Dkim | FailureOptions::DkimSpf
183 ))
184 || (self.spf_result != DmarcResult::Pass
185 && matches!(
186 record.fo,
187 FailureOptions::Any | FailureOptions::Spf | FailureOptions::DkimSpf
188 ))
189 || (self.dkim_result != DmarcResult::Pass
190 && self.spf_result != DmarcResult::Pass
191 && record.fo == FailureOptions::All)) =>
192 {
193 Some(record.fo.clone())
194 }
195 _ => None,
196 }
197 }
198}
199
200impl Display for DmarcResult {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 DmarcResult::Pass => f.write_str("pass"),
204 DmarcResult::Fail(err) => write!(f, "fail; {err}"),
205 DmarcResult::TempError(err) => write!(f, "temp error; {err}"),
206 DmarcResult::PermError(err) => write!(f, "perm error; {err}"),
207 DmarcResult::None => f.write_str("none"),
208 }
209 }
210}