diff --git a/crates/nuncio-cli/src/args.rs b/crates/nuncio-cli/src/args.rs index 8e8bdf4..a2e5c16 100644 --- a/crates/nuncio-cli/src/args.rs +++ b/crates/nuncio-cli/src/args.rs @@ -211,13 +211,15 @@ pub enum AccountSubcommand { /// Email address or account username. #[arg(short, long, help = "Email address or account username")] email: String, - /// Account protocol: imap-smtp (default), jmap, or caldav. A caldav - /// account requires --collection-url and ignores the IMAP/SMTP flags. + /// Account protocol: imap-smtp (default), jmap, caldav, or carddav. A + /// caldav or carddav account requires --collection-url and ignores the + /// IMAP/SMTP flags. #[arg(long, default_value = "imap-smtp", help = "Account protocol")] protocol: String, - /// Fully-qualified CalDAV calendar-collection URL (required for a - /// caldav account; ignored for mail accounts). - #[arg(long, help = "CalDAV calendar-collection URL (caldav only)")] + /// Fully-qualified DAV collection URL -- a CalDAV calendar collection + /// or a CardDAV address book (required for a caldav or carddav + /// account; ignored for mail accounts). + #[arg(long, help = "DAV collection URL (caldav/carddav only)")] collection_url: Option, /// IMAP server hostname. #[arg(long, default_value = "mail.kof22.com", help = "IMAP server hostname")] diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index c9d05ec..1f21f52 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -75,8 +75,9 @@ fn parse_account_protocol(protocol: &str) -> Result Ok(nuncio_proto::v1::AccountProtocol::ImapSmtp), "jmap" => Ok(nuncio_proto::v1::AccountProtocol::Jmap), "caldav" | "cal-dav" | "cal_dav" => Ok(nuncio_proto::v1::AccountProtocol::Caldav), + "carddav" | "card-dav" | "card_dav" => Ok(nuncio_proto::v1::AccountProtocol::Carddav), other => Err(format!( - "invalid protocol '{other}' (expected imap-smtp, jmap, or caldav)" + "invalid protocol '{other}' (expected imap-smtp, jmap, caldav, or carddav)" )), } } @@ -104,6 +105,10 @@ fn account_transport_json(config: &nuncio_proto::v1::AccountConfig) -> serde_jso "protocol": "caldav", "collection_url": t.collection_url, }), + Some(Transport::Carddav(t)) => json!({ + "protocol": "carddav", + "collection_url": t.collection_url, + }), None => json!({ "protocol": "unspecified" }), } } @@ -135,6 +140,7 @@ fn account_transport_summary(config: &nuncio_proto::v1::AccountConfig) -> String ), Some(Transport::Jmap(t)) => format!("JMAP {}", t.endpoint_host), Some(Transport::Dav(t)) => format!("CalDAV {}", t.collection_url), + Some(Transport::Carddav(t)) => format!("CardDAV {}", t.collection_url), None => "(no transport configured)".to_string(), } } @@ -2117,9 +2123,20 @@ impl HeadlessRunner { Ok(p) => p, Err(e) => return Self::render_error(&e, json_mode), }; - let is_caldav = account_protocol == nuncio_proto::v1::AccountProtocol::Caldav; - if is_caldav && collection_url.map(str::trim).unwrap_or_default().is_empty() { - return Self::render_error("a caldav account requires --collection-url", json_mode); + // Both DAV flavours are addressed by a collection URL rather than a + // mail host/port, so neither can be added without one. + let dav_kind = match account_protocol { + nuncio_proto::v1::AccountProtocol::Caldav => Some("caldav"), + nuncio_proto::v1::AccountProtocol::Carddav => Some("carddav"), + _ => None, + }; + if let Some(kind) = dav_kind { + if collection_url.map(str::trim).unwrap_or_default().is_empty() { + return Self::render_error( + &format!("a {kind} account requires --collection-url"), + json_mode, + ); + } } let keyring_key = format!("nuncio/{}", email); @@ -2152,6 +2169,13 @@ impl HeadlessRunner { collection_url: collection_url.unwrap_or_default().to_string(), }) } + nuncio_proto::v1::AccountProtocol::Carddav => { + nuncio_proto::v1::account_config::Transport::Carddav( + nuncio_proto::v1::DavTransport { + collection_url: collection_url.unwrap_or_default().to_string(), + }, + ) + } nuncio_proto::v1::AccountProtocol::Unspecified => { return Self::render_error("account protocol is required", json_mode) } @@ -2191,20 +2215,25 @@ impl HeadlessRunner { .map(|c| c.id) .unwrap_or(account_id); let is_jmap = account_protocol == nuncio_proto::v1::AccountProtocol::Jmap; - if is_caldav { + if let Some(kind) = dav_kind { let collection = collection_url.unwrap_or_default(); + let label = if kind == "carddav" { + "CardDAV" + } else { + "CalDAV" + }; if json_mode { format_json(&json!({ "configured": true, "account_id": account_id, "email": email, - "protocol": "caldav", + "protocol": kind, "collection_url": collection, "keyring_key": keyring_key })) } else { format!( - "CalDAV account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}" + "{label} account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}" ) } } else if is_jmap { diff --git a/crates/nuncio-core/src/config.rs b/crates/nuncio-core/src/config.rs index 245ad11..96256b9 100644 --- a/crates/nuncio-core/src/config.rs +++ b/crates/nuncio-core/src/config.rs @@ -16,6 +16,10 @@ pub enum AccountProtocol { /// `collection_url` and its own keyring credential; it does not use the /// IMAP/JMAP/SMTP mail endpoints. CalDav, + /// CardDAV (RFC 6352) address-book-collection protocol engine. Like + /// CalDAV, a CardDAV account is a standalone account entry addressed by + /// its `collection_url` and its own keyring credential. + CardDav, } impl AccountProtocol { @@ -24,8 +28,22 @@ impl AccountProtocol { /// require [`AccountConfig::collection_url`] and do not require the /// IMAP/SMTP mail endpoint fields. pub fn is_dav(self) -> bool { + matches!(self, AccountProtocol::CalDav | AccountProtocol::CardDav) + } + + /// Whether this protocol is specifically CalDAV. Callers that drive + /// calendar sync must use this rather than [`Self::is_dav`], which is also + /// true for CardDAV address books. + pub fn is_caldav(self) -> bool { matches!(self, AccountProtocol::CalDav) } + + /// Whether this protocol is specifically CardDAV. Callers that drive + /// contacts sync must use this rather than [`Self::is_dav`], which is also + /// true for CalDAV calendars. + pub fn is_carddav(self) -> bool { + matches!(self, AccountProtocol::CardDav) + } } /// Security and transport encryption mode for mail protocol streams (IMAP & SMTP). @@ -104,9 +122,11 @@ pub struct JmapTransport { pub endpoint_host: String, } -/// DAV transport parameters (CalDAV RFC 4791 today, CardDAV later). A DAV -/// account is addressed by a fully-qualified collection URL rather than a mail -/// host/port, because a DAV client dispatches requests directly at that URL. +/// DAV transport parameters (CalDAV RFC 4791 calendars and CardDAV RFC 6352 +/// address books). A DAV account is addressed by a fully-qualified collection +/// URL rather than a mail host/port, because a DAV client dispatches requests +/// directly at that URL. Which flavour of DAV is spoken is carried by the +/// enclosing [`Transport`] variant, not by this struct. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DavTransport { /// Fully-qualified collection URL (scheme + host + path). @@ -124,8 +144,10 @@ pub enum Transport { ImapSmtp(ImapSmtpTransport), /// JMAP mail. Jmap(JmapTransport), - /// WebDAV collection (CalDAV today, CardDAV later). + /// CalDAV calendar collection. Dav(DavTransport), + /// CardDAV address-book collection. + CardDav(DavTransport), } impl Transport { @@ -135,6 +157,7 @@ impl Transport { Transport::ImapSmtp(_) => AccountProtocol::ImapSmtp, Transport::Jmap(_) => AccountProtocol::Jmap, Transport::Dav(_) => AccountProtocol::CalDav, + Transport::CardDav(_) => AccountProtocol::CardDav, } } } @@ -211,11 +234,23 @@ impl AccountConfig { self.transport.protocol() } - /// Whether this account speaks WebDAV against a collection URL. + /// Whether this account speaks WebDAV against a collection URL (CalDAV or + /// CardDAV). Callers driving a specific protocol's sync must use + /// [`Self::is_caldav`] or [`Self::is_carddav`] instead. pub fn is_dav(&self) -> bool { + matches!(self.transport, Transport::Dav(_) | Transport::CardDav(_)) + } + + /// Whether this account is specifically a CalDAV calendar account. + pub fn is_caldav(&self) -> bool { matches!(self.transport, Transport::Dav(_)) } + /// Whether this account is specifically a CardDAV address-book account. + pub fn is_carddav(&self) -> bool { + matches!(self.transport, Transport::CardDav(_)) + } + /// The account's IMAP/SMTP transport parameters, present only for an /// IMAP/SMTP account (the only transport with an SMTP outbound endpoint). pub fn imap_smtp(&self) -> Option<&ImapSmtpTransport> { @@ -225,10 +260,10 @@ impl AccountConfig { } } - /// The account's DAV collection URL, present only for a DAV account. + /// The account's DAV collection URL, present for either DAV flavour. pub fn dav_collection_url(&self) -> Option<&str> { match &self.transport { - Transport::Dav(t) => Some(t.collection_url.as_str()), + Transport::Dav(t) | Transport::CardDav(t) => Some(t.collection_url.as_str()), _ => None, } } @@ -294,9 +329,15 @@ impl AccountConfig { }); } } - Transport::Dav(t) => { - // A DAV account is addressed by its collection URL, not by mail - // host/port endpoints. + Transport::Dav(t) | Transport::CardDav(t) => { + // Both DAV flavours are addressed by their collection URL, not + // by mail host/port endpoints, so they validate identically; + // only the endpoint label in the error differs. + let endpoint = if self.is_carddav() { + "carddav" + } else { + "caldav" + }; let url = t.collection_url.trim(); if url.is_empty() { return Err(ConfigError::EmptyField { @@ -313,7 +354,7 @@ impl AccountConfig { let host = url_host(url).unwrap_or_default(); if !is_loopback_host(host) { return Err(ConfigError::CleartextToRemoteHost { - endpoint: "caldav", + endpoint, host: host.to_string(), }); } diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index ccb5760..10d207c 100644 Binary files a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin and b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin differ diff --git a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto index e198420..ab8d6b2 100644 --- a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto +++ b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto @@ -299,6 +299,10 @@ enum AccountProtocol { // own first-class account entry: it carries `collection_url` and its own // keyring credential, and does not require the IMAP/SMTP mail endpoints. ACCOUNT_PROTOCOL_CALDAV = 3; + // CardDAV (RFC 6352) address-book-collection account. Like CalDAV, it is its + // own first-class account entry carrying `collection_url` and its own + // keyring credential. + ACCOUNT_PROTOCOL_CARDDAV = 4; } // Mirrors `nuncio_core::TlsMode`. @@ -337,12 +341,14 @@ message JmapTransport { string endpoint_host = 1; } -// DAV transport parameters (CalDAV RFC 4791 today, CardDAV later). A DAV -// account is addressed by a fully-qualified collection URL rather than a mail -// host/port, because a DAV client dispatches requests directly at that URL. +// DAV transport parameters (CalDAV RFC 4791 calendars and CardDAV RFC 6352 +// address books). A DAV account is addressed by a fully-qualified collection +// URL rather than a mail host/port, because a DAV client dispatches requests +// directly at that URL. Which flavour of DAV is spoken is carried by the +// `transport` oneof case, not by this message. message DavTransport { // Fully-qualified collection URL (scheme + host + path), e.g. a CalDAV - // calendar collection or, later, a CardDAV address-book collection. + // calendar collection or a CardDAV address-book collection. string collection_url = 1; } @@ -385,6 +391,7 @@ message AccountConfig { ImapSmtpTransport imap_smtp = 15; JmapTransport jmap = 16; DavTransport dav = 17; + DavTransport carddav = 19; } // Whether the filter engine runs against this account's incoming mail. // @@ -1322,10 +1329,11 @@ message ExportResponse { // `get_calendar_event`), plus a real `Sync` that fetches events from a // `CalendarBackend` and persists them. // -// This is an honest surface: there is currently no way to persist a -// per-account CalDAV collection URL/credential, so `Sync` in production (no -// injected test backend) has no real CalDAV connection to make and returns -// an error rather than a fabricated success. There are deliberately no +// This is an honest surface: in production `Sync` drives real CalDAV accounts +// resolved from the store (`ACCOUNT_PROTOCOL_CALDAV` + `dav.collection_url`, +// credential read from the OS keyring). With no such account configured it +// returns `FAILED_PRECONDITION` rather than a fabricated success. There are +// deliberately no // create/delete RPCs here -- this service does not add real CalDAV write // support. // @@ -1428,10 +1436,10 @@ message GetEventResponse { // them, and a `CreateContact` that persists a locally-authored contact // directly to that same store. // -// This is an honest surface: there is currently no way to persist a -// per-account CardDAV address book collection URL/credential, so `Sync` in -// production (no injected test backend) has no real CardDAV connection to -// make and returns an error rather than a fabricated success. +// This is an honest surface: in production `Sync` drives real CardDAV accounts +// resolved from the store (`ACCOUNT_PROTOCOL_CARDDAV` + `carddav.collection_url`, +// credential read from the OS keyring). With no such account configured it +// returns `FAILED_PRECONDITION` rather than a fabricated success. // `CreateContact` is NOT a CardDAV write-back -- it only ever writes to the // daemon's own local store, the same way the address book has always worked // here, just now actually persisted through the daemon's real store instead @@ -1445,10 +1453,11 @@ message GetEventResponse { // See `nunciod::grpc::serve_on_listener`. service Contacts { // Sync fetches contacts for `account_id` from the account's contacts - // backend and persists them via the store. With no per-account CardDAV - // configuration available in production, this returns `Status::internal` - // describing that gap unless a test double has been injected -- never a - // fabricated `synced_count`. + // backend and persists them via the store. In production it builds a real + // CardDAV client per configured CardDAV account; if `account_id` names an + // account that is not CardDAV, or no CardDAV account is configured at all, + // it returns `FAILED_PRECONDITION` -- never a fabricated `synced_count`. + // An empty `account_id` syncs every configured CardDAV account. rpc Sync(ContactsSyncRequest) returns (ContactsSyncResponse); // ListContacts returns a keyset-paginated page of persisted contacts for // `account_id`, ordered by interaction count (descending) then display diff --git a/crates/nuncio-store/src/db.rs b/crates/nuncio-store/src/db.rs index ff4b7ec..ef83c88 100644 --- a/crates/nuncio-store/src/db.rs +++ b/crates/nuncio-store/src/db.rs @@ -1404,7 +1404,7 @@ impl DatabaseEngine { TlsMode::ImplicitTls, String::new(), ), - Transport::Dav(t) => ( + Transport::Dav(t) | Transport::CardDav(t) => ( String::new(), 0u16, TlsMode::ImplicitTls, @@ -1563,6 +1563,11 @@ impl DatabaseEngine { collection_url: collection_url.unwrap_or_default(), }) } + nuncio_core::AccountProtocol::CardDav => { + nuncio_core::Transport::CardDav(nuncio_core::DavTransport { + collection_url: collection_url.unwrap_or_default(), + }) + } }; Ok(nuncio_core::AccountConfig { id, @@ -4724,6 +4729,43 @@ mod tests { ); } + /// A CardDAV account round-trips as CardDAV, not as the CalDAV variant it + /// shares physical columns with: both flavours persist their URL in + /// `collection_url`, so only the `protocol` discriminator distinguishes + /// them on reload, and confusing the two would silently point contacts + /// sync at a calendar collection (or vice versa). + #[tokio::test] + async fn carddav_account_round_trips_as_carddav_not_caldav() { + let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap(); + + let carddav = nuncio_core::AccountConfig { + id: "acct-carddav-store-1".to_string(), + name: "Work Address Book".to_string(), + email_address: "card@nuncio.mx".to_string(), + keyring_secret_key: "nuncio/acct-carddav-store-1".to_string(), + sync_interval_secs: 300, + filters_enabled: false, + transport: nuncio_core::Transport::CardDav(nuncio_core::DavTransport { + collection_url: "https://dav.example.com/addressbooks/user/default/".to_string(), + }), + }; + engine.save_account(&carddav).await.expect("save succeeds"); + + let fetched = engine + .get_account("acct-carddav-store-1") + .await + .expect("get succeeds") + .expect("account present"); + assert_eq!(fetched, carddav); + assert_eq!(fetched.protocol(), nuncio_core::AccountProtocol::CardDav); + assert!(fetched.is_carddav()); + assert!(!fetched.is_caldav()); + assert_eq!( + fetched.dav_collection_url(), + Some("https://dav.example.com/addressbooks/user/default/") + ); + } + /// Regression proof that accounts of DIFFERENT transports coexist on disk /// and each loads back as its exact original transport variant -- the flat /// physical columns must not blur one transport into another on reload. diff --git a/crates/nuncio-store/src/recovery.rs b/crates/nuncio-store/src/recovery.rs index e5e3849..53abf99 100644 --- a/crates/nuncio-store/src/recovery.rs +++ b/crates/nuncio-store/src/recovery.rs @@ -458,6 +458,11 @@ impl SqliteRecoveryEngine { collection_url: collection_url.unwrap_or_default(), }) } + nuncio_core::AccountProtocol::CardDav => { + nuncio_core::Transport::CardDav(nuncio_core::DavTransport { + collection_url: collection_url.unwrap_or_default(), + }) + } }; nuncio_core::AccountConfig { id, diff --git a/crates/nunciod/src/contacts_sync.rs b/crates/nunciod/src/contacts_sync.rs index 640f8f7..a37b994 100644 --- a/crates/nunciod/src/contacts_sync.rs +++ b/crates/nunciod/src/contacts_sync.rs @@ -5,18 +5,12 @@ //! single account, then persists each contact via //! [`DatabaseEngine::save_contact`]. //! -//! # No production entry point yet -//! -//! Unlike `sync.rs`'s `run_account_sync`/`run_all_accounts_sync`, this module -//! deliberately has NO production entry point that resolves a real account's -//! CardDAV address book collection URL and credential and builds a real -//! `CardDavClient` from them: `nuncio_core::AccountConfig` carries no CardDAV -//! fields today, so there is nothing persisted to build one from. -//! [`sync_with_backend`] is the only function here; it is exercised directly -//! in tests with `MockContactsBackend`, and it is the same seam a future -//! story's production entry point will call once per-account CardDAV -//! configuration exists. -use nuncio_contacts::ContactsBackend; +//! [`sync_carddav_account`] is the production entry point: it resolves a real +//! account's persisted CardDAV address-book collection URL, builds a real +//! [`CardDavClient`] from it plus the credential the caller read from the OS +//! keyring, and funnels through the same [`sync_with_backend`] seam that tests +//! drive with `MockContactsBackend`. +use nuncio_contacts::{CardDavAccountConfig, CardDavClient, ContactsBackend}; use nuncio_store::db::DatabaseError; use thiserror::Error; @@ -68,6 +62,34 @@ pub async fn sync_with_backend( Ok(synced) } +/// Production entry point: build a real [`CardDavClient`] for `account` from +/// its persisted `collection_url` and the `password` resolved from the OS +/// keyring vault, then fetch-and-persist that account's contacts via +/// [`sync_with_backend`]. Returns the number of contacts synced. +/// +/// `account`'s collection URL must be a fully-qualified CardDAV address-book +/// collection URL (validated at `AddAccount` time); the account's +/// `email_address` is used as the DAV basic-auth username. No fabricated data +/// is ever returned -- a transport or persistence failure surfaces as a +/// [`ContactsSyncError`]. +pub async fn sync_carddav_account( + db: &nuncio_store::db::DatabaseEngine, + account: &nuncio_core::AccountConfig, + password: &str, +) -> Result { + tracing::info!( + account_id = %account.id, + "contacts sync: dispatching CardDAV sync for account" + ); + let client = CardDavClient::new(CardDavAccountConfig { + account_id: account.id.clone(), + carddav_url: account.dav_collection_url().unwrap_or_default().to_string(), + username: account.email_address.clone(), + auth_token: nuncio_core::redact::Redacted::new(password.to_string()), + }); + sync_with_backend(db, &client, &account.id).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index dd07c5f..8ae3671 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -398,6 +398,7 @@ fn transport_kind(transport: &nuncio_core::Transport) -> &'static str { nuncio_core::Transport::ImapSmtp(_) => "imap_smtp", nuncio_core::Transport::Jmap(_) => "jmap", nuncio_core::Transport::Dav(_) => "dav", + nuncio_core::Transport::CardDav(_) => "carddav", } } @@ -422,6 +423,9 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon nuncio_core::Transport::Dav(t) => TransportProto::Dav(DavTransportProto { collection_url: t.collection_url, }), + nuncio_core::Transport::CardDav(t) => TransportProto::Carddav(DavTransportProto { + collection_url: t.collection_url, + }), }; AccountConfigProto { id: config.id, @@ -480,10 +484,15 @@ fn map_account_config_from_proto( Some(TransportProto::Dav(t)) => nuncio_core::Transport::Dav(nuncio_core::DavTransport { collection_url: t.collection_url, }), + Some(TransportProto::Carddav(t)) => { + nuncio_core::Transport::CardDav(nuncio_core::DavTransport { + collection_url: t.collection_url, + }) + } None => { return Err(errors::status( ErrorReason::ValidationFailed, - "account transport is required (one of imap_smtp, jmap, dav must be set)", + "account transport is required (one of imap_smtp, jmap, dav, carddav must be set)", )) } }; @@ -583,13 +592,13 @@ impl AccountConnectionTester for RealAccountConnectionTester { ), } } - nuncio_core::Transport::Dav(_) => { + nuncio_core::Transport::Dav(_) | nuncio_core::Transport::CardDav(_) => { return AccountConnectionReport { imap: ProtocolProbe::failed( - "IMAP connection probe is not applicable to a CalDAV account".to_string(), + "IMAP connection probe is not applicable to a DAV account".to_string(), ), smtp: ProtocolProbe::failed( - "SMTP connection probe is not applicable to a CalDAV account".to_string(), + "SMTP connection probe is not applicable to a DAV account".to_string(), ), } } @@ -1239,7 +1248,7 @@ impl CalendarGrpcService { .await .map_err(|e| Status::internal(format!("failed to list accounts: {e}")))? .into_iter() - .filter(|a| a.is_dav()) + .filter(|a| a.is_caldav()) .filter(|a| req.account_id.is_empty() || a.id == req.account_id) .collect(); @@ -1465,15 +1474,88 @@ impl std::fmt::Debug for ContactsEngineOverrides { /// `ListContacts`/`GetContact` always read genuinely persisted data. /// `CreateContact` persists a locally-authored contact directly to the /// store -- real local persistence, never a fabricated CardDAV write-back. -/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when -/// injected; otherwise it returns an honest error, since there is no -/// persisted per-account CardDAV configuration to build a real backend from -/// yet (see `nunciod::contacts_sync`'s doc comment). +/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when a +/// test double is injected; otherwise it takes the production path, building a +/// real `CardDavClient` per configured CardDAV account (see +/// [`Self::sync_configured_carddav_accounts`]). struct ContactsGrpcService { db: Arc, + secrets: Arc, overrides: ContactsEngineOverrides, } +impl ContactsGrpcService { + /// Production path: resolve every configured CardDAV account from the + /// store, build a real `CardDavClient` per account from its persisted + /// collection URL and keyring credential, and sync its contacts into the + /// store. Returns the total number of contacts synced. + /// + /// A request naming a specific `account_id` is scoped to that account; an + /// empty `account_id` syncs every configured CardDAV account. When no + /// CardDAV account matches -- including when the named account exists but + /// speaks some other protocol -- this returns an honest error rather than + /// a fabricated zero-count success, because there is genuinely no remote + /// address book to fetch from. + async fn sync_configured_carddav_accounts( + &self, + req: &ContactsSyncRequest, + ) -> Result { + let carddav_accounts: Vec = self + .db + .list_accounts() + .await + .map_err(|e| Status::internal(format!("failed to list accounts: {e}")))? + .into_iter() + .filter(|a| a.is_carddav()) + .filter(|a| req.account_id.is_empty() || a.id == req.account_id) + .collect(); + + if carddav_accounts.is_empty() { + return Err(errors::status( + ErrorReason::NotConfigured, + if req.account_id.is_empty() { + "no CardDAV account is configured; add one with protocol CARDDAV and a \ + collection_url before syncing" + .to_string() + } else { + format!( + "no CardDAV account with id '{}' is configured", + req.account_id + ) + }, + )); + } + + let mut total = 0usize; + for account in carddav_accounts { + // The credential lives ONLY in the OS keyring vault, keyed by the + // account's `keyring_secret_key`; it is read just-in-time here and + // wrapped so its backing memory is zeroized on drop. + let password = zeroize::Zeroizing::new( + self.secrets + .get_secret(&account.keyring_secret_key) + .map_err(|e| { + Status::internal(format!( + "failed to read CardDAV credential for account '{}': {e}", + account.id + )) + })?, + ); + + total += crate::contacts_sync::sync_carddav_account(&self.db, &account, &password) + .await + .map_err(|e| { + Status::internal(format!( + "CardDAV sync failed for account '{}': {e}", + account.id + )) + })?; + } + + Ok(total) + } +} + #[tonic::async_trait] impl Contacts for ContactsGrpcService { async fn sync( @@ -1488,13 +1570,7 @@ impl Contacts for ContactsGrpcService { .await .map_err(|e| Status::internal(format!("contacts sync failed: {e}")))? } - None => { - return Err(Status::internal(format!( - "no CardDAV configuration exists for account '{}'; per-account CardDAV \ - configuration is not yet implemented", - req.account_id - ))); - } + None => self.sync_configured_carddav_accounts(&req).await?, }; tracing::info!( @@ -3751,6 +3827,7 @@ pub async fn serve_on_listener_with_overrides_and_shutdown( // this function's doc comment. let contacts_service = ContactsGrpcService { db, + secrets, overrides: contacts_overrides, }; let contacts_interceptor = BearerAuthInterceptor::new(token); @@ -8356,10 +8433,10 @@ mod tests { account_id: "acct-contacts-no-config".to_string(), })) .await - .expect_err("sync with no injected backend and no CardDAV config must fail"); - assert_eq!(err.code(), Code::Internal); + .expect_err("sync with no injected backend and no CardDAV account must fail"); + assert_eq!(err.code(), Code::FailedPrecondition); assert!(err.message().contains("acct-contacts-no-config")); - assert!(err.message().contains("no CardDAV configuration")); + assert!(err.message().contains("no CardDAV account")); } /// With a [`ContactsEngineOverrides::contacts_backend`] injected, `Sync` diff --git a/crates/nunciod/src/sync.rs b/crates/nunciod/src/sync.rs index bae7d3c..2f643b6 100644 --- a/crates/nunciod/src/sync.rs +++ b/crates/nunciod/src/sync.rs @@ -482,7 +482,9 @@ pub(crate) fn build_mail_backend( ))), // A DAV-protocol account (e.g. CalDAV) has no inbound mail backend; // it is synced through its own domain service, not the mail path. - Transport::Dav(_) => Err(SyncError::NotAMailAccount(config.id.clone())), + Transport::Dav(_) | Transport::CardDav(_) => { + Err(SyncError::NotAMailAccount(config.id.clone())) + } } } diff --git a/crates/nunciod/tests/carddav_production_e2e_test.rs b/crates/nunciod/tests/carddav_production_e2e_test.rs new file mode 100644 index 0000000..5bc6f85 --- /dev/null +++ b/crates/nunciod/tests/carddav_production_e2e_test.rs @@ -0,0 +1,271 @@ +//! Full-daemon offline end-to-end test for the PRODUCTION CardDAV sync path. +//! +//! Unlike `contacts_e2e_test.rs`, which injects a `MockContactsBackend` through +//! the [`nunciod::grpc::ContactsEngineOverrides`] seam, this test drives the +//! real production path with NO injected backend: it configures a first-class +//! CardDAV account over the authenticated `Accounts/AddAccount` RPC (protocol +//! CARDDAV + a `collection_url` pointing at a WireMock server + a keyring +//! credential), then calls `Contacts/Sync`. The daemon resolves the configured +//! CardDAV account, reads its credential from the (mock) vault, builds a REAL +//! [`nuncio_contacts::CardDavClient`] from the persisted collection URL, issues +//! a genuine CardDAV `REPORT` against WireMock, and persists the fetched +//! contacts. +//! +//! This proves the production wiring end to end while remaining fully offline: +//! WireMock stands in for the remote CardDAV server, and a +//! [`SecretManager::mock`] vault stands in for the OS keyring -- no real +//! network and no real keyring are ever touched. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use nuncio_core::EventBus; +use nuncio_filter::FilterEngine; +use nuncio_proto::v1::{ + AccountConfig as AccountConfigProto, AddAccountRequest, ContactsSyncRequest, + ListContactsRequest, +}; +use nuncio_store::db::DatabaseEngine; +use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT}; +use nunciod::grpc::{ + serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides, + ContactsEngineOverrides, MailEngineOverrides, +}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tonic::Code; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ACCOUNT_ID: &str = "acct-carddav-e2e-1"; +const ACCOUNT_EMAIL: &str = "james.maes@nuncio.mx"; +const KEYRING_KEY: &str = "nuncio/acct-carddav-e2e-1"; +const CARDDAV_PASSWORD: &str = "wiremock-app-token"; + +/// A mail account added alongside the CardDAV one, to prove `Contacts/Sync` +/// refuses an account that is not a CardDAV account rather than silently +/// syncing nothing. +const MAIL_ACCOUNT_ID: &str = "acct-carddav-e2e-mail"; + +/// A canned CardDAV `multistatus` response with two vCards, mirroring the +/// transport-level fixture used by `nuncio-contacts`'s own WireMock test. +fn multistatus_body() -> String { + r#" + + + /addressbooks/contacts/james.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Production CardDAV Maes +N:Maes;James;;; +ORG:KofTwentyTwo +TITLE:Founder +EMAIL;TYPE=WORK:james.maes@kof22.com +END:VCARD + + + + + /addressbooks/contacts/alice.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Production CardDAV Dev +EMAIL:alice@nuncio.mx +END:VCARD + + + +"# + .to_string() +} + +/// Boots the real daemon gRPC server with DEFAULT overrides (no injected +/// contacts backend), backed by a real temp file-backed [`DatabaseEngine`] and +/// the given mock vault. Returns the server address and the minted bearer +/// token; the `TempDir` guard is returned so the caller controls its lifetime. +async fn boot_daemon( + secrets: Arc, +) -> (std::net::SocketAddr, String, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("create temp dir for file-backed db"); + let db_path = dir.path().join("carddav_prod_e2e.db"); + let db = Arc::new( + DatabaseEngine::connect_file(&db_path, &secrets) + .await + .expect("open temp file-backed database"), + ); + + let token_bytes = secrets + .get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32) + .expect("mint gRPC bearer token from mock vault"); + let token = hex::encode(token_bytes); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("listener has local addr"); + + let event_bus = Arc::new(EventBus::new()); + let server_secrets = secrets.clone(); + let server_token = token.clone(); + let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")); + tokio::spawn(async move { + let _ = serve_on_listener_with_overrides( + listener, + event_bus, + db, + server_filter_engine, + server_secrets, + server_token, + MailEngineOverrides::default(), + CalendarEngineOverrides::default(), + // No injected contacts backend: exercise the real production path. + ContactsEngineOverrides::default(), + AccountsEngineOverrides::default(), + ) + .await; + }); + + (addr, token, dir) +} + +/// The full offline proof of the production CardDAV sync path: configure a +/// CardDAV account over the real `Accounts` API, then `Contacts/Sync` builds a +/// real `CardDavClient` and syncs contacts fetched from WireMock into the +/// store, immediately visible to `ListContacts` -- all over the authenticated +/// gRPC API. +#[tokio::test] +async fn carddav_production_sync_builds_real_client_and_persists_contacts() { + let mock_server = MockServer::start().await; + Mock::given(method("REPORT")) + .and(path("/addressbooks/contacts/")) + .respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body())) + .mount(&mock_server) + .await; + let collection_url = format!("{}/addressbooks/contacts/", mock_server.uri()); + + let secrets = Arc::new(SecretManager::mock()); + let (addr, token, _db_dir) = boot_daemon(secrets).await; + let addr_str = addr.to_string(); + + // ---- Configure a first-class CardDAV account over the Accounts API ---- + // The password travels to the daemon ONLY on this RPC and is written only + // to the mock vault; the sync path below reads it back from there. + let mut accounts_client = nuncio_proto::client::connect_accounts(&addr_str, &token) + .await + .expect("accounts client connects"); + accounts_client + .add_account(AddAccountRequest { + config: Some(AccountConfigProto { + id: ACCOUNT_ID.to_string(), + name: "Work Address Book".to_string(), + email_address: ACCOUNT_EMAIL.to_string(), + keyring_secret_key: KEYRING_KEY.to_string(), + sync_interval: Some(nuncio_proto::time::duration_from_secs(300)), + filters_enabled: false, + transport: Some(nuncio_proto::v1::account_config::Transport::Carddav( + nuncio_proto::v1::DavTransport { + collection_url: collection_url.clone(), + }, + )), + }), + password: CARDDAV_PASSWORD.to_string(), + }) + .await + .expect("CardDAV account is accepted and persisted"); + + // A non-CardDAV account, so the "not a CardDAV account" assertion below + // exercises a real, configured account rather than a missing one. + accounts_client + .add_account(AddAccountRequest { + config: Some(AccountConfigProto { + id: MAIL_ACCOUNT_ID.to_string(), + name: "Work Mail".to_string(), + email_address: ACCOUNT_EMAIL.to_string(), + keyring_secret_key: "nuncio/acct-carddav-e2e-mail".to_string(), + sync_interval: Some(nuncio_proto::time::duration_from_secs(300)), + filters_enabled: false, + transport: Some(nuncio_proto::v1::account_config::Transport::ImapSmtp( + nuncio_proto::v1::ImapSmtpTransport { + imap_host: "127.0.0.1".to_string(), + imap_port: 1143, + imap_tls_mode: nuncio_proto::v1::TlsMode::Plain.into(), + smtp_host: "127.0.0.1".to_string(), + smtp_port: 1025, + smtp_tls_mode: nuncio_proto::v1::TlsMode::Plain.into(), + }, + )), + }), + password: "mail-password".to_string(), + }) + .await + .expect("mail account is accepted and persisted"); + + // ---- Contacts/Sync runs the PRODUCTION path (no injected backend) ---- + let mut contacts_client = nuncio_proto::client::connect_contacts(&addr_str, &token) + .await + .expect("contacts client connects"); + let sync_response = contacts_client + .sync(ContactsSyncRequest { + account_id: ACCOUNT_ID.to_string(), + }) + .await + .expect("production CardDAV sync succeeds") + .into_inner(); + assert_eq!(sync_response.synced_count, 2); + + // Immediately visible: `Sync` awaits full fetch-and-persist completion. + let list_response = contacts_client + .list_contacts(ListContactsRequest { + account_id: ACCOUNT_ID.to_string(), + page_size: 0, + page_token: String::new(), + }) + .await + .expect("list_contacts succeeds") + .into_inner(); + assert_eq!(list_response.contacts.len(), 2); + let names: Vec<&str> = list_response + .contacts + .iter() + .map(|c| c.display_name.as_str()) + .collect(); + assert!(names.contains(&"Production CardDAV Maes")); + assert!(names.contains(&"Production CardDAV Dev")); + + // ---- A configured account that is NOT CardDAV fails honestly ---- + let not_carddav = contacts_client + .sync(ContactsSyncRequest { + account_id: MAIL_ACCOUNT_ID.to_string(), + }) + .await + .expect_err("a sync targeting a non-CardDAV account must fail honestly"); + assert_eq!(not_carddav.code(), Code::FailedPrecondition); + assert!(not_carddav.message().contains(MAIL_ACCOUNT_ID)); + + // ---- An account with no CardDAV configuration at all fails honestly ---- + let missing = contacts_client + .sync(ContactsSyncRequest { + account_id: "acct-does-not-exist".to_string(), + }) + .await + .expect_err("a sync targeting an unconfigured account must fail honestly"); + assert_eq!(missing.code(), Code::FailedPrecondition); + + // ---- An unauthenticated call to Contacts is rejected ---- + let mut unauthenticated_client = + nuncio_proto::v1::contacts_client::ContactsClient::connect(format!("http://{addr_str}")) + .await + .expect("client connects at the transport level without any auth"); + let err = unauthenticated_client + .list_contacts(ListContactsRequest { + account_id: ACCOUNT_ID.to_string(), + page_size: 0, + page_token: String::new(), + }) + .await + .expect_err("missing bearer token must be rejected"); + assert_eq!(err.code(), Code::Unauthenticated); +}