diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a979c..9bfef83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2.2.0] - 2026-04-11 + +### Added + +- **Contact CRUD** ([#17](https://github.com/radiosilence/fastmail-cli/issues/17)): `contacts create`, `contacts update`, `contacts delete` CLI commands for managing contacts via CardDAV. +- **GraphQL contact mutations**: `createContact`, `updateContact`, `deleteContact` mutations in the MCP server, so AI assistants can manage contacts too. +- **`ContactFields` struct**: Replaces positional args for contact write operations, keeping clippy happy and the API clean. +- **vCard builder**: `build_vcard()` generates vCard 3.0 strings with proper `N`/`FN`/`EMAIL`/`TEL`/`ORG`/`TITLE`/`NOTE` properties. +- **4 new tests**: vCard building, roundtrip parsing, UID generation. + +### Changed + +- `contacts` CLI subcommand now has `create`, `update`, `delete` subcommands alongside existing `list` and `search`. +- Update merges fields: only provided fields are overwritten, existing fields are preserved. +- Delete requires `-y` confirmation flag (consistent with `masked delete` and `spam`). + ## [2.1.0] - 2026-04-11 ### Added diff --git a/Cargo.lock b/Cargo.lock index 357416e..45f631e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "fastmail-cli" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "async-graphql", diff --git a/Cargo.toml b/Cargo.toml index 94ec58c..3935fa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastmail-cli" -version = "2.1.0" +version = "2.2.0" edition = "2024" description = "CLI for Fastmail's JMAP API" repository = "https://github.com/radiosilence/fastmail-cli" diff --git a/README.md b/README.md index 40740ec..4116960 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ CLI for Fastmail's JMAP API. Read, search, send, and manage emails from your ter | --------------------- | ---------------------------------------------------------------------- | | **Email** | List, search, read, send, reply, forward, threads, identity selection, HTML bodies, file attachments | | **Mailboxes** | List folders, move emails, mark spam/read | -| **Contacts** | Search contacts via CardDAV | +| **Contacts** | Search, create, update, delete contacts via CardDAV | | **Attachments** | Download files, extract text, resize images | | **Text Extraction** | 56 formats via [kreuzberg](https://github.com/kreuzberg-dev/kreuzberg) | | **Image Resizing** | `--max-size` to resize images on download | @@ -280,7 +280,7 @@ fastmail-cli completions fish > ~/.config/fish/completions/fastmail-cli.fish ### Contacts -Search your Fastmail contacts via CardDAV. Requires an app password (API tokens don't work for CardDAV). +CRUD operations for Fastmail contacts via CardDAV. Requires an app password (API tokens don't work for CardDAV). ```bash # Set credentials @@ -292,6 +292,15 @@ fastmail-cli contacts list # Search by name, email, or organization fastmail-cli contacts search "alice" + +# Create a new contact +fastmail-cli contacts create --name "Jane Doe" --email "jane@example.com" --organization "Acme Corp" + +# Update an existing contact (only provided fields are changed) +fastmail-cli contacts update CONTACT_ID --organization "New Corp" --title "CEO" + +# Delete a contact (requires -y confirmation) +fastmail-cli contacts delete CONTACT_ID -y ``` Generate an app password at [Fastmail Settings > Privacy & Security > Integrations > App passwords](https://app.fastmail.com/settings/security/devicekeys). diff --git a/src/carddav/mod.rs b/src/carddav/mod.rs index f79bf71..f8ba514 100644 --- a/src/carddav/mod.rs +++ b/src/carddav/mod.rs @@ -48,6 +48,18 @@ pub struct AddressBook { pub name: String, } +/// Fields for creating or updating a contact. +/// All fields are optional for updates (only provided fields are changed). +#[derive(Debug, Clone, Default)] +pub struct ContactFields<'a> { + pub name: Option<&'a str>, + pub emails: Option<&'a [ContactEmail]>, + pub phones: Option<&'a [ContactPhone]>, + pub organization: Option<&'a str>, + pub title: Option<&'a str>, + pub notes: Option<&'a str>, +} + /// CardDAV client pub struct CardDavClient { client: Client, @@ -243,6 +255,254 @@ impl CardDavClient { Ok(filtered) } + + /// Get the first (default) address book href + async fn default_addressbook(&self) -> Result { + let addressbooks = self.list_addressbooks().await?; + addressbooks + .into_iter() + .next() + .map(|ab| ab.href) + .ok_or(Error::Server("No address books found".to_string())) + } + + /// Find a contact's CardDAV href and current vCard data by UID. + /// Returns (href, vcard_string) so we can PUT back to the same URL. + async fn find_contact_href(&self, contact_id: &str) -> Result> { + let addressbooks = self.list_addressbooks().await?; + + for ab in addressbooks { + let url = format!("{}{}", CARDDAV_BASE, ab.href); + + let body = r#" + + + + + +"#; + + let response = self + .client + .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) + .basic_auth(&self.username, Some(&self.app_password)) + .header("Content-Type", "application/xml") + .header("Depth", "1") + .body(body) + .send() + .await?; + + let status = response.status(); + let text: String = response.text().await?; + + if !status.is_success() && status.as_u16() != 207 { + continue; + } + + let doc = roxmltree::Document::parse(&text) + .map_err(|e| Error::Server(format!("Failed to parse XML: {e}")))?; + + let dav_ns = "DAV:"; + let carddav_ns = "urn:ietf:params:xml:ns:carddav"; + + for response in doc + .descendants() + .filter(|n| n.has_tag_name((dav_ns, "response"))) + { + let href = response + .descendants() + .find(|n| n.has_tag_name((dav_ns, "href"))) + .and_then(|n| n.text()) + .unwrap_or_default(); + + if let Some(vcard_data) = response + .descendants() + .find(|n| n.has_tag_name((carddav_ns, "address-data"))) + .and_then(|n| n.text()) + { + let unfolded = unfold_vcard(vcard_data); + for line in unfolded.lines() { + if line.starts_with("UID") && line.contains(':') { + let uid = line.split_once(':').map(|(_, v)| v).unwrap_or(""); + if uid == contact_id { + return Ok(Some((href.to_string(), vcard_data.to_string()))); + } + } + } + } + } + } + + Ok(None) + } + + /// Create a new contact. Returns the created Contact. + /// `fields.name` is required for creation. + #[instrument(skip(self, fields))] + pub async fn create_contact(&self, fields: &ContactFields<'_>) -> Result { + let name = fields.name.ok_or(Error::Server( + "Name is required to create a contact".to_string(), + ))?; + let emails = fields.emails.unwrap_or(&[]); + let phones = fields.phones.unwrap_or(&[]); + + let ab_href = self.default_addressbook().await?; + let uid = generate_uid(); + let vcard = build_vcard( + &uid, + name, + emails, + phones, + fields.organization, + fields.title, + fields.notes, + ); + + let url = format!("{}{}{}.vcf", CARDDAV_BASE, ab_href, uid); + debug!(url = %url, "Creating contact"); + + let response = self + .client + .put(&url) + .basic_auth(&self.username, Some(&self.app_password)) + .header("Content-Type", "text/vcard; charset=utf-8") + .header("If-None-Match", "*") + .body(vcard) + .send() + .await?; + + let status = response.status(); + let text: String = response.text().await?; + + if !status.is_success() && status.as_u16() != 201 && status.as_u16() != 204 { + return Err(Error::Server(format!( + "CardDAV PUT failed: {} - {}", + status, text + ))); + } + + Ok(Contact { + id: uid, + name: name.to_string(), + emails: emails.to_vec(), + phones: phones.to_vec(), + organization: fields.organization.map(String::from), + title: fields.title.map(String::from), + notes: fields.notes.map(String::from), + }) + } + + /// Update an existing contact. Merges provided fields with existing data. + /// Returns the updated Contact. + #[instrument(skip(self, fields))] + pub async fn update_contact( + &self, + contact_id: &str, + fields: &ContactFields<'_>, + ) -> Result { + let (href, existing_vcard) = self + .find_contact_href(contact_id) + .await? + .ok_or_else(|| Error::Server(format!("Contact not found: {contact_id}")))?; + + // Parse existing contact to merge with + let existing = parse_vcard(&existing_vcard) + .ok_or_else(|| Error::Server("Failed to parse existing contact".to_string()))?; + + let final_name = fields.name.unwrap_or(&existing.name); + let owned_emails; + let final_emails = match fields.emails { + Some(e) => e, + None => { + owned_emails = existing.emails; + &owned_emails + } + }; + let owned_phones; + let final_phones = match fields.phones { + Some(p) => p, + None => { + owned_phones = existing.phones; + &owned_phones + } + }; + let final_org = fields.organization.or(existing.organization.as_deref()); + let final_title = fields.title.or(existing.title.as_deref()); + let final_notes = fields.notes.or(existing.notes.as_deref()); + + let vcard = build_vcard( + contact_id, + final_name, + final_emails, + final_phones, + final_org, + final_title, + final_notes, + ); + + let url = format!("{}{}", CARDDAV_BASE, href); + debug!(url = %url, "Updating contact"); + + let response = self + .client + .put(&url) + .basic_auth(&self.username, Some(&self.app_password)) + .header("Content-Type", "text/vcard; charset=utf-8") + .body(vcard) + .send() + .await?; + + let status = response.status(); + let text: String = response.text().await?; + + if !status.is_success() && status.as_u16() != 204 { + return Err(Error::Server(format!( + "CardDAV PUT failed: {} - {}", + status, text + ))); + } + + Ok(Contact { + id: contact_id.to_string(), + name: final_name.to_string(), + emails: final_emails.to_vec(), + phones: final_phones.to_vec(), + organization: final_org.map(String::from), + title: final_title.map(String::from), + notes: final_notes.map(String::from), + }) + } + + /// Delete a contact by ID. + #[instrument(skip(self))] + pub async fn delete_contact(&self, contact_id: &str) -> Result<()> { + let (href, _) = self + .find_contact_href(contact_id) + .await? + .ok_or_else(|| Error::Server(format!("Contact not found: {contact_id}")))?; + + let url = format!("{}{}", CARDDAV_BASE, href); + debug!(url = %url, "Deleting contact"); + + let response = self + .client + .delete(&url) + .basic_auth(&self.username, Some(&self.app_password)) + .send() + .await?; + + let status = response.status(); + let text: String = response.text().await?; + + if !status.is_success() && status.as_u16() != 204 { + return Err(Error::Server(format!( + "CardDAV DELETE failed: {} - {}", + status, text + ))); + } + + Ok(()) + } } /// Unfold vCard lines per RFC 6350 §3.2: continuation lines start with a space or tab. @@ -388,6 +648,92 @@ fn hash_id(s: &str) -> u64 { hasher.finish() } +/// Generate a UUID-like UID for new contacts +fn generate_uid() -> String { + use std::hash::{Hash, Hasher}; + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + // Mix timestamp nanos with a counter for uniqueness + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + now.as_nanos().hash(&mut hasher); + count.hash(&mut hasher); + let hi = hasher.finish(); + // Second hash with different seed for lower bits + now.as_nanos() + .wrapping_mul(6364136223846793005) + .hash(&mut hasher); + let lo = hasher.finish(); + format!( + "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", + (hi >> 32) as u32, + (hi >> 16) as u16, + hi as u16, + (lo >> 48) as u16, + lo & 0xFFFF_FFFF_FFFF + ) +} + +/// Build a vCard 3.0 string from contact fields +fn build_vcard( + uid: &str, + name: &str, + emails: &[ContactEmail], + phones: &[ContactPhone], + organization: Option<&str>, + title: Option<&str>, + notes: Option<&str>, +) -> String { + let mut lines = vec![ + "BEGIN:VCARD".to_string(), + "VERSION:3.0".to_string(), + format!("UID:{uid}"), + format!("FN:{name}"), + ]; + + // N property — split FN into family/given (best-effort) + let parts: Vec<&str> = name.splitn(2, ' ').collect(); + if parts.len() == 2 { + lines.push(format!("N:{};{};;;", parts[1], parts[0])); + } else { + lines.push(format!("N:{name};;;;")); + } + + for email in emails { + if let Some(ref label) = email.label { + lines.push(format!("EMAIL;TYPE={label}:{}", email.email)); + } else { + lines.push(format!("EMAIL:{}", email.email)); + } + } + + for phone in phones { + if let Some(ref label) = phone.label { + lines.push(format!("TEL;TYPE={label}:{}", phone.number)); + } else { + lines.push(format!("TEL:{}", phone.number)); + } + } + + if let Some(org) = organization { + lines.push(format!("ORG:{org}")); + } + + if let Some(t) = title { + lines.push(format!("TITLE:{t}")); + } + + if let Some(n) = notes { + lines.push(format!("NOTE:{n}")); + } + + lines.push("END:VCARD".to_string()); + lines.join("\r\n") + "\r\n" +} + #[cfg(test)] mod tests { use super::*; @@ -460,4 +806,100 @@ mod tests { let vcard = "BEGIN:VCARD\nUID:abc\nEMAIL:test@example.com\nEND:VCARD"; assert!(parse_vcard(vcard).is_none()); } + + #[test] + fn test_build_vcard_basic() { + let vcard = build_vcard( + "test-uid-123", + "Jane Doe", + &[ContactEmail { + email: "jane@example.com".to_string(), + label: None, + }], + &[], + Some("Acme Corp"), + None, + None, + ); + assert!(vcard.contains("BEGIN:VCARD")); + assert!(vcard.contains("VERSION:3.0")); + assert!(vcard.contains("UID:test-uid-123")); + assert!(vcard.contains("FN:Jane Doe")); + assert!(vcard.contains("N:Doe;Jane;;;")); + assert!(vcard.contains("EMAIL:jane@example.com")); + assert!(vcard.contains("ORG:Acme Corp")); + assert!(vcard.contains("END:VCARD")); + } + + #[test] + fn test_build_vcard_with_labels() { + let vcard = build_vcard( + "uid-456", + "Bob", + &[ContactEmail { + email: "bob@work.com".to_string(), + label: Some("work".to_string()), + }], + &[ContactPhone { + number: "+1234567890".to_string(), + label: Some("cell".to_string()), + }], + None, + Some("Engineer"), + Some("A note"), + ); + assert!(vcard.contains("EMAIL;TYPE=work:bob@work.com")); + assert!(vcard.contains("TEL;TYPE=cell:+1234567890")); + assert!(vcard.contains("N:Bob;;;;")); + assert!(vcard.contains("TITLE:Engineer")); + assert!(vcard.contains("NOTE:A note")); + } + + #[test] + fn test_build_vcard_roundtrips() { + let vcard = build_vcard( + "roundtrip-uid", + "Alice Smith", + &[ + ContactEmail { + email: "alice@home.com".to_string(), + label: Some("home".to_string()), + }, + ContactEmail { + email: "alice@work.com".to_string(), + label: Some("work".to_string()), + }, + ], + &[ContactPhone { + number: "+9876543210".to_string(), + label: None, + }], + Some("Widgets Inc"), + Some("CEO"), + Some("Important person"), + ); + + // parse_vcard expects \n line endings, build_vcard uses \r\n + let unix_vcard = vcard.replace("\r\n", "\n"); + let contact = parse_vcard(&unix_vcard).expect("Should parse built vcard"); + assert_eq!(contact.id, "roundtrip-uid"); + assert_eq!(contact.name, "Alice Smith"); + assert_eq!(contact.emails.len(), 2); + assert_eq!(contact.emails[0].email, "alice@home.com"); + assert_eq!(contact.emails[0].label, Some("home".to_string())); + assert_eq!(contact.phones.len(), 1); + assert_eq!(contact.phones[0].number, "+9876543210"); + assert_eq!(contact.organization, Some("Widgets Inc".to_string())); + assert_eq!(contact.title, Some("CEO".to_string())); + assert_eq!(contact.notes, Some("Important person".to_string())); + } + + #[test] + fn test_generate_uid_unique() { + let uid1 = generate_uid(); + let uid2 = generate_uid(); + assert_ne!(uid1, uid2); + // Should look UUID-ish + assert_eq!(uid1.matches('-').count(), 4); + } } diff --git a/src/commands/contacts.rs b/src/commands/contacts.rs index 7c52c32..f34e05f 100644 --- a/src/commands/contacts.rs +++ b/src/commands/contacts.rs @@ -1,14 +1,45 @@ -use crate::carddav::CardDavClient; +use crate::carddav::{CardDavClient, ContactEmail, ContactFields, ContactPhone}; use crate::config::Config; use crate::models::Output; -/// List all contacts from all address books -pub async fn list_contacts() -> anyhow::Result<()> { +fn make_carddav_client() -> anyhow::Result { let config = Config::load()?; let username = config.get_username()?; let app_password = config.get_app_password()?; + Ok(CardDavClient::new(username, app_password)) +} + +/// Parse comma-separated emails into ContactEmail vec +fn parse_emails(input: Option<&str>) -> Vec { + input + .map(|e| { + e.split(',') + .map(|addr| ContactEmail { + email: addr.trim().to_string(), + label: None, + }) + .collect() + }) + .unwrap_or_default() +} + +/// Parse comma-separated phones into ContactPhone vec +fn parse_phones(input: Option<&str>) -> Vec { + input + .map(|p| { + p.split(',') + .map(|num| ContactPhone { + number: num.trim().to_string(), + label: None, + }) + .collect() + }) + .unwrap_or_default() +} - let client = CardDavClient::new(username, app_password); +/// List all contacts from all address books +pub async fn list_contacts() -> anyhow::Result<()> { + let client = make_carddav_client()?; let addressbooks = client.list_addressbooks().await?; eprintln!("Found {} address book(s)", addressbooks.len()); @@ -26,13 +57,88 @@ pub async fn list_contacts() -> anyhow::Result<()> { /// Search contacts by name or email pub async fn search_contacts(query: &str) -> anyhow::Result<()> { - let config = Config::load()?; - let username = config.get_username()?; - let app_password = config.get_app_password()?; - - let client = CardDavClient::new(username, app_password); + let client = make_carddav_client()?; let contacts = client.search_contacts(query).await?; Output::success(contacts).print(); Ok(()) } + +/// Create a new contact +pub async fn create_contact( + name: &str, + email: Option<&str>, + phone: Option<&str>, + organization: Option<&str>, + title: Option<&str>, + notes: Option<&str>, +) -> anyhow::Result<()> { + let client = make_carddav_client()?; + let emails = parse_emails(email); + let phones = parse_phones(phone); + + let contact = client + .create_contact(&ContactFields { + name: Some(name), + emails: Some(&emails), + phones: Some(&phones), + organization, + title, + notes, + }) + .await?; + + Output::success(contact).print(); + Ok(()) +} + +/// Update an existing contact +pub async fn update_contact( + contact_id: &str, + name: Option<&str>, + email: Option<&str>, + phone: Option<&str>, + organization: Option<&str>, + title: Option<&str>, + notes: Option<&str>, +) -> anyhow::Result<()> { + let client = make_carddav_client()?; + let emails = parse_emails(email); + let phones = parse_phones(phone); + + let emails_ref = if emails.is_empty() { + None + } else { + Some(emails.as_slice()) + }; + let phones_ref = if phones.is_empty() { + None + } else { + Some(phones.as_slice()) + }; + + let contact = client + .update_contact( + contact_id, + &ContactFields { + name, + emails: emails_ref, + phones: phones_ref, + organization, + title, + notes, + }, + ) + .await?; + + Output::success(contact).print(); + Ok(()) +} + +/// Delete a contact +pub async fn delete_contact(contact_id: &str) -> anyhow::Result<()> { + let client = make_carddav_client()?; + client.delete_contact(contact_id).await?; + Output::<()>::success_msg(format!("Contact {contact_id} deleted.")).print(); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index fe58563..8f8b7ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -408,6 +408,73 @@ enum ContactsCommands { /// Search query query: String, }, + + /// Create a new contact + Create { + /// Full name + #[arg(long)] + name: String, + + /// Email address(es), comma-separated + #[arg(long)] + email: Option, + + /// Phone number(s), comma-separated + #[arg(long)] + phone: Option, + + /// Organization/company + #[arg(long)] + organization: Option, + + /// Job title + #[arg(long)] + title: Option, + + /// Notes + #[arg(long)] + notes: Option, + }, + + /// Update an existing contact + Update { + /// Contact ID + contact_id: String, + + /// Full name + #[arg(long)] + name: Option, + + /// Email address(es), comma-separated (replaces existing) + #[arg(long)] + email: Option, + + /// Phone number(s), comma-separated (replaces existing) + #[arg(long)] + phone: Option, + + /// Organization/company + #[arg(long)] + organization: Option, + + /// Job title + #[arg(long)] + title: Option, + + /// Notes + #[arg(long)] + notes: Option, + }, + + /// Delete a contact + Delete { + /// Contact ID + contact_id: String, + + /// Skip confirmation + #[arg(short = 'y', long)] + yes: bool, + }, } #[tokio::main] @@ -620,6 +687,51 @@ async fn main() { Commands::Contacts(cmd) => match cmd { ContactsCommands::List => commands::list_contacts().await, ContactsCommands::Search { query } => commands::search_contacts(&query).await, + ContactsCommands::Create { + name, + email, + phone, + organization, + title, + notes, + } => { + commands::create_contact( + &name, + email.as_deref(), + phone.as_deref(), + organization.as_deref(), + title.as_deref(), + notes.as_deref(), + ) + .await + } + ContactsCommands::Update { + contact_id, + name, + email, + phone, + organization, + title, + notes, + } => { + commands::update_contact( + &contact_id, + name.as_deref(), + email.as_deref(), + phone.as_deref(), + organization.as_deref(), + title.as_deref(), + notes.as_deref(), + ) + .await + } + ContactsCommands::Delete { contact_id, yes } => { + if !yes { + eprintln!("Delete contact {}? Use -y to confirm.", contact_id); + std::process::exit(1); + } + commands::delete_contact(&contact_id).await + } }, Commands::Mcp => mcp::run_server().await, diff --git a/src/mcp/graphql/mutation.rs b/src/mcp/graphql/mutation.rs index bbfca7c..21c1ba1 100644 --- a/src/mcp/graphql/mutation.rs +++ b/src/mcp/graphql/mutation.rs @@ -557,6 +557,146 @@ impl MutationRoot { }), } } + + /// Create a new contact via CardDAV. Requires FASTMAIL_USERNAME and FASTMAIL_APP_PASSWORD. + async fn create_contact( + &self, + #[graphql(desc = "Full name")] name: String, + #[graphql(desc = "Email address")] email: Option, + #[graphql(desc = "Phone number")] phone: Option, + #[graphql(desc = "Organization/company")] organization: Option, + #[graphql(desc = "Job title")] title: Option, + #[graphql(desc = "Notes")] notes: Option, + ) -> Result { + let (client, emails, phones) = build_carddav_context(email, phone)?; + + let contact = client + .create_contact(&crate::carddav::ContactFields { + name: Some(&name), + emails: Some(&emails), + phones: Some(&phones), + organization: organization.as_deref(), + title: title.as_deref(), + notes: notes.as_deref(), + }) + .await?; + Ok(GqlContact::from(contact)) + } + + /// Update an existing contact via CardDAV. Only provided fields are changed; others are preserved. + async fn update_contact( + &self, + #[graphql(desc = "Contact ID (UID from the vCard)")] id: String, + #[graphql(desc = "New full name")] name: Option, + #[graphql(desc = "New email address (replaces existing)")] email: Option, + #[graphql(desc = "New phone number (replaces existing)")] phone: Option, + #[graphql(desc = "New organization/company")] organization: Option, + #[graphql(desc = "New job title")] title: Option, + #[graphql(desc = "New notes")] notes: Option, + ) -> Result { + let (client, emails, phones) = build_carddav_context(email, phone)?; + + let emails_ref = if emails.is_empty() { + None + } else { + Some(emails.as_slice()) + }; + let phones_ref = if phones.is_empty() { + None + } else { + Some(phones.as_slice()) + }; + + let contact = client + .update_contact( + &id, + &crate::carddav::ContactFields { + name: name.as_deref(), + emails: emails_ref, + phones: phones_ref, + organization: organization.as_deref(), + title: title.as_deref(), + notes: notes.as_deref(), + }, + ) + .await?; + Ok(GqlContact::from(contact)) + } + + /// Delete a contact via CardDAV. Cannot be undone! + async fn delete_contact( + &self, + #[graphql(desc = "Contact ID (UID from the vCard)")] id: String, + ) -> Result { + let config = crate::config::Config::load()?; + let username = config.get_username().map_err(|_| { + async_graphql::Error::new("Username not configured. Set FASTMAIL_USERNAME env var.") + })?; + let app_password = config.get_app_password().map_err(|_| { + async_graphql::Error::new( + "App password not configured. Set FASTMAIL_APP_PASSWORD env var.", + ) + })?; + + let client = crate::carddav::CardDavClient::new(username, app_password); + match client.delete_contact(&id).await { + Ok(()) => Ok(GqlStatus { + success: true, + message: Some(format!("Contact {id} deleted.")), + error: None, + }), + Err(e) => Ok(GqlStatus { + success: false, + message: None, + error: Some(e.to_string()), + }), + } + } +} + +// ============ CardDAV helpers ============ + +fn build_carddav_context( + email: Option, + phone: Option, +) -> async_graphql::Result<( + crate::carddav::CardDavClient, + Vec, + Vec, +)> { + let config = crate::config::Config::load()?; + let username = config.get_username().map_err(|_| { + async_graphql::Error::new("Username not configured. Set FASTMAIL_USERNAME env var.") + })?; + let app_password = config.get_app_password().map_err(|_| { + async_graphql::Error::new("App password not configured. Set FASTMAIL_APP_PASSWORD env var.") + })?; + + let client = crate::carddav::CardDavClient::new(username, app_password); + + let emails: Vec = email + .map(|e| { + e.split(',') + .map(|addr| crate::carddav::ContactEmail { + email: addr.trim().to_string(), + label: None, + }) + .collect() + }) + .unwrap_or_default(); + + let phones: Vec = phone + .map(|p| { + p.split(',') + .map(|num| crate::carddav::ContactPhone { + number: num.trim().to_string(), + label: None, + }) + .collect() + }) + .unwrap_or_default(); + + Ok((client, emails, phones)) } // ============ Formatting helpers (preview only) ============