#![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 SpfError {
Empty,
UnknownLabel,
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 {}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfVersion {
#[default]
V1,
}
impl SpfVersion {
#[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),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfQualifier {
#[default]
Pass,
Fail,
SoftFail,
Neutral,
}
impl SpfQualifier {
#[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())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfMechanism {
All,
Include(String),
A,
Mx,
Ip4(String),
Ip6(String),
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),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SpfModifier {
name: String,
value: String,
}
impl SpfModifier {
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(),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[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)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SpfTerm {
qualifier: SpfQualifier,
mechanism: SpfMechanism,
}
impl SpfTerm {
#[must_use]
pub const fn new(qualifier: SpfQualifier, mechanism: SpfMechanism) -> Self {
Self {
qualifier,
mechanism,
}
}
#[must_use]
pub const fn qualifier(&self) -> SpfQualifier {
self.qualifier
}
#[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()?))
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SpfRecord {
version: SpfVersion,
terms: Vec<SpfTerm>,
modifiers: Vec<SpfModifier>,
}
impl SpfRecord {
#[must_use]
pub const fn new() -> Self {
Self {
version: SpfVersion::V1,
terms: Vec::new(),
modifiers: Vec::new(),
}
}
#[must_use]
pub fn with_term(mut self, term: SpfTerm) -> Self {
self.terms.push(term);
self
}
#[must_use]
pub fn with_modifier(mut self, modifier: SpfModifier) -> Self {
self.modifiers.push(modifier);
self
}
#[must_use]
pub const fn version(&self) -> SpfVersion {
self.version
}
#[must_use]
pub fn terms(&self) -> &[SpfTerm] {
&self.terms
}
#[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(())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SpfResult {
Pass,
Fail,
SoftFail,
Neutral,
None,
TempError,
PermError,
}
impl SpfResult {
#[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(())
}
}