feat(grpc): Filters gRPC service (create/list/delete/validate/preview)

Add an authenticated Filters service backed by the NSQL engine + store:
CreateRule/DeleteRule persist and hot-reload the live FilterEngine (ArcSwap);
ValidateRule returns an honest valid/error verdict without failing the RPC;
PreviewRule dry-runs a candidate (unsaved) rule against stored mail. Mounted
with the bearer interceptor like the other services (auth-rejection tested).
Wire CLI filter create/list/delete/validate/test to it; the shared filter_engine
instance is reused by both the gRPC path and the legacy filter.* handler during
the transition. filter edit/export/import/logs stay local for now (tracked).

Refs #171
This commit is contained in:
James Maes committed 2026-07-27 05:38:33 -05:00
1 parent dd10eda5f3
commit c7a73afcd0
9 files changed
+1495 -161

No files matched your search

+6
View File
@@ -369,6 +369,12 @@ pub enum FilterSubcommand {
#[arg(short, long, help = "Rule identifier")]
id: String,
},
/// Validate an NSQL query's syntax and semantics without persisting it.
Validate {
/// NSQL query string to validate.
#[arg(short, long, help = "NSQL query string")]
sql: String,
},
/// Test / dry-run NSQL query against an email.
Test {
/// NSQL query string to test.
+528 -124
View File
@@ -57,6 +57,23 @@ fn message_proto_to_json(message: &nuncio_proto::v1::Message) -> serde_json::Val
})
}
/// Renders a `nuncio.v1.FilterRule` (as returned by the daemon's `Filters`
/// gRPC service, backlog story 2.A / GH #171) into the JSON shape used by
/// `filter list`/`filter create`'s `--json` output.
fn filter_rule_proto_to_json(rule: &nuncio_proto::v1::FilterRule) -> serde_json::Value {
json!({
"id": rule.id,
"name": rule.name,
"target_account": rule.target_account,
"priority": rule.priority,
"enabled": rule.enabled,
"nsql_text": rule.nsql_text,
"actions": rule.actions,
"created_at": rule.created_at,
"updated_at": rule.updated_at,
})
}
/// Errors emitted by the CLI headless runner.
#[derive(Error, Debug)]
pub enum RunnerError {
@@ -368,60 +385,35 @@ impl HeadlessRunner {
}
},
Commands::Filter { action } => match action {
FilterSubcommand::List => {
let rules = self.db.list_filter_rules().await.unwrap_or_default();
if json_mode {
format_json(&json!(rules))
} else if rules.is_empty() {
"No filter rules configured.".to_string()
} else {
let mut out = String::from(
"ID PRIORITY ENABLED NAME NSQL\n",
);
for r in rules {
out.push_str(&format!(
"{:<10} {:<8} {:<7} {:<20} {}\n",
r.id, r.priority, r.enabled, r.name, r.nsql_text
));
}
out
}
}
FilterSubcommand::List => self.handle_filter_list(json_mode).await,
FilterSubcommand::Create {
name,
sql,
priority,
} => match nuncio_filter::NsqlParser::parse_rule(name, *priority, sql) {
Ok(rule) => {
let opts = nuncio_filter::ValidationOptions::default();
if let Err(e) = nuncio_filter::NsqlValidator::validate(&rule, &opts) {
return if json_mode {
format_json_error(&e.to_string())
} else {
format!("Validation Error: {e}")
};
}
if let Err(e) = self.db.save_filter_rule(&rule).await {
return if json_mode {
format_json_error(&e.to_string())
} else {
format!("Database Error: {e}")
};
}
if json_mode {
format_json(&json!(rule))
} else {
format!("✓ Created filter rule '{}' (ID: {}).", rule.name, rule.id)
}
}
Err(e) => {
if json_mode {
format_json_error(&e.to_string())
} else {
format!("Syntax Error: {e}")
}
}
},
} => {
self.handle_filter_create(name, sql, *priority, json_mode)
.await
}
FilterSubcommand::Delete { id } => self.handle_filter_delete(id, json_mode).await,
FilterSubcommand::Validate { sql } => {
self.handle_filter_validate(sql, json_mode).await
}
FilterSubcommand::Test { sql, message_id } => {
self.handle_filter_test(sql, message_id.as_deref(), json_mode)
.await
}
// `filter edit`/`export`/`import`/`logs` are NOT part of
// backlog story 2.A (GH #171)'s required `Filters` gRPC
// surface (`CreateRule`/`ListRules`/`DeleteRule`/
// `ValidateRule`/`PreviewRule`); they still read/write this
// runner's own ephemeral local `db` below, exactly as
// before. Because `List`/`Create`/`Delete` above now go
// through the daemon's real, persistent store instead, a
// rule created via `filter create` will NOT show up in
// `filter export`/`filter logs` (which only see this
// process's throwaway `db`) until these are migrated too --
// a known gap tracked for a later story, not a regression
// introduced silently here.
FilterSubcommand::Edit {
id,
name,
@@ -474,80 +466,6 @@ impl HeadlessRunner {
format!("Rule '{}' not found.", id)
}
}
FilterSubcommand::Delete { id } => {
if let Err(e) = self.db.delete_filter_rule(id).await {
if json_mode {
format_json_error(&e.to_string())
} else {
format!("Error: {e}")
}
} else if json_mode {
format_json(&json!({ "status": "deleted", "id": id }))
} else {
format!("✓ Filter rule '{}' deleted.", id)
}
}
FilterSubcommand::Test { sql, message_id } => {
match nuncio_filter::NsqlParser::parse_rule("Test Rule", 0, sql) {
Ok(rule) => {
let engine = match nuncio_filter::FilterEngine::new(vec![rule.clone()])
{
Ok(engine) => engine,
Err(e) => {
return if json_mode {
format_json_error(&e)
} else {
format!("Filter Engine Error: {e}")
};
}
};
let sample_email = if let Some(mid) = message_id {
self.db.get_message(mid).await.unwrap_or_else(|_| {
nuncio_core::model::Email {
id: mid.clone(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: "Test Subject".to_string(),
sender: "test@nuncio.mx".to_string(),
recipient: "me@nuncio.mx".to_string(),
received_at: chrono::Utc::now().timestamp(),
read: false,
body_plain: Some("Sample body text".to_string()),
body_html: None,
attachments: Vec::new(),
}
})
} else {
nuncio_core::model::Email {
id: "msg-test".to_string(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: "Test Subject".to_string(),
sender: "test@nuncio.mx".to_string(),
recipient: "me@nuncio.mx".to_string(),
received_at: chrono::Utc::now().timestamp(),
read: false,
body_plain: Some("Sample body text".to_string()),
body_html: None,
attachments: Vec::new(),
}
};
let preview = engine.preview(&sample_email);
if json_mode {
format_json(&json!(preview))
} else {
format!("Dry-run evaluation result: matched={}, actions={:?}, elapsed={}us", preview.matched, preview.actions_evaluated, preview.execution_time_us)
}
}
Err(e) => {
if json_mode {
format_json_error(&e.to_string())
} else {
format!("Syntax Error: {e}")
}
}
}
}
FilterSubcommand::Export { format } => {
let rules = self.db.list_filter_rules().await.unwrap_or_default();
if format == "json" || json_mode {
@@ -1023,6 +941,230 @@ impl HeadlessRunner {
}
}
/// `filter list`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171). Lists
/// every persisted filter rule from the daemon's real, persistent
/// store -- NOT this runner's own ephemeral local `db`, which is thrown
/// away when this CLI process exits.
async fn handle_filter_list(&self, json_mode: bool) -> String {
let mut client = match self.connect_filters_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.list_rules(nuncio_proto::v1::ListRulesRequest {})
.await
{
Ok(response) => {
let rules = response.into_inner().rules;
if json_mode {
let rules_json: Vec<serde_json::Value> =
rules.iter().map(filter_rule_proto_to_json).collect();
format_json(&json!({ "rules": rules_json }))
} else if rules.is_empty() {
"No filter rules configured.".to_string()
} else {
let mut out =
String::from("ID PRIORITY ENABLED NAME NSQL\n");
for r in rules {
out.push_str(&format!(
"{:<10} {:<8} {:<7} {:<20} {}\n",
r.id, r.priority, r.enabled, r.name, r.nsql_text
));
}
out
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected list_rules: {status}"),
json_mode,
),
}
}
/// `filter create`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171). The
/// daemon parses, validates (6-pass `NsqlValidator`), and persists the
/// rule, then reloads its own live `FilterEngine` -- this runner's own
/// ephemeral local `db` is never touched, so the rule survives this CLI
/// process exiting.
async fn handle_filter_create(
&self,
name: &str,
sql: &str,
priority: i32,
json_mode: bool,
) -> String {
let mut client = match self.connect_filters_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.create_rule(nuncio_proto::v1::CreateRuleRequest {
name: name.to_string(),
nsql: sql.to_string(),
priority,
})
.await
{
Ok(response) => match response.into_inner().rule {
Some(rule) => {
if json_mode {
format_json(&json!({ "rule": filter_rule_proto_to_json(&rule) }))
} else {
format!("✓ Created filter rule '{}' (ID: {}).", rule.name, rule.id)
}
}
None => Self::render_error(
"nunciod daemon accepted create_rule but returned no rule",
json_mode,
),
},
Err(status) if status.code() == tonic::Code::InvalidArgument => {
Self::render_error(status.message(), json_mode)
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected create_rule: {status}"),
json_mode,
),
}
}
/// `filter delete`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171). The
/// daemon deletes the persisted rule and reloads its own live
/// `FilterEngine` so the removal takes effect immediately.
async fn handle_filter_delete(&self, id: &str, json_mode: bool) -> String {
let mut client = match self.connect_filters_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.delete_rule(nuncio_proto::v1::DeleteRuleRequest { id: id.to_string() })
.await
{
Ok(_) => {
if json_mode {
format_json(&json!({ "status": "deleted", "id": id }))
} else {
format!("✓ Filter rule '{}' deleted.", id)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected delete_rule: {status}"),
json_mode,
),
}
}
/// `filter validate`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171). Unlike
/// `filter create`, an invalid rule is never a connection/RPC failure
/// here -- it is the daemon's honestly reported `valid: false` result,
/// which this renders as a validation error without ever suggesting
/// the daemon itself was unreachable or misbehaving.
async fn handle_filter_validate(&self, sql: &str, json_mode: bool) -> String {
let mut client = match self.connect_filters_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.validate_rule(nuncio_proto::v1::ValidateRuleRequest {
nsql: sql.to_string(),
})
.await
{
Ok(response) => {
let response = response.into_inner();
if json_mode {
format_json(&json!({ "valid": response.valid, "error": response.error }))
} else if response.valid {
"✓ NSQL rule is valid.".to_string()
} else {
format!("Validation Error: {}", response.error)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected validate_rule: {status}"),
json_mode,
),
}
}
/// `filter test`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171).
/// Dry-run evaluates the given NSQL rule against a stored message (by
/// `message_id`, read from the daemon's real, persistent store) or a
/// fixed synthetic sample when none is given/resolvable -- the rule is
/// never persisted and no action is ever executed.
async fn handle_filter_test(
&self,
sql: &str,
message_id: Option<&str>,
json_mode: bool,
) -> String {
let mut client = match self.connect_filters_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.preview_rule(nuncio_proto::v1::PreviewRuleRequest {
nsql: sql.to_string(),
message_id: message_id.map(str::to_string),
})
.await
{
Ok(response) => {
let preview = response.into_inner();
if json_mode {
format_json(&json!({
"message_id": preview.message_id,
"matched": preview.matched,
"matched_rule_id": preview.matched_rule_id,
"matched_rule_name": preview.matched_rule_name,
"actions_evaluated": preview.actions_evaluated,
"execution_time_us": preview.execution_time_us,
"condition_traces": preview.condition_traces,
}))
} else {
format!(
"Dry-run evaluation result: matched={}, actions={:?}, elapsed={}us",
preview.matched, preview.actions_evaluated, preview.execution_time_us
)
}
}
Err(status) if status.code() == tonic::Code::InvalidArgument => {
Self::render_error(status.message(), json_mode)
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected preview_rule: {status}"),
json_mode,
),
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// `nuncio.v1.Filters` service at `self.grpc_addr` (backlog story 2.A,
/// GH #171), shared by every `filter` handler above.
async fn connect_filters_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedFiltersClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
nuncio_proto::client::connect_filters(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// `account add`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Accounts` API (backlog story 1.C.1 / 1.C.2,
/// GH #156 / GH #157).
@@ -2043,6 +2185,268 @@ mod tests {
assert_eq!(recorded_send.body_text, "Let's discuss the roadmap.");
}
/// Reference-client proof for backlog story 2.A (GH #171): boots a stub
/// `nuncio.v1.Filters` gRPC server (mirroring the `Accounts`/`Mail` stub
/// pattern above) and drives the real `HeadlessRunner`'s
/// `filter list`/`filter create`/`filter delete`/`filter validate`/
/// `filter test` gRPC client paths against it.
///
/// Real persistence, `ArcSwap` engine reload, and honest
/// validation/preview semantics are proven by `nunciod`'s own
/// `grpc::tests`; this test exists purely to prove the CLI's connect +
/// call + JSON-format happy path, and its honest invalid-argument error
/// handling.
#[tokio::test]
async fn filter_rpcs_round_trip_over_grpc_to_a_stub_daemon() {
use nuncio_proto::v1::filters_server::{Filters as FiltersService, FiltersServer};
use nuncio_proto::v1::{
CreateRuleRequest, CreateRuleResponse, DeleteRuleRequest, DeleteRuleResponse,
FilterRule as FilterRuleProto, ListRulesRequest, ListRulesResponse, PreviewRuleRequest,
PreviewRuleResponse, ValidateRuleRequest, ValidateRuleResponse,
};
use std::sync::Mutex;
/// Minimal test-only stub of `nuncio.v1.Filters`: records the last
/// `DeleteRuleRequest` it received (so this test can assert on
/// exactly what the CLI sent over the wire) and otherwise returns
/// fixed responses.
#[derive(Default)]
struct StubFilters {
last_delete: Arc<Mutex<Option<DeleteRuleRequest>>>,
}
#[tonic::async_trait]
impl FiltersService for StubFilters {
async fn create_rule(
&self,
request: tonic::Request<CreateRuleRequest>,
) -> Result<tonic::Response<CreateRuleResponse>, tonic::Status> {
let req = request.into_inner();
if req.nsql.contains("BROKEN") {
return Err(tonic::Status::invalid_argument(
"NSQL syntax error: stub rejects BROKEN rules",
));
}
Ok(tonic::Response::new(CreateRuleResponse {
rule: Some(FilterRuleProto {
id: "rule-stub-1".to_string(),
name: req.name,
target_account: "*".to_string(),
priority: req.priority,
enabled: true,
nsql_text: req.nsql,
actions: vec!["MARK READ".to_string()],
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
}),
}))
}
async fn list_rules(
&self,
_request: tonic::Request<ListRulesRequest>,
) -> Result<tonic::Response<ListRulesResponse>, tonic::Status> {
Ok(tonic::Response::new(ListRulesResponse {
rules: vec![FilterRuleProto {
id: "rule-stub-1".to_string(),
name: "Stub Rule".to_string(),
target_account: "*".to_string(),
priority: 5,
enabled: true,
nsql_text: "WHERE subject CONTAINS 'Urgent' ACTION MARK READ".to_string(),
actions: vec!["MARK READ".to_string()],
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
}],
}))
}
async fn delete_rule(
&self,
request: tonic::Request<DeleteRuleRequest>,
) -> Result<tonic::Response<DeleteRuleResponse>, tonic::Status> {
let req = request.into_inner();
*self.last_delete.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(DeleteRuleResponse {}))
}
async fn validate_rule(
&self,
request: tonic::Request<ValidateRuleRequest>,
) -> Result<tonic::Response<ValidateRuleResponse>, tonic::Status> {
let req = request.into_inner();
if req.nsql.contains("BROKEN") {
Ok(tonic::Response::new(ValidateRuleResponse {
valid: false,
error: "NSQL syntax error: stub rejects BROKEN rules".to_string(),
}))
} else {
Ok(tonic::Response::new(ValidateRuleResponse {
valid: true,
error: String::new(),
}))
}
}
async fn preview_rule(
&self,
_request: tonic::Request<PreviewRuleRequest>,
) -> Result<tonic::Response<PreviewRuleResponse>, tonic::Status> {
Ok(tonic::Response::new(PreviewRuleResponse {
message_id: "msg-stub-1".to_string(),
matched: true,
matched_rule_id: Some("rule-stub-1".to_string()),
matched_rule_name: Some("Stub Rule".to_string()),
actions_evaluated: vec!["MARK READ".to_string()],
execution_time_us: 42,
condition_traces: vec!["Rule 'Stub Rule': MATCH".to_string()],
}))
}
}
let stub = StubFilters::default();
let delete_probe = stub.last_delete.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(FiltersServer::new(stub))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
// `filter create` happy path.
let create_out = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::Create {
name: "My Rule".to_string(),
sql: "WHERE subject CONTAINS 'Urgent' ACTION MARK READ".to_string(),
priority: 5,
},
},
true,
)
.await;
assert!(create_out.contains("rule-stub-1"));
assert!(create_out.contains("My Rule"));
// `filter create` honest invalid-argument error.
let create_err = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::Create {
name: "Broken".to_string(),
sql: "BROKEN NSQL".to_string(),
priority: 0,
},
},
true,
)
.await;
assert!(create_err.contains(r#""status":"error""#));
assert!(create_err.contains("stub rejects BROKEN rules"));
// `filter list`.
let list_out = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::List,
},
true,
)
.await;
assert!(list_out.contains("rule-stub-1"));
assert!(list_out.contains("Stub Rule"));
// `filter delete`.
let delete_out = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::Delete {
id: "rule-stub-1".to_string(),
},
},
true,
)
.await;
assert!(delete_out.contains(r#""status":"deleted""#));
let recorded_delete = delete_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a delete_rule request");
assert_eq!(recorded_delete.id, "rule-stub-1");
// `filter validate` valid + invalid.
let validate_ok = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::Validate {
sql: "WHERE subject CONTAINS 'Urgent' ACTION MARK READ".to_string(),
},
},
true,
)
.await;
assert!(validate_ok.contains(r#""valid":true"#));
let validate_err = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::Validate {
sql: "BROKEN NSQL".to_string(),
},
},
true,
)
.await;
assert!(validate_err.contains(r#""valid":false"#));
assert!(validate_err.contains("stub rejects BROKEN rules"));
// `filter test` (PreviewRule) dry-run.
let test_out = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::Test {
sql: "WHERE subject CONTAINS 'Urgent' ACTION MARK READ".to_string(),
message_id: Some("msg-stub-1".to_string()),
},
},
true,
)
.await;
assert!(test_out.contains(r#""matched":true"#));
assert!(test_out.contains("msg-stub-1"));
}
#[tokio::test]
async fn filter_commands_report_honest_error_when_daemon_unreachable() {
let addr = reserve_unreachable_addr().await;
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr)
.await
.expect("ephemeral runner initializes");
let out = runner
.execute_command(
&Commands::Filter {
action: FilterSubcommand::List,
},
true,
)
.await;
assert!(out.contains(r#""status":"error""#));
assert!(out.contains("unreachable"));
}
#[test]
fn runner_error_display() {
let err = RunnerError::InitFailed("failed to open database".to_string());
@@ -422,3 +422,162 @@ message SyncRequest {
message SyncResponse {
uint64 synced_count = 1;
}
// Filters exposes the NSQL filter engine's rule CRUD, validation, and
// dry-run preview operations over gRPC (backlog story 2.A, GH #171) -- the
// authenticated replacement for the existing hand-rolled `filter.*` JSON-RPC
// IPC methods (`nunciod::main`'s `CustomRpcHandler`), which remain in place
// (both transports coexist) until they are retired in a later story (2.C).
//
// This faithfully exposes the filter engine as it exists today, including
// its known correctness gaps (e.g. `target_account`/`ON ACCOUNT` matching
// against `folder_id` rather than the message's actual account field) --
// fixing those is out of scope here (Phase 3).
//
// SECURITY (GH #165): this service MUST be mounted behind the SAME
// `BearerAuthInterceptor` that guards `System`/`Accounts`/`Mail` -- every
// gRPC service `nunciod` exposes MUST be authenticated, with no exceptions
// carved out for "just one more" unauthenticated service. See
// `nunciod::grpc::serve_on_listener`.
service Filters {
// CreateRule parses and validates an NSQL rule definition, then persists
// it and reloads the daemon's live `FilterEngine` rule set (via its
// `ArcSwap`-backed hot-reload) so the new rule is active immediately.
// Rejects a syntactically or semantically invalid rule with
// `Status::invalid_argument` -- nothing is ever persisted on failure.
rpc CreateRule(CreateRuleRequest) returns (CreateRuleResponse);
// ListRules returns every persisted filter rule, priority ascending.
rpc ListRules(ListRulesRequest) returns (ListRulesResponse);
// DeleteRule removes a persisted filter rule by ID and reloads the
// daemon's live `FilterEngine` rule set so the removal takes effect
// immediately.
rpc DeleteRule(DeleteRuleRequest) returns (DeleteRuleResponse);
// ValidateRule parses and runs the 6-pass `NsqlValidator` audit against
// an NSQL rule definition WITHOUT persisting it. Unlike `CreateRule`,
// an invalid rule is NOT an RPC failure: it is the expected, honestly
// reported result carried in `ValidateRuleResponse.valid`/`error`.
rpc ValidateRule(ValidateRuleRequest) returns (ValidateRuleResponse);
// PreviewRule dry-run evaluates an ad hoc, NOT-yet-persisted NSQL rule
// definition against a stored message (by `message_id`) -- or, if none is
// given, a synthetic sample message -- using `FilterEngine::preview`'s
// existing dry-run evaluation path. Never persists the rule and never
// executes any of its actions.
rpc PreviewRule(PreviewRuleRequest) returns (PreviewRuleResponse);
}
// FilterRule mirrors `nuncio_filter::FilterRule`'s persisted/user-facing
// fields. The parsed condition AST is intentionally NOT mirrored here --
// `nsql_text` is the single source of truth, and re-parsing it via
// `NsqlParser::parse_rule` deterministically reconstructs the identical AST,
// so there is no independent state to carry over the wire. `actions` is the
// NSQL-rendered form (`RuleAction::to_nsql`) of the rule's parsed action
// list, included for display purposes.
message FilterRule {
// Rule unique identifier.
string id = 1;
// Rule display name.
string name = 2;
// Target account pattern ("*" for all accounts, an exact address, or a
// wildcard like "%@kof22.com").
string target_account = 3;
// Rule evaluation order (0 = highest).
int32 priority = 4;
// Active flag.
bool enabled = 5;
// Full NSQL source code text.
string nsql_text = 6;
// Rendered NSQL action clauses (e.g. "MOVE TO 'Archive'"), derived from
// the parsed AST.
repeated string actions = 7;
// Creation timestamp (unix seconds).
int64 created_at = 8;
// Last update timestamp (unix seconds).
int64 updated_at = 9;
}
// CreateRuleRequest carries a new rule's display name, full NSQL source
// text, and evaluation priority.
message CreateRuleRequest {
// Display name for the rule.
string name = 1;
// Full NSQL source code text (may include an `ON ACCOUNT` clause and
// `ACTION` clause).
string nsql = 2;
// Evaluation order (0 = highest).
int32 priority = 3;
}
// CreateRuleResponse carries the persisted rule, including its
// daemon-assigned `id` and timestamps.
message CreateRuleResponse {
FilterRule rule = 1;
}
// ListRulesRequest currently carries no parameters.
message ListRulesRequest {}
// ListRulesResponse.
message ListRulesResponse {
repeated FilterRule rules = 1;
}
// DeleteRuleRequest.
message DeleteRuleRequest {
// Rule unique identifier.
string id = 1;
}
// DeleteRuleResponse currently carries no parameters; a successful RPC
// response (as opposed to a `Status` error) is confirmation enough.
message DeleteRuleResponse {}
// ValidateRuleRequest carries only the raw NSQL source text -- validation
// never persists anything, so no name/priority/id is needed.
message ValidateRuleRequest {
string nsql = 1;
}
// ValidateRuleResponse. `valid == false` is an ordinary, successfully
// returned result (never a `Status` error) carrying the honest parse or
// `NsqlValidator` failure message in `error`.
message ValidateRuleResponse {
// Whether `nsql` parsed AND passed every `NsqlValidator` pass.
bool valid = 1;
// Empty when `valid` is true; otherwise the parse or validation error
// message.
string error = 2;
}
// PreviewRuleRequest carries an ad hoc, NOT-yet-persisted NSQL rule
// definition to dry-run evaluate. `message_id` optionally names a stored
// message to evaluate against (looked up via the same store `Mail/
// GetMessage` reads); when absent (or when `message_id` does not resolve to
// a stored message), a fixed synthetic sample message is evaluated instead,
// so this RPC can always return a result.
message PreviewRuleRequest {
// Full NSQL source code text of the rule to preview.
string nsql = 1;
// Optional stored message id to evaluate the rule against.
optional string message_id = 2;
}
// PreviewRuleResponse mirrors `nuncio_filter::FilterPreviewResult`
// field-for-field, except `actions_evaluated` is rendered as NSQL strings
// (`RuleAction::to_nsql`) rather than a structured action AST.
message PreviewRuleResponse {
// Evaluated message id (either the requested `message_id`, or the
// synthetic sample message's fixed id when none was given/resolvable).
string message_id = 1;
// Whether the previewed rule matched the evaluated message.
bool matched = 2;
// Present only when `matched` is true.
optional string matched_rule_id = 3;
// Present only when `matched` is true.
optional string matched_rule_name = 4;
// NSQL-rendered actions that would be executed on a real match.
repeated string actions_evaluated = 5;
// Evaluation duration in microseconds.
uint64 execution_time_us = 6;
// Human-readable step-by-step match traces.
repeated string condition_traces = 7;
}
+42
View File
@@ -10,6 +10,7 @@
//! server-side interceptor in `nunciod::grpc`.
use crate::v1::accounts_client::AccountsClient;
use crate::v1::filters_client::FiltersClient;
use crate::v1::mail_client::MailClient;
use crate::v1::system_client::SystemClient;
use crate::v1::{Event, SubscribeRequest};
@@ -94,6 +95,11 @@ pub type AuthenticatedAccountsClient =
/// [`connect_mail`].
pub type AuthenticatedMailClient = MailClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// The authenticated `nuncio.v1.Filters` client type returned by
/// [`connect_filters`].
pub type AuthenticatedFiltersClient =
FiltersClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
/// token instead) and returns the connected [`Channel`], shared by every
@@ -164,6 +170,25 @@ pub async fn connect_mail(
Ok(MailClient::with_interceptor(channel, interceptor))
}
/// Dials the `nuncio.v1.Filters` gRPC endpoint at `addr` and returns a
/// client that injects `authorization: Bearer <token>` metadata on every
/// call (backlog story 2.A, GH #171).
///
/// `Filters` is guarded by the exact same `BearerAuthInterceptor` as
/// `System`/`Accounts`/`Mail` on the server side (see
/// `nunciod::grpc::serve_on_listener`, GH #165), so this shares
/// [`BearerTokenInterceptor`] and [`dial`] with [`connect_system`] /
/// [`connect_accounts`] / [`connect_mail`] rather than hand-rolling a fourth
/// auth handshake.
pub async fn connect_filters(
addr: &str,
token: &str,
) -> Result<AuthenticatedFiltersClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(FiltersClient::with_interceptor(channel, interceptor))
}
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
/// authenticated client (as returned by [`connect_system`]), reusing the
/// bearer-token interceptor already attached to `client` rather than
@@ -272,6 +297,23 @@ mod tests {
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_filters_fails_closed_on_invalid_token() {
let err = connect_filters("127.0.0.1:0", "tok\ntoken")
.await
.expect_err("invalid token must fail before dialing");
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_filters_reports_transport_error_when_daemon_unreachable() {
let err = connect_filters("127.0.0.1:1", "abc123")
.await
.expect_err("connecting to an unreachable daemon must fail");
assert!(matches!(err, ConnectError::Transport { .. }));
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_system_reports_invalid_endpoint_for_a_malformed_address() {
// An embedded space is not a legal URI authority character, so
+735 -35
View File
@@ -8,27 +8,33 @@
//! transport is out of scope here and lands in a later story.
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
use nuncio_mail::{MailBackend, MessageSender};
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::filters_server::{Filters, FiltersServer};
use nuncio_proto::v1::mail_server::{Mail, MailServer};
use nuncio_proto::v1::system_server::{System, SystemServer};
use nuncio_proto::v1::{
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto, BatchFilterProgress,
DatabaseRecovered, Event, EventError, FilterExecuted, Folder as FolderProto, GetMessageRequest,
GetMessageResponse, GetStatusRequest, GetStatusResponse, ListAccountsRequest,
ListAccountsResponse, ListFoldersRequest, ListFoldersResponse, ListMessagesRequest,
ListMessagesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto,
MessageFlagsChanged, MessageSearchHit, SearchMessagesRequest, SearchMessagesResponse,
SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted,
SyncRequest, SyncResponse, SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
CreateRuleRequest, CreateRuleResponse, DatabaseRecovered, DeleteRuleRequest,
DeleteRuleResponse, Event, EventError, FilterExecuted, FilterRule as FilterRuleProto,
Folder as FolderProto, GetMessageRequest, GetMessageResponse, GetStatusRequest,
GetStatusResponse, ListAccountsRequest, ListAccountsResponse, ListFoldersRequest,
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRulesRequest,
ListRulesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto,
MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest, PreviewRuleResponse,
SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest, SendMessageResponse,
ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted,
TlsMode as TlsModeProto, UpdateAvailable, ValidateRuleRequest, ValidateRuleResponse,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::search::SearchEngine;
use nuncio_store::vault::SecretManager;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
use thiserror::Error;
use tokio::net::TcpListener;
@@ -672,6 +678,234 @@ impl Mail for MailGrpcService {
}
}
/// Maps a `nuncio_filter::FilterRule` onto its wire-format
/// `nuncio.v1.FilterRule` representation (backlog story 2.A, GH #171). The
/// parsed condition AST is deliberately NOT carried over the wire -- see the
/// message's doc comment in `proto/nuncio/v1/nuncio.proto` -- `actions` is
/// rendered as NSQL strings via `RuleAction::to_nsql`.
fn map_filter_rule_to_proto(rule: nuncio_filter::FilterRule) -> FilterRuleProto {
FilterRuleProto {
id: rule.id,
name: rule.name,
target_account: rule.target_account,
priority: rule.priority,
enabled: rule.enabled,
nsql_text: rule.nsql_text,
actions: rule.actions.iter().map(|a| a.to_nsql()).collect(),
created_at: rule.created_at,
updated_at: rule.updated_at,
}
}
/// Maps a `nuncio_filter::FilterPreviewResult` onto its wire-format
/// `nuncio.v1.PreviewRuleResponse` representation.
fn map_preview_result_to_proto(preview: nuncio_filter::FilterPreviewResult) -> PreviewRuleResponse {
PreviewRuleResponse {
message_id: preview.message_id,
matched: preview.matched,
matched_rule_id: preview.matched_rule_id,
matched_rule_name: preview.matched_rule_name,
actions_evaluated: preview
.actions_evaluated
.iter()
.map(|a| a.to_nsql())
.collect(),
execution_time_us: preview.execution_time_us,
condition_traces: preview.condition_traces,
}
}
/// Builds a fixed synthetic sample message for `PreviewRule` to evaluate
/// against when the caller supplies no `message_id` (or one that does not
/// resolve to a stored message), mirroring the exact fallback sample the
/// CLI's previous local `filter test` path used -- so this RPC can always
/// return a preview result rather than requiring pre-existing stored mail.
fn synthetic_preview_email(id: &str) -> nuncio_core::model::Email {
let received_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default();
nuncio_core::model::Email {
id: id.to_string(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: "Test Subject".to_string(),
sender: "test@nuncio.mx".to_string(),
recipient: "me@nuncio.mx".to_string(),
received_at,
read: false,
body_plain: Some("Sample body text".to_string()),
body_html: None,
attachments: Vec::new(),
}
}
/// `nuncio.v1.Filters` gRPC service implementation backed by the daemon's
/// live [`DatabaseEngine`] filter-rule CRUD and the daemon's live
/// [`FilterEngine`] (backlog story 2.A, GH #171) -- the authenticated gRPC
/// replacement for the existing hand-rolled `filter.*` JSON-RPC IPC methods
/// (`nunciod::main`'s `CustomRpcHandler`), which remain in place (both
/// transports coexist) until retired in a later story (2.C).
///
/// `CreateRule`/`DeleteRule` persist through `db` FIRST, then reload
/// `filter_engine`'s `ArcSwap`-backed rule set from the freshly persisted
/// state via `db.list_filter_rules()`, so the live engine and the persisted
/// store never disagree about which rules exist. A reload failure (e.g. a
/// rule whose regex no longer compiles) is logged but does NOT fail the
/// RPC: the mutation already succeeded in the store, so failing the RPC
/// here would misreport a successful write as an error.
struct FiltersGrpcService {
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
}
impl FiltersGrpcService {
/// Reloads `self.filter_engine`'s active rule set from `self.db`'s
/// current persisted rules, logging (rather than propagating) a reload
/// failure -- shared by `create_rule` and `delete_rule` after each
/// persists its own mutation.
async fn reload_engine_from_store(&self) {
match self.db.list_filter_rules().await {
Ok(rules) => {
if let Err(e) = self.filter_engine.reload_rules(rules) {
tracing::warn!("Filters: failed to reload live FilterEngine rules: {}", e);
}
}
Err(e) => {
tracing::warn!(
"Filters: failed to re-list persisted filter rules for engine reload: {}",
e
);
}
}
}
}
#[tonic::async_trait]
impl Filters for FiltersGrpcService {
async fn create_rule(
&self,
request: Request<CreateRuleRequest>,
) -> Result<Response<CreateRuleResponse>, Status> {
let req = request.into_inner();
let rule = NsqlParser::parse_rule(req.name, req.priority, &req.nsql)
.map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?;
NsqlValidator::validate(&rule, &ValidationOptions::default())
.map_err(|e| Status::invalid_argument(format!("NSQL validation error: {e}")))?;
self.db
.save_filter_rule(&rule)
.await
.map_err(|e| Status::internal(format!("failed to persist filter rule: {e}")))?;
self.reload_engine_from_store().await;
Ok(Response::new(CreateRuleResponse {
rule: Some(map_filter_rule_to_proto(rule)),
}))
}
async fn list_rules(
&self,
_request: Request<ListRulesRequest>,
) -> Result<Response<ListRulesResponse>, Status> {
let rules = self
.db
.list_filter_rules()
.await
.map_err(|e| Status::internal(format!("failed to list filter rules: {e}")))?
.into_iter()
.map(map_filter_rule_to_proto)
.collect();
Ok(Response::new(ListRulesResponse { rules }))
}
async fn delete_rule(
&self,
request: Request<DeleteRuleRequest>,
) -> Result<Response<DeleteRuleResponse>, Status> {
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
self.db
.delete_filter_rule(&req.id)
.await
.map_err(|e| Status::internal(format!("failed to delete filter rule: {e}")))?;
self.reload_engine_from_store().await;
Ok(Response::new(DeleteRuleResponse {}))
}
/// Unlike `create_rule`, an invalid rule is never an RPC failure here --
/// see this RPC's doc comment in `proto/nuncio/v1/nuncio.proto`.
async fn validate_rule(
&self,
request: Request<ValidateRuleRequest>,
) -> Result<Response<ValidateRuleResponse>, Status> {
let req = request.into_inner();
let rule = match NsqlParser::parse_rule("Validation Preview", 0, &req.nsql) {
Ok(rule) => rule,
Err(e) => {
return Ok(Response::new(ValidateRuleResponse {
valid: false,
error: format!("NSQL syntax error: {e}"),
}));
}
};
match NsqlValidator::validate(&rule, &ValidationOptions::default()) {
Ok(()) => Ok(Response::new(ValidateRuleResponse {
valid: true,
error: String::new(),
})),
Err(e) => Ok(Response::new(ValidateRuleResponse {
valid: false,
error: format!("NSQL validation error: {e}"),
})),
}
}
/// Dry-run evaluates an ad hoc, NOT-yet-persisted NSQL rule against a
/// stored message (or a synthetic sample -- see
/// [`synthetic_preview_email`]), via a fresh one-off [`FilterEngine`]
/// built from just that single rule and its existing `preview` path.
/// This deliberately does NOT use `self.filter_engine` (the daemon's
/// live, persisted rule set): the whole point of a preview is to try out
/// a candidate rule that may not be, and may never be, saved.
async fn preview_rule(
&self,
request: Request<PreviewRuleRequest>,
) -> Result<Response<PreviewRuleResponse>, Status> {
let req = request.into_inner();
let rule = NsqlParser::parse_rule("Preview Rule", 0, &req.nsql)
.map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?;
let preview_engine = FilterEngine::new(vec![rule])
.map_err(|e| Status::internal(format!("failed to build preview engine: {e}")))?;
let email = match &req.message_id {
Some(message_id) if !message_id.is_empty() => {
match self.db.get_message(message_id).await {
Ok(email) => email,
Err(_) => synthetic_preview_email(message_id),
}
}
_ => synthetic_preview_email("msg-test"),
};
let preview = preview_engine.preview(&email);
Ok(Response::new(map_preview_result_to_proto(preview)))
}
}
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
/// server.
///
@@ -719,8 +953,9 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
}
/// Binds a loopback TCP listener at `addr` and serves the `nuncio.v1.System`,
/// `nuncio.v1.Accounts`, and `nuncio.v1.Mail` gRPC services on it, all
/// authenticated by `token`, until the transport server errors.
/// `nuncio.v1.Accounts`, `nuncio.v1.Mail`, and `nuncio.v1.Filters` gRPC
/// services on it, all authenticated by `token`, until the transport server
/// errors.
///
/// `addr` MUST be a loopback address (e.g. `127.0.0.1:PORT`); callers are
/// responsible for passing loopback-only addresses (see
@@ -729,6 +964,7 @@ pub async fn serve(
addr: &str,
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
) -> Result<(), GrpcServeError> {
@@ -738,11 +974,11 @@ pub async fn serve(
addr: addr.to_string(),
source,
})?;
serve_on_listener(listener, event_bus, db, secrets, token).await
serve_on_listener(listener, event_bus, db, filter_engine, secrets, token).await
}
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, and `nuncio.v1.Mail`
/// gRPC services on an already-bound [`TcpListener`].
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`, and
/// `nuncio.v1.Filters` gRPC services on an already-bound [`TcpListener`].
///
/// # Security (GH #165)
///
@@ -763,6 +999,7 @@ pub async fn serve_on_listener(
listener: TcpListener,
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
) -> Result<(), GrpcServeError> {
@@ -770,6 +1007,7 @@ pub async fn serve_on_listener(
listener,
event_bus,
db,
filter_engine,
secrets,
token,
MailEngineOverrides::default(),
@@ -788,6 +1026,7 @@ pub async fn serve_on_listener_with_overrides(
listener: TcpListener,
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
overrides: MailEngineOverrides,
@@ -812,18 +1051,27 @@ pub async fn serve_on_listener_with_overrides(
// exactly like `System` and `Accounts` above -- see the hard invariant
// documented on this function's doc comment (GH #165).
let mail_service = MailGrpcService {
db,
db: db.clone(),
event_bus,
secrets,
secrets: secrets.clone(),
overrides,
};
let mail_interceptor = BearerAuthInterceptor::new(token);
let mail_interceptor = BearerAuthInterceptor::new(token.clone());
let mail_svc = MailServer::with_interceptor(mail_service, mail_interceptor);
// Filters (backlog story 2.A, GH #171): mounted behind its own
// `BearerAuthInterceptor`, exactly like `System`/`Accounts`/`Mail` above
// -- see the hard invariant documented on this function's doc comment
// (GH #165).
let filters_service = FiltersGrpcService { db, filter_engine };
let filters_interceptor = BearerAuthInterceptor::new(token);
let filters_svc = FiltersServer::with_interceptor(filters_service, filters_interceptor);
Server::builder()
.add_service(system_svc)
.add_service(accounts_svc)
.add_service(mail_svc)
.add_service(filters_svc)
.serve_with_incoming(TcpListenerStream::new(listener))
.await
.map_err(GrpcServeError::Transport)
@@ -837,12 +1085,15 @@ mod tests {
use nuncio_proto::v1::system_client::SystemClient;
use tonic::Code;
/// Spawns a test server backed by a fresh ephemeral database and a
/// fresh [`SecretManager::mock`] vault -- the right default for every
/// test that only exercises `System` or doesn't care about pre-existing
/// account/keyring state. Tests that DO care (persistence-across-restart,
/// credential-secrecy) build their own `db`/`secrets` and call
/// [`spawn_test_server_with`] directly instead.
/// Spawns a test server backed by a fresh ephemeral database, a fresh
/// empty [`FilterEngine`], and a fresh [`SecretManager::mock`] vault --
/// the right default for every test that only exercises
/// `System`/`Accounts`/`Mail` or doesn't care about pre-existing
/// account/keyring/filter-rule state. Tests that DO care
/// (persistence-across-restart, credential-secrecy, the `Filters`
/// service itself) build their own `db`/`secrets`/`filter_engine` and
/// call [`spawn_test_server_with`] / [`spawn_test_server_with_overrides`]
/// directly instead.
async fn spawn_test_server(
event_bus: Arc<EventBus>,
token: &str,
@@ -851,21 +1102,25 @@ mod tests {
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
spawn_test_server_with(event_bus, Arc::new(db), secrets, token).await
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
spawn_test_server_with(event_bus, Arc::new(db), filter_engine, secrets, token).await
}
/// Spawns a test server on an ephemeral loopback port backed by the
/// given `db` and `secrets`, so tests can share (and re-open) the same
/// database path / mock keyring state across multiple server instances.
/// given `db`, `filter_engine`, and `secrets`, so tests can share (and
/// re-open) the same database path / engine instance / mock keyring
/// state across multiple server instances.
async fn spawn_test_server_with(
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: &str,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
spawn_test_server_with_overrides(
event_bus,
db,
filter_engine,
secrets,
token,
MailEngineOverrides::default(),
@@ -874,14 +1129,15 @@ mod tests {
}
/// Spawns a test server on an ephemeral loopback port backed by the
/// given `db`/`secrets`/`overrides` (backlog story 1.C.6, GH #161): the
/// helper every test that injects a [`MailEngineOverrides::mail_backend`]
/// / [`MailEngineOverrides::message_sender`] uses to prove `Sync` /
/// `SendMessage` drive an injected test double over the real
/// authenticated gRPC API.
/// given `db`/`filter_engine`/`secrets`/`overrides` (backlog story
/// 1.C.6, GH #161): the helper every test that injects a
/// [`MailEngineOverrides::mail_backend`] / [`MailEngineOverrides::message_sender`]
/// uses to prove `Sync` / `SendMessage` drive an injected test double
/// over the real authenticated gRPC API.
async fn spawn_test_server_with_overrides(
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: &str,
overrides: MailEngineOverrides,
@@ -893,7 +1149,13 @@ mod tests {
let token = token.to_string();
let handle = tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener, event_bus, db, secrets, token, overrides,
listener,
event_bus,
db,
filter_engine,
secrets,
token,
overrides,
)
.await;
});
@@ -1021,8 +1283,17 @@ mod tests {
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let _handle = tokio::spawn(async move {
let _ = serve("127.0.0.1:0", event_bus, Arc::new(db), secrets, "token").await;
let _ = serve(
"127.0.0.1:0",
event_bus,
Arc::new(db),
filter_engine,
secrets,
"token",
)
.await;
});
// Yield so the spawned task is polled at least once and actually
// reaches the bind + delegate-to-`serve_on_listener` call before
@@ -1037,10 +1308,12 @@ mod tests {
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let err = serve(
"not-a-valid-addr",
event_bus,
Arc::new(db),
filter_engine,
secrets,
"token",
)
@@ -1313,6 +1586,7 @@ mod tests {
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db_first_run.clone(),
Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")),
secrets.clone(),
"correct-token",
)
@@ -1348,6 +1622,7 @@ mod tests {
let (addr2, _handle2) = spawn_test_server_with(
Arc::new(EventBus::new()),
db_second_run,
Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")),
secrets.clone(),
"correct-token",
)
@@ -1392,6 +1667,7 @@ mod tests {
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db.clone(),
Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")),
secrets.clone(),
"correct-token",
)
@@ -1657,8 +1933,15 @@ mod tests {
let event_bus = Arc::new(EventBus::new());
let mut events = event_bus.subscribe_events();
let secrets = Arc::new(SecretManager::mock());
let (addr, _handle) =
spawn_test_server_with(event_bus, Arc::new(db), secrets, "correct-token").await;
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
@@ -1853,8 +2136,15 @@ mod tests {
.set_secret(&config.keyring_secret_key, "irrelevant-password")
.expect("store credential in mock vault");
let (addr, _handle) =
spawn_test_server_with(Arc::new(EventBus::new()), db, secrets, "correct-token").await;
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db,
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
@@ -1944,9 +2234,11 @@ mod tests {
mail_backend: Some(Arc::new(mock_backend)),
message_sender: None,
};
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
filter_engine,
secrets,
"correct-token",
overrides,
@@ -2017,9 +2309,11 @@ mod tests {
mail_backend: None,
message_sender: Some(Arc::new(mock_sender)),
};
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
filter_engine,
secrets,
"correct-token",
overrides,
@@ -2071,9 +2365,11 @@ mod tests {
mail_backend: Some(Arc::new(mock_backend)),
message_sender: None,
};
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
filter_engine,
secrets,
"correct-token",
overrides,
@@ -2126,9 +2422,11 @@ mod tests {
mail_backend: None,
message_sender: Some(Arc::new(mock_sender)),
};
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
filter_engine,
secrets,
"correct-token",
overrides,
@@ -2145,4 +2443,406 @@ mod tests {
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("failed to send message"));
}
// ---- Filters (backlog story 2.A, GH #171) ----
use nuncio_proto::v1::filters_client::FiltersClient;
use nuncio_proto::v1::{
CreateRuleRequest, DeleteRuleRequest, ListRulesRequest, PreviewRuleRequest,
ValidateRuleRequest,
};
const SAMPLE_RULE_NSQL: &str =
"WHERE subject CONTAINS 'Urgent' ACTION MARK READ, MOVE TO 'Priority'";
/// Spawns a test server on an ephemeral loopback port backed by a fresh
/// ephemeral database and a fresh empty [`FilterEngine`], returning the
/// address AND the shared `filter_engine`/`db` handles so `Filters`
/// tests can assert directly on live engine state (proving the
/// `ArcSwap` reload actually happened) in addition to driving the gRPC
/// API.
async fn spawn_filters_test_server(
token: &str,
) -> (
std::net::SocketAddr,
tokio::task::JoinHandle<()>,
Arc<DatabaseEngine>,
Arc<FilterEngine>,
) {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let secrets = Arc::new(SecretManager::mock());
let (addr, handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db.clone(),
filter_engine.clone(),
secrets,
token,
)
.await;
(addr, handle, db, filter_engine)
}
#[tokio::test]
async fn filters_rpcs_reject_missing_bearer_token() {
// Confirms `Filters` is mounted behind its own `BearerAuthInterceptor`
// exactly like `System`/`Accounts`/`Mail` (GH #165: no un-intercepted
// service).
let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.create_rule(CreateRuleRequest {
name: "Unauthed".to_string(),
nsql: SAMPLE_RULE_NSQL.to_string(),
priority: 0,
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.list_rules(ListRulesRequest {})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.delete_rule(DeleteRuleRequest {
id: "rule-1".to_string(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.validate_rule(ValidateRuleRequest {
nsql: SAMPLE_RULE_NSQL.to_string(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.preview_rule(PreviewRuleRequest {
nsql: SAMPLE_RULE_NSQL.to_string(),
message_id: None,
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
/// End-to-end proof for backlog story 2.A (GH #171): `CreateRule`
/// persists AND reloads the live `FilterEngine`'s `ArcSwap` rule set
/// (proven by evaluating the SAME `filter_engine` instance directly,
/// not just re-reading it back over `ListRules`); `ListRules` reflects
/// the persisted rule; `DeleteRule` removes it AND reloads the engine
/// back to empty.
#[tokio::test]
async fn create_list_and_delete_rule_round_trip_and_keep_the_live_engine_in_sync() {
let (addr, _handle, _db, filter_engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let sample_email = |subject: &str| nuncio_core::model::Email {
id: "msg-filters-1".to_string(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: subject.to_string(),
sender: "alice@nuncio.mx".to_string(),
recipient: "bob@nuncio.mx".to_string(),
received_at: 1_700_000_000,
read: false,
body_plain: Some("body".to_string()),
body_html: None,
attachments: Vec::new(),
};
// Before creating anything, the live engine matches nothing.
assert!(filter_engine
.evaluate(&sample_email("Urgent Meeting"))
.is_empty());
let create_response = client
.create_rule(authed_bearer_request(CreateRuleRequest {
name: "Urgent Rule".to_string(),
nsql: SAMPLE_RULE_NSQL.to_string(),
priority: 5,
}))
.await
.expect("create_rule succeeds")
.into_inner();
let created_rule = create_response.rule.expect("response carries the rule");
assert_eq!(created_rule.name, "Urgent Rule");
assert_eq!(created_rule.priority, 5);
assert!(created_rule.enabled);
assert_eq!(created_rule.nsql_text, SAMPLE_RULE_NSQL);
assert!(created_rule.actions.iter().any(|a| a.contains("MARK READ")));
let rule_id = created_rule.id.clone();
// The live `FilterEngine`'s `ArcSwap` rule set was reloaded --
// proven by evaluating the SAME instance the server holds, not a
// fresh one.
let matches = filter_engine.evaluate(&sample_email("Urgent Meeting"));
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].0.id, rule_id);
// ListRules reflects the persisted rule.
let list_response = client
.list_rules(authed_bearer_request(ListRulesRequest {}))
.await
.expect("list_rules succeeds")
.into_inner();
assert_eq!(list_response.rules.len(), 1);
assert_eq!(list_response.rules[0].id, rule_id);
// DeleteRule removes it AND reloads the engine back to empty.
client
.delete_rule(authed_bearer_request(DeleteRuleRequest {
id: rule_id.clone(),
}))
.await
.expect("delete_rule succeeds");
let list_after_delete = client
.list_rules(authed_bearer_request(ListRulesRequest {}))
.await
.expect("list_rules succeeds")
.into_inner();
assert!(list_after_delete.rules.is_empty());
assert!(filter_engine
.evaluate(&sample_email("Urgent Meeting"))
.is_empty());
}
#[tokio::test]
async fn create_rule_rejects_invalid_nsql_and_persists_nothing() {
let (addr, _handle, _db, filter_engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.create_rule(authed_bearer_request(CreateRuleRequest {
name: "Broken Rule".to_string(),
nsql: "THIS IS NOT VALID NSQL AT ALL {{{".to_string(),
priority: 0,
}))
.await
.expect_err("invalid NSQL must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
let list_response = client
.list_rules(authed_bearer_request(ListRulesRequest {}))
.await
.expect("list_rules succeeds")
.into_inner();
assert!(
list_response.rules.is_empty(),
"an invalid rule must never be persisted"
);
assert!(filter_engine
.evaluate(&nuncio_core::model::Email {
id: "msg-1".to_string(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: "anything".to_string(),
sender: "a@b.com".to_string(),
recipient: "c@d.com".to_string(),
received_at: 0,
read: false,
body_plain: None,
body_html: None,
attachments: Vec::new(),
})
.is_empty());
}
#[tokio::test]
async fn delete_rule_rejects_empty_id() {
let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.delete_rule(authed_bearer_request(DeleteRuleRequest {
id: String::new(),
}))
.await
.expect_err("empty id must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
/// `ValidateRule` never fails the RPC itself -- an invalid rule is an
/// honest `valid: false` result, not a `Status` error (see this RPC's
/// doc comment). Also proves validation never persists anything.
#[tokio::test]
async fn validate_rule_reports_honest_ok_and_error_verdicts_without_persisting() {
let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let valid_response = client
.validate_rule(authed_bearer_request(ValidateRuleRequest {
nsql: SAMPLE_RULE_NSQL.to_string(),
}))
.await
.expect("validate_rule call itself succeeds for a valid rule")
.into_inner();
assert!(valid_response.valid);
assert!(valid_response.error.is_empty());
let invalid_response = client
.validate_rule(authed_bearer_request(ValidateRuleRequest {
nsql: "THIS IS NOT VALID NSQL AT ALL {{{".to_string(),
}))
.await
.expect("validate_rule call itself succeeds even for an invalid rule")
.into_inner();
assert!(!invalid_response.valid);
assert!(!invalid_response.error.is_empty());
// Nothing was persisted by either validation call.
let list_response = client
.list_rules(authed_bearer_request(ListRulesRequest {}))
.await
.expect("list_rules succeeds")
.into_inner();
assert!(
list_response.rules.is_empty(),
"validate_rule must never persist a rule"
);
}
/// `PreviewRule` dry-run evaluates an ad hoc rule against a stored
/// message (proving it reads REAL persisted mail via the same store
/// `Mail/GetMessage` reads) without ever persisting the rule itself.
#[tokio::test]
async fn preview_rule_dry_runs_against_a_stored_message_without_persisting() {
let (addr, _handle, db, _engine) = spawn_filters_test_server("correct-token").await;
db.save_email(&nuncio_core::model::Email {
id: "msg-preview-1".to_string(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: "Urgent Board Meeting".to_string(),
sender: "alice@nuncio.mx".to_string(),
recipient: "bob@nuncio.mx".to_string(),
received_at: 1_700_000_000,
read: false,
body_plain: Some("Please review the attached deck".to_string()),
body_html: None,
attachments: Vec::new(),
})
.await
.expect("seed message");
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.preview_rule(authed_bearer_request(PreviewRuleRequest {
nsql: SAMPLE_RULE_NSQL.to_string(),
message_id: Some("msg-preview-1".to_string()),
}))
.await
.expect("preview_rule succeeds")
.into_inner();
assert_eq!(response.message_id, "msg-preview-1");
assert!(response.matched);
assert!(response
.actions_evaluated
.iter()
.any(|a| a.contains("MARK READ")));
assert!(!response.condition_traces.is_empty());
// Preview evaluates a NON-matching message honestly too.
let non_matching = client
.preview_rule(authed_bearer_request(PreviewRuleRequest {
nsql: "WHERE subject CONTAINS 'Nonexistent Term' ACTION DELETE".to_string(),
message_id: Some("msg-preview-1".to_string()),
}))
.await
.expect("preview_rule succeeds")
.into_inner();
assert!(!non_matching.matched);
// Previewing must never persist the rule.
let list_response = client
.list_rules(authed_bearer_request(ListRulesRequest {}))
.await
.expect("list_rules succeeds")
.into_inner();
assert!(
list_response.rules.is_empty(),
"preview_rule must never persist a rule"
);
}
/// When no `message_id` is given (or it does not resolve to a stored
/// message), `PreviewRule` falls back to a fixed synthetic sample
/// message rather than failing the RPC.
#[tokio::test]
async fn preview_rule_falls_back_to_a_synthetic_message_when_none_is_stored() {
let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.preview_rule(authed_bearer_request(PreviewRuleRequest {
nsql: "WHERE subject CONTAINS 'Test Subject' ACTION FLAG".to_string(),
message_id: None,
}))
.await
.expect("preview_rule succeeds with no message_id")
.into_inner();
assert!(response.matched);
assert_eq!(response.message_id, "msg-test");
let response_missing_id = client
.preview_rule(authed_bearer_request(PreviewRuleRequest {
nsql: "WHERE subject CONTAINS 'Test Subject' ACTION FLAG".to_string(),
message_id: Some("does-not-exist".to_string()),
}))
.await
.expect("preview_rule succeeds with an unresolvable message_id")
.into_inner();
assert!(response_missing_id.matched);
assert_eq!(response_missing_id.message_id, "does-not-exist");
}
#[tokio::test]
async fn preview_rule_and_validate_rule_reject_invalid_nsql_syntax() {
let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.preview_rule(authed_bearer_request(PreviewRuleRequest {
nsql: "THIS IS NOT VALID NSQL AT ALL {{{".to_string(),
message_id: None,
}))
.await
.expect_err("invalid NSQL must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
}
+8 -1
View File
@@ -380,16 +380,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let grpc_token = hex::encode(grpc_token_bytes);
let grpc_addr = nunciod::grpc::grpc_addr_from_env();
tracing::info!(
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts) starting on {} (loopback only)",
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts, nuncio.v1.Mail, nuncio.v1.Filters) \
starting on {} (loopback only)",
grpc_addr
);
let grpc_event_bus = event_bus.clone();
let grpc_db = db.clone();
// The gRPC `Filters` service (backlog story 2.A, GH #171) shares this
// SAME `filter_engine` instance with the JSON-RPC `filter.*` handler
// above, so a rule created/deleted through EITHER transport reloads the
// one live `ArcSwap`-backed rule set both transports evaluate against.
let grpc_filter_engine = filter_engine.clone();
let _grpc_task = tokio::spawn(async move {
if let Err(e) = nunciod::grpc::serve(
&grpc_addr,
grpc_event_bus,
grpc_db,
grpc_filter_engine,
grpc_secrets,
grpc_token,
)
+3
View File
@@ -14,6 +14,7 @@
use nuncio_cli::{AccountSubcommand, Commands, HeadlessRunner, SystemSubcommand};
use nuncio_core::ipc::{IpcClient, IpcDaemonServer};
use nuncio_core::{CoreCommand, EventBus};
use nuncio_filter::FilterEngine;
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use std::sync::Arc;
@@ -157,11 +158,13 @@ async fn cli_system_status_round_trips_over_grpc_to_live_daemon() {
.await
.expect("connect ephemeral test db");
let server_secrets = secrets.clone();
let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let _server_handle = tokio::spawn(async move {
let _ = nunciod::grpc::serve_on_listener(
listener,
server_event_bus,
Arc::new(server_db),
server_filter_engine,
server_secrets,
token,
)
+11 -1
View File
@@ -14,6 +14,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::{CoreCommand, EngineStatus, EventBus};
use nuncio_filter::FilterEngine;
use nuncio_proto::v1::system_client::SystemClient;
use nuncio_proto::v1::GetStatusRequest;
use nuncio_store::db::DatabaseEngine;
@@ -40,8 +41,17 @@ async fn start_server(event_bus: Arc<EventBus>, token: String) -> std::net::Sock
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
tokio::spawn(async move {
let _ = serve_on_listener(listener, event_bus, Arc::new(db), secrets, token).await;
let _ = serve_on_listener(
listener,
event_bus,
Arc::new(db),
filter_engine,
secrets,
token,
)
.await;
});
addr
}
+3
View File
@@ -34,6 +34,7 @@
use nuncio_core::model::{Email, Folder};
use nuncio_core::EventBus;
use nuncio_filter::FilterEngine;
use nuncio_mail::{MockMailBackend, MockMessageSender};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::{
@@ -115,11 +116,13 @@ async fn boot_daemon(
let event_bus = Arc::new(EventBus::new());
let server_secrets = secrets.clone();
let server_token = token.clone();
let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
server_filter_engine,
server_secrets,
server_token,
overrides,