#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcError {
Empty,
InvalidValue,
InvalidPercentage,
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 {}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcPolicy {
#[default]
None,
Quarantine,
Reject,
}
impl DmarcPolicy {
#[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),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DmarcSubdomainPolicy(DmarcPolicy);
impl DmarcSubdomainPolicy {
#[must_use]
pub const fn new(policy: DmarcPolicy) -> Self {
Self(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)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcAlignmentMode {
#[default]
Relaxed,
Strict,
}
impl DmarcAlignmentMode {
#[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())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DmarcReportUri(String);
impl DmarcReportUri {
pub fn new(value: impl AsRef<str>) -> Result<Self, DmarcError> {
validate_dmarc_text(value.as_ref()).map(|value| Self(value.to_owned()))
}
#[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())
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcFailureOption {
#[default]
All,
Any,
Dkim,
Spf,
}
impl DmarcFailureOption {
#[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())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DmarcResult {
Pass,
Fail,
TempError,
PermError,
}
impl DmarcResult {
#[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())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DmarcPercentage(u8);
impl DmarcPercentage {
pub const fn new(value: u8) -> Result<Self, DmarcError> {
if value <= 100 {
Ok(Self(value))
} else {
Err(DmarcError::InvalidPercentage)
}
}
#[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)
}
}
#[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 {
#[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,
}
}
#[must_use]
pub const fn with_subdomain_policy(mut self, policy: DmarcSubdomainPolicy) -> Self {
self.subdomain_policy = Some(policy);
self
}
#[must_use]
pub const fn with_dkim_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
self.dkim_alignment = alignment;
self
}
#[must_use]
pub const fn with_spf_alignment(mut self, alignment: DmarcAlignmentMode) -> Self {
self.spf_alignment = alignment;
self
}
#[must_use]
pub fn with_report_uri(mut self, uri: DmarcReportUri) -> Self {
self.report_uris.push(uri);
self
}
#[must_use]
pub fn with_failure_option(mut self, option: DmarcFailureOption) -> Self {
self.failure_options.push(option);
self
}
#[must_use]
pub const fn with_percentage(mut self, percentage: DmarcPercentage) -> Self {
self.percentage = Some(percentage);
self
}
#[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(())
}
}