use std::net::IpAddr;
use crate::decision::PipelineInput;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ReceiveContext {
pub client_ip: IpAddr,
pub ehlo_domain: String,
pub sender: String,
pub recipient: String,
pub message: Vec<u8>,
pub hostname: String,
pub auth_results: AuthResults,
pub greylisted: bool,
pub virus_found: Option<String>,
pub content_score: f64,
pub matched_rules: Vec<String>,
pub ptr_score: f64,
pub ai_score: f64,
}
impl ReceiveContext {
pub fn new(
client_ip: IpAddr,
ehlo_domain: impl Into<String>,
sender: impl Into<String>,
recipient: impl Into<String>,
message: Vec<u8>,
hostname: impl Into<String>,
) -> Self {
Self {
client_ip,
ehlo_domain: ehlo_domain.into(),
sender: sender.into(),
recipient: recipient.into(),
message,
hostname: hostname.into(),
auth_results: AuthResults::default(),
greylisted: false,
virus_found: None,
content_score: 0.0,
matched_rules: Vec::new(),
ptr_score: 0.0,
ai_score: 0.0,
}
}
pub fn to_pipeline_input(&self, spam_threshold: f64) -> PipelineInput {
PipelineInput {
greylisted: self.greylisted,
auth: self.auth_results.clone(),
virus_found: self.virus_found.clone(),
content_score: self.content_score,
matched_rules: self.matched_rules.clone(),
ptr_score: self.ptr_score,
ai_score: self.ai_score,
spam_threshold,
hostname: self.hostname.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AuthResults {
pub spf: String,
pub dkim: String,
pub arc: String,
pub dmarc: String,
pub dmarc_policy: DmarcPolicy,
}
impl Default for AuthResults {
fn default() -> Self {
Self {
spf: "none".into(),
dkim: "none".into(),
arc: "none".into(),
dmarc: "none".into(),
dmarc_policy: DmarcPolicy::None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DmarcPolicy {
Reject,
Quarantine,
None,
Pass,
}
#[cfg(test)]
mod tests {
use std::net::Ipv4Addr;
use super::*;
fn ctx() -> ReceiveContext {
ReceiveContext::new(
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
"client.example.com",
"alice@example.com",
"bob@example.com",
b"From: alice\r\n\r\nhello".to_vec(),
"mx.example.com",
)
}
#[test]
fn new_zeroes_all_signal_fields() {
let c = ctx();
assert!(!c.greylisted);
assert!(c.virus_found.is_none());
assert_eq!(c.content_score, 0.0);
assert!(c.matched_rules.is_empty());
assert_eq!(c.ptr_score, 0.0);
assert_eq!(c.ai_score, 0.0);
}
#[test]
fn auth_results_default_is_none() {
let a = AuthResults::default();
assert_eq!(a.spf, "none");
assert_eq!(a.dkim, "none");
assert_eq!(a.arc, "none");
assert_eq!(a.dmarc, "none");
assert_eq!(a.dmarc_policy, DmarcPolicy::None);
}
#[test]
fn to_pipeline_input_carries_threshold_and_hostname() {
let c = ctx();
let input = c.to_pipeline_input(7.5);
assert_eq!(input.spam_threshold, 7.5);
assert_eq!(input.hostname, "mx.example.com");
}
#[test]
fn to_pipeline_input_round_trips_signals() {
let mut c = ctx();
c.greylisted = true;
c.virus_found = Some("X".into());
c.content_score = 1.5;
c.matched_rules.push("rule-a".into());
c.ptr_score = 0.5;
c.ai_score = 2.0;
let input = c.to_pipeline_input(5.0);
assert!(input.greylisted);
assert_eq!(input.virus_found.as_deref(), Some("X"));
assert_eq!(input.content_score, 1.5);
assert_eq!(input.matched_rules, vec!["rule-a"]);
assert_eq!(input.ptr_score, 0.5);
assert_eq!(input.ai_score, 2.0);
}
#[test]
fn clone_preserves_all_signal_state() {
let mut c = ctx();
c.content_score = 4.2;
c.matched_rules.push("foo".into());
c.ai_score = 1.1;
let cloned = c.clone();
assert_eq!(cloned.content_score, 4.2);
assert_eq!(cloned.matched_rules, vec!["foo".to_string()]);
assert_eq!(cloned.ai_score, 1.1);
let mut cloned = cloned;
cloned.content_score = 0.0;
assert_eq!(c.content_score, 4.2);
}
#[test]
fn to_pipeline_input_clones_matched_rules() {
let mut c = ctx();
c.matched_rules.push("orig".into());
let input = c.to_pipeline_input(5.0);
c.matched_rules.push("post".into());
assert_eq!(input.matched_rules, vec!["orig"]);
}
#[test]
fn auth_results_clone_independence() {
let mut a = AuthResults {
spf: "pass".into(),
..AuthResults::default()
};
let b = a.clone();
a.spf = "fail".into();
assert_eq!(b.spf, "pass");
}
}