mod context;
mod test_engine;
use crate::ast::{Action, Argument, Command, Envelope, Test};
use crate::capabilities::validate as validate_capabilities;
use crate::parse::{ParseError, parse_script};
use crate::vacation::parse_vacation_args;
use context::MessageContext;
use test_engine::eval_test;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum EvalError {
#[error("parse: {0}")]
Parse(#[from] ParseError),
#[error("unknown command {0:?}")]
UnknownCommand(String),
#[error("unknown test {0:?}")]
UnknownTest(String),
#[error("invalid argument for {cmd:?}: {detail}")]
BadArg {
cmd: String,
detail: String,
},
#[error(
"missing capability for {feature:?}: script must `require [\"{capability}\"]`"
)]
MissingCapability {
feature: String,
capability: String,
},
#[error("unsupported capability {0:?}")]
UnsupportedCapability(String),
#[error("require out of order: must appear before any other action")]
RequireOutOfOrder,
}
pub fn eval_script(script: &str, message: &[u8]) -> Result<Vec<Action>, EvalError> {
eval_script_with_envelope(script, message, &Envelope::default())
}
pub fn eval_script_with_envelope(
script: &str,
message: &[u8],
envelope: &Envelope,
) -> Result<Vec<Action>, EvalError> {
let commands = parse_script(script)?;
validate_capabilities(&commands)?;
let ctx = MessageContext::new(message);
let mut state = EvalState::default();
eval_block(&commands, &ctx, envelope, &mut state)?;
if !state.explicit_action {
state.actions.push(Action::Keep {
flags: state.flags.clone(),
});
}
Ok(state.actions)
}
#[derive(Default)]
struct EvalState {
actions: Vec<Action>,
explicit_action: bool,
last_if_matched: bool,
stopped: bool,
flags: Vec<String>,
}
fn eval_block(
commands: &[Command],
ctx: &MessageContext<'_>,
envelope: &Envelope,
state: &mut EvalState,
) -> Result<(), EvalError> {
for cmd in commands {
if state.stopped {
break;
}
eval_command(cmd, ctx, envelope, state)?;
}
Ok(())
}
fn eval_command(
cmd: &Command,
ctx: &MessageContext<'_>,
envelope: &Envelope,
state: &mut EvalState,
) -> Result<(), EvalError> {
match cmd.name.as_str() {
"require" => Ok(()), "keep" => {
let local = override_flags_from_tag(&cmd.args);
state.actions.push(Action::Keep {
flags: local.unwrap_or_else(|| state.flags.clone()),
});
state.explicit_action = true;
Ok(())
}
"discard" => {
state.actions.push(Action::Discard);
state.explicit_action = true;
Ok(())
}
"fileinto" => {
let arg = first_string(&cmd.args).ok_or_else(|| EvalError::BadArg {
cmd: "fileinto".into(),
detail: "expects string mailbox name".into(),
})?;
let local = override_flags_from_tag(&cmd.args);
state.actions.push(Action::FileInto {
mailbox: arg.to_string(),
flags: local.unwrap_or_else(|| state.flags.clone()),
});
state.explicit_action = true;
Ok(())
}
"setflag" => {
state.flags = flag_list_from_args(&cmd.args);
Ok(())
}
"addflag" => {
for f in flag_list_from_args(&cmd.args) {
if !state.flags.iter().any(|existing| existing == &f) {
state.flags.push(f);
}
}
Ok(())
}
"removeflag" => {
let to_remove = flag_list_from_args(&cmd.args);
state.flags.retain(|f| !to_remove.iter().any(|r| r == f));
Ok(())
}
"redirect" => {
let arg = first_string(&cmd.args).ok_or_else(|| EvalError::BadArg {
cmd: "redirect".into(),
detail: "expects string address".into(),
})?;
state.actions.push(Action::Redirect(arg.to_string()));
state.explicit_action = true;
Ok(())
}
"reject" => {
let arg = first_string(&cmd.args).ok_or_else(|| EvalError::BadArg {
cmd: "reject".into(),
detail: "expects string reason".into(),
})?;
state.actions.push(Action::Reject(arg.to_string()));
state.explicit_action = true;
Ok(())
}
"stop" => {
state.stopped = true;
Ok(())
}
"vacation" => {
let va = parse_vacation_args(&cmd.args).map_err(|e| EvalError::BadArg {
cmd: "vacation".into(),
detail: e.to_string(),
})?;
state.actions.push(Action::Vacation(va));
Ok(())
}
"if" => {
let test = first_test(&cmd.args).ok_or_else(|| EvalError::BadArg {
cmd: "if".into(),
detail: "expects test expression".into(),
})?;
let matched = eval_test(test, ctx, &state.flags, envelope)?;
state.last_if_matched = matched;
if matched {
eval_block(&cmd.block, ctx, envelope, state)?;
}
Ok(())
}
"elsif" => {
if state.last_if_matched {
return Ok(());
}
let test = first_test(&cmd.args).ok_or_else(|| EvalError::BadArg {
cmd: "elsif".into(),
detail: "expects test expression".into(),
})?;
let matched = eval_test(test, ctx, &state.flags, envelope)?;
state.last_if_matched = matched;
if matched {
eval_block(&cmd.block, ctx, envelope, state)?;
}
Ok(())
}
"else" => {
if state.last_if_matched {
return Ok(());
}
state.last_if_matched = true;
eval_block(&cmd.block, ctx, envelope, state)?;
Ok(())
}
other => Err(EvalError::UnknownCommand(other.to_string())),
}
}
fn first_string(args: &[Argument]) -> Option<&str> {
let mut i = 0;
while i < args.len() {
match &args[i] {
Argument::Tag(_) => {
i += 2;
}
Argument::String(s) => return Some(s.as_str()),
_ => i += 1,
}
}
None
}
fn first_test(args: &[Argument]) -> Option<&Test> {
args.iter().find_map(|a| match a {
Argument::Test(t) => Some(t),
_ => None,
})
}
fn override_flags_from_tag(args: &[Argument]) -> Option<Vec<String>> {
for (i, a) in args.iter().enumerate() {
if matches!(a, Argument::Tag(t) if t == "flags") {
return match args.get(i + 1) {
Some(Argument::String(s)) => Some(vec![s.clone()]),
Some(Argument::StringList(v)) => Some(v.clone()),
_ => Some(Vec::new()),
};
}
}
None
}
fn flag_list_from_args(args: &[Argument]) -> Vec<String> {
for a in args {
match a {
Argument::String(s) => return vec![s.clone()],
Argument::StringList(v) => return v.clone(),
_ => {}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
const MSG: &[u8] = b"\
From: Alice <alice@example.com>\r\n\
To: bob@dest.com, carol@dest.com\r\n\
Subject: spam offer\r\n\
\r\n\
hello world\r\n";
#[test]
fn implicit_keep_on_empty_script() {
assert_eq!(eval_script("", MSG).unwrap(), vec![Action::Keep { flags: vec![] }]);
}
#[test]
fn explicit_keep() {
assert_eq!(eval_script("keep;", MSG).unwrap(), vec![Action::Keep { flags: vec![] }]);
}
#[test]
fn discard() {
assert_eq!(eval_script("discard;", MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn fileinto() {
let script = r#"require ["fileinto"]; fileinto "Junk";"#;
assert_eq!(
eval_script(script, MSG).unwrap(),
vec![Action::FileInto { mailbox: "Junk".into(), flags: vec![] }]
);
}
#[test]
fn header_is_match_fires_discard() {
let script = r#"if header :is "Subject" "spam offer" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn header_is_no_match_falls_through_to_keep() {
let script = r#"if header :is "Subject" "newsletter" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Keep { flags: vec![] }]);
}
#[test]
fn header_contains_substring() {
let script = r#"require ["fileinto"];
if header :contains "Subject" "offer" { fileinto "Ads"; }"#;
assert_eq!(
eval_script(script, MSG).unwrap(),
vec![Action::FileInto { mailbox: "Ads".into(), flags: vec![] }]
);
}
#[test]
fn header_matches_glob() {
let script = r#"if header :matches "Subject" "*offer*" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn exists_test_present() {
let script = r#"if exists "Subject" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn exists_test_missing() {
let script = r#"if exists "X-Spam-Score" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Keep { flags: vec![] }]);
}
#[test]
fn size_over() {
let script = "if size :over 1 { discard; }";
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn size_under_huge() {
let script = "if size :under 100K { discard; }";
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn not_invert() {
let script = r#"if not header :is "Subject" "newsletter" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn allof_true() {
let script = r#"
if allof(
header :contains "Subject" "spam",
header :is "From" "Alice <alice@example.com>"
) {
discard;
}"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn anyof_one_true() {
let script = r#"
if anyof(
header :is "Subject" "newsletter",
header :contains "From" "alice"
) {
discard;
}"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn elsif_chain_only_first_matching_branch_fires() {
let script = r#"
require ["fileinto"];
if header :is "Subject" "no-match" { fileinto "A"; }
elsif header :contains "Subject" "spam" { fileinto "Spam"; }
elsif header :contains "Subject" "offer" { fileinto "Ads"; }
else { keep; }"#;
assert_eq!(
eval_script(script, MSG).unwrap(),
vec![Action::FileInto { mailbox: "Spam".into(), flags: vec![] }]
);
}
#[test]
fn else_branch() {
let script = r#"
if header :is "Subject" "no-match" { discard; }
else { keep; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Keep { flags: vec![] }]);
}
#[test]
fn address_localpart() {
let script = r#"if address :localpart "From" "alice" { discard; }"#;
assert_eq!(eval_script(script, MSG).unwrap(), vec![Action::Discard]);
}
#[test]
fn address_domain() {
let script = r#"require ["fileinto"];
if address :domain "To" "dest.com" { fileinto "Sent"; }"#;
assert_eq!(
eval_script(script, MSG).unwrap(),
vec![Action::FileInto { mailbox: "Sent".into(), flags: vec![] }]
);
}
#[test]
fn redirect_action() {
let script = r#"redirect "alice@example.com";"#;
assert_eq!(
eval_script(script, MSG).unwrap(),
vec![Action::Redirect("alice@example.com".into())]
);
}
#[test]
fn reject_action() {
let script = r#"require ["reject"]; reject "policy reject";"#;
assert_eq!(
eval_script(script, MSG).unwrap(),
vec![Action::Reject("policy reject".into())]
);
}
#[test]
fn fileinto_without_require_errors() {
let err = eval_script(r#"fileinto "Junk";"#, MSG).unwrap_err();
assert!(
matches!(
err,
EvalError::MissingCapability { ref capability, .. } if capability == "fileinto"
),
"got {err:?}",
);
}
#[test]
fn reject_without_require_errors() {
let err = eval_script(r#"reject "no";"#, MSG).unwrap_err();
assert!(
matches!(err, EvalError::MissingCapability { .. }),
"got {err:?}",
);
}
#[test]
fn unsupported_require_errors() {
let err = eval_script(r#"require ["foreverbar"]; keep;"#, MSG).unwrap_err();
assert!(
matches!(err, EvalError::UnsupportedCapability(ref s) if s == "foreverbar"),
"got {err:?}",
);
}
#[test]
fn require_out_of_order_errors() {
let err =
eval_script(r#"keep; require ["fileinto"];"#, MSG).unwrap_err();
assert!(matches!(err, EvalError::RequireOutOfOrder), "got {err:?}");
}
}