Skip to content

Add a CardDAV client that syncs curated people into an external address book #627

Description

@salmonumbrella

The situation

Step 13 of #534 says "CardDAV storage, authentication, sync, compatibility, and staged migration".

Everyone who cares about contacts already runs an address book server: Nextcloud, Fastmail, iCloud, Stalwart, Radicale, Baikal. Every phone and laptop is already paired with it. If msgvault serves CardDAV it takes on a second protocol surface for the same data: its own auth and TLS story, a DAV endpoint that phones reach from outside loopback (a new trust boundary for a tool whose .roborev.toml threat model treats every API caller as the trusted single user), and a permanent Apple interop burden (Apple clients need getctag, sync-collection, and supported-report-set to behave exactly right or they quietly sync nothing, and they are picky about .well-known redirects). None of that makes msgvault a better source of truth for people. It just makes it another server to keep alive.

The better shape is a CardDAV client: msgvault owns the durable person model and pushes a portable projection of curated people into the address book the user already has, then pulls back edits made from a phone. Bidirectional sync with a foreign server, no serving. Every device already talks to that server; msgvault only has to talk to it too.

This is what #534 step 13 should mean. I am rewording the roadmap line to point here.

What works today, and why it is not enough

The substrate is mostly in place, and #621 was designed for exactly this even though its PR body says "future CardDAV layer" without picking a side:

What is missing is everything on the network side of that envelope. There is no CardDAV account, no discovery, no per-book sync token, no remote ETag, no ledger of which person is bound to which remote resource, no notion of a person being published to an address book, no conflict queue, no outbound DAV transport, and no CLI or settings surface. The nearest precedents in the tree are the SSRF-guarded fetcher in internal/api/remote_image.go and the revision-from-ETag write client in internal/taskclient, neither of which speaks WebDAV. go.mod has no CardDAV, WebDAV, or XML dependency, and dependency changes are CODEOWNERS-gated.

There is a complete client to copy from. maathimself/mailflow#263 (bidirectional CardDAV client) and maathimself/mailflow#264 (per-book roles) implement the whole design below in Node against a fixture server plus a live Nextcloud, standards-only, with no provider branches. The maintainer declined them as out of scope for that project, but the design and the tests hold, and the invariants map directly onto msgvault's Go store.

Ask

Add a CardDAV client that syncs curated people into an external address book, in focused increments. Standards only: RFC 6352, RFC 6764 discovery, RFC 6578 incremental sync, RFC 7232 conditional writes. Nothing branches on a hostname or provider name; every server capability is discovered and optional, and a server that advertises nothing still works read-only.

1. Account, discovery, transport

  • One CardDAV account to start, configured like the other sources: a [carddav] config block with base URL and username, the app password in a 0600 token file under ~/.msgvault/tokens/, and an add-carddav command that validates discovery before saving anything.
  • Discovery: current-user-principal PROPFIND against the entered URL, then /.well-known/carddav, then addressbook-home-set, then a Depth 1 PROPFIND on the home set for resourcetype, displayname, supported-report-set, current-user-privilege-set, and supported-address-data. Absent privileges are unknown, never denied; a real 403 or 405 later flips only that operation.
  • Transport on stdlib net/http: per-request and per-operation timeouts, a response and operation byte budget, redirects allowed only within the credential origin and re-validated per hop with the same host and address denylist remote_image.go uses, Basic auth sent only to that origin, Retry-After honoured and clamped so a hostile server cannot freeze sync forever.
  • XML on encoding/xml with hand-built PROPFIND and REPORT bodies and a multistatus parser that resolves namespaces, rejects DOCTYPE, and bounds depth and size. That avoids a go.mod change for the first increments; if a library later earns its place, that is a separate, gated decision.
  • Tables: carddav_accounts and carddav_address_books (canonical URL, discovery alias URL, display name, role flags, sync_token, sync_capability, sync_revision, create/update/delete capability). Both backends, no runtime DDL.

2. Pull

  • Build the whole per-book plan on the network first, then apply it in one transaction. No network I/O inside a database transaction.
  • When the book advertises sync-collection, the full sync is a sync-collection REPORT with an empty token: it returns the whole member set and a usable next token in one shot (RFC 6578 §3.8, the requirement servers most often get subtly wrong, so check that members came back and not only the collection self-response). addressbook-query with getetag and address-data is the fallback for books that do not advertise it; it yields no token, so such a book stays in snapshot mode. Incremental sync is sync-collection with the stored token, sync-level 1, getetag only, honouring 507 truncation and continuation tokens with cycle detection, then addressbook-multiget in bounded batches for the changed hrefs. 404 in either place is a removal. A 403 with DAV:valid-sync-token triggers exactly one empty-token reconciliation, never a recursive one; a stale plan at apply time gets one refetch, then aborts. Tokens are opaque and never parsed or ordered. A 405 or 501 on the REPORT downgrades the book to snapshot mode. CTag is not used; sync token plus ETag is the whole story.
  • A snapshot is all-or-nothing. A top-level 507 on the addressbook-query multistatus means the server truncated the result set; reject the whole snapshot before any card is applied, never treat the successful subset as the complete book. Snapshot mode is replace-all, so a truncated snapshot taken as complete would tombstone every card the server left out. Parser bounds have to admit real address books of a thousand cards or more (fix: raise CardDAV parser entity cap for large address books maathimself/mailflow#231 hit exactly this).
  • Ledger: one row per remote resource, keyed by (address_book_id, href), holding remote_etag, a semantic hash of the remote card, the local hash of the bound person, mapping_status, mapping_revision, and the pending-intent columns below. Store the exact remote bytes through the feat(vcard): add lossless native resource envelopes #621 envelope with source_ref = the book and href = the resource, so unknown properties and vendor extensions survive by construction. ETag churn without a semantic change is a no-op that only refreshes the cached ETag.
  • Binding remote cards to persons: exact UID match first, then a uniquely identifying normalized email or phone. Anything ambiguous becomes a reviewable identity_match_candidates row, never an automatic merge (feat(beeper): add reviewable identity candidates #596). Cards that bind to nothing create a person with carddav_import provenance only in a subscribed book; see roles.
  • The remote UID owns the resource. msgvault never rewrites a server UID; on its own creates it mints the UID and derives the href once as <uid>.vcf, and from then on href is the identity and UID is a linking hint. Retired UIDs go through person_uid_aliases.

3. Push, and the publish decision

  • Publication is explicit. A person reaches the address book only after msgvault person publish <id> (or the equivalent API and UI action). Promoting a participant to a person does not publish it, and no observed sender or newsletter is ever pushed. This is the boundary between the archive and the address book.
  • Remote-first mutation, always: (1) a short transaction records the intent (pending_push, mapping_revision + 1, the exact card and both hashes it is about to send); (2) the transaction closes; (3) PUT with If-Match on the stored ETag, or If-None-Match: * for a create, or DELETE with If-Match; (4) re-GET the resource and commit under compare-and-swap on the mapping revision, or raise a conflict. Local state changes only after the server confirms.
  • A timed-out or otherwise ambiguous mapped write is never replayed. The intent stays persisted, recovery is read-only, and the next sync picks up leftover intents before it does anything else. A 429 rolls the intent back and stores the retry-after. A 412 flows into the conflict path.
  • The outgoing card is the feat(vcard): add lossless native resource envelopes #621 envelope with owned properties overlaid at their original occurrences, residue untouched, remote UID preserved, and the retained vCard version kept. New resources are 3.0 unless the book advertises only 4.0, which is what the compatibility view in feat(vcard): add lossless native resource envelopes #621 exists for.
  • mapping_status values: pending_materialization, synced, pending_push, conflict, lookup. Every mutator is fenced on the expected mapping_revision and reports stale rather than clobbering.

4. Conflicts

  • Detected on pull (local and remote both changed since the last common state, or edit-versus-delete either way) and on write recovery (the re-read no longer matches the recorded intent).
  • Stored in carddav_conflicts with both full card snapshots, tombstone flags, the base local hash and remote ETag, one unresolved row per mapping. A conflict blocks only that person; the book's sync token still advances.
  • Resolution is keep-local or keep-remote, from CLI and API. Keep-local refetches the current ETag and pushes with it; keep-remote applies the remote card. Resolved rows are kept for diagnostics and swept after 30 days. Field-level merge is out of scope.

5. Per-book roles

One credential usually exposes several books, and the curated Apple Contacts book is not where msgvault should be writing. Modelled on JMAP's isDefault / isSubscribed split:

  • Write target: exactly one. All msgvault writes go there and nowhere else. A stable alias URL keeps the role when rediscovery changes the book's canonical URL.
  • Subscribed: pulled and materialized as persons.
  • Lookup source: pulled into the ledger only, to resolve display names and photos for inbound participants without creating persons.
  • Neither: ignored; its ledger rows are dropped once.

A newly discovered book defaults to lookup-only. A mapped person whose book is not the write target is read-only regardless of what the server would allow. Changing a role that widens the footprint schedules a full reconcile; unsubscribing demotes cleanly.

Rules the reference client learned the hard way

These are the invariants that cost the most to get wrong in maathimself/mailflow#263 and #264. They belong in the design, not in review comments.

  • Remote tombstones do not delete curated people. A remote delete removes the local person only when that person is remote-governed: materialized from that book, or pushed by msgvault so its own UID is the card's UID. A person bound by email or phone match keeps its UID and is only unmapped. persons.vcard_uid and the mapping's remote UID are separate values that never overwrite each other.
  • person unpublish deletes the remote resource with If-Match (404 counts as done) and drops the mapping; the local person stays. Dropping the mapping alone is unstable in a subscribed book because the orphaned card re-binds on the next pull. Deleting a published person does the same remote delete first.
  • Publication reconciles through a sweep: published and unmapped, ordered by id, only when a write target exists, run after every book's pull has applied. Per-person failures are reported without aborting the run or poisoning any book token; only a 429 aborts the sweep.
  • One mutation owner. CLI, API, UI, scheduler, and conflict resolution all funnel through one CardDAV mutation service that owns preflight, intent, conditional write, canonical re-fetch, and commit. No route touches a mapped person's row directly. The recurring defect shape in the reference client was a fix on the main path while an adjacent path still reached the same sink.
  • Server-owned properties (PRODID, REV, SOURCE, CREATED, LAST-MODIFIED) are one named set, stripped from every outbound card and excluded from the semantic hash. Filter them on write but not in the hash and a server that stamps REV on every write becomes a permanent false conflict.
  • Photos: bounded decoded size enforced on parse and on serialize, MIME allowlist, never fetch a PHOTO URL (keep it opaque and unrendered), a photo-only change moves both hashes and gets the same conditional-write and conflict guarantees as text; lookup rows decode photos lazily from the retained card.
  • Href hygiene: every server-returned href is validated before it is stored or requested — same origin as the credential, a direct child of the collection, no dot segments, backslashes, credentials, or fragments; redirect targets go through the same check; a collection whose identity changes mid-operation aborts the operation.
  • Retry-After is a durable account-level gate, not a per-request sleep: persist the next-eligible time, check it before a sync and before every interactive write, clear it on success. The clamp (1 h) knowingly under-honours longer backoffs. One in-flight sync per account; reconnecting or changing credentials bumps a connection generation that every plan, role change, sweep, and commit is fenced on.
  • Pending intents never age into a retry. Scheduled recovery is read-only; a second interactive mutation is rejected while an intent exists; on recovery a 404 confirms a delete intent but conflicts with an update intent. On the one bounded create retry, a 412 is success (someone else created it) and any authoritative non-404 aborts.
  • Constraints in the schema, not in intent: at most one active mapping per person (partial unique index, so lookup rows cannot collide), at most one write target per account (partial unique) plus CHECK (NOT is_write_target OR is_subscribed), and the write-target swap is one transaction that clears the old and sets the new.
  • First connect is the exception to lookup-by-default: after the whole discovery snapshot is persisted, the first create-capable book is auto-assigned write target plus subscribed, so person publish works on day one; sibling books default to lookup-only. Books are pruned only after a complete, successful discovery, and an ignored book still counts as seen by its persisted URL, or reconciliation resurrects it as lookup-only.
  • Demotion is destructive and must say so. Unsubscribing a book has to decide the fate of persons materialized from it: the reference client deletes them; msgvault should keep any that acquired participant links or user edits and delete only untouched carddav_import-only persons. Unsubscribing the write target is refused. Re-enabling a role schedules a full reconcile because the ledger it would diff against was dropped.
  • Conflict snapshots are bounded and push-ready: the combined size cap is enforced before the conflict transaction opens; a malformed or oversized remote card creates no conflict and does not move the mapping revision; the stored local snapshot is the overlay onto the retained remote document re-keyed to the remote UID, so keep-local pushes it verbatim without ever minting the local UID onto the server.
  • Never log credentials, Authorization headers, vCards, field values, photo bytes, or conflict snapshots. Log ids, operation class, status, retry decision, duration.

6. Surfaces

  • CLI: add-carddav, sync-carddav [--full], person publish / person unpublish, carddav books and role changes, carddav conflicts list|resolve.
  • API and generated clients for account, books, publication state, and conflicts; scheduler job with the usual yield behaviour; a settings-catalog entry with the secret + testable shape used by the other integrations. MCP can come later.

Testing

An in-process fixture CardDAV server on httptest that scripts principal, home-set, and collection discovery, sync-collection queues including 507 truncation and valid-sync-token errors, addressbook-multiget, addressbook-query, conditional GET/PUT/DELETE returning 428 and 412 correctly, and redirects. iCloud- and Fastmail-shaped discovery fixtures as conformance cases, not provider profiles. A rollback matrix beside the protocol fixtures: remote failure, canonical re-fetch failure after an applied write, revision or generation mismatch, local commit failure, resolution failure, timeout, redirect rejection, and 403/404/405/409/412/423/429/5xx, each with before-and-after assertions on tokens, ETags, both hashes, intents, mappings, persons, and conflicts, plus request-count assertions proving ambiguous recovery never issues a blind second PUT or DELETE. SQLite and PostgreSQL parity for every table and every fence. A live gate against a disposable Nextcloud or Radicale container is useful but stays outside CI. Synthetic identities only.

Delivery boundary

This issue replaces the "CardDAV storage, authentication, sync, compatibility, and staged migration" line in #534. Msgvault serving CardDAV is out, not deferred. Also out: CalDAV, more than one account, writing to more than one book, offline mutation queues, field-level conflict merge, raw vCard editing, and live remote lookup queries. Depends on #621.

Refs #534, #533, #621.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions