fix(filter): scope rules to account; fix UTF-8 offsets and NOT IN

- Enforce a rule's account scope in evaluate, evaluate_with_timeout, and
  preview via matches_account; previously a rule created ON ACCOUNT one account
  would fire on any account whose message matched the WHERE clause.
- Return true byte offsets from find_keyword_outside_quotes (char_indices)
  instead of a char count, so the WHERE/ACTION and ON ACCOUNT splits no longer
  corrupt or panic on non-ASCII text preceding a keyword.
- Add FilterOperator::NotIn evaluated as the complement of IN. Negated IN lists
  previously mapped to NotEquals over a List value, which no eval arm handled,
  so NOT IN was silently always false.

Refs #177
This commit is contained in:
James Maes committed 2026-07-27 08:29:12 -05:00
1 parent 2fa40c4afa
commit fdb5a3fbe1
3 files changed
+270 -16

No files matched your search

+3
View File
@@ -91,6 +91,8 @@ pub enum FilterOperator {
LessThanOrEqual,
/// In set (`IN`).
In,
/// Not in set (`NOT IN`); the logical complement of `In`.
NotIn,
}
impl FilterOperator {
@@ -107,6 +109,7 @@ impl FilterOperator {
Self::GreaterThanOrEqual => ">=",
Self::LessThanOrEqual => "<=",
Self::In => "IN",
Self::NotIn => "NOT IN",
}
}
}
+184 -6
View File
@@ -55,6 +55,12 @@ impl CompiledFilter {
}
/// Evaluate email message against compiled rule condition tree.
///
/// This checks only the WHERE-clause conditions; it does NOT apply the
/// rule's account scope. Callers must gate on
/// [`FilterRule::matches_account`] first (as every `FilterEngine` entry
/// point does), or a rule scoped to one account will fire on messages of
/// another.
pub fn evaluate_condition(&self, email: &Email) -> bool {
Self::eval_node(&self.rule.conditions, email, &self.compiled_regexes)
}
@@ -183,10 +189,18 @@ impl CompiledFilter {
}
_ => false,
},
FilterOperator::In => match val {
FilterValue::List(list) => list.iter().any(|s| haystack.eq_ignore_ascii_case(s)),
_ => false,
},
FilterOperator::In => Self::eval_in_set(haystack, val),
// NOT IN is defined as the exact complement of IN: an empty set
// makes IN always false, so NOT IN is always true, and any value
// shape that IN cannot match against also makes NOT IN true.
FilterOperator::NotIn => !Self::eval_in_set(haystack, val),
_ => false,
}
}
fn eval_in_set(haystack: &str, val: &FilterValue) -> bool {
match val {
FilterValue::List(list) => list.iter().any(|s| haystack.eq_ignore_ascii_case(s)),
_ => false,
}
}
@@ -234,12 +248,17 @@ impl FilterEngine {
}
/// Evaluate email message returning matching rule actions.
///
/// A rule only ever fires for the account it was created against
/// (`FilterRule::target_account`, set via `ON ACCOUNT` or `*` for all
/// accounts) — condition matching alone is not account-scoped, so this
/// check must happen before `evaluate_condition` runs.
pub fn evaluate(&self, email: &Email) -> Vec<(FilterRule, Vec<RuleAction>)> {
let guard = self.cache.load();
let mut results = Vec::new();
for filter in &guard.filters {
if filter.evaluate_condition(email) {
if filter.rule.matches_account(&email.account_id) && filter.evaluate_condition(email) {
results.push((filter.rule.clone(), filter.rule.actions.clone()));
}
}
@@ -260,7 +279,9 @@ impl FilterEngine {
let guard = engine_cache.load();
let mut results = Vec::new();
for filter in &guard.filters {
if filter.evaluate_condition(&email_clone) {
if filter.rule.matches_account(&email_clone.account_id)
&& filter.evaluate_condition(&email_clone)
{
results.push((filter.rule.clone(), filter.rule.actions.clone()));
}
}
@@ -271,6 +292,10 @@ impl FilterEngine {
}
/// Dry-run preview evaluation returning detailed microsecond traces.
///
/// Rules whose account scope does not match the message are reported as
/// `SKIPPED (account scope mismatch)` rather than omitted, so the trace
/// distinguishes "condition did not match" from "rule does not apply here".
pub fn preview(&self, email: &Email) -> FilterPreviewResult {
let start = Instant::now();
let guard = self.cache.load();
@@ -281,6 +306,14 @@ impl FilterEngine {
let mut matched = false;
for filter in &guard.filters {
if !filter.rule.matches_account(&email.account_id) {
traces.push(format!(
"Rule '{}' (priority {}): SKIPPED (account scope mismatch)",
filter.rule.name, filter.rule.priority
));
continue;
}
let is_match = filter.evaluate_condition(email);
traces.push(format!(
"Rule '{}' (priority {}): {}",
@@ -328,6 +361,22 @@ mod tests {
use super::*;
use nuncio_core::model::Email;
fn test_email(account_id: &str, subject: &str, folder_id: &str) -> Email {
Email {
id: "msg-1".to_string(),
account_id: account_id.to_string(),
folder_id: folder_id.to_string(),
subject: subject.to_string(),
sender: "alice@nuncio.mx".to_string(),
recipient: "bob@nuncio.mx".to_string(),
received_at: 1700000000,
read: false,
body_plain: Some("Hello".to_string()),
body_html: None,
attachments: Vec::new(),
}
}
#[test]
fn test_engine_evaluate_and_lockfree_reload() {
let nsql = "WHERE subject CONTAINS 'Urgent' ACTION MARK READ, MOVE TO 'Priority'";
@@ -363,4 +412,133 @@ mod tests {
FilterEngine::sign_webhook_payload("secret123", 1700000000, "{\"event\":\"mail\"}");
assert!(sig.starts_with("t=1700000000,v1="));
}
#[test]
fn test_evaluate_scopes_rule_to_its_owning_account() {
let nsql = "ON ACCOUNT 'acct-a' WHERE subject CONTAINS 'Report' ACTION MARK READ";
let rule = crate::parser::NsqlParser::parse_rule("Scoped To A", 1, nsql).unwrap();
let engine = FilterEngine::new(vec![rule]).unwrap();
// Condition matches the subject, but the message belongs to a
// different account than the rule was created for.
let email_b = test_email("acct-b", "Weekly Report", "inbox");
assert!(
engine.evaluate(&email_b).is_empty(),
"rule scoped to acct-a must not match a message from acct-b"
);
let email_a = test_email("acct-a", "Weekly Report", "inbox");
assert_eq!(engine.evaluate(&email_a).len(), 1);
}
#[tokio::test]
async fn test_evaluate_with_timeout_scopes_rule_to_its_owning_account() {
let nsql = "ON ACCOUNT 'acct-a' WHERE subject CONTAINS 'Report' ACTION MARK READ";
let rule = crate::parser::NsqlParser::parse_rule("Scoped To A", 1, nsql).unwrap();
let engine = FilterEngine::new(vec![rule]).unwrap();
let email_b = test_email("acct-b", "Weekly Report", "inbox");
let results = engine
.evaluate_with_timeout(&email_b, Duration::from_secs(1))
.await;
assert!(results.is_empty());
let email_a = test_email("acct-a", "Weekly Report", "inbox");
let results = engine
.evaluate_with_timeout(&email_a, Duration::from_secs(1))
.await;
assert_eq!(results.len(), 1);
}
#[test]
fn test_preview_reports_account_scope_mismatch() {
let nsql = "ON ACCOUNT 'acct-a' WHERE subject CONTAINS 'Report' ACTION MARK READ";
let rule = crate::parser::NsqlParser::parse_rule("Scoped To A", 1, nsql).unwrap();
let engine = FilterEngine::new(vec![rule]).unwrap();
let email_b = test_email("acct-b", "Weekly Report", "inbox");
let preview = engine.preview(&email_b);
assert!(!preview.matched);
assert!(preview
.condition_traces
.iter()
.any(|t| t.contains("account scope mismatch")));
}
#[test]
fn test_wildcard_account_rule_still_matches_any_account() {
let nsql = "WHERE subject CONTAINS 'Report' ACTION MARK READ";
let rule = crate::parser::NsqlParser::parse_rule("Global", 1, nsql).unwrap();
let engine = FilterEngine::new(vec![rule]).unwrap();
let email = test_email("any-account", "Weekly Report", "inbox");
assert_eq!(engine.evaluate(&email).len(), 1);
}
#[test]
fn test_not_in_operator_is_complement_of_in() {
let nsql = "WHERE folder NOT IN ('spam', 'trash') ACTION MARK READ";
let rule = crate::parser::NsqlParser::parse_rule("Not In Spam Or Trash", 1, nsql).unwrap();
let engine = FilterEngine::new(vec![rule]).unwrap();
let inbox_email = test_email("acct-1", "Anything", "inbox");
assert_eq!(engine.evaluate(&inbox_email).len(), 1);
let spam_email = test_email("acct-1", "Anything", "spam");
assert!(engine.evaluate(&spam_email).is_empty());
}
#[test]
fn test_not_in_empty_set_always_matches() {
// NOT IN () is the complement of IN (), and IN against an empty set
// never matches anything, so NOT IN () must always evaluate true.
let rule = FilterRule {
id: "r1".to_string(),
name: "Empty Set".to_string(),
target_account: "*".to_string(),
priority: 1,
enabled: true,
nsql_text: String::new(),
conditions: ConditionNode::Leaf(ConditionLeaf {
field: FilterField::Folder,
operator: FilterOperator::NotIn,
value: FilterValue::List(vec![]),
}),
actions: vec![RuleAction::MarkRead],
created_at: 0,
updated_at: 0,
};
let engine = FilterEngine::new(vec![rule]).unwrap();
let email = test_email("acct-1", "Anything", "inbox");
assert_eq!(engine.evaluate(&email).len(), 1);
}
#[test]
fn test_not_in_against_non_list_value_matches_as_in_complement() {
// A leaf carrying a non-list value under NOT IN is a degenerate shape
// that IN can never match (see `eval_in_set`); NOT IN, defined as
// IN's exact complement, must therefore always match it.
let leaf = ConditionLeaf {
field: FilterField::Subject,
operator: FilterOperator::NotIn,
value: FilterValue::String("not-a-list".to_string()),
};
let rule = FilterRule {
id: "r1".to_string(),
name: "Null Case".to_string(),
target_account: "*".to_string(),
priority: 1,
enabled: true,
nsql_text: String::new(),
conditions: ConditionNode::Leaf(leaf),
actions: vec![RuleAction::MarkRead],
created_at: 0,
updated_at: 0,
};
let engine = FilterEngine::new(vec![rule]).unwrap();
let email = test_email("acct-1", "Anything", "inbox");
assert_eq!(engine.evaluate(&email).len(), 1);
}
}
+83 -10
View File
@@ -307,7 +307,7 @@ impl NsqlParser {
}
}
let op = if *negated {
FilterOperator::NotEquals
FilterOperator::NotIn
} else {
FilterOperator::In
};
@@ -546,26 +546,35 @@ fn preprocess_nsql_where(input: &str) -> String {
fn find_keyword_outside_quotes(text: &str, keyword: &str) -> Option<usize> {
let mut in_single_quote = false;
let mut in_double_quote = false;
let chars: Vec<char> = text.chars().collect();
// Pair each char with its byte offset in `text`. Callers slice `text`
// directly with the returned index, so the index must be a byte offset;
// a plain `chars().collect()` char count silently diverges from the byte
// offset (and can land mid-character) as soon as the text contains any
// multi-byte UTF-8 character, e.g. a non-ASCII subject/account literal.
let indexed_chars: Vec<(usize, char)> = text.char_indices().collect();
let kw_chars: Vec<char> = keyword.chars().collect();
for i in 0..chars.len() {
let c = chars[i];
for i in 0..indexed_chars.len() {
let (byte_idx, c) = indexed_chars[i];
if c == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if c == '"' && !in_single_quote {
in_double_quote = !in_double_quote;
} else if !in_single_quote && !in_double_quote && i + kw_chars.len() <= chars.len() {
let is_match = chars[i..i + kw_chars.len()]
} else if !in_single_quote && !in_double_quote && i + kw_chars.len() <= indexed_chars.len()
{
let is_match = indexed_chars[i..i + kw_chars.len()]
.iter()
.zip(kw_chars.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
.all(|((_, a), b)| a.eq_ignore_ascii_case(b));
if is_match {
let prev_ok = i == 0 || chars[i - 1].is_whitespace() || chars[i - 1] == ')';
let prev_ok = i == 0
|| indexed_chars[i - 1].1.is_whitespace()
|| indexed_chars[i - 1].1 == ')';
let next_idx = i + kw_chars.len();
let next_ok = next_idx == chars.len() || chars[next_idx].is_whitespace();
let next_ok =
next_idx == indexed_chars.len() || indexed_chars[next_idx].1.is_whitespace();
if prev_ok && next_ok {
return Some(i);
return Some(byte_idx);
}
}
}
@@ -630,4 +639,68 @@ mod tests {
assert_eq!(rule.target_account, "*");
assert_eq!(rule.actions.len(), 2);
}
#[test]
fn test_parse_not_in_operator() {
let nsql = "WHERE folder NOT IN ('Spam', 'Trash') ACTION DELETE";
let rule = NsqlParser::parse_rule("Not In Rule", 1, nsql).expect("parse rule");
match &rule.conditions {
ConditionNode::Leaf(leaf) => {
assert_eq!(leaf.operator, FilterOperator::NotIn);
assert_eq!(
leaf.value,
FilterValue::List(vec!["Spam".to_string(), "Trash".to_string()])
);
}
other => panic!("expected leaf condition, got {other:?}"),
}
}
#[test]
fn test_parse_in_operator_still_distinct_from_not_in() {
let nsql = "WHERE folder IN ('Inbox', 'Priority') ACTION MARK READ";
let rule = NsqlParser::parse_rule("In Rule", 1, nsql).expect("parse rule");
match &rule.conditions {
ConditionNode::Leaf(leaf) => assert_eq!(leaf.operator, FilterOperator::In),
other => panic!("expected leaf condition, got {other:?}"),
}
}
#[test]
fn test_action_split_survives_multibyte_utf8_before_action_keyword() {
// Regression test: `find_keyword_outside_quotes` used to return a
// *char* index that callers then used as a *byte* index to slice the
// source text. Any multi-byte UTF-8 character ahead of the ACTION
// keyword desynchronized that offset, corrupting the WHERE/ACTION
// split (or panicking on a non-UTF-8-boundary slice).
let nsql = "WHERE subject = '日本語のテスト' ACTION MARK READ";
let rule = NsqlParser::parse_rule("Multibyte Subject", 1, nsql)
.expect("parse rule with multi-byte utf8 subject");
assert_eq!(rule.actions, vec![RuleAction::MarkRead]);
match &rule.conditions {
ConditionNode::Leaf(leaf) => {
assert_eq!(leaf.field, FilterField::Subject);
assert_eq!(
leaf.value,
FilterValue::String("日本語のテスト".to_string())
);
}
other => panic!("expected leaf condition, got {other:?}"),
}
}
#[test]
fn test_on_account_split_survives_multibyte_utf8_before_where_keyword() {
let nsql = "ON ACCOUNT 'jörg@kof22.com' WHERE subject = '日本語' ACTION MARK READ";
let rule = NsqlParser::parse_rule("Multibyte Account", 1, nsql).expect("parse rule");
assert_eq!(rule.target_account, "jörg@kof22.com");
match &rule.conditions {
ConditionNode::Leaf(leaf) => {
assert_eq!(leaf.value, FilterValue::String("日本語".to_string()));
}
other => panic!("expected leaf condition, got {other:?}"),
}
}
}