From a107f034a81fd1a9db36a0c29a57c5804bf43a8b Mon Sep 17 00:00:00 2001 From: QingJ01 Date: Fri, 7 Aug 2026 16:20:09 +0800 Subject: [PATCH 01/16] feat(contacts): add contact schema and core types --- crates/pebble-core/src/types.rs | 61 ++++++++++++++++ crates/pebble-store/src/migrations.rs | 100 +++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/crates/pebble-core/src/types.rs b/crates/pebble-core/src/types.rs index 7a00949..1edcb45 100644 --- a/crates/pebble-core/src/types.rs +++ b/crates/pebble-core/src/types.rs @@ -274,6 +274,67 @@ pub struct KnownContact { pub address: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ContactEmailLabel { + Work, + Personal, + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContactEmail { + pub id: String, + pub address: String, + pub label: ContactEmailLabel, + pub is_primary: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Contact { + pub id: String, + pub display_name: String, + pub notes: String, + pub is_favorite: bool, + pub emails: Vec, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContactEmailInput { + pub id: Option, + pub address: String, + pub label: ContactEmailLabel, + pub is_primary: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContactInput { + pub id: Option, + pub display_name: String, + pub notes: String, + pub is_favorite: bool, + pub emails: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ContactSuggestionSource { + Saved, + Recent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContactSuggestion { + pub contact_id: Option, + pub name: Option, + pub address: String, + pub source: ContactSuggestionSource, + pub is_favorite: bool, + pub last_interaction_at: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Category { pub id: String, diff --git a/crates/pebble-store/src/migrations.rs b/crates/pebble-store/src/migrations.rs index ed08363..6de9982 100644 --- a/crates/pebble-store/src/migrations.rs +++ b/crates/pebble-store/src/migrations.rs @@ -2,7 +2,7 @@ use pebble_core::{build_snippet, PebbleError, Result}; use rusqlite::{Connection, OptionalExtension}; use std::collections::HashSet; -const CURRENT_VERSION: u32 = 14; +const CURRENT_VERSION: u32 = 15; const ACCOUNT_COLOR_PRESETS: [&str; 12] = [ "#0ea5e9", "#22c55e", "#f59e0b", "#8b5cf6", "#f43f5e", "#14b8a6", "#6366f1", "#f97316", "#06b6d4", "#ec4899", "#84cc16", "#3b82f6", @@ -435,6 +435,46 @@ pub fn run_migrations(conn: &Connection) -> Result<()> { .map_err(|e| PebbleError::Storage(format!("Migration V14 commit failed: {e}")))?; } + // V15: profile-level address book and hidden recent-contact suggestions. + if version < 15 { + let tx = conn + .unchecked_transaction() + .map_err(|e| PebbleError::Storage(format!("Migration V15 begin failed: {e}")))?; + tx.execute_batch( + "CREATE TABLE contacts ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + is_favorite INTEGER NOT NULL DEFAULT 0 CHECK(is_favorite IN (0, 1)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE contact_emails ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + address TEXT NOT NULL, + normalized_address TEXT NOT NULL COLLATE NOCASE, + label TEXT NOT NULL DEFAULT 'other' + CHECK(label IN ('work', 'personal', 'other')), + is_primary INTEGER NOT NULL DEFAULT 0 CHECK(is_primary IN (0, 1)), + created_at INTEGER NOT NULL, + UNIQUE(normalized_address) + ); + CREATE INDEX idx_contact_emails_contact + ON contact_emails(contact_id); + CREATE UNIQUE INDEX idx_contact_emails_one_primary + ON contact_emails(contact_id) WHERE is_primary = 1; + CREATE TABLE contact_suggestion_suppressions ( + normalized_address TEXT PRIMARY KEY COLLATE NOCASE, + created_at INTEGER NOT NULL + );", + ) + .map_err(|e| PebbleError::Storage(format!("Migration V15 failed: {e}")))?; + set_schema_version(&tx, 15)?; + tx.commit() + .map_err(|e| PebbleError::Storage(format!("Migration V15 commit failed: {e}")))?; + } + Ok(()) } @@ -582,6 +622,64 @@ CREATE TABLE IF NOT EXISTS translate_config ( mod tests { use super::*; + #[test] + fn migration_v15_creates_contact_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA user_version=14;") + .unwrap(); + + run_migrations(&conn).unwrap(); + + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 15); + + conn.execute_batch( + "INSERT INTO contacts + (id, display_name, notes, is_favorite, created_at, updated_at) + VALUES ('contact-1', 'Alice', '', 0, 1, 1); + INSERT INTO contact_emails + (id, contact_id, address, normalized_address, label, is_primary, created_at) + VALUES ('email-1', 'contact-1', 'Alice@Example.com', 'alice@example.com', 'work', 1, 1); + INSERT INTO contact_suggestion_suppressions (normalized_address, created_at) + VALUES ('hidden@example.com', 1);", + ) + .expect("V15 contact tables should accept valid rows"); + + let duplicate_address = conn.execute( + "INSERT INTO contact_emails + (id, contact_id, address, normalized_address, label, is_primary, created_at) + VALUES ('email-2', 'contact-1', 'ALICE@example.com', 'ALICE@EXAMPLE.COM', 'other', 0, 1)", + [], + ); + assert!( + duplicate_address.is_err(), + "normalized email addresses must be unique case-insensitively" + ); + + let second_primary = conn.execute( + "INSERT INTO contact_emails + (id, contact_id, address, normalized_address, label, is_primary, created_at) + VALUES ('email-3', 'contact-1', 'other@example.com', 'other@example.com', 'personal', 1, 1)", + [], + ); + assert!( + second_primary.is_err(), + "a contact must not have more than one primary email" + ); + + conn.execute("DELETE FROM contacts WHERE id = 'contact-1'", []) + .unwrap(); + let remaining_emails: i64 = conn + .query_row("SELECT COUNT(*) FROM contact_emails", [], |row| row.get(0)) + .unwrap(); + assert_eq!( + remaining_emails, 0, + "contact emails should cascade on delete" + ); + } + #[test] fn migration_v11_adds_account_color_and_sets_schema_version() { let conn = Connection::open_in_memory().unwrap(); From e9ee08441adf2007089410c7500682933117d696 Mon Sep 17 00:00:00 2001 From: QingJ01 Date: Fri, 7 Aug 2026 16:24:13 +0800 Subject: [PATCH 02/16] feat(contacts): implement transactional contact CRUD --- crates/pebble-store/src/contacts.rs | 548 +++++++++++++++++++++++++++- 1 file changed, 546 insertions(+), 2 deletions(-) diff --git a/crates/pebble-store/src/contacts.rs b/crates/pebble-store/src/contacts.rs index d80659e..1508e3a 100644 --- a/crates/pebble-store/src/contacts.rs +++ b/crates/pebble-store/src/contacts.rs @@ -1,9 +1,345 @@ -use pebble_core::{KnownContact, Result}; -use rusqlite::params; +use std::collections::HashSet; + +use pebble_core::{ + Contact, ContactEmail, ContactEmailInput, ContactEmailLabel, ContactInput, KnownContact, + PebbleError, Result, +}; +use rusqlite::{params, Connection, OptionalExtension}; use crate::Store; +fn contact_label_to_str(label: &ContactEmailLabel) -> &'static str { + match label { + ContactEmailLabel::Work => "work", + ContactEmailLabel::Personal => "personal", + ContactEmailLabel::Other => "other", + } +} + +fn str_to_contact_label(label: &str) -> ContactEmailLabel { + match label { + "work" => ContactEmailLabel::Work, + "personal" => ContactEmailLabel::Personal, + _ => ContactEmailLabel::Other, + } +} + +fn prepare_email(input: &ContactEmailInput) -> Result<(String, String)> { + let address = input.address.trim().to_string(); + if address.is_empty() || address.len() > 320 { + return Err(PebbleError::Validation( + "Email address is required and must not exceed 320 characters".to_string(), + )); + } + if address.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(PebbleError::Validation(format!( + "Invalid email address: {}", + input.address + ))); + } + let Some((local, domain)) = address.split_once('@') else { + return Err(PebbleError::Validation(format!( + "Invalid email address: {}", + input.address + ))); + }; + if local.is_empty() + || local.len() > 64 + || domain.is_empty() + || domain.contains('@') + || domain.starts_with('.') + || domain.ends_with('.') + || !domain.contains('.') + { + return Err(PebbleError::Validation(format!( + "Invalid email address: {}", + input.address + ))); + } + Ok((address.clone(), address.to_lowercase())) +} + +fn validate_contact_input(input: &ContactInput) -> Result> { + if input.emails.is_empty() { + return Err(PebbleError::Validation( + "A contact must have at least one email address".to_string(), + )); + } + if input.notes.chars().count() > 2000 { + return Err(PebbleError::Validation( + "Contact notes must not exceed 2000 characters".to_string(), + )); + } + let primary_count = input.emails.iter().filter(|email| email.is_primary).count(); + if primary_count != 1 { + return Err(PebbleError::Validation( + "A contact must have exactly one primary email address".to_string(), + )); + } + + let mut seen = HashSet::new(); + let mut prepared = Vec::with_capacity(input.emails.len()); + for email in &input.emails { + let values = prepare_email(email)?; + if !seen.insert(values.1.clone()) { + return Err(PebbleError::Validation(format!( + "Duplicate email address: {}", + email.address.trim() + ))); + } + prepared.push(values); + } + Ok(prepared) +} + +fn load_contact_with_conn(conn: &Connection, contact_id: &str) -> Result> { + let row = conn + .query_row( + "SELECT id, display_name, notes, is_favorite, created_at, updated_at + FROM contacts WHERE id = ?1", + params![contact_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, bool>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + }, + ) + .optional()?; + let Some((id, display_name, notes, is_favorite, created_at, updated_at)) = row else { + return Ok(None); + }; + + let mut stmt = conn.prepare( + "SELECT id, address, label, is_primary + FROM contact_emails + WHERE contact_id = ?1 + ORDER BY is_primary DESC, created_at ASC, id ASC", + )?; + let emails = stmt + .query_map(params![id], |row| { + Ok(ContactEmail { + id: row.get(0)?, + address: row.get(1)?, + label: str_to_contact_label(&row.get::<_, String>(2)?), + is_primary: row.get(3)?, + }) + })? + .collect::, _>>()?; + + Ok(Some(Contact { + id, + display_name, + notes, + is_favorite, + emails, + created_at, + updated_at, + })) +} + impl Store { + pub fn save_contact(&self, input: &ContactInput) -> Result { + let prepared_emails = validate_contact_input(input)?; + let display_name = input.display_name.trim().to_string(); + let notes = input.notes.trim().to_string(); + let now = pebble_core::now_timestamp(); + + self.with_write(|conn| { + let tx = conn.unchecked_transaction()?; + let (contact_id, created_at, existing_email_ids) = if let Some(id) = &input.id { + if id.trim().is_empty() { + return Err(PebbleError::Validation( + "Contact id must not be empty".to_string(), + )); + } + let created_at = tx + .query_row( + "SELECT created_at FROM contacts WHERE id = ?1", + params![id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| PebbleError::Validation(format!("Contact not found: {id}")))?; + let mut stmt = tx.prepare("SELECT id FROM contact_emails WHERE contact_id = ?1")?; + let existing_ids = stmt + .query_map(params![id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + (id.clone(), created_at, existing_ids) + } else { + (pebble_core::new_id(), now, HashSet::new()) + }; + + for (_, normalized) in &prepared_emails { + let owner: Option = tx + .query_row( + "SELECT contact_id FROM contact_emails + WHERE normalized_address = ?1 COLLATE NOCASE AND contact_id != ?2", + params![normalized, contact_id], + |row| row.get(0), + ) + .optional()?; + if owner.is_some() { + return Err(PebbleError::Validation(format!( + "Email address already belongs to another contact: {normalized}" + ))); + } + } + + if input.id.is_some() { + tx.execute( + "UPDATE contacts + SET display_name = ?1, notes = ?2, is_favorite = ?3, updated_at = ?4 + WHERE id = ?5", + params![display_name, notes, input.is_favorite, now, contact_id], + )?; + tx.execute( + "DELETE FROM contact_emails WHERE contact_id = ?1", + params![contact_id], + )?; + } else { + tx.execute( + "INSERT INTO contacts + (id, display_name, notes, is_favorite, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + contact_id, + display_name, + notes, + input.is_favorite, + created_at, + now + ], + )?; + } + + for (index, email) in input.emails.iter().enumerate() { + let (address, normalized) = &prepared_emails[index]; + let email_id = email + .id + .as_ref() + .filter(|id| existing_email_ids.contains(*id)) + .cloned() + .unwrap_or_else(pebble_core::new_id); + tx.execute( + "INSERT INTO contact_emails + (id, contact_id, address, normalized_address, label, is_primary, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + email_id, + contact_id, + address, + normalized, + contact_label_to_str(&email.label), + email.is_primary, + now + ], + ) + .map_err(|error| { + PebbleError::Validation(format!( + "Unable to save contact email {address}: {error}" + )) + })?; + } + + let contact = load_contact_with_conn(&tx, &contact_id)?.ok_or_else(|| { + PebbleError::Internal("Saved contact could not be loaded".to_string()) + })?; + tx.commit()?; + Ok(contact) + }) + } + + pub fn get_contact(&self, contact_id: &str) -> Result> { + self.with_read(|conn| load_contact_with_conn(conn, contact_id)) + } + + pub fn list_contacts( + &self, + query: Option<&str>, + favorite_only: bool, + limit: i64, + offset: i64, + ) -> Result> { + let query = query.unwrap_or_default().trim(); + let escaped = query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + let pattern = format!("%{escaped}%"); + let limit = limit.clamp(1, 200); + let offset = offset.max(0); + + self.with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT c.id + FROM contacts c + WHERE (?1 = '' OR c.display_name LIKE ?2 ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM contact_emails ce + WHERE ce.contact_id = c.id + AND ce.address LIKE ?2 ESCAPE '\\' COLLATE NOCASE + )) + AND (?3 = 0 OR c.is_favorite = 1) + ORDER BY LOWER(CASE + WHEN c.display_name = '' THEN COALESCE(( + SELECT ce.address FROM contact_emails ce + WHERE ce.contact_id = c.id + ORDER BY ce.is_primary DESC, ce.created_at ASC LIMIT 1 + ), '') + ELSE c.display_name + END) ASC, c.id ASC + LIMIT ?4 OFFSET ?5", + )?; + let ids = stmt + .query_map( + params![query, pattern, favorite_only, limit, offset], + |row| row.get::<_, String>(0), + )? + .collect::, _>>()?; + + ids.into_iter() + .map(|id| { + load_contact_with_conn(conn, &id)?.ok_or_else(|| { + PebbleError::Internal(format!("Contact disappeared while listing: {id}")) + }) + }) + .collect() + }) + } + + pub fn delete_contact(&self, contact_id: &str, _suppress_addresses: bool) -> Result<()> { + self.with_write(|conn| { + let deleted = + conn.execute("DELETE FROM contacts WHERE id = ?1", params![contact_id])?; + if deleted == 0 { + return Err(PebbleError::Validation(format!( + "Contact not found: {contact_id}" + ))); + } + Ok(()) + }) + } + + pub fn set_contact_favorite(&self, contact_id: &str, is_favorite: bool) -> Result<()> { + self.with_write(|conn| { + let updated = conn.execute( + "UPDATE contacts SET is_favorite = ?1, updated_at = ?2 WHERE id = ?3", + params![is_favorite, pebble_core::now_timestamp(), contact_id], + )?; + if updated == 0 { + return Err(PebbleError::Validation(format!( + "Contact not found: {contact_id}" + ))); + } + Ok(()) + }) + } + /// Query distinct contacts from the messages table matching a prefix. /// /// Searches `from_address`/`from_name` columns and also parses `to_list` @@ -112,6 +448,21 @@ mod tests { use crate::Store; use pebble_core::*; + fn contact_input(name: &str, address: &str) -> ContactInput { + ContactInput { + id: None, + display_name: name.to_string(), + notes: String::new(), + is_favorite: false, + emails: vec![ContactEmailInput { + id: None, + address: address.to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + }], + } + } + fn setup_store_with_contacts() -> (Store, String) { let store = Store::open_in_memory().unwrap(); let now = now_timestamp(); @@ -266,4 +617,197 @@ mod tests { .unwrap(); assert!(results.is_empty()); } + + #[test] + fn contact_crud_round_trips_multiple_emails() { + let store = Store::open_in_memory().unwrap(); + let input = ContactInput { + id: None, + display_name: " Alice Smith ".to_string(), + notes: "Met at RustConf".to_string(), + is_favorite: true, + emails: vec![ + ContactEmailInput { + id: None, + address: " Alice@Example.com ".to_string(), + label: ContactEmailLabel::Work, + is_primary: true, + }, + ContactEmailInput { + id: None, + address: "alice@home.example".to_string(), + label: ContactEmailLabel::Personal, + is_primary: false, + }, + ], + }; + + let saved = store.save_contact(&input).unwrap(); + assert_eq!(saved.display_name, "Alice Smith"); + assert_eq!(saved.emails.len(), 2); + assert_eq!(saved.emails[0].address, "Alice@Example.com"); + assert!(saved.emails[0].is_primary); + assert!(saved.is_favorite); + + let loaded = store.get_contact(&saved.id).unwrap().unwrap(); + assert_eq!(loaded, saved); + } + + #[test] + fn contact_save_requires_email() { + let store = Store::open_in_memory().unwrap(); + let no_email = ContactInput { + emails: vec![], + ..contact_input("Nobody", "unused@example.com") + }; + assert!(matches!( + store.save_contact(&no_email), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn contact_save_rejects_invalid_email() { + let store = Store::open_in_memory().unwrap(); + let invalid_email = contact_input("Invalid", "not-an-address"); + assert!(matches!( + store.save_contact(&invalid_email), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn contact_save_requires_exactly_one_primary_email() { + let store = Store::open_in_memory().unwrap(); + let multiple_primary = ContactInput { + emails: vec![ + ContactEmailInput { + id: None, + address: "one@example.com".to_string(), + label: ContactEmailLabel::Work, + is_primary: true, + }, + ContactEmailInput { + id: None, + address: "two@example.com".to_string(), + label: ContactEmailLabel::Personal, + is_primary: true, + }, + ], + ..contact_input("Two Primaries", "unused@example.com") + }; + assert!(matches!( + store.save_contact(&multiple_primary), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn contact_save_rejects_notes_over_limit() { + let store = Store::open_in_memory().unwrap(); + let input = ContactInput { + notes: "a".repeat(2001), + ..contact_input("Verbose", "verbose@example.com") + }; + + assert!(matches!( + store.save_contact(&input), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn contact_duplicate_email_is_case_insensitive_and_atomic() { + let store = Store::open_in_memory().unwrap(); + let first = store + .save_contact(&contact_input("Alice", "Alice@Example.com")) + .unwrap(); + + let duplicate = store.save_contact(&contact_input("Other Alice", "alice@example.COM")); + assert!(matches!(duplicate, Err(PebbleError::Validation(_)))); + + let contacts = store.list_contacts(None, false, 20, 0).unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].id, first.id); + } + + #[test] + fn contact_edit_replaces_emails_and_preserves_created_at() { + let store = Store::open_in_memory().unwrap(); + let created = store + .save_contact(&contact_input("Alice", "old@example.com")) + .unwrap(); + let updated = store + .save_contact(&ContactInput { + id: Some(created.id.clone()), + display_name: "Alice Updated".to_string(), + notes: "New note".to_string(), + is_favorite: true, + emails: vec![ContactEmailInput { + id: None, + address: "new@example.com".to_string(), + label: ContactEmailLabel::Work, + is_primary: true, + }], + }) + .unwrap(); + + assert_eq!(updated.id, created.id); + assert_eq!(updated.created_at, created.created_at); + assert!(updated.updated_at >= created.updated_at); + assert_eq!(updated.emails.len(), 1); + assert_eq!(updated.emails[0].address, "new@example.com"); + } + + #[test] + fn contact_list_searches_filters_favorites_and_paginates() { + let store = Store::open_in_memory().unwrap(); + let alice = store + .save_contact(&contact_input("Alice", "alice@example.com")) + .unwrap(); + let mut bob_input = contact_input("Bob", "bob@work.test"); + bob_input.is_favorite = true; + let bob = store.save_contact(&bob_input).unwrap(); + store + .save_contact(&contact_input("Charlie", "charlie@example.net")) + .unwrap(); + + let by_name = store.list_contacts(Some("ali"), false, 20, 0).unwrap(); + assert_eq!( + by_name.iter().map(|c| &c.id).collect::>(), + vec![&alice.id] + ); + + let by_email = store + .list_contacts(Some("work.test"), false, 20, 0) + .unwrap(); + assert_eq!( + by_email.iter().map(|c| &c.id).collect::>(), + vec![&bob.id] + ); + + let favorites = store.list_contacts(None, true, 20, 0).unwrap(); + assert_eq!( + favorites.iter().map(|c| &c.id).collect::>(), + vec![&bob.id] + ); + + let page = store.list_contacts(None, false, 1, 1).unwrap(); + assert_eq!(page.len(), 1); + assert_eq!(page[0].display_name, "Bob"); + } + + #[test] + fn contact_favorite_and_delete_update_persisted_contact() { + let store = Store::open_in_memory().unwrap(); + let saved = store + .save_contact(&contact_input("Alice", "alice@example.com")) + .unwrap(); + + store.set_contact_favorite(&saved.id, true).unwrap(); + assert!(store.get_contact(&saved.id).unwrap().unwrap().is_favorite); + + store.delete_contact(&saved.id, false).unwrap(); + assert!(store.get_contact(&saved.id).unwrap().is_none()); + } } From 95a76f431c1a8ca3b5b3491c262d62bbfa523817 Mon Sep 17 00:00:00 2001 From: QingJ01 Date: Fri, 7 Aug 2026 16:29:22 +0800 Subject: [PATCH 03/16] feat(contacts): merge saved and recent contact suggestions --- crates/pebble-store/src/contacts.rs | 565 +++++++++++++++++++++++++++- 1 file changed, 559 insertions(+), 6 deletions(-) diff --git a/crates/pebble-store/src/contacts.rs b/crates/pebble-store/src/contacts.rs index 1508e3a..29cf935 100644 --- a/crates/pebble-store/src/contacts.rs +++ b/crates/pebble-store/src/contacts.rs @@ -1,8 +1,8 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use pebble_core::{ - Contact, ContactEmail, ContactEmailInput, ContactEmailLabel, ContactInput, KnownContact, - PebbleError, Result, + Contact, ContactEmail, ContactEmailInput, ContactEmailLabel, ContactInput, ContactSuggestion, + ContactSuggestionSource, EmailAddress, KnownContact, PebbleError, Result, }; use rusqlite::{params, Connection, OptionalExtension}; @@ -142,6 +142,64 @@ fn load_contact_with_conn(conn: &Connection, contact_id: &str) -> Result, + address: String, + last_interaction_at: i64, +} + +fn add_recent_candidate( + candidates: &mut HashMap, + self_address: &str, + name: Option, + address: String, + date: i64, +) { + let trimmed = address.trim(); + let normalized = trimmed.to_lowercase(); + if normalized.is_empty() || normalized == self_address { + return; + } + let email = ContactEmailInput { + id: None, + address: trimmed.to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + }; + if prepare_email(&email).is_err() { + return; + } + let name = name.and_then(|value| { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) + }); + + match candidates.get_mut(&normalized) { + Some(existing) if date > existing.last_interaction_at => { + *existing = RecentContactCandidate { + name, + address: trimmed.to_string(), + last_interaction_at: date, + }; + } + Some(existing) if existing.name.is_none() && name.is_some() => { + existing.name = name; + } + Some(_) => {} + None => { + candidates.insert( + normalized, + RecentContactCandidate { + name, + address: trimmed.to_string(), + last_interaction_at: date, + }, + ); + } + } +} + impl Store { pub fn save_contact(&self, input: &ContactInput) -> Result { let prepared_emails = validate_contact_input(input)?; @@ -312,15 +370,35 @@ impl Store { }) } - pub fn delete_contact(&self, contact_id: &str, _suppress_addresses: bool) -> Result<()> { + pub fn delete_contact(&self, contact_id: &str, suppress_addresses: bool) -> Result<()> { self.with_write(|conn| { - let deleted = - conn.execute("DELETE FROM contacts WHERE id = ?1", params![contact_id])?; + let tx = conn.unchecked_transaction()?; + if suppress_addresses { + let addresses = { + let mut stmt = tx.prepare( + "SELECT normalized_address FROM contact_emails WHERE contact_id = ?1", + )?; + let values = stmt + .query_map(params![contact_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + values + }; + let now = pebble_core::now_timestamp(); + for address in addresses { + tx.execute( + "INSERT OR IGNORE INTO contact_suggestion_suppressions + (normalized_address, created_at) VALUES (?1, ?2)", + params![address, now], + )?; + } + } + let deleted = tx.execute("DELETE FROM contacts WHERE id = ?1", params![contact_id])?; if deleted == 0 { return Err(PebbleError::Validation(format!( "Contact not found: {contact_id}" ))); } + tx.commit()?; Ok(()) }) } @@ -340,6 +418,191 @@ impl Store { }) } + pub fn suppress_contact_suggestion(&self, address: &str) -> Result<()> { + let (_, normalized) = prepare_email(&ContactEmailInput { + id: None, + address: address.to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + })?; + self.with_write(|conn| { + conn.execute( + "INSERT OR IGNORE INTO contact_suggestion_suppressions + (normalized_address, created_at) VALUES (?1, ?2)", + params![normalized, pebble_core::now_timestamp()], + )?; + Ok(()) + }) + } + + pub fn search_contact_suggestions( + &self, + account_id: &str, + query: &str, + limit: i64, + ) -> Result> { + let limit = limit.clamp(1, 100) as usize; + let candidate_limit = (limit.saturating_mul(5)).max(100) as i64; + let query = query.trim(); + let lower_query = query.to_lowercase(); + let escaped = query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + let pattern = format!("%{escaped}%"); + + self.with_read(|conn| { + let self_address = conn + .query_row( + "SELECT email FROM accounts WHERE id = ?1", + params![account_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| { + PebbleError::Validation(format!("Account not found: {account_id}")) + })? + .trim() + .to_lowercase(); + + let suppressed = { + let mut stmt = conn.prepare( + "SELECT normalized_address FROM contact_suggestion_suppressions", + )?; + let values = stmt + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + values + }; + + let mut recent = HashMap::new(); + let mut history_stmt = conn.prepare( + "SELECT from_name, from_address, to_list, cc_list, bcc_list, date + FROM messages + WHERE account_id = ?1 AND is_deleted = 0 + ORDER BY date DESC + LIMIT 1000", + )?; + let history_rows = history_stmt.query_map(params![account_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, i64>(5)?, + )) + })?; + for row in history_rows { + let (from_name, from_address, to_json, cc_json, bcc_json, date) = row?; + add_recent_candidate( + &mut recent, + &self_address, + (!from_name.trim().is_empty()).then_some(from_name), + from_address, + date, + ); + for json in [&to_json, &cc_json, &bcc_json] { + if let Ok(addresses) = serde_json::from_str::>(json) { + for address in addresses { + add_recent_candidate( + &mut recent, + &self_address, + address.name, + address.address, + date, + ); + } + } + } + } + drop(history_stmt); + + let mut suggestions = Vec::new(); + let mut seen = HashSet::new(); + let mut saved_stmt = conn.prepare( + "SELECT c.id, c.display_name, c.is_favorite, + ce.address, ce.normalized_address + FROM contacts c + JOIN contact_emails ce ON ce.contact_id = c.id + WHERE (?1 = '' OR c.display_name LIKE ?2 ESCAPE '\\' COLLATE NOCASE + OR ce.address LIKE ?2 ESCAPE '\\' COLLATE NOCASE) + ORDER BY c.is_favorite DESC, + LOWER(CASE WHEN c.display_name = '' THEN ce.address ELSE c.display_name END), + ce.is_primary DESC, + LOWER(ce.address), ce.id + LIMIT ?3", + )?; + let saved_rows = saved_stmt.query_map( + params![query, pattern, candidate_limit], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, bool>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, + )?; + for row in saved_rows { + let (contact_id, display_name, is_favorite, address, normalized) = row?; + let normalized = normalized.to_lowercase(); + if normalized == self_address || !seen.insert(normalized.clone()) { + continue; + } + suggestions.push(ContactSuggestion { + contact_id: Some(contact_id), + name: (!display_name.trim().is_empty()).then_some(display_name), + address, + source: ContactSuggestionSource::Saved, + is_favorite, + last_interaction_at: recent + .get(&normalized) + .map(|item| item.last_interaction_at), + }); + } + drop(saved_stmt); + + let mut recent_entries = recent.into_iter().collect::>(); + recent_entries.sort_by(|(left_address, left), (right_address, right)| { + right + .last_interaction_at + .cmp(&left.last_interaction_at) + .then_with(|| left_address.cmp(right_address)) + }); + for (normalized, candidate) in recent_entries { + if suggestions.len() >= limit { + break; + } + let name_matches = candidate + .name + .as_ref() + .map(|name| name.to_lowercase().contains(&lower_query)) + .unwrap_or(false); + if (!lower_query.is_empty() + && !normalized.contains(&lower_query) + && !name_matches) + || suppressed.contains(&normalized) + || !seen.insert(normalized) + { + continue; + } + suggestions.push(ContactSuggestion { + contact_id: None, + name: candidate.name, + address: candidate.address, + source: ContactSuggestionSource::Recent, + is_favorite: false, + last_interaction_at: Some(candidate.last_interaction_at), + }); + } + + suggestions.truncate(limit); + Ok(suggestions) + }) + } + /// Query distinct contacts from the messages table matching a prefix. /// /// Searches `from_address`/`from_name` columns and also parses `to_list` @@ -566,6 +829,84 @@ mod tests { (store, account.id) } + fn setup_suggestion_store() -> (Store, String, String) { + let store = Store::open_in_memory().unwrap(); + let now = now_timestamp(); + let account = Account { + id: new_id(), + email: "me@example.com".to_string(), + display_name: "Me".to_string(), + color: None, + provider: ProviderType::Imap, + created_at: now, + updated_at: now, + }; + store.insert_account(&account).unwrap(); + let folder = Folder { + id: new_id(), + account_id: account.id.clone(), + remote_id: "INBOX".to_string(), + name: "Inbox".to_string(), + folder_type: FolderType::Folder, + role: Some(FolderRole::Inbox), + parent_id: None, + color: None, + is_system: true, + sort_order: 0, + }; + store.insert_folder(&folder).unwrap(); + (store, account.id, folder.id) + } + + struct SuggestionMessage<'a> { + remote_id: &'a str, + from_name: &'a str, + from_address: &'a str, + to: Vec, + cc: Vec, + bcc: Vec, + date: i64, + } + + fn insert_suggestion_message( + store: &Store, + account_id: &str, + folder_id: &str, + message: SuggestionMessage<'_>, + ) { + let saved = Message { + id: new_id(), + account_id: account_id.to_string(), + remote_id: message.remote_id.to_string(), + message_id_header: None, + in_reply_to: None, + references_header: None, + thread_id: None, + subject: "Contact history".to_string(), + snippet: String::new(), + from_address: message.from_address.to_string(), + from_name: message.from_name.to_string(), + to_list: message.to, + cc_list: message.cc, + bcc_list: message.bcc, + body_text: String::new(), + body_html_raw: String::new(), + has_attachments: false, + is_read: true, + is_starred: false, + is_draft: false, + date: message.date, + remote_version: None, + is_deleted: false, + deleted_at: None, + created_at: message.date, + updated_at: message.date, + }; + store + .insert_message(&saved, &[folder_id.to_string()]) + .unwrap(); + } + #[test] fn test_list_known_contacts_by_from_address() { let (store, account_id) = setup_store_with_contacts(); @@ -810,4 +1151,216 @@ mod tests { store.delete_contact(&saved.id, false).unwrap(); assert!(store.get_contact(&saved.id).unwrap().is_none()); } + + #[test] + fn contact_suggestions_rank_favorite_saved_then_saved_then_recent() { + let (store, account_id, folder_id) = setup_suggestion_store(); + let mut favorite = contact_input("Zoe Favorite", "zoe@example.com"); + favorite.is_favorite = true; + store.save_contact(&favorite).unwrap(); + store + .save_contact(&contact_input("Alice Saved", "alice@example.com")) + .unwrap(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "recent", + from_name: "Recent Person", + from_address: "recent@example.com", + to: vec![], + cc: vec![], + bcc: vec![], + date: 300, + }, + ); + + let results = store + .search_contact_suggestions(&account_id, "", 20) + .unwrap(); + assert_eq!( + results + .iter() + .map(|item| item.address.as_str()) + .collect::>(), + vec!["zoe@example.com", "alice@example.com", "recent@example.com"] + ); + assert_eq!(results[0].source, ContactSuggestionSource::Saved); + assert_eq!(results[2].source, ContactSuggestionSource::Recent); + } + + #[test] + fn contact_suggestions_deduplicate_saved_and_recent_addresses() { + let (store, account_id, folder_id) = setup_suggestion_store(); + let saved = store + .save_contact(&contact_input("Saved Alice", "Alice@Example.com")) + .unwrap(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "alice-history", + from_name: "Historical Alice", + from_address: "alice@example.COM", + to: vec![], + cc: vec![], + bcc: vec![], + date: 500, + }, + ); + + let results = store + .search_contact_suggestions(&account_id, "alice", 20) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].contact_id.as_deref(), Some(saved.id.as_str())); + assert_eq!(results[0].source, ContactSuggestionSource::Saved); + assert_eq!(results[0].last_interaction_at, Some(500)); + } + + #[test] + fn contact_suggestions_include_cc_and_bcc_but_filter_current_account() { + let (store, account_id, folder_id) = setup_suggestion_store(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "all-recipients", + from_name: "Sender", + from_address: "sender@example.com", + to: vec![EmailAddress { + name: Some("Me".to_string()), + address: "ME@example.com".to_string(), + }], + cc: vec![EmailAddress { + name: Some("Copy".to_string()), + address: "copy@example.com".to_string(), + }], + bcc: vec![EmailAddress { + name: Some("Blind".to_string()), + address: "blind@example.com".to_string(), + }], + date: 100, + }, + ); + + let results = store + .search_contact_suggestions(&account_id, "", 20) + .unwrap(); + let addresses = results + .iter() + .map(|item| item.address.to_lowercase()) + .collect::>(); + assert!(addresses.contains(&"sender@example.com".to_string())); + assert!(addresses.contains(&"copy@example.com".to_string())); + assert!(addresses.contains(&"blind@example.com".to_string())); + assert!(!addresses.contains(&"me@example.com".to_string())); + } + + #[test] + fn recent_contact_suggestions_sort_by_latest_interaction() { + let (store, account_id, folder_id) = setup_suggestion_store(); + for (remote_id, address, date) in [ + ("older", "older@example.com", 100), + ("newer", "newer@example.com", 200), + ] { + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id, + from_name: "Recent", + from_address: address, + to: vec![], + cc: vec![], + bcc: vec![], + date, + }, + ); + } + + let results = store + .search_contact_suggestions(&account_id, "", 20) + .unwrap(); + assert_eq!(results[0].address, "newer@example.com"); + assert_eq!(results[1].address, "older@example.com"); + } + + #[test] + fn contact_suggestion_suppression_hides_recent_but_not_saved_contact() { + let (store, account_id, folder_id) = setup_suggestion_store(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "hidden", + from_name: "Hidden", + from_address: "hidden@example.com", + to: vec![], + cc: vec![], + bcc: vec![], + date: 100, + }, + ); + store + .suppress_contact_suggestion("HIDDEN@example.com") + .unwrap(); + assert!(store + .search_contact_suggestions(&account_id, "hidden", 20) + .unwrap() + .is_empty()); + + store + .save_contact(&contact_input("Saved Hidden", "hidden@example.com")) + .unwrap(); + let results = store + .search_contact_suggestions(&account_id, "hidden", 20) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].source, ContactSuggestionSource::Saved); + } + + #[test] + fn suppress_contact_suggestion_rejects_invalid_email() { + let store = Store::open_in_memory().unwrap(); + + assert!(matches!( + store.suppress_contact_suggestion("not-an-address"), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn deleting_contact_can_suppress_its_addresses_from_recent_history() { + let (store, account_id, folder_id) = setup_suggestion_store(); + let saved = store + .save_contact(&contact_input("Delete Me", "delete@example.com")) + .unwrap(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "delete-history", + from_name: "Delete Me", + from_address: "delete@example.com", + to: vec![], + cc: vec![], + bcc: vec![], + date: 100, + }, + ); + + store.delete_contact(&saved.id, true).unwrap(); + + assert!(store + .search_contact_suggestions(&account_id, "delete", 20) + .unwrap() + .is_empty()); + } } From c3fe268bc4de93084acaeb14a5c88b700c09dc9b Mon Sep 17 00:00:00 2001 From: QingJ01 Date: Fri, 7 Aug 2026 16:47:41 +0800 Subject: [PATCH 04/16] feat(contacts): expose contact management commands --- src-tauri/src/commands/contacts.rs | 155 ++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 7 ++ src/lib/api.ts | 53 ++++++++++ src/lib/ipc-types.ts | 52 ++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/contacts.rs b/src-tauri/src/commands/contacts.rs index a0f47bc..cd8cf87 100644 --- a/src-tauri/src/commands/contacts.rs +++ b/src-tauri/src/commands/contacts.rs @@ -1,7 +1,107 @@ use crate::state::AppState; -use pebble_core::{KnownContact, PebbleError}; +use pebble_core::{Contact, ContactInput, ContactSuggestion, KnownContact, PebbleError}; +use pebble_store::Store; use tauri::State; +fn validated_contact_id(contact_id: &str) -> std::result::Result<&str, PebbleError> { + let contact_id = contact_id.trim(); + if contact_id.is_empty() { + return Err(PebbleError::Validation( + "Contact id must not be empty".to_string(), + )); + } + Ok(contact_id) +} + +fn get_contact_with_store( + store: &Store, + contact_id: &str, +) -> std::result::Result, PebbleError> { + store.get_contact(validated_contact_id(contact_id)?) +} + +fn save_contact_with_store( + store: &Store, + input: &ContactInput, +) -> std::result::Result { + store.save_contact(input) +} + +#[tauri::command] +pub async fn list_contacts( + state: State<'_, AppState>, + query: Option, + favorite_only: Option, + limit: Option, + offset: Option, +) -> std::result::Result, PebbleError> { + state.store.list_contacts( + query.as_deref(), + favorite_only.unwrap_or(false), + limit.unwrap_or(50), + offset.unwrap_or(0), + ) +} + +#[tauri::command] +pub async fn get_contact( + state: State<'_, AppState>, + contact_id: String, +) -> std::result::Result, PebbleError> { + get_contact_with_store(&state.store, &contact_id) +} + +#[tauri::command] +pub async fn save_contact( + state: State<'_, AppState>, + input: ContactInput, +) -> std::result::Result { + save_contact_with_store(&state.store, &input) +} + +#[tauri::command] +pub async fn delete_contact( + state: State<'_, AppState>, + contact_id: String, + suppress_addresses: Option, +) -> std::result::Result<(), PebbleError> { + state.store.delete_contact( + validated_contact_id(&contact_id)?, + suppress_addresses.unwrap_or(false), + ) +} + +#[tauri::command] +pub async fn set_contact_favorite( + state: State<'_, AppState>, + contact_id: String, + is_favorite: bool, +) -> std::result::Result<(), PebbleError> { + state + .store + .set_contact_favorite(validated_contact_id(&contact_id)?, is_favorite) +} + +#[tauri::command] +pub async fn search_contact_suggestions( + state: State<'_, AppState>, + account_id: String, + query: String, + limit: Option, +) -> std::result::Result, PebbleError> { + state + .store + .search_contact_suggestions(&account_id, &query, limit.unwrap_or(20)) +} + +#[tauri::command] +pub async fn suppress_contact_suggestion( + state: State<'_, AppState>, + address: String, +) -> std::result::Result<(), PebbleError> { + state.store.suppress_contact_suggestion(&address) +} + #[tauri::command] pub async fn search_contacts( state: State<'_, AppState>, @@ -12,3 +112,56 @@ pub async fn search_contacts( let limit = limit.unwrap_or(20); state.store.list_known_contacts(&account_id, &query, limit) } + +#[cfg(test)] +mod tests { + use super::{get_contact_with_store, save_contact_with_store}; + use pebble_core::{ContactEmailInput, ContactEmailLabel, ContactInput, PebbleError}; + use pebble_store::Store; + + fn input(address: &str) -> ContactInput { + ContactInput { + id: None, + display_name: "Alice".to_string(), + notes: String::new(), + is_favorite: false, + emails: vec![ContactEmailInput { + id: None, + address: address.to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + }], + } + } + + #[test] + fn contact_command_rejects_empty_contact_id_as_validation() { + let store = Store::open_in_memory().unwrap(); + + assert!(matches!( + get_contact_with_store(&store, " "), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn contact_command_maps_invalid_email_to_validation() { + let store = Store::open_in_memory().unwrap(); + + assert!(matches!( + save_contact_with_store(&store, &input("not-an-address")), + Err(PebbleError::Validation(_)) + )); + } + + #[test] + fn contact_command_maps_duplicate_email_to_validation() { + let store = Store::open_in_memory().unwrap(); + save_contact_with_store(&store, &input("Alice@example.com")).unwrap(); + + assert!(matches!( + save_contact_with_store(&store, &input("alice@EXAMPLE.COM")), + Err(PebbleError::Validation(_)) + )); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2d722d..0c86d90 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -575,6 +575,13 @@ pub fn run() { commands::cloud_sync::save_auto_backup_config, commands::cloud_sync::load_auto_backup_config, commands::cloud_sync::delete_auto_backup_config, + commands::contacts::list_contacts, + commands::contacts::get_contact, + commands::contacts::save_contact, + commands::contacts::delete_contact, + commands::contacts::set_contact_favorite, + commands::contacts::search_contact_suggestions, + commands::contacts::suppress_contact_suggestion, commands::contacts::search_contacts, commands::advanced_search::advanced_search, commands::sync_cmd::reindex_search, diff --git a/src/lib/api.ts b/src/lib/api.ts index 226adef..c2c158f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,6 +11,13 @@ export type { Attachment, BackupPreview, ConnectionSecurity, + Contact, + ContactEmail, + ContactEmailInput, + ContactEmailLabel, + ContactInput, + ContactSuggestion, + ContactSuggestionSource, EmailAddress, Folder, HttpProxyConfig, @@ -46,6 +53,9 @@ import type { Attachment, BackupPreview, ConnectionSecurity, + Contact, + ContactInput, + ContactSuggestion, Folder, HttpProxyConfig, ImapSyncFolderSettings, @@ -694,6 +704,49 @@ export async function searchContacts( return invoke("search_contacts", { accountId, query, limit }); } +export async function listContacts( + query?: string, + favoriteOnly = false, + limit = 50, + offset = 0, +): Promise { + return invoke("list_contacts", { query, favoriteOnly, limit, offset }); +} + +export async function getContact(contactId: string): Promise { + return invoke("get_contact", { contactId }); +} + +export async function saveContact(input: ContactInput): Promise { + return invoke("save_contact", { input }); +} + +export async function deleteContact( + contactId: string, + suppressAddresses = false, +): Promise { + return invoke("delete_contact", { contactId, suppressAddresses }); +} + +export async function setContactFavorite( + contactId: string, + isFavorite: boolean, +): Promise { + return invoke("set_contact_favorite", { contactId, isFavorite }); +} + +export async function searchContactSuggestions( + accountId: string, + query: string, + limit = 20, +): Promise { + return invoke("search_contact_suggestions", { accountId, query, limit }); +} + +export async function suppressContactSuggestion(address: string): Promise { + return invoke("suppress_contact_suggestion", { address }); +} + // ─── Drafts API ────────────────────────────────────────────────────────────── export async function saveDraft(args: { diff --git a/src/lib/ipc-types.ts b/src/lib/ipc-types.ts index c7908de..222377f 100644 --- a/src/lib/ipc-types.ts +++ b/src/lib/ipc-types.ts @@ -339,6 +339,58 @@ export interface BackupPreview { // ─── Contacts types ───────────────────────────────────────────────────────────── +/** @rust pebble-core/src/types.rs → ContactEmailLabel */ +export type ContactEmailLabel = "work" | "personal" | "other"; + +/** @rust pebble-core/src/types.rs → ContactEmail */ +export interface ContactEmail { + id: string; + address: string; + label: ContactEmailLabel; + is_primary: boolean; +} + +/** @rust pebble-core/src/types.rs → Contact */ +export interface Contact { + id: string; + display_name: string; + notes: string; + is_favorite: boolean; + emails: ContactEmail[]; + created_at: number; + updated_at: number; +} + +/** @rust pebble-core/src/types.rs → ContactEmailInput */ +export interface ContactEmailInput { + id?: string | null; + address: string; + label: ContactEmailLabel; + is_primary: boolean; +} + +/** @rust pebble-core/src/types.rs → ContactInput */ +export interface ContactInput { + id?: string | null; + display_name: string; + notes: string; + is_favorite: boolean; + emails: ContactEmailInput[]; +} + +/** @rust pebble-core/src/types.rs → ContactSuggestionSource */ +export type ContactSuggestionSource = "saved" | "recent"; + +/** @rust pebble-core/src/types.rs → ContactSuggestion */ +export interface ContactSuggestion { + contact_id: string | null; + name: string | null; + address: string; + source: ContactSuggestionSource; + is_favorite: boolean; + last_interaction_at: number | null; +} + /** @rust pebble-core/src/types.rs → KnownContact */ export interface KnownContact { name: string | null; From 99e8a19d0d0d01635bdc0cca9331c2943431d952 Mon Sep 17 00:00:00 2001 From: QingJ01 Date: Fri, 7 Aug 2026 16:50:17 +0800 Subject: [PATCH 05/16] feat(contacts): add contact query and mutation hooks --- src/hooks/mutations/index.ts | 1 + src/hooks/mutations/useContactMutations.ts | 60 ++++++++++++ src/hooks/queries/index.ts | 8 ++ src/hooks/queries/useContactsQuery.ts | 48 ++++++++++ tests/hooks/useContactMutations.test.tsx | 104 +++++++++++++++++++++ tests/hooks/useContactsQuery.test.tsx | 63 +++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 src/hooks/mutations/useContactMutations.ts create mode 100644 src/hooks/queries/useContactsQuery.ts create mode 100644 tests/hooks/useContactMutations.test.tsx create mode 100644 tests/hooks/useContactsQuery.test.tsx diff --git a/src/hooks/mutations/index.ts b/src/hooks/mutations/index.ts index fb7c30b..0c73af6 100644 --- a/src/hooks/mutations/index.ts +++ b/src/hooks/mutations/index.ts @@ -1,3 +1,4 @@ export { useSendEmailMutation } from "./useSendEmailMutation"; export { useUpdateFlagsMutation } from "./useUpdateFlagsMutation"; export { useSyncMutation } from "./useSyncMutation"; +export { useContactMutations } from "./useContactMutations"; diff --git a/src/hooks/mutations/useContactMutations.ts b/src/hooks/mutations/useContactMutations.ts new file mode 100644 index 0000000..54cc151 --- /dev/null +++ b/src/hooks/mutations/useContactMutations.ts @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + deleteContact, + saveContact, + setContactFavorite, + type ContactInput, +} from "@/lib/api"; +import { + contactQueryKey, + contactsQueryRoot, + contactSuggestionsQueryRoot, +} from "@/hooks/queries/useContactsQuery"; + +async function invalidateContactCaches(queryClient: ReturnType) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: contactsQueryRoot }), + queryClient.invalidateQueries({ queryKey: contactSuggestionsQueryRoot }), + ]); +} + +export function useContactMutations() { + const queryClient = useQueryClient(); + + const save = useMutation({ + mutationFn: (input: ContactInput) => saveContact(input), + onSuccess: async (contact) => { + queryClient.setQueryData(contactQueryKey(contact.id), contact); + await invalidateContactCaches(queryClient); + }, + }); + + const remove = useMutation({ + mutationFn: ({ + contactId, + suppressAddresses = false, + }: { + contactId: string; + suppressAddresses?: boolean; + }) => deleteContact(contactId, suppressAddresses), + onSuccess: async (_data, { contactId }) => { + queryClient.removeQueries({ queryKey: contactQueryKey(contactId) }); + await invalidateContactCaches(queryClient); + }, + }); + + const setFavorite = useMutation({ + mutationFn: ({ + contactId, + isFavorite, + }: { + contactId: string; + isFavorite: boolean; + }) => setContactFavorite(contactId, isFavorite), + onSuccess: async () => { + await invalidateContactCaches(queryClient); + }, + }); + + return { save, remove, setFavorite }; +} diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index 8226f77..9adee5b 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -28,3 +28,11 @@ export { usePendingMailOpsQuery, pendingMailOpsQueryKey, } from "./usePendingMailOpsQuery"; +export { + useContactsQuery, + contactsQueryKey, + contactQueryKey, + contactSuggestionsQueryKey, + contactsQueryRoot, + contactSuggestionsQueryRoot, +} from "./useContactsQuery"; diff --git a/src/hooks/queries/useContactsQuery.ts b/src/hooks/queries/useContactsQuery.ts new file mode 100644 index 0000000..fe52485 --- /dev/null +++ b/src/hooks/queries/useContactsQuery.ts @@ -0,0 +1,48 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { listContacts } from "@/lib/api"; + +export const contactsQueryRoot = ["contacts"] as const; +export const contactSuggestionsQueryRoot = ["contact-suggestions"] as const; + +export interface ContactsQueryOptions { + query: string; + favoriteOnly: boolean; + limit: number; + offset: number; +} + +export const contactsQueryKey = ({ + query, + favoriteOnly, + limit, + offset, +}: ContactsQueryOptions) => ["contacts", query, favoriteOnly, limit, offset] as const; + +export const contactQueryKey = (contactId: string) => ["contact", contactId] as const; + +export const contactSuggestionsQueryKey = (accountId: string, query: string) => + ["contact-suggestions", accountId, query] as const; + +export function useContactsQuery(options: ContactsQueryOptions) { + const [debouncedQuery, setDebouncedQuery] = useState(options.query); + + useEffect(() => { + if (options.query === debouncedQuery) return; + const timer = window.setTimeout(() => setDebouncedQuery(options.query), 200); + return () => window.clearTimeout(timer); + }, [debouncedQuery, options.query]); + + const debouncedOptions = { ...options, query: debouncedQuery }; + return useQuery({ + queryKey: contactsQueryKey(debouncedOptions), + queryFn: () => listContacts( + debouncedQuery, + options.favoriteOnly, + options.limit, + options.offset, + ), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); +} diff --git a/tests/hooks/useContactMutations.test.tsx b/tests/hooks/useContactMutations.test.tsx new file mode 100644 index 0000000..0660563 --- /dev/null +++ b/tests/hooks/useContactMutations.test.tsx @@ -0,0 +1,104 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + deleteContact, + saveContact, + setContactFavorite, + type Contact, + type ContactInput, +} from "@/lib/api"; +import { useContactMutations } from "@/hooks/mutations/useContactMutations"; + +vi.mock("@/lib/api", () => ({ + deleteContact: vi.fn(() => Promise.resolve()), + saveContact: vi.fn(), + setContactFavorite: vi.fn(() => Promise.resolve()), +})); + +const mockDeleteContact = vi.mocked(deleteContact); +const mockSaveContact = vi.mocked(saveContact); +const mockSetContactFavorite = vi.mocked(setContactFavorite); + +const input: ContactInput = { + display_name: "Alice", + notes: "", + is_favorite: false, + emails: [{ address: "alice@example.com", label: "work", is_primary: true }], +}; + +const saved: Contact = { + id: "contact-1", + display_name: "Alice", + notes: "", + is_favorite: false, + emails: [{ + id: "email-1", + address: "alice@example.com", + label: "work", + is_primary: true, + }], + created_at: 1, + updated_at: 1, +}; + +function setup() { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const hook = renderHook(() => useContactMutations(), { wrapper }); + return { ...hook, invalidateSpy }; +} + +function expectContactCachesInvalidated(invalidateSpy: ReturnType) { + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["contacts"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["contact-suggestions"] }); +} + +describe("useContactMutations", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSaveContact.mockResolvedValue(saved); + }); + + it("invalidates contact and suggestion caches after saving", async () => { + const { result, invalidateSpy } = setup(); + + await act(async () => result.current.save.mutateAsync(input)); + + expect(mockSaveContact).toHaveBeenCalledWith(input); + await waitFor(() => expectContactCachesInvalidated(invalidateSpy)); + }); + + it("invalidates contact and suggestion caches after deleting", async () => { + const { result, invalidateSpy } = setup(); + + await act(async () => result.current.remove.mutateAsync({ + contactId: "contact-1", + suppressAddresses: true, + })); + + expect(mockDeleteContact).toHaveBeenCalledWith("contact-1", true); + await waitFor(() => expectContactCachesInvalidated(invalidateSpy)); + }); + + it("invalidates contact and suggestion caches after changing favorite state", async () => { + const { result, invalidateSpy } = setup(); + + await act(async () => result.current.setFavorite.mutateAsync({ + contactId: "contact-1", + isFavorite: true, + })); + + expect(mockSetContactFavorite).toHaveBeenCalledWith("contact-1", true); + await waitFor(() => expectContactCachesInvalidated(invalidateSpy)); + }); +}); diff --git a/tests/hooks/useContactsQuery.test.tsx b/tests/hooks/useContactsQuery.test.tsx new file mode 100644 index 0000000..f6ad561 --- /dev/null +++ b/tests/hooks/useContactsQuery.test.tsx @@ -0,0 +1,63 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listContacts } from "@/lib/api"; +import { + contactsQueryKey, + useContactsQuery, +} from "@/hooks/queries/useContactsQuery"; + +vi.mock("@/lib/api", () => ({ + listContacts: vi.fn(() => Promise.resolve([])), +})); + +const mockListContacts = vi.mocked(listContacts); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useContactsQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("includes search, favorite filter, limit, and offset in its query key", () => { + expect( + contactsQueryKey({ query: "alice", favoriteOnly: true, limit: 25, offset: 50 }), + ).toEqual(["contacts", "alice", true, 25, 50]); + }); + + it("debounces changed search text for 200 milliseconds", async () => { + vi.useFakeTimers(); + const { rerender } = renderHook( + ({ query }) => useContactsQuery({ query, favoriteOnly: false, limit: 50, offset: 0 }), + { initialProps: { query: "a" }, wrapper: createWrapper() }, + ); + await act(async () => Promise.resolve()); + expect(mockListContacts).toHaveBeenCalledWith("a", false, 50, 0); + + rerender({ query: "alice" }); + await act(async () => { + vi.advanceTimersByTime(199); + await Promise.resolve(); + }); + expect(mockListContacts).not.toHaveBeenCalledWith("alice", false, 50, 0); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + expect(mockListContacts).toHaveBeenCalledWith("alice", false, 50, 0); + }); +}); From 2e12089cbf27a6dbce15ba5d294af854c64166f0 Mon Sep 17 00:00:00 2001 From: QingJ01 Date: Fri, 7 Aug 2026 17:01:39 +0800 Subject: [PATCH 06/16] feat(contacts): add contact management view --- src/app/Layout.tsx | 4 + src/components/Sidebar.tsx | 11 +- src/features/command-palette/commands.ts | 6 + src/features/contacts/ContactEditorDialog.tsx | 298 ++++++++ src/features/contacts/ContactListItem.tsx | 39 + src/features/contacts/ContactsView.tsx | 342 +++++++++ src/locales/en.json | 44 ++ src/locales/zh.json | 44 ++ src/stores/ui.store.ts | 2 +- src/styles/index.css | 665 ++++++++++++++++++ tests/components/Sidebar.navigation.test.tsx | 3 + .../features/command-palette/commands.test.ts | 6 + .../contacts/ContactEditorDialog.test.tsx | 90 +++ tests/features/contacts/ContactsView.test.tsx | 137 ++++ 14 files changed, 1689 insertions(+), 2 deletions(-) create mode 100644 src/features/contacts/ContactEditorDialog.tsx create mode 100644 src/features/contacts/ContactListItem.tsx create mode 100644 src/features/contacts/ContactsView.tsx create mode 100644 tests/features/contacts/ContactEditorDialog.test.tsx create mode 100644 tests/features/contacts/ContactsView.test.tsx diff --git a/src/app/Layout.tsx b/src/app/Layout.tsx index ad04acd..f7f8df8 100644 --- a/src/app/Layout.tsx +++ b/src/app/Layout.tsx @@ -27,6 +27,7 @@ import AppBackground from "./AppBackground"; const loadSettingsView = () => import("../features/settings/SettingsView"); const loadComposeView = () => import("../features/compose/ComposeView"); const loadKanbanView = () => import("../features/kanban/KanbanView"); +const loadContactsView = () => import("../features/contacts/ContactsView"); const loadSearchView = () => import("../features/search/SearchView"); const loadSnoozedView = () => import("../features/snoozed/SnoozedView"); const loadStarredView = () => import("../features/starred/StarredView"); @@ -34,6 +35,7 @@ const preloadLazyViews = createLazyViewPreloader([ loadSettingsView, loadComposeView, loadKanbanView, + loadContactsView, loadSearchView, loadSnoozedView, loadStarredView, @@ -42,6 +44,7 @@ const preloadLazyViews = createLazyViewPreloader([ const SettingsView = lazy(loadSettingsView); const ComposeView = lazy(loadComposeView); const KanbanView = lazy(loadKanbanView); +const ContactsView = lazy(loadContactsView); const SearchView = lazy(loadSearchView); const SnoozedView = lazy(loadSnoozedView); const StarredView = lazy(loadStarredView); @@ -137,6 +140,7 @@ export default function Layout() { }> {displayedView === "inbox" && } {displayedView === "kanban" && } + {displayedView === "contacts" && } {displayedView === "settings" && } {displayedView === "search" && } {displayedView === "snoozed" && } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 08221b2..e98b0d9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,6 +12,7 @@ import { Search, Clock, Star, + ContactRound, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useUIStore } from "../stores/ui.store"; @@ -322,7 +323,7 @@ export default function Sidebar() { }} /> - {/* Bottom nav: Snoozed + Kanban + Settings */} + {/* Bottom nav: Contacts + Snoozed + Kanban + Settings */}