diff --git a/crates/nuncio-filter/src/engine.rs b/crates/nuncio-filter/src/engine.rs index e3f4822..bce77e8 100644 --- a/crates/nuncio-filter/src/engine.rs +++ b/crates/nuncio-filter/src/engine.rs @@ -97,9 +97,12 @@ impl CompiledFilter { .unwrap_or(""); Self::eval_string_op(body, &leaf.operator, &leaf.value, regexes) } - FilterField::Folder | FilterField::Account => { + FilterField::Folder => { Self::eval_string_op(&email.folder_id, &leaf.operator, &leaf.value, regexes) } + FilterField::Account => { + Self::eval_string_op(&email.account_id, &leaf.operator, &leaf.value, regexes) + } FilterField::HasAttachment => { let has = !email.attachments.is_empty(); if let FilterValue::Boolean(b) = leaf.value { @@ -148,7 +151,11 @@ impl CompiledFilter { } } FilterField::Header(_name) => { - // Header evaluation fallback + // Unreachable defensive branch: `NsqlValidator::pass1_field_types` + // rejects any rule with a `header[...]` condition before it can + // reach a `CompiledFilter`, since `Email` has no parsed-headers + // field to match against. Kept only so this match stays + // exhaustive over `FilterField`. false } } @@ -475,6 +482,30 @@ mod tests { assert_eq!(engine.evaluate(&email).len(), 1); } + #[test] + fn test_account_condition_field_matches_account_id_not_folder_id() { + let nsql = "WHERE account = 'acct-a' ACTION MARK READ"; + let rule = crate::parser::NsqlParser::parse_rule("Account Filter", 1, nsql).unwrap(); + let engine = FilterEngine::new(vec![rule]).unwrap(); + + // Same folder_id, different account_id: a rule matching on the + // `account` condition field must key off email.account_id, never + // fall through to comparing folder_id. + let email_a = test_email("acct-a", "Anything", "shared-folder"); + assert_eq!( + engine.evaluate(&email_a).len(), + 1, + "rule on account = 'acct-a' must match a message with account_id 'acct-a'" + ); + + let email_b = test_email("acct-b", "Anything", "shared-folder"); + assert!( + engine.evaluate(&email_b).is_empty(), + "rule on account = 'acct-a' must not match a message with account_id 'acct-b', \ + even when it shares the same folder_id as an acct-a message" + ); + } + #[test] fn test_not_in_operator_is_complement_of_in() { let nsql = "WHERE folder NOT IN ('spam', 'trash') ACTION MARK READ"; diff --git a/crates/nuncio-filter/src/validator.rs b/crates/nuncio-filter/src/validator.rs index 145d836..ff67cbd 100644 --- a/crates/nuncio-filter/src/validator.rs +++ b/crates/nuncio-filter/src/validator.rs @@ -83,11 +83,14 @@ impl NsqlValidator { "field 'has_attachment' requires a boolean value".to_string(), )), }, + FilterField::Header(_) => Err(ValidationError::FieldTypeMismatch( + "header field conditions are not yet supported: message headers are not indexed" + .to_string(), + )), FilterField::Subject | FilterField::From | FilterField::To | FilterField::Body - | FilterField::Header(_) | FilterField::Folder | FilterField::Account => match &leaf.value { FilterValue::String(_) | FilterValue::List(_) => Ok(()), @@ -322,6 +325,18 @@ mod tests { assert!(matches!(err, ValidationError::FieldTypeMismatch(_))); } + #[test] + fn test_pass1_rejects_unsupported_header_field_conditions() { + let rule = crate::parser::NsqlParser::parse_rule( + "Header Spam Score", + 1, + "WHERE header['X-Spam-Score'] = '10' ACTION MARK READ", + ) + .unwrap(); + let err = NsqlValidator::validate(&rule, &ValidationOptions::default()).unwrap_err(); + assert!(matches!(err, ValidationError::FieldTypeMismatch(_))); + } + #[test] fn test_pass4_redos_validation() { let rule = crate::parser::NsqlParser::parse_rule(