use crate::auth_header::build_auth_header;
use crate::context::{AuthResults, DmarcPolicy};
#[derive(Debug, Clone, PartialEq)]
pub enum DeliveryDecision {
Accept {
auth_header: String,
},
Junk {
auth_header: String,
reason: String,
},
Reject {
code: u16,
message: String,
},
Greylist,
}
#[derive(Debug, Clone)]
pub struct PipelineInput {
pub greylisted: bool,
pub auth: AuthResults,
pub virus_found: Option<String>,
pub content_score: f64,
pub matched_rules: Vec<String>,
pub ptr_score: f64,
pub ai_score: f64,
pub spam_threshold: f64,
pub hostname: String,
}
pub fn make_delivery_decision(input: &PipelineInput) -> DeliveryDecision {
if input.greylisted {
return DeliveryDecision::Greylist;
}
if let Some(ref name) = input.virus_found {
return DeliveryDecision::Reject {
code: 550,
message: format!("5.7.1 Message rejected: virus detected ({name})"),
};
}
let dmarc_reason = match input.auth.dmarc_policy {
DmarcPolicy::Reject => Some("policy=reject"),
DmarcPolicy::Quarantine => Some("policy=quarantine"),
DmarcPolicy::None => Some("policy=none"),
DmarcPolicy::Pass => None,
};
let auth_header = build_auth_header(
&input.hostname,
&input.auth.spf,
&input.auth.dkim,
&input.auth.arc,
&input.auth.dmarc,
dmarc_reason,
);
if input.auth.dmarc_policy == DmarcPolicy::Reject {
return DeliveryDecision::Reject {
code: 550,
message: "5.7.1 DMARC policy reject".to_string(),
};
}
if input.auth.dmarc_policy == DmarcPolicy::Quarantine {
return DeliveryDecision::Junk {
auth_header,
reason: "DMARC policy quarantine".into(),
};
}
let total_score = input.content_score + input.ptr_score + input.ai_score;
if total_score >= input.spam_threshold {
return DeliveryDecision::Junk {
auth_header,
reason: build_junk_reason(input, total_score),
};
}
DeliveryDecision::Accept { auth_header }
}
fn build_junk_reason(input: &PipelineInput, total_score: f64) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(160);
let _ = write!(
out,
"score {total_score:.1} >= {:.1} (content={:.1}, ptr={:.1}, ai={:.1}, ",
input.spam_threshold, input.content_score, input.ptr_score, input.ai_score,
);
let mut first = true;
for rule in &input.matched_rules {
if !first {
out.push_str(", ");
}
out.push_str(rule);
first = false;
}
out.push(')');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn passing_auth() -> AuthResults {
AuthResults {
spf: "pass".into(),
dkim: "pass".into(),
arc: "none".into(),
dmarc: "pass".into(),
dmarc_policy: DmarcPolicy::Pass,
}
}
fn baseline_input() -> PipelineInput {
PipelineInput {
greylisted: false,
auth: passing_auth(),
virus_found: None,
content_score: 0.0,
matched_rules: vec![],
ptr_score: 0.0,
ai_score: 0.0,
spam_threshold: 5.0,
hostname: "mx.example.com".into(),
}
}
#[test]
fn baseline_passes_to_accept() {
let d = make_delivery_decision(&baseline_input());
assert!(matches!(d, DeliveryDecision::Accept { .. }));
}
#[test]
fn greylist_short_circuits_everything() {
let mut input = baseline_input();
input.greylisted = true;
input.virus_found = Some("nope".into()); input.auth.dmarc_policy = DmarcPolicy::Reject; assert_eq!(make_delivery_decision(&input), DeliveryDecision::Greylist);
}
#[test]
fn virus_yields_550() {
let mut input = baseline_input();
input.virus_found = Some("Eicar".into());
match make_delivery_decision(&input) {
DeliveryDecision::Reject { code, message } => {
assert_eq!(code, 550);
assert!(message.contains("Eicar"));
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn dmarc_reject_yields_550() {
let mut input = baseline_input();
input.auth.dmarc_policy = DmarcPolicy::Reject;
match make_delivery_decision(&input) {
DeliveryDecision::Reject { code, message } => {
assert_eq!(code, 550);
assert!(message.contains("DMARC"));
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn dmarc_quarantine_yields_junk() {
let mut input = baseline_input();
input.auth.dmarc_policy = DmarcPolicy::Quarantine;
match make_delivery_decision(&input) {
DeliveryDecision::Junk { reason, .. } => {
assert!(reason.contains("DMARC"));
}
other => panic!("expected Junk, got {other:?}"),
}
}
#[test]
fn score_above_threshold_yields_junk_with_reason() {
let mut input = baseline_input();
input.content_score = 3.0;
input.ptr_score = 2.0;
input.ai_score = 1.5;
input.matched_rules = vec!["bulk-list".into(), "shouting-subject".into()];
match make_delivery_decision(&input) {
DeliveryDecision::Junk { reason, .. } => {
assert!(reason.contains("6.5"));
assert!(reason.contains("bulk-list"));
}
other => panic!("expected Junk, got {other:?}"),
}
}
#[test]
fn score_below_threshold_passes_to_accept() {
let mut input = baseline_input();
input.content_score = 1.0;
input.ptr_score = 0.5;
input.ai_score = 1.0;
input.spam_threshold = 5.0;
assert!(matches!(
make_delivery_decision(&input),
DeliveryDecision::Accept { .. }
));
}
#[test]
fn precedence_greylist_over_virus() {
let mut input = baseline_input();
input.greylisted = true;
input.virus_found = Some("x".into());
assert_eq!(make_delivery_decision(&input), DeliveryDecision::Greylist);
}
#[test]
fn precedence_virus_over_dmarc() {
let mut input = baseline_input();
input.virus_found = Some("x".into());
input.auth.dmarc_policy = DmarcPolicy::Reject;
match make_delivery_decision(&input) {
DeliveryDecision::Reject { message, .. } => {
assert!(message.contains("virus"));
}
other => panic!("expected virus Reject, got {other:?}"),
}
}
#[test]
fn precedence_dmarc_reject_over_dmarc_quarantine_check() {
let mut input = baseline_input();
input.auth.dmarc_policy = DmarcPolicy::Reject;
assert!(matches!(
make_delivery_decision(&input),
DeliveryDecision::Reject { .. }
));
}
#[test]
fn accept_carries_auth_header() {
let d = make_delivery_decision(&baseline_input());
match d {
DeliveryDecision::Accept { auth_header } => {
assert!(auth_header.starts_with("Authentication-Results:"));
assert!(auth_header.contains("spf=pass"));
}
other => panic!("expected Accept, got {other:?}"),
}
}
#[test]
fn junk_carries_auth_header() {
let mut input = baseline_input();
input.content_score = 100.0;
match make_delivery_decision(&input) {
DeliveryDecision::Junk { auth_header, .. } => {
assert!(auth_header.starts_with("Authentication-Results:"));
}
other => panic!("expected Junk, got {other:?}"),
}
}
#[test]
fn virus_wins_over_dmarc_reject_simultaneously() {
let mut input = baseline_input();
input.virus_found = Some("Test.Virus".into());
input.auth.dmarc_policy = DmarcPolicy::Reject;
match make_delivery_decision(&input) {
DeliveryDecision::Reject { code, message } => {
assert_eq!(code, 550);
assert!(message.contains("virus"));
assert!(!message.contains("DMARC"), "virus message should not mention DMARC");
}
other => panic!("expected virus Reject, got {other:?}"),
}
}
#[test]
fn dmarc_none_does_not_reject_or_quarantine() {
let mut input = baseline_input();
input.auth.dmarc_policy = DmarcPolicy::None;
let d = make_delivery_decision(&input);
assert!(matches!(d, DeliveryDecision::Accept { .. }));
}
#[test]
fn score_exactly_at_threshold_yields_junk() {
let mut input = baseline_input();
input.content_score = 5.0;
input.spam_threshold = 5.0;
match make_delivery_decision(&input) {
DeliveryDecision::Junk { .. } => {}
other => panic!("expected Junk at == threshold, got {other:?}"),
}
}
#[test]
fn score_just_below_threshold_yields_accept() {
let mut input = baseline_input();
input.content_score = 4.999;
input.spam_threshold = 5.0;
let d = make_delivery_decision(&input);
assert!(matches!(d, DeliveryDecision::Accept { .. }));
}
#[test]
fn score_components_summed_independently() {
let mut input = baseline_input();
input.content_score = 1.0;
input.ptr_score = 2.0;
input.ai_score = 3.0;
match make_delivery_decision(&input) {
DeliveryDecision::Junk { reason, .. } => {
assert!(reason.contains("6.0") || reason.contains("6"));
}
other => panic!("expected Junk, got {other:?}"),
}
}
#[test]
fn empty_matched_rules_in_junk_reason_renders_cleanly() {
let mut input = baseline_input();
input.content_score = 10.0;
input.matched_rules.clear();
match make_delivery_decision(&input) {
DeliveryDecision::Junk { reason, .. } => {
assert!(reason.contains("score"));
}
other => panic!("expected Junk, got {other:?}"),
}
}
}