diff --git a/CHANGELOG.md b/CHANGELOG.md index 2adb9584..fc9dbfa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- Added a local address book with searchable contacts, multiple labeled email addresses, favorites, notes, and quick create/edit actions from message participants (#81). +- Added recipient suggestions that prioritize saved contacts while retaining recent correspondents, with controls to suppress unwanted recent addresses (#81). +- Added standards-compatible vCard import and export with duplicate merging, partial-error reporting, UTF-8/folded-line support, and safe file/card limits (#81). +- Added contacts to local file and WebDAV settings backups using the backward-compatible v2 backup schema (#81). + ## [0.1.4] - 2026-08-08 ### Added diff --git a/README.md b/README.md index bc55f358..a7ae690b 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,15 @@ Pebble currently supports Gmail, IMAP, POP3, and experimental Outlook accounts. ### Productivity tools +- Local address book with search, favorites, notes, multiple labeled email addresses, and one-click contact actions from message participants. +- Recipient autocomplete that prioritizes saved contacts and falls back to recent correspondents. +- vCard (`.vcf`) import and export for moving contacts between Pebble and other address books. Re-imports merge by normalized email; existing non-empty local names and notes take precedence over imported values. - Kanban board with Todo, Waiting, and Done columns. - Command palette and keyboard-first navigation. - Built-in translation providers with bilingual reading and customizable shortcuts (`T` to translate selection, `Ctrl+Shift+T` to toggle bilingual view). - Dark and light themes with wallpaper background support. - English and Chinese UI. -- Optional local file export/import and WebDAV backup for settings, rules, Kanban cards, Kanban notes, and separately encrypted account secrets. +- Optional local file export/import and WebDAV backup for contacts, settings, rules, Kanban cards, Kanban notes, and separately encrypted account secrets. - Automatic scheduled WebDAV backup with configurable interval. ### Platform integration diff --git a/README.zh-CN.md b/README.zh-CN.md index 7adb7d36..dd453dc8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,12 +61,15 @@ Pebble 目前支持 Gmail、IMAP、POP3,以及实验性的 Outlook 账户。 ### 效率工具 +- 本地通讯录,支持搜索、收藏、备注、多个带标签的邮箱地址,并可从邮件参与者一键创建或查看联系人。 +- 收件人自动补全会优先显示已保存联系人,并保留最近联系过的地址。 +- 支持导入和导出 vCard(`.vcf`),方便与其他通讯录迁移联系人。重新导入时按规范化邮箱合并;若本地姓名或备注非空,则优先保留本地内容。 - 看板视图,包含 Todo、Waiting、Done 三列。 - 命令面板和键盘优先导航。 - 内置翻译能力,支持双语阅读和自定义快捷键(`T` 翻译选中文字,`Ctrl+Shift+T` 切换双语对照)。 - 深色和浅色主题,支持壁纸背景。 - 内置英文和中文界面。 -- 可选的 WebDAV 备份,用于同步设置、规则、看板卡片和看板备注。 +- 可选的本地文件导入/导出和 WebDAV 备份,用于同步联系人、设置、规则、看板卡片和看板备注。 - 支持自动定时 WebDAV 备份,间隔可配置。 ### 平台集成 diff --git a/crates/pebble-core/src/types.rs b/crates/pebble-core/src/types.rs index 7a00949b..09454646 100644 --- a/crates/pebble-core/src/types.rs +++ b/crates/pebble-core/src/types.rs @@ -274,6 +274,76 @@ 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, PartialEq, Eq, Default)] +pub struct VcardImportResult { + pub created: usize, + pub merged: usize, + pub skipped: usize, + pub invalid: usize, + pub errors: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Category { pub id: String, diff --git a/crates/pebble-store/src/cloud_sync.rs b/crates/pebble-store/src/cloud_sync.rs index fcca4c6a..d3ee9f26 100644 --- a/crates/pebble-store/src/cloud_sync.rs +++ b/crates/pebble-store/src/cloud_sync.rs @@ -3,7 +3,7 @@ use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use crate::Store; +use crate::{contacts::list_all_contacts_for_backup_with_conn, Store}; /// Maximum accepted size for a settings backup download. /// Settings backups (accounts metadata, rules, kanban cards, translate config) @@ -12,9 +12,37 @@ use crate::Store; pub const MAX_BACKUP_SIZE_BYTES: usize = 16 * 1024 * 1024; /// Highest backup schema version this build understands. -pub const BACKUP_SCHEMA_VERSION: u32 = 1; +pub const BACKUP_SCHEMA_VERSION: u32 = 2; pub const SETTINGS_BACKUP_FILENAME: &str = "pebble-settings-backup.json"; +fn validate_backup_schema(backup: &SettingsBackup) -> Result<()> { + if backup.version == 0 || backup.version > BACKUP_SCHEMA_VERSION { + return Err(PebbleError::Validation(format!( + "Unsupported backup version {} (this build supports up to {})", + backup.version, BACKUP_SCHEMA_VERSION + ))); + } + if backup.version >= 2 && backup.contacts.is_none() { + return Err(PebbleError::Validation( + "Backup version 2 is missing the required contacts field".to_string(), + )); + } + Ok(()) +} + +pub fn serialize_backup(backup: &SettingsBackup) -> Result> { + let json = serde_json::to_vec_pretty(backup) + .map_err(|e| PebbleError::Internal(format!("Failed to serialize settings: {e}")))?; + if json.len() > MAX_BACKUP_SIZE_BYTES { + return Err(PebbleError::Validation(format!( + "Backup file is too large ({} bytes, max {})", + json.len(), + MAX_BACKUP_SIZE_BYTES + ))); + } + Ok(json) +} + fn validate_backup_payload_shape(data: &[u8]) -> Result<()> { let data = data.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(data); let Some(first) = data.iter().copied().find(|b| !b.is_ascii_whitespace()) else { @@ -56,6 +84,7 @@ pub struct BackupPreview { pub rule_count: usize, pub kanban_card_count: usize, pub kanban_note_count: usize, + pub contact_count: usize, pub has_translate_config: bool, pub has_encrypted_secrets: bool, pub secret_account_count: usize, @@ -77,12 +106,7 @@ pub fn preview_backup(data: &[u8]) -> Result { let backup: SettingsBackup = serde_json::from_slice(data).map_err(|e| { PebbleError::Validation(format!("Backup file is not a valid settings backup: {e}")) })?; - if backup.version == 0 || backup.version > BACKUP_SCHEMA_VERSION { - return Err(PebbleError::Validation(format!( - "Unsupported backup version {} (this build supports up to {})", - backup.version, BACKUP_SCHEMA_VERSION - ))); - } + validate_backup_schema(&backup)?; Ok(BackupPreview { version: backup.version, exported_at: backup.exported_at, @@ -90,6 +114,7 @@ pub fn preview_backup(data: &[u8]) -> Result { rule_count: backup.rules.len(), kanban_card_count: backup.kanban_cards.len(), kanban_note_count: backup.kanban_context_notes.len(), + contact_count: backup.contacts.as_ref().map(Vec::len).unwrap_or(0), has_translate_config: backup .translate_config .as_ref() @@ -120,6 +145,8 @@ pub struct SettingsBackup { pub kanban_cards: Vec, #[serde(default)] pub kanban_context_notes: HashMap, + #[serde(default)] + pub contacts: Option>, pub translate_config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub secret_summary: Option, @@ -294,7 +321,7 @@ impl WebDavClient { } impl Store { - /// Export settings (accounts without passwords, rules, kanban cards, translate config) as JSON bytes. + /// Export settings (accounts without passwords, rules, kanban cards, contacts, translate config) as JSON bytes. pub fn export_settings(&self) -> Result> { let accounts = self.list_accounts()?; let account_backups: Vec = accounts @@ -310,6 +337,12 @@ impl Store { let rules = self.list_rules()?; let kanban_cards = self.list_kanban_cards(None)?; + let contacts = self.with_read(|conn| { + let tx = conn.unchecked_transaction()?; + let contacts = list_all_contacts_for_backup_with_conn(&tx)?; + tx.commit()?; + Ok(contacts) + })?; // Redact translate config — never export API keys or encrypted secrets let translate_config = self.get_translate_config()?.map(|mut tc| { tc.config = String::new(); @@ -317,20 +350,19 @@ impl Store { }); let backup = SettingsBackup { - version: 1, + version: BACKUP_SCHEMA_VERSION, exported_at: pebble_core::now_timestamp(), accounts: account_backups, rules, kanban_cards, kanban_context_notes: HashMap::new(), + contacts: Some(contacts), translate_config, secret_summary: None, encrypted_secrets: None, }; - let json = serde_json::to_vec_pretty(&backup) - .map_err(|e| PebbleError::Internal(format!("Failed to serialize settings: {e}")))?; - Ok(json) + serialize_backup(&backup) } /// Import settings from JSON bytes, upserting into the store. @@ -349,12 +381,7 @@ impl Store { validate_backup_payload_shape(data)?; let backup: SettingsBackup = serde_json::from_slice(data) .map_err(|e| PebbleError::Validation(format!("Failed to deserialize settings: {e}")))?; - if backup.version == 0 || backup.version > BACKUP_SCHEMA_VERSION { - return Err(PebbleError::Validation(format!( - "Unsupported backup version {} (this build supports up to {})", - backup.version, BACKUP_SCHEMA_VERSION - ))); - } + validate_backup_schema(&backup)?; self.with_write(|conn| { let tx = conn.unchecked_transaction() @@ -429,6 +456,14 @@ impl Store { Self::upsert_kanban_card_with_conn(&tx, card)?; } + // Contacts were added in schema v2. A v1 restore must preserve + // local contacts because the older file could not contain them. + if backup.version >= 2 { + if let Some(contacts) = &backup.contacts { + crate::contacts::replace_contacts_with_conn(&tx, contacts)?; + } + } + // Upsert translate config — skip if config field is empty (redacted export) if let Some(tc) = &backup.translate_config { if !tc.config.is_empty() { @@ -458,12 +493,7 @@ impl Store { validate_backup_payload_shape(data)?; let backup: SettingsBackup = serde_json::from_slice(data) .map_err(|e| PebbleError::Validation(format!("Failed to deserialize settings: {e}")))?; - if backup.version == 0 || backup.version > BACKUP_SCHEMA_VERSION { - return Err(PebbleError::Validation(format!( - "Unsupported backup version {} (this build supports up to {})", - backup.version, BACKUP_SCHEMA_VERSION - ))); - } + validate_backup_schema(&backup)?; self.with_write(|conn| { let tx = conn @@ -535,6 +565,12 @@ impl Store { Self::upsert_kanban_card_with_conn(&tx, card)?; } + if backup.version >= 2 { + if let Some(contacts) = &backup.contacts { + crate::contacts::replace_contacts_with_conn(&tx, contacts)?; + } + } + if let Some(tc) = &backup.translate_config { if !tc.config.is_empty() { Self::save_translate_config_with_conn(&tx, tc)?; @@ -758,15 +794,42 @@ mod tests { }; store.save_translate_config(&tc).unwrap(); + store + .save_contact(&ContactInput { + id: None, + display_name: "Alice Example".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, + }, + ], + }) + .unwrap(); + // Export let data = store.export_settings().unwrap(); let backup: SettingsBackup = serde_json::from_slice(&data).unwrap(); - assert_eq!(backup.version, 1); + assert_eq!(backup.version, 2); assert_eq!(backup.accounts.len(), 1); assert_eq!(backup.accounts[0].email, "test@example.com"); assert_eq!(backup.accounts[0].color.as_deref(), Some("#22c55e")); assert_eq!(backup.rules.len(), 1); assert_eq!(backup.rules[0].name, "Auto-archive"); + let backed_up_contacts = backup.contacts.as_ref().unwrap(); + assert_eq!(backed_up_contacts.len(), 1); + assert_eq!(backed_up_contacts[0].emails.len(), 2); + assert!(backed_up_contacts[0].is_favorite); assert!(backup.translate_config.is_some()); // Config field should be redacted (empty) in export assert_eq!(backup.translate_config.as_ref().unwrap().config, ""); @@ -785,6 +848,21 @@ mod tests { assert_eq!(rules.len(), 1); assert_eq!(rules[0].name, "Auto-archive"); + let contacts = store2.list_contacts(None, false, 200, 0).unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].display_name, "Alice Example"); + assert_eq!(contacts[0].notes, "Met at RustConf"); + assert_eq!(contacts[0].emails.len(), 2); + assert!(contacts[0].is_favorite); + assert_eq!( + contacts[0] + .emails + .iter() + .find(|email| email.is_primary) + .map(|email| email.address.as_str()), + Some("alice@example.com") + ); + // Translate config should NOT be imported when config is redacted let tc_loaded = store2.get_translate_config().unwrap(); assert!(tc_loaded.is_none()); @@ -840,6 +918,149 @@ mod tests { assert!(preview.has_translate_secret); } + #[test] + fn version_one_backup_has_no_contacts_and_preserves_local_contacts() { + let store = Store::open_in_memory().unwrap(); + store + .save_contact(&ContactInput { + id: None, + display_name: "Local contact".to_string(), + notes: String::new(), + is_favorite: false, + emails: vec![ContactEmailInput { + id: None, + address: "local@example.com".to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + }], + }) + .unwrap(); + let backup = serde_json::json!({ + "version": 1, + "exported_at": now_timestamp(), + "accounts": [], + "rules": [], + "kanban_cards": [], + "kanban_context_notes": {}, + "translate_config": null + }); + let data = serde_json::to_vec(&backup).unwrap(); + + let preview = preview_backup(&data).unwrap(); + assert_eq!(preview.contact_count, 0); + store.import_settings(&data).unwrap(); + + let contacts = store.list_contacts(None, false, 200, 0).unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].display_name, "Local contact"); + } + + #[test] + fn version_two_backup_requires_contacts_field() { + let backup = serde_json::json!({ + "version": 2, + "exported_at": now_timestamp(), + "accounts": [], + "rules": [], + "kanban_cards": [], + "kanban_context_notes": {}, + "translate_config": null + }); + let data = serde_json::to_vec(&backup).unwrap(); + + let error = preview_backup(&data).unwrap_err().to_string(); + + assert!(error.contains("contacts")); + assert!(error.contains("version 2")); + } + + #[test] + fn export_rejects_backup_that_cannot_be_restored_due_to_size() { + let store = Store::open_in_memory().unwrap(); + let notes = "x".repeat(2000); + store + .with_write(|conn| { + let tx = conn.unchecked_transaction()?; + for index in 0..8_500 { + let contact_id = format!("contact-{index}"); + let email_id = format!("email-{index}"); + let address = format!("user{index}@example.com"); + tx.execute( + "INSERT INTO contacts + (id, display_name, notes, is_favorite, created_at, updated_at) + VALUES (?1, ?2, ?3, 0, 1, 1)", + rusqlite::params![contact_id, address, notes], + )?; + tx.execute( + "INSERT INTO contact_emails + (id, contact_id, address, normalized_address, label, is_primary, created_at) + VALUES (?1, ?2, ?3, ?3, 'other', 1, 1)", + rusqlite::params![email_id, contact_id, address], + )?; + } + tx.commit()?; + Ok(()) + }) + .unwrap(); + + let error = store.export_settings().unwrap_err().to_string(); + + assert!(error.contains("too large")); + assert!(error.contains(&MAX_BACKUP_SIZE_BYTES.to_string())); + } + + #[test] + fn version_two_contact_restore_rolls_back_on_duplicate_email() { + let store = Store::open_in_memory().unwrap(); + store + .save_contact(&ContactInput { + id: None, + display_name: "Keep me".to_string(), + notes: String::new(), + is_favorite: false, + emails: vec![ContactEmailInput { + id: None, + address: "keep@example.com".to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + }], + }) + .unwrap(); + let contact = |id: &str, email_id: &str| { + serde_json::json!({ + "id": id, + "display_name": id, + "notes": "", + "is_favorite": false, + "emails": [{ + "id": email_id, + "address": "duplicate@example.com", + "label": "other", + "is_primary": true + }], + "created_at": 1, + "updated_at": 1 + }) + }; + let backup = serde_json::json!({ + "version": 2, + "exported_at": now_timestamp(), + "accounts": [], + "rules": [], + "kanban_cards": [], + "kanban_context_notes": {}, + "contacts": [contact("first", "first-email"), contact("second", "second-email")], + "translate_config": null + }); + + assert!(store + .import_settings(&serde_json::to_vec(&backup).unwrap()) + .is_err()); + let contacts = store.list_contacts(None, false, 200, 0).unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].display_name, "Keep me"); + } + #[test] fn test_import_does_not_duplicate_existing_accounts() { let store = Store::open_in_memory().unwrap(); @@ -938,6 +1159,7 @@ mod tests { }], kanban_cards: vec![], kanban_context_notes: HashMap::new(), + contacts: None, translate_config: None, secret_summary: None, encrypted_secrets: None, @@ -983,6 +1205,7 @@ mod tests { }], kanban_cards: vec![], kanban_context_notes: HashMap::new(), + contacts: None, translate_config: None, secret_summary: None, encrypted_secrets: None, @@ -1095,6 +1318,7 @@ mod tests { updated_at: now, }], kanban_context_notes: HashMap::new(), + contacts: None, translate_config: None, secret_summary: None, encrypted_secrets: None, @@ -1128,6 +1352,7 @@ mod tests { rules: vec![], kanban_cards: vec![], kanban_context_notes: HashMap::new(), + contacts: None, translate_config: None, secret_summary: None, encrypted_secrets: None, diff --git a/crates/pebble-store/src/contacts.rs b/crates/pebble-store/src/contacts.rs index d80659e8..3e331cb0 100644 --- a/crates/pebble-store/src/contacts.rs +++ b/crates/pebble-store/src/contacts.rs @@ -1,9 +1,779 @@ -use pebble_core::{KnownContact, Result}; -use rusqlite::params; +use std::collections::{HashMap, HashSet}; + +use pebble_core::{ + Contact, ContactEmail, ContactEmailInput, ContactEmailLabel, ContactInput, ContactSuggestion, + ContactSuggestionSource, EmailAddress, KnownContact, PebbleError, Result, +}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; use crate::Store; +pub(crate) const MAX_CONTACT_DISPLAY_NAME_CHARS: usize = 512; + +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.display_name.chars().count() > MAX_CONTACT_DISPLAY_NAME_CHARS { + return Err(PebbleError::Validation(format!( + "Contact display name must not exceed {MAX_CONTACT_DISPLAY_NAME_CHARS} characters" + ))); + } + 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 map_contact_email_insert_error(error: rusqlite::Error, address: &str) -> PebbleError { + if let rusqlite::Error::SqliteFailure(sqlite_error, _) = &error { + if matches!( + sqlite_error.extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY | rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE + ) { + return PebbleError::Validation(format!( + "Unable to save contact email {address}: {error}" + )); + } + } + PebbleError::from(error) +} + +pub(crate) 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, + })) +} + +#[derive(Debug)] +struct RecentContactCandidate { + name: Option, + 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, + }, + ); + } + } +} + +pub(crate) fn save_contact_with_conn(conn: &Connection, 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(); + 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 = conn + .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 = conn.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 = conn + .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() { + conn.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], + )?; + conn.execute( + "DELETE FROM contact_emails WHERE contact_id = ?1", + params![contact_id], + )?; + } else { + conn.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); + conn.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| map_contact_email_insert_error(error, address))?; + } + + load_contact_with_conn(conn, &contact_id)? + .ok_or_else(|| PebbleError::Internal("Saved contact could not be loaded".to_string())) +} + +pub(crate) fn replace_contacts_with_conn( + conn: &Transaction<'_>, + contacts: &[Contact], +) -> Result<()> { + let mut contact_ids = HashSet::new(); + let mut email_ids = HashSet::new(); + let mut normalized_addresses = HashSet::new(); + let mut prepared_contacts = Vec::with_capacity(contacts.len()); + for contact in contacts { + if contact.id.trim().is_empty() { + return Err(PebbleError::Validation( + "Restored contact id must not be empty".to_string(), + )); + } + if !contact_ids.insert(contact.id.clone()) { + return Err(PebbleError::Validation(format!( + "Duplicate restored contact id: {}", + contact.id + ))); + } + + let input = ContactInput { + id: Some(contact.id.clone()), + display_name: contact.display_name.clone(), + notes: contact.notes.clone(), + is_favorite: contact.is_favorite, + emails: contact + .emails + .iter() + .map(|email| ContactEmailInput { + id: Some(email.id.clone()), + address: email.address.clone(), + label: email.label.clone(), + is_primary: email.is_primary, + }) + .collect(), + }; + let prepared_emails = validate_contact_input(&input)?; + + for (email, (_, normalized)) in contact.emails.iter().zip(&prepared_emails) { + if email.id.trim().is_empty() { + return Err(PebbleError::Validation( + "Restored contact email id must not be empty".to_string(), + )); + } + if !email_ids.insert(email.id.clone()) { + return Err(PebbleError::Validation(format!( + "Duplicate restored contact email id: {}", + email.id + ))); + } + if !normalized_addresses.insert(normalized.clone()) { + return Err(PebbleError::Validation(format!( + "Duplicate restored contact email address: {normalized}" + ))); + } + } + prepared_contacts.push((contact, prepared_emails)); + } + + conn.execute("DELETE FROM contacts", [])?; + + for (contact, prepared_emails) in prepared_contacts { + conn.execute( + "INSERT INTO contacts + (id, display_name, notes, is_favorite, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + contact.id, + contact.display_name.trim(), + contact.notes.trim(), + contact.is_favorite, + contact.created_at, + contact.updated_at + ], + )?; + + for (email, (address, normalized)) in contact.emails.iter().zip(prepared_emails) { + conn.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, + contact.created_at + ], + )?; + } + } + + Ok(()) +} + +pub(crate) fn list_contacts_with_conn( + conn: &Connection, + 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); + 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(crate) fn list_all_contacts_for_backup_with_conn( + conn: &Transaction<'_>, +) -> Result> { + let mut contacts = Vec::new(); + loop { + let page = list_contacts_with_conn(conn, None, false, 200, contacts.len() as i64)?; + let page_len = page.len(); + contacts.extend(page); + if page_len < 200 { + return Ok(contacts); + } + } +} + impl Store { + pub fn save_contact(&self, input: &ContactInput) -> Result { + self.with_write(|conn| { + let tx = conn.unchecked_transaction()?; + let contact = save_contact_with_conn(&tx, input)?; + 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 get_contact_by_email(&self, address: &str) -> Result> { + let (_, normalized) = prepare_email(&ContactEmailInput { + id: None, + address: address.to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + })?; + self.with_read(|conn| { + let contact_id = conn + .query_row( + "SELECT contact_id + FROM contact_emails + WHERE normalized_address = ?1 COLLATE NOCASE", + params![normalized], + |row| row.get::<_, String>(0), + ) + .optional()?; + match contact_id { + Some(contact_id) => load_contact_with_conn(conn, &contact_id), + None => Ok(None), + } + }) + } + + pub fn list_contacts( + &self, + query: Option<&str>, + favorite_only: bool, + limit: i64, + offset: i64, + ) -> Result> { + self.with_read(|conn| list_contacts_with_conn(conn, query, favorite_only, limit, offset)) + } + + pub fn delete_contact(&self, contact_id: &str, suppress_addresses: bool) -> Result<()> { + self.with_write(|conn| { + 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(()) + }) + } + + 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(()) + }) + } + + 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 + AND (?2 = '' + OR from_name LIKE ?3 ESCAPE '\\' COLLATE NOCASE + OR from_address LIKE ?3 ESCAPE '\\' COLLATE NOCASE + OR to_list LIKE ?3 ESCAPE '\\' COLLATE NOCASE + OR cc_list LIKE ?3 ESCAPE '\\' COLLATE NOCASE + OR bcc_list LIKE ?3 ESCAPE '\\' COLLATE NOCASE) + ORDER BY date DESC + LIMIT 1000", + )?; + let history_rows = history_stmt.query_map(params![account_id, query, pattern], |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` @@ -52,49 +822,55 @@ impl Store { } } - // Second: search inside to_list JSON for matching recipients + // Second: search inside recipient JSON for matching To/Cc/Bcc entries. if (contacts.len() as i64) < limit { let remaining = limit - contacts.len() as i64; let mut stmt2 = conn.prepare( - "SELECT DISTINCT to_list + "SELECT DISTINCT to_list, cc_list, bcc_list FROM messages WHERE account_id = ?1 AND is_deleted = 0 - AND to_list LIKE ?2 ESCAPE '\\' - LIMIT ?3", + AND (to_list LIKE ?2 ESCAPE '\\' + OR cc_list LIKE ?2 ESCAPE '\\' + OR bcc_list LIKE ?2 ESCAPE '\\') + LIMIT ?3", )?; - let to_rows = stmt2 - .query_map(params![account_id, pattern, remaining * 5], |row| { - row.get::<_, String>(0) + let recipient_rows = + stmt2.query_map(params![account_id, pattern, remaining * 5], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) })?; - for row in to_rows { + for row in recipient_rows { if contacts.len() as i64 >= limit { break; } - let json_str = row?; - if let Ok(addrs) = - serde_json::from_str::>(&json_str) - { - let lower_query = query.to_lowercase(); - for addr in addrs { - if contacts.len() as i64 >= limit { - break; - } - let matches = addr.address.to_lowercase().contains(&lower_query) - || addr - .name - .as_ref() - .map(|n| n.to_lowercase().contains(&lower_query)) - .unwrap_or(false); - if matches { - let key = addr.address.to_lowercase(); - if seen.insert(key) { - contacts.push(KnownContact { - name: addr.name, - address: addr.address, - }); + let (to_json, cc_json, bcc_json) = row?; + for json_str in [to_json, cc_json, bcc_json] { + if let Ok(addrs) = serde_json::from_str::>(&json_str) { + let lower_query = query.to_lowercase(); + for addr in addrs { + if contacts.len() as i64 >= limit { + break; + } + let matches = addr.address.to_lowercase().contains(&lower_query) + || addr + .name + .as_ref() + .map(|n| n.to_lowercase().contains(&lower_query)) + .unwrap_or(false); + if matches { + let key = addr.address.to_lowercase(); + if seen.insert(key) { + contacts.push(KnownContact { + name: addr.name, + address: addr.address, + }); + } } } } @@ -109,9 +885,25 @@ impl Store { #[cfg(test)] mod tests { + use super::{list_all_contacts_for_backup_with_conn, replace_contacts_with_conn}; 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(); @@ -215,6 +1007,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(); @@ -233,6 +1103,37 @@ mod tests { assert_eq!(results[0].name.as_deref(), Some("Bob Jones")); } + #[test] + fn test_list_known_contacts_by_cc_and_bcc_lists() { + let (store, account_id, folder_id) = setup_suggestion_store(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "copy-recipients", + from_name: "Sender", + from_address: "sender@example.com", + to: vec![], + cc: vec![EmailAddress { + name: Some("Copy Person".to_string()), + address: "copy@example.com".to_string(), + }], + bcc: vec![EmailAddress { + name: Some("Blind Person".to_string()), + address: "blind@example.com".to_string(), + }], + date: 100, + }, + ); + + let cc = store.list_known_contacts(&account_id, "copy", 10).unwrap(); + let bcc = store.list_known_contacts(&account_id, "blind", 10).unwrap(); + + assert_eq!(cc[0].address, "copy@example.com"); + assert_eq!(bcc[0].address, "blind@example.com"); + } + #[test] fn test_list_known_contacts_broad_query() { let (store, account_id) = setup_store_with_contacts(); @@ -266,4 +1167,562 @@ 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_lookup_by_email_is_exact_and_case_insensitive() { + let store = Store::open_in_memory().unwrap(); + let alice = store + .save_contact(&contact_input("Alice", "Alice@Example.com")) + .unwrap(); + store + .save_contact(&contact_input("Alias", "alice+other@example.com")) + .unwrap(); + + let found = store + .get_contact_by_email(" alice@EXAMPLE.COM ") + .unwrap() + .unwrap(); + assert_eq!(found.id, alice.id); + assert!(store + .get_contact_by_email("missing@example.com") + .unwrap() + .is_none()); + } + + #[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_save_rejects_display_name_over_limit() { + let store = Store::open_in_memory().unwrap(); + let input = ContactInput { + display_name: "a".repeat(513), + ..contact_input("unused", "long-name@example.com") + }; + + assert!(matches!( + store.save_contact(&input), + Err(PebbleError::Validation(message)) if message.contains("512") + )); + } + + #[test] + fn contact_email_operational_errors_remain_storage_errors() { + let store = Store::open_in_memory().unwrap(); + store + .with_write(|conn| { + conn.execute_batch( + "CREATE TRIGGER fail_contact_email_insert + BEFORE INSERT ON contact_emails + BEGIN + SELECT RAISE(FAIL, 'simulated contact email storage failure'); + END;", + )?; + Ok(()) + }) + .unwrap(); + + assert!(matches!( + store.save_contact(&contact_input("Alice", "alice@example.com")), + Err(PebbleError::Storage(message)) + if message.contains("simulated contact email storage failure") + )); + } + + #[test] + fn contact_restore_validates_before_deleting_existing_contacts() { + let store = Store::open_in_memory().unwrap(); + store + .save_contact(&contact_input("Keep me", "keep@example.com")) + .unwrap(); + let invalid = Contact { + id: String::new(), + display_name: "Invalid".to_string(), + notes: String::new(), + is_favorite: false, + emails: vec![], + created_at: 1, + updated_at: 1, + }; + + store + .with_write(|conn| { + let tx = conn.unchecked_transaction()?; + assert!(replace_contacts_with_conn(&tx, &[invalid]).is_err()); + let remaining: i64 = + tx.query_row("SELECT COUNT(*) FROM contacts", [], |row| row.get(0))?; + assert_eq!(remaining, 1); + Ok(()) + }) + .unwrap(); + } + + #[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 backup_contact_loader_reads_all_pages_from_a_transaction() { + let store = Store::open_in_memory().unwrap(); + for index in 0..201 { + store + .save_contact(&contact_input( + &format!("Contact {index:03}"), + &format!("contact-{index}@example.com"), + )) + .unwrap(); + } + + store + .with_read(|conn| { + let tx = conn.unchecked_transaction()?; + let contacts = list_all_contacts_for_backup_with_conn(&tx)?; + assert_eq!(contacts.len(), 201); + tx.commit()?; + Ok(()) + }) + .unwrap(); + } + + #[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()); + } + + #[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 recent_contact_search_filters_before_applying_history_limit() { + let (store, account_id, folder_id) = setup_suggestion_store(); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: "older-match", + from_name: "Needle Person", + from_address: "needle@example.com", + to: vec![], + cc: vec![], + bcc: vec![], + date: 1, + }, + ); + for index in 0..1_000 { + let remote_id = format!("unrelated-{index}"); + let address = format!("unrelated-{index}@example.com"); + insert_suggestion_message( + &store, + &account_id, + &folder_id, + SuggestionMessage { + remote_id: &remote_id, + from_name: "Unrelated", + from_address: &address, + to: vec![], + cc: vec![], + bcc: vec![], + date: 100 + index, + }, + ); + } + + let results = store + .search_contact_suggestions(&account_id, "needle", 20) + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].address, "needle@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()); + } } diff --git a/crates/pebble-store/src/lib.rs b/crates/pebble-store/src/lib.rs index a801ad0f..46637de5 100644 --- a/crates/pebble-store/src/lib.rs +++ b/crates/pebble-store/src/lib.rs @@ -17,6 +17,7 @@ pub mod snooze; pub mod sync_failures; pub mod translate_config; pub mod trusted_senders; +pub mod vcard; use pebble_core::{PebbleError, Result}; use r2d2::Pool; diff --git a/crates/pebble-store/src/migrations.rs b/crates/pebble-store/src/migrations.rs index 8de48256..351d05d2 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 = 16; +const CURRENT_VERSION: u32 = 17; const ACCOUNT_COLOR_PRESETS: [&str; 12] = [ "#0ea5e9", "#22c55e", "#f59e0b", "#8b5cf6", "#f43f5e", "#14b8a6", "#6366f1", "#f97316", "#06b6d4", "#ec4899", "#84cc16", "#3b82f6", @@ -918,11 +918,51 @@ pub fn run_migrations(conn: &Connection) -> Result<()> { ) .map_err(|e| PebbleError::Storage(format!("Migration V16 failed: {e}")))?; } - set_schema_version(&tx, CURRENT_VERSION)?; + set_schema_version(&tx, 16)?; tx.commit() .map_err(|e| PebbleError::Storage(format!("Migration V16 commit failed: {e}")))?; } + // V17: profile-level address book and hidden recent-contact suggestions. + if version < 17 { + let tx = conn + .unchecked_transaction() + .map_err(|e| PebbleError::Storage(format!("Migration V17 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 V17 failed: {e}")))?; + set_schema_version(&tx, CURRENT_VERSION)?; + tx.commit() + .map_err(|e| PebbleError::Storage(format!("Migration V17 commit failed: {e}")))?; + } + Ok(()) } @@ -1070,6 +1110,64 @@ CREATE TABLE IF NOT EXISTS translate_config ( mod tests { use super::*; + #[test] + fn migration_v17_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, 17); + + 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("V17 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(); diff --git a/crates/pebble-store/src/vcard.rs b/crates/pebble-store/src/vcard.rs new file mode 100644 index 00000000..a848d02c --- /dev/null +++ b/crates/pebble-store/src/vcard.rs @@ -0,0 +1,777 @@ +use std::collections::HashSet; + +use pebble_core::{ + Contact, ContactEmailInput, ContactEmailLabel, ContactInput, PebbleError, Result, + VcardImportResult, +}; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::{ + contacts::{load_contact_with_conn, save_contact_with_conn, MAX_CONTACT_DISPLAY_NAME_CHARS}, + Store, +}; + +const MAX_VCARD_BYTES: usize = 5 * 1024 * 1024; +const MAX_VCARD_CONTACTS: usize = 10_000; +const MAX_IMPORT_ERRORS: usize = 20; + +#[derive(Debug)] +struct ParsedEmail { + address: String, + label: ContactEmailLabel, + preferred: bool, +} + +fn unescape_value(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + output.push(ch); + continue; + } + match chars.next() { + Some('n' | 'N') => output.push('\n'), + Some('\\') => output.push('\\'), + Some(',') => output.push(','), + Some(';') => output.push(';'), + Some(other) => output.push(other), + None => output.push('\\'), + } + } + output +} + +fn escape_value(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace(';', "\\;") + .replace(',', "\\,") +} + +fn split_escaped(value: &str, separator: char) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + for ch in value.chars() { + if escaped { + current.push('\\'); + current.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == separator { + parts.push(current); + current = String::new(); + } else { + current.push(ch); + } + } + if escaped { + current.push('\\'); + } + parts.push(current); + parts +} + +fn split_once_unquoted(value: &str, separator: char) -> Option<(&str, &str)> { + let mut quoted = false; + let mut escaped = false; + for (index, ch) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == '"' { + quoted = !quoted; + continue; + } + if ch == separator && !quoted { + let separator_end = index + ch.len_utf8(); + return Some((&value[..index], &value[separator_end..])); + } + } + None +} + +fn split_unquoted(value: &str, separator: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut quoted = false; + let mut escaped = false; + for (index, ch) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == '"' { + quoted = !quoted; + continue; + } + if ch == separator && !quoted { + parts.push(&value[start..index]); + start = index + ch.len_utf8(); + } + } + parts.push(&value[start..]); + parts +} + +fn name_from_n(value: &str) -> String { + let fields = split_escaped(value, ';'); + let decoded = fields + .iter() + .map(|field| unescape_value(field).trim().to_string()) + .collect::>(); + [ + decoded.get(3), + decoded.get(1), + decoded.get(2), + decoded.first(), + decoded.get(4), + ] + .into_iter() + .flatten() + .filter(|part| !part.is_empty()) + .cloned() + .collect::>() + .join(" ") +} + +fn parse_email_header(header: &str) -> (ContactEmailLabel, bool) { + let tokens = split_unquoted(header, ';') + .into_iter() + .skip(1) + .flat_map(|part| { + let (name, value) = split_once_unquoted(part, '=') + .map(|(name, value)| (Some(name.trim()), value.trim())) + .unwrap_or((None, part.trim())); + if name.is_some_and(|name| !name.eq_ignore_ascii_case("TYPE")) { + return Vec::new(); + } + value + .trim_matches('"') + .split(',') + .map(|token| token.trim().trim_matches('"').to_ascii_uppercase()) + .filter(|token| !token.is_empty()) + .collect::>() + }) + .collect::>(); + let label = if tokens.contains("WORK") { + ContactEmailLabel::Work + } else if tokens.contains("HOME") || tokens.contains("PERSONAL") { + ContactEmailLabel::Personal + } else { + ContactEmailLabel::Other + }; + (label, tokens.contains("PREF")) +} + +fn parse_card(lines: &[String]) -> std::result::Result { + let mut version = None; + let mut display_name = String::new(); + let mut structured_name = String::new(); + let mut notes = Vec::new(); + let mut parsed_emails = Vec::new(); + + for line in lines { + let Some((header, raw_value)) = split_once_unquoted(line, ':') else { + continue; + }; + let property = split_unquoted(header, ';') + .into_iter() + .next() + .unwrap_or_default() + .rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + match property.as_str() { + "VERSION" => version = Some(raw_value.trim().to_string()), + "FN" => display_name = unescape_value(raw_value).trim().to_string(), + "N" => structured_name = name_from_n(raw_value), + "NOTE" => notes.push(unescape_value(raw_value)), + "EMAIL" => { + let address = unescape_value(raw_value).trim().to_string(); + if !address.is_empty() { + let (label, preferred) = parse_email_header(header); + parsed_emails.push(ParsedEmail { + address, + label, + preferred, + }); + } + } + _ => {} + } + } + + if version.as_deref() != Some("3.0") { + return Err("Only vCard 3.0 is supported".to_string()); + } + if parsed_emails.is_empty() { + return Err("Contact has no email address".to_string()); + } + + let mut seen = HashSet::new(); + parsed_emails.retain(|email| seen.insert(email.address.to_lowercase())); + let preferred_index = parsed_emails + .iter() + .position(|email| email.preferred) + .unwrap_or(0); + let emails = parsed_emails + .into_iter() + .enumerate() + .map(|(index, email)| ContactEmailInput { + id: None, + address: email.address, + label: email.label, + is_primary: index == preferred_index, + }) + .collect(); + + let display_name = if display_name.is_empty() { + structured_name + } else { + display_name + }; + if display_name.chars().count() > MAX_CONTACT_DISPLAY_NAME_CHARS { + return Err(format!( + "Contact display name must not exceed {MAX_CONTACT_DISPLAY_NAME_CHARS} characters" + )); + } + + Ok(ContactInput { + id: None, + display_name, + notes: notes.join("\n").trim().to_string(), + is_favorite: false, + emails, + }) +} + +fn parse_vcards(data: &str) -> Result>> { + if data.len() > MAX_VCARD_BYTES { + return Err(PebbleError::Validation( + "vCard import must not exceed 5 MiB".to_string(), + )); + } + + let normalized = data.replace("\r\n", "\n").replace('\r', "\n"); + let mut unfolded: Vec = Vec::new(); + for raw_line in normalized.lines() { + if raw_line.starts_with([' ', '\t']) { + if let Some(previous) = unfolded.last_mut() { + previous.push_str(raw_line.trim_start_matches([' ', '\t'])); + } + } else { + unfolded.push(raw_line.to_string()); + } + } + + let card_count = unfolded + .iter() + .filter(|line| line.eq_ignore_ascii_case("BEGIN:VCARD")) + .count(); + if card_count > MAX_VCARD_CONTACTS { + return Err(PebbleError::Validation( + "vCard import must not contain more than 10,000 contacts".to_string(), + )); + } + + struct CardState { + lines: Vec, + nested_depth: usize, + error: Option, + } + + let mut results = Vec::with_capacity(card_count); + let mut current: Option = None; + for line in unfolded { + if line.eq_ignore_ascii_case("BEGIN:VCARD") { + if let Some(card) = current.as_mut() { + card.nested_depth += 1; + card.error + .get_or_insert_with(|| "Nested BEGIN:VCARD".to_string()); + } else { + current = Some(CardState { + lines: Vec::new(), + nested_depth: 0, + error: None, + }); + } + } else if line.eq_ignore_ascii_case("END:VCARD") { + if let Some(card) = current.as_mut() { + if card.nested_depth > 0 { + card.nested_depth -= 1; + continue; + } + } + if let Some(card) = current.take() { + results.push(match card.error { + Some(error) => Err(error), + None => parse_card(&card.lines), + }); + } + } else if let Some(card) = current.as_mut() { + if card.nested_depth == 0 { + card.lines.push(line); + } + } + } + if let Some(card) = current { + results.push(Err(card + .error + .unwrap_or_else(|| "Missing END:VCARD".to_string()))); + } + Ok(results) +} + +fn owner_ids_for_input(conn: &Connection, input: &ContactInput) -> Result> { + let mut owners = HashSet::new(); + for email in &input.emails { + let owner = conn + .query_row( + "SELECT contact_id FROM contact_emails WHERE normalized_address = ?1 COLLATE NOCASE", + params![email.address.trim().to_lowercase()], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(owner) = owner { + owners.insert(owner); + } + } + Ok(owners) +} + +fn merge_input(existing: &Contact, imported: ContactInput) -> Option { + let mut emails = existing + .emails + .iter() + .map(|email| ContactEmailInput { + id: Some(email.id.clone()), + address: email.address.clone(), + label: email.label.clone(), + is_primary: email.is_primary, + }) + .collect::>(); + let mut known = emails + .iter() + .map(|email| email.address.to_lowercase()) + .collect::>(); + for email in imported.emails { + if known.insert(email.address.to_lowercase()) { + emails.push(ContactEmailInput { + is_primary: false, + ..email + }); + } + } + let display_name = if existing.display_name.trim().is_empty() { + imported.display_name + } else { + existing.display_name.clone() + }; + let notes = if existing.notes.trim().is_empty() { + imported.notes + } else { + existing.notes.clone() + }; + if emails.len() == existing.emails.len() + && display_name == existing.display_name + && notes == existing.notes + { + return None; + } + Some(ContactInput { + id: Some(existing.id.clone()), + display_name, + notes, + is_favorite: existing.is_favorite, + emails, + }) +} + +fn push_import_error(result: &mut VcardImportResult, index: usize, message: impl Into) { + result.invalid += 1; + if result.errors.len() < MAX_IMPORT_ERRORS { + result + .errors + .push(format!("Card {}: {}", index + 1, message.into())); + } +} + +fn import_with_conn( + conn: &Connection, + cards: Vec>, +) -> Result { + let mut result = VcardImportResult::default(); + for (index, card) in cards.into_iter().enumerate() { + let input = match card { + Ok(input) => input, + Err(message) => { + push_import_error(&mut result, index, message); + continue; + } + }; + let owners = owner_ids_for_input(conn, &input)?; + let (input, merged) = match owners.len() { + 0 => (input, false), + 1 => { + let owner = owners.into_iter().next().unwrap_or_default(); + let existing = load_contact_with_conn(conn, &owner)?.ok_or_else(|| { + PebbleError::Internal("Existing contact could not be loaded".to_string()) + })?; + let Some(merged_input) = merge_input(&existing, input) else { + result.skipped += 1; + continue; + }; + (merged_input, true) + } + _ => { + push_import_error( + &mut result, + index, + "Email addresses belong to multiple existing contacts", + ); + continue; + } + }; + match save_contact_with_conn(conn, &input) { + Ok(_) if merged => result.merged += 1, + Ok(_) => result.created += 1, + Err(PebbleError::Validation(message)) => push_import_error(&mut result, index, message), + Err(error) => return Err(error), + } + } + Ok(result) +} + +fn fold_line(line: &str) -> String { + if line.len() <= 75 { + return format!("{line}\r\n"); + } + let mut output = String::new(); + let mut remaining = line; + let mut first = true; + while !remaining.is_empty() { + let limit = if first { 75 } else { 74 }; + let mut end = remaining.len().min(limit); + while !remaining.is_char_boundary(end) { + end -= 1; + } + if !first { + output.push(' '); + } + output.push_str(&remaining[..end]); + output.push_str("\r\n"); + remaining = &remaining[end..]; + first = false; + } + output +} + +fn serialize_contact(contact: &Contact) -> String { + let mut output = String::new(); + for line in [ + "BEGIN:VCARD".to_string(), + "VERSION:3.0".to_string(), + format!("FN:{}", escape_value(&contact.display_name)), + "N:;;;;".to_string(), + ] { + output.push_str(&fold_line(&line)); + } + for email in &contact.emails { + let label = match email.label { + ContactEmailLabel::Work => "WORK", + ContactEmailLabel::Personal => "HOME", + ContactEmailLabel::Other => "INTERNET", + }; + let preferred = if email.is_primary { ",PREF" } else { "" }; + output.push_str(&fold_line(&format!( + "EMAIL;TYPE={label}{preferred}:{}", + escape_value(&email.address) + ))); + } + if !contact.notes.is_empty() { + output.push_str(&fold_line(&format!( + "NOTE:{}", + escape_value(&contact.notes) + ))); + } + output.push_str("END:VCARD\r\n"); + output +} + +impl Store { + pub fn import_contacts_vcard(&self, data: &str) -> Result { + let cards = parse_vcards(data)?; + self.with_write(|conn| { + let tx = conn.unchecked_transaction()?; + let result = import_with_conn(&tx, cards)?; + tx.commit()?; + Ok(result) + }) + } + + pub fn export_contacts_vcard(&self) -> Result { + self.with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT id FROM contacts ORDER BY display_name COLLATE NOCASE ASC, created_at ASC", + )?; + let ids = stmt + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + let mut output = String::new(); + for id in ids { + if let Some(contact) = load_contact_with_conn(conn, &id)? { + output.push_str(&serialize_contact(&contact)); + } + } + Ok(output) + }) + } +} + +#[cfg(test)] +mod tests { + use super::parse_card; + use pebble_core::{ContactEmailInput, ContactEmailLabel, ContactInput, PebbleError}; + + use crate::Store; + + const COMPLEX_CARD: &str = concat!( + "BEGIN:VCARD\r\n", + "VERSION:3.0\r\n", + "FN:张三\r\n", + "EMAIL;TYPE=WORK,PREF:Zhang.San@example.com\r\n", + "EMAIL;TYPE=HOME:zhang@example.net\r\n", + "NOTE:第一行\\n第二行\\,有逗号\\;有分号并且很\r\n", + " 长\r\n", + "END:VCARD\r\n", + ); + + fn contact_input(address: &str) -> ContactInput { + ContactInput { + id: None, + display_name: "Existing".to_string(), + notes: "existing note".to_string(), + is_favorite: true, + emails: vec![ContactEmailInput { + id: None, + address: address.to_string(), + label: ContactEmailLabel::Other, + is_primary: true, + }], + } + } + + #[test] + fn imports_utf8_multiple_emails_folded_lines_and_escapes() { + let store = Store::open_in_memory().unwrap(); + + let result = store.import_contacts_vcard(COMPLEX_CARD).unwrap(); + + assert_eq!(result.created, 1); + assert_eq!(result.merged, 0); + assert_eq!(result.invalid, 0); + let contacts = store.list_contacts(None, false, 20, 0).unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].display_name, "张三"); + assert_eq!(contacts[0].emails.len(), 2); + assert_eq!(contacts[0].emails[0].label, ContactEmailLabel::Work); + assert!(contacts[0].emails[0].is_primary); + assert_eq!(contacts[0].emails[1].label, ContactEmailLabel::Personal); + assert_eq!(contacts[0].notes, "第一行\n第二行,有逗号;有分号并且很长"); + } + + #[test] + fn imports_name_from_n_when_fn_is_missing() { + let store = Store::open_in_memory().unwrap(); + let data = + "BEGIN:VCARD\nVERSION:3.0\nN:Lovelace;Ada;;;\nEMAIL:ada@example.com\nEND:VCARD\n"; + + store.import_contacts_vcard(data).unwrap(); + + let contacts = store.list_contacts(None, false, 20, 0).unwrap(); + assert_eq!(contacts[0].display_name, "Ada Lovelace"); + } + + #[test] + fn imports_quoted_type_parameter_tokens() { + let store = Store::open_in_memory().unwrap(); + let data = "BEGIN:VCARD\nVERSION:3.0\nFN:Alice\nEMAIL;TYPE=\"WORK,PREF\":alice@example.com\nEMAIL;TYPE=\"HOME\":alice@home.example\nEND:VCARD\n"; + + store.import_contacts_vcard(data).unwrap(); + + let contact = &store.list_contacts(None, false, 20, 0).unwrap()[0]; + assert_eq!(contact.emails[0].label, ContactEmailLabel::Work); + assert!(contact.emails[0].is_primary); + assert_eq!(contact.emails[1].label, ContactEmailLabel::Personal); + } + + #[test] + fn rejects_cards_without_version_three() { + let store = Store::open_in_memory().unwrap(); + let data = "BEGIN:VCARD\nFN:Alice\nEMAIL:alice@example.com\nEND:VCARD\n"; + + let result = store.import_contacts_vcard(data).unwrap(); + + assert_eq!(result.created, 0); + assert_eq!(result.invalid, 1); + assert!(result.errors[0].contains("vCard 3.0")); + } + + #[test] + fn nested_cards_are_rejected_without_importing_the_inner_fragment() { + let store = Store::open_in_memory().unwrap(); + let data = "BEGIN:VCARD\nVERSION:3.0\nFN:Outer\n\ + BEGIN:VCARD\nVERSION:3.0\nFN:Inner\nEMAIL:inner@example.com\nEND:VCARD\n\ + EMAIL:outer@example.com\nEND:VCARD\n"; + + let result = store.import_contacts_vcard(data).unwrap(); + + assert_eq!(result.created, 0); + assert_eq!(result.invalid, 1); + assert!(result.errors[0].contains("Nested BEGIN:VCARD")); + assert!(store.list_contacts(None, false, 20, 0).unwrap().is_empty()); + } + + #[test] + fn quoted_custom_parameters_may_contain_property_delimiters() { + let store = Store::open_in_memory().unwrap(); + let data = "BEGIN:VCARD\nVERSION:3.0\nFN:Alice\n\ + EMAIL;X-FOO=\"a:b=c;d\";TYPE=WORK:alice@example.com\nEND:VCARD\n"; + + let result = store.import_contacts_vcard(data).unwrap(); + + assert_eq!(result.created, 1); + assert_eq!(result.invalid, 0); + let contact = store.list_contacts(None, false, 20, 0).unwrap().remove(0); + assert_eq!(contact.emails[0].address, "alice@example.com"); + assert_eq!(contact.emails[0].label, ContactEmailLabel::Work); + } + + #[test] + fn parse_card_rejects_display_names_over_limit() { + let lines = vec![ + "VERSION:3.0".to_string(), + format!("FN:{}", "a".repeat(513)), + "EMAIL:alice@example.com".to_string(), + ]; + + let error = parse_card(&lines).unwrap_err(); + + assert!(error.contains("512")); + } + + #[test] + fn merges_duplicate_addresses_into_an_existing_contact() { + let store = Store::open_in_memory().unwrap(); + store + .save_contact(&contact_input("Alice@example.com")) + .unwrap(); + let data = "BEGIN:VCARD\nVERSION:3.0\nFN:Alice Imported\nEMAIL;TYPE=PREF:alice@EXAMPLE.com\nEMAIL;TYPE=WORK:alice.work@example.com\nEND:VCARD\n"; + + let result = store.import_contacts_vcard(data).unwrap(); + + assert_eq!(result.created, 0); + assert_eq!(result.merged, 1); + let contacts = store.list_contacts(None, false, 20, 0).unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].display_name, "Existing"); + assert!(contacts[0].is_favorite); + assert_eq!(contacts[0].emails.len(), 2); + } + + #[test] + fn reports_partial_invalid_records_without_losing_valid_contacts() { + let store = Store::open_in_memory().unwrap(); + let data = "BEGIN:VCARD\nVERSION:3.0\nFN:No Email\nEND:VCARD\n\ + BEGIN:VCARD\nVERSION:3.0\nFN:Valid\nEMAIL:valid@example.com\nEND:VCARD\n"; + + let result = store.import_contacts_vcard(data).unwrap(); + + assert_eq!(result.created, 1); + assert_eq!(result.invalid, 1); + assert_eq!(result.skipped, 0); + assert_eq!(result.errors.len(), 1); + assert_eq!(store.list_contacts(None, false, 20, 0).unwrap().len(), 1); + } + + #[test] + fn rejects_files_larger_than_five_mib() { + let store = Store::open_in_memory().unwrap(); + let oversized = "X".repeat(5 * 1024 * 1024 + 1); + + assert!(matches!( + store.import_contacts_vcard(&oversized), + Err(PebbleError::Validation(message)) if message.contains("5 MiB") + )); + } + + #[test] + fn rejects_more_than_ten_thousand_cards() { + let store = Store::open_in_memory().unwrap(); + let data = (0..10_001) + .map(|index| { + format!("BEGIN:VCARD\nVERSION:3.0\nEMAIL:user{index}@example.com\nEND:VCARD\n") + }) + .collect::(); + + assert!(matches!( + store.import_contacts_vcard(&data), + Err(PebbleError::Validation(message)) if message.contains("10,000") + )); + } + + #[test] + fn exported_contacts_round_trip_with_names_notes_and_labels() { + let source = Store::open_in_memory().unwrap(); + source + .save_contact(&ContactInput { + id: None, + display_name: "Zoë, Example".to_string(), + notes: "Line one\nLine two; detail".to_string(), + is_favorite: false, + emails: vec![ + ContactEmailInput { + id: None, + address: "zoe@example.com".to_string(), + label: ContactEmailLabel::Personal, + is_primary: true, + }, + ContactEmailInput { + id: None, + address: "work@example.com".to_string(), + label: ContactEmailLabel::Work, + is_primary: false, + }, + ], + }) + .unwrap(); + + let exported = source.export_contacts_vcard().unwrap(); + assert!(exported.contains("VERSION:3.0\r\n")); + assert!(exported.contains("FN:Zoë\\, Example\r\n")); + + let restored = Store::open_in_memory().unwrap(); + let result = restored.import_contacts_vcard(&exported).unwrap(); + assert_eq!(result.created, 1); + let contact = &restored.list_contacts(None, false, 20, 0).unwrap()[0]; + assert_eq!(contact.display_name, "Zoë, Example"); + assert_eq!(contact.notes, "Line one\nLine two; detail"); + assert_eq!(contact.emails.len(), 2); + assert_eq!(contact.emails[0].label, ContactEmailLabel::Personal); + assert!(contact.emails[0].is_primary); + } +} diff --git a/docs/plans/2026-08-10-pr-89-review-fixes-design.md b/docs/plans/2026-08-10-pr-89-review-fixes-design.md new file mode 100644 index 00000000..e7e3c33d --- /dev/null +++ b/docs/plans/2026-08-10-pr-89-review-fixes-design.md @@ -0,0 +1,33 @@ +# PR #89 Review Fixes Design + +## Scope and decisions + +This change set resolves every actionable finding from the second review of PR #89 without changing behavior that was incorrectly reported as broken. The recipient autocomplete keeps the browser's default Tab behavior when a recent-suggestion removal action is present; a regression test will make the non-cancelled event explicit. vCard re-import continues to preserve non-empty local names, notes, and favorite state while adding new addresses, because overwriting local edits is a more destructive default. That policy will be documented in both READMEs. + +The implementation takes a targeted-refactor approach. A minimal patch would leave repeated IPC lookups, weak transaction preconditions, and parser duplication in place. A broader redesign with a new recent-recipient materialized table and infinite contact pagination would add a migration and significantly expand PR scope. The selected middle approach fixes current correctness, accessibility, and 10,000-contact scaling risks using existing SQLite, React Query, and `@tanstack/react-virtual` infrastructure. + +## Backend architecture + +All Tauri contact commands will use one generic `spawn_blocking` adapter around the shared `Arc`. This follows the pattern already used by message, thread, search, and folder commands and prevents synchronous SQLite, vCard parsing, and serialization from occupying Tokio worker threads. A new exact normalized-email lookup will replace substring `list_contacts` calls made by every message participant. + +Contact-email insert errors will classify only SQLite UNIQUE/PRIMARY KEY conflicts as validation failures. Other SQLite failures, including trigger, I/O, full-disk, busy, and corruption errors, will propagate as storage failures so vCard import aborts and rolls back. Backup replacement will prevalidate all IDs, names, notes, email structure, and cross-contact uniqueness before deletion, and its API will require `&Transaction` to encode the caller-managed transaction precondition. + +Backup contact pagination will run inside one read transaction. The regular contact-list query will gain a connection-level helper so both normal queries and backup snapshots share the same SQL. Recent suggestion history will add SQL-side query predicates before JSON parsing, while the legacy known-contact query will include To, Cc, and Bcc consistently. + +## vCard behavior + +The parser will require `VERSION:3.0`, enforce a 512-character display-name cap, and locate property delimiters outside quoted parameter values. Header parameters will be split on unquoted semicolons and assignment delimiters, preserving custom values such as `X-FOO="a:b=c;d"`. + +Nested cards will be treated as one malformed outer structure. A depth-aware state keeps the outer parse state until its matching `END:VCARD`, records one clean error, and does not import the nested fragment as a separate valid contact. Operational save failures remain fatal to the whole transaction; only malformed individual cards and genuine validation conflicts remain partial import failures. + +## Frontend data flow and accessibility + +`ContactAddressAction` will use a React Query hook keyed by normalized email. Identical participants therefore share one exact lookup and one cache entry, and contact mutations invalidate the same `contacts` root. From/To/Cc participants will be normalized and deduplicated before rendering in both message views. + +The Contacts view will keep its existing data query but virtualize list rows with the list pane as the scroll element, so at most the visible window plus overscan is mounted. The translated `contacts.count` key will supply the count label. + +The editor dialog will reuse the focus-management pattern from `ConfirmDialog`: capture and restore previous focus, trap forward and reverse Tab, keep Escape disabled while saving, and associate validation errors with the failing name, email group, or notes field. Name and notes DOM limits will match backend validation. Danger styles will use theme variables and remove conflicting declarations. + +## Verification + +Every behavior change starts with a failing unit or component test. Rust coverage will include storage-error classification, pre-delete validation, exact email lookup, strict/nested/quoted vCards, name limits, snapshot page loading, and Cc/Bcc legacy search. Frontend coverage will include cached participant lookup, participant deduplication, virtualization, dialog focus/error behavior, all editor validation paths, localized count rendering, ContactListItem behavior, and import error details. Final verification will run formatting, frontend tests/build, Rust tests/clippy excluding only the disclosed pre-existing OAuth failure where necessary, and `git diff --check` before commit, push, and merge. diff --git a/docs/plans/2026-08-10-pr-89-review-fixes.md b/docs/plans/2026-08-10-pr-89-review-fixes.md new file mode 100644 index 00000000..1f153a3b --- /dev/null +++ b/docs/plans/2026-08-10-pr-89-review-fixes.md @@ -0,0 +1,132 @@ +# PR #89 Review Fixes Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Resolve all actionable Important and Minor findings on PR #89, verify the full repository, and merge the PR into `master`. + +**Architecture:** Keep the existing SQLite/contact model, but enforce transaction and error boundaries in Rust, move blocking IPC work off Tokio workers, use exact cached participant lookups, virtualize the contact list, and harden the vCard state machine. Preserve local-wins vCard merge semantics and browser-default Tab navigation, documenting and testing both. + +**Tech Stack:** Rust, rusqlite, Tokio/Tauri, React 19, TypeScript, TanStack React Query/Virtual, Vitest, Testing Library. + +--- + +### Task 1: Encode contact-store validation and storage-error boundaries + +**Files:** +- Modify: `crates/pebble-store/src/contacts.rs` +- Test: `crates/pebble-store/src/contacts.rs` +- Test: `crates/pebble-store/src/vcard.rs` + +**Steps:** +1. Add failing tests for display names over 512 characters, exact case-insensitive email lookup, operational email-insert failures returning `Storage`, fatal vCard rollback, and replacement validation occurring before deletion. +2. Run `cargo test -p pebble-store contacts::tests` and the targeted vCard test; confirm failures describe missing validation/lookup or wrong error classes. +3. Add a shared display-name limit, exact lookup, selective SQLite constraint mapping, prevalidation of the complete restore payload, and a `&Transaction` replacement signature. +4. Re-run the targeted tests and then `cargo test -p pebble-store`. + +### Task 2: Harden vCard parsing + +**Files:** +- Modify: `crates/pebble-store/src/vcard.rs` + +**Steps:** +1. Add failing tests for missing VERSION, nested cards not importing inner fragments, quoted custom parameters containing colon/equal/semicolon, and oversized names becoming partial validation errors. +2. Run the four targeted tests and confirm each fails for the expected parser behavior. +3. Implement quote-aware delimiter helpers, strict VERSION validation, nested-card depth tracking, and parse-time name limits. +4. Run all `pebble-store` tests. + +### Task 3: Make backup reads stable and recent searches consistent + +**Files:** +- Modify: `crates/pebble-store/src/contacts.rs` +- Modify: `crates/pebble-store/src/cloud_sync.rs` + +**Steps:** +1. Add failing coverage for the new transaction-only backup contact loader and Cc/Bcc results in `list_known_contacts`. +2. Extract a connection-level contact-list helper and load all backup pages through a read transaction. +3. Add SQL-side history predicates for suggestion queries and search To/Cc/Bcc uniformly in the legacy query. +4. Run `cargo test -p pebble-store`. + +### Task 4: Move contact IPC work off Tokio workers + +**Files:** +- Modify: `src-tauri/src/commands/contacts.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/api.ts` + +**Steps:** +1. Add a failing async test proving the store adapter runs on a different thread. +2. Add a generic `spawn_blocking` store adapter and route all contact commands through it. +3. Add the exact-email command/API and remove the unused get-by-ID IPC wrapper and registration. +4. Run the targeted Tauri command tests and `cargo check -p pebble`. + +### Task 5: Cache participant lookups and deduplicate participants + +**Files:** +- Modify: `src/hooks/queries/useContactsQuery.ts` +- Modify: `src/components/ContactAddressAction.tsx` +- Modify: `src/components/MessageDetail.tsx` +- Modify: `src/components/ThreadMessageBubble.tsx` +- Create: `src/components/contact-participants.ts` +- Modify: `tests/components/ContactAddressAction.test.tsx` +- Modify: `tests/components/MessageDetail.selection.test.tsx` +- Modify: `tests/components/ThreadMessageBubble.test.tsx` +- Create: `tests/components/contact-participants.test.ts` + +**Steps:** +1. Add failing tests showing two identical actions issue one lookup and duplicate From/To/Cc addresses collapse case-insensitively. +2. Add a normalized-address React Query key/hook backed by the exact IPC command. +3. Replace local effect state in `ContactAddressAction`, update cache after save, and share a participant-deduplication helper between both message views. +4. Run the affected frontend tests. + +### Task 6: Virtualize ContactsView and complete localization/styles + +**Files:** +- Modify: `src/features/contacts/ContactsView.tsx` +- Modify: `src/styles/index.css` +- Modify: `tests/features/contacts/ContactsView.test.tsx` +- Create: `tests/features/contacts/ContactListItem.test.tsx` + +**Steps:** +1. Add failing tests that a large contact collection does not mount every row, the count label uses translation, import error text renders, and ContactListItem covers fallback/favorite/selection behavior. +2. Add `useVirtualizer` with a stable scroll container, estimated row size, overscan, and semantic list/listitem wrappers. +3. Use `t("contacts.count", { count })`, consolidate danger selectors, and replace hardcoded red values with `var(--color-danger)`. +4. Run contact-view tests. + +### Task 7: Complete editor-dialog accessibility and validation coverage + +**Files:** +- Modify: `src/features/contacts/ContactEditorDialog.tsx` +- Modify: `src/locales/en.json` +- Modify: `src/locales/zh.json` +- Modify: `tests/features/contacts/ContactEditorDialog.test.tsx` + +**Steps:** +1. Add failing tests for focus wrap/restore, duplicate email, zero/multiple primaries, name/notes limits, add/remove email, favorite toggle, and field-specific ARIA associations. +2. Add dialog focus capture/trap/restore and saving-aware Escape handling. +3. Return structured validation errors, set `aria-invalid`/`aria-describedby`, and align name/notes max lengths with Rust. +4. Run editor and locale parity tests. + +### Task 8: Preserve and document intended autocomplete/import behavior + +**Files:** +- Modify: `src/components/ContactAutocomplete.tsx` +- Modify: `tests/components/ContactAutocomplete.test.tsx` +- Modify: `README.md` +- Modify: `README.zh-CN.md` + +**Steps:** +1. Strengthen the Tab regression test to assert the event remains uncancelled and document why focus proceeds to the recent-suggestion removal action. +2. Add a concise code comment without changing the default Tab behavior. +3. Document that vCard re-import adds new addresses but preserves non-empty local fields and favorite state. +4. Run the autocomplete tests. + +### Task 9: Full verification, review, and integration + +**Files:** +- Verify all modified files. + +**Steps:** +1. Run `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `pnpm test -- --reporter=dot`, `pnpm build:frontend`, `cargo test --workspace --exclude pebble-oauth`, and `git diff --check`. +2. Inspect the final diff and confirm the PR worktree contains no unrelated changes. +3. Commit with `QingJ01 `, push `codex/issue-81-contacts`, and wait for PR checks. +4. Merge PR #89 into `master`, verify the PR reports merged, and report the resulting commit. diff --git a/src-tauri/src/commands/cloud_sync.rs b/src-tauri/src/commands/cloud_sync.rs index 4007aaad..1eca602a 100644 --- a/src-tauri/src/commands/cloud_sync.rs +++ b/src-tauri/src/commands/cloud_sync.rs @@ -13,8 +13,9 @@ use pebble_crypto::passphrase::{ decrypt_with_passphrase, encrypt_with_passphrase, PassphraseEncryptedBlob, }; use pebble_store::cloud_sync::{ - preview_backup, BackupPreview, BackupSecretSummary, RestoredAuthData, RestoredPrivateData, - RestoredSecureUserData, SettingsBackup, WebDavClient, SETTINGS_BACKUP_FILENAME, + preview_backup, serialize_backup, BackupPreview, BackupSecretSummary, RestoredAuthData, + RestoredPrivateData, RestoredSecureUserData, SettingsBackup, WebDavClient, + SETTINGS_BACKUP_FILENAME, }; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager, State}; @@ -201,8 +202,7 @@ fn build_backup_data( .map_err(|e| PebbleError::Internal(format!("Failed to build backup payload: {e}")))?; backup.kanban_context_notes = load_kanban_context_notes_for_state(state)?; attach_encrypted_secrets(state, &mut backup, secret_passphrase)?; - serde_json::to_vec_pretty(&backup) - .map_err(|e| PebbleError::Internal(format!("Failed to serialize backup payload: {e}"))) + serialize_backup(&backup) } fn restore_backup_data( diff --git a/src-tauri/src/commands/contacts.rs b/src-tauri/src/commands/contacts.rs index a0f47bc6..11b1fcff 100644 --- a/src-tauri/src/commands/contacts.rs +++ b/src-tauri/src/commands/contacts.rs @@ -1,7 +1,151 @@ use crate::state::AppState; -use pebble_core::{KnownContact, PebbleError}; +use pebble_core::{ + Contact, ContactInput, ContactSuggestion, KnownContact, PebbleError, VcardImportResult, +}; +use pebble_store::Store; +use std::sync::Arc; use tauri::State; +async fn run_store_blocking( + store: Arc, + operation: F, +) -> std::result::Result +where + F: FnOnce(&Store) -> std::result::Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(move || operation(&store)) + .await + .map_err(|error| PebbleError::Internal(format!("Task join error: {error}")))? +} + +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 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> { + let store = state.store.clone(); + run_store_blocking(store, move |store| { + store.list_contacts( + query.as_deref(), + favorite_only.unwrap_or(false), + limit.unwrap_or(50), + offset.unwrap_or(0), + ) + }) + .await +} + +#[tauri::command] +pub async fn get_contact_by_email( + state: State<'_, AppState>, + address: String, +) -> std::result::Result, PebbleError> { + let store = state.store.clone(); + run_store_blocking(store, move |store| store.get_contact_by_email(&address)).await +} + +#[tauri::command] +pub async fn save_contact( + state: State<'_, AppState>, + input: ContactInput, +) -> std::result::Result { + let store = state.store.clone(); + run_store_blocking(store, move |store| save_contact_with_store(store, &input)).await +} + +#[tauri::command] +pub async fn delete_contact( + state: State<'_, AppState>, + contact_id: String, + suppress_addresses: Option, +) -> std::result::Result<(), PebbleError> { + let store = state.store.clone(); + run_store_blocking(store, move |store| { + store.delete_contact( + validated_contact_id(&contact_id)?, + suppress_addresses.unwrap_or(false), + ) + }) + .await +} + +#[tauri::command] +pub async fn set_contact_favorite( + state: State<'_, AppState>, + contact_id: String, + is_favorite: bool, +) -> std::result::Result<(), PebbleError> { + let store = state.store.clone(); + run_store_blocking(store, move |store| { + store.set_contact_favorite(validated_contact_id(&contact_id)?, is_favorite) + }) + .await +} + +#[tauri::command] +pub async fn search_contact_suggestions( + state: State<'_, AppState>, + account_id: String, + query: String, + limit: Option, +) -> std::result::Result, PebbleError> { + let store = state.store.clone(); + run_store_blocking(store, move |store| { + store.search_contact_suggestions(&account_id, &query, limit.unwrap_or(20)) + }) + .await +} + +#[tauri::command] +pub async fn suppress_contact_suggestion( + state: State<'_, AppState>, + address: String, +) -> std::result::Result<(), PebbleError> { + let store = state.store.clone(); + run_store_blocking(store, move |store| { + store.suppress_contact_suggestion(&address) + }) + .await +} + +#[tauri::command] +pub async fn import_contacts_vcard( + state: State<'_, AppState>, + data: String, +) -> std::result::Result { + let store = state.store.clone(); + run_store_blocking(store, move |store| store.import_contacts_vcard(&data)).await +} + +#[tauri::command] +pub async fn export_contacts_vcard( + state: State<'_, AppState>, +) -> std::result::Result { + let store = state.store.clone(); + run_store_blocking(store, Store::export_contacts_vcard).await +} + #[tauri::command] pub async fn search_contacts( state: State<'_, AppState>, @@ -9,6 +153,66 @@ pub async fn search_contacts( query: String, limit: Option, ) -> std::result::Result, PebbleError> { - let limit = limit.unwrap_or(20); - state.store.list_known_contacts(&account_id, &query, limit) + let store = state.store.clone(); + run_store_blocking(store, move |store| { + store.list_known_contacts(&account_id, &query, limit.unwrap_or(20)) + }) + .await +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, thread}; + + use super::{run_store_blocking, 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_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(_)) + )); + } + + #[tokio::test] + async fn contact_store_work_runs_on_a_blocking_thread() { + let calling_thread = thread::current().id(); + let store = Arc::new(Store::open_in_memory().unwrap()); + + let worker_thread = run_store_blocking(store, |_| Ok(thread::current().id())) + .await + .unwrap(); + + assert_ne!(worker_thread, calling_thread); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c0e1166..740c9b71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -598,6 +598,15 @@ 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_by_email, + 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::import_contacts_vcard, + commands::contacts::export_contacts_vcard, commands::contacts::search_contacts, commands::advanced_search::advanced_search, commands::sync_cmd::reindex_search, diff --git a/src/app/Layout.tsx b/src/app/Layout.tsx index ad04acd4..f7f8df8c 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/ContactAddressAction.tsx b/src/components/ContactAddressAction.tsx new file mode 100644 index 00000000..2d4c1275 --- /dev/null +++ b/src/components/ContactAddressAction.tsx @@ -0,0 +1,89 @@ +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ContactRound, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { type ContactInput } from "@/lib/api"; +import { useAccountsQuery } from "@/hooks/queries"; +import { + contactByAddressQueryKey, + useContactByAddressQuery, +} from "@/hooks/queries/useContactsQuery"; +import { useContactMutations } from "@/hooks/mutations"; +import { useToastStore } from "@/stores/toast.store"; +import { useUIStore } from "@/stores/ui.store"; +import ContactEditorDialog from "@/features/contacts/ContactEditorDialog"; + +interface ContactAddressActionProps { + accountId: string; + name?: string | null; + address: string; +} + +export default function ContactAddressAction({ + accountId, + name, + address, +}: ContactAddressActionProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const normalizedAddress = address.trim().toLowerCase(); + const { data: accounts = [] } = useAccountsQuery(); + const { save } = useContactMutations(); + const [editorOpen, setEditorOpen] = useState(false); + const addToast = useToastStore((state) => state.addToast); + + const isSelf = useMemo(() => accounts.some((account) => ( + account.id === accountId + && account.email.trim().toLowerCase() === normalizedAddress + )), [accountId, accounts, normalizedAddress]); + const { data: contact = null, isLoading: loading } = useContactByAddressQuery( + normalizedAddress, + !isSelf, + ); + + if (isSelf || !normalizedAddress) return null; + + const contactName = contact?.display_name.trim() + || contact?.emails[0]?.address + || name?.trim() + || address; + const addLabel = `${t("contacts.addAddressPrefix", "Add")} ${address} ${t("contacts.addAddressSuffix", "to contacts")}`; + const viewLabel = `${t("contacts.viewPrefix", "View")} ${contactName} ${t("contacts.viewSuffix", "in contacts")}`; + + const handleSave = async (input: ContactInput) => { + const saved = await save.mutateAsync(input); + queryClient.setQueryData(contactByAddressQueryKey(normalizedAddress), saved); + setEditorOpen(false); + addToast({ message: t("contacts.saveSuccess", "Contact saved"), type: "success" }); + }; + + return ( + <> + + + {editorOpen && ( + setEditorOpen(false)} + onSave={handleSave} + /> + )} + + ); +} diff --git a/src/components/ContactAutocomplete.tsx b/src/components/ContactAutocomplete.tsx index 2b05e34f..7e96e68e 100644 --- a/src/components/ContactAutocomplete.tsx +++ b/src/components/ContactAutocomplete.tsx @@ -1,6 +1,13 @@ import { useState, useRef, useEffect, useCallback, useId } from "react"; import { useTranslation } from "react-i18next"; -import { searchContacts, type KnownContact } from "@/lib/api"; +import { X } from "lucide-react"; +import { + searchContactSuggestions, + suppressContactSuggestion, + type ContactSuggestion, +} from "@/lib/api"; +import { queryClient } from "@/lib/query-client"; +import { contactSuggestionsQueryRoot } from "@/hooks/queries"; import { useToastStore } from "@/stores/toast.store"; import { isValidEmailAddress } from "@/features/compose/recipient-utils"; @@ -33,7 +40,7 @@ export default function ContactAutocomplete({ const instanceId = useId(); const [uncontrolledInputValue, setUncontrolledInputValue] = useState(""); const inputValue = controlledInputValue ?? uncontrolledInputValue; - const [suggestions, setSuggestions] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const [loading, setLoading] = useState(false); @@ -72,11 +79,13 @@ export default function ContactAutocomplete({ } setLoading(true); try { - const results = await searchContacts(accountId, query, 10); + const results = await searchContactSuggestions(accountId, query, 10); if (!requestIsCurrent()) return; - // Filter out already-selected addresses + const selectedAddresses = new Set( + selectedAddressesRef.current.map((address) => address.trim().toLowerCase()), + ); const filtered = results.filter( - (c) => !selectedAddressesRef.current.includes(c.address), + (contact) => !selectedAddresses.has(contact.address.trim().toLowerCase()), ); setSuggestions(filtered); setShowDropdown(filtered.length > 0); @@ -103,8 +112,9 @@ export default function ContactAutocomplete({ }, 200); }; - const selectContact = (contact: KnownContact) => { - if (!value.includes(contact.address)) { + const selectContact = (contact: ContactSuggestion) => { + const normalized = contact.address.trim().toLowerCase(); + if (!value.some((address) => address.trim().toLowerCase() === normalized)) { onChange([...value, contact.address]); } setInputValue(""); @@ -118,7 +128,9 @@ export default function ContactAutocomplete({ const addRawAddress = (text: string) => { const trimmed = text.trim(); if (!trimmed) { setInputValue(""); return; } - if (isValidEmailAddress(trimmed) && !value.includes(trimmed)) { + if (isValidEmailAddress(trimmed) && !value.some( + (address) => address.trim().toLowerCase() === trimmed.toLowerCase(), + )) { onChange([...value, trimmed]); } else if (!isValidEmailAddress(trimmed)) { useToastStore.getState().addToast({ @@ -137,6 +149,32 @@ export default function ContactAutocomplete({ inputRef.current?.focus(); }; + const removeSuggestion = async ( + event: React.MouseEvent, + contact: ContactSuggestion, + ) => { + event.preventDefault(); + event.stopPropagation(); + try { + await suppressContactSuggestion(contact.address); + setSuggestions((current) => { + const next = current.filter((item) => ( + item.address.trim().toLowerCase() !== contact.address.trim().toLowerCase() + )); + if (next.length === 0) setShowDropdown(false); + return next; + }); + setActiveIndex(-1); + await queryClient.invalidateQueries({ queryKey: contactSuggestionsQueryRoot }); + inputRef.current?.focus(); + } catch { + useToastStore.getState().addToast({ + message: t("compose.removeSuggestionFailed", "Failed to remove suggestion"), + type: "error", + }); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); @@ -166,6 +204,17 @@ export default function ContactAutocomplete({ removeChip(value[value.length - 1]); } else if (e.key === "," || e.key === "Tab") { if (inputValue.trim()) { + const activeSuggestion = suggestions[activeIndex]; + const removableSuggestion = activeIndex >= 0 + ? (activeSuggestion?.source === "recent" ? activeSuggestion : undefined) + : suggestions.find((suggestion) => suggestion.source === "recent"); + if (e.key === "Tab" && showDropdown && removableSuggestion) { + // Keep the recent-only removal control from being converted into a chip, + // while deliberately leaving Tab uncancelled so focus advances normally. + setShowDropdown(false); + setActiveIndex(-1); + return; + } e.preventDefault(); addRawAddress(inputValue); } @@ -219,6 +268,10 @@ export default function ContactAutocomplete({ ); }; + const removableSuggestion = activeIndex >= 0 + ? (suggestions[activeIndex]?.source === "recent" ? suggestions[activeIndex] : undefined) + : suggestions.find((suggestion) => suggestion.source === "recent"); + return (
{addr} ))} @@ -304,8 +358,6 @@ export default function ContactAutocomplete({ {showDropdown && (
+
{loading ? (
selectContact(contact)} onMouseEnter={() => setActiveIndex(idx)} style={{ - padding: "6px 12px", + display: "flex", + alignItems: "center", + gap: "10px", + padding: "7px 9px 7px 12px", cursor: "pointer", backgroundColor: idx === activeIndex @@ -360,27 +419,71 @@ export default function ContactAutocomplete({ fontSize: "13px", }} > - {contact.name && ( +
+ {contact.name && ( +
+ {highlightMatch(contact.name, inputValue)} +
+ )}
- {highlightMatch(contact.name, inputValue)} + {highlightMatch(contact.address, inputValue)}
- )} -
+ - {highlightMatch(contact.address, inputValue)} -
+ {contact.source === "saved" + ? t("compose.savedContact", "Saved contact") + : t("compose.recentContact", "Recent")} +
)) )} +
+ {removableSuggestion && ( + + )}
)}
diff --git a/src/components/MessageDetail.tsx b/src/components/MessageDetail.tsx index 519e7aca..a17c73bf 100644 --- a/src/components/MessageDetail.tsx +++ b/src/components/MessageDetail.tsx @@ -19,6 +19,8 @@ import { useToastStore } from "@/stores/toast.store"; import { useUIStore } from "@/stores/ui.store"; import SelectionActionPopover from "./SelectionActionPopover"; import type { EmailAddress } from "@/lib/api"; +import ContactAddressAction from "./ContactAddressAction"; +import { uniqueContactParticipants } from "./contact-participants"; interface Props { messageId: string; @@ -229,6 +231,11 @@ export default function MessageDetail({ messageId, onBack, folderRole }: Props) const recipientLine = formatRecipients(message.to_list); const ccLine = formatRecipients(message.cc_list); + const contactParticipants = uniqueContactParticipants( + { name: message.from_name, address: message.from_address }, + message.to_list, + message.cc_list, + ); return (
)}
+
+ {contactParticipants.map((participant) => ( + + ))} +
{formatFullDate(message.date)}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 08221b29..e98b0d98 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 */}
)} +
+ {contactParticipants.map((participant) => ( + + ))} +
{/* Body content */} {rendered?.html ? ( diff --git a/src/components/contact-participants.ts b/src/components/contact-participants.ts new file mode 100644 index 00000000..43653f53 --- /dev/null +++ b/src/components/contact-participants.ts @@ -0,0 +1,21 @@ +export interface ContactParticipant { + name?: string | null; + address: string; +} + +export function uniqueContactParticipants( + sender: ContactParticipant, + to: readonly ContactParticipant[], + cc: readonly ContactParticipant[], +): ContactParticipant[] { + const seen = new Set(); + const participants: ContactParticipant[] = []; + for (const participant of [sender, ...to, ...cc]) { + const address = participant.address.trim(); + const normalizedAddress = address.toLowerCase(); + if (!normalizedAddress || seen.has(normalizedAddress)) continue; + seen.add(normalizedAddress); + participants.push({ name: participant.name, address }); + } + return participants; +} diff --git a/src/features/command-palette/commands.ts b/src/features/command-palette/commands.ts index 63be0a05..c9ad0d11 100644 --- a/src/features/command-palette/commands.ts +++ b/src/features/command-palette/commands.ts @@ -25,6 +25,12 @@ export function buildCommands(t: (key: string, defaultValue: string) => string): category: t("commands.navigation", "Navigation"), execute: () => useUIStore.getState().setActiveView("kanban"), }, + { + id: "nav:contacts", + name: t("commands.goToContacts", "Go to Contacts"), + category: t("commands.navigation", "Navigation"), + execute: () => useUIStore.getState().setActiveView("contacts"), + }, { id: "nav:settings", name: t("commands.goToSettings", "Go to Settings"), diff --git a/src/features/contacts/ContactEditorDialog.tsx b/src/features/contacts/ContactEditorDialog.tsx new file mode 100644 index 00000000..0d67000f --- /dev/null +++ b/src/features/contacts/ContactEditorDialog.tsx @@ -0,0 +1,388 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Plus, Trash2, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { + Contact, + ContactEmailInput, + ContactEmailLabel, + ContactInput, +} from "@/lib/api"; +import { extractErrorMessage } from "@/lib/extractErrorMessage"; +import { isValidEmailAddress } from "@/features/compose/recipient-utils"; +import { fieldGroupStyle, inputStyle, labelStyle } from "@/styles/form"; + +interface ContactEditorDialogProps { + contact: Contact | null; + initialValue?: { displayName?: string | null; address: string }; + onClose: () => void; + onSave: (input: ContactInput) => Promise; +} + +interface DraftEmail extends ContactEmailInput { + key: string; +} + +type ErrorField = "name" | "emails" | "notes" | "form"; + +interface FormError { + field: ErrorField; + message: string; +} + +let draftEmailId = 0; +const CONTACT_EDITOR_ERROR_ID = "contact-editor-error"; + +function getFocusableElements(container: HTMLElement | null): HTMLElement[] { + if (!container) return []; + return Array.from( + container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute("disabled")); +} + +function makeDraftEmail(email?: Contact["emails"][number], initialAddress = ""): DraftEmail { + return { + key: email?.id ?? `new-email-${++draftEmailId}`, + id: email?.id, + address: email?.address ?? initialAddress, + label: email?.label ?? "work", + is_primary: email?.is_primary ?? true, + }; +} + +export default function ContactEditorDialog({ + contact, + initialValue, + onClose, + onSave, +}: ContactEditorDialogProps) { + const { t } = useTranslation(); + const dialogRef = useRef(null); + const nameRef = useRef(null); + const onCloseRef = useRef(onClose); + const isSavingRef = useRef(false); + const [displayName, setDisplayName] = useState( + contact?.display_name ?? initialValue?.displayName ?? "", + ); + const [notes, setNotes] = useState(contact?.notes ?? ""); + const [isFavorite, setIsFavorite] = useState(contact?.is_favorite ?? false); + const [emails, setEmails] = useState( + contact?.emails.length + ? contact.emails.map((email) => makeDraftEmail(email)) + : [makeDraftEmail(undefined, initialValue?.address ?? "")], + ); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const title = contact + ? t("contacts.edit", "Edit contact") + : t("contacts.new", "New contact"); + + useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + useEffect(() => { isSavingRef.current = isSaving; }, [isSaving]); + + useEffect(() => { + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + nameRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (!isSavingRef.current) { + event.preventDefault(); + onCloseRef.current(); + } + return; + } + if (event.key !== "Tab") return; + + const focusable = getFocusableElements(dialogRef.current); + if (focusable.length === 0) return; + + const currentIndex = focusable.indexOf(document.activeElement as HTMLElement); + const nextIndex = event.shiftKey + ? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1) + : (currentIndex === focusable.length - 1 ? 0 : currentIndex + 1); + event.preventDefault(); + focusable[nextIndex]?.focus(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previousFocus?.focus(); + }; + }, []); + + const updateEmail = (key: string, patch: Partial) => { + setEmails((current) => current.map((email) => ( + email.key === key ? { ...email, ...patch } : email + ))); + }; + + const makePrimary = (key: string) => { + setEmails((current) => current.map((email) => ({ + ...email, + is_primary: email.key === key, + }))); + }; + + const addEmail = () => { + setEmails((current) => [ + ...current, + { ...makeDraftEmail(), is_primary: current.length === 0 }, + ]); + }; + + const removeEmail = (key: string) => { + setEmails((current) => { + const next = current.filter((email) => email.key !== key); + if (next.length > 0 && !next.some((email) => email.is_primary)) { + next[0] = { ...next[0], is_primary: true }; + } + return next; + }); + }; + + const validate = (): FormError | null => { + if (displayName.trim().length > 512) { + return { + field: "name", + message: t("contacts.nameTooLong", "Name must be 512 characters or fewer"), + }; + } + if (emails.length === 0) { + return { + field: "emails", + message: t("contacts.emailRequired", "Add at least one email address"), + }; + } + if (emails.some((email) => !isValidEmailAddress(email.address))) { + return { + field: "emails", + message: t("contacts.invalidEmail", "Enter a valid email address"), + }; + } + if (emails.filter((email) => email.is_primary).length !== 1) { + return { + field: "emails", + message: t("contacts.primaryRequired", "Choose exactly one primary email"), + }; + } + const normalized = emails.map((email) => email.address.trim().toLowerCase()); + if (new Set(normalized).size !== normalized.length) { + return { + field: "emails", + message: t("contacts.duplicateEmail", "Each email address can only be added once"), + }; + } + if (notes.trim().length > 2000) { + return { + field: "notes", + message: t("contacts.notesTooLong", "Notes must be 2000 characters or fewer"), + }; + } + return null; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + + setError(null); + setIsSaving(true); + try { + await onSave({ + ...(contact ? { id: contact.id } : {}), + display_name: displayName.trim(), + notes: notes.trim(), + is_favorite: isFavorite, + emails: emails.map(({ id, address, label, is_primary }) => ({ + id, + address: address.trim(), + label, + is_primary, + })), + }); + } catch (saveError) { + setError({ field: "form", message: extractErrorMessage(saveError) }); + } finally { + setIsSaving(false); + } + }; + + return ( +
{ + if (event.target === event.currentTarget && !isSaving) onClose(); + }}> +
+
+
+ {t("contacts.title", "Contacts")} +

{title}

+
+ +
+ +
+
+
+ + setDisplayName(event.target.value)} + autoComplete="name" + /> +
+ +
+ {t("contacts.emailAddress", "Email address")} +
+ {emails.map((email, index) => { + const addressId = `contact-email-${email.key}`; + const labelId = `contact-email-label-${email.key}`; + return ( +
+
+
+ + updateEmail(email.key, { address: event.target.value })} + autoComplete="email" + /> +
+
+ + +
+
+
+ + +
+
+ ); + })} +
+ +
+ +
+ +