use-dmarc 0.1.0

DMARC policy 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 DMARC metadata is invalid.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcError {
    /// The supplied value was empty.
    Empty,
    /// The supplied value was invalid for this primitive.
    InvalidValue,
    /// The supplied percentage was outside 0..=100.
    InvalidPercentage,
    /// The supplied label was not recognized.
    UnknownLabel,
}

impl fmt::Display for DmarcError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("DMARC value cannot be empty"),
            Self::InvalidValue => formatter.write_str("invalid DMARC value"),
            Self::InvalidPercentage => {
                formatter.write_str("DMARC percentage must be between 0 and 100")
            }
            Self::UnknownLabel => formatter.write_str("unknown DMARC label"),
        }
    }
}

impl Error for DmarcError {}

/// DMARC policy label.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcPolicy {
    /// Monitor only.
    #[default]
    None,
    /// Quarantine failing mail.
    Quarantine,
    /// Reject failing mail.
    Reject,
}

impl DmarcPolicy {
    /// Returns the policy label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Quarantine => "quarantine",
            Self::Reject => "reject",
        }
    }
}

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

impl FromStr for DmarcPolicy {
    type Err = DmarcError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "none" => Ok(Self::None),
            "quarantine" => Ok(Self::Quarantine),
            "reject" => Ok(Self::Reject),
            "" => Err(DmarcError::Empty),
            _ => Err(DmarcError::UnknownLabel),
        }
    }
}

/// DMARC subdomain policy wrapper.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DmarcSubdomainPolicy(DmarcPolicy);

impl DmarcSubdomainPolicy {
    /// Creates a subdomain policy.
    #[must_use]
    pub const fn new(policy: DmarcPolicy) -> Self {
        Self(policy)
    }

    /// Returns the policy.
    #[must_use]
    pub const fn policy(self) -> DmarcPolicy {
        self.0
    }
}

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

/// DMARC alignment mode.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcAlignmentMode {
    /// Relaxed alignment.
    #[default]
    Relaxed,
    /// Strict alignment.
    Strict,
}

impl DmarcAlignmentMode {
    /// Returns the DMARC tag label.
    #[must_use]
    pub const fn as_tag_value(self) -> &'static str {
        match self {
            Self::Relaxed => "r",
            Self::Strict => "s",
        }
    }
}

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

/// DMARC reporting URI metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DmarcReportUri(String);

impl DmarcReportUri {
    /// Creates a report URI metadata value.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DmarcError> {
        validate_dmarc_text(value.as_ref()).map(|value| Self(value.to_owned()))
    }

    /// Returns the report URI text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

/// DMARC failure reporting option.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcFailureOption {
    /// Report all failures.
    #[default]
    All,
    /// Report any failure.
    Any,
    /// Report DKIM failures.
    Dkim,
    /// Report SPF failures.
    Spf,
}

impl DmarcFailureOption {
    /// Returns the DMARC failure option tag value.
    #[must_use]
    pub const fn as_tag_value(self) -> &'static str {
        match self {
            Self::All => "0",
            Self::Any => "1",
            Self::Dkim => "d",
            Self::Spf => "s",
        }
    }
}

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

/// DMARC result label.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcResult {
    /// DMARC passed.
    Pass,
    /// DMARC failed.
    Fail,
    /// Temporary error.
    TempError,
    /// Permanent error.
    PermError,
}

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

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

/// DMARC percentage tag value.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DmarcPercentage(u8);

impl DmarcPercentage {
    /// Creates a percentage in the inclusive range 0..=100.
    pub const fn new(value: u8) -> Result<Self, DmarcError> {
        if value <= 100 {
            Ok(Self(value))
        } else {
            Err(DmarcError::InvalidPercentage)
        }
    }

    /// Returns the numeric percentage.
    #[must_use]
    pub const fn value(self) -> u8 {
        self.0
    }
}

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

/// DMARC record metadata. This type does not enforce policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DmarcRecord {
    policy: DmarcPolicy,
    subdomain_policy: Option<DmarcSubdomainPolicy>,
    dkim_alignment: DmarcAlignmentMode,
    spf_alignment: DmarcAlignmentMode,
    report_uris: Vec<DmarcReportUri>,
    failure_options: Vec<DmarcFailureOption>,
    percentage: Option<DmarcPercentage>,
}

impl DmarcRecord {
    /// Creates a DMARC record with the requested policy.
    #[must_use]
    pub const fn new(policy: DmarcPolicy) -> Self {
        Self {
            policy,
            subdomain_policy: None,
            dkim_alignment: DmarcAlignmentMode::Relaxed,
            spf_alignment: DmarcAlignmentMode::Relaxed,
            report_uris: Vec::new(),
            failure_options: Vec::new(),
            percentage: None,
        }
    }

    /// Sets the subdomain policy.
    #[must_use]
    pub const fn with_subdomain_policy(mut self, policy: DmarcSubdomainPolicy) -> Self {
        self.subdomain_policy = Some(policy);
        self
    }

    /// Sets DKIM alignment.
    #[must_use]
    pub const fn with_dkim_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
        self.dkim_alignment = alignment;
        self
    }

    /// Sets SPF alignment.
    #[must_use]
    pub const fn with_spf_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
        self.spf_alignment = alignment;
        self
    }

    /// Adds a reporting URI.
    #[must_use]
    pub fn with_report_uri(mut self, uri: DmarcReportUri) -> Self {
        self.report_uris.push(uri);
        self
    }

    /// Adds a failure option.
    #[must_use]
    pub fn with_failure_option(mut self, option: DmarcFailureOption) -> Self {
        self.failure_options.push(option);
        self
    }

    /// Sets the percentage tag.
    #[must_use]
    pub const fn with_percentage(mut self, percentage: DmarcPercentage) -> Self {
        self.percentage = Some(percentage);
        self
    }

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

impl fmt::Display for DmarcRecord {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "v=DMARC1; p={}", self.policy)?;
        if let Some(policy) = self.subdomain_policy {
            write!(formatter, "; sp={policy}")?;
        }
        if self.dkim_alignment != DmarcAlignmentMode::Relaxed {
            write!(formatter, "; adkim={}", self.dkim_alignment)?;
        }
        if self.spf_alignment != DmarcAlignmentMode::Relaxed {
            write!(formatter, "; aspf={}", self.spf_alignment)?;
        }
        if let Some(percentage) = self.percentage {
            write!(formatter, "; pct={percentage}")?;
        }
        if !self.report_uris.is_empty() {
            formatter.write_str("; rua=")?;
            for (index, uri) in self.report_uris.iter().enumerate() {
                if index > 0 {
                    formatter.write_str(",")?;
                }
                write!(formatter, "{uri}")?;
            }
        }
        if !self.failure_options.is_empty() {
            formatter.write_str("; fo=")?;
            for (index, option) in self.failure_options.iter().enumerate() {
                if index > 0 {
                    formatter.write_str(":")?;
                }
                write!(formatter, "{option}")?;
            }
        }
        Ok(())
    }
}

fn validate_dmarc_text(value: &str) -> Result<&str, DmarcError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(DmarcError::Empty);
    }
    if trimmed
        .chars()
        .any(|character| character.is_control() || character.is_whitespace())
    {
        return Err(DmarcError::InvalidValue);
    }
    Ok(trimmed)
}

#[cfg(test)]
mod tests {
    use super::{
        DmarcAlignmentMode, DmarcError, DmarcPercentage, DmarcPolicy, DmarcRecord, DmarcReportUri,
    };

    #[test]
    fn renders_policy_records() -> Result<(), DmarcError> {
        let record = DmarcRecord::new(DmarcPolicy::Quarantine)
            .with_spf_alignment(DmarcAlignmentMode::Strict)
            .with_percentage(DmarcPercentage::new(50)?)
            .with_report_uri(DmarcReportUri::new("mailto:dmarc@example.com")?);

        assert_eq!(
            record.to_string(),
            "v=DMARC1; p=quarantine; aspf=s; pct=50; rua=mailto:dmarc@example.com"
        );
        Ok(())
    }

    #[test]
    fn parses_policy_labels() -> Result<(), DmarcError> {
        assert_eq!("reject".parse::<DmarcPolicy>()?, DmarcPolicy::Reject);
        assert_eq!(
            DmarcPercentage::new(101),
            Err(DmarcError::InvalidPercentage)
        );
        Ok(())
    }
}