From d1a6f8f417e08c1780b020ab4129e36defed89ef Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 31 Jul 2026 17:05:21 -0500 Subject: [PATCH 1/2] feat(store): implement contact search over contacts_fts (#354) Add search_contacts/search_contacts_page to SearchEngine, mirroring the existing message and event search queries and their keyset pagination. The contacts_fts virtual table and its insert/update/delete triggers already existed but were unused; new tests prove the triggers keep the index in sync across insert, update, and delete, that ranked hits correctly include/exclude matches, and that sanitized/edge queries do not error. Refs #318 Co-authored-by: James Maes --- crates/nuncio-store/src/search.rs | 236 ++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/crates/nuncio-store/src/search.rs b/crates/nuncio-store/src/search.rs index dc25f64..92d63e1 100644 --- a/crates/nuncio-store/src/search.rs +++ b/crates/nuncio-store/src/search.rs @@ -133,6 +133,92 @@ impl<'a> SearchEngine<'a> { Ok((hits, next)) } + /// Perform a full-text trigram search over contact display names, organizations, and + /// email addresses. + pub async fn search_contacts(&self, query: &str) -> Result, DatabaseError> { + let clean_query = Self::sanitize_fts5_query(query); + if clean_query.is_empty() { + return Ok(Vec::new()); + } + + let rows: Vec<(String, String, String)> = sqlx::query_as( + r#" + SELECT id, display_name, snippet(contacts_fts, 3, '', '', '...', 10) as snippet + FROM contacts_fts + WHERE contacts_fts MATCH ? + ORDER BY rank + LIMIT 50 + "#, + ) + .bind(&clean_query) + .fetch_all(self.db.pool()) + .await + .map_err(DatabaseError::Query)?; + + Ok(rows + .into_iter() + .map(|(id, title, snippet)| SearchHit { id, title, snippet }) + .collect()) + } + + /// Keyset-paginated full-text search over contact display names, + /// organizations, and email addresses, ordered by relevance (`rank ASC, id + /// ASC`). Mirrors [`Self::search_messages_page`]'s rank-materializing + /// subquery so the same keyset-pagination guarantee (every hit returned + /// exactly once, across pages) applies to contact search. + pub async fn search_contacts_page( + &self, + query: &str, + after: Option<(f64, String)>, + page_size: usize, + ) -> Result<(Vec, Option<(f64, String)>), DatabaseError> { + let clean_query = Self::sanitize_fts5_query(query); + if clean_query.is_empty() { + return Ok((Vec::new(), None)); + } + let fetch = page_size.saturating_add(1); + + let mut builder: sqlx::QueryBuilder<'_, sqlx::Sqlite> = sqlx::QueryBuilder::new( + "SELECT id, title, snippet, rk FROM ( \ + SELECT id, display_name AS title, \ + snippet(contacts_fts, 3, '', '', '...', 10) AS snippet, rank AS rk \ + FROM contacts_fts WHERE contacts_fts MATCH ", + ); + builder.push_bind(clean_query); + builder.push(") "); + if let Some((rank, id)) = &after { + builder.push("WHERE (rk > "); + builder.push_bind(*rank); + builder.push(" OR (rk = "); + builder.push_bind(*rank); + builder.push(" AND id > "); + builder.push_bind(id.clone()); + builder.push("))"); + } + builder.push(" ORDER BY rk ASC, id ASC LIMIT "); + builder.push_bind(fetch as i64); + + let rows = builder + .build_query_as::<(String, String, String, f64)>() + .fetch_all(self.db.pool()) + .await + .map_err(DatabaseError::Query)?; + + let has_more = rows.len() > page_size; + let mut last_key: Option<(f64, String)> = None; + let hits: Vec = rows + .into_iter() + .take(page_size) + .map(|(id, title, snippet, rk)| { + last_key = Some((rk, id.clone())); + SearchHit { id, title, snippet } + }) + .collect(); + + let next = if has_more { last_key } else { None }; + Ok((hits, next)) + } + /// Perform a full-text trigram search over calendar event summaries and locations. pub async fn search_events(&self, query: &str) -> Result, DatabaseError> { let clean_query = Self::sanitize_fts5_query(query); @@ -359,6 +445,156 @@ mod tests { assert_eq!(hits[0].title, "Architecture Summit"); } + async fn insert_contact( + db: &DatabaseEngine, + id: &str, + display_name: &str, + organization: &str, + emails_json: &str, + ) { + sqlx::query( + "INSERT INTO contacts \ + (id, display_name, organization, emails_json, created_at, updated_at) \ + VALUES (?, ?, ?, ?, '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + ) + .bind(id) + .bind(display_name) + .bind(organization) + .bind(emails_json) + .execute(db.pool()) + .await + .unwrap(); + } + + /// Proves `contacts_fts` is actually populated by the `contacts_ai`/`contacts_au` triggers + /// (not merely created and left unused): several contacts are inserted, a matching term + /// ranks the true matches and excludes the non-matching contact. + #[tokio::test] + async fn fts5_contact_search_ranks_matches_and_excludes_others() { + let (db, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap(); + let search = SearchEngine::new(&db); + + assert!(search.search_contacts("").await.unwrap().is_empty()); + + insert_contact(&db, "ct-1", "Alice Architect", "Acme Corp", "[]").await; + insert_contact(&db, "ct-2", "Bob Baker", "Baker Studio", "[]").await; + insert_contact(&db, "ct-3", "Carol Architect", "Acme Corp", "[]").await; + + let hits = search.search_contacts("Architect").await.unwrap(); + let ids: Vec<&str> = hits.iter().map(|h| h.id.as_str()).collect(); + assert_eq!(hits.len(), 2); + assert!(ids.contains(&"ct-1")); + assert!(ids.contains(&"ct-3")); + assert!(!ids.contains(&"ct-2")); + + let bob_hits = search.search_contacts("Bob").await.unwrap(); + assert_eq!(bob_hits.len(), 1); + assert_eq!(bob_hits[0].id, "ct-2"); + assert_eq!(bob_hits[0].title, "Bob Baker"); + } + + /// Updating and deleting a contact keeps `contacts_fts` in sync via the `contacts_au`/ + /// `contacts_ad` triggers: a renamed contact stops matching its old name and starts + /// matching its new one, and a deleted contact stops matching entirely. + #[tokio::test] + async fn fts5_contact_search_reflects_update_and_delete() { + let (db, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap(); + let search = SearchEngine::new(&db); + + insert_contact(&db, "ct-upd-1", "Dana Original", "Original Org", "[]").await; + assert_eq!(search.search_contacts("Original").await.unwrap().len(), 1); + + sqlx::query( + "UPDATE contacts SET display_name = 'Dana Renamed', organization = 'New Org' \ + WHERE id = 'ct-upd-1'", + ) + .execute(db.pool()) + .await + .unwrap(); + + assert!(search.search_contacts("Original").await.unwrap().is_empty()); + let hits = search.search_contacts("Renamed").await.unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, "ct-upd-1"); + + sqlx::query("DELETE FROM contacts WHERE id = 'ct-upd-1'") + .execute(db.pool()) + .await + .unwrap(); + + assert!(search.search_contacts("Renamed").await.unwrap().is_empty()); + } + + /// A search term matching an indexed email address (stored as JSON) is found, and a + /// sanitized/edge-case query (FTS5 operator characters) does not error -- it just yields + /// no matches when nothing survives sanitization to match. + #[tokio::test] + async fn fts5_contact_search_matches_email_and_survives_edge_query() { + let (db, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap(); + let search = SearchEngine::new(&db); + + insert_contact( + &db, + "ct-email-1", + "Erin Example", + "", + r#"[{"email":"erin.example@nuncio.mx","label":"work"}]"#, + ) + .await; + + let hits = search.search_contacts("erin.example").await.unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, "ct-email-1"); + + let edge_hits = search.search_contacts("\"weird*: query\"").await.unwrap(); + assert!(edge_hits.is_empty()); + } + + /// Keyset pagination over contact search must return every matching hit exactly once, + /// mirroring the message-search pagination guarantee. + #[tokio::test] + async fn search_contacts_page_returns_every_hit_once_no_gaps() { + let (db, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap(); + for i in 0..5 { + insert_contact( + &db, + &format!("ct-page-{i}"), + "Planning Committee Member", + "Acme Corp", + "[]", + ) + .await; + } + + let search = SearchEngine::new(&db); + let mut seen = Vec::new(); + let mut after: Option<(f64, String)> = None; + let mut pages = 0; + loop { + let (hits, next) = search + .search_contacts_page("Planning", after.clone(), 2) + .await + .unwrap(); + pages += 1; + assert!(hits.len() <= 2, "page must not exceed page_size"); + seen.extend(hits.iter().map(|h| h.id.clone())); + match next { + Some(cursor) => after = Some(cursor), + None => break, + } + assert!(pages < 100, "pagination must terminate"); + } + + seen.sort(); + seen.dedup(); + assert_eq!( + seen.len(), + 5, + "every matching contact returned exactly once" + ); + assert!(pages >= 3, "5 hits at page_size 2 must span multiple pages"); + } + #[test] fn fts5_query_sanitizer_removes_special_operators() { assert_eq!(SearchEngine::sanitize_fts5_query(""), ""); -- 2.54.0 From a8fa5121cfea008a7f61dc8432d6c62f07ae4b9e Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 31 Jul 2026 17:24:40 -0500 Subject: [PATCH 2/2] feat(proto): enum hygiene for rule export format and account TLS Introduce a dedicated RuleExportFormat enum (SQL/JSON) for ExportRules, replacing the stringly "sql"/"json" format field; UNSPECIFIED is rejected with invalid_argument rather than silently defaulting. Reuse of the mailbox Export service's ExportFormat enum was rejected since its members (MBOX/EML_ZIP/JSON/JSONL) describe portable mailbox output, not rule rendering, and the two have no overlapping meaning. Drop AccountConfig.use_tls from the wire contract (reserving field 7) and from nuncio_core::AccountConfig: it duplicated imap_tls_mode/smtp_tls_mode and was never read for behavior anywhere in the daemon, CLI, or store -- only ever written and then ignored. The SQLite accounts.use_tls column is kept (with a default) for on-disk compatibility with existing rows; no code reads it anymore. Regenerate the nuncio.v1 descriptor golden. Refs #277 --- crates/nuncio-cli/src/runner.rs | 32 ++++++--- crates/nuncio-core/src/config.rs | 4 -- .../proto/nuncio/v1/descriptor.bin | Bin 15166 -> 15298 bytes .../nuncio-proto/proto/nuncio/v1/nuncio.proto | 19 ++++-- crates/nuncio-store/src/db.rs | 25 +++---- crates/nuncio-store/src/recovery.rs | 8 +-- crates/nunciod/src/grpc.rs | 63 +++++++++++------- crates/nunciod/src/orchestrator.rs | 1 - crates/nunciod/src/send.rs | 1 - crates/nunciod/src/sync.rs | 2 - .../tests/accounts_lifecycle_e2e_test.rs | 1 - .../tests/caldav_production_e2e_test.rs | 1 - .../nunciod/tests/outbox_executor_e2e_test.rs | 1 - crates/nunciod/tests/spine_e2e_test.rs | 1 - 14 files changed, 92 insertions(+), 67 deletions(-) diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index 10fd6a3..7afe09a 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -53,6 +53,20 @@ fn parse_account_protocol(protocol: &str) -> Result Result { + match format.to_ascii_lowercase().as_str() { + "sql" => Ok(nuncio_proto::v1::RuleExportFormat::Sql), + "json" => Ok(nuncio_proto::v1::RuleExportFormat::Json), + other => Err(format!( + "invalid export format '{other}' (expected sql or json)" + )), + } +} + /// Renders a wire-format `google.protobuf.Timestamp` as an RFC 3339 UTC /// string for human-readable and `--json` output. `nanos` outside /// `0..1_000_000_000` (never produced by the daemon's own mappers) and an @@ -1561,6 +1575,11 @@ impl HeadlessRunner { /// the daemon's real, persistent store -- not this runner's own /// ephemeral local `db`. async fn handle_filter_export(&self, format: &str, json_mode: bool) -> String { + let format = match parse_rule_export_format(format) { + Ok(f) => f, + Err(e) => return Self::render_error(&e, json_mode), + }; + let mut client = match self.connect_filters_client().await { Ok(client) => client, Err(e) => return Self::render_error(&e, json_mode), @@ -1568,7 +1587,7 @@ impl HeadlessRunner { match client .export_rules(nuncio_proto::v1::ExportRulesRequest { - format: format.to_string(), + format: format.into(), }) .await { @@ -1863,7 +1882,6 @@ impl HeadlessRunner { imap_host.to_string() }, server_port: if is_caldav { 0 } else { u32::from(imap_port) }, - use_tls: true, imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(), smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(), keyring_secret_key: keyring_key.clone(), @@ -1974,7 +1992,6 @@ impl HeadlessRunner { "server_port": a.server_port, "smtp_host": a.smtp_host, "smtp_port": a.smtp_port, - "use_tls": a.use_tls, "imap_tls_mode": a.imap_tls_mode().as_str_name(), "smtp_tls_mode": a.smtp_tls_mode().as_str_name(), "keyring_secret_key": a.keyring_secret_key, @@ -1997,15 +2014,16 @@ impl HeadlessRunner { )); } else { out.push_str(&format!( - "\n [{}] {} <{}> IMAP {}:{} SMTP {}:{} TLS={}", + "\n [{}] {} <{}> IMAP {}:{} ({}) SMTP {}:{} ({})", a.id, a.name, a.email_address, a.server_host, a.server_port, + a.imap_tls_mode().as_str_name(), a.smtp_host, a.smtp_port, - a.use_tls, + a.smtp_tls_mode().as_str_name(), )); } } @@ -2069,7 +2087,6 @@ impl HeadlessRunner { "server_port": account.server_port, "smtp_host": account.smtp_host, "smtp_port": account.smtp_port, - "use_tls": account.use_tls, "imap_tls_mode": account.imap_tls_mode().as_str_name(), "smtp_tls_mode": account.smtp_tls_mode().as_str_name(), "keyring_secret_key": account.keyring_secret_key, @@ -3103,7 +3120,6 @@ mod tests { protocol: AccountProtocolProto::ImapSmtp.into(), server_host: "imap.nuncio.mx".to_string(), server_port: 993, - use_tls: true, imap_tls_mode: TlsModeProto::ImplicitTls.into(), smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: "nuncio/acct-stub-1".to_string(), @@ -4320,7 +4336,7 @@ mod tests { request: tonic::Request, ) -> Result, tonic::Status> { let req = request.into_inner(); - let content = if req.format == "json" { + let content = if req.format() == nuncio_proto::v1::RuleExportFormat::Json { r#"[{"id":"rule-stub-1"}]"#.to_string() } else { "SELECT * FROM emails WHERE subject CONTAINS 'Urgent' ACTION MARK READ" diff --git a/crates/nuncio-core/src/config.rs b/crates/nuncio-core/src/config.rs index df39c9c..348bbec 100644 --- a/crates/nuncio-core/src/config.rs +++ b/crates/nuncio-core/src/config.rs @@ -95,8 +95,6 @@ pub struct AccountConfig { pub smtp_host: String, /// SMTP server port number, used for outbound mail. pub smtp_port: u16, - /// Whether TLS connection encryption is enabled. - pub use_tls: bool, /// IMAP connection security transport mode. pub imap_tls_mode: TlsMode, /// SMTP connection security transport mode. @@ -247,7 +245,6 @@ mod tests { server_port: 443, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: TlsMode::ImplicitTls, smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-123".to_string(), @@ -266,7 +263,6 @@ mod tests { server_port: 0, smtp_host: String::new(), smtp_port: 0, - use_tls: true, imap_tls_mode: TlsMode::ImplicitTls, smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-cal-1".to_string(), diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index b13188a703949fd7a632ec5f75449273968c39ef..21045b8042fb3ee6a230729a84bae43aa4d01b36 100644 GIT binary patch delta 252 zcmdm2cBp&gHCbM#0+5Cyqj*;Jsg@avyBZ$4UI5oZ`r+Bjg_i+Iy zPc9+Xih}&2lAzL@)Z(Dj!qU{@5+P$QwzT}B+{6+IMg>L<<6R>H{DVT`-TZ@m9Yf+n{elBroju(=U0nni ogrvB{u&4i7CbM#0kr3x%FD*`uFUcvEU{_$&;0R&^azk>8H{am2 zXJl7O=yL-A1^Xh` diff --git a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto index 742c34b..6275953 100644 --- a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto +++ b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto @@ -270,8 +270,8 @@ message AccountConfig { string server_host = 5; // Server port number (1..=65535) (IMAP/JMAP). uint32 server_port = 6; - // Whether TLS connection encryption is enabled. - bool use_tls = 7; + reserved 7; + reserved "use_tls"; // IMAP connection security transport mode. TlsMode imap_tls_mode = 8; // SMTP connection security transport mode. @@ -844,10 +844,21 @@ message UpdateRuleResponse { FilterRule rule = 1; } +// Rendering format for `ExportRules`. Kept distinct from `ExportFormat` +// (the mailbox `Export` service's format enum) because the two have no +// overlapping members and no shared meaning -- rule export renders lossless +// NSQL source or a JSON array of rule definitions, never MBOX/EML/JSONL. +enum RuleExportFormat { + RULE_EXPORT_FORMAT_UNSPECIFIED = 0; + // Lossless NSQL source text, one rule per line. + RULE_EXPORT_FORMAT_SQL = 1; + // JSON array of rule definitions. + RULE_EXPORT_FORMAT_JSON = 2; +} + // ExportRulesRequest names the rendering format for `ExportRules`. message ExportRulesRequest { - // "sql" or "json". - string format = 1; + RuleExportFormat format = 1; } // ExportRulesResponse carries every persisted rule pre-rendered as a single diff --git a/crates/nuncio-store/src/db.rs b/crates/nuncio-store/src/db.rs index c58912c..2ff1c62 100644 --- a/crates/nuncio-store/src/db.rs +++ b/crates/nuncio-store/src/db.rs @@ -499,7 +499,11 @@ impl DatabaseEngine { protocol TEXT NOT NULL, server_host TEXT NOT NULL, server_port INTEGER NOT NULL, - use_tls INTEGER NOT NULL, + -- Retained (with a default) for on-disk compatibility with + -- rows written before `imap_tls_mode`/`smtp_tls_mode` became + -- the authoritative transport-security setting; no code + -- reads or writes this column anymore. + use_tls INTEGER NOT NULL DEFAULT 1, keyring_secret_key TEXT NOT NULL, sync_interval_secs INTEGER NOT NULL, smtp_host TEXT, @@ -964,7 +968,13 @@ impl DatabaseEngine { .bind(protocol_str) .bind(&config.server_host) .bind(config.server_port as i64) - .bind(if config.use_tls { 1i64 } else { 0i64 }) + // `use_tls` is a retained-but-unread on-disk column (see the + // `CREATE TABLE`'s comment) -- written as a fixed placeholder since + // no `AccountConfig` field backs it anymore, satisfying its + // pre-existing `NOT NULL` constraint on every accounts table + // regardless of whether it was created before or after this column + // stopped being read. + .bind(1i64) .bind(&config.keyring_secret_key) .bind(config.sync_interval_secs as i64) .bind(&config.smtp_host) @@ -1010,7 +1020,6 @@ impl DatabaseEngine { String, String, i64, - i64, String, i64, Option, @@ -1020,7 +1029,7 @@ impl DatabaseEngine { Option, )> = sqlx::query_as( r#" - SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url + SELECT id, name, email_address, protocol, server_host, server_port, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url FROM accounts "#, ) @@ -1038,7 +1047,6 @@ impl DatabaseEngine { protocol_str, server_host, server_port, - use_tls, keyring_secret_key, sync_interval_secs, smtp_host, @@ -1069,7 +1077,6 @@ impl DatabaseEngine { server_port: server_port as u16, smtp_host: resolved_smtp_host, smtp_port: resolved_smtp_port, - use_tls: use_tls != 0, imap_tls_mode: tls_mode_from_db(&imap_tls_mode), smtp_tls_mode: tls_mode_from_db(&smtp_tls_mode), keyring_secret_key, @@ -2765,7 +2772,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-contended-1".to_string(), @@ -3345,7 +3351,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-test-1".to_string(), @@ -3389,7 +3394,6 @@ mod tests { server_port: 143, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 25, - use_tls: false, imap_tls_mode: nuncio_core::TlsMode::StartTls, smtp_tls_mode: nuncio_core::TlsMode::Plain, keyring_secret_key: "nuncio/acct-tls-1".to_string(), @@ -3430,7 +3434,6 @@ mod tests { server_port: 0, smtp_host: String::new(), smtp_port: 0, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-caldav-store-1".to_string(), @@ -3468,7 +3471,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-del-1".to_string(), @@ -3660,7 +3662,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 587, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::StartTls, keyring_secret_key: "nuncio/acct-post-migration".to_string(), diff --git a/crates/nuncio-store/src/recovery.rs b/crates/nuncio-store/src/recovery.rs index 6bc97ef..30a1fa0 100644 --- a/crates/nuncio-store/src/recovery.rs +++ b/crates/nuncio-store/src/recovery.rs @@ -18,7 +18,6 @@ type SalvagedAccountRow = ( String, String, i64, - i64, String, i64, Option, @@ -384,7 +383,7 @@ impl SqliteRecoveryEngine { async fn salvage_accounts(pool: &sqlx::SqlitePool) -> Vec { let rows: Result, _> = sqlx::query_as( - "SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url FROM accounts" + "SELECT id, name, email_address, protocol, server_host, server_port, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode, collection_url FROM accounts" ) .fetch_all(pool) .await; @@ -400,7 +399,6 @@ impl SqliteRecoveryEngine { protocol_str, server_host, server_port, - use_tls, keyring_secret_key, sync_interval_secs, smtp_host, @@ -433,7 +431,6 @@ impl SqliteRecoveryEngine { server_port: server_port as u16, smtp_host: resolved_smtp_host, smtp_port: resolved_smtp_port, - use_tls: use_tls != 0, imap_tls_mode, smtp_tls_mode, keyring_secret_key, @@ -606,7 +603,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-test-1".to_string(), @@ -668,7 +664,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-salvage-1".to_string(), @@ -806,7 +801,6 @@ mod tests { server_port: 143, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 587, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::StartTls, smtp_tls_mode: nuncio_core::TlsMode::Plain, keyring_secret_key: "nuncio/acct-starttls-1".to_string(), diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index 0b95c1c..d436516 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -41,13 +41,14 @@ use nuncio_proto::v1::{ ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRecordsRequest, ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto, MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest, - PreviewRuleResponse, RemoveAccountRequest, RemoveAccountResponse, SearchMessagesRequest, - SearchMessagesResponse, SendMessageRequest, SendMessageResponse, ShuttingDown, - SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted, - TestAccountConnectionRequest, TestAccountConnectionResponse, TlsMode as TlsModeProto, - TriageProgress, TriageRequest, UpdateAccountRequest, UpdateAccountResponse, UpdateAvailable, - UpdateRuleRequest, UpdateRuleResponse, ValidateRuleRequest, ValidateRuleResponse, - VerifyChainRequest, VerifyChainResponse, + PreviewRuleResponse, RemoveAccountRequest, RemoveAccountResponse, + RuleExportFormat as RuleExportFormatProto, SearchMessagesRequest, SearchMessagesResponse, + SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted, + SyncRequest, SyncResponse, SyncStarted, TestAccountConnectionRequest, + TestAccountConnectionResponse, TlsMode as TlsModeProto, TriageProgress, TriageRequest, + UpdateAccountRequest, UpdateAccountResponse, UpdateAvailable, UpdateRuleRequest, + UpdateRuleResponse, ValidateRuleRequest, ValidateRuleResponse, VerifyChainRequest, + VerifyChainResponse, }; use nuncio_store::db::DatabaseEngine; use nuncio_store::search::SearchEngine; @@ -282,7 +283,6 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon protocol: map_account_protocol_to_proto(config.protocol).into(), server_host: config.server_host, server_port: u32::from(config.server_port), - use_tls: config.use_tls, imap_tls_mode: map_tls_mode_to_proto(config.imap_tls_mode).into(), smtp_tls_mode: map_tls_mode_to_proto(config.smtp_tls_mode).into(), keyring_secret_key: config.keyring_secret_key, @@ -321,7 +321,6 @@ fn map_account_config_from_proto( server_port, smtp_host: config.smtp_host, smtp_port, - use_tls: config.use_tls, imap_tls_mode, smtp_tls_mode, keyring_secret_key: config.keyring_secret_key, @@ -2024,6 +2023,7 @@ impl Filters for FiltersGrpcService { request: Request, ) -> Result, Status> { let req = request.into_inner(); + let format = req.format(); let rules = self .db @@ -2031,15 +2031,17 @@ impl Filters for FiltersGrpcService { .await .map_err(|e| Status::internal(format!("failed to list filter rules: {e}")))?; - let content = if req.format == "json" { - serde_json::to_string_pretty(&rules) - .map_err(|e| Status::internal(format!("failed to render rules as JSON: {e}")))? - } else { - rules + let content = match format { + RuleExportFormatProto::Sql => rules .iter() .map(|r| r.to_nsql()) .collect::>() - .join("\n") + .join("\n"), + RuleExportFormatProto::Json => serde_json::to_string_pretty(&rules) + .map_err(|e| Status::internal(format!("failed to render rules as JSON: {e}")))?, + RuleExportFormatProto::Unspecified => { + return Err(Status::invalid_argument("rule export format is required")) + } }; Ok(Response::new(ExportRulesResponse { content })) @@ -3342,7 +3344,6 @@ mod tests { protocol: AccountProtocolProto::ImapSmtp.into(), server_host: "imap.nuncio.mx".to_string(), server_port: 993, - use_tls: true, imap_tls_mode: TlsModeProto::ImplicitTls.into(), smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: keyring_secret_key.to_string(), @@ -3737,7 +3738,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: key.to_string(), @@ -3781,7 +3781,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: key.to_string(), @@ -4349,7 +4348,6 @@ mod tests { server_port: 993, smtp_host: "127.0.0.1".to_string(), smtp_port: 1, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-send-1".to_string(), @@ -4518,7 +4516,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-injected-send-1".to_string(), @@ -4635,7 +4632,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-injected-send-fail-1".to_string(), @@ -4789,7 +4785,7 @@ mod tests { let err = client .export_rules(ExportRulesRequest { - format: "sql".to_string(), + format: RuleExportFormatProto::Sql.into(), }) .await .expect_err("missing bearer token must be rejected"); @@ -5366,7 +5362,7 @@ mod tests { let sql_export = client .export_rules(authed_bearer_request(ExportRulesRequest { - format: "sql".to_string(), + format: RuleExportFormatProto::Sql.into(), })) .await .expect("export_rules succeeds") @@ -5376,7 +5372,7 @@ mod tests { let json_export = client .export_rules(authed_bearer_request(ExportRulesRequest { - format: "json".to_string(), + format: RuleExportFormatProto::Json.into(), })) .await .expect("export_rules succeeds") @@ -5385,6 +5381,25 @@ mod tests { assert!(json_export.content.contains("Export Me")); } + /// `ExportRules` rejects an unspecified format rather than silently + /// defaulting to one rendering. + #[tokio::test] + async fn export_rules_rejects_unspecified_format() { + let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await; + + let mut client = FiltersClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + + let err = client + .export_rules(authed_bearer_request(ExportRulesRequest { + format: RuleExportFormatProto::Unspecified.into(), + })) + .await + .expect_err("unspecified format must be rejected"); + assert_eq!(err.code(), Code::InvalidArgument); + } + /// `ImportRules` persists every line that parses AND reports the parse /// error for every line that does not -- proving failures are surfaced /// rather than silently dropped. diff --git a/crates/nunciod/src/orchestrator.rs b/crates/nunciod/src/orchestrator.rs index 2fa48f3..5147754 100644 --- a/crates/nunciod/src/orchestrator.rs +++ b/crates/nunciod/src/orchestrator.rs @@ -157,7 +157,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: "nuncio/acct-orch-1".to_string(), diff --git a/crates/nunciod/src/send.rs b/crates/nunciod/src/send.rs index 3593ab5..ee800db 100644 --- a/crates/nunciod/src/send.rs +++ b/crates/nunciod/src/send.rs @@ -238,7 +238,6 @@ mod tests { server_port: 993, smtp_host: "127.0.0.1".to_string(), smtp_port: 1, - use_tls: true, imap_tls_mode: TlsMode::ImplicitTls, smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{id}"), diff --git a/crates/nunciod/src/sync.rs b/crates/nunciod/src/sync.rs index 98dc78a..e2983ba 100644 --- a/crates/nunciod/src/sync.rs +++ b/crates/nunciod/src/sync.rs @@ -520,7 +520,6 @@ mod tests { server_port: 443, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: TlsMode::ImplicitTls, smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{id}"), @@ -612,7 +611,6 @@ mod tests { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: TlsMode::ImplicitTls, smtp_tls_mode: TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{id}"), diff --git a/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs b/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs index 37a5b33..68c53d8 100644 --- a/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs +++ b/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs @@ -82,7 +82,6 @@ fn sample_account_config() -> AccountConfig { protocol: AccountProtocol::ImapSmtp.into(), server_host: "imap.nuncio.mx".to_string(), server_port: 143, - use_tls: false, imap_tls_mode: TlsMode::StartTls.into(), smtp_tls_mode: TlsMode::Plain.into(), keyring_secret_key: KEYRING_KEY.to_string(), diff --git a/crates/nunciod/tests/caldav_production_e2e_test.rs b/crates/nunciod/tests/caldav_production_e2e_test.rs index 6a210f3..566c911 100644 --- a/crates/nunciod/tests/caldav_production_e2e_test.rs +++ b/crates/nunciod/tests/caldav_production_e2e_test.rs @@ -161,7 +161,6 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() { protocol: AccountProtocolProto::Caldav.into(), server_host: String::new(), server_port: 0, - use_tls: true, imap_tls_mode: TlsModeProto::ImplicitTls.into(), smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: KEYRING_KEY.to_string(), diff --git a/crates/nunciod/tests/outbox_executor_e2e_test.rs b/crates/nunciod/tests/outbox_executor_e2e_test.rs index d6fd97f..d4a4e4d 100644 --- a/crates/nunciod/tests/outbox_executor_e2e_test.rs +++ b/crates/nunciod/tests/outbox_executor_e2e_test.rs @@ -104,7 +104,6 @@ fn sample_account() -> nuncio_core::AccountConfig { server_port: 993, smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, - use_tls: true, imap_tls_mode: nuncio_core::TlsMode::ImplicitTls, smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls, keyring_secret_key: format!("nuncio/{ACCOUNT_ID}"), diff --git a/crates/nunciod/tests/spine_e2e_test.rs b/crates/nunciod/tests/spine_e2e_test.rs index e99d5b3..be1b8d2 100644 --- a/crates/nunciod/tests/spine_e2e_test.rs +++ b/crates/nunciod/tests/spine_e2e_test.rs @@ -61,7 +61,6 @@ fn sample_account_config() -> AccountConfig { protocol: AccountProtocol::ImapSmtp.into(), server_host: "imap.nuncio.mx".to_string(), server_port: 993, - use_tls: true, imap_tls_mode: TlsMode::ImplicitTls.into(), smtp_tls_mode: TlsMode::ImplicitTls.into(), keyring_secret_key: KEYRING_KEY.to_string(), -- 2.54.0