feat(contacts): drive contacts sync from stored CardDAV accounts #431

Merged
KofTwentyTwo merged 1 commits from story/wire-contacts-sync into dev 2026-08-15 02:48:47 +00:00
KofTwentyTwo commented 2026-08-15 02:41:43 +00:00 (Migrated from github.com)

What a user can now do that they could not before

Add a CardDAV account and actually sync their contacts:

nuncio account add --protocol carddav \
  --email me@example.com \
  --collection-url https://dav.example.com/addressbooks/me/default/
nuncio contact sync --account acct-me-at-example-com

Before this change that was impossible. There was a real, wiremock-tested
CardDavClient in nuncio-contacts, but nothing in the product could reach it:
nuncio_core::AccountConfig had no way to express a CardDAV address book, so
Contacts/Sync in production always fell through to its honest-error branch and
only worked when a test double was injected through the overrides seam. The
capability existed in the library and was unreachable in the product.

How

Wired the same way the CalDAV vertical already is, rather than via a parallel
mechanism:

  • Account model — AccountProtocol::CardDav and
    Transport::CardDav(DavTransport). Added is_caldav()/is_carddav() so
    callers select a specific DAV flavour; is_dav() remains the "not a mail
    account" predicate (which keeps the mail scheduler's exclusion correct).
    Calendar sync now filters on is_caldav(), so a CardDAV account can never be
    picked up as a calendar.
  • Storage — reuses the existing collection_url column, distinguished by the
    protocol discriminator. No schema change; nothing added to migrate().
  • Proto — additive only: ACCOUNT_PROTOCOL_CARDDAV = 4 and a carddav
    case in the AccountConfig.transport oneof at field 19. No renumbering, no
    type changes. Descriptor golden regenerated per the test's own instructions.
  • Engine — contacts_sync::sync_carddav_account builds a real
    CardDavClient from the persisted collection URL, with the credential read
    just-in-time from the OS keyring and wrapped in Zeroizing. Never from
    plaintext in source or SQLite.
  • Honest failure — Contacts/Sync returns a typed NotConfigured
    (FAILED_PRECONDITION) when no CardDAV account matches, including when the
    named account exists but speaks another protocol. Previously an untyped
    Status::internal. Never a fabricated synced_count.
  • CLI — --protocol carddav, with --collection-url required for either
    DAV flavour.

Tests

New crates/nunciod/tests/carddav_production_e2e_test.rs drives the full daemon
with default overrides (no injected backend), covering:

  • a successful per-account sync the way production drives it — account added over
    the real Accounts/AddAccount RPC, real CardDavClient issuing a genuine
    CardDAV REPORT against wiremock, contacts persisted and immediately visible
    to ListContacts;
  • an account that is configured but is not CardDAV → FAILED_PRECONDITION;
  • an account with no CardDAV configuration at all → FAILED_PRECONDITION;
  • credential resolution through the mocked vault (SecretManager::mock() /
    MockKeyring);
  • unauthenticated access still rejected.

Plus a store round-trip test proving a CardDAV account reloads as CardDAV and not
as the CalDAV variant it shares physical columns with. No live network anywhere.

Gate

cargo fmt --all -- --check, cargo check-all, and cargo test-all all green
(43 suites, 0 failures). Ran RUST_TEST_THREADS=2 cargo test-all three times
with zero FAILED lines each.

Closes #255

## What a user can now do that they could not before Add a CardDAV account and actually sync their contacts: ``` nuncio account add --protocol carddav \ --email me@example.com \ --collection-url https://dav.example.com/addressbooks/me/default/ nuncio contact sync --account acct-me-at-example-com ``` Before this change that was impossible. There was a real, wiremock-tested `CardDavClient` in `nuncio-contacts`, but nothing in the product could reach it: `nuncio_core::AccountConfig` had no way to express a CardDAV address book, so `Contacts/Sync` in production always fell through to its honest-error branch and only worked when a test double was injected through the overrides seam. The capability existed in the library and was unreachable in the product. ## How Wired the same way the CalDAV vertical already is, rather than via a parallel mechanism: - **Account model** — `AccountProtocol::CardDav` and `Transport::CardDav(DavTransport)`. Added `is_caldav()`/`is_carddav()` so callers select a specific DAV flavour; `is_dav()` remains the "not a mail account" predicate (which keeps the mail scheduler's exclusion correct). Calendar sync now filters on `is_caldav()`, so a CardDAV account can never be picked up as a calendar. - **Storage** — reuses the existing `collection_url` column, distinguished by the `protocol` discriminator. **No schema change**; nothing added to `migrate()`. - **Proto** — additive only: `ACCOUNT_PROTOCOL_CARDDAV = 4` and a `carddav` case in the `AccountConfig.transport` oneof at field 19. No renumbering, no type changes. Descriptor golden regenerated per the test's own instructions. - **Engine** — `contacts_sync::sync_carddav_account` builds a real `CardDavClient` from the persisted collection URL, with the credential read just-in-time from the OS keyring and wrapped in `Zeroizing`. Never from plaintext in source or SQLite. - **Honest failure** — `Contacts/Sync` returns a typed `NotConfigured` (`FAILED_PRECONDITION`) when no CardDAV account matches, including when the named account exists but speaks another protocol. Previously an untyped `Status::internal`. Never a fabricated `synced_count`. - **CLI** — `--protocol carddav`, with `--collection-url` required for either DAV flavour. ## Tests New `crates/nunciod/tests/carddav_production_e2e_test.rs` drives the full daemon with **default overrides** (no injected backend), covering: - a successful per-account sync the way production drives it — account added over the real `Accounts/AddAccount` RPC, real `CardDavClient` issuing a genuine CardDAV `REPORT` against wiremock, contacts persisted and immediately visible to `ListContacts`; - an account that is configured but is **not** CardDAV → `FAILED_PRECONDITION`; - an account with no CardDAV configuration at all → `FAILED_PRECONDITION`; - credential resolution through the mocked vault (`SecretManager::mock()` / `MockKeyring`); - unauthenticated access still rejected. Plus a store round-trip test proving a CardDAV account reloads as CardDAV and not as the CalDAV variant it shares physical columns with. No live network anywhere. ## Gate `cargo fmt --all -- --check`, `cargo check-all`, and `cargo test-all` all green (43 suites, 0 failures). Ran `RUST_TEST_THREADS=2 cargo test-all` three times with zero `FAILED` lines each. Closes #255
Sign in to join this conversation.