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
diff --git a/src/hooks/mutations/index.ts b/src/hooks/mutations/index.ts
index fb7c30bf..0c73af6d 100644
--- a/src/hooks/mutations/index.ts
+++ b/src/hooks/mutations/index.ts
@@ -1,3 +1,4 @@
export { useSendEmailMutation } from "./useSendEmailMutation";
export { useUpdateFlagsMutation } from "./useUpdateFlagsMutation";
export { useSyncMutation } from "./useSyncMutation";
+export { useContactMutations } from "./useContactMutations";
diff --git a/src/hooks/mutations/useContactMutations.ts b/src/hooks/mutations/useContactMutations.ts
new file mode 100644
index 00000000..19c8fdec
--- /dev/null
+++ b/src/hooks/mutations/useContactMutations.ts
@@ -0,0 +1,54 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ deleteContact,
+ saveContact,
+ setContactFavorite,
+ type ContactInput,
+} from "@/lib/api";
+import { contactsQueryRoot, contactSuggestionsQueryRoot } from "@/hooks/queries/useContactsQuery";
+
+async function invalidateContactCaches(queryClient: ReturnType) {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: contactsQueryRoot }),
+ queryClient.invalidateQueries({ queryKey: contactSuggestionsQueryRoot }),
+ ]);
+}
+
+export function useContactMutations() {
+ const queryClient = useQueryClient();
+
+ const save = useMutation({
+ mutationFn: (input: ContactInput) => saveContact(input),
+ onSuccess: async () => {
+ await invalidateContactCaches(queryClient);
+ },
+ });
+
+ const remove = useMutation({
+ mutationFn: ({
+ contactId,
+ suppressAddresses = false,
+ }: {
+ contactId: string;
+ suppressAddresses?: boolean;
+ }) => deleteContact(contactId, suppressAddresses),
+ onSuccess: async () => {
+ await invalidateContactCaches(queryClient);
+ },
+ });
+
+ const setFavorite = useMutation({
+ mutationFn: ({
+ contactId,
+ isFavorite,
+ }: {
+ contactId: string;
+ isFavorite: boolean;
+ }) => setContactFavorite(contactId, isFavorite),
+ onSuccess: async () => {
+ await invalidateContactCaches(queryClient);
+ },
+ });
+
+ return { save, remove, setFavorite };
+}
diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts
index 8226f770..adc1bfb8 100644
--- a/src/hooks/queries/index.ts
+++ b/src/hooks/queries/index.ts
@@ -28,3 +28,12 @@ export {
usePendingMailOpsQuery,
pendingMailOpsQueryKey,
} from "./usePendingMailOpsQuery";
+export {
+ useContactsQuery,
+ useContactByAddressQuery,
+ contactsQueryKey,
+ contactByAddressQueryKey,
+ contactSuggestionsQueryKey,
+ contactsQueryRoot,
+ contactSuggestionsQueryRoot,
+} from "./useContactsQuery";
diff --git a/src/hooks/queries/useContactsQuery.ts b/src/hooks/queries/useContactsQuery.ts
new file mode 100644
index 00000000..b2dc6ac4
--- /dev/null
+++ b/src/hooks/queries/useContactsQuery.ts
@@ -0,0 +1,80 @@
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { getContactByEmail, listContacts, type Contact } from "@/lib/api";
+
+export const contactsQueryRoot = ["contacts"] as const;
+export const contactSuggestionsQueryRoot = ["contact-suggestions"] as const;
+
+export interface ContactsQueryOptions {
+ query: string;
+ favoriteOnly: boolean;
+ limit: number;
+ offset: number;
+}
+
+export const contactsQueryKey = ({
+ query,
+ favoriteOnly,
+ limit,
+ offset,
+}: ContactsQueryOptions) => ["contacts", query, favoriteOnly, limit, offset] as const;
+
+export const contactByAddressQueryKey = (address: string) => [
+ ...contactsQueryRoot,
+ "by-address",
+ address.trim().toLowerCase(),
+] as const;
+
+export const contactSuggestionsQueryKey = (accountId: string, query: string) =>
+ ["contact-suggestions", accountId, query] as const;
+
+const CONTACT_PAGE_SIZE = 200;
+
+async function listContactPages(options: ContactsQueryOptions): Promise {
+ const requestedLimit = Math.max(1, options.limit);
+ const contacts: Contact[] = [];
+ let offset = Math.max(0, options.offset);
+
+ while (contacts.length < requestedLimit) {
+ const pageLimit = Math.min(CONTACT_PAGE_SIZE, requestedLimit - contacts.length);
+ const page = await listContacts(
+ options.query,
+ options.favoriteOnly,
+ pageLimit,
+ offset,
+ );
+ contacts.push(...page);
+ if (page.length < pageLimit) break;
+ offset += page.length;
+ }
+
+ return contacts;
+}
+
+export function useContactsQuery(options: ContactsQueryOptions) {
+ const [debouncedQuery, setDebouncedQuery] = useState(options.query);
+
+ useEffect(() => {
+ if (options.query === debouncedQuery) return;
+ const timer = window.setTimeout(() => setDebouncedQuery(options.query), 200);
+ return () => window.clearTimeout(timer);
+ }, [debouncedQuery, options.query]);
+
+ const debouncedOptions = { ...options, query: debouncedQuery };
+ return useQuery({
+ queryKey: contactsQueryKey(debouncedOptions),
+ queryFn: () => listContactPages(debouncedOptions),
+ placeholderData: keepPreviousData,
+ staleTime: 30_000,
+ });
+}
+
+export function useContactByAddressQuery(address: string, enabled = true) {
+ const normalizedAddress = address.trim().toLowerCase();
+ return useQuery({
+ queryKey: contactByAddressQueryKey(normalizedAddress),
+ queryFn: () => getContactByEmail(normalizedAddress),
+ enabled: enabled && normalizedAddress.length > 0,
+ staleTime: 5 * 60_000,
+ });
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 464cabc1..9cc6a503 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -11,6 +11,13 @@ export type {
Attachment,
BackupPreview,
ConnectionSecurity,
+ Contact,
+ ContactEmail,
+ ContactEmailInput,
+ ContactEmailLabel,
+ ContactInput,
+ ContactSuggestion,
+ ContactSuggestionSource,
EmailAddress,
Folder,
HttpProxyConfig,
@@ -34,6 +41,7 @@ export type {
TranslateConfig,
TranslateResult,
TrustedSender,
+ VcardImportResult,
} from "./ipc-types";
import type {
@@ -46,6 +54,9 @@ import type {
Attachment,
BackupPreview,
ConnectionSecurity,
+ Contact,
+ ContactInput,
+ ContactSuggestion,
Folder,
HttpProxyConfig,
ImapSyncFolderSettings,
@@ -67,6 +78,7 @@ import type {
TranslateConfig,
TranslateResult,
TrustedSender,
+ VcardImportResult,
} from "./ipc-types";
// ─── Account API ─────────────────────────────────────────────────────────────
@@ -698,6 +710,57 @@ export async function searchContacts(
return invoke("search_contacts", { accountId, query, limit });
}
+export async function listContacts(
+ query?: string,
+ favoriteOnly = false,
+ limit = 50,
+ offset = 0,
+): Promise {
+ return invoke("list_contacts", { query, favoriteOnly, limit, offset });
+}
+
+export async function getContactByEmail(address: string): Promise {
+ return invoke("get_contact_by_email", { address });
+}
+
+export async function saveContact(input: ContactInput): Promise {
+ return invoke("save_contact", { input });
+}
+
+export async function deleteContact(
+ contactId: string,
+ suppressAddresses = false,
+): Promise {
+ return invoke("delete_contact", { contactId, suppressAddresses });
+}
+
+export async function setContactFavorite(
+ contactId: string,
+ isFavorite: boolean,
+): Promise {
+ return invoke("set_contact_favorite", { contactId, isFavorite });
+}
+
+export async function searchContactSuggestions(
+ accountId: string,
+ query: string,
+ limit = 20,
+): Promise {
+ return invoke("search_contact_suggestions", { accountId, query, limit });
+}
+
+export async function suppressContactSuggestion(address: string): Promise {
+ return invoke("suppress_contact_suggestion", { address });
+}
+
+export async function importContactsVcard(data: string): Promise {
+ return invoke("import_contacts_vcard", { data });
+}
+
+export async function exportContactsVcard(): Promise {
+ return invoke("export_contacts_vcard");
+}
+
// ─── Drafts API ──────────────────────────────────────────────────────────────
export async function saveDraft(args: {
diff --git a/src/lib/ipc-types.ts b/src/lib/ipc-types.ts
index c7908ded..0a820c92 100644
--- a/src/lib/ipc-types.ts
+++ b/src/lib/ipc-types.ts
@@ -330,6 +330,7 @@ export interface BackupPreview {
rule_count: number;
kanban_card_count: number;
kanban_note_count: number;
+ contact_count: number;
has_translate_config: boolean;
has_encrypted_secrets: boolean;
secret_account_count: number;
@@ -339,6 +340,67 @@ export interface BackupPreview {
// ─── Contacts types ─────────────────────────────────────────────────────────────
+/** @rust pebble-core/src/types.rs → ContactEmailLabel */
+export type ContactEmailLabel = "work" | "personal" | "other";
+
+/** @rust pebble-core/src/types.rs → ContactEmail */
+export interface ContactEmail {
+ id: string;
+ address: string;
+ label: ContactEmailLabel;
+ is_primary: boolean;
+}
+
+/** @rust pebble-core/src/types.rs → Contact */
+export interface Contact {
+ id: string;
+ display_name: string;
+ notes: string;
+ is_favorite: boolean;
+ emails: ContactEmail[];
+ created_at: number;
+ updated_at: number;
+}
+
+/** @rust pebble-core/src/types.rs → ContactEmailInput */
+export interface ContactEmailInput {
+ id?: string | null;
+ address: string;
+ label: ContactEmailLabel;
+ is_primary: boolean;
+}
+
+/** @rust pebble-core/src/types.rs → ContactInput */
+export interface ContactInput {
+ id?: string | null;
+ display_name: string;
+ notes: string;
+ is_favorite: boolean;
+ emails: ContactEmailInput[];
+}
+
+/** @rust pebble-core/src/types.rs → ContactSuggestionSource */
+export type ContactSuggestionSource = "saved" | "recent";
+
+/** @rust pebble-core/src/types.rs → ContactSuggestion */
+export interface ContactSuggestion {
+ contact_id: string | null;
+ name: string | null;
+ address: string;
+ source: ContactSuggestionSource;
+ is_favorite: boolean;
+ last_interaction_at: number | null;
+}
+
+/** @rust pebble-core/src/types.rs → VcardImportResult */
+export interface VcardImportResult {
+ created: number;
+ merged: number;
+ skipped: number;
+ invalid: number;
+ errors: string[];
+}
+
/** @rust pebble-core/src/types.rs → KnownContact */
export interface KnownContact {
name: string | null;
diff --git a/src/locales/en.json b/src/locales/en.json
index 5f775c05..309036a3 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -40,6 +40,7 @@
"archive": "Archive",
"spam": "Spam",
"kanban": "Kanban Board",
+ "contacts": "Contacts",
"settings": "Settings",
"mail": "Mail",
"compose": "Compose",
@@ -73,6 +74,10 @@
"discard": "Discard",
"contactSuggestions": "Contact suggestions",
"noContactsFound": "No contacts found",
+ "savedContact": "Saved contact",
+ "recentContact": "Recent",
+ "removeSuggestion": "Remove suggestion",
+ "removeSuggestionFailed": "Failed to remove suggestion",
"bcc": "Bcc",
"sending": "Sending...",
"back": "Back",
@@ -235,7 +240,7 @@
},
"cloudSync": {
"title": "Settings Backup",
- "description": "Back up rules, Kanban cards and notes, and account metadata to WebDAV. Account passwords, OAuth tokens, and API keys can be included with a separate backup encryption password.",
+ "description": "Back up contacts, rules, Kanban cards and notes, and account metadata to WebDAV. Account passwords, OAuth tokens, and API keys can be included with a separate backup encryption password.",
"webdavUrl": "WebDAV URL",
"username": "Username",
"password": "Password",
@@ -251,7 +256,7 @@
"backupSuccess": "Settings backup completed successfully",
"restoreSuccess": "Backup restored. Reconnect email accounts and translation providers to continue.",
"restoreSuccessWithSecrets": "Backup restored with account passwords, OAuth tokens, and API keys.",
- "restoreConfirm": "This will replace local rules and Kanban cards/notes, merge account metadata, and restore encrypted secrets when present. Continue?",
+ "restoreConfirm": "This will replace local rules, Kanban cards/notes, and contacts; merge account metadata; and restore encrypted secrets when present. Continue?",
"backupFailed": "Backup failed: {{error}}",
"restoreFailed": "Restore failed: {{error}}",
"exportSuccess": "Backup file exported",
@@ -265,10 +270,11 @@
"restorePreviewRules": "Rules: {{count}}",
"restorePreviewKanban": "Kanban cards: {{count}}",
"restorePreviewKanbanNotes": "Kanban notes: {{count}}",
+ "restorePreviewContacts": "Contacts: {{count}}",
"restorePreviewEncryptedSecrets": "Encrypted account secrets: {{count}}",
"restorePreviewTranslateSecret": "Encrypted translation API keys: included",
"restorePreviewSize": "Size: {{kb}} KB",
- "scopeNotice": "WebDAV backup includes settings, rules, Kanban cards, and Kanban notes. Optional encrypted secrets include account passwords, OAuth tokens, and translation API keys. Message bodies and attachments are not included unless you saved text into a Kanban note.",
+ "scopeNotice": "WebDAV backup includes contacts, settings, rules, Kanban cards, and Kanban notes. Optional encrypted secrets include account passwords, OAuth tokens, and translation API keys. Message bodies and attachments are not included unless you saved text into a Kanban note.",
"includeSecrets": "Include account passwords, OAuth tokens, and API keys",
"includeSecretsDesc": "Secrets are encrypted with the password below before upload. You will need the same password to restore them on another device.",
"secretPassphrase": "Backup encryption password",
@@ -519,6 +525,68 @@
"tlsCertificateVerification": "TLS certificate verification",
"verifyTlsCerts": "Verify certificates"
},
+ "contacts": {
+ "title": "Contacts",
+ "new": "New contact",
+ "edit": "Edit contact",
+ "empty": "No contacts yet",
+ "emptyHint": "Save the people you email most for faster addressing.",
+ "selectPrompt": "Select a contact to see details",
+ "search": "Search contacts",
+ "favoritesOnly": "Favorites only",
+ "name": "Name",
+ "emailAddress": "Email address",
+ "emailLabel": "Email label",
+ "work": "Work",
+ "personal": "Personal",
+ "other": "Other",
+ "primary": "Primary",
+ "setPrimary": "Set as primary",
+ "favorite": "Favorite contact",
+ "addEmail": "Add email",
+ "removeEmail": "Remove email",
+ "notes": "Notes",
+ "save": "Save contact",
+ "write": "Write email",
+ "editAction": "Edit contact",
+ "delete": "Delete contact",
+ "deleteConfirm": "Delete this contact and hide its addresses from recent suggestions?",
+ "deleteSuccess": "Contact deleted",
+ "saveSuccess": "Contact saved",
+ "favoriteAdd": "Add to favorites",
+ "favoriteRemove": "Remove from favorites",
+ "copy": "Copy email address",
+ "copySuccess": "Email address copied",
+ "addAddressPrefix": "Add",
+ "addAddressSuffix": "to contacts",
+ "viewPrefix": "View",
+ "viewSuffix": "in contacts",
+ "participantActions": "Contact actions",
+ "loadError": "Failed to load contacts",
+ "retry": "Retry",
+ "invalidEmail": "Enter a valid email address",
+ "emailRequired": "Add at least one email address",
+ "primaryRequired": "Choose exactly one primary email",
+ "duplicateEmail": "Each email address can only be added once",
+ "nameTooLong": "Name must be 512 characters or fewer",
+ "notesTooLong": "Notes must be 2000 characters or fewer",
+ "back": "Back to contacts",
+ "count": "{{count}} contacts",
+ "chooseVcard": "Choose vCard file",
+ "importVcard": "Import vCard",
+ "exportVcard": "Export vCard",
+ "importingVcard": "Importing…",
+ "exportingVcard": "Exporting…",
+ "vcardFileTooLarge": "vCard files must be 5 MB or smaller",
+ "importSuccess": "vCard import complete",
+ "exportSuccess": "Contacts exported",
+ "importSummary": "Import summary",
+ "importCreated": "created",
+ "importMerged": "merged",
+ "importSkipped": "skipped",
+ "importInvalid": "invalid",
+ "closeImportSummary": "Close import summary"
+ },
"commands": {
"navigation": "Navigation",
"view": "View",
@@ -526,6 +594,7 @@
"settings": "Settings",
"goToInbox": "Go to Inbox",
"goToKanban": "Go to Kanban",
+ "goToContacts": "Go to Contacts",
"goToSettings": "Go to Settings",
"openSearch": "Open Search",
"toggleSidebar": "Toggle Sidebar",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index cbaeb9a9..b549fb81 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -40,6 +40,7 @@
"archive": "归档",
"spam": "垃圾邮件",
"kanban": "看板",
+ "contacts": "联系人",
"settings": "设置",
"mail": "邮件",
"compose": "撰写",
@@ -73,6 +74,10 @@
"discard": "丢弃",
"contactSuggestions": "联系人建议",
"noContactsFound": "未找到联系人",
+ "savedContact": "已保存联系人",
+ "recentContact": "最近往来",
+ "removeSuggestion": "移除此建议",
+ "removeSuggestionFailed": "移除建议失败",
"bcc": "密送",
"sending": "发送中...",
"back": "返回",
@@ -235,7 +240,7 @@
},
"cloudSync": {
"title": "设置备份",
- "description": "将规则、看板卡片和备注、账户元数据备份到 WebDAV。邮箱密码、OAuth tokens 和 API keys 可使用单独的备份加密密码一并备份。",
+ "description": "将联系人、规则、看板卡片和备注、账户元数据备份到 WebDAV。邮箱密码、OAuth tokens 和 API keys 可使用单独的备份加密密码一并备份。",
"webdavUrl": "WebDAV 地址",
"username": "用户名",
"password": "密码",
@@ -251,7 +256,7 @@
"backupSuccess": "设置备份已完成",
"restoreSuccess": "备份已恢复,请重新连接邮箱账户和翻译提供方后再继续使用。",
"restoreSuccessWithSecrets": "备份已恢复,并已恢复邮箱密码、OAuth tokens 和 API keys。",
- "restoreConfirm": "这会用备份替换本地的规则和看板卡片/备注,合并账户元数据,并在存在时恢复加密 secrets。是否继续?",
+ "restoreConfirm": "这会用备份替换本地的规则、看板卡片/备注和联系人,合并账户元数据,并在存在时恢复加密 secrets。是否继续?",
"backupFailed": "备份失败:{{error}}",
"restoreFailed": "恢复失败:{{error}}",
"exportSuccess": "备份文件已导出",
@@ -265,10 +270,11 @@
"restorePreviewRules": "规则:{{count}}",
"restorePreviewKanban": "看板卡片:{{count}}",
"restorePreviewKanbanNotes": "看板备注:{{count}}",
+ "restorePreviewContacts": "联系人:{{count}}",
"restorePreviewEncryptedSecrets": "加密账户 secrets:{{count}}",
"restorePreviewTranslateSecret": "加密翻译 API keys:已包含",
"restorePreviewSize": "大小:{{kb}} KB",
- "scopeNotice": "WebDAV 备份包含设置、规则、看板卡片和看板备注。可选加密 secrets 包含邮箱密码、OAuth tokens 和翻译 API keys。不包含邮件正文和附件,除非你把正文文本保存到了看板备注中。",
+ "scopeNotice": "WebDAV 备份包含联系人、设置、规则、看板卡片和看板备注。可选加密 secrets 包含邮箱密码、OAuth tokens 和翻译 API keys。不包含邮件正文和附件,除非你把正文文本保存到了看板备注中。",
"includeSecrets": "包含邮箱密码、OAuth tokens 和 API keys",
"includeSecretsDesc": "Secrets 会先用下方密码加密再上传。你需要用同一个密码在其他设备上恢复。",
"secretPassphrase": "备份加密密码",
@@ -519,6 +525,68 @@
"tlsCertificateVerification": "TLS 证书验证",
"verifyTlsCerts": "验证证书"
},
+ "contacts": {
+ "title": "联系人",
+ "new": "新建联系人",
+ "edit": "编辑联系人",
+ "empty": "还没有联系人",
+ "emptyHint": "保存常用联系人,写邮件时可以更快填写收件人。",
+ "selectPrompt": "选择一个联系人查看详情",
+ "search": "搜索联系人",
+ "favoritesOnly": "仅显示收藏",
+ "name": "姓名",
+ "emailAddress": "邮箱地址",
+ "emailLabel": "邮箱标签",
+ "work": "工作",
+ "personal": "个人",
+ "other": "其他",
+ "primary": "主要邮箱",
+ "setPrimary": "设为主要邮箱",
+ "favorite": "收藏联系人",
+ "addEmail": "添加邮箱",
+ "removeEmail": "移除邮箱",
+ "notes": "备注",
+ "save": "保存联系人",
+ "write": "写邮件",
+ "editAction": "编辑联系人",
+ "delete": "删除联系人",
+ "deleteConfirm": "删除此联系人,并从最近使用的地址建议中隐藏其邮箱?",
+ "deleteSuccess": "联系人已删除",
+ "saveSuccess": "联系人已保存",
+ "favoriteAdd": "添加到收藏",
+ "favoriteRemove": "取消收藏",
+ "copy": "复制邮箱地址",
+ "copySuccess": "邮箱地址已复制",
+ "addAddressPrefix": "添加",
+ "addAddressSuffix": "到联系人",
+ "viewPrefix": "查看",
+ "viewSuffix": "联系人",
+ "participantActions": "联系人操作",
+ "loadError": "无法加载联系人",
+ "retry": "重试",
+ "invalidEmail": "请输入有效的邮箱地址",
+ "emailRequired": "请至少添加一个邮箱地址",
+ "primaryRequired": "请选择且仅选择一个主要邮箱",
+ "duplicateEmail": "同一个邮箱地址只能添加一次",
+ "nameTooLong": "姓名不能超过 512 个字符",
+ "notesTooLong": "备注不能超过 2000 个字符",
+ "back": "返回联系人列表",
+ "count": "{{count}} 位联系人",
+ "chooseVcard": "选择 vCard 文件",
+ "importVcard": "导入 vCard",
+ "exportVcard": "导出 vCard",
+ "importingVcard": "正在导入…",
+ "exportingVcard": "正在导出…",
+ "vcardFileTooLarge": "vCard 文件不能超过 5 MB",
+ "importSuccess": "vCard 导入完成",
+ "exportSuccess": "联系人已导出",
+ "importSummary": "导入摘要",
+ "importCreated": "个已创建",
+ "importMerged": "个已合并",
+ "importSkipped": "个已跳过",
+ "importInvalid": "个无效",
+ "closeImportSummary": "关闭导入摘要"
+ },
"commands": {
"navigation": "导航",
"view": "视图",
@@ -526,6 +594,7 @@
"settings": "设置",
"goToInbox": "前往收件箱",
"goToKanban": "前往看板",
+ "goToContacts": "前往联系人",
"goToSettings": "前往设置",
"openSearch": "打开搜索",
"toggleSidebar": "切换侧边栏",
diff --git a/src/stores/ui.store.ts b/src/stores/ui.store.ts
index 709e2f3a..3ef38813 100644
--- a/src/stores/ui.store.ts
+++ b/src/stores/ui.store.ts
@@ -6,7 +6,7 @@ import { readStartHiddenToTrayPreference, START_HIDDEN_TO_TRAY_KEY } from "@/lib
import { useComposeStore } from "./compose.store";
import { useMailStore } from "./mail.store";
-export type ActiveView = "inbox" | "kanban" | "settings" | "search" | "snoozed" | "starred" | "compose";
+export type ActiveView = "inbox" | "kanban" | "contacts" | "settings" | "search" | "snoozed" | "starred" | "compose";
export type SettingsTab = "accounts" | "general" | "proxy" | "appearance" | "privacy" | "rules" | "remoteWrites" | "translation" | "shortcuts" | "cloudSync" | "about";
export type Theme = "light" | "dark" | "system";
export type { Language } from "@/lib/language";
@@ -147,8 +147,11 @@ interface UIState {
startHiddenToTray: boolean;
setStartHiddenToTray: (enabled: boolean) => void;
previousView: ActiveView;
+ pendingContactId: string | null;
toggleSidebar: () => void;
setActiveView: (view: ActiveView) => void;
+ openContactInContacts: (contactId: string) => void;
+ clearPendingContact: () => void;
openMessageInInbox: (messageId: string) => void;
setTheme: (theme: Theme) => void;
setBackgroundImage: (image: { path: string; filename: string }) => void;
@@ -200,6 +203,7 @@ export const useUIStore = create((set) => ({
set({ startHiddenToTray: enabled });
},
previousView: "inbox",
+ pendingContactId: null,
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setActiveView: (view) => {
@@ -227,6 +231,11 @@ export const useUIStore = create((set) => ({
set({ activeView: view });
},
+ openContactInContacts: (contactId) => set({
+ activeView: "contacts",
+ pendingContactId: contactId,
+ }),
+ clearPendingContact: () => set({ pendingContactId: null }),
openMessageInInbox: (messageId) => {
useMailStore.setState({
selectedMessageId: messageId,
diff --git a/src/styles/index.css b/src/styles/index.css
index 2f679915..00c9c0f4 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -1048,3 +1048,745 @@ textarea:focus-visible {
width: 100%;
}
}
+
+/* Contacts */
+.contacts-view {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ background: color-mix(in srgb, var(--color-main-bg) 96%, var(--color-sidebar-bg));
+}
+
+.contacts-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 22px 28px 16px;
+}
+
+.contacts-header-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.contacts-eyebrow,
+.contact-dialog-kicker {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--color-text-secondary);
+ font-size: 10px;
+ font-weight: 650;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.contacts-title-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+.contacts-title-row h1,
+.contact-dialog-header h2,
+.contact-detail h2,
+.contacts-empty-state h2 {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-weight: 650;
+ letter-spacing: -0.025em;
+}
+
+.contacts-title-row h1 {
+ font-size: 25px;
+ line-height: 1.15;
+}
+
+.contacts-count {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 22px;
+ height: 20px;
+ padding: 0 6px;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+
+.contacts-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 28px 16px;
+}
+
+.contacts-import-summary {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin: 0 28px 16px;
+ padding: 12px 14px;
+ border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
+ border-radius: 7px;
+ background: color-mix(in srgb, var(--color-accent) 6%, var(--color-bg));
+ color: var(--color-text-primary);
+ font-size: 12px;
+}
+
+.contacts-import-summary strong {
+ font-size: 12px;
+}
+
+.contacts-import-summary p {
+ margin: 4px 0 0;
+ color: var(--color-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.contacts-import-summary ul {
+ margin: 8px 0 0;
+ padding-left: 18px;
+ color: var(--color-danger);
+}
+
+.contacts-search-shell {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ max-width: 520px;
+ height: 36px;
+ padding: 0 11px;
+ border: 1px solid var(--color-border);
+ border-radius: 7px;
+ background: var(--color-bg);
+ color: var(--color-text-secondary);
+ transition: border-color 0.14s ease, box-shadow 0.14s ease;
+}
+
+.contacts-search-shell:focus-within {
+ border-color: color-mix(in srgb, var(--color-accent) 52%, var(--color-border));
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 12%, transparent);
+}
+
+.contacts-search-shell input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ padding: 0 0 0 8px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--color-text-primary);
+ font: inherit;
+ font-size: 13px;
+}
+
+.contacts-search-shell input::placeholder {
+ color: var(--color-text-secondary);
+}
+
+.contacts-favorite-filter {
+ display: inline-flex;
+ align-items: center;
+ height: 36px;
+ gap: 7px;
+ box-sizing: border-box;
+ padding: 0 11px;
+ border: 1px solid var(--color-border);
+ border-radius: 7px;
+ background: var(--color-bg);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.contacts-favorite-filter input,
+.contact-favorite-control input,
+.contact-primary-control input {
+ margin: 0;
+ accent-color: var(--color-accent);
+}
+
+.contacts-shell {
+ display: grid;
+ grid-template-columns: minmax(270px, 340px) minmax(0, 1fr);
+ flex: 1;
+ min-height: 0;
+ margin: 0 28px 24px;
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+ border-radius: 9px;
+ background: var(--color-bg);
+ box-shadow: 0 10px 32px rgba(26, 26, 26, 0.04);
+}
+
+.contacts-list-pane {
+ min-width: 0;
+ overflow: auto;
+ border-right: 1px solid var(--color-border);
+ background: color-mix(in srgb, var(--color-sidebar-bg) 58%, var(--color-bg));
+}
+
+.contact-list {
+ padding: 0;
+}
+
+.contact-list-item {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) 18px;
+ align-items: center;
+ width: 100%;
+ gap: 10px;
+ padding: 9px 10px;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--color-text-primary);
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ transition: background-color 0.14s ease, border-color 0.14s ease;
+}
+
+.contact-list-item:hover {
+ background: var(--color-bg-hover);
+}
+
+.contact-list-item--selected {
+ border-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-border));
+ background: color-mix(in srgb, var(--color-accent) 9%, var(--color-bg));
+}
+
+.contact-avatar,
+.contact-detail-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--color-accent) 11%, var(--color-bg));
+ color: var(--color-accent);
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.contact-avatar {
+ width: 32px;
+ height: 32px;
+ font-size: 11px;
+}
+
+.contact-list-copy {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.contact-list-name,
+.contact-list-email {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.contact-list-name {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.contact-list-email {
+ color: var(--color-text-secondary);
+ font-size: 11px;
+}
+
+.contact-list-star {
+ color: var(--color-accent);
+}
+
+.contacts-detail-pane {
+ min-width: 0;
+ overflow: auto;
+}
+
+.contacts-state,
+.contacts-empty-state,
+.contacts-selection-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100%;
+ box-sizing: border-box;
+ padding: 36px 24px;
+ color: var(--color-text-secondary);
+ font-size: 13px;
+ text-align: center;
+}
+
+.contacts-state--error,
+.contacts-empty-state,
+.contacts-selection-state {
+ flex-direction: column;
+ gap: 10px;
+}
+
+.contacts-state--error p,
+.contacts-empty-state p,
+.contacts-selection-state p {
+ max-width: 290px;
+ margin: 0;
+ line-height: 1.55;
+}
+
+.contacts-empty-state h2 {
+ font-size: 16px;
+}
+
+.contacts-empty-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ margin-bottom: 2px;
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ background: var(--color-bg-secondary);
+ color: var(--color-accent);
+}
+
+.contact-detail {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 34px 40px 46px;
+}
+
+.contact-detail-heading {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.contact-detail-avatar {
+ width: 48px;
+ height: 48px;
+ font-size: 15px;
+}
+
+.contact-detail-name-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.contact-detail-name-row svg {
+ color: var(--color-accent);
+}
+
+.contact-detail h2 {
+ font-size: 21px;
+}
+
+.contact-detail-heading p {
+ margin: 4px 0 0;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+}
+
+.contact-detail-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 24px 0 30px;
+}
+
+.contact-detail-section {
+ padding: 20px 0;
+ border-top: 1px solid var(--color-border);
+}
+
+.contact-detail-section h3 {
+ margin: 0 0 12px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+ font-weight: 650;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.contact-address-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.contact-address-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 48px;
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
+}
+
+.contact-address-row:last-child {
+ border-bottom: 0;
+}
+
+.contact-address-value,
+.contact-address-meta {
+ display: block;
+}
+
+.contact-address-value {
+ font-size: 13px;
+}
+
+.contact-address-meta {
+ margin-top: 3px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+ text-transform: capitalize;
+}
+
+.contact-notes {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-size: 13px;
+ line-height: 1.65;
+ white-space: pre-wrap;
+}
+
+.contact-detail-danger-zone {
+ padding-top: 26px;
+ border-top: 1px solid var(--color-border);
+}
+
+.contact-primary-button,
+.contact-secondary-button,
+.contact-danger-button,
+.contact-text-button,
+.contact-inline-danger,
+.contact-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border-radius: 6px;
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease, opacity 0.14s ease;
+}
+
+.contact-address-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 25px;
+ height: 25px;
+ padding: 0;
+ border: 1px solid var(--color-border);
+ border-radius: 5px;
+ background: var(--color-bg);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease;
+}
+
+.contact-address-action:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
+ background: color-mix(in srgb, var(--color-accent) 7%, var(--color-bg));
+ color: var(--color-accent);
+}
+
+.contact-address-action:disabled {
+ cursor: default;
+ opacity: 0.42;
+}
+
+.contact-primary-button,
+.contact-secondary-button,
+.contact-danger-button {
+ min-height: 34px;
+ padding: 0 12px;
+}
+
+.contact-primary-button {
+ border: 1px solid var(--color-accent);
+ background: var(--color-accent);
+ color: #fff;
+}
+
+.contact-primary-button:hover {
+ filter: brightness(0.96);
+}
+
+.contact-secondary-button {
+ border: 1px solid var(--color-border);
+ background: var(--color-bg);
+ color: var(--color-text-primary);
+}
+
+.contact-secondary-button:hover,
+.contact-icon-button:hover {
+ background: var(--color-bg-hover);
+}
+
+.contact-danger-button {
+ border: 1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border));
+ background: transparent;
+ color: var(--color-danger);
+}
+
+.contact-danger-button:hover {
+ background: color-mix(in srgb, var(--color-danger) 8%, transparent);
+}
+
+.contact-text-button {
+ padding: 4px 0;
+ border: 0;
+ background: transparent;
+ color: var(--color-accent);
+}
+
+.contact-inline-danger {
+ padding: 4px 0;
+ border: 0;
+ background: transparent;
+ color: var(--color-danger);
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.contact-icon-button {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--color-text-secondary);
+}
+
+.contact-primary-button:disabled,
+.contact-secondary-button:disabled,
+.contact-icon-button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
+
+.contacts-mobile-back {
+ display: none;
+ margin-bottom: 20px;
+}
+
+.contact-dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(18, 18, 18, 0.42);
+ backdrop-filter: blur(3px);
+}
+
+.contact-dialog {
+ width: min(620px, 100%);
+ max-height: min(760px, calc(100vh - 48px));
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ background: var(--color-bg);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.24);
+}
+
+.contact-dialog-header,
+.contact-dialog-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 17px 20px;
+}
+
+.contact-dialog-header {
+ border-bottom: 1px solid var(--color-border);
+}
+
+.contact-dialog-header h2 {
+ font-size: 18px;
+}
+
+.contact-dialog form {
+ display: flex;
+ max-height: calc(min(760px, 100vh - 48px) - 73px);
+ flex-direction: column;
+}
+
+.contact-dialog-body {
+ overflow: auto;
+ padding: 20px;
+}
+
+.contact-dialog-footer {
+ justify-content: flex-end;
+ border-top: 1px solid var(--color-border);
+ background: color-mix(in srgb, var(--color-sidebar-bg) 55%, var(--color-bg));
+}
+
+.contact-email-fieldset {
+ margin: 0 0 16px;
+ padding: 0;
+ border: 0;
+}
+
+.contact-email-fieldset > legend {
+ margin-bottom: 7px;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+}
+
+.contact-email-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+ margin-bottom: 7px;
+}
+
+.contact-email-row {
+ padding: 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 7px;
+ background: color-mix(in srgb, var(--color-sidebar-bg) 45%, var(--color-bg));
+}
+
+.contact-email-fields {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 126px;
+ gap: 10px;
+}
+
+.contact-email-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 9px;
+}
+
+.contact-primary-control,
+.contact-favorite-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.contact-character-count {
+ display: block;
+ margin-top: 4px;
+ color: var(--color-text-secondary);
+ font-size: 10px;
+ text-align: right;
+}
+
+.contact-form-error {
+ margin: 14px 0 0;
+ padding: 9px 10px;
+ border-left: 3px solid var(--color-danger);
+ background: color-mix(in srgb, var(--color-danger) 7%, transparent);
+ color: var(--color-danger);
+ font-size: 12px;
+}
+
+@media (max-width: 760px) {
+ .contacts-header {
+ align-items: flex-start;
+ flex-direction: column;
+ padding: 18px 18px 14px;
+ }
+
+ .contacts-header-actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .contacts-toolbar {
+ flex-wrap: wrap;
+ padding: 0 18px 14px;
+ }
+
+ .contacts-import-summary {
+ margin: 0 18px 14px;
+ }
+
+ .contacts-search-shell {
+ flex-basis: 100%;
+ max-width: none;
+ }
+
+ .contacts-shell {
+ grid-template-columns: minmax(0, 1fr);
+ margin: 0 18px 18px;
+ }
+
+ .contacts-list-pane {
+ border-right: 0;
+ }
+
+ .contacts-shell[data-has-selection="true"] .contacts-list-pane,
+ .contacts-shell[data-has-selection="false"] .contacts-detail-pane {
+ display: none;
+ }
+
+ .contacts-mobile-back {
+ display: inline-flex;
+ }
+
+ .contact-detail {
+ padding: 24px 22px 38px;
+ }
+
+ .contact-dialog-backdrop {
+ align-items: flex-end;
+ padding: 0;
+ }
+
+ .contact-dialog {
+ max-height: calc(100vh - 22px);
+ border-radius: 10px 10px 0 0;
+ }
+
+ .contact-dialog form {
+ max-height: calc(100vh - 95px);
+ }
+}
+
+@media (max-width: 520px) {
+ .contacts-favorite-filter {
+ width: 100%;
+ }
+
+ .contact-email-fields {
+ grid-template-columns: 1fr;
+ }
+
+ .contact-detail-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/tests/components/ContactAddressAction.test.tsx b/tests/components/ContactAddressAction.test.tsx
new file mode 100644
index 00000000..267521e2
--- /dev/null
+++ b/tests/components/ContactAddressAction.test.tsx
@@ -0,0 +1,142 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Contact } from "@/lib/api";
+import { getContactByEmail } from "@/lib/api";
+import { useUIStore } from "@/stores/ui.store";
+import ContactAddressAction from "@/components/ContactAddressAction";
+
+const mocks = vi.hoisted(() => ({
+ accounts: [{ id: "account-1", email: "me@example.com" }],
+ save: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ useTranslation: () => ({
+ t: (_key: string, fallback?: string) => fallback ?? _key,
+ }),
+}));
+
+vi.mock("@/lib/api", () => ({
+ getContactByEmail: vi.fn(),
+}));
+
+vi.mock("@/hooks/queries", () => ({
+ useAccountsQuery: () => ({ data: mocks.accounts }),
+}));
+
+vi.mock("@/hooks/mutations", () => ({
+ useContactMutations: () => ({
+ save: { mutateAsync: mocks.save, isPending: false },
+ }),
+}));
+
+const alice: Contact = {
+ id: "contact-1",
+ display_name: "Alice",
+ notes: "",
+ is_favorite: false,
+ emails: [{
+ id: "email-1",
+ address: "alice@example.com",
+ label: "work",
+ is_primary: true,
+ }],
+ created_at: 1,
+ updated_at: 1,
+};
+
+function renderAction(element: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+ {element},
+ );
+}
+
+describe("ContactAddressAction", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.accounts = [{ id: "account-1", email: "me@example.com" }];
+ vi.mocked(getContactByEmail).mockResolvedValue(null);
+ mocks.save.mockResolvedValue(alice);
+ useUIStore.setState({ activeView: "inbox", pendingContactId: null });
+ });
+
+ it("opens a prefilled editor for an unsaved participant", async () => {
+ renderAction(
+ ,
+ );
+
+ const addButton = await screen.findByRole("button", {
+ name: "Add sender@example.com to contacts",
+ });
+ await waitFor(() => expect((addButton as HTMLButtonElement).disabled).toBe(false));
+ fireEvent.click(addButton);
+
+ expect(screen.getByRole("dialog", { name: "New contact" })).toBeTruthy();
+ expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Sender");
+ expect((screen.getByLabelText("Email address") as HTMLInputElement).value).toBe("sender@example.com");
+ });
+
+ it("opens an existing contact in the contacts view", async () => {
+ vi.mocked(getContactByEmail).mockResolvedValue(alice);
+ renderAction(
+ ,
+ );
+
+ fireEvent.click(await screen.findByRole("button", {
+ name: "View Alice in contacts",
+ }));
+
+ await waitFor(() => {
+ expect(useUIStore.getState().activeView).toBe("contacts");
+ expect(useUIStore.getState().pendingContactId).toBe("contact-1");
+ });
+ });
+
+ it("does not offer contact actions for the current account address", async () => {
+ renderAction(
+ ,
+ );
+
+ await waitFor(() => expect(getContactByEmail).not.toHaveBeenCalled());
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("deduplicates identical participant lookups through the query cache", async () => {
+ renderAction(
+ <>
+
+
+ >,
+ );
+
+ await waitFor(() => {
+ expect(getContactByEmail).toHaveBeenCalledTimes(1);
+ expect(getContactByEmail).toHaveBeenCalledWith("alice@example.com");
+ });
+ });
+});
diff --git a/tests/components/ContactAutocomplete.test.tsx b/tests/components/ContactAutocomplete.test.tsx
index 2ef97ee0..31f4dd7b 100644
--- a/tests/components/ContactAutocomplete.test.tsx
+++ b/tests/components/ContactAutocomplete.test.tsx
@@ -1,21 +1,38 @@
-import { act, fireEvent, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ContactAutocomplete from "../../src/components/ContactAutocomplete";
-import { searchContacts } from "../../src/lib/api";
+import {
+ searchContactSuggestions,
+ suppressContactSuggestion,
+ type ContactSuggestion,
+} from "../../src/lib/api";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock("../../src/lib/api", () => ({
- searchContacts: vi.fn(),
+ searchContactSuggestions: vi.fn(),
+ suppressContactSuggestion: vi.fn(),
}));
vi.mock("../../src/stores/toast.store", () => ({
useToastStore: { getState: () => ({ addToast: vi.fn() }) },
}));
-const searchContactsMock = vi.mocked(searchContacts);
+const searchContactSuggestionsMock = vi.mocked(searchContactSuggestions);
+
+function suggestion(overrides: Partial): ContactSuggestion {
+ return {
+ contact_id: null,
+ name: null,
+ address: "person@example.com",
+ source: "recent",
+ is_favorite: false,
+ last_interaction_at: 1,
+ ...overrides,
+ };
+}
function deferred() {
let resolve!: (value: T) => void;
@@ -26,9 +43,13 @@ function deferred() {
}
describe("ContactAutocomplete", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ searchContactSuggestionsMock.mockReset();
+ });
+
afterEach(() => {
vi.useRealTimers();
- searchContactsMock.mockReset();
});
it("forwards form identity and label association to the combobox input", () => {
@@ -73,7 +94,6 @@ describe("ContactAutocomplete", () => {
const input = screen.getByRole("combobox", { name: "To" }) as HTMLInputElement;
fireEvent.change(input, { target: { value: "typed@example.com" } });
-
expect(onInputValueChange).toHaveBeenCalledWith("typed@example.com");
rerender(
@@ -94,11 +114,104 @@ describe("ContactAutocomplete", () => {
expect(input.value).toBe("typed@example.com");
});
+ it("shows saved and recent sources and selects only the address", async () => {
+ searchContactSuggestionsMock.mockResolvedValue([
+ suggestion({
+ contact_id: "contact-1",
+ name: "Alice",
+ address: "alice@example.com",
+ source: "saved",
+ is_favorite: true,
+ last_interaction_at: null,
+ }),
+ suggestion({ name: "Alex", address: "alex@example.com", last_interaction_at: 100 }),
+ ]);
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "al" } });
+
+ expect(await screen.findByText("Saved contact")).toBeTruthy();
+ expect(screen.getByText("Recent")).toBeTruthy();
+ fireEvent.click(screen.getByRole("option", { name: /Alice.*alice@example.com/i }));
+ expect(onChange).toHaveBeenCalledWith(["alice@example.com"]);
+ });
+
+ it("filters selected addresses without case sensitivity and supports keyboard selection", async () => {
+ searchContactSuggestionsMock.mockResolvedValue([
+ suggestion({
+ contact_id: "contact-1",
+ name: "Alice",
+ address: "ALICE@example.com",
+ source: "saved",
+ last_interaction_at: null,
+ }),
+ suggestion({ name: "Bob", address: "bob@example.com", last_interaction_at: 50 }),
+ ]);
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "example" } });
+
+ await waitFor(() => expect(searchContactSuggestions).toHaveBeenCalled());
+ expect(screen.queryByText("ALICE@example.com")).toBeNull();
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onChange).toHaveBeenCalledWith(["alice@example.com", "bob@example.com"]);
+ });
+
+ it("removes a recent suggestion without selecting it", async () => {
+ searchContactSuggestionsMock.mockResolvedValue([
+ suggestion({ name: "Alex", address: "alex@example.com", last_interaction_at: 100 }),
+ ]);
+ vi.mocked(suppressContactSuggestion).mockResolvedValue(undefined);
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "alex" } });
+ const removeButton = await screen.findByRole("button", {
+ name: "Remove suggestion alex@example.com",
+ });
+ fireEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(suppressContactSuggestion).toHaveBeenCalledWith("alex@example.com");
+ expect(screen.queryByText("alex@example.com")).toBeNull();
+ });
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("lets Tab leave a recent suggestion without committing it", async () => {
+ searchContactSuggestionsMock.mockResolvedValue([
+ suggestion({ name: "Alex", address: "alex@example.com", last_interaction_at: 100 }),
+ ]);
+ render();
+
+ const input = screen.getByRole("combobox");
+ fireEvent.change(input, { target: { value: "alex" } });
+ const removeButton = await screen.findByRole("button", {
+ name: "Remove suggestion alex@example.com",
+ });
+
+ expect(removeButton.closest('[role="option"]')).toBeNull();
+ expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
+ expect(input.getAttribute("aria-expanded")).toBe("false");
+ });
+
it("ignores an older search response that resolves after the latest query", async () => {
vi.useFakeTimers();
- const older = deferred>();
- const latest = deferred>();
- searchContactsMock.mockReturnValueOnce(older.promise).mockReturnValueOnce(latest.promise);
+ const older = deferred();
+ const latest = deferred();
+ searchContactSuggestionsMock
+ .mockReturnValueOnce(older.promise)
+ .mockReturnValueOnce(latest.promise);
render(
{
act(() => vi.advanceTimersByTime(200));
await act(async () => {
- latest.resolve([{ name: "Newest", address: "new@example.com" }]);
+ latest.resolve([suggestion({ name: "Newest", address: "new@example.com" })]);
await latest.promise;
});
expect(screen.getByRole("option").textContent).toContain("new@example.com");
await act(async () => {
- older.resolve([{ name: "Outdated", address: "old@example.com" }]);
+ older.resolve([suggestion({ name: "Outdated", address: "old@example.com" })]);
await older.promise;
});
@@ -132,8 +245,8 @@ describe("ContactAutocomplete", () => {
it("ignores a pending response after switching accounts", async () => {
vi.useFakeTimers();
- const oldAccountSearch = deferred>();
- searchContactsMock.mockReturnValueOnce(oldAccountSearch.promise);
+ const oldAccountSearch = deferred();
+ searchContactSuggestionsMock.mockReturnValueOnce(oldAccountSearch.promise);
const { rerender } = render(
,
@@ -141,11 +254,11 @@ describe("ContactAutocomplete", () => {
fireEvent.change(screen.getByRole("combobox"), { target: { value: "alice" } });
act(() => vi.advanceTimersByTime(200));
- rerender(
- ,
- );
+ rerender();
await act(async () => {
- oldAccountSearch.resolve([{ name: "Old account", address: "old@example.com" }]);
+ oldAccountSearch.resolve([
+ suggestion({ name: "Old account", address: "old@example.com" }),
+ ]);
await oldAccountSearch.promise;
});
@@ -154,8 +267,8 @@ describe("ContactAutocomplete", () => {
it("filters a contact selected externally while its search is pending", async () => {
vi.useFakeTimers();
- const pendingSearch = deferred>();
- searchContactsMock.mockReturnValueOnce(pendingSearch.promise);
+ const pendingSearch = deferred();
+ searchContactSuggestionsMock.mockReturnValueOnce(pendingSearch.promise);
const { rerender } = render(
,
@@ -171,7 +284,7 @@ describe("ContactAutocomplete", () => {
/>,
);
await act(async () => {
- pendingSearch.resolve([{ name: "Alice", address: "alice@example.com" }]);
+ pendingSearch.resolve([suggestion({ name: "Alice", address: "ALICE@example.com" })]);
await pendingSearch.promise;
});
diff --git a/tests/components/MessageDetail.selection.test.tsx b/tests/components/MessageDetail.selection.test.tsx
index 5cf69e5c..f014c7ee 100644
--- a/tests/components/MessageDetail.selection.test.tsx
+++ b/tests/components/MessageDetail.selection.test.tsx
@@ -110,6 +110,12 @@ vi.mock("../../src/components/ShadowDomEmail", () => ({
ShadowDomEmail: ({ html }: { html: string }) => {html}
,
}));
+vi.mock("../../src/components/ContactAddressAction", () => ({
+ default: ({ address }: { address: string }) => (
+ {address}
+ ),
+}));
+
function setSelectedText(text: string) {
Object.defineProperty(window, "getSelection", {
configurable: true,
@@ -173,11 +179,21 @@ describe("MessageDetail selected-text context actions", () => {
it("shows message recipients instead of the account email in the header", () => {
render();
- expect(screen.getByText(/destination@example\.com/)).toBeTruthy();
- expect(screen.getByText(/cc@example\.com/)).toBeTruthy();
+ expect(screen.getAllByText(/destination@example\.com/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/cc@example\.com/).length).toBeGreaterThan(0);
expect(screen.queryByText(/current@example\.com/)).toBeNull();
});
+ it("offers contact actions for the sender and each visible recipient", () => {
+ render();
+
+ expect(screen.getAllByTestId("contact-address-action").map((node) => node.textContent)).toEqual([
+ "sender@example.com",
+ "destination@example.com",
+ "cc@example.com",
+ ]);
+ });
+
it("does not carry a sender trust override to the next message", async () => {
const { rerender } = render();
fireEvent.click(screen.getByRole("button", { name: "trust sender" }));
diff --git a/tests/components/Sidebar.navigation.test.tsx b/tests/components/Sidebar.navigation.test.tsx
index 84b00ffd..95395023 100644
--- a/tests/components/Sidebar.navigation.test.tsx
+++ b/tests/components/Sidebar.navigation.test.tsx
@@ -27,6 +27,7 @@ vi.mock("react-i18next", () => ({
"sidebar.archive": "Archive",
"sidebar.spam": "Spam",
"sidebar.starred": "Starred",
+ "sidebar.contacts": "Contacts",
"sidebar.snoozed": "Snoozed",
"sidebar.kanban": "Kanban",
"sidebar.settings": "Settings",
@@ -97,6 +98,7 @@ describe("Sidebar navigation", () => {
});
it.each([
+ ["Contacts", "contacts"],
["Snoozed", "snoozed"],
["Kanban", "kanban"],
["Settings", "settings"],
@@ -125,6 +127,7 @@ describe("Sidebar navigation", () => {
render();
expect(screen.getByRole("button", { name: "Snoozed" }).getAttribute("type")).toBe("button");
+ expect(screen.getByRole("button", { name: "Contacts" }).getAttribute("type")).toBe("button");
expect(screen.getByRole("button", { name: "Kanban" }).getAttribute("type")).toBe("button");
expect(screen.getByRole("button", { name: "Settings" }).getAttribute("type")).toBe("button");
});
diff --git a/tests/components/ThreadMessageBubble.test.tsx b/tests/components/ThreadMessageBubble.test.tsx
index 7b9a80ba..e89f60a4 100644
--- a/tests/components/ThreadMessageBubble.test.tsx
+++ b/tests/components/ThreadMessageBubble.test.tsx
@@ -22,6 +22,12 @@ vi.mock("../../src/components/ShadowDomEmail", () => ({
ShadowDomEmail: ({ html }: { html: string }) => {html}
,
}));
+vi.mock("../../src/components/ContactAddressAction", () => ({
+ default: ({ address }: { address: string }) => (
+ {address}
+ ),
+}));
+
const message: Message = {
id: "message-1",
account_id: "account-1",
@@ -67,4 +73,10 @@ describe("ThreadMessageBubble", () => {
expect(getRenderedHtml).toHaveBeenCalledWith("message-1", "LoadOnce");
});
});
+
+ it("offers contact actions for expanded message participants", () => {
+ render();
+
+ expect(document.querySelectorAll("[data-testid='contact-address-action']")).toHaveLength(3);
+ });
});
diff --git a/tests/components/contact-participants.test.ts b/tests/components/contact-participants.test.ts
new file mode 100644
index 00000000..73de255c
--- /dev/null
+++ b/tests/components/contact-participants.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { uniqueContactParticipants } from "@/components/contact-participants";
+
+describe("uniqueContactParticipants", () => {
+ it("deduplicates From, To, and Cc addresses case-insensitively", () => {
+ const participants = uniqueContactParticipants(
+ { name: "Sender", address: " Sender@example.com " },
+ [
+ { name: "Sender duplicate", address: "sender@EXAMPLE.com" },
+ { name: "Destination", address: "destination@example.com" },
+ ],
+ [
+ { name: "Destination duplicate", address: "DESTINATION@example.com" },
+ { name: "Copy", address: "copy@example.com" },
+ ],
+ );
+
+ expect(participants).toEqual([
+ { name: "Sender", address: "Sender@example.com" },
+ { name: "Destination", address: "destination@example.com" },
+ { name: "Copy", address: "copy@example.com" },
+ ]);
+ });
+
+ it("drops participants with empty addresses", () => {
+ expect(uniqueContactParticipants(
+ { name: "Missing", address: " " },
+ [{ name: "Valid", address: "valid@example.com" }],
+ [],
+ )).toEqual([{ name: "Valid", address: "valid@example.com" }]);
+ });
+});
diff --git a/tests/features/command-palette/commands.test.ts b/tests/features/command-palette/commands.test.ts
index cf614b1b..2befec97 100644
--- a/tests/features/command-palette/commands.test.ts
+++ b/tests/features/command-palette/commands.test.ts
@@ -126,4 +126,10 @@ describe("command palette mail commands", () => {
expect(mocks.updateMessageFlags).toHaveBeenCalledWith("message-1", undefined, true);
expect(mocks.queryClient.invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ["folder-unread-counts"] });
});
+
+ it("navigates to contacts from the command palette", async () => {
+ await command("nav:contacts").execute();
+
+ expect(mocks.uiState.setActiveView).toHaveBeenCalledWith("contacts");
+ });
});
diff --git a/tests/features/contacts/ContactEditorDialog.test.tsx b/tests/features/contacts/ContactEditorDialog.test.tsx
new file mode 100644
index 00000000..defe1a6f
--- /dev/null
+++ b/tests/features/contacts/ContactEditorDialog.test.tsx
@@ -0,0 +1,233 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Contact } from "@/lib/api";
+import ContactEditorDialog from "@/features/contacts/ContactEditorDialog";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (_key: string, fallback?: string) => fallback ?? _key,
+ }),
+}));
+
+const contact: Contact = {
+ id: "contact-1",
+ display_name: "Alice",
+ notes: "Old note",
+ is_favorite: true,
+ emails: [{
+ id: "email-1",
+ address: "alice@example.com",
+ label: "work",
+ is_primary: true,
+ }],
+ created_at: 1,
+ updated_at: 1,
+};
+
+describe("ContactEditorDialog", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a contact with a labeled primary email", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Bob " } });
+ fireEvent.change(screen.getByLabelText("Email address"), {
+ target: { value: " bob@example.com " },
+ });
+ fireEvent.change(screen.getByLabelText("Email label"), { target: { value: "personal" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith({
+ display_name: "Bob",
+ notes: "",
+ is_favorite: false,
+ emails: [{
+ id: undefined,
+ address: "bob@example.com",
+ label: "personal",
+ is_primary: true,
+ }],
+ });
+ });
+ });
+
+ it("blocks an invalid email before saving", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByLabelText("Email address"), {
+ target: { value: "invalid" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ const alert = await screen.findByRole("alert");
+ const emailInput = screen.getByLabelText("Email address");
+ expect(alert.textContent).toContain("Enter a valid email address");
+ expect(emailInput.getAttribute("aria-invalid")).toBe("true");
+ expect(emailInput.getAttribute("aria-describedby")).toBe(alert.id);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("associates duplicate-email validation with every email input", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByLabelText("Email address"), {
+ target: { value: "duplicate@example.com" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add email" }));
+ const emailInputs = screen.getAllByLabelText("Email address");
+ fireEvent.change(emailInputs[1], { target: { value: " DUPLICATE@example.com " } });
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toContain("Each email address can only be added once");
+ for (const input of emailInputs) {
+ expect(input.getAttribute("aria-invalid")).toBe("true");
+ expect(input.getAttribute("aria-describedby")).toBe(alert.id);
+ }
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("associates one-primary validation with the email group", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const invalidPrimaryContact: Contact = {
+ ...contact,
+ emails: [
+ contact.emails[0],
+ {
+ id: "email-2",
+ address: "alice.personal@example.com",
+ label: "personal",
+ is_primary: true,
+ },
+ ],
+ };
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ const alert = await screen.findByRole("alert");
+ const emailGroup = screen.getByRole("group", { name: "Email address" });
+ expect(alert.textContent).toContain("Choose exactly one primary email");
+ expect(emailGroup.getAttribute("aria-invalid")).toBe("true");
+ expect(emailGroup.getAttribute("aria-describedby")).toBe(alert.id);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("enforces name and notes limits and associates each error with its field", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ const nameInput = screen.getByLabelText("Name");
+ const emailInput = screen.getByLabelText("Email address");
+ const notesInput = screen.getByLabelText("Notes");
+ expect(nameInput.getAttribute("maxlength")).toBe("512");
+ expect(notesInput.getAttribute("maxlength")).toBe("2000");
+
+ fireEvent.change(emailInput, { target: { value: "valid@example.com" } });
+ fireEvent.change(nameInput, { target: { value: "n".repeat(513) } });
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ let alert = await screen.findByRole("alert");
+ expect(alert.textContent).toContain("Name must be 512 characters or fewer");
+ expect(nameInput.getAttribute("aria-invalid")).toBe("true");
+ expect(nameInput.getAttribute("aria-describedby")).toBe(alert.id);
+
+ fireEvent.change(nameInput, { target: { value: "Valid name" } });
+ fireEvent.change(notesInput, { target: { value: "n".repeat(2001) } });
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ alert = await screen.findByRole("alert");
+ expect(alert.textContent).toContain("Notes must be 2000 characters or fewer");
+ expect(notesInput.getAttribute("aria-invalid")).toBe("true");
+ expect(notesInput.getAttribute("aria-describedby")).toBe(alert.id);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("keeps a primary email after add/remove and saves favorite state", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByLabelText("Email address"), {
+ target: { value: "remove@example.com" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add email" }));
+ const emailInputs = screen.getAllByLabelText("Email address");
+ fireEvent.change(emailInputs[1], { target: { value: "keep@example.com" } });
+ fireEvent.click(screen.getByRole("button", { name: "Remove email 1" }));
+ fireEvent.click(screen.getByLabelText("Favorite contact"));
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ is_favorite: true,
+ emails: [expect.objectContaining({
+ address: "keep@example.com",
+ is_primary: true,
+ })],
+ }));
+ });
+ });
+
+ it("traps focus inside the dialog and restores the invoking control", () => {
+ const trigger = document.createElement("button");
+ trigger.textContent = "Open editor";
+ document.body.appendChild(trigger);
+ trigger.focus();
+
+ const { unmount } = render(
+ ,
+ );
+
+ expect(document.activeElement).toBe(screen.getByLabelText("Name"));
+ const closeButton = screen.getByRole("button", { name: "Close" });
+ const saveButton = screen.getByRole("button", { name: "Save contact" });
+
+ closeButton.focus();
+ fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
+ expect(document.activeElement).toBe(saveButton);
+
+ saveButton.focus();
+ fireEvent.keyDown(document, { key: "Tab" });
+ expect(document.activeElement).toBe(closeButton);
+
+ unmount();
+ expect(document.activeElement).toBe(trigger);
+ trigger.remove();
+ });
+
+ it("preserves ids while editing and closes on Escape", async () => {
+ const onClose = vi.fn();
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Alice Updated" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save contact" }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ id: "contact-1",
+ display_name: "Alice Updated",
+ emails: [expect.objectContaining({ id: "email-1" })],
+ }));
+ });
+ await waitFor(() => {
+ expect((screen.getByRole("button", { name: "Save contact" }) as HTMLButtonElement).disabled)
+ .toBe(false);
+ });
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/tests/features/contacts/ContactListItem.test.tsx b/tests/features/contacts/ContactListItem.test.tsx
new file mode 100644
index 00000000..ea8534a4
--- /dev/null
+++ b/tests/features/contacts/ContactListItem.test.tsx
@@ -0,0 +1,65 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import ContactListItem from "@/features/contacts/ContactListItem";
+import type { Contact } from "@/lib/api";
+
+const contact: Contact = {
+ id: "contact-1",
+ display_name: "Alice Example",
+ notes: "",
+ is_favorite: true,
+ created_at: 1,
+ updated_at: 1,
+ emails: [
+ {
+ id: "email-1",
+ address: "secondary@example.com",
+ label: "other",
+ is_primary: false,
+ },
+ {
+ id: "email-2",
+ address: "alice@example.com",
+ label: "work",
+ is_primary: true,
+ },
+ ],
+};
+
+describe("ContactListItem", () => {
+ it("renders the display name, primary email, initials, and favorite state", () => {
+ render();
+
+ const button = screen.getByRole("button", { name: "Alice Example alice@example.com" });
+ expect(button.getAttribute("aria-pressed")).toBe("true");
+ expect(screen.getByText("AE")).toBeTruthy();
+ expect(screen.getByText("alice@example.com")).toBeTruthy();
+ expect(document.querySelector(".contact-list-star")).toBeTruthy();
+ });
+
+ it("falls back to the first email and invokes selection", () => {
+ const onSelect = vi.fn();
+ render(
+ ({ ...email, is_primary: false })),
+ }}
+ selected={false}
+ onSelect={onSelect}
+ />,
+ );
+
+ const button = screen.getByRole("button", {
+ name: "secondary@example.com secondary@example.com",
+ });
+ expect(button.getAttribute("aria-pressed")).toBe("false");
+ expect(screen.getByText("SE")).toBeTruthy();
+ expect(document.querySelector(".contact-list-star")).toBeNull();
+
+ fireEvent.click(button);
+ expect(onSelect).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/features/contacts/ContactsView.test.tsx b/tests/features/contacts/ContactsView.test.tsx
new file mode 100644
index 00000000..653da7d1
--- /dev/null
+++ b/tests/features/contacts/ContactsView.test.tsx
@@ -0,0 +1,233 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Contact } from "@/lib/api";
+import { useComposeStore } from "@/stores/compose.store";
+import { useConfirmStore } from "@/stores/confirm.store";
+import { useUIStore } from "@/stores/ui.store";
+
+const mocks = vi.hoisted(() => ({
+ contacts: [] as Contact[],
+ useContactsQuery: vi.fn(),
+ save: vi.fn(),
+ remove: vi.fn(),
+ setFavorite: vi.fn(),
+ confirm: vi.fn(),
+ importVcard: vi.fn(),
+ exportVcard: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ initReactI18next: {
+ type: "3rdParty",
+ init: vi.fn(),
+ },
+ useTranslation: () => ({
+ t: (key: string, fallbackOrOptions?: string | { count?: number; defaultValue?: string }) => {
+ if (key === "contacts.count" && typeof fallbackOrOptions === "object") {
+ return `translated count ${fallbackOrOptions.count}`;
+ }
+ if (typeof fallbackOrOptions === "string") return fallbackOrOptions;
+ return fallbackOrOptions?.defaultValue ?? key;
+ },
+ }),
+}));
+
+vi.mock("@tanstack/react-virtual", () => ({
+ useVirtualizer: ({ count }: { count: number }) => ({
+ getTotalSize: () => count * 60,
+ getVirtualItems: () => Array.from(
+ { length: Math.min(count, 10) },
+ (_, index) => ({ index, key: `contact-row-${index}`, start: index * 60 }),
+ ),
+ measureElement: vi.fn(),
+ scrollToIndex: vi.fn(),
+ }),
+}));
+
+vi.mock("@/hooks/queries", () => ({
+ useContactsQuery: (options: unknown) => mocks.useContactsQuery(options),
+}));
+
+vi.mock("@/hooks/mutations", () => ({
+ useContactMutations: () => ({
+ save: { mutateAsync: mocks.save, isPending: false },
+ remove: { mutateAsync: mocks.remove, isPending: false },
+ setFavorite: { mutateAsync: mocks.setFavorite, isPending: false },
+ }),
+}));
+
+vi.mock("@/lib/api", () => ({
+ importContactsVcard: (data: string) => mocks.importVcard(data),
+ exportContactsVcard: () => mocks.exportVcard(),
+}));
+
+import ContactsView from "@/features/contacts/ContactsView";
+
+const alice: Contact = {
+ id: "contact-1",
+ display_name: "Alice",
+ notes: "Rust community",
+ is_favorite: false,
+ emails: [{
+ id: "email-1",
+ address: "alice@example.com",
+ label: "work",
+ is_primary: true,
+ }],
+ created_at: 1,
+ updated_at: 1,
+};
+
+function contact(index: number): Contact {
+ return {
+ ...alice,
+ id: `contact-${index}`,
+ display_name: `Contact ${index}`,
+ emails: [{
+ ...alice.emails[0],
+ id: `email-${index}`,
+ address: `contact-${index}@example.com`,
+ }],
+ };
+}
+
+describe("ContactsView", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.contacts = [];
+ mocks.useContactsQuery.mockImplementation(() => ({
+ data: mocks.contacts,
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ }));
+ mocks.save.mockImplementation(async () => alice);
+ mocks.remove.mockResolvedValue(undefined);
+ mocks.setFavorite.mockResolvedValue(undefined);
+ mocks.confirm.mockResolvedValue(true);
+ mocks.importVcard.mockResolvedValue({
+ created: 1,
+ merged: 2,
+ skipped: 3,
+ invalid: 4,
+ errors: ["Card 4: invalid email"],
+ });
+ mocks.exportVcard.mockResolvedValue("BEGIN:VCARD\r\nEND:VCARD\r\n");
+ useConfirmStore.setState({ confirm: mocks.confirm });
+ useUIStore.setState({ activeView: "contacts" as never });
+ useComposeStore.setState({
+ composeMode: null,
+ composePrefill: null,
+ composeReplyTo: null,
+ composeDirty: false,
+ });
+ });
+
+ it("shows an empty state and opens the new contact editor", () => {
+ render();
+
+ expect(screen.getByText("No contacts yet")).toBeTruthy();
+ fireEvent.click(screen.getAllByRole("button", { name: "New contact" })[0]);
+ expect(screen.getByRole("dialog", { name: "New contact" })).toBeTruthy();
+ });
+
+ it("passes search and favorite filters to the contacts query", () => {
+ render();
+
+ fireEvent.change(screen.getByLabelText("Search contacts"), {
+ target: { value: "alice" },
+ });
+ fireEvent.click(screen.getByLabelText("Favorites only"));
+
+ expect(mocks.useContactsQuery).toHaveBeenLastCalledWith(expect.objectContaining({
+ query: "alice",
+ favoriteOnly: true,
+ limit: Number.MAX_SAFE_INTEGER,
+ }));
+ });
+
+ it("uses the translated count and only mounts virtualized contact rows", () => {
+ mocks.contacts = Array.from({ length: 100 }, (_, index) => contact(index));
+
+ render();
+
+ expect(screen.getByLabelText("translated count 100")).toBeTruthy();
+ expect(screen.getAllByRole("listitem")).toHaveLength(10);
+ });
+
+ it("opens contact details and composes to the primary email", () => {
+ mocks.contacts = [alice];
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /Alice.*alice@example.com/i }));
+ fireEvent.click(screen.getByRole("button", { name: "Write email" }));
+
+ expect(useUIStore.getState().activeView).toBe("compose");
+ expect(useComposeStore.getState().composePrefill).toEqual({
+ to: ["alice@example.com"],
+ });
+ });
+
+ it("toggles favorite and deletes after confirmation", async () => {
+ mocks.contacts = [alice];
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /Alice.*alice@example.com/i }));
+
+ fireEvent.click(screen.getByRole("button", { name: "Add to favorites" }));
+ await waitFor(() => {
+ expect(mocks.setFavorite).toHaveBeenCalledWith({
+ contactId: "contact-1",
+ isFavorite: true,
+ });
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Delete contact" }));
+ await waitFor(() => expect(mocks.confirm).toHaveBeenCalled());
+ await waitFor(() => {
+ expect(mocks.remove).toHaveBeenCalledWith({
+ contactId: "contact-1",
+ suppressAddresses: true,
+ });
+ });
+ });
+
+ it("imports a vCard file and shows the result summary", async () => {
+ render();
+ const file = new File(["BEGIN:VCARD\r\nEND:VCARD\r\n"], "contacts.vcf", {
+ type: "text/vcard",
+ });
+ Object.defineProperty(file, "text", {
+ value: vi.fn().mockResolvedValue("BEGIN:VCARD\r\nEND:VCARD\r\n"),
+ });
+
+ fireEvent.change(screen.getByLabelText("Choose vCard file"), {
+ target: { files: [file] },
+ });
+
+ await waitFor(() => {
+ expect(mocks.importVcard).toHaveBeenCalledWith("BEGIN:VCARD\r\nEND:VCARD\r\n");
+ });
+ expect(await screen.findByRole("status")).toBeTruthy();
+ expect(document.body.textContent).toContain("1 created");
+ expect(document.body.textContent).toContain("2 merged");
+ expect(document.body.textContent).toContain("4 invalid");
+ expect(document.body.textContent).toContain("Card 4: invalid email");
+ });
+
+ it("exports saved contacts as a vCard download", async () => {
+ const createObjectURL = vi.fn().mockReturnValue("blob:contacts");
+ const revokeObjectURL = vi.fn();
+ Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectURL });
+ Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectURL });
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Export vCard" }));
+
+ await waitFor(() => expect(mocks.exportVcard).toHaveBeenCalled());
+ expect(createObjectURL).toHaveBeenCalled();
+ expect(click).toHaveBeenCalled();
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:contacts");
+ click.mockRestore();
+ });
+});
diff --git a/tests/features/settings/CloudSyncTab.contacts.test.tsx b/tests/features/settings/CloudSyncTab.contacts.test.tsx
new file mode 100644
index 00000000..52694fe2
--- /dev/null
+++ b/tests/features/settings/CloudSyncTab.contacts.test.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ previewWebdavBackup: vi.fn(),
+ loadAutoBackupConfig: vi.fn(),
+ invalidateQueries: vi.fn(),
+}));
+
+vi.mock("react-i18next", () => ({
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | Record, options?: Record) => {
+ let value = typeof fallback === "string" ? fallback : key;
+ const values = typeof fallback === "object" ? fallback : options;
+ for (const [name, replacement] of Object.entries(values ?? {})) {
+ value = value.replaceAll(`{{${name}}}`, String(replacement));
+ }
+ return value;
+ },
+ }),
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }),
+}));
+
+vi.mock("@/lib/api", () => ({
+ testWebdavConnection: vi.fn(),
+ backupToWebdav: vi.fn(),
+ exportBackupFile: vi.fn(),
+ importBackupFile: vi.fn(),
+ previewBackupFile: vi.fn(),
+ previewWebdavBackup: (...args: unknown[]) => mocks.previewWebdavBackup(...args),
+ restoreFromWebdav: vi.fn(),
+ saveAutoBackupConfig: vi.fn(),
+ loadAutoBackupConfig: () => mocks.loadAutoBackupConfig(),
+}));
+
+import CloudSyncTab from "@/features/settings/CloudSyncTab";
+
+describe("CloudSyncTab contact backups", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.loadAutoBackupConfig.mockResolvedValue(null);
+ mocks.previewWebdavBackup.mockResolvedValue({
+ version: 2,
+ exported_at: 1_700_000_000,
+ account_count: 1,
+ rule_count: 2,
+ kanban_card_count: 3,
+ kanban_note_count: 4,
+ contact_count: 7,
+ has_translate_config: false,
+ has_encrypted_secrets: false,
+ secret_account_count: 0,
+ has_translate_secret: false,
+ size_bytes: 2048,
+ });
+ });
+
+ it("shows the contact count in the restore preview", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Restore Settings Backup" }));
+
+ expect(await screen.findByText(/Contacts: 7/)).toBeTruthy();
+ });
+});
diff --git a/tests/hooks/useContactMutations.test.tsx b/tests/hooks/useContactMutations.test.tsx
new file mode 100644
index 00000000..06605637
--- /dev/null
+++ b/tests/hooks/useContactMutations.test.tsx
@@ -0,0 +1,104 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ deleteContact,
+ saveContact,
+ setContactFavorite,
+ type Contact,
+ type ContactInput,
+} from "@/lib/api";
+import { useContactMutations } from "@/hooks/mutations/useContactMutations";
+
+vi.mock("@/lib/api", () => ({
+ deleteContact: vi.fn(() => Promise.resolve()),
+ saveContact: vi.fn(),
+ setContactFavorite: vi.fn(() => Promise.resolve()),
+}));
+
+const mockDeleteContact = vi.mocked(deleteContact);
+const mockSaveContact = vi.mocked(saveContact);
+const mockSetContactFavorite = vi.mocked(setContactFavorite);
+
+const input: ContactInput = {
+ display_name: "Alice",
+ notes: "",
+ is_favorite: false,
+ emails: [{ address: "alice@example.com", label: "work", is_primary: true }],
+};
+
+const saved: Contact = {
+ id: "contact-1",
+ display_name: "Alice",
+ notes: "",
+ is_favorite: false,
+ emails: [{
+ id: "email-1",
+ address: "alice@example.com",
+ label: "work",
+ is_primary: true,
+ }],
+ created_at: 1,
+ updated_at: 1,
+};
+
+function setup() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ mutations: { retry: false },
+ queries: { retry: false },
+ },
+ });
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const hook = renderHook(() => useContactMutations(), { wrapper });
+ return { ...hook, invalidateSpy };
+}
+
+function expectContactCachesInvalidated(invalidateSpy: ReturnType) {
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["contacts"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["contact-suggestions"] });
+}
+
+describe("useContactMutations", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSaveContact.mockResolvedValue(saved);
+ });
+
+ it("invalidates contact and suggestion caches after saving", async () => {
+ const { result, invalidateSpy } = setup();
+
+ await act(async () => result.current.save.mutateAsync(input));
+
+ expect(mockSaveContact).toHaveBeenCalledWith(input);
+ await waitFor(() => expectContactCachesInvalidated(invalidateSpy));
+ });
+
+ it("invalidates contact and suggestion caches after deleting", async () => {
+ const { result, invalidateSpy } = setup();
+
+ await act(async () => result.current.remove.mutateAsync({
+ contactId: "contact-1",
+ suppressAddresses: true,
+ }));
+
+ expect(mockDeleteContact).toHaveBeenCalledWith("contact-1", true);
+ await waitFor(() => expectContactCachesInvalidated(invalidateSpy));
+ });
+
+ it("invalidates contact and suggestion caches after changing favorite state", async () => {
+ const { result, invalidateSpy } = setup();
+
+ await act(async () => result.current.setFavorite.mutateAsync({
+ contactId: "contact-1",
+ isFavorite: true,
+ }));
+
+ expect(mockSetContactFavorite).toHaveBeenCalledWith("contact-1", true);
+ await waitFor(() => expectContactCachesInvalidated(invalidateSpy));
+ });
+});
diff --git a/tests/hooks/useContactsQuery.test.tsx b/tests/hooks/useContactsQuery.test.tsx
new file mode 100644
index 00000000..5cce995a
--- /dev/null
+++ b/tests/hooks/useContactsQuery.test.tsx
@@ -0,0 +1,95 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { Contact } from "@/lib/api";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { listContacts } from "@/lib/api";
+import {
+ contactsQueryKey,
+ useContactsQuery,
+} from "@/hooks/queries/useContactsQuery";
+
+vi.mock("@/lib/api", () => ({
+ listContacts: vi.fn(() => Promise.resolve([])),
+}));
+
+const mockListContacts = vi.mocked(listContacts);
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+}
+
+describe("useContactsQuery", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("includes search, favorite filter, limit, and offset in its query key", () => {
+ expect(
+ contactsQueryKey({ query: "alice", favoriteOnly: true, limit: 25, offset: 50 }),
+ ).toEqual(["contacts", "alice", true, 25, 50]);
+ });
+
+ it("debounces changed search text for 200 milliseconds", async () => {
+ vi.useFakeTimers();
+ const { rerender } = renderHook(
+ ({ query }) => useContactsQuery({ query, favoriteOnly: false, limit: 50, offset: 0 }),
+ { initialProps: { query: "a" }, wrapper: createWrapper() },
+ );
+ await act(async () => Promise.resolve());
+ expect(mockListContacts).toHaveBeenCalledWith("a", false, 50, 0);
+
+ rerender({ query: "alice" });
+ await act(async () => {
+ vi.advanceTimersByTime(199);
+ await Promise.resolve();
+ });
+ expect(mockListContacts).not.toHaveBeenCalledWith("alice", false, 50, 0);
+
+ await act(async () => {
+ vi.advanceTimersByTime(1);
+ await Promise.resolve();
+ });
+ expect(mockListContacts).toHaveBeenCalledWith("alice", false, 50, 0);
+ });
+
+ it("loads every backend page when the requested limit exceeds 200", async () => {
+ const contact = (index: number): Contact => ({
+ id: `contact-${index}`,
+ display_name: `Contact ${index}`,
+ notes: "",
+ is_favorite: false,
+ emails: [{
+ id: `email-${index}`,
+ address: `user${index}@example.com`,
+ label: "other",
+ is_primary: true,
+ }],
+ created_at: 1,
+ updated_at: 1,
+ });
+ mockListContacts.mockImplementation(async (_query, _favoriteOnly, _limit, offset) => (
+ offset === 0
+ ? Array.from({ length: 200 }, (_, index) => contact(index))
+ : [contact(200)]
+ ));
+
+ const { result } = renderHook(
+ () => useContactsQuery({ query: "", favoriteOnly: false, limit: 10_000, offset: 0 }),
+ { wrapper: createWrapper() },
+ );
+
+ await waitFor(() => expect(result.current.data).toHaveLength(201));
+ expect(mockListContacts).toHaveBeenNthCalledWith(1, "", false, 200, 0);
+ expect(mockListContacts).toHaveBeenNthCalledWith(2, "", false, 200, 200);
+ });
+});