use bitflags::bitflags;
use thiserror::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Record<'a> {
pub version: &'a str,
pub dkim: AlignmentMode,
pub spf: AlignmentMode,
pub failure_reporting: FailureReportingOptions,
pub requested_receiver_policy: ReceiverPolicy,
pub subdomain_receiver_policy: ReceiverPolicy,
pub percent: u32,
pub report_format: &'a str,
pub report_interval: u32,
pub aggregate_report_addresses: Option<&'a str>,
pub message_report_addresses: Option<&'a str>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AlignmentMode {
#[default]
Relaxed,
Strict,
}
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FailureReportingOptions: u32 {
const FAIL_TO_PASS = 1 << 0;
const EVAL_NOT_PASS = 1 << 1;
const FAILED_DKIM = 1 << 2;
const FAILED_SPF = 1 << 3;
}
}
impl Default for FailureReportingOptions {
fn default() -> Self {
Self::FAIL_TO_PASS
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ReceiverPolicy {
#[default]
None,
Quarantine,
Reject,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
pub enum Error {
#[error("unsupported DMARC record version")]
UnsupportedVersion,
#[error("no DMARC record version")]
NoVersion,
#[error("no policy (p=) field")]
NoPolicy,
#[error("invalid policy (p=) field")]
InvalidPolicy,
#[error("more than one {0}= field")]
DuplicateField(&'static str),
}
impl<'a> Record<'a> {
pub fn parse(s: &'a str) -> Result<Self, Error> {
fn set_opt<T>(
field: &'static str,
opt: &mut Option<T>,
value: T,
) -> Result<(), Error> {
if opt.is_some() {
Err(Error::DuplicateField(field))
} else {
*opt = Some(value);
Ok(())
}
}
let mut version = None::<&'a str>;
let mut dkim = None::<AlignmentMode>;
let mut spf = None::<AlignmentMode>;
let mut failure_reporting = None::<FailureReportingOptions>;
let mut requested_receiver_policy = None::<ReceiverPolicy>;
let mut subdomain_receiver_policy = None::<ReceiverPolicy>;
let mut percent = None::<u32>;
let mut report_format = None::<&'a str>;
let mut report_interval = None::<u32>;
let mut aggregate_report_addresses = None::<&'a str>;
let mut message_report_addresses = None::<&'a str>;
for word in s.split(';') {
if word.is_empty() {
continue;
}
let Some((k, v)) = word.split_once('=') else {
continue;
};
let k = k.trim();
let v = v.trim();
match k {
"v" => {
set_opt("v", &mut version, v)?;
if v != "DMARC1" {
return Err(Error::UnsupportedVersion);
}
},
"adkim" => {
let mode = if "s" == v {
AlignmentMode::Strict
} else {
AlignmentMode::Relaxed
};
set_opt("adkim", &mut dkim, mode)?;
},
"aspf" => {
let mode = if "s" == v {
AlignmentMode::Strict
} else {
AlignmentMode::Relaxed
};
set_opt("aspf", &mut spf, mode)?;
},
"fo" => {
let mut opts = FailureReportingOptions::empty();
for opt in v.split(':') {
use FailureReportingOptions as Fro;
match opt {
"0" => opts |= Fro::FAIL_TO_PASS,
"1" => opts |= Fro::EVAL_NOT_PASS,
"d" => opts |= Fro::FAILED_DKIM,
"s" => opts |= Fro::FAILED_SPF,
_ => {},
}
}
if opts == FailureReportingOptions::empty() {
opts = FailureReportingOptions::default();
}
set_opt("fo", &mut failure_reporting, opts)?;
},
"p" => {
let p = ReceiverPolicy::parse(v)?;
set_opt("p", &mut requested_receiver_policy, p)?;
},
"pct" => {
let pct = v.parse::<u32>().unwrap_or(100).min(100);
set_opt("pct", &mut percent, pct)?;
},
"rf" => set_opt("rf", &mut report_format, v)?,
"ri" => {
let ri = v.parse::<u32>().unwrap_or(86400);
set_opt("ri", &mut report_interval, ri)?;
},
"rua" => set_opt("rua", &mut aggregate_report_addresses, v)?,
"ruf" => set_opt("ruf", &mut message_report_addresses, v)?,
"s" => {
let p = ReceiverPolicy::parse(v)?;
set_opt("s", &mut subdomain_receiver_policy, p)?;
},
_ => {},
}
}
let version = version.ok_or(Error::NoVersion)?;
let requested_receiver_policy =
requested_receiver_policy.ok_or(Error::NoPolicy)?;
Ok(Self {
version,
dkim: dkim.unwrap_or_default(),
spf: spf.unwrap_or_default(),
failure_reporting: failure_reporting.unwrap_or_default(),
requested_receiver_policy,
subdomain_receiver_policy: subdomain_receiver_policy
.unwrap_or(requested_receiver_policy),
percent: percent.unwrap_or(100),
report_format: report_format.unwrap_or("afrf"),
report_interval: report_interval.unwrap_or(86400),
aggregate_report_addresses,
message_report_addresses,
})
}
}
impl ReceiverPolicy {
fn parse(s: &str) -> Result<Self, Error> {
match s {
"none" => Ok(Self::None),
"quarantine" => Ok(Self::Quarantine),
"reject" => Ok(Self::Reject),
_ => Err(Error::InvalidPolicy),
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_parse() {
assert_eq!(Err(Error::NoVersion), Record::parse(""));
assert_eq!(Err(Error::UnsupportedVersion), Record::parse("v=DMARC2"));
assert_eq!(Err(Error::NoPolicy), Record::parse("v=DMARC1"));
assert_eq!(
Err(Error::InvalidPolicy),
Record::parse("v=DMARC1; p=whatever"),
);
assert_eq!(
Err(Error::DuplicateField("p")),
Record::parse("v=DMARC1; p=reject; p=none"),
);
assert_eq!(
Ok(Record {
version: "DMARC1",
dkim: AlignmentMode::Relaxed,
spf: AlignmentMode::Relaxed,
failure_reporting: FailureReportingOptions::FAIL_TO_PASS,
requested_receiver_policy: ReceiverPolicy::Quarantine,
subdomain_receiver_policy: ReceiverPolicy::Quarantine,
percent: 100,
report_format: "afrf",
report_interval: 86400,
aggregate_report_addresses: None,
message_report_addresses: None,
}),
Record::parse("v = DMARC1 ; p = quarantine "),
);
assert_eq!(
Ok(Record {
version: "DMARC1",
dkim: AlignmentMode::Strict,
spf: AlignmentMode::Strict,
failure_reporting: FailureReportingOptions::FAIL_TO_PASS
| FailureReportingOptions::EVAL_NOT_PASS
| FailureReportingOptions::FAILED_SPF
| FailureReportingOptions::FAILED_DKIM,
requested_receiver_policy: ReceiverPolicy::None,
subdomain_receiver_policy: ReceiverPolicy::Reject,
percent: 42,
report_format: "text/plain",
report_interval: 3600,
aggregate_report_addresses: Some("mailto:foo@example.com"),
message_report_addresses: Some("mailto:bar@example.com"),
}),
Record::parse(
"v=DMARC1; p=none; s=reject; adkim=s; aspf=s; fo=1:0:s:d; \
pct=42; rf=text/plain; ri=3600; \
rua=mailto:foo@example.com; ruf=mailto:bar@example.com",
),
);
assert_eq!(
Ok(Record {
version: "DMARC1",
dkim: AlignmentMode::Relaxed,
spf: AlignmentMode::Relaxed,
failure_reporting: FailureReportingOptions::FAIL_TO_PASS,
requested_receiver_policy: ReceiverPolicy::None,
subdomain_receiver_policy: ReceiverPolicy::None,
percent: 100,
report_format: "afrf",
report_interval: 86400,
aggregate_report_addresses: None,
message_report_addresses: None,
}),
Record::parse(
"v=DMARC1; p=none; adkim=y; aspf=y; fo=y; pct=all; ri=never",
),
);
}
}