Skip to content

feat: per-book roles for CardDAV multi-address-book sync#264

Open
salmonumbrella wants to merge 2 commits into
maathimself:mainfrom
salmonumbrella:feat/carddav-multibook
Open

feat: per-book roles for CardDAV multi-address-book sync#264
salmonumbrella wants to merge 2 commits into
maathimself:mainfrom
salmonumbrella:feat/carddav-multibook

Conversation

@salmonumbrella

@salmonumbrella salmonumbrella commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #263 (bidirectional CardDAV sync). Until that merges, GitHub shows both diffs. This PR adds the multi-book layer.

Summary

One CardDAV credential often exposes several address books — a curated Apple Contacts book alongside the "Email contacts" book you want MailFlow writing to. The client pulled every book and wrote to the first writable: thousands of unwanted contacts in, writes to the wrong book. This PR assigns each book an explicit role and makes publication to a shared book deliberate.

Changes

  • Per-book roles, managed in the admin panel and enforced across sync, export, and conflict handling — modelled on JMAP's isDefault/isSubscribed split rather than a single flag:
    • Write target — exactly one; all MailFlow writes go there and nowhere else. A stable alias keeps the role when re-discovery changes the book's canonical URL.
    • Subscribed — pulled and shown in the contact list.
    • Lookup source — pulled into the sync ledger only, to resolve inbound senders (names, avatars) without materializing contacts.
    • Neither — ignored; its ledger rows are dropped.
  • Deliberate publication. Editing an auto-collected contact no longer silently publishes it; an explicit promote action does. Sending mail to someone still marks them for autocomplete but no longer publishes them.
  • publishEmailedContacts setting, default off. When off, nothing reaches shared books without deliberate action; when on, MailFlow fills books with correspondents. Pre-existing exports continue (the migration backfills intent). A separate carddav_publish_intent column keeps send.js untouched and preserves autocomplete ranking on is_auto.
  • Migrations 0038–0040: role, alias, and publish-intent columns with behaviour-preserving backfills; the only pre-existing object touched is a widened mapping-status CHECK.

Testing

Backend 1,466 unit + 171 PostgreSQL-backed tests; frontend 1,450. Tests verify both toggle states: when off, an emailed-only contact produces zero PUTs; when on, it exports to the write-target book.

Note

macOS Contacts.app cannot handle multi-collection CardDAV accounts (Stalwart's maintainer hit the same limit). When MailFlow's CardDAV server exposes multiple books, it hits this client limitation — something to document, not fix server-side.


Contributor License Agreement

By submitting this pull request I confirm that:

  • I have read and agree to the Contributor License Agreement.
  • My contribution is my own original work (or I have identified any
    third-party material and confirmed it is compatible with the CLA).
  • I have the right to submit this contribution under the terms of the CLA.

Adds two-way contact synchronisation with any CardDAV server, building on
the existing read-only client.

- Remote-first create/update/delete: local state changes only after the
  server confirms, with committed intent and read-only recovery for
  ambiguous writes (no replayed PUT/DELETE).
- Retained lossless vCards: properties MailFlow does not model (X-*,
  CATEGORIES, KEY, TZ, grouped item properties) survive every edit path,
  including edits made through MailFlow's own CardDAV server and both
  conflict resolutions.
- Conflict detection and resolution: simultaneous local/remote edits raise
  an explicit conflict with a side-by-side comparison and keep-mine /
  keep-theirs resolution; deletions conflict too.
- RFC 6578 incremental sync with full-resync fallback, RFC 7232 conditional
  writes (If-Match required on mapped writes), and Retry-After throttling.
- Per-book capability discovery (create/update/delete allowed | denied |
  unknown) drives the UI; read-only books are surfaced as such.
- Contacts UI: photos (uploadable from the detail view too), typed
  additional fields with humanized vCard labels, per-operation controls,
  conflict queue, and sync-state markers, translated in all seven locales.
- Contact list pagination orders by a unique key so paging reaches every
  contact; the integration's contact count is derived from the sync ledger
  rather than accumulated, so it cannot drift.

Provider-neutral: no code branches on a provider name or hostname; discovery
is RFC 6764/6352 (current-user-principal -> addressbook-home-set), and every
server capability is optional. Verified against Nextcloud, plus iCloud- and
Fastmail-shaped protocol fixtures.

Migrations 0034-0037 add the sync schema; the only changes to pre-existing
data are a guarded consolidation of duplicate CardDAV address books and
removal of the obsolete dupMode config key.
A CardDAV credential often exposes several address books — a curated
"Apple Contacts" book next to the "Email contacts" book MailFlow should
write to. Pulling every book and writing into whichever came first
flooded the contact list and risked writing into a curated book. Each
book now carries explicit roles, managed per book in the admin panel
and enforced across sync, export, and conflict handling:

- Write target: exactly one per account; every MailFlow-originated
  create/update/delete goes there and nowhere else. A stable alias
  keeps the role when re-discovery changes the book's canonical URL.
- Subscribed: pulled and shown in the contact list.
- Lookup source: pulled into the sync ledger only, to resolve inbound
  senders (names, avatars) without materializing contacts.
- Neither: ignored entirely; its ledger rows are dropped.

Publication into a shared book becomes a deliberate act. Editing an
auto-collected contact no longer silently publishes it; an explicit
promote action does. Sending mail to someone still marks them explicit
for autocomplete but no longer publishes them: a new
publishEmailedContacts setting (default OFF) gates that, and turning it
ON restores a book that fills with everyone you correspond with.
Default OFF means nothing new reaches a shared address book without a
deliberate act. The gate is a separate carddav_publish_intent column,
so send.js is untouched and autocomplete ranking on is_auto is
unaffected; contacts already exporting keep exporting via the backfill.

The role model follows JMAP's isDefault/isSubscribed split rather than
a single flag.

Migrations 0038-0040 add role, alias, and publish-intent columns with
behaviour-preserving backfills; the only pre-existing object touched is
a widened mapping-status CHECK constraint.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant