spf-milter 0.6.0

Milter for SPF verification
Documentation
// SPF Milter – milter for SPF verification
// Copyright © 2020–2023 David Bürgin <dbuergin@gluet.ch>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.

use crate::{
    auth::SpfResultKind,
    header::{format, HeaderField, HEADER_LINE_WIDTH},
    verify::{Identity, VerificationResult},
};
use std::{
    borrow::Cow,
    fmt::{self, Display, Formatter, Write},
};
use viaspf::{DomainName, SpfResultCause};

// The `Authentication-Results` header field is defined in RFC 8601.

/// Extracts the *authserv-id* from an `Authentication-Results` header field
/// value, if found.
pub fn extract_authserv_id(value: &str) -> Option<Cow<'_, str>> {
    // *authserv-id* is a lexical token of kind `value` as defined in RFC 2045,
    // section 5.1, that is, either a `token` or a quoted string. Immediately
    // before the *authserv-id* there may be a CFWS.
    let value = format::strip_cfws(value).unwrap_or(value);

    if let Some(rest) = format::strip_mime_value(value) {
        // Directly after the *authserv-id* may come either a semicolon or
        // another CFWS. Validation proceeds no further than this.
        if rest.starts_with(';') || format::strip_cfws(rest).is_some() {
            // We have a match. If it is a quoted string, now it needs to be
            // decoded to be in a form comparable with another *authserv-id*.
            let authserv_id = &value[..(value.len() - rest.len())];
            return Some(if authserv_id.starts_with('"') {
                format::decode_quoted_string(authserv_id).into()
            } else {
                authserv_id.into()
            });
        }
    }

    None
}

/// An `Authentication-Results` header, containing the header field body in an
/// encoded, pre-formatted form.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct AuthenticationResultsHeader {
    body_pieces: Vec<String>,
}

impl AuthenticationResultsHeader {
    pub const NAME: &'static str = "Authentication-Results";

    pub fn new(
        authserv_id: &str,
        results: &[VerificationResult],
        include_mailfrom_local_part: bool,
    ) -> Self {
        assert!(!results.is_empty());
        Self {
            body_pieces: prepare_field_body(authserv_id, results, include_mailfrom_local_part),
        }
    }
}

fn prepare_field_body(
    authserv_id: &str,
    results: &[VerificationResult],
    include_mailfrom_local_part: bool,
) -> Vec<String> {
    let mut parts = make_auth_results_parts(authserv_id, results, include_mailfrom_local_part);

    if let [parts @ .., _] = &mut parts[..] {
        for part in parts {
            part.push(';');
        }
    }

    parts
}

fn make_auth_results_parts(
    authserv_id: &str,
    results: &[VerificationResult],
    include_mailfrom_local_part: bool,
) -> Vec<String> {
    let mut parts = Vec::new();

    parts.push(format::encode_mime_value(authserv_id).into());

    for VerificationResult { identity, spf_result, cause } in results {
        let mut resinfo = String::new();

        write!(resinfo, "spf={}", spf_result.kind()).unwrap();

        // Only in the case of an error result do we record the result cause as
        // the ‘reason’.
        if let Some(SpfResultCause::Error(error_cause)) = cause {
            write!(
                resinfo,
                " reason={}",
                format::encode_mime_value(&error_cause.to_string())
            )
            .unwrap();
        }

        write!(
            resinfo,
            " smtp.{}={}",
            identity.name(),
            encode_sender(identity, include_mailfrom_local_part)
        )
        .unwrap();

        parts.push(resinfo);
    }

    parts
}

fn encode_sender(identity: &Identity, include_mailfrom_local_part: bool) -> String {
    let mut sender = identity.as_ref();

    // The `Authentication-Results` header only records ‘authenticated’ data.
    // According to RFC 8601, even the MAIL FROM identity *should* therefore (by
    // default) only record the domain without the local-part.
    //
    // Also, RFC 8616, section 5 states that internationalised domain names
    // should be in Unicode form. See the reference to RFC 6376, section 3.5 and
    // RFC 8601, section 2.2.

    let s;
    if let Identity::MailFrom(mail_from) = identity {
        if let Some((l, d)) = mail_from.rsplit_once('@') {
            let (udomain, e) = idna::domain_to_unicode(d);

            // Only use flawless conversions, and only if something changed.
            if e.is_ok() && !udomain.eq_ignore_ascii_case(d) {
                if include_mailfrom_local_part {
                    s = format!("{l}@{udomain}");
                } else {
                    s = udomain;
                }
                sender = &s;
            } else if !include_mailfrom_local_part {
                sender = d;
            }
        }
    };

    // Encode the sender identity as an RFC 2045 `value`, unless it conforms to
    // the production `[local-part "@"] domain-name` of RFC 8601, section 2.2,
    // in which case quoting must not be applied.
    if is_valid_unquoted_pvalue(sender) {
        sender.into()
    } else {
        format::encode_mime_value(sender).into()
    }
}

fn is_valid_unquoted_pvalue(mut s: &str) -> bool {
    // See RFC 5322, section 3.4.1.
    fn is_local_part(s: &str) -> bool {
        format::is_dot_atom(s) || format::is_quoted_string(s)
    }
    // See RFC 6376, section 3.5, and RFC 8616.
    fn is_domain_name(s: &str) -> bool {
        DomainName::new(s).is_ok()
    }

    if let Some((l, d)) = s.rsplit_once('@') {
        if !is_local_part(l) {
            return false;
        }
        s = d;
    }
    is_domain_name(s)
}

impl Display for AuthenticationResultsHeader {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", Self::NAME, self.body_pieces.join(" "))
    }
}

impl HeaderField for AuthenticationResultsHeader {
    fn name(&self) -> &'static str {
        Self::NAME
    }

    fn format_body(&self) -> String {
        let body_len: usize = self
            .body_pieces
            .iter()
            .map(|p| p.chars().count() + 1)  // item length plus leading space
            .sum();
        let name_len = Self::NAME.len() + 1;  // name length plus colon
        self.body_pieces
            .join(if name_len + body_len <= HEADER_LINE_WIDTH {
                " "
            } else {
                "\n\t"
            })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use viaspf::{
        record::{Mechanism, A},
        ErrorCause, SpfResult,
    };

    #[test]
    fn extract_authserv_id_ok() {
        assert_eq!(extract_authserv_id("x"), None);
        assert_eq!(extract_authserv_id("x;"), Some("x".into()));
        assert_eq!(extract_authserv_id("\"x\";"), Some("x".into()));
        assert_eq!(extract_authserv_id("x\n\t;"), Some("x".into()));
        assert_eq!(extract_authserv_id("xy.abc "), Some("xy.abc".into()));
        assert_eq!(
            extract_authserv_id(" (w h\n\tat ) x.34-q*y(\n\t());"),
            Some("x.34-q*y".into())
        );
        assert_eq!(
            extract_authserv_id(" (w h\n\tat ) \"x.34🚀-q?y\\ \"(\n\t());"),
            Some("x.34🚀-q?y ".into())
        );
    }

    #[test]
    fn encode_sender_ok() {
        use Identity::*;

        assert_eq!(encode_sender(&Helo("mail.gluet.ch".into()), false), "mail.gluet.ch");
        assert_eq!(encode_sender(&Helo("mail.gluet.ch.".into()), false), "mail.gluet.ch.");
        assert_eq!(encode_sender(&Helo("_mail.gluet.ch".into()), false), "_mail.gluet.ch");
        assert_eq!(encode_sender(&Helo("mail.例子.cn".into()), false), "mail.例子.cn");
        assert_eq!(encode_sender(&Helo(":unknown".into()), false), "\":unknown\"");

        assert_eq!(encode_sender(&MailFrom("me@localhost".into()), false), "localhost");
        assert_eq!(encode_sender(&MailFrom("me@例子.cn".into()), false), "例子.cn");

        assert_eq!(encode_sender(&MailFrom("me@例子.cn".into()), true), "me@例子.cn");
        assert_eq!(encode_sender(&MailFrom("me+you=♡@例子.cn".into()), true), "me+you=♡@例子.cn");
        assert_eq!(encode_sender(&MailFrom("\"me\"@例子.cn".into()), true), "\"me\"@例子.cn");
        assert_eq!(encode_sender(&MailFrom("me@例子".into()), true), "\"me@例子\"");
        assert_eq!(encode_sender(&MailFrom("me.@例子.cn".into()), true), "\"me.@例子.cn\"");

        assert_eq!(
            encode_sender(&Helo("mail.xn--fsqu00a.xn--fiqs8s".into()), false),
            "mail.xn--fsqu00a.xn--fiqs8s"
        );
        assert_eq!(
            encode_sender(&MailFrom("me@mail.xn--fsqu00a.xn--fiqs8s".into()), false),
            "mail.例子.中国"
        );
        assert_eq!(
            encode_sender(&MailFrom("me@mail.xn--fsqu00a.xn--fiqs8s".into()), true),
            "me@mail.例子.中国"
        );
        assert_eq!(
            encode_sender(&MailFrom("me@MAIL.example.com".into()), false),
            "MAIL.example.com"
        );
    }

    #[test]
    fn auth_results_header_display_error() {
        let results = [VerificationResult {
            identity: Identity::MailFrom("me@example.org".into()),
            spf_result: SpfResult::Permerror,
            cause: Some(SpfResultCause::Error(ErrorCause::LookupLimitExceeded)),
        }];

        let header = AuthenticationResultsHeader::new("mail.example.com", &results, false);

        assert_eq!(
            header.to_string(),
            "Authentication-Results: \
            mail.example.com; \
            spf=permerror reason=\"lookup limit exceeded\" smtp.mailfrom=example.org"
        );
    }

    #[test]
    fn auth_results_header_display_all_results() {
        let results = [
            VerificationResult {
                identity: Identity::Helo("mail.example.org".into()),
                spf_result: SpfResult::None,
                cause: None,
            },
            VerificationResult {
                identity: Identity::MailFrom("me@example.org".into()),
                spf_result: SpfResult::Pass,
                cause: Some(SpfResultCause::Match(Mechanism::A(A {
                    domain_spec: None,
                    prefix_len: None,
                }))),
            },
        ];

        let header = AuthenticationResultsHeader::new("mail.example.com", &results, true);

        assert_eq!(
            header.to_string(),
            "Authentication-Results: \
            mail.example.com; \
            spf=none smtp.helo=mail.example.org; \
            spf=pass smtp.mailfrom=me@example.org"
        );
    }
}