Skip to main content

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}