Skip to main content

mail_auth/spf/
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::{RR_FAIL, RR_NEUTRAL_NONE, RR_SOFTFAIL, RR_TEMP_PERM_ERROR, SpfRecord};
8use crate::Error;
9use crate::sampling::is_within_pct;
10use std::{fmt::Display, str::FromStr};
11
12/// The result of an SPF check (RFC 7208, Section 2.6).
13///
14/// Displays as the lowercase RFC token (`pass`, `fail`, `softfail`,
15/// `neutral`, `temperror`, `permerror`, `none`) and parses from the same
16/// tokens, ignoring case, through [`FromStr`] (which fails with
17/// [`Error::Parse`] on any other input).
18#[derive(Debug, PartialEq, Eq, Clone, Copy)]
19pub enum SpfResult {
20    /// The client is authorized to use the domain (Section 2.6.3).
21    Pass,
22    /// The client is explicitly not authorized to use the domain
23    /// (Section 2.6.4).
24    Fail,
25    /// The client is probably not authorized, a weak statement between
26    /// `fail` and `neutral` (Section 2.6.5).
27    SoftFail,
28    /// The domain owner makes no assertion about the client (Section 2.6.2).
29    /// Also returned when no directive matches.
30    Neutral,
31    /// A transient error, usually a DNS failure, prevented the check
32    /// (Section 2.6.6).
33    TempError,
34    /// The published record could not be interpreted, or a processing limit
35    /// was exceeded (Section 2.6.7).
36    PermError,
37    /// No SPF record was found, or the domain is not a valid, fully qualified
38    /// name (Section 2.6.1).
39    None,
40}
41
42/// The outcome of an SPF check.
43///
44/// Returned by [`MessageAuthenticator::verify_spf`] and
45/// [`MessageAuthenticator::check_host`]. Besides the [`SpfResult`], it
46/// carries the checked domain, the explanation string of a `fail` result and
47/// the RFC 6652 report address, when the record requests a report for this
48/// result. [`SpfOutput::new`] and [`SpfOutput::with_result`] build a
49/// synthetic output, for example to feed DMARC with a result obtained
50/// elsewhere.
51///
52/// [`MessageAuthenticator::verify_spf`]: crate::MessageAuthenticator::verify_spf
53/// [`MessageAuthenticator::check_host`]: crate::MessageAuthenticator::check_host
54#[derive(Debug, Eq, Clone)]
55pub struct SpfOutput {
56    pub(crate) result: SpfResult,
57    pub(crate) domain: String,
58    pub(crate) report: Option<String>,
59    pub(crate) explanation: Option<String>,
60    pub(crate) identity: Option<SpfIdentity>,
61}
62
63impl PartialEq for SpfOutput {
64    fn eq(&self, other: &Self) -> bool {
65        self.result == other.result
66            && self.domain == other.domain
67            && self.report == other.report
68            && self.explanation == other.explanation
69    }
70}
71
72#[derive(Debug, PartialEq, Eq, Clone, Copy)]
73pub(crate) enum SpfIdentity {
74    Helo,
75    MailFrom,
76}
77
78impl FromStr for SpfResult {
79    type Err = Error;
80
81    fn from_str(value: &str) -> Result<Self, Self::Err> {
82        hashify::map_ignore_case!(value.as_bytes(), SpfResult,
83            b"pass" => SpfResult::Pass,
84            b"fail" => SpfResult::Fail,
85            b"softfail" => SpfResult::SoftFail,
86            b"neutral" => SpfResult::Neutral,
87            b"temperror" => SpfResult::TempError,
88            b"permerror" => SpfResult::PermError,
89            b"none" => SpfResult::None,
90        )
91        .copied()
92        .ok_or(Error::Parse)
93    }
94}
95
96impl SpfOutput {
97    /// Creates an output for `domain` with result [`SpfResult::None`], no
98    /// explanation and no report address.
99    pub fn new(domain: String) -> Self {
100        SpfOutput {
101            result: SpfResult::None,
102            report: None,
103            explanation: None,
104            domain,
105            identity: None,
106        }
107    }
108
109    pub(crate) fn with_identity(mut self, identity: SpfIdentity) -> Self {
110        self.identity = Some(identity);
111        self
112    }
113
114    /// Sets the result.
115    pub fn with_result(mut self, result: SpfResult) -> Self {
116        self.result = result;
117        self
118    }
119
120    /// Sets the report address to `<ra>@<domain>` when `spf` has an `ra=`
121    /// modifier, its `rr=` bitmask covers the current result and the message
122    /// is sampled by `rp=` (RFC 6652, Section 3). Call it after
123    /// [`SpfOutput::with_result`]; a `pass` result never produces a report.
124    pub fn with_report(mut self, spf: &SpfRecord) -> Self {
125        match &spf.ra {
126            Some(ra)
127                if is_within_pct(spf.rp)
128                    && match self.result {
129                        SpfResult::Fail => (spf.rr & RR_FAIL) != 0,
130                        SpfResult::SoftFail => (spf.rr & RR_SOFTFAIL) != 0,
131                        SpfResult::Neutral | SpfResult::None => (spf.rr & RR_NEUTRAL_NONE) != 0,
132                        SpfResult::TempError | SpfResult::PermError => {
133                            (spf.rr & RR_TEMP_PERM_ERROR) != 0
134                        }
135                        SpfResult::Pass => false,
136                    } =>
137            {
138                let ra = String::from_utf8_lossy(ra);
139                let mut report = String::with_capacity(ra.len() + self.domain.len() + 1);
140                report.push_str(ra.as_ref());
141                report.push('@');
142                report.push_str(&self.domain);
143                self.report = report.into();
144            }
145            _ => (),
146        }
147        self
148    }
149
150    /// Sets the explanation string (RFC 7208, Section 6.2).
151    pub fn with_explanation(mut self, explanation: String) -> Self {
152        self.explanation = explanation.into();
153        self
154    }
155
156    /// Returns the SPF result.
157    pub fn result(&self) -> SpfResult {
158        self.result
159    }
160
161    /// Returns the domain that was checked: the HELO domain or the domain of
162    /// the MAIL FROM address.
163    pub fn domain(&self) -> &str {
164        &self.domain
165    }
166
167    /// Returns the expanded `exp=` explanation, set only on a `fail` result
168    /// whose record has an `exp=` modifier that resolved.
169    pub fn explanation(&self) -> Option<&str> {
170        self.explanation.as_deref()
171    }
172
173    /// Returns the address that requested an authentication failure report
174    /// for this result (RFC 6652), if any.
175    pub fn report_address(&self) -> Option<&str> {
176        self.report.as_deref()
177    }
178}
179
180impl Default for SpfOutput {
181    fn default() -> Self {
182        Self {
183            result: SpfResult::None,
184            domain: Default::default(),
185            report: Default::default(),
186            explanation: Default::default(),
187            identity: None,
188        }
189    }
190}
191
192impl Display for SpfResult {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.write_str(match self {
195            SpfResult::Pass => "pass",
196            SpfResult::Fail => "fail",
197            SpfResult::SoftFail => "softfail",
198            SpfResult::Neutral => "neutral",
199            SpfResult::TempError => "temperror",
200            SpfResult::PermError => "permerror",
201            SpfResult::None => "none",
202        })
203    }
204}