use-spf 0.1.0

SPF record metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned when SPF metadata is invalid.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfError {
    /// The supplied value was empty.
    Empty,
    /// The supplied label was not recognized.
    UnknownLabel,
    /// The supplied term was invalid.
    InvalidTerm,
}

impl fmt::Display for SpfError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("SPF value cannot be empty"),
            Self::UnknownLabel => formatter.write_str("unknown SPF label"),
            Self::InvalidTerm => formatter.write_str("invalid SPF term"),
        }
    }
}

impl Error for SpfError {}

/// SPF record version marker.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfVersion {
    /// SPF version 1.
    #[default]
    V1,
}

impl SpfVersion {
    /// Returns the record version label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        "v=spf1"
    }
}

impl fmt::Display for SpfVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SpfVersion {
    type Err = SpfError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "v=spf1" | "spf1" => Ok(Self::V1),
            "" => Err(SpfError::Empty),
            _ => Err(SpfError::UnknownLabel),
        }
    }
}

/// SPF mechanism qualifier.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfQualifier {
    /// Positive pass qualifier. This is omitted when rendered.
    #[default]
    Pass,
    /// Fail qualifier.
    Fail,
    /// Soft-fail qualifier.
    SoftFail,
    /// Neutral qualifier.
    Neutral,
}

impl SpfQualifier {
    /// Returns the SPF qualifier prefix.
    #[must_use]
    pub const fn as_prefix(self) -> &'static str {
        match self {
            Self::Pass => "",
            Self::Fail => "-",
            Self::SoftFail => "~",
            Self::Neutral => "?",
        }
    }
}

impl fmt::Display for SpfQualifier {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_prefix())
    }
}

/// SPF mechanism metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfMechanism {
    /// `all` mechanism.
    All,
    /// `include:<domain>` mechanism.
    Include(String),
    /// `a` mechanism.
    A,
    /// `mx` mechanism.
    Mx,
    /// `ip4:<cidr>` mechanism.
    Ip4(String),
    /// `ip6:<cidr>` mechanism.
    Ip6(String),
    /// `exists:<domain>` mechanism.
    Exists(String),
}

impl fmt::Display for SpfMechanism {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::All => formatter.write_str("all"),
            Self::Include(domain) => write!(formatter, "include:{domain}"),
            Self::A => formatter.write_str("a"),
            Self::Mx => formatter.write_str("mx"),
            Self::Ip4(cidr) => write!(formatter, "ip4:{cidr}"),
            Self::Ip6(cidr) => write!(formatter, "ip6:{cidr}"),
            Self::Exists(domain) => write!(formatter, "exists:{domain}"),
        }
    }
}

impl FromStr for SpfMechanism {
    type Err = SpfError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = validate_spf_text(value)?;
        match trimmed.to_ascii_lowercase().as_str() {
            "all" => Ok(Self::All),
            "a" => Ok(Self::A),
            "mx" => Ok(Self::Mx),
            _ if trimmed.starts_with("include:") => Ok(Self::Include(trimmed[8..].to_owned())),
            _ if trimmed.starts_with("ip4:") => Ok(Self::Ip4(trimmed[4..].to_owned())),
            _ if trimmed.starts_with("ip6:") => Ok(Self::Ip6(trimmed[4..].to_owned())),
            _ if trimmed.starts_with("exists:") => Ok(Self::Exists(trimmed[7..].to_owned())),
            _ => Err(SpfError::UnknownLabel),
        }
    }
}

/// SPF modifier metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SpfModifier {
    name: String,
    value: String,
}

impl SpfModifier {
    /// Creates an SPF modifier.
    pub fn new(name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self, SpfError> {
        let name = validate_spf_text(name.as_ref())?;
        let value = validate_spf_text(value.as_ref())?;
        if name.contains('=') {
            return Err(SpfError::InvalidTerm);
        }
        Ok(Self {
            name: name.to_owned(),
            value: value.to_owned(),
        })
    }

    /// Returns the modifier name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns the modifier value.
    #[must_use]
    pub fn value(&self) -> &str {
        &self.value
    }
}

impl fmt::Display for SpfModifier {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}={}", self.name, self.value)
    }
}

/// One SPF term with qualifier and mechanism.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SpfTerm {
    qualifier: SpfQualifier,
    mechanism: SpfMechanism,
}

impl SpfTerm {
    /// Creates an SPF term.
    #[must_use]
    pub const fn new(qualifier: SpfQualifier, mechanism: SpfMechanism) -> Self {
        Self {
            qualifier,
            mechanism,
        }
    }

    /// Returns the qualifier.
    #[must_use]
    pub const fn qualifier(&self) -> SpfQualifier {
        self.qualifier
    }

    /// Returns the mechanism.
    #[must_use]
    pub const fn mechanism(&self) -> &SpfMechanism {
        &self.mechanism
    }
}

impl fmt::Display for SpfTerm {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}{}", self.qualifier, self.mechanism)
    }
}

impl FromStr for SpfTerm {
    type Err = SpfError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = validate_spf_text(value)?;
        let (qualifier, mechanism_text) = match trimmed.as_bytes().first() {
            Some(b'-') => (SpfQualifier::Fail, &trimmed[1..]),
            Some(b'~') => (SpfQualifier::SoftFail, &trimmed[1..]),
            Some(b'?') => (SpfQualifier::Neutral, &trimmed[1..]),
            Some(b'+') => (SpfQualifier::Pass, &trimmed[1..]),
            _ => (SpfQualifier::Pass, trimmed),
        };
        Ok(Self::new(qualifier, mechanism_text.parse()?))
    }
}

/// SPF record metadata.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SpfRecord {
    version: SpfVersion,
    terms: Vec<SpfTerm>,
    modifiers: Vec<SpfModifier>,
}

impl SpfRecord {
    /// Creates an empty SPF v1 record.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            version: SpfVersion::V1,
            terms: Vec::new(),
            modifiers: Vec::new(),
        }
    }

    /// Adds a term and returns the updated record.
    #[must_use]
    pub fn with_term(mut self, term: SpfTerm) -> Self {
        self.terms.push(term);
        self
    }

    /// Adds a modifier and returns the updated record.
    #[must_use]
    pub fn with_modifier(mut self, modifier: SpfModifier) -> Self {
        self.modifiers.push(modifier);
        self
    }

    /// Returns the version.
    #[must_use]
    pub const fn version(&self) -> SpfVersion {
        self.version
    }

    /// Returns terms.
    #[must_use]
    pub fn terms(&self) -> &[SpfTerm] {
        &self.terms
    }

    /// Returns modifiers.
    #[must_use]
    pub fn modifiers(&self) -> &[SpfModifier] {
        &self.modifiers
    }
}

impl fmt::Display for SpfRecord {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}", self.version)?;
        for term in &self.terms {
            write!(formatter, " {term}")?;
        }
        for modifier in &self.modifiers {
            write!(formatter, " {modifier}")?;
        }
        Ok(())
    }
}

/// Possible SPF result labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfResult {
    /// SPF pass.
    Pass,
    /// SPF fail.
    Fail,
    /// SPF soft fail.
    SoftFail,
    /// SPF neutral.
    Neutral,
    /// No SPF policy was available.
    None,
    /// Temporary error.
    TempError,
    /// Permanent error.
    PermError,
}

impl SpfResult {
    /// Returns the result label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Pass => "pass",
            Self::Fail => "fail",
            Self::SoftFail => "softfail",
            Self::Neutral => "neutral",
            Self::None => "none",
            Self::TempError => "temperror",
            Self::PermError => "permerror",
        }
    }
}

impl fmt::Display for SpfResult {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

fn validate_spf_text(value: &str) -> Result<&str, SpfError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(SpfError::Empty);
    }
    if trimmed.chars().any(char::is_whitespace) {
        return Err(SpfError::InvalidTerm);
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{SpfMechanism, SpfQualifier, SpfRecord, SpfTerm};

    #[test]
    fn renders_spf_records() {
        let record = SpfRecord::new()
            .with_term(SpfTerm::new(SpfQualifier::Pass, SpfMechanism::Mx))
            .with_term(SpfTerm::new(SpfQualifier::Fail, SpfMechanism::All));

        assert_eq!(record.to_string(), "v=spf1 mx -all");
    }

    #[test]
    fn parses_spf_terms() -> Result<(), super::SpfError> {
        let term: SpfTerm = "~include:example.com".parse()?;

        assert_eq!(term.qualifier(), SpfQualifier::SoftFail);
        assert_eq!(term.to_string(), "~include:example.com");
        Ok(())
    }
}