Skip to main content

mail_auth/
auth_results.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7//! Builders for the `Authentication-Results` and `Received-SPF` headers.
8//!
9//! - [`AuthenticationResults`] writes the `Authentication-Results` header
10//!   defined in RFC 8601, with one method entry per `dkim`, `dkim-atps`
11//!   (RFC 6541), `dkim2`, `spf`, `arc` (RFC 8617), `dmarc` (RFC 9989) and
12//!   `iprev` (RFC 8601 Section 3) result.
13//! - [`ReceivedSpf`] writes the `Received-SPF` header defined in RFC 7208
14//!   Section 9.1.
15//!
16//! Both implement [`HeaderWriter`], so the finished header can be written with
17//! [`HeaderWriter::write_header`] or rendered with [`HeaderWriter::to_header`].
18//! Property values taken from the message or the SMTP session are sanitized
19//! or quoted as required by the RFC 8601 `pvalue` grammar.
20
21#[cfg(feature = "arc")]
22use crate::{ArcOutput, arc::ArcError};
23use crate::{
24    Dkim2Result, DkimOutput, DkimResult, DmarcOutput, DmarcResult, DnsError, Error, IprevOutput,
25    IprevResult, SpfOutput, SpfResult,
26    crypto::CryptoError,
27    dkim::DkimError,
28    dkim2::{Dkim2Output, Verification},
29    dmarc::Policy,
30    headers::{HeaderWriter, IntegerBuffer, Writer},
31    spf::{SpfIdentity, verify::SpfParameters},
32};
33use encodify::base64;
34use std::{
35    borrow::Cow,
36    fmt::{Display, Write},
37    net::{IpAddr, Ipv4Addr},
38};
39
40/// An `Authentication-Results` header (RFC 8601) under construction.
41///
42/// Create one with [`new`](Self::new), naming the host that performed the
43/// checks (the `authserv-id`), then add the output of each verifier. Every
44/// result has a consuming `with_*` method for chaining and a `set_*` method
45/// that takes `&mut self`. Results are written in the order they are added.
46/// A header with no results is written as `Authentication-Results:
47/// <authserv-id>; none`.
48///
49/// The [`Display`] implementation renders the header value without the field
50/// name and without the trailing CRLF.
51///
52/// # Example
53///
54/// ```rust,no_run
55/// use mail_auth::{
56///     AuthenticatedMessage, AuthenticationResults, MessageAuthenticator,
57///     headers::HeaderWriter, spf::verify::SpfParameters,
58/// };
59///
60/// # async fn run(raw_message: &[u8]) {
61/// let authenticator = MessageAuthenticator::new_cloudflare_tls().unwrap();
62/// let message = AuthenticatedMessage::parse(raw_message).unwrap();
63/// let dkim = authenticator.verify_dkim(&message).await;
64/// let spf_params = || {
65///     SpfParameters::mail_from(
66///         "192.0.2.10".parse().unwrap(),
67///         "mail.example.org",
68///         "mx.example.com",
69///         "sender@example.org",
70///     )
71/// };
72/// let spf = authenticator.verify_spf(spf_params()).await;
73///
74/// let header = AuthenticationResults::new("mx.example.com")
75///     .with_dkim_results(&dkim, message.first_from_address())
76///     .with_spf_result(&spf, &spf_params())
77///     .to_header();
78/// # }
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct AuthenticationResults<'x> {
82    pub(crate) hostname: &'x str,
83    pub(crate) auth_results: String,
84}
85
86/// A `Received-SPF` header (RFC 7208 Section 9.1).
87///
88/// Built from an SPF result with [`ReceivedSpf::new`] and written with
89/// [`HeaderWriter`]. The header holds the result, a comment explaining it,
90/// and the `receiver`, `client-ip`, `envelope-from` and `helo` keys.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ReceivedSpf {
93    pub(crate) received_spf: String,
94}
95
96impl<'x> AuthenticationResults<'x> {
97    /// Creates an empty header. `hostname` is the `authserv-id` (RFC 8601
98    /// Section 2.5): the name of the host that performed the checks.
99    pub fn new(hostname: &'x str) -> Self {
100        AuthenticationResults {
101            hostname,
102            auth_results: String::with_capacity(256),
103        }
104    }
105
106    /// Adds one `dkim` entry per DKIM output. See
107    /// [`set_dkim_result`](Self::set_dkim_result).
108    pub fn with_dkim_results(mut self, dkim: &[DkimOutput], header_from: &str) -> Self {
109        self.set_dkim_results(dkim, header_from);
110        self
111    }
112
113    /// Adds one `dkim` entry per DKIM output. See
114    /// [`set_dkim_result`](Self::set_dkim_result).
115    pub fn set_dkim_results(&mut self, dkim: &[DkimOutput], header_from: &str) {
116        for dkim in dkim {
117            self.set_dkim_result(dkim, header_from);
118        }
119    }
120
121    /// Adds a `dkim` or `dkim-atps` entry. See
122    /// [`set_dkim_result`](Self::set_dkim_result).
123    pub fn with_dkim_result(mut self, dkim: &DkimOutput, header_from: &str) -> Self {
124        self.set_dkim_result(dkim, header_from);
125        self
126    }
127
128    /// Adds a `dkim` entry (RFC 8601 Section 2.7.1) for one DKIM output.
129    ///
130    /// The entry reports `header.i` (or `header.d` when the signature has no
131    /// `i=` tag), `header.s` and `header.b`, the first eight Base64
132    /// characters of the signature (RFC 6008). An ATPS result (RFC 6541) is
133    /// written as a `dkim-atps` entry and also reports `header.from`, taken
134    /// from `header_from`, the RFC 5322 `From` address. `header_from` is
135    /// ignored for other results.
136    pub fn set_dkim_result(&mut self, dkim: &DkimOutput, header_from: &str) {
137        if !dkim.is_atps {
138            self.auth_results.push_str(";\r\n\tdkim=");
139        } else {
140            self.auth_results.push_str(";\r\n\tdkim-atps=");
141        }
142        dkim.result.as_auth_result(&mut self.auth_results);
143        if let Some(signature) = &dkim.signature {
144            if !signature.i.is_empty() {
145                self.auth_results.push_str(" header.i=");
146                push_quoted_pvalue(&mut self.auth_results, &signature.i);
147            } else {
148                self.auth_results.push_str(" header.d=");
149                push_pvalue(&mut self.auth_results, &signature.d);
150            }
151            self.auth_results.push_str(" header.s=");
152            push_pvalue(&mut self.auth_results, &signature.s);
153            if let Some(prefix) = signature.b.get(..6) {
154                self.auth_results.push_str(" header.b=");
155                base64::STANDARD.encode_append(prefix, &mut self.auth_results);
156            }
157        }
158
159        if dkim.is_atps {
160            self.auth_results.push_str(" header.from=");
161            push_quoted_pvalue(&mut self.auth_results, header_from);
162        }
163    }
164
165    /// Adds a `dkim2` entry. See [`set_dkim2_result`](Self::set_dkim2_result).
166    pub fn with_dkim2_result(mut self, dkim2: &Dkim2Output) -> Self {
167        self.set_dkim2_result(dkim2);
168        self
169    }
170
171    /// Adds a `dkim2` entry for the result of a DKIM2 chain verification.
172    ///
173    /// The entry reports `header.d` (signing domain) and `header.i`
174    /// (signature instance number) of the first link in the chain when the
175    /// chain passed, or of the first link that did not pass otherwise. Both
176    /// are omitted for a provisional verification.
177    pub fn set_dkim2_result(&mut self, dkim2: &Dkim2Output) {
178        self.auth_results.push_str(";\r\n\tdkim2=");
179        dkim2.result().as_auth_result(&mut self.auth_results);
180
181        let link = if dkim2.verification() != Verification::Full {
182            None
183        } else if matches!(dkim2.result(), Dkim2Result::Pass) {
184            dkim2.chain().first()
185        } else {
186            dkim2
187                .chain()
188                .iter()
189                .find(|link| !matches!(link.result, Dkim2Result::Pass))
190                .or_else(|| dkim2.chain().first())
191        };
192        if let Some(link) = link {
193            self.auth_results.push_str(" header.d=");
194            push_pvalue(&mut self.auth_results, &link.signature.d);
195            self.auth_results.push_str(" header.i=");
196            push_integer(&mut self.auth_results, link.signature.i as u64);
197        }
198    }
199
200    /// Adds an `spf` entry. See [`set_spf_result`](Self::set_spf_result).
201    pub fn with_spf_result(mut self, spf: &SpfOutput, params: &SpfParameters<'_>) -> Self {
202        self.set_spf_result(spf, params);
203        self
204    }
205
206    /// Adds an `spf` entry (RFC 8601 Section 2.7.2) for an SPF output.
207    ///
208    /// `params` supplies the client IP, the HELO domain, the host domain and
209    /// the MAIL FROM address. Pass the MAIL FROM address as received, empty
210    /// for the null reverse-path (`check_host` substitutes
211    /// `postmaster@<domain>` on its own); a substituted address would be
212    /// reported instead of `<>`. The reported
213    /// identity follows the identity that `check_host` evaluated: `smtp.helo`
214    /// when the HELO identity produced the result, otherwise `smtp.mailfrom`
215    /// (written as `<>` for the null reverse-path). A comment explaining the
216    /// result, including the client IP address, precedes the property.
217    pub fn set_spf_result(&mut self, spf: &SpfOutput, params: &SpfParameters<'_>) {
218        let ip_addr = params.ip();
219        let helo_domain = sanitize_pvalue(params.helo_domain());
220        self.auth_results.push_str(";\r\n\tspf=");
221        match spf_mail_from(spf, params) {
222            None => {
223                spf.result.as_spf_result(
224                    &mut self.auth_results,
225                    self.hostname,
226                    [POSTMASTER_AT, helo_domain.as_ref()],
227                    ip_addr,
228                );
229                self.auth_results.push_str(" smtp.helo=");
230                self.auth_results.push_str(helo_domain.as_ref());
231            }
232            Some(from) => {
233                let sanitized_from = sanitize_pvalue(from);
234                let mail_from = if !from.is_empty() {
235                    [sanitized_from.as_ref(), ""]
236                } else {
237                    [POSTMASTER_AT, helo_domain.as_ref()]
238                };
239                spf.result
240                    .as_spf_result(&mut self.auth_results, self.hostname, mail_from, ip_addr);
241                self.auth_results.push_str(" smtp.mailfrom=");
242                if !from.is_empty() {
243                    push_quoted_pvalue(&mut self.auth_results, from);
244                } else {
245                    self.auth_results.push_str("<>");
246                }
247            }
248        }
249    }
250
251    /// Adds an `arc` entry. See [`set_arc_result`](Self::set_arc_result).
252    #[cfg(feature = "arc")]
253    pub fn with_arc_result(mut self, arc: &ArcOutput, remote_ip: IpAddr) -> Self {
254        self.set_arc_result(arc, remote_ip);
255        self
256    }
257
258    /// Adds an `arc` entry (RFC 8617) for an ARC chain
259    /// validation result. `remote_ip` is the SMTP client address, reported as
260    /// `smtp.remote-ip`.
261    #[cfg(feature = "arc")]
262    pub fn set_arc_result(&mut self, arc: &ArcOutput, remote_ip: IpAddr) {
263        self.auth_results.push_str(";\r\n\tarc=");
264        arc.result.as_auth_result(&mut self.auth_results);
265        self.auth_results.push_str(" smtp.remote-ip=");
266        push_ip_as_pvalue(&mut self.auth_results, remote_ip);
267    }
268
269    /// Adds a `dmarc` entry. See [`set_dmarc_result`](Self::set_dmarc_result).
270    pub fn with_dmarc_result(mut self, dmarc: &DmarcOutput) -> Self {
271        self.set_dmarc_result(dmarc);
272        self
273    }
274
275    /// Adds a `dmarc` entry for a DMARC output (RFC 9989).
276    ///
277    /// The result is the best of the DKIM and SPF alignment results. The
278    /// entry reports `header.from` (the RFC 5322 `From` domain) and
279    /// `policy.dmarc`, the policy published by that domain (`none`,
280    /// `quarantine` or `reject`).
281    pub fn set_dmarc_result(&mut self, dmarc: &DmarcOutput) {
282        self.auth_results.push_str(";\r\n\tdmarc=");
283        match dmarc.mechanism_result() {
284            Some(result) => result.as_auth_result(&mut self.auth_results),
285            None => dmarc.result().as_auth_result(&mut self.auth_results),
286        }
287        self.auth_results.push_str(" header.from=");
288        push_pvalue(&mut self.auth_results, &dmarc.domain);
289        self.auth_results.push_str(match dmarc.policy {
290            Policy::Quarantine => " policy.dmarc=quarantine",
291            Policy::Reject => " policy.dmarc=reject",
292            Policy::None | Policy::Unspecified => " policy.dmarc=none",
293        });
294    }
295
296    /// Adds an `iprev` entry. See [`set_iprev_result`](Self::set_iprev_result).
297    pub fn with_iprev_result(mut self, iprev: &IprevOutput, remote_ip: IpAddr) -> Self {
298        self.set_iprev_result(iprev, remote_ip);
299        self
300    }
301
302    /// Adds an `iprev` entry (RFC 8601 Section 3) for an `iprev` output.
303    /// `remote_ip` is the SMTP client address checked, reported as
304    /// `policy.iprev`.
305    pub fn set_iprev_result(&mut self, iprev: &IprevOutput, remote_ip: IpAddr) {
306        self.auth_results.push_str(";\r\n\tiprev=");
307        iprev.result.as_auth_result(&mut self.auth_results);
308        self.auth_results.push_str(" policy.iprev=");
309        push_ip_as_pvalue(&mut self.auth_results, remote_ip);
310    }
311}
312
313impl Display for AuthenticationResults<'_> {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.write_str(self.hostname)?;
316        f.write_str(&self.auth_results)
317    }
318}
319
320impl HeaderWriter for AuthenticationResults<'_> {
321    fn write_header(&self, writer: &mut impl Writer) {
322        writer.write(b"Authentication-Results: ");
323        writer.write(self.hostname.as_bytes());
324        if !self.auth_results.is_empty() {
325            writer.write(self.auth_results.as_bytes());
326        } else {
327            writer.write(b"; none");
328        }
329        writer.write(b"\r\n");
330    }
331}
332
333impl HeaderWriter for ReceivedSpf {
334    fn write_header(&self, writer: &mut impl Writer) {
335        writer.write(b"Received-SPF: ");
336        writer.write(self.received_spf.as_bytes());
337        writer.write(b"\r\n");
338    }
339}
340
341fn spf_mail_from<'x>(spf: &SpfOutput, params: &SpfParameters<'x>) -> Option<&'x str> {
342    match spf.identity {
343        Some(SpfIdentity::Helo) => None,
344        Some(SpfIdentity::MailFrom) | None => params.mail_from_address(),
345    }
346}
347
348impl ReceivedSpf {
349    /// Builds a `Received-SPF` header (RFC 7208 Section 9.1) for an SPF
350    /// output.
351    ///
352    /// `params` supplies the values reported, as for
353    /// [`AuthenticationResults::set_spf_result`]: pass the MAIL FROM address
354    /// as received, empty for the null reverse-path. `receiver` is
355    /// the host domain, `client-ip` the SMTP client address, `helo` the
356    /// HELO/EHLO domain, and `envelope-from` the MAIL FROM address, or
357    /// `postmaster@<helo domain>` when the reverse-path is null or no MAIL FROM
358    /// was given. The comment names the identity that was evaluated, as
359    /// [`AuthenticationResults::set_spf_result`] does.
360    pub fn new(spf: &SpfOutput, params: &SpfParameters<'_>) -> Self {
361        let ip_addr = params.ip();
362        let helo = params.helo_domain();
363        let mail_from = params.mail_from_address().unwrap_or_default();
364        let hostname = params.host_domain();
365        let mut received_spf = String::with_capacity(256);
366        let helo = sanitize_pvalue(helo);
367        let checked = spf_mail_from(spf, params).unwrap_or_default();
368        let sanitized_checked = sanitize_pvalue(checked);
369        let envelope_from = if !mail_from.is_empty() {
370            [mail_from, ""]
371        } else {
372            [POSTMASTER_AT, helo.as_ref()]
373        };
374        let pieces = if !checked.is_empty() {
375            [sanitized_checked.as_ref(), ""]
376        } else {
377            [POSTMASTER_AT, helo.as_ref()]
378        };
379
380        spf.result
381            .as_spf_result(&mut received_spf, hostname, pieces, ip_addr);
382
383        received_spf.push_str("\r\n\treceiver=");
384        received_spf.push_str(hostname);
385        received_spf.push_str("; client-ip=");
386        push_ip(&mut received_spf, ip_addr);
387        received_spf.push_str("; envelope-from=\"");
388        for piece in envelope_from {
389            push_qcontent(&mut received_spf, piece);
390        }
391        received_spf.push_str("\"; helo=");
392        received_spf.push_str(helo.as_ref());
393        received_spf.push(';');
394
395        ReceivedSpf { received_spf }
396    }
397}
398
399const POSTMASTER_AT: &str = "postmaster@";
400const MAX_IP_TEXT_LEN: usize = 46;
401
402impl SpfResult {
403    fn as_spf_result(
404        &self,
405        header: &mut String,
406        hostname: &str,
407        mail_from: [&str; 2],
408        ip_addr: IpAddr,
409    ) {
410        let (result, reason, designation, close) = match self {
411            SpfResult::Pass => (
412                "pass (",
413                ": domain of ",
414                Some(" designates "),
415                " as permitted sender)",
416            ),
417            SpfResult::Fail => (
418                "fail (",
419                ": domain of ",
420                Some(" does not designate "),
421                " as permitted sender)",
422            ),
423            SpfResult::SoftFail => (
424                "softfail (",
425                ": domain of ",
426                Some(" reports soft fail for "),
427                ")",
428            ),
429            SpfResult::Neutral => (
430                "neutral (",
431                ": domain of ",
432                Some(" reports neutral for "),
433                ")",
434            ),
435            SpfResult::TempError => (
436                "temperror (",
437                ": temporary dns error validating ",
438                None,
439                ")",
440            ),
441            SpfResult::PermError => (
442                "permerror (",
443                ": unable to verify SPF record for ",
444                None,
445                ")",
446            ),
447            SpfResult::None => ("none (", ": no SPF records found for ", None, ")"),
448        };
449
450        let mail_from_len = mail_from[0].len() + mail_from[1].len();
451        header.reserve(
452            result.len()
453                + hostname.len()
454                + reason.len()
455                + mail_from_len
456                + close.len()
457                + designation.map_or(0, |text| text.len() + MAX_IP_TEXT_LEN),
458        );
459
460        header.push_str(result);
461        header.push_str(hostname);
462        header.push_str(reason);
463        for piece in mail_from {
464            header.push_str(piece);
465        }
466        if let Some(designation) = designation {
467            header.push_str(designation);
468            push_ip(header, ip_addr);
469        }
470        header.push_str(close);
471    }
472}
473
474/// Formats a verifier result as an RFC 8601 result keyword.
475///
476/// Implemented for the result enums of each verifier and for [`Error`]. Used
477/// by [`AuthenticationResults`] to write method results.
478pub(crate) trait AsAuthResult {
479    /// Appends the result keyword (such as `pass` or `temperror`) to `header`,
480    /// followed by a parenthesized comment giving the reason when the result
481    /// carries an error. For [`Error`], appends only the comment.
482    fn as_auth_result(&self, header: &mut String);
483}
484
485impl AsAuthResult for DmarcResult {
486    fn as_auth_result(&self, header: &mut String) {
487        match &self {
488            DmarcResult::Pass => header.push_str("pass"),
489            DmarcResult::Fail(err) => {
490                header.push_str("fail");
491                err.as_auth_result(header);
492            }
493            DmarcResult::PermError(err) => {
494                header.push_str("permerror");
495                err.as_auth_result(header);
496            }
497            DmarcResult::TempError(err) => {
498                header.push_str("temperror");
499                err.as_auth_result(header);
500            }
501            DmarcResult::None => header.push_str("none"),
502        }
503    }
504}
505
506impl AsAuthResult for IprevResult {
507    fn as_auth_result(&self, header: &mut String) {
508        match &self {
509            IprevResult::Pass => header.push_str("pass"),
510            IprevResult::Fail(err) => {
511                header.push_str("fail");
512                err.as_auth_result(header);
513            }
514            IprevResult::PermError(err) => {
515                header.push_str("permerror");
516                err.as_auth_result(header);
517            }
518            IprevResult::TempError(err) => {
519                header.push_str("temperror");
520                err.as_auth_result(header);
521            }
522            IprevResult::None => header.push_str("none"),
523        }
524    }
525}
526
527impl AsAuthResult for DkimResult {
528    fn as_auth_result(&self, header: &mut String) {
529        match &self {
530            DkimResult::Pass => header.push_str("pass"),
531            DkimResult::Neutral(err) => {
532                header.push_str("neutral");
533                err.as_auth_result(header);
534            }
535            DkimResult::Fail(err) => {
536                header.push_str("fail");
537                err.as_auth_result(header);
538            }
539            DkimResult::PermError(err) => {
540                header.push_str("permerror");
541                err.as_auth_result(header);
542            }
543            DkimResult::TempError(err) => {
544                header.push_str("temperror");
545                err.as_auth_result(header);
546            }
547            DkimResult::None => header.push_str("none"),
548        }
549    }
550}
551
552impl AsAuthResult for Dkim2Result {
553    fn as_auth_result(&self, header: &mut String) {
554        match &self {
555            Dkim2Result::Pass => header.push_str("pass"),
556            Dkim2Result::Fail(err) => {
557                header.push_str("fail");
558                err.as_auth_result(header);
559            }
560            Dkim2Result::PermError(err) => {
561                header.push_str("permerror");
562                err.as_auth_result(header);
563            }
564            Dkim2Result::TempError(err) => {
565                header.push_str("temperror");
566                err.as_auth_result(header);
567            }
568            Dkim2Result::None => header.push_str("none"),
569        }
570    }
571}
572
573impl AsAuthResult for Error {
574    fn as_auth_result(&self, header: &mut String) {
575        header.push_str(" (");
576        header.push_str(match self {
577            Error::Parse => "dns record parse error",
578            Error::MissingParameters => "missing parameters",
579            Error::NoHeadersFound => "no headers found",
580            Error::Crypto(CryptoError::Library(_)) => "verification failed",
581            Error::Io(_) => "i/o error",
582            Error::Base64 => "base64 error",
583            Error::Dkim(DkimError::UnsupportedAlgorithm) => "unsupported algorithm",
584            Error::Dkim(DkimError::UnsupportedCanonicalization) => "unsupported canonicalization",
585            Error::Dkim(DkimError::UnsupportedKeyType) => "unsupported key type",
586            Error::Crypto(CryptoError::FailedVerification) => "verification failed",
587            Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
588                "incompatible record/signature algorithms"
589            }
590            Error::Dns(DnsError::Resolver(_)) => "dns error",
591            Error::Dns(DnsError::RecordNotFound(_)) => "dns record not found",
592            Error::Dkim(DkimError::UnsupportedVersion) => "unsupported version",
593            Error::Dkim(DkimError::BodyHashMismatch) => "body hash did not verify",
594            #[cfg(feature = "arc")]
595            Error::Arc(ArcError::BodyHashMismatch) => "body hash did not verify",
596            Error::Dkim(DkimError::AuidMismatch) => "auid does not match",
597            Error::Dkim(DkimError::PublicKeyRevoked) => "revoked public key",
598            Error::Dkim(DkimError::SignatureExpired) => "signature error",
599            #[cfg(feature = "arc")]
600            Error::Arc(ArcError::SignatureExpired) => "signature error",
601            Error::Dkim(DkimError::BodyLengthTag) => {
602                "signature length ignored due to security risk"
603            }
604            #[cfg(feature = "arc")]
605            Error::Arc(ArcError::BodyLengthTag) => "signature length ignored due to security risk",
606            #[cfg(feature = "arc")]
607            Error::Arc(ArcError::InvalidInstance(i)) => {
608                write!(header, "invalid ARC instance {i})").ok();
609                return;
610            }
611            #[cfg(feature = "arc")]
612            Error::Arc(ArcError::InvalidChainValidation) => "invalid ARC cv",
613            #[cfg(feature = "arc")]
614            Error::Arc(ArcError::ChainTooLong) => "too many ARC headers",
615            #[cfg(feature = "arc")]
616            Error::Arc(ArcError::HasHeaderTag) => "ARC has header tag",
617            #[cfg(feature = "arc")]
618            Error::Arc(ArcError::BrokenChain) => "broken ARC chain",
619            Error::NotAligned => "policy not aligned",
620            Error::Dns(DnsError::InvalidRecordType) => "invalid dns record type",
621            Error::Dkim2(e) => {
622                write!(CommentWriter(header), "{e}").ok();
623                header.push(')');
624                return;
625            }
626        });
627        header.push(')');
628    }
629}
630
631struct CommentWriter<'x>(&'x mut String);
632
633impl Write for CommentWriter<'_> {
634    fn write_str(&mut self, value: &str) -> std::fmt::Result {
635        for ch in value.chars() {
636            match ch {
637                '(' | ')' | '\\' => {
638                    self.0.push('\\');
639                    self.0.push(ch);
640                }
641                '\0'..='\u{1f}' | '\u{7f}' => {}
642                _ => self.0.push(ch),
643            }
644        }
645        Ok(())
646    }
647}
648
649/// Encodes the IP address to be used in a [`pvalue`] field.
650///
651/// IPv4 addresses can be used as-is, but IPv6 addresses need to be quoted
652/// since they contain `:` characters.
653///
654/// [`pvalue`]: https://datatracker.ietf.org/doc/html/rfc8601#section-2.2
655fn push_ip_as_pvalue(header: &mut String, ip: IpAddr) {
656    match ip {
657        IpAddr::V4(addr) => push_ipv4(header, addr),
658        IpAddr::V6(addr) => {
659            header.push('"');
660            write!(header, "{addr}").ok();
661            header.push('"');
662        }
663    }
664}
665
666fn push_ip(header: &mut String, ip: IpAddr) {
667    match ip {
668        IpAddr::V4(addr) => push_ipv4(header, addr),
669        IpAddr::V6(addr) => {
670            write!(header, "{addr}").ok();
671        }
672    }
673}
674
675fn push_ipv4(header: &mut String, addr: Ipv4Addr) {
676    const MAX_IPV4_TEXT_LEN: usize = 15;
677
678    let mut text = [0u8; MAX_IPV4_TEXT_LEN];
679    let mut len = 0;
680    let mut push = |byte: u8| {
681        if let Some(slot) = text.get_mut(len) {
682            *slot = byte;
683            len += 1;
684        }
685    };
686
687    for (pos, octet) in addr.octets().into_iter().enumerate() {
688        if pos > 0 {
689            push(b'.');
690        }
691        if octet >= 100 {
692            push(b'0' + octet / 100);
693        }
694        if octet >= 10 {
695            push(b'0' + (octet / 10) % 10);
696        }
697        push(b'0' + octet % 10);
698    }
699
700    header.push_str(std::str::from_utf8(text.get(..len).unwrap_or_default()).unwrap_or_default());
701}
702
703fn push_integer(header: &mut String, value: u64) {
704    let mut integer = IntegerBuffer::new();
705    header.push_str(integer.text(value));
706}
707
708#[inline]
709fn is_pvalue_safe(ch: char) -> bool {
710    !matches!(ch, '\0'..=' ' | '\u{7f}'..='\u{9f}' | '(' | ')' | ';' | '=' | '"' | '\\')
711}
712
713#[inline(always)]
714fn is_pvalue_safe_ascii(ch: u8) -> bool {
715    !matches!(ch, 0..=b' ' | 0x7f..=u8::MAX | b'(' | b')' | b';' | b'=' | b'"' | b'\\')
716}
717
718#[inline]
719fn is_pvalue_clean(value: &str) -> bool {
720    value.bytes().all(is_pvalue_safe_ascii) || value.chars().all(is_pvalue_safe)
721}
722
723#[inline]
724fn sanitize_pvalue(value: &str) -> Cow<'_, str> {
725    if is_pvalue_clean(value) {
726        Cow::Borrowed(value)
727    } else {
728        Cow::Owned(value.chars().filter(|&ch| is_pvalue_safe(ch)).collect())
729    }
730}
731
732#[inline]
733fn push_pvalue(header: &mut String, value: &str) {
734    if is_pvalue_clean(value) {
735        header.push_str(value);
736    } else {
737        header.extend(value.chars().filter(|&ch| is_pvalue_safe(ch)));
738    }
739}
740
741#[inline]
742fn push_quoted_pvalue(header: &mut String, value: &str) {
743    if !value.is_empty() && is_pvalue_clean(value) {
744        header.push_str(value);
745    } else {
746        header.push('"');
747        push_qcontent(header, value);
748        header.push('"');
749    }
750}
751
752#[inline]
753fn push_qcontent(header: &mut String, value: &str) {
754    let mut start = 0;
755    for (pos, ch) in value.char_indices() {
756        match ch {
757            '"' | '\\' => {
758                header.push_str(value.get(start..pos).unwrap_or_default());
759                header.push('\\');
760                header.push(ch);
761                start = pos + 1;
762            }
763            '\0'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {
764                header.push_str(value.get(start..pos).unwrap_or_default());
765                start = pos + ch.len_utf8();
766            }
767            _ => {}
768        }
769    }
770    header.push_str(value.get(start..).unwrap_or_default());
771}
772
773#[cfg(test)]
774mod tests;