From b20e442dfebe45fea6a47def253a97cabdb47c3a Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 13 Jul 2026 20:18:35 -0700
Subject: [PATCH 1/2] feat: bidirectional CardDAV contact sync
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.
---
.github/workflows/ci.yml | 18 +
.../0034_carddav_incremental_sync.sql | 76 +
.../0035_carddav_bidirectional_sync.sql | 76 +
.../0036_carddav_bidirectional_cleanup.sql | 42 +
.../0037_carddav_conflict_retention.sql | 6 +
backend/package.json | 4 +-
backend/src/index.js | 2 +
backend/src/routes/carddav.js | 356 +-
backend/src/routes/carddav.test.js | 704 +++
backend/src/routes/carddavAccount.js | 68 +-
backend/src/routes/carddavAccount.test.js | 275 ++
backend/src/routes/carddavConflicts.js | 45 +
backend/src/routes/carddavConflicts.test.js | 113 +
backend/src/routes/contacts.js | 258 +-
backend/src/routes/contacts.test.js | 319 ++
backend/src/services/carddavClient.js | 1046 +++-
.../services/carddavClient.protocol.test.js | 1749 +++++++
backend/src/services/carddavClient.test.js | 485 +-
.../src/services/carddavConflictService.js | 387 ++
.../services/carddavConflictService.test.js | 717 +++
.../carddavContactService.integration.test.js | 1252 +++++
backend/src/services/carddavContactService.js | 1489 ++++++
.../services/carddavContactService.test.js | 1371 ++++++
backend/src/services/carddavFixtureServer.js | 397 ++
backend/src/services/carddavMappingState.js | 508 ++
.../src/services/carddavMappingState.test.js | 562 +++
.../src/services/carddavMigration.db.test.js | 1129 +++++
backend/src/services/carddavProjection.js | 63 +
.../src/services/carddavProjection.test.js | 156 +
backend/src/services/carddavSync.db.test.js | 4091 ++++++++++++++++
.../services/carddavSync.integration.test.js | 4281 +++++++++++++++++
backend/src/services/carddavSync.js | 1443 +++++-
backend/src/services/carddavSync.test.js | 2037 ++++++++
backend/src/services/carddavTransport.js | 335 ++
backend/src/services/carddavTransport.test.js | 491 ++
backend/src/services/carddavXml.js | 273 ++
backend/src/services/carddavXml.test.js | 298 ++
backend/src/services/db.js | 2 +-
backend/src/services/hostValidation.js | 71 +-
backend/src/services/hostValidation.test.js | 53 +-
backend/src/services/migrations.js | 16 +-
backend/src/services/postgresTestHelpers.js | 162 +
.../src/services/postgresTestHelpers.test.js | 69 +
backend/src/services/safeFetch.js | 106 +-
backend/src/services/safeFetch.test.js | 217 +-
backend/src/utils/vcard.js | 244 +-
backend/src/utils/vcard.test.js | 241 +
backend/src/utils/vcardDocument.js | 657 +++
backend/src/utils/vcardHashes.js | 192 +
backend/src/utils/vcardProjection.js | 1299 +++++
backend/src/utils/vcardProperties.js | 31 +
backend/src/utils/vcardProperties.test.js | 3194 ++++++++++++
frontend/src/carddavConflictState.js | 137 +
frontend/src/carddavConflictState.test.js | 184 +
frontend/src/components/AdminPanel.jsx | 82 +-
frontend/src/components/CardDavConflicts.jsx | 284 ++
frontend/src/components/ContactsPage.jsx | 639 ++-
frontend/src/contactCarddavState.js | 228 +
frontend/src/contactCarddavState.test.js | 345 ++
frontend/src/contactLabels.js | 57 +
frontend/src/contactLabels.test.js | 107 +
frontend/src/locales/de.json | 85 +-
frontend/src/locales/en.json | 85 +-
frontend/src/locales/es.json | 85 +-
frontend/src/locales/fr.json | 85 +-
frontend/src/locales/i18n.test.js | 113 +-
frontend/src/locales/it.json | 85 +-
frontend/src/locales/ru.json | 88 +-
frontend/src/locales/zhCN.json | 82 +-
frontend/src/utils/api.js | 14 +-
70 files changed, 35164 insertions(+), 1097 deletions(-)
create mode 100644 backend/migrations/0034_carddav_incremental_sync.sql
create mode 100644 backend/migrations/0035_carddav_bidirectional_sync.sql
create mode 100644 backend/migrations/0036_carddav_bidirectional_cleanup.sql
create mode 100644 backend/migrations/0037_carddav_conflict_retention.sql
create mode 100644 backend/src/routes/carddav.test.js
create mode 100644 backend/src/routes/carddavAccount.test.js
create mode 100644 backend/src/routes/carddavConflicts.js
create mode 100644 backend/src/routes/carddavConflicts.test.js
create mode 100644 backend/src/routes/contacts.test.js
create mode 100644 backend/src/services/carddavClient.protocol.test.js
create mode 100644 backend/src/services/carddavConflictService.js
create mode 100644 backend/src/services/carddavConflictService.test.js
create mode 100644 backend/src/services/carddavContactService.integration.test.js
create mode 100644 backend/src/services/carddavContactService.js
create mode 100644 backend/src/services/carddavContactService.test.js
create mode 100644 backend/src/services/carddavFixtureServer.js
create mode 100644 backend/src/services/carddavMappingState.js
create mode 100644 backend/src/services/carddavMappingState.test.js
create mode 100644 backend/src/services/carddavMigration.db.test.js
create mode 100644 backend/src/services/carddavProjection.js
create mode 100644 backend/src/services/carddavProjection.test.js
create mode 100644 backend/src/services/carddavSync.db.test.js
create mode 100644 backend/src/services/carddavSync.integration.test.js
create mode 100644 backend/src/services/carddavSync.test.js
create mode 100644 backend/src/services/carddavTransport.js
create mode 100644 backend/src/services/carddavTransport.test.js
create mode 100644 backend/src/services/carddavXml.js
create mode 100644 backend/src/services/carddavXml.test.js
create mode 100644 backend/src/services/postgresTestHelpers.js
create mode 100644 backend/src/services/postgresTestHelpers.test.js
create mode 100644 backend/src/utils/vcard.test.js
create mode 100644 backend/src/utils/vcardDocument.js
create mode 100644 backend/src/utils/vcardHashes.js
create mode 100644 backend/src/utils/vcardProjection.js
create mode 100644 backend/src/utils/vcardProperties.js
create mode 100644 backend/src/utils/vcardProperties.test.js
create mode 100644 frontend/src/carddavConflictState.js
create mode 100644 frontend/src/carddavConflictState.test.js
create mode 100644 frontend/src/components/CardDavConflicts.jsx
create mode 100644 frontend/src/contactCarddavState.js
create mode 100644 frontend/src/contactCarddavState.test.js
create mode 100644 frontend/src/contactLabels.js
create mode 100644 frontend/src/contactLabels.test.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c590b96..e72544d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,24 @@ jobs:
name: Backend
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d postgres"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ env:
+ TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres
+
steps:
- uses: actions/checkout@v6
diff --git a/backend/migrations/0034_carddav_incremental_sync.sql b/backend/migrations/0034_carddav_incremental_sync.sql
new file mode 100644
index 0000000..5e4e07e
--- /dev/null
+++ b/backend/migrations/0034_carddav_incremental_sync.sql
@@ -0,0 +1,76 @@
+-- Remote CardDAV collection state is separate from the sync token and ETags
+-- Mailflow serves to its own CardDAV clients.
+DO $$
+BEGIN
+ IF EXISTS (
+ WITH ranked AS (
+ SELECT id,
+ row_number() OVER (
+ PARTITION BY user_id, external_url
+ ORDER BY created_at, id
+ ) AS position
+ FROM address_books
+ WHERE source = 'carddav' AND external_url IS NOT NULL
+ )
+ SELECT 1
+ FROM ranked
+ JOIN contacts contact ON contact.address_book_id = ranked.id
+ WHERE ranked.position > 1
+ ) THEN
+ RAISE EXCEPTION 'cannot consolidate populated duplicate CardDAV address books';
+ END IF;
+END $$;
+
+ALTER TABLE address_books
+ ADD COLUMN IF NOT EXISTS remote_sync_token TEXT,
+ ADD COLUMN IF NOT EXISTS remote_sync_capability TEXT NOT NULL DEFAULT 'unknown'
+ CHECK (remote_sync_capability IN ('unknown', 'sync-collection', 'snapshot')),
+ ADD COLUMN IF NOT EXISTS remote_sync_revision BIGINT NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS remote_projection_fingerprint TEXT;
+
+CREATE TABLE IF NOT EXISTS carddav_remote_objects (
+ address_book_id UUID NOT NULL REFERENCES address_books(id) ON DELETE CASCADE,
+ href TEXT NOT NULL,
+ remote_etag TEXT,
+ vcard TEXT NOT NULL,
+ primary_email TEXT,
+ disposition TEXT NOT NULL CHECK (disposition IN ('separate', 'merge', 'skip')),
+ local_contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
+ merge_before JSONB,
+ merge_applied JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (address_book_id, href)
+);
+
+UPDATE user_integrations
+SET config = config || jsonb_build_object('connectionGeneration', gen_random_uuid()::text)
+WHERE provider = 'carddav'
+ AND NOT (config ? 'connectionGeneration');
+
+WITH ranked AS (
+ SELECT id,
+ row_number() OVER (
+ PARTITION BY user_id, external_url
+ ORDER BY created_at, id
+ ) AS position
+ FROM address_books
+ WHERE source = 'carddav' AND external_url IS NOT NULL
+)
+DELETE FROM address_books
+WHERE id IN (SELECT id FROM ranked WHERE position > 1);
+
+CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_remote_book_idx
+ ON address_books (user_id, external_url)
+ WHERE source = 'carddav' AND external_url IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS carddav_remote_object_contact_idx
+ ON carddav_remote_objects (local_contact_id)
+ WHERE local_contact_id IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS carddav_remote_object_email_idx
+ ON carddav_remote_objects (address_book_id, primary_email);
+
+CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_merge_source_per_contact_idx
+ ON carddav_remote_objects (local_contact_id)
+ WHERE disposition = 'merge' AND local_contact_id IS NOT NULL;
diff --git a/backend/migrations/0035_carddav_bidirectional_sync.sql b/backend/migrations/0035_carddav_bidirectional_sync.sql
new file mode 100644
index 0000000..38258c2
--- /dev/null
+++ b/backend/migrations/0035_carddav_bidirectional_sync.sql
@@ -0,0 +1,76 @@
+ALTER TABLE address_books
+ ADD COLUMN IF NOT EXISTS remote_create_capability TEXT NOT NULL DEFAULT 'unknown'
+ CHECK (remote_create_capability IN ('unknown', 'allowed', 'denied')),
+ ADD COLUMN IF NOT EXISTS remote_update_capability TEXT NOT NULL DEFAULT 'unknown'
+ CHECK (remote_update_capability IN ('unknown', 'allowed', 'denied')),
+ ADD COLUMN IF NOT EXISTS remote_delete_capability TEXT NOT NULL DEFAULT 'unknown'
+ CHECK (remote_delete_capability IN ('unknown', 'allowed', 'denied'));
+
+ALTER TABLE contacts
+ ADD COLUMN IF NOT EXISTS additional_fields JSONB NOT NULL DEFAULT '[]'::jsonb;
+
+ALTER TABLE carddav_remote_objects
+ ALTER COLUMN disposition SET DEFAULT 'separate',
+ ADD COLUMN IF NOT EXISTS mapping_status TEXT NOT NULL DEFAULT 'pending_materialization'
+ CHECK (mapping_status IN ('pending_materialization', 'synced', 'pending_push', 'conflict')),
+ ADD COLUMN IF NOT EXISTS vcard_version TEXT
+ CHECK (vcard_version IS NULL OR vcard_version IN ('3.0', '4.0')),
+ ADD COLUMN IF NOT EXISTS remote_semantic_hash TEXT,
+ ADD COLUMN IF NOT EXISTS local_contact_hash TEXT,
+ ADD COLUMN IF NOT EXISTS mapping_revision BIGINT NOT NULL DEFAULT 0
+ CHECK (mapping_revision >= 0),
+ ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS last_push_error_code TEXT,
+ ADD COLUMN IF NOT EXISTS last_push_error_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS legacy_projection JSONB;
+
+UPDATE carddav_remote_objects
+SET legacy_projection = jsonb_build_object(
+ 'disposition', disposition,
+ 'merge_before', merge_before,
+ 'merge_applied', merge_applied
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_active_mapping_per_contact_idx
+ ON carddav_remote_objects (local_contact_id)
+ WHERE local_contact_id IS NOT NULL
+ AND mapping_status <> 'pending_materialization';
+
+CREATE TABLE IF NOT EXISTS carddav_conflicts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ address_book_id UUID NOT NULL,
+ href TEXT NOT NULL,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ base_local_hash TEXT,
+ remote_etag TEXT,
+ local_vcard TEXT,
+ remote_vcard TEXT,
+ local_tombstone BOOLEAN NOT NULL DEFAULT false,
+ remote_tombstone BOOLEAN NOT NULL DEFAULT false,
+ status TEXT NOT NULL DEFAULT 'unresolved'
+ CHECK (status IN ('unresolved', 'resolved')),
+ resolution TEXT
+ CHECK (resolution IS NULL OR resolution IN ('keep-mailflow', 'keep-carddav')),
+ resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
+ resolved_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT carddav_conflicts_remote_object_fkey FOREIGN KEY (address_book_id, href)
+ REFERENCES carddav_remote_objects(address_book_id, href) ON DELETE CASCADE,
+ CHECK (local_tombstone OR local_vcard IS NOT NULL),
+ CHECK (remote_tombstone OR remote_vcard IS NOT NULL),
+ CHECK (
+ (status = 'unresolved'
+ AND resolution IS NULL
+ AND resolved_by IS NULL
+ AND resolved_at IS NULL)
+ OR
+ (status = 'resolved'
+ AND resolution IS NOT NULL
+ AND resolved_at IS NOT NULL)
+ )
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_unresolved_conflict_per_mapping_idx
+ ON carddav_conflicts (address_book_id, href)
+ WHERE status = 'unresolved';
diff --git a/backend/migrations/0036_carddav_bidirectional_cleanup.sql b/backend/migrations/0036_carddav_bidirectional_cleanup.sql
new file mode 100644
index 0000000..6d253e0
--- /dev/null
+++ b/backend/migrations/0036_carddav_bidirectional_cleanup.sql
@@ -0,0 +1,42 @@
+UPDATE user_integrations
+SET config = config - 'dupMode'
+WHERE provider = 'carddav' AND config ? 'dupMode';
+
+DROP INDEX IF EXISTS carddav_one_merge_source_per_contact_idx;
+
+ALTER TABLE carddav_remote_objects
+ DROP COLUMN disposition,
+ DROP COLUMN merge_before,
+ DROP COLUMN merge_applied,
+ DROP COLUMN legacy_projection,
+ ADD COLUMN IF NOT EXISTS pending_operation TEXT
+ CHECK (pending_operation IS NULL OR pending_operation IN ('update', 'delete')),
+ ADD COLUMN IF NOT EXISTS pending_vcard TEXT,
+ ADD COLUMN IF NOT EXISTS pending_local_hash TEXT,
+ ADD COLUMN IF NOT EXISTS pending_remote_semantic_hash TEXT,
+ ADD COLUMN IF NOT EXISTS pending_started_at TIMESTAMPTZ,
+ ADD CHECK (
+ (
+ pending_operation IS NULL
+ AND pending_vcard IS NULL
+ AND pending_local_hash IS NULL
+ AND pending_remote_semantic_hash IS NULL
+ AND pending_started_at IS NULL
+ )
+ OR
+ (
+ pending_operation = 'update'
+ AND pending_vcard IS NOT NULL
+ AND pending_local_hash IS NOT NULL
+ AND pending_remote_semantic_hash IS NOT NULL
+ AND pending_started_at IS NOT NULL
+ )
+ OR
+ (
+ pending_operation = 'delete'
+ AND pending_vcard IS NULL
+ AND pending_local_hash IS NOT NULL
+ AND pending_remote_semantic_hash IS NULL
+ AND pending_started_at IS NOT NULL
+ )
+ );
diff --git a/backend/migrations/0037_carddav_conflict_retention.sql b/backend/migrations/0037_carddav_conflict_retention.sql
new file mode 100644
index 0000000..7714b95
--- /dev/null
+++ b/backend/migrations/0037_carddav_conflict_retention.sql
@@ -0,0 +1,6 @@
+ALTER TABLE carddav_conflicts
+ DROP CONSTRAINT carddav_conflicts_remote_object_fkey;
+
+ALTER TABLE carddav_conflicts
+ ADD CONSTRAINT carddav_conflicts_address_book_id_fkey
+ FOREIGN KEY (address_book_id) REFERENCES address_books(id) ON DELETE CASCADE;
diff --git a/backend/package.json b/backend/package.json
index fc54c5c..53bcb5c 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -5,7 +5,9 @@
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
- "test": "vitest run",
+ "test": "npm run test:unit && npm run test:db",
+ "test:unit": "vitest run --exclude='**/*.db.test.js' --exclude='**/*.integration.test.js'",
+ "test:db": "vitest run src/**/*.db.test.js src/**/*.integration.test.js",
"test:watch": "vitest",
"lint": "eslint src --max-warnings 0"
},
diff --git a/backend/src/index.js b/backend/src/index.js
index 0369188..4875141 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -29,6 +29,7 @@ import categoriesRoutes from './routes/categories.js';
import gtdRoutes from './routes/gtd.js';
import carddavRouter from './routes/carddav.js';
import carddavAccountRouter from './routes/carddavAccount.js';
+import carddavConflictsRouter from './routes/carddavConflicts.js';
import { startCardavScheduler } from './services/carddavSync.js';
import { encryptExistingCredentials, query } from './services/db.js';
import { runMigrations } from './services/migrations.js';
@@ -159,6 +160,7 @@ app.use('/api/rules', rulesRoutes);
app.use('/api/block-list', blockListRoutes);
app.use('/api/contacts', contactsRoutes);
app.use('/api/todoist', todoistRoutes);
+app.use('/api/carddav/conflicts', carddavConflictsRouter);
app.use('/api/carddav', carddavAccountRouter);
app.use('/api', aiRoutes);
app.use('/api', categoriesRoutes);
diff --git a/backend/src/routes/carddav.js b/backend/src/routes/carddav.js
index 231383d..c03f44b 100644
--- a/backend/src/routes/carddav.js
+++ b/backend/src/routes/carddav.js
@@ -11,12 +11,30 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
-import crypto from 'crypto';
import { query } from '../services/db.js';
-import { parseVCard } from '../utils/vcard.js';
import { authLimiterConfig } from '../services/authLimiter.js';
import { consume as rlConsume } from '../services/rateLimiter.js';
import { logAuthEvent } from '../services/authEvents.js';
+import { xmlEscape } from '../services/carddavXml.js';
+import {
+ CARDDAV_CONTACT_ERROR_STATUS,
+ createContactFromVCard,
+ deleteContactFromVCard,
+ replaceContactFromVCard,
+} from '../services/carddavContactService.js';
+import { presentedEtag, presentedVCard } from '../utils/vcardProperties.js';
+
+// Select the modeled columns + the retained remote vCard so a mapped contact can be
+// served losslessly (see presentedVCard). local_contact_id maps at most one active
+// remote object per contact, so this stays one row per contact.
+const CONTACT_READ_COLUMNS = `
+ c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones,
+ c.organization, c.notes, c.photo_data, c.additional_fields,
+ c.vcard, c.etag, mapping.vcard AS mapping_vcard`;
+const CONTACT_MAPPING_JOIN = `
+ LEFT JOIN carddav_remote_objects mapping
+ ON mapping.local_contact_id = c.id
+ AND mapping.mapping_status <> 'pending_materialization'`;
const router = Router();
@@ -37,6 +55,7 @@ setInterval(() => {
// CardDAV clients can issue dozens of requests per sync (PROPFIND + per-card GET/PUT).
// Use a generous per-IP ceiling independent of the login rate-limit config.
const CARDDAV_MAX_REQUESTS = 500;
+const CARDDAV_MAX_BODY_BYTES = 1024 * 1024;
function cardavRateLimit(req, res, next) {
const { windowMs } = authLimiterConfig;
@@ -114,6 +133,10 @@ async function cardavAuth(req, res, next) {
router.use(cardavRateLimit);
router.use(cardavAuth);
+router.param('userId', (req, res, next, userId) => {
+ if (userId !== req.cardavUserId) return res.status(403).end();
+ next();
+});
// ── XML helpers ───────────────────────────────────────────────────────────────
@@ -154,35 +177,161 @@ function propstat(props, status) {
].join('');
}
-function xmlEscape(s) {
- return String(s || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
function sendXml(res, status, xml) {
res.status(status)
.setHeader('Content-Type', 'application/xml; charset=utf-8')
.send(xml);
}
-// Collect the request body as a string by reading the raw stream.
+function bodyTooLargeError() {
+ return Object.assign(new Error('CardDAV request body exceeds 1 MiB'), {
+ code: 'ERR_CARDDAV_BODY_TOO_LARGE',
+ });
+}
+
+function requestAbortedError() {
+ return Object.assign(new Error('CardDAV request body ended prematurely'), {
+ code: 'ERR_CARDDAV_REQUEST_ABORTED',
+ });
+}
+
+// Collect the request body as bytes before converting it to UTF-8.
// We do not go through express.json/text — CardDAV uses custom content types.
-function rawBody(req) {
+export function readRawBody(req, { maxBytes = Infinity } = {}) {
return new Promise((resolve, reject) => {
// If a body parser already collected it (unlikely here), use it.
- if (typeof req.body === 'string') return resolve(req.body);
- if (Buffer.isBuffer(req.body)) return resolve(req.body.toString('utf8'));
- let data = '';
- req.setEncoding('utf8');
- req.on('data', chunk => { data += chunk; });
- req.on('end', () => resolve(data));
- req.on('error', reject);
+ if (typeof req.body === 'string') {
+ return Buffer.byteLength(req.body, 'utf8') > maxBytes
+ ? reject(bodyTooLargeError())
+ : resolve(req.body);
+ }
+ if (Buffer.isBuffer(req.body)) {
+ return req.body.length > maxBytes
+ ? reject(bodyTooLargeError())
+ : resolve(req.body.toString('utf8'));
+ }
+
+ const chunks = [];
+ let total = 0;
+ let state = 'collecting';
+ const cleanup = () => {
+ req.removeListener('data', onData);
+ req.removeListener('end', onEnd);
+ req.removeListener('error', onError);
+ req.removeListener('close', onClose);
+ req.removeListener('aborted', onAborted);
+ };
+ const rejectAndDrain = (error, resume) => {
+ if (state !== 'collecting') return;
+ state = 'draining';
+ req.removeListener('data', onData);
+ req.removeListener('aborted', onAborted);
+ reject(error);
+ if (resume) req.resume();
+ };
+ const onData = chunk => {
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ if (total + buffer.length > maxBytes) {
+ rejectAndDrain(bodyTooLargeError(), true);
+ return;
+ }
+ total += buffer.length;
+ chunks.push(buffer);
+ };
+ const onEnd = () => {
+ const shouldResolve = state === 'collecting';
+ state = 'settled';
+ cleanup();
+ if (shouldResolve) resolve(Buffer.concat(chunks, total).toString('utf8'));
+ };
+ const onError = error => {
+ if (state === 'draining') return;
+ state = 'settled';
+ cleanup();
+ reject(error);
+ };
+ const onClose = () => {
+ const shouldReject = state === 'collecting';
+ state = 'settled';
+ cleanup();
+ if (shouldReject) reject(requestAbortedError());
+ };
+ const onAborted = () => rejectAndDrain(requestAbortedError(), false);
+ req.on('data', onData);
+ req.on('end', onEnd);
+ req.on('error', onError);
+ req.on('close', onClose);
+ req.on('aborted', onAborted);
});
}
+function ifMatchEtag(req) {
+ const value = req.headers['if-match'];
+ return value && value !== '*' ? value.replace(/^"(.*)"$/, '$1') : null;
+}
+
+function requiresAbsentResource(req) {
+ return String(req.headers['if-none-match'] || '').trim() === '*';
+}
+
+// Read a served contact (modeled columns + retained mapping vCard) so the route can
+// serve, and validate preconditions against, the presented representation.
+async function readServedContact(bookId, userId, uid) {
+ const { rows: [contact] } = await query(
+ `SELECT ${CONTACT_READ_COLUMNS}
+ FROM contacts c
+ JOIN address_books ab ON ab.id = c.address_book_id
+ ${CONTACT_MAPPING_JOIN}
+ WHERE ab.id = $1 AND ab.user_id = $2 AND c.uid = $3`,
+ [bookId, userId, uid],
+ );
+ return contact || null;
+}
+
+// The strong validator MailFlow's CardDAV server exposes, derived from the presented
+// document, so GET/REPORT/PROPFIND all quote the same ETag and mutations
+// validate against it.
+function servedEtag(contact) {
+ return `"${presentedEtag(contact)}"`;
+}
+
+// A mapped contact (retained upstream document present) — its mutations delegate to the
+// shared external write path and must be conditional.
+function isMappedContact(contact) {
+ return Boolean(contact?.mapping_vcard);
+}
+
+// RFC 7232 §3.1/§3.2 + RFC 6352 §6.3.2 preconditions against the SERVED ETag,
+// centralized so every mutation path fences on the same validator GET exposes. Returns
+// the HTTP status to send, or null to proceed.
+function preconditionFailure(req, contact) {
+ if (requiresAbsentResource(req)) return contact ? 412 : null;
+ if (!contact) return null;
+ const clientEtag = ifMatchEtag(req);
+ if (isMappedContact(contact)) {
+ // A mapped mutation REQUIRES a REAL strong validator. ifMatchEtag returns
+ // null for absent, `*`, and empty If-Match — all of which lack freshness and would let
+ // a client authoritatively overwrite the upstream document — so 428 (retry with a real
+ // If-Match). A real ETag is compared strongly (412 on mismatch).
+ if (!clientEtag) return 428;
+ return clientEtag !== presentedEtag(contact) ? 412 : null;
+ }
+ return clientEtag && clientEtag !== presentedEtag(contact) ? 412 : null;
+}
+
+function carddavMutationError(res, err, operation) {
+ const status = {
+ ...CARDDAV_CONTACT_ERROR_STATUS,
+ ERR_CARDDAV_BODY_TOO_LARGE: 413,
+ ERR_CARDDAV_REQUEST_ABORTED: 400,
+ ERR_LOCAL_ETAG_MISMATCH: 412,
+ ERR_LOCAL_PRECONDITION_FAILED: 412,
+ }[err.code] ?? (Number.isInteger(err.status) ? err.status : null);
+ if (status) return res.status(status).end();
+ console.error(`CardDAV ${operation} error:`, err);
+ return res.status(500).end();
+}
+
// ── OPTIONS (broadcast CardDAV support) ──────────────────────────────────────
router.options('*', (req, res) => {
@@ -213,7 +362,6 @@ router.propfind('/', async (req, res) => {
router.propfind('/:userId/', async (req, res) => {
const userId = req.cardavUserId;
- if (req.params.userId !== userId) return res.status(403).end();
const principalPath = `/carddav/${userId}/`;
@@ -242,7 +390,6 @@ router.propfind('/:userId/', async (req, res) => {
router.propfind('/:userId/:bookId/', async (req, res) => {
const userId = req.cardavUserId;
- if (req.params.userId !== userId) return res.status(403).end();
const depth = req.headers['depth'] || '0';
@@ -268,9 +415,11 @@ router.propfind('/:userId/:bookId/', async (req, res) => {
return sendXml(res, 207, multistatus([bookResponse]));
}
- // Depth: 1 — list all VCards in the book.
+ // Depth: 1 — list all VCards in the book. Quote the PRESENTED ETag so
+ // PROPFIND, REPORT, and GET all expose the same strong validator.
const contacts = await query(
- 'SELECT uid, etag FROM contacts WHERE address_book_id = $1',
+ `SELECT ${CONTACT_READ_COLUMNS} FROM contacts c ${CONTACT_MAPPING_JOIN}
+ WHERE c.address_book_id = $1`,
[book.id]
);
@@ -278,7 +427,7 @@ router.propfind('/:userId/:bookId/', async (req, res) => {
response(`${bookPath}${encodeURIComponent(c.uid)}.vcf`, [
propstat([
' ',
- `"${xmlEscape(c.etag)}" `,
+ `${servedEtag(c)} `,
'text/vcard;charset=utf-8 ',
], '200 OK'),
])
@@ -291,7 +440,6 @@ router.propfind('/:userId/:bookId/', async (req, res) => {
router.report('/:userId/:bookId/', async (req, res) => {
const userId = req.cardavUserId;
- if (req.params.userId !== userId) return res.status(403).end();
const bookResult = await query(
'SELECT * FROM address_books WHERE id = $1 AND user_id = $2',
@@ -301,12 +449,22 @@ router.report('/:userId/:bookId/', async (req, res) => {
const book = bookResult.rows[0];
const bookPath = `/carddav/${userId}/${book.id}/`;
- const body = await rawBody(req);
+ // Bound the REPORT body like PUT — the handler only tests it for a marker
+ // string, so an unbounded body would be a memory-exhaustion vector (413).
+ let body;
+ try {
+ body = await readRawBody(req, { maxBytes: CARDDAV_MAX_BODY_BYTES });
+ } catch (err) {
+ return carddavMutationError(res, err, 'REPORT');
+ }
const isSyncCollection = body.includes('sync-collection');
- // Fetch all contacts with their vCard data.
+ // Fetch all contacts with their vCard data (retained remote vCard included so a
+ // mapped contact is served losslessly).
const contacts = await query(
- 'SELECT uid, vcard, etag FROM contacts WHERE address_book_id = $1',
+ `SELECT ${CONTACT_READ_COLUMNS}
+ FROM contacts c ${CONTACT_MAPPING_JOIN}
+ WHERE c.address_book_id = $1`,
[book.id]
);
@@ -315,9 +473,9 @@ router.report('/:userId/:bookId/', async (req, res) => {
return response(href, [
propstat([
' ',
- `"${xmlEscape(c.etag)}" `,
+ `${servedEtag(c)} `,
'text/vcard;charset=utf-8 ',
- `${xmlEscape(c.vcard || '')} `,
+ `${xmlEscape(presentedVCard(c) || '')} `,
], '200 OK'),
]);
});
@@ -340,42 +498,28 @@ router.report('/:userId/:bookId/', async (req, res) => {
router.get('/:userId/:bookId/:filename', async (req, res) => {
const userId = req.cardavUserId;
- if (req.params.userId !== userId) return res.status(403).end();
const uid = req.params.filename.replace(/\.vcf$/i, '');
- const result = await query(
- `SELECT c.vcard, c.etag FROM contacts c
- JOIN address_books ab ON ab.id = c.address_book_id
- WHERE ab.id = $1 AND ab.user_id = $2 AND c.uid = $3`,
- [req.params.bookId, userId, uid]
- );
- if (!result.rows.length) return res.status(404).end();
+ const contact = await readServedContact(req.params.bookId, userId, uid);
+ if (!contact) return res.status(404).end();
- const { vcard, etag } = result.rows[0];
res.set({
'Content-Type': 'text/vcard;charset=utf-8',
- 'ETag': `"${etag}"`,
- }).send(vcard);
+ 'ETag': servedEtag(contact),
+ }).send(presentedVCard(contact));
});
// ── PUT /{userId}/{bookId}/{uid}.vcf (create or update) ──────────────────────
router.put('/:userId/:bookId/:filename', async (req, res) => {
const userId = req.cardavUserId;
- if (req.params.userId !== userId) return res.status(403).end();
const uid = req.params.filename.replace(/\.vcf$/i, '');
- const body = await rawBody(req);
- if (!body.trim()) return res.status(400).end();
-
- const parsed = parseVCard(body);
- const vcard = body; // store what the client sent verbatim
- const etag = crypto.createHash('md5').update(vcard).digest('hex');
-
- const primaryEmail = parsed.emails[0]?.value?.toLowerCase() || null;
try {
+ const body = await readRawBody(req, { maxBytes: CARDDAV_MAX_BODY_BYTES });
+ if (!body.trim()) return res.status(400).end();
const bookResult = await query(
'SELECT id FROM address_books WHERE id = $1 AND user_id = $2',
[req.params.bookId, userId]
@@ -383,65 +527,33 @@ router.put('/:userId/:bookId/:filename', async (req, res) => {
if (!bookResult.rows.length) return res.status(404).end();
const bookId = bookResult.rows[0].id;
- const existing = await query(
- 'SELECT id, etag FROM contacts WHERE address_book_id = $1 AND uid = $2',
- [bookId, uid]
- );
-
- if (existing.rows.length) {
- // Enforce If-Match precondition (RFC 6352 §6.3.2)
- const ifMatch = req.headers['if-match'];
- if (ifMatch && ifMatch !== '*') {
- const clientEtag = ifMatch.replace(/^"(.*)"$/, '$1');
- if (clientEtag !== existing.rows[0].etag) return res.status(412).end();
- }
- // Update
- await query(`
- UPDATE contacts SET
- vcard = $1, etag = $2,
- display_name = $3, first_name = $4, last_name = $5,
- primary_email = $6, emails = $7, phones = $8,
- organization = $9, notes = $10, photo_data = $11,
- is_auto = false, updated_at = NOW()
- WHERE id = $12
- `, [
- vcard, etag,
- parsed.displayName, parsed.firstName, parsed.lastName,
- primaryEmail,
- JSON.stringify(parsed.emails), JSON.stringify(parsed.phones),
- parsed.organization, parsed.notes, parsed.photoData,
- existing.rows[0].id,
- ]);
- await query(
- 'UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1',
- [bookId]
- );
- res.set('ETag', `"${etag}"`).status(204).end();
+ const existing = await readServedContact(bookId, userId, uid);
+ const failure = preconditionFailure(req, existing);
+ if (failure) return res.status(failure).end();
+
+ if (existing) {
+ await replaceContactFromVCard(userId, {
+ localAddressBookId: bookId,
+ uid,
+ rawVCard: body,
+ expectedLocalEtag: existing.etag,
+ });
+ const served = await readServedContact(bookId, userId, uid);
+ if (served) res.set('ETag', servedEtag(served));
+ res.status(204).end();
} else {
- // Create
- await query(`
- INSERT INTO contacts (
- address_book_id, user_id, uid, vcard, etag,
- display_name, first_name, last_name, primary_email,
- emails, phones, organization, notes, photo_data, is_auto
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, false)
- `, [
- bookId, userId, uid, vcard, etag,
- parsed.displayName, parsed.firstName, parsed.lastName,
- primaryEmail,
- JSON.stringify(parsed.emails), JSON.stringify(parsed.phones),
- parsed.organization, parsed.notes, parsed.photoData,
- ]);
- await query(
- 'UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1',
- [bookId]
- );
- res.set('ETag', `"${etag}"`).status(201).end();
+ await createContactFromVCard(userId, {
+ localAddressBookId: bookId,
+ uid,
+ rawVCard: body,
+ ...(requiresAbsentResource(req) ? { expectedAbsent: true } : {}),
+ });
+ const served = await readServedContact(bookId, userId, uid);
+ if (served) res.set('ETag', servedEtag(served));
+ res.status(201).end();
}
} catch (err) {
- if (err.code === '23505') return res.status(409).end(); // unique conflict
- console.error('CardDAV PUT error:', err);
- res.status(500).end();
+ carddavMutationError(res, err, 'PUT');
}
});
@@ -449,30 +561,28 @@ router.put('/:userId/:bookId/:filename', async (req, res) => {
router.delete('/:userId/:bookId/:filename', async (req, res) => {
const userId = req.cardavUserId;
- if (req.params.userId !== userId) return res.status(403).end();
const uid = req.params.filename.replace(/\.vcf$/i, '');
try {
- const result = await query(
- `DELETE FROM contacts
- USING address_books
- WHERE contacts.address_book_id = address_books.id
- AND address_books.id = $1
- AND address_books.user_id = $2
- AND contacts.uid = $3
- RETURNING address_books.id AS book_id`,
- [req.params.bookId, userId, uid]
- );
- if (!result.rows.length) return res.status(404).end();
- await query(
- 'UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1',
- [result.rows[0].book_id]
+ const bookResult = await query(
+ 'SELECT id FROM address_books WHERE id = $1 AND user_id = $2',
+ [req.params.bookId, userId]
);
+ if (!bookResult.rows.length) return res.status(404).end();
+ const bookId = bookResult.rows[0].id;
+ const existing = await readServedContact(bookId, userId, uid);
+ if (!existing) return res.status(404).end();
+ const failure = preconditionFailure(req, existing);
+ if (failure) return res.status(failure).end();
+ await deleteContactFromVCard(userId, {
+ localAddressBookId: bookId,
+ uid,
+ expectedLocalEtag: existing.etag,
+ });
res.status(204).end();
} catch (err) {
- console.error('CardDAV DELETE error:', err);
- res.status(500).end();
+ carddavMutationError(res, err, 'DELETE');
}
});
diff --git a/backend/src/routes/carddav.test.js b/backend/src/routes/carddav.test.js
new file mode 100644
index 0000000..3484bdf
--- /dev/null
+++ b/backend/src/routes/carddav.test.js
@@ -0,0 +1,704 @@
+import { EventEmitter, once } from 'node:events';
+import { Readable } from 'node:stream';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ query: vi.fn(),
+ createContactFromVCard: vi.fn(),
+ replaceContactFromVCard: vi.fn(),
+ deleteContactFromVCard: vi.fn(),
+}));
+
+vi.mock('bcryptjs', () => ({
+ default: {
+ hashSync: vi.fn(() => 'hash'),
+ compare: vi.fn(),
+ },
+}));
+vi.mock('../services/db.js', () => ({ query: mocks.query }));
+vi.mock('../services/authLimiter.js', () => ({
+ authLimiterConfig: { maxRequests: 5, windowMs: 60_000 },
+}));
+vi.mock('../services/rateLimiter.js', () => ({ consume: vi.fn() }));
+vi.mock('../services/authEvents.js', () => ({ logAuthEvent: vi.fn() }));
+vi.mock('../services/carddavContactService.js', () => ({
+ CARDDAV_CONTACT_ERROR_STATUS: {
+ ERR_CONTACT_VALIDATION: 400,
+ ERR_CONTACT_UID_MISMATCH: 400,
+ ERR_CONTACT_NOT_FOUND: 404,
+ ERR_ADDRESS_BOOK_NOT_FOUND: 404,
+ ERR_CONTACT_EXISTS: 409,
+ ERR_CARDDAV_CONFLICT: 409,
+ ERR_CARDDAV_FINAL_FENCE: 503,
+ ERR_CARDDAV_STALE_GENERATION: 503,
+ ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
+ ERR_CARDDAV_PENDING_INTENT: 409,
+ ERR_CARDDAV_READ_ONLY: 403,
+ '23505': 409,
+ },
+ createContactFromVCard: mocks.createContactFromVCard,
+ replaceContactFromVCard: mocks.replaceContactFromVCard,
+ deleteContactFromVCard: mocks.deleteContactFromVCard,
+}));
+
+const { presentedEtag } = await import('../utils/vcardProperties.js');
+const { default: router, readRawBody } = await import('./carddav.js');
+
+// A served contact row as readServedContact returns it (modeled columns + retained
+// mapping vCard). The served ETag is derived from the presented document.
+const SERVED_VCARD = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD\r\n';
+function servedContactRow(overrides = {}) {
+ return {
+ id: 'contact-1', uid: 'contact-uid', etag: 'local-etag', vcard: SERVED_VCARD,
+ mapping_vcard: null, display_name: 'Confirmed', first_name: null, last_name: null,
+ emails: [], phones: [], organization: null, notes: null, photo_data: null,
+ additional_fields: [], ...overrides,
+ };
+}
+const PRESENTED_ETAG = presentedEtag(servedContactRow());
+const SERVED_ETAG = `"${PRESENTED_ETAG}"`;
+
+function handler(method, path) {
+ return router.stack
+ .find(layer => layer.route?.path === path && layer.route.methods[method])
+ .route.stack.at(-1).handle;
+}
+
+const reportHandler = handler('report', '/:userId/:bookId/');
+const getHandler = handler('get', '/:userId/:bookId/:filename');
+const putHandler = handler('put', '/:userId/:bookId/:filename');
+const deleteHandler = handler('delete', '/:userId/:bookId/:filename');
+
+describe('userId route parameter', () => {
+ it('rejects a userId that differs from the authenticated CardDAV user', () => {
+ const guard = router.params.userId?.[0];
+ const res = response();
+ const next = vi.fn();
+
+ guard?.({ cardavUserId: 'user-1' }, res, next, 'user-2');
+
+ expect(guard).toBeTypeOf('function');
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.end).toHaveBeenCalledOnce();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('continues when the route and authenticated user IDs match', () => {
+ const guard = router.params.userId?.[0];
+ const res = response();
+ const next = vi.fn();
+
+ guard?.({ cardavUserId: 'user-1' }, res, next, 'user-1');
+
+ expect(next).toHaveBeenCalledOnce();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+});
+
+function response() {
+ return {
+ end: vi.fn(),
+ send: vi.fn(),
+ set: vi.fn().mockReturnThis(),
+ setHeader: vi.fn().mockReturnThis(),
+ status: vi.fn().mockReturnThis(),
+ };
+}
+
+function request(overrides = {}) {
+ return {
+ cardavUserId: 'user-1',
+ params: {
+ userId: 'user-1',
+ bookId: 'book-1',
+ filename: 'contact-uid.vcf',
+ },
+ headers: {},
+ body: [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:contact-uid',
+ 'FN:Ada Lovelace',
+ 'EMAIL:ada@example.test',
+ 'END:VCARD',
+ ].join('\r\n'),
+ ...overrides,
+ };
+}
+
+function streamingRequest(chunks, overrides = {}) {
+ const req = Readable.from(chunks);
+ return Object.assign(req, request({ body: undefined }), overrides);
+}
+
+function lifecycleRequest(overrides = {}) {
+ return Object.assign(new EventEmitter(), request({ body: undefined }), {
+ resume: vi.fn(),
+ ...overrides,
+ });
+}
+
+async function settlementWithin(promise, timeoutMs = 25) {
+ let timer;
+ const timeout = new Promise(resolve => {
+ timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs);
+ });
+ const settlement = promise.then(
+ value => ({ kind: 'resolved', value }),
+ error => ({ kind: 'rejected', error }),
+ );
+ const result = await Promise.race([settlement, timeout]);
+ clearTimeout(timer);
+ return result;
+}
+
+function mockResource(existing = servedContactRow(), created = existing ?? servedContactRow()) {
+ let contactReads = 0;
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes('FROM address_books') && !sql.includes('JOIN')) {
+ return { rows: [{ id: 'book-1', sync_token: 'sync-1' }] };
+ }
+ if (sql.includes('FROM contacts c') && sql.includes('c.uid = $3')) {
+ // First read = existence/precondition check; a later read = post-write served ETag.
+ contactReads++;
+ const row = contactReads === 1 ? existing : created;
+ return { rows: row ? [row] : [] };
+ }
+ if (sql.includes('DELETE FROM contacts')) return { rows: [{ book_id: 'book-1' }] };
+ return { rows: [] };
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('readRawBody stream lifecycle', () => {
+ it('keeps tail errors protected until an over-limit request finishes draining', async () => {
+ const tailError = new Error('tail drain failed');
+ let unhandledError;
+ const req = lifecycleRequest();
+ req.resume.mockImplementation(() => {
+ try {
+ req.emit('error', tailError);
+ } catch (error) {
+ unhandledError = error;
+ }
+ req.emit('close');
+ });
+
+ const body = readRawBody(req, { maxBytes: 1 });
+ req.emit('data', Buffer.from('xx'));
+
+ await expect(body).rejects.toMatchObject({ code: 'ERR_CARDDAV_BODY_TOO_LARGE' });
+ expect(unhandledError).toBeUndefined();
+ expect(req.resume).toHaveBeenCalledOnce();
+ for (const event of ['data', 'end', 'error', 'close', 'aborted']) {
+ expect(req.listenerCount(event), event).toBe(0);
+ }
+ });
+
+ it.each(['close', 'aborted'])(
+ 'rejects once and detaches when a partial request emits %s without end/error',
+ async event => {
+ const req = lifecycleRequest();
+ let settlements = 0;
+ const body = readRawBody(req, { maxBytes: 10 }).then(
+ value => {
+ settlements += 1;
+ return value;
+ },
+ error => {
+ settlements += 1;
+ throw error;
+ },
+ );
+
+ req.emit('data', Buffer.from('partial'));
+ req.emit(event);
+
+ const outcome = await settlementWithin(body);
+ expect(outcome).toMatchObject({
+ kind: 'rejected',
+ error: { code: 'ERR_CARDDAV_REQUEST_ABORTED' },
+ });
+ req.emit(event === 'close' ? 'aborted' : 'close');
+ req.emit('end');
+ await Promise.resolve();
+ expect(settlements).toBe(1);
+ for (const listenerEvent of ['data', 'end', 'error', 'close', 'aborted']) {
+ expect(req.listenerCount(listenerEvent), listenerEvent).toBe(0);
+ }
+ },
+ );
+});
+
+describe('PUT CardDAV contact resource', () => {
+ it('returns 400 when the request aborts before its body ends', async () => {
+ const req = lifecycleRequest();
+ const res = response();
+
+ const handling = putHandler(req, res);
+ req.emit('data', Buffer.from('partial'));
+ req.emit('aborted');
+
+ expect(await settlementWithin(handling)).toMatchObject({ kind: 'resolved' });
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ });
+
+ it('rejects a multi-chunk body at 1 MiB + 1 before delegation, then drains and detaches', async () => {
+ mockResource();
+ const consumed = [];
+ const req = streamingRequest((function* bodyChunks() {
+ for (const chunk of [Buffer.alloc(1024 * 1024), Buffer.from('x'), Buffer.from('tail')]) {
+ consumed.push(chunk.length);
+ yield chunk;
+ }
+ })());
+ const setEncoding = vi.spyOn(req, 'setEncoding');
+ const ended = once(req, 'end');
+ const res = response();
+
+ await putHandler(req, res);
+ await ended;
+
+ expect(res.status).toHaveBeenCalledWith(413);
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ expect(setEncoding).not.toHaveBeenCalled();
+ expect(consumed).toEqual([1024 * 1024, 1, 4]);
+ expect(req.readableEnded).toBe(true);
+ expect(req.listenerCount('data')).toBe(0);
+ expect(req.listenerCount('end')).toBe(0);
+ expect(req.listenerCount('error')).toBe(0);
+ });
+
+ it.each([
+ ['stream', () => streamingRequest([Buffer.alloc(1024 * 1024 - 1), Buffer.from('x')])],
+ ['string', () => request({ body: '🙂'.repeat(256 * 1024) })],
+ ['Buffer', () => request({ body: Buffer.alloc(1024 * 1024) })],
+ ])('allows an exact 1 MiB %s body to reach normal vCard validation', async (_kind, makeRequest) => {
+ mockResource();
+ mocks.replaceContactFromVCard.mockRejectedValueOnce(
+ Object.assign(new Error('invalid vCard'), { code: 'ERR_CONTACT_VALIDATION' }),
+ );
+ const res = response();
+
+ await putHandler(makeRequest(), res);
+
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledOnce();
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it.each([
+ ['string', '🙂'.repeat(256 * 1024) + 'x'],
+ ['Buffer', Buffer.alloc(1024 * 1024 + 1)],
+ ])('rejects a pre-collected %s body at 1 MiB + 1 before delegation', async (_kind, body) => {
+ mockResource();
+ const res = response();
+
+ await putHandler(request({ body }), res);
+
+ expect(res.status).toHaveBeenCalledWith(413);
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ });
+
+ it('delegates a mapped replacement exactly once with raw vCard and matching local ETag', async () => {
+ mockResource();
+ mocks.replaceContactFromVCard.mockResolvedValueOnce({ etag: 'confirmed-etag' });
+ const req = request({ headers: { 'if-match': SERVED_ETAG } });
+ const res = response();
+
+ await putHandler(req, res);
+
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledWith('user-1', {
+ localAddressBookId: 'book-1',
+ uid: 'contact-uid',
+ rawVCard: req.body,
+ expectedLocalEtag: 'local-etag',
+ });
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(res.set).toHaveBeenCalledWith('ETag', SERVED_ETAG);
+ expect(res.status).toHaveBeenCalledWith(204);
+ });
+
+ it('requires a conditional request on a mapped PUT: no If-Match → 428, nothing pushed', async () => {
+ mockResource(servedContactRow({
+ mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n',
+ }));
+ const res = response();
+
+ await putHandler(request(), res);
+
+ expect(res.status).toHaveBeenCalledWith(428);
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ });
+
+ it.each([['star', '*'], ['empty', '']])(
+ 'rejects a mapped PUT whose If-Match is %s (not a real strong ETag) → 428, nothing pushed',
+ async (_kind, ifMatch) => {
+ mockResource(servedContactRow({
+ mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n',
+ }));
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-match': ifMatch } }), res);
+
+ expect(res.status).toHaveBeenCalledWith(428);
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ });
+
+ it('delegates a mapped PUT that carries a matching If-Match', async () => {
+ const mapped = servedContactRow({
+ mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n',
+ });
+ mockResource(mapped);
+ mocks.replaceContactFromVCard.mockResolvedValueOnce({ etag: 'x' });
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-match': `"${presentedEtag(mapped)}"` } }), res);
+
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(204);
+ });
+
+ it('delegates a new resource exactly once with its local book, UID, and raw vCard', async () => {
+ mockResource(null);
+ mocks.createContactFromVCard.mockResolvedValueOnce({ etag: 'confirmed-etag' });
+ const req = request();
+ const res = response();
+
+ await putHandler(req, res);
+
+ expect(mocks.createContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(mocks.createContactFromVCard).toHaveBeenCalledWith('user-1', {
+ localAddressBookId: 'book-1',
+ uid: 'contact-uid',
+ rawVCard: req.body,
+ });
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ expect(res.set).toHaveBeenCalledWith('ETag', SERVED_ETAG);
+ expect(res.status).toHaveBeenCalledWith(201);
+ });
+
+ it('returns 412 without delegation when If-None-Match requires an existing resource to be absent', async () => {
+ mockResource();
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-none-match': '*' } }), res);
+
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(412);
+ });
+
+ it('delegates an absent If-None-Match resource once with the create-only precondition', async () => {
+ mockResource(null);
+ mocks.createContactFromVCard.mockResolvedValueOnce({ etag: 'confirmed-etag' });
+ const req = request({ headers: { 'if-none-match': ' * ' } });
+ const res = response();
+
+ await putHandler(req, res);
+
+ expect(mocks.createContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(mocks.createContactFromVCard).toHaveBeenCalledWith('user-1', {
+ localAddressBookId: 'book-1',
+ uid: 'contact-uid',
+ rawVCard: req.body,
+ expectedAbsent: true,
+ });
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(201);
+ });
+
+ it('maps a typed local create-only failure to 412', async () => {
+ mockResource(null);
+ mocks.createContactFromVCard.mockRejectedValueOnce(Object.assign(
+ new Error('The contact already exists'),
+ { code: 'ERR_LOCAL_PRECONDITION_FAILED' },
+ ));
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-none-match': '*' } }), res);
+
+ expect(res.status).toHaveBeenCalledWith(412);
+ expect(res.end).toHaveBeenCalled();
+ });
+
+ it('returns 412 without delegation when If-Match does not match confirmed local state', async () => {
+ mockResource();
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-match': '"stale-etag"' } }), res);
+
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(412);
+ });
+
+ it('propagates an upstream failure without route-level local mutation', async () => {
+ mockResource();
+ mocks.replaceContactFromVCard.mockRejectedValueOnce(Object.assign(new Error('Unavailable'), { status: 503 }));
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res);
+
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(mocks.query.mock.calls.every(([sql]) => /^\s*SELECT\b/.test(sql))).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.end).toHaveBeenCalled();
+ });
+
+ it.each([
+ ['ERR_CARDDAV_FINAL_FENCE'],
+ ['ERR_CARDDAV_STALE_GENERATION'],
+ ])('maps the self-healing %s race to a retriable 503 for external DAV clients', async code => {
+ mockResource();
+ mocks.replaceContactFromVCard.mockRejectedValueOnce(
+ Object.assign(new Error('mapping changed after the remote write'), { code }),
+ );
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res);
+
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.end).toHaveBeenCalled();
+ });
+
+ it.each([
+ ['ERR_CARDDAV_AMBIGUOUS_WRITE'],
+ ['ERR_CARDDAV_PENDING_INTENT'],
+ ])('maps the post-write %s to a non-retriable 409 for external DAV clients', async code => {
+ mockResource();
+ mocks.replaceContactFromVCard.mockRejectedValueOnce(
+ Object.assign(new Error('post-write state is indeterminate'), { code }),
+ );
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res);
+
+ expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1);
+ // 409 (not a retriable 5xx) so DAV clients refresh state instead of re-issuing
+ // a mutation whose remote effect may already have landed.
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.end).toHaveBeenCalled();
+ });
+});
+
+describe('DELETE CardDAV contact resource', () => {
+ it.each([['absent', undefined], ['star', '*'], ['empty', '']])(
+ 'requires a real strong If-Match on a mapped DELETE (%s) → 428, nothing deleted',
+ async (_kind, ifMatch) => {
+ mockResource(servedContactRow({
+ mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nEND:VCARD\r\n',
+ }));
+ const res = response();
+
+ await deleteHandler(request({ headers: ifMatch === undefined ? {} : { 'if-match': ifMatch } }), res);
+
+ expect(res.status).toHaveBeenCalledWith(428);
+ expect(mocks.deleteContactFromVCard).not.toHaveBeenCalled();
+ });
+
+ it('delegates exactly once with matching local ETag', async () => {
+ mockResource();
+ mocks.deleteContactFromVCard.mockResolvedValueOnce({ ok: true });
+ const res = response();
+
+ await deleteHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res);
+
+ expect(mocks.deleteContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(mocks.deleteContactFromVCard).toHaveBeenCalledWith('user-1', {
+ localAddressBookId: 'book-1',
+ uid: 'contact-uid',
+ expectedLocalEtag: 'local-etag',
+ });
+ expect(res.status).toHaveBeenCalledWith(204);
+ });
+
+ it('returns 412 without delegation when If-Match is stale', async () => {
+ mockResource();
+ const res = response();
+
+ await deleteHandler(request({ headers: { 'if-match': '"stale-etag"' } }), res);
+
+ expect(mocks.deleteContactFromVCard).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(412);
+ });
+
+ it('propagates an upstream failure without route-level local mutation', async () => {
+ mockResource();
+ mocks.deleteContactFromVCard.mockRejectedValueOnce(Object.assign(new Error('Unavailable'), { status: 503 }));
+ const res = response();
+
+ await deleteHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res);
+
+ expect(mocks.deleteContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(mocks.query.mock.calls.every(([sql]) => /^\s*SELECT\b/.test(sql))).toBe(true);
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.end).toHaveBeenCalled();
+ });
+
+ it.each([
+ ['ERR_CARDDAV_AMBIGUOUS_WRITE'],
+ ['ERR_CARDDAV_PENDING_INTENT'],
+ ])('maps the post-write %s delete to a non-retriable 409 for external DAV clients', async code => {
+ mockResource();
+ mocks.deleteContactFromVCard.mockRejectedValueOnce(
+ Object.assign(new Error('post-write state is indeterminate'), { code }),
+ );
+ const res = response();
+
+ await deleteHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res);
+
+ expect(mocks.deleteContactFromVCard).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.end).toHaveBeenCalled();
+ });
+});
+
+describe('CardDAV confirmed local reads', () => {
+ it('GET keeps serving the confirmed local vCard and the presented ETag', async () => {
+ const row = { vcard: 'BEGIN:VCARD\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD', etag: 'confirmed-etag' };
+ mocks.query.mockResolvedValueOnce({ rows: [row] });
+ const res = response();
+
+ await getHandler(request(), res);
+
+ expect(res.set).toHaveBeenCalledWith({
+ 'Content-Type': 'text/vcard;charset=utf-8',
+ 'ETag': `"${presentedEtag(row)}"`,
+ });
+ expect(res.send).toHaveBeenCalledWith('BEGIN:VCARD\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD');
+ });
+
+ it.each([
+ ['string', '🙂'.repeat(256 * 1024) + 'x'],
+ ['Buffer', Buffer.alloc(1024 * 1024 + 1)],
+ ])('rejects a REPORT %s body at 1 MiB + 1 before scanning it', async (_kind, body) => {
+ mocks.query.mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] });
+ const res = response();
+
+ await reportHandler(request({ body }), res);
+
+ expect(res.status).toHaveBeenCalledWith(413);
+ // The oversized body is rejected before any contact vCards are queried.
+ expect(mocks.query).toHaveBeenCalledTimes(1);
+ expect(res.send).not.toHaveBeenCalled();
+ });
+
+ it('rejects a multi-chunk REPORT body at 1 MiB + 1, then drains and detaches', async () => {
+ mocks.query.mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] });
+ const req = streamingRequest((function* bodyChunks() {
+ yield Buffer.alloc(1024 * 1024);
+ yield Buffer.from('x');
+ yield Buffer.from('tail');
+ })());
+ const ended = once(req, 'end');
+ const res = response();
+
+ await reportHandler(req, res);
+ await ended;
+
+ expect(res.status).toHaveBeenCalledWith(413);
+ expect(mocks.query).toHaveBeenCalledTimes(1);
+ expect(req.readableEnded).toBe(true);
+ expect(req.listenerCount('data')).toBe(0);
+ });
+
+ it('GET serves the retained remote document overlaid with the local edit for a mapped contact', async () => {
+ const row = {
+ uid: 'contact-uid',
+ display_name: 'Confirmed Local',
+ first_name: null,
+ last_name: null,
+ emails: [{ value: 'mapped@example.test', type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photo_data: null,
+ additional_fields: [],
+ vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-uid\r\nFN:Confirmed Local\r\nEMAIL:mapped@example.test\r\nEND:VCARD\r\n',
+ etag: 'confirmed-etag',
+ mapping_vcard: [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:remote-uid-xyz', 'FN:Old Remote Name',
+ 'EMAIL:mapped@example.test', 'CATEGORIES:VIP,Board', 'X-CUSTOM-FLAG:keep-me',
+ 'TZ:America/New_York', 'END:VCARD', '',
+ ].join('\r\n'),
+ };
+ mocks.query.mockResolvedValueOnce({ rows: [row] });
+ const res = response();
+
+ await getHandler(request(), res);
+
+ // The served ETag derives from the presented document, not contacts.etag.
+ expect(res.set).toHaveBeenCalledWith(expect.objectContaining({ 'ETag': `"${presentedEtag(row)}"` }));
+ const body = res.send.mock.calls[0][0];
+ // The local modeled edit wins, the retained unmodeled properties are visible to
+ // the client, and the served UID stays the local UID that keys the resource URL.
+ expect(body).toContain('FN:Confirmed Local');
+ expect(body).toContain('CATEGORIES:VIP,Board');
+ expect(body).toContain('X-CUSTOM-FLAG:keep-me');
+ expect(body).toContain('TZ:America/New_York');
+ expect(body).toContain('UID:contact-uid');
+ expect(body).not.toContain('remote-uid-xyz');
+ });
+
+ it('REPORT serves the retained remote document for a mapped contact', async () => {
+ const row = {
+ uid: 'contact-uid',
+ display_name: 'Confirmed Local',
+ first_name: null,
+ last_name: null,
+ emails: [{ value: 'mapped@example.test', type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photo_data: null,
+ additional_fields: [],
+ vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-uid\r\nFN:Confirmed Local\r\nEMAIL:mapped@example.test\r\nEND:VCARD\r\n',
+ etag: 'confirmed-etag',
+ mapping_vcard: [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:remote-uid-xyz', 'FN:Old Remote Name',
+ 'EMAIL:mapped@example.test', 'CATEGORIES:VIP,Board', 'END:VCARD', '',
+ ].join('\r\n'),
+ };
+ mocks.query
+ .mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] })
+ .mockResolvedValueOnce({ rows: [row] });
+ const res = response();
+
+ await reportHandler(request({ body: ' ' }), res);
+
+ const xml = res.send.mock.calls[0][0];
+ expect(xml).toContain(`"${presentedEtag(row)}" `);
+ expect(xml).toContain('FN:Confirmed Local');
+ expect(xml).toContain('CATEGORIES:VIP,Board');
+ });
+
+ it('REPORT keeps serving confirmed local address data and the presented ETag', async () => {
+ const row = {
+ uid: 'contact-uid',
+ vcard: 'BEGIN:VCARD\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD',
+ etag: 'confirmed-etag',
+ };
+ mocks.query
+ .mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] })
+ .mockResolvedValueOnce({ rows: [row] });
+ const res = response();
+
+ await reportHandler(request({ body: ' ' }), res);
+
+ const xml = res.send.mock.calls[0][0];
+ expect(xml).toContain(`"${presentedEtag(row)}" `);
+ expect(xml).toContain('FN:Confirmed');
+ expect(mocks.createContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled();
+ expect(mocks.deleteContactFromVCard).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/src/routes/carddavAccount.js b/backend/src/routes/carddavAccount.js
index 8495cda..d3c0eb8 100644
--- a/backend/src/routes/carddavAccount.js
+++ b/backend/src/routes/carddavAccount.js
@@ -4,18 +4,24 @@
// distinct from routes/carddav.js, which is the CardDAV *server* MailFlow exposes.
import { Router } from 'express';
-import { query } from '../services/db.js';
import { requireAuth } from '../middleware/auth.js';
import { encrypt } from '../services/encryption.js';
import { validateHost } from '../services/hostValidation.js';
import { getConnectionPolicy } from '../services/connectionPolicy.js';
import { discoverAddressBooks } from '../services/carddavClient.js';
-import { syncUser, scheduleCardavUser, stopCardavUser, getCardavConfig } from '../services/carddavSync.js';
+import {
+ syncUser,
+ requestCarddavSync,
+ scheduleCardavUser,
+ getCardavConfig,
+ replaceCarddavConnection,
+ patchCarddavConnection,
+ disconnectCarddavAccount,
+} from '../services/carddavSync.js';
const router = Router();
router.use(requireAuth);
-const DUP_MODES = ['separate', 'merge', 'skip'];
const clampInterval = (v) => Math.max(15, Math.min(1440, parseInt(v) || 60));
// Public view of the connection — never leaks the stored password.
@@ -25,7 +31,6 @@ function publicStatus(config) {
connected: true,
serverUrl: config.serverUrl,
username: config.username,
- dupMode: config.dupMode || 'separate',
intervalMin: config.intervalMin || 60,
lastSyncAt: config.lastSyncAt || null,
lastError: config.lastError || null,
@@ -39,7 +44,7 @@ router.get('/', async (req, res) => {
});
router.post('/connect', async (req, res) => {
- const { serverUrl, username, password, dupMode, intervalMin } = req.body || {};
+ const { serverUrl, username, password, intervalMin } = req.body || {};
if (!serverUrl || !username || !password) {
return res.status(400).json({ error: 'Server URL, username, and password are required' });
}
@@ -74,43 +79,47 @@ router.post('/connect', async (req, res) => {
return res.status(400).json({ error: err.message });
}
- const config = {
+ const connection = {
serverUrl, username,
password: encrypt(password),
- dupMode: DUP_MODES.includes(dupMode) ? dupMode : 'separate',
intervalMin: clampInterval(intervalMin),
- lastError: null,
};
- await query(
- `INSERT INTO user_integrations (user_id, provider, config)
- VALUES ($1, 'carddav', $2::jsonb)
- ON CONFLICT (user_id, provider) DO UPDATE SET config = $2::jsonb, updated_at = NOW()`,
- [req.session.userId, JSON.stringify(config)],
- );
+ const config = await replaceCarddavConnection(req.session.userId, connection);
scheduleCardavUser(req.session.userId, config.intervalMin);
// Kick off the first sync in the background; the client polls GET / for status.
- syncUser(req.session.userId).catch(() => {});
+ requestCarddavSync(req.session.userId, config.connectionGeneration);
res.json(publicStatus(config));
});
-// Update duplicate handling / interval (and optionally rotate the password).
+// Update the interval (and optionally rotate the password).
router.patch('/', async (req, res) => {
const existing = await getCardavConfig(req.session.userId);
if (!existing?.serverUrl) return res.status(409).json({ error: 'CardDAV not connected' });
const patch = {};
- if (req.body.dupMode && DUP_MODES.includes(req.body.dupMode)) patch.dupMode = req.body.dupMode;
if (req.body.intervalMin != null) patch.intervalMin = clampInterval(req.body.intervalMin);
- if (req.body.password) patch.password = encrypt(req.body.password);
+ if (req.body.password) {
+ const policy = await getConnectionPolicy();
+ try {
+ await discoverAddressBooks({
+ serverUrl: existing.serverUrl,
+ username: existing.username,
+ password: req.body.password,
+ allowPrivate: policy.allowPrivateHosts,
+ });
+ } catch (err) {
+ return res.status(400).json({ error: err.message });
+ }
+ patch.password = encrypt(req.body.password);
+ }
- await query(
- `UPDATE user_integrations SET config = config || $2::jsonb, updated_at = NOW()
- WHERE user_id = $1 AND provider = 'carddav'`,
- [req.session.userId, JSON.stringify(patch)],
- );
+ const config = patch.password
+ ? await patchCarddavConnection(req.session.userId, patch, existing.connectionGeneration)
+ : await patchCarddavConnection(req.session.userId, patch);
if (patch.intervalMin) scheduleCardavUser(req.session.userId, patch.intervalMin);
- res.json(publicStatus({ ...existing, ...patch }));
+ if (patch.password) requestCarddavSync(req.session.userId, config.connectionGeneration);
+ res.json(publicStatus(config));
});
router.post('/sync', async (req, res) => {
@@ -121,16 +130,7 @@ router.post('/sync', async (req, res) => {
});
router.delete('/', async (req, res) => {
- stopCardavUser(req.session.userId);
- // Remove the synced (read-only) address books; contacts cascade with them.
- await query(
- "DELETE FROM address_books WHERE user_id = $1 AND source = 'carddav'",
- [req.session.userId],
- );
- await query(
- "DELETE FROM user_integrations WHERE user_id = $1 AND provider = 'carddav'",
- [req.session.userId],
- );
+ await disconnectCarddavAccount(req.session.userId);
res.json({ ok: true });
});
diff --git a/backend/src/routes/carddavAccount.test.js b/backend/src/routes/carddavAccount.test.js
new file mode 100644
index 0000000..9b3b20c
--- /dev/null
+++ b/backend/src/routes/carddavAccount.test.js
@@ -0,0 +1,275 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ query: vi.fn(),
+ discoverAddressBooks: vi.fn(),
+ encrypt: vi.fn(value => `encrypted:${value}`),
+ getCardavConfig: vi.fn(),
+ replaceCarddavConnection: vi.fn(),
+ patchCarddavConnection: vi.fn(),
+ requestCarddavSync: vi.fn(),
+ scheduleCardavUser: vi.fn(),
+ syncUser: vi.fn(),
+ disconnectCarddavAccount: vi.fn(),
+}));
+
+vi.mock('../services/db.js', () => ({ query: mocks.query }));
+vi.mock('../middleware/auth.js', () => ({ requireAuth: vi.fn((req, res, next) => next()) }));
+vi.mock('../services/encryption.js', () => ({ encrypt: mocks.encrypt }));
+vi.mock('../services/hostValidation.js', () => ({ validateHost: vi.fn() }));
+vi.mock('../services/connectionPolicy.js', () => ({
+ getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })),
+}));
+vi.mock('../services/carddavClient.js', () => ({
+ discoverAddressBooks: mocks.discoverAddressBooks,
+}));
+vi.mock('../services/carddavSync.js', () => ({
+ getCardavConfig: mocks.getCardavConfig,
+ replaceCarddavConnection: mocks.replaceCarddavConnection,
+ patchCarddavConnection: mocks.patchCarddavConnection,
+ requestCarddavSync: mocks.requestCarddavSync,
+ scheduleCardavUser: mocks.scheduleCardavUser,
+ syncUser: mocks.syncUser,
+ disconnectCarddavAccount: mocks.disconnectCarddavAccount,
+}));
+
+const { default: router } = await import('./carddavAccount.js');
+const connectHandler = router.stack
+ .find(layer => layer.route?.path === '/connect' && layer.route.methods.post)
+ .route.stack.at(-1).handle;
+const patchHandler = router.stack
+ .find(layer => layer.route?.path === '/' && layer.route.methods.patch)
+ .route.stack.at(-1).handle;
+const syncHandler = router.stack
+ .find(layer => layer.route?.path === '/sync' && layer.route.methods.post)
+ .route.stack.at(-1).handle;
+const deleteHandler = router.stack
+ .find(layer => layer.route?.path === '/' && layer.route.methods.delete)
+ .route.stack.at(-1).handle;
+
+function response() {
+ return {
+ json: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ };
+}
+
+describe('POST /api/carddav/connect', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.discoverAddressBooks.mockResolvedValue([]);
+ mocks.replaceCarddavConnection.mockResolvedValue({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ intervalMin: 30,
+ connectionGeneration: 'generation-b',
+ lastError: null,
+ });
+ mocks.requestCarddavSync.mockReturnValue(true);
+ });
+
+ it('delegates replacement atomically and requests its committed generation', async () => {
+ const res = response();
+
+ await connectHandler({
+ session: { userId: 'user-1' },
+ body: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'secret',
+ dupMode: 'merge',
+ intervalMin: 30,
+ },
+ }, res);
+
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(mocks.replaceCarddavConnection).toHaveBeenCalledWith('user-1', {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted:secret',
+ intervalMin: 30,
+ });
+ expect(mocks.requestCarddavSync).toHaveBeenCalledWith('user-1', 'generation-b');
+ expect(mocks.replaceCarddavConnection.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.requestCarddavSync.mock.invocationCallOrder[0]);
+ expect(mocks.scheduleCardavUser).toHaveBeenCalledWith('user-1', 30);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
+ connected: true,
+ }));
+ expect(res.json.mock.calls[0][0]).not.toHaveProperty('dupMode');
+ expect(res.json.mock.calls[0][0]).not.toHaveProperty('connectionGeneration');
+ });
+
+ it('does not request a sync when replacement persistence fails', async () => {
+ mocks.replaceCarddavConnection.mockRejectedValueOnce(new Error('replace failed'));
+ const res = response();
+
+ await expect(connectHandler({
+ session: { userId: 'user-1' },
+ body: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'secret',
+ dupMode: 'merge',
+ },
+ }, res)).rejects.toThrow('replace failed');
+
+ expect(mocks.requestCarddavSync).not.toHaveBeenCalled();
+ expect(mocks.scheduleCardavUser).not.toHaveBeenCalled();
+ expect(res.json).not.toHaveBeenCalled();
+ });
+});
+
+describe('PATCH /api/carddav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getCardavConfig.mockResolvedValue({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ intervalMin: 60,
+ connectionGeneration: 'generation-a',
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([]);
+ mocks.patchCarddavConnection.mockResolvedValue({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ intervalMin: 60,
+ connectionGeneration: 'generation-a',
+ });
+ mocks.requestCarddavSync.mockReturnValue(true);
+ });
+
+ it('ignores obsolete duplicate-mode input while delegating the interval', async () => {
+ const req = {
+ session: { userId: 'user-1' },
+ body: { dupMode: 'merge', intervalMin: 30 },
+ };
+ const res = response();
+ mocks.patchCarddavConnection.mockResolvedValueOnce({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ intervalMin: 30,
+ connectionGeneration: 'generation-a',
+ });
+
+ await patchHandler(req, res);
+
+ expect(mocks.patchCarddavConnection).toHaveBeenCalledWith('user-1', {
+ intervalMin: 30,
+ });
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(mocks.requestCarddavSync).not.toHaveBeenCalled();
+ expect(mocks.scheduleCardavUser).toHaveBeenCalledWith('user-1', 30);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
+ connected: true,
+ }));
+ expect(res.json.mock.calls[0][0]).not.toHaveProperty('dupMode');
+ });
+
+ it('preflights a password replacement before persisting and queues its generation', async () => {
+ const res = response();
+ mocks.patchCarddavConnection.mockResolvedValueOnce({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ intervalMin: 60,
+ connectionGeneration: 'generation-b',
+ });
+
+ await patchHandler({
+ session: { userId: 'user-1' },
+ body: { password: 'new-secret' },
+ }, res);
+
+ expect(mocks.discoverAddressBooks).toHaveBeenCalledWith({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'new-secret',
+ allowPrivate: false,
+ });
+ expect(mocks.patchCarddavConnection).toHaveBeenCalledWith(
+ 'user-1',
+ { password: 'encrypted:new-secret' },
+ 'generation-a',
+ );
+ expect(mocks.requestCarddavSync).toHaveBeenCalledWith('user-1', 'generation-b');
+ expect(mocks.discoverAddressBooks.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.patchCarddavConnection.mock.invocationCallOrder[0]);
+ expect(mocks.patchCarddavConnection.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.requestCarddavSync.mock.invocationCallOrder[0]);
+ expect(mocks.query).not.toHaveBeenCalled();
+ });
+
+ it('changes and queues nothing when password preflight fails', async () => {
+ mocks.discoverAddressBooks.mockRejectedValueOnce(new Error('bad credentials'));
+ const res = response();
+
+ await patchHandler({
+ session: { userId: 'user-1' },
+ body: { password: 'bad-secret' },
+ }, res);
+
+ expect(mocks.patchCarddavConnection).not.toHaveBeenCalled();
+ expect(mocks.requestCarddavSync).not.toHaveBeenCalled();
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: 'bad credentials' });
+ });
+});
+
+describe('POST /api/carddav/sync', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getCardavConfig
+ .mockResolvedValueOnce({ serverUrl: 'https://dav.example.test/' })
+ .mockResolvedValueOnce({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ bookCount: 1,
+ contactCount: 2,
+ });
+ });
+
+ it('preserves the result-plus-status envelope while exposing counters', async () => {
+ const result = {
+ ok: true,
+ bookCount: 1,
+ contactCount: 2,
+ remote: 3,
+ fetched: 1,
+ updated: 1,
+ removed: 0,
+ fallback: 0,
+ };
+ mocks.syncUser.mockResolvedValue(result);
+ const res = response();
+
+ await syncHandler({ session: { userId: 'user-1' } }, res);
+
+ expect(res.json).toHaveBeenCalledWith({
+ ...result,
+ status: expect.objectContaining({
+ connected: true,
+ bookCount: 1,
+ contactCount: 2,
+ }),
+ });
+ });
+});
+
+describe('DELETE /api/carddav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.disconnectCarddavAccount.mockResolvedValue(true);
+ });
+
+ it('delegates disconnect once without route lifecycle SQL', async () => {
+ const res = response();
+
+ await deleteHandler({ session: { userId: 'user-1' } }, res);
+
+ expect(mocks.disconnectCarddavAccount).toHaveBeenCalledTimes(1);
+ expect(mocks.disconnectCarddavAccount).toHaveBeenCalledWith('user-1');
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith({ ok: true });
+ });
+});
diff --git a/backend/src/routes/carddavConflicts.js b/backend/src/routes/carddavConflicts.js
new file mode 100644
index 0000000..ed168c1
--- /dev/null
+++ b/backend/src/routes/carddavConflicts.js
@@ -0,0 +1,45 @@
+import { Router } from 'express';
+
+import { requireAuth } from '../middleware/auth.js';
+import {
+ getConflict,
+ listConflicts,
+ resolveConflict,
+} from '../services/carddavConflictService.js';
+
+const router = Router();
+router.use(requireAuth);
+
+function conflictError(res, error) {
+ const status = {
+ ERR_CARDDAV_CONFLICT_RESOLUTION: 400,
+ ERR_CARDDAV_CONFLICT_NOT_FOUND: 404,
+ ERR_CARDDAV_CONFLICT_STALE: 409,
+ }[error.code];
+ if (status) return res.status(status).json({ error: error.message });
+ throw error;
+}
+
+router.get('/', async (req, res) => {
+ res.json({ conflicts: await listConflicts(req.session.userId) });
+});
+
+router.get('/:id', async (req, res) => {
+ const conflict = await getConflict(req.session.userId, req.params.id);
+ if (!conflict) return res.status(404).json({ error: 'CardDAV conflict not found' });
+ res.json(conflict);
+});
+
+router.post('/:id/resolve', async (req, res) => {
+ try {
+ res.json(await resolveConflict(
+ req.session.userId,
+ req.params.id,
+ req.body?.resolution,
+ ));
+ } catch (error) {
+ conflictError(res, error);
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/carddavConflicts.test.js b/backend/src/routes/carddavConflicts.test.js
new file mode 100644
index 0000000..7794d39
--- /dev/null
+++ b/backend/src/routes/carddavConflicts.test.js
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ getConflict: vi.fn(),
+ listConflicts: vi.fn(),
+ requireAuth: vi.fn((req, res, next) => next()),
+ resolveConflict: vi.fn(),
+}));
+
+vi.mock('../middleware/auth.js', () => ({ requireAuth: mocks.requireAuth }));
+vi.mock('../services/carddavConflictService.js', () => ({
+ getConflict: mocks.getConflict,
+ listConflicts: mocks.listConflicts,
+ resolveConflict: mocks.resolveConflict,
+}));
+
+const { default: router } = await import('./carddavConflicts.js');
+
+function handler(method, path) {
+ return router.stack
+ .find(layer => layer.route?.path === path && layer.route.methods[method])
+ .route.stack.at(-1).handle;
+}
+
+const listHandler = handler('get', '/');
+const detailHandler = handler('get', '/:id');
+const resolveHandler = handler('post', '/:id/resolve');
+
+function response() {
+ return {
+ json: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('CardDAV conflict routes', () => {
+ it('gates every route through the shared authentication middleware', () => {
+ expect(router.stack[0].handle).toBe(mocks.requireAuth);
+ });
+
+ it('lists the current user conflict comparison envelope', async () => {
+ const conflicts = [{ id: 'conflict-1', local: {}, remote: {} }];
+ mocks.listConflicts.mockResolvedValueOnce(conflicts);
+ const res = response();
+
+ await listHandler({ session: { userId: 'user-1' } }, res);
+
+ expect(mocks.listConflicts).toHaveBeenCalledWith('user-1');
+ expect(res.json).toHaveBeenCalledWith({ conflicts });
+ });
+
+ it('returns 404 when an owned conflict detail does not exist', async () => {
+ mocks.getConflict.mockResolvedValueOnce(null);
+ const res = response();
+
+ await detailHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'conflict-1' },
+ }, res);
+
+ expect(mocks.getConflict).toHaveBeenCalledWith('user-1', 'conflict-1');
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'CardDAV conflict not found' });
+ });
+
+ it('delegates only the authenticated user, conflict ID, and exact resolution', async () => {
+ const conflict = { id: 'conflict-1', status: 'resolved', resolution: 'keep-mailflow' };
+ mocks.resolveConflict.mockResolvedValueOnce(conflict);
+ const res = response();
+
+ await resolveHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'conflict-1' },
+ body: {
+ resolution: 'keep-mailflow',
+ contactId: 'attacker-supplied-contact',
+ addressBookId: 'attacker-supplied-book',
+ },
+ }, res);
+
+ expect(mocks.resolveConflict).toHaveBeenCalledWith(
+ 'user-1',
+ 'conflict-1',
+ 'keep-mailflow',
+ );
+ expect(res.json).toHaveBeenCalledWith(conflict);
+ });
+
+ it('maps invalid resolutions to 400 and stale or resolved transitions to 409', async () => {
+ const invalid = Object.assign(new Error('Invalid CardDAV conflict resolution'), {
+ code: 'ERR_CARDDAV_CONFLICT_RESOLUTION',
+ });
+ const stale = Object.assign(new Error('CardDAV conflict is stale'), {
+ code: 'ERR_CARDDAV_CONFLICT_STALE',
+ });
+
+ for (const [error, status] of [[invalid, 400], [stale, 409]]) {
+ mocks.resolveConflict.mockRejectedValueOnce(error);
+ const res = response();
+ await resolveHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'conflict-1' },
+ body: { resolution: 'bad' },
+ }, res);
+ expect(res.status).toHaveBeenCalledWith(status);
+ expect(res.json).toHaveBeenCalledWith({ error: error.message });
+ }
+ });
+});
diff --git a/backend/src/routes/contacts.js b/backend/src/routes/contacts.js
index 8c096f1..15dc71c 100644
--- a/backend/src/routes/contacts.js
+++ b/backend/src/routes/contacts.js
@@ -1,31 +1,93 @@
import { Router } from 'express';
import { query } from '../services/db.js';
import { requireAuth } from '../middleware/auth.js';
-import { generateVCard } from '../utils/vcard.js';
-import crypto from 'crypto';
+import {
+ CARDDAV_CONTACT_ERROR_STATUS,
+ createContact,
+ deleteContact,
+ updateContact,
+} from '../services/carddavContactService.js';
const router = Router();
router.use(requireAuth);
-// Resolve the user's default address book id, creating it if needed.
-async function defaultAddressBook(userId) {
- const r = await query(
- `INSERT INTO address_books (user_id, name)
- VALUES ($1, 'Personal')
- ON CONFLICT (user_id, name) DO UPDATE SET updated_at = NOW()
- RETURNING id`,
- [userId]
- );
- return r.rows[0].id;
+const MAX_PHOTO_BYTES = 512 * 1024;
+
+const CONTACT_SYNC_COLUMNS = `
+ COALESCE(mapping.mapping_status, 'local') AS sync_state,
+ remote_book.remote_create_capability,
+ remote_book.remote_update_capability,
+ remote_book.remote_delete_capability,
+ (c.photo_data IS NOT NULL) AS has_photo,
+ conflict.id AS conflict_id,
+ (mapping.local_contact_id IS NOT NULL
+ AND remote_book.remote_update_capability = 'denied'
+ AND remote_book.remote_delete_capability = 'denied') AS read_only`;
+const CONTACT_SYNC_JOINS = `
+ LEFT JOIN carddav_remote_objects mapping
+ ON mapping.local_contact_id = c.id
+ AND mapping.mapping_status <> 'pending_materialization'
+ LEFT JOIN address_books remote_book ON remote_book.id = mapping.address_book_id
+ LEFT JOIN carddav_conflicts conflict
+ ON conflict.address_book_id = mapping.address_book_id
+ AND conflict.href = mapping.href
+ AND conflict.status = 'unresolved'`;
+
+function validateDraft(draft) {
+ if (draft.emails !== undefined && !Array.isArray(draft.emails)) return 'emails must be an array';
+ if (draft.phones !== undefined && !Array.isArray(draft.phones)) return 'phones must be an array';
+ if (draft.additionalFields !== undefined && !Array.isArray(draft.additionalFields)) {
+ return 'additionalFields must be an array';
+ }
+ if (draft.photoData === undefined || draft.photoData === null) return null;
+ if (typeof draft.photoData !== 'string') return 'photoData must be a JPEG or PNG data URI';
+
+ const match = /^data:([^;,]+);base64,(.*)$/i.exec(draft.photoData);
+ if (!match || !/^image\/(?:jpeg|png)$/i.test(match[1])) {
+ return 'photoData must be a JPEG or PNG data URI';
+ }
+ const encoded = match[2];
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
+ return 'photoData must contain valid base64 data';
+ }
+ if (Buffer.from(encoded, 'base64').length > MAX_PHOTO_BYTES) {
+ return 'photoData must not exceed 512 KiB';
+ }
+ return null;
}
-// Bump the address book sync_token so CardDAV clients re-sync.
-async function bumpSyncToken(addressBookId) {
- await query(
- `UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW()
- WHERE id = $1`,
- [addressBookId]
- );
+function contactError(res, err, fallback) {
+ if (err.code === 'ERR_CARDDAV_CONFLICT') {
+ return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code]).json({ conflictId: err.conflictId });
+ }
+ // A concurrent sync (or reconnect) can move the mapping fence out from under an
+ // in-flight write whose remote effect already landed — a transient, self-healing
+ // race, not a client error. Surface it as retriable with a machine-readable code
+ // so the client re-issues the edit instead of treating it as a 500.
+ // (ERR_CARDDAV_STALE_GENERATION is a defensive mapping — no current contacts-route
+ // path throws it; only exportExistingContact does, and its caller handles it.)
+ if (err.code === 'ERR_CARDDAV_FINAL_FENCE' || err.code === 'ERR_CARDDAV_STALE_GENERATION') {
+ return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code])
+ .json({ error: err.message, code: err.code, retriable: true });
+ }
+ // A post-write ambiguous result (the remote effect may already have landed; the
+ // service persisted a pending intent and recovered read-only) or a rejected write
+ // because another mutation is still awaiting confirmation. Neither is safe to
+ // re-issue — the client must refresh state and let the next sync reconcile, so map
+ // to 409 with `refresh` and deliberately WITHOUT `retriable`.
+ if (err.code === 'ERR_CARDDAV_AMBIGUOUS_WRITE' || err.code === 'ERR_CARDDAV_PENDING_INTENT') {
+ return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code])
+ .json({ error: err.message, code: err.code, refresh: true });
+ }
+ if (err.code === '23505') {
+ return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code])
+ .json({ error: 'A contact with that email already exists' });
+ }
+ const status = CARDDAV_CONTACT_ERROR_STATUS[err.code]
+ ?? (Number.isInteger(err.status) ? err.status : null);
+ if (status) return res.status(status).json({ error: err.message });
+ console.error(`${fallback}:`, err);
+ return res.status(500).json({ error: fallback });
}
// GET /api/contacts
@@ -63,16 +125,19 @@ router.get('/', async (req, res) => {
SELECT
c.id, c.uid, c.display_name, c.first_name, c.last_name,
c.primary_email, c.emails, c.phones, c.organization,
- c.notes, c.is_auto, c.send_count, c.last_sent,
+ c.notes, c.additional_fields,
+ c.is_auto, c.send_count, c.last_sent,
c.etag, c.created_at, c.updated_at,
- (ab.source = 'carddav') AS read_only
+ ${CONTACT_SYNC_COLUMNS}
FROM contacts c
JOIN address_books ab ON ab.id = c.address_book_id
+ ${CONTACT_SYNC_JOINS}
WHERE ${conditions.join(' AND ')}
ORDER BY
c.is_auto ASC,
c.send_count DESC,
- lower(coalesce(c.display_name, c.primary_email, '')) ASC
+ lower(coalesce(c.display_name, c.primary_email, '')) ASC,
+ c.id ASC
LIMIT $${p} OFFSET $${p + 1}
`, [...params, cap, off]);
@@ -135,11 +200,13 @@ router.get('/:id', async (req, res) => {
const result = await query(
`SELECT c.id, c.uid, c.display_name, c.first_name, c.last_name,
c.primary_email, c.emails, c.phones, c.organization,
- c.notes, c.photo_data, c.is_auto, c.send_count, c.last_sent,
- c.etag, c.vcard, c.created_at, c.updated_at,
- (ab.source = 'carddav') AS read_only
+ c.notes, c.photo_data, c.additional_fields,
+ c.is_auto, c.send_count, c.last_sent,
+ c.etag, c.created_at, c.updated_at,
+ ${CONTACT_SYNC_COLUMNS}
FROM contacts c
JOIN address_books ab ON ab.id = c.address_book_id
+ ${CONTACT_SYNC_JOINS}
WHERE c.id = $1 AND c.user_id = $2`,
[req.params.id, userId]
);
@@ -154,128 +221,28 @@ router.get('/:id', async (req, res) => {
// POST /api/contacts
router.post('/', async (req, res) => {
const userId = req.session.userId;
- const {
- displayName, firstName, lastName,
- emails = [], phones = [],
- organization, notes,
- } = req.body || {};
-
- if (!Array.isArray(emails)) return res.status(400).json({ error: 'emails must be an array' });
- if (!Array.isArray(phones)) return res.status(400).json({ error: 'phones must be an array' });
-
- const primaryEmail = emails[0]?.value
- ? emails[0].value.toLowerCase().trim()
- : null;
-
- if (!displayName && !primaryEmail) {
- return res.status(400).json({ error: 'A name or email address is required' });
- }
+ const draft = req.body || {};
+ const validationError = validateDraft(draft);
+ if (validationError) return res.status(400).json({ error: validationError });
try {
- const addressBookId = await defaultAddressBook(userId);
- const uid = crypto.randomUUID();
- const vcard = generateVCard({ uid, displayName, firstName, lastName, emails, phones, organization, notes });
- const etag = crypto.createHash('md5').update(vcard).digest('hex');
-
- const result = await query(`
- INSERT INTO contacts (
- address_book_id, user_id, uid, vcard, etag,
- display_name, first_name, last_name, primary_email,
- emails, phones, organization, notes, is_auto
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, false)
- RETURNING id, uid, display_name, first_name, last_name,
- primary_email, emails, phones, organization, notes,
- is_auto, send_count, last_sent, etag, created_at, updated_at
- `, [
- addressBookId, userId, uid, vcard, etag,
- displayName || null, firstName || null, lastName || null, primaryEmail,
- JSON.stringify(emails), JSON.stringify(phones),
- organization || null, notes || null,
- ]);
-
- await bumpSyncToken(addressBookId);
- res.status(201).json(result.rows[0]);
+ res.status(201).json(await createContact(userId, draft));
} catch (err) {
- if (err.code === '23505') return res.status(409).json({ error: 'A contact with that email already exists' });
- console.error('Contact create error:', err);
- res.status(500).json({ error: 'Failed to create contact' });
+ contactError(res, err, 'Failed to create contact');
}
});
// PATCH /api/contacts/:id
router.patch('/:id', async (req, res) => {
const userId = req.session.userId;
- const {
- displayName, firstName, lastName,
- emails, phones, organization, notes,
- } = req.body || {};
-
- if (emails !== undefined && !Array.isArray(emails)) return res.status(400).json({ error: 'emails must be an array' });
- if (phones !== undefined && !Array.isArray(phones)) return res.status(400).json({ error: 'phones must be an array' });
+ const draft = req.body || {};
+ const validationError = validateDraft(draft);
+ if (validationError) return res.status(400).json({ error: validationError });
try {
- // Load current contact (with its book source to block edits to synced contacts)
- const cur = await query(
- `SELECT c.*, ab.source AS book_source FROM contacts c
- JOIN address_books ab ON ab.id = c.address_book_id
- WHERE c.id = $1 AND c.user_id = $2`,
- [req.params.id, userId]
- );
- if (!cur.rows.length) return res.status(404).json({ error: 'Contact not found' });
- const c = cur.rows[0];
- if (c.book_source === 'carddav') {
- return res.status(403).json({ error: 'This contact is synced from CardDAV and is read-only' });
- }
-
- const newEmails = emails !== undefined ? emails : c.emails;
- const newPhones = phones !== undefined ? phones : c.phones;
- const newDisplay = displayName !== undefined ? displayName : c.display_name;
- const newFirst = firstName !== undefined ? firstName : c.first_name;
- const newLast = lastName !== undefined ? lastName : c.last_name;
- const newOrg = organization !== undefined ? organization : c.organization;
- const newNotes = notes !== undefined ? notes : c.notes;
- const newPrimary = emails === undefined
- ? c.primary_email
- : (newEmails[0]?.value ? newEmails[0].value.toLowerCase().trim() : null);
-
- const vcard = generateVCard({
- uid: c.uid,
- displayName: newDisplay,
- firstName: newFirst,
- lastName: newLast,
- emails: newEmails,
- phones: newPhones,
- organization: newOrg,
- notes: newNotes,
- });
- const etag = crypto.createHash('md5').update(vcard).digest('hex');
-
- const result = await query(`
- UPDATE contacts SET
- display_name = $1, first_name = $2, last_name = $3,
- primary_email = $4, emails = $5, phones = $6,
- organization = $7, notes = $8,
- vcard = $9, etag = $10, updated_at = NOW(),
- is_auto = false
- WHERE id = $11 AND user_id = $12
- RETURNING id, uid, display_name, first_name, last_name,
- primary_email, emails, phones, organization, notes,
- is_auto, send_count, last_sent, etag, created_at, updated_at
- `, [
- newDisplay || null, newFirst || null, newLast || null,
- newPrimary,
- JSON.stringify(newEmails), JSON.stringify(newPhones),
- newOrg || null, newNotes || null,
- vcard, etag,
- req.params.id, userId,
- ]);
-
- await bumpSyncToken(c.address_book_id);
- res.json(result.rows[0]);
+ res.json(await updateContact(userId, req.params.id, draft));
} catch (err) {
- if (err.code === '23505') return res.status(409).json({ error: 'A contact with that email already exists' });
- console.error('Contact update error:', err);
- res.status(500).json({ error: 'Failed to update contact' });
+ contactError(res, err, 'Failed to update contact');
}
});
@@ -283,26 +250,9 @@ router.patch('/:id', async (req, res) => {
router.delete('/:id', async (req, res) => {
const userId = req.session.userId;
try {
- // Block deletion of CardDAV-synced (read-only) contacts; they reappear on next sync anyway.
- const owner = await query(
- `SELECT ab.source FROM contacts c JOIN address_books ab ON ab.id = c.address_book_id
- WHERE c.id = $1 AND c.user_id = $2`,
- [req.params.id, userId]
- );
- if (!owner.rows.length) return res.status(404).json({ error: 'Contact not found' });
- if (owner.rows[0].source === 'carddav') {
- return res.status(403).json({ error: 'This contact is synced from CardDAV and is read-only' });
- }
- const result = await query(
- 'DELETE FROM contacts WHERE id = $1 AND user_id = $2 RETURNING address_book_id',
- [req.params.id, userId]
- );
- if (!result.rows.length) return res.status(404).json({ error: 'Contact not found' });
- await bumpSyncToken(result.rows[0].address_book_id);
- res.json({ ok: true });
+ res.json(await deleteContact(userId, req.params.id));
} catch (err) {
- console.error('Contact delete error:', err);
- res.status(500).json({ error: 'Failed to delete contact' });
+ contactError(res, err, 'Failed to delete contact');
}
});
diff --git a/backend/src/routes/contacts.test.js b/backend/src/routes/contacts.test.js
new file mode 100644
index 0000000..e13c183
--- /dev/null
+++ b/backend/src/routes/contacts.test.js
@@ -0,0 +1,319 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ query: vi.fn(),
+ createContact: vi.fn(),
+ updateContact: vi.fn(),
+ deleteContact: vi.fn(),
+}));
+
+vi.mock('../services/db.js', () => ({ query: mocks.query }));
+vi.mock('../middleware/auth.js', () => ({ requireAuth: vi.fn((req, res, next) => next()) }));
+vi.mock('../services/carddavContactService.js', () => ({
+ CARDDAV_CONTACT_ERROR_STATUS: {
+ ERR_CONTACT_VALIDATION: 400,
+ ERR_CONTACT_UID_MISMATCH: 400,
+ ERR_CONTACT_NOT_FOUND: 404,
+ ERR_ADDRESS_BOOK_NOT_FOUND: 404,
+ ERR_CONTACT_EXISTS: 409,
+ ERR_CARDDAV_CONFLICT: 409,
+ ERR_CARDDAV_FINAL_FENCE: 503,
+ ERR_CARDDAV_STALE_GENERATION: 503,
+ ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
+ ERR_CARDDAV_PENDING_INTENT: 409,
+ ERR_CARDDAV_READ_ONLY: 403,
+ '23505': 409,
+ },
+ createContact: mocks.createContact,
+ updateContact: mocks.updateContact,
+ deleteContact: mocks.deleteContact,
+}));
+
+const { default: router } = await import('./contacts.js');
+
+function handler(method, path) {
+ return router.stack
+ .find(layer => layer.route?.path === path && layer.route.methods[method])
+ .route.stack.at(-1).handle;
+}
+
+const listHandler = handler('get', '/');
+const getHandler = handler('get', '/:id');
+const createHandler = handler('post', '/');
+const updateHandler = handler('patch', '/:id');
+const deleteHandler = handler('delete', '/:id');
+
+function response() {
+ return {
+ json: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ };
+}
+
+function draft(overrides = {}) {
+ return {
+ displayName: 'Ada Lovelace',
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ emails: [{ value: 'ada@example.test', type: 'work' }],
+ phones: [],
+ organization: 'Analytical Engines',
+ notes: 'First programmer',
+ photoData: 'data:image/png;base64,AQID',
+ additionalFields: [{ id: 'site', kind: 'url', label: 'Website', value: 'https://example.test' }],
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('contact read routes', () => {
+ it('lists contacts with CardDAV sync, capability, Additional-field, photo, and conflict state', async () => {
+ const contact = {
+ id: 'contact-1',
+ sync_state: 'conflict',
+ remote_create_capability: 'allowed',
+ remote_update_capability: 'unknown',
+ remote_delete_capability: 'denied',
+ additional_fields: [{ id: 'site', kind: 'url', value: 'https://example.test' }],
+ has_photo: true,
+ conflict_id: 'conflict-1',
+ read_only: false,
+ };
+ mocks.query
+ .mockResolvedValueOnce({ rows: [contact] })
+ .mockResolvedValueOnce({ rows: [{ count: '1' }] });
+ const res = response();
+
+ await listHandler({ session: { userId: 'user-1' }, query: {} }, res);
+
+ const sql = mocks.query.mock.calls[0][0];
+ expect(sql).toContain('carddav_remote_objects');
+ expect(sql).toContain('carddav_conflicts');
+ expect(sql).toContain('remote_update_capability');
+ expect(sql).not.toContain("(ab.source = 'carddav') AS read_only");
+ expect(res.json).toHaveBeenCalledWith({ contacts: [contact], total: 1 });
+ });
+
+ it('gets the same CardDAV state projection without mutating it', async () => {
+ const contact = {
+ id: 'contact-1',
+ sync_state: 'synced',
+ remote_update_capability: 'allowed',
+ remote_delete_capability: 'allowed',
+ additional_fields: [],
+ photo_data: null,
+ has_photo: false,
+ conflict_id: null,
+ read_only: false,
+ };
+ mocks.query.mockResolvedValueOnce({ rows: [contact] });
+ const res = response();
+
+ await getHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res);
+
+ const sql = mocks.query.mock.calls[0][0];
+ expect(sql).toContain('carddav_remote_objects');
+ expect(sql).toContain('carddav_conflicts');
+ expect(sql).not.toMatch(/\bc\.vcard\b/);
+ expect(sql).not.toMatch(/\b(?:INSERT|UPDATE|DELETE)\b/);
+ expect(res.json).toHaveBeenCalledWith(contact);
+ expect(res.json.mock.calls[0][0]).toBe(contact);
+ });
+});
+
+describe('POST /api/contacts', () => {
+ it('delegates exactly once and preserves local-only response behavior', async () => {
+ const body = draft();
+ const created = { id: 'contact-1', ...body };
+ mocks.createContact.mockResolvedValueOnce(created);
+ const res = response();
+
+ await createHandler({ session: { userId: 'user-1' }, body }, res);
+
+ expect(mocks.createContact).toHaveBeenCalledTimes(1);
+ expect(mocks.createContact).toHaveBeenCalledWith('user-1', body);
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(res.json).toHaveBeenCalledWith(created);
+ });
+
+ it.each([
+ ['emails', draft({ emails: 'not-an-array' }), 'emails must be an array'],
+ ['phones', draft({ phones: 'not-an-array' }), 'phones must be an array'],
+ ['Additional fields', draft({ additionalFields: {} }), 'additionalFields must be an array'],
+ ['photo MIME', draft({ photoData: 'data:image/gif;base64,AQID' }), 'photoData must be a JPEG or PNG data URI'],
+ ['photo encoding', draft({ photoData: 'data:image/png;base64,***' }), 'photoData must contain valid base64 data'],
+ ['photo size', draft({ photoData: `data:image/jpeg;base64,${Buffer.alloc(512 * 1024 + 1).toString('base64')}` }), 'photoData must not exceed 512 KiB'],
+ ])('rejects invalid %s before delegation', async (_case, body, error) => {
+ const res = response();
+
+ await createHandler({ session: { userId: 'user-1' }, body }, res);
+
+ expect(mocks.createContact).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error });
+ });
+});
+
+describe('PATCH /api/contacts/:id', () => {
+ it('delegates a writable mapped contact exactly once', async () => {
+ const body = draft({ displayName: 'Ada Byron' });
+ const updated = { id: 'contact-1', ...body };
+ mocks.updateContact.mockResolvedValueOnce(updated);
+ const res = response();
+
+ await updateHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' }, body }, res);
+
+ expect(mocks.updateContact).toHaveBeenCalledTimes(1);
+ expect(mocks.updateContact).toHaveBeenCalledWith('user-1', 'contact-1', body);
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith(updated);
+ });
+
+ it('returns 403 when the operation is denied', async () => {
+ mocks.updateContact.mockRejectedValueOnce(Object.assign(
+ new Error('This CardDAV address book does not allow update'),
+ { code: 'ERR_CARDDAV_READ_ONLY' },
+ ));
+ const res = response();
+
+ await updateHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'contact-1' },
+ body: { displayName: 'Ada Byron' },
+ }, res);
+
+ expect(mocks.updateContact).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ error: 'This CardDAV address book does not allow update' });
+ });
+
+ it('attempts an unknown-capability operation and reports its upstream denial', async () => {
+ mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error('Forbidden'), { status: 403 }));
+ const res = response();
+
+ await updateHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'contact-1' },
+ body: { displayName: 'Ada Byron' },
+ }, res);
+
+ expect(mocks.updateContact).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
+ });
+
+ it('returns the durable conflict ID for a stale write', async () => {
+ mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error('conflict'), {
+ code: 'ERR_CARDDAV_CONFLICT',
+ conflictId: 'conflict-1',
+ }));
+ const res = response();
+
+ await updateHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'contact-1' },
+ body: { displayName: 'Ada Byron' },
+ }, res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.json).toHaveBeenCalledWith({ conflictId: 'conflict-1' });
+ });
+
+ it.each([
+ ['ERR_CARDDAV_FINAL_FENCE', 'CardDAV mapping changed after the remote write'],
+ ['ERR_CARDDAV_STALE_GENERATION', 'The CardDAV connection changed before export'],
+ ])('maps the self-healing %s race to a retriable 503', async (code, message) => {
+ mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
+ const res = response();
+
+ await updateHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'contact-1' },
+ body: { displayName: 'Ada Byron' },
+ }, res);
+
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.json).toHaveBeenCalledWith({ error: message, code, retriable: true });
+ });
+
+ it.each([
+ ['ERR_CARDDAV_AMBIGUOUS_WRITE', 'The CardDAV write succeeded, but MailFlow could not confirm its local state'],
+ ['ERR_CARDDAV_PENDING_INTENT', 'A CardDAV mutation is already awaiting confirmation'],
+ ])('maps the post-write %s to a non-retriable 409 that tells the client to refresh', async (code, message) => {
+ mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
+ const res = response();
+
+ await updateHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'contact-1' },
+ body: { displayName: 'Ada Byron' },
+ }, res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.json).toHaveBeenCalledWith({ error: message, code, refresh: true });
+ // The remote effect may have landed; never invite a blind re-issue of the mutation.
+ expect(res.json.mock.calls[0][0]).not.toHaveProperty('retriable', true);
+ });
+
+ it('accepts an explicit photo removal', async () => {
+ mocks.updateContact.mockResolvedValueOnce({ id: 'contact-1', photo_data: null });
+ const res = response();
+
+ await updateHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'contact-1' },
+ body: { photoData: null, additionalFields: [] },
+ }, res);
+
+ expect(mocks.updateContact).toHaveBeenCalledWith('user-1', 'contact-1', {
+ photoData: null,
+ additionalFields: [],
+ });
+ expect(res.json).toHaveBeenCalledWith({ id: 'contact-1', photo_data: null });
+ });
+});
+
+describe('DELETE /api/contacts/:id', () => {
+ it('delegates exactly once and preserves the local-only response', async () => {
+ mocks.deleteContact.mockResolvedValueOnce({ ok: true });
+ const res = response();
+
+ await deleteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res);
+
+ expect(mocks.deleteContact).toHaveBeenCalledTimes(1);
+ expect(mocks.deleteContact).toHaveBeenCalledWith('user-1', 'contact-1');
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith({ ok: true });
+ });
+
+ it('returns 403 when deletion is denied', async () => {
+ mocks.deleteContact.mockRejectedValueOnce(Object.assign(
+ new Error('This CardDAV address book does not allow delete'),
+ { code: 'ERR_CARDDAV_READ_ONLY' },
+ ));
+ const res = response();
+
+ await deleteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res);
+
+ expect(mocks.deleteContact).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it.each([
+ ['ERR_CARDDAV_AMBIGUOUS_WRITE', 'The CardDAV write succeeded, but MailFlow could not confirm its local state'],
+ ['ERR_CARDDAV_PENDING_INTENT', 'A CardDAV mutation is already awaiting confirmation'],
+ ])('maps a post-write %s delete to a non-retriable 409 that tells the client to refresh', async (code, message) => {
+ mocks.deleteContact.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
+ const res = response();
+
+ await deleteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.json).toHaveBeenCalledWith({ error: message, code, refresh: true });
+ expect(res.json.mock.calls[0][0]).not.toHaveProperty('retriable', true);
+ });
+});
diff --git a/backend/src/services/carddavClient.js b/backend/src/services/carddavClient.js
index af1ed10..fa55a24 100644
--- a/backend/src/services/carddavClient.js
+++ b/backend/src/services/carddavClient.js
@@ -1,118 +1,238 @@
-// Minimal CardDAV *client* — discovers address books on a remote server (e.g.
-// Nextcloud) and pulls vCards. One-way/read-only: we never write back.
+// CardDAV client — discovers address books, pulls vCards, and performs
+// conditional resource writes against a remote server (e.g. Nextcloud).
//
// Flow: current-user-principal -> addressbook-home-set -> enumerate collections
-// -> addressbook-query REPORT for each book's vCards. Uses native fetch with the
-// WebDAV verbs PROPFIND/REPORT and HTTP Basic auth. Host is SSRF-validated up
-// front (reusing the same policy IMAP/SMTP hosts use).
-
-import { XMLParser } from 'fast-xml-parser';
-import { validateHost } from './hostValidation.js';
-import { safeFetch } from './safeFetch.js';
-
-const parser = new XMLParser({
- ignoreAttributes: false,
- removeNSPrefix: true, // -> response, so parsing is namespace-agnostic
- trimValues: false, // preserve vCard line structure inside
- // Large CardDAV REPORTs can exceed fast-xml-parser's 1000-expansion default.
- // Raise it generously while preserving the previous depth setting.
- processEntities: { maxTotalExpansions: 10_000_000, maxExpansionDepth: 10 },
-});
-
-const toArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]);
-
-function basicAuth(username, password) {
- return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
-}
-
-async function assertHostAllowed(url, allowPrivate) {
- let hostname;
- try { hostname = new URL(url).hostname; }
- catch { throw new Error('Invalid server URL'); }
- const err = await validateHost(hostname, { allowPrivate });
- if (err) throw new Error(err);
-}
-
-async function dav(method, url, { username, password, depth, body, allowPrivate = false } = {}) {
- // Re-validate on every request: hrefs returned by the server (principal, home
- // set, book URLs) are attacker-influenced and could point at internal hosts.
- await assertHostAllowed(url, allowPrivate);
- const headers = {
- Authorization: basicAuth(username, password),
- 'Content-Type': 'application/xml; charset=utf-8',
- };
- if (depth != null) headers.Depth = String(depth);
- let res;
- try {
- // safeFetch validates every redirect hop's IP (well-known discovery relies on
- // the server's 301 redirect), honouring the admin private-host policy.
- res = await safeFetch(url, { method, headers, body, redirect: 'follow', signal: AbortSignal.timeout(30000) }, { allowPrivate });
- } catch (err) {
- if (err.name === 'TimeoutError') throw new Error('CardDAV server did not respond (timed out)', { cause: err });
- throw new Error(`Could not reach the CardDAV server: ${err.message}`, { cause: err });
- }
- if (res.status === 401) throw new Error('Authentication failed — check the username and app password');
- if (!res.ok && res.status !== 207) {
- throw new Error(`CardDAV request failed (${res.status} ${res.statusText})`);
- }
- return res.text();
-}
-
-// Merge the blocks from every 2xx propstat of a into one object.
-// A propstat carrying a non-2xx status (e.g. 404 for unsupported props) is skipped;
-// a propstat with no status line at all is treated as usable.
-function propsOf(response) {
- const merged = {};
- for (const ps of toArray(response.propstat)) {
- const status = typeof ps.status === 'string' ? ps.status : '';
- if (status && !/\b2\d\d\b/.test(status)) continue;
- Object.assign(merged, ps.prop || {});
- }
- return merged;
-}
-
-function textOf(node) {
- if (node == null) return '';
- if (typeof node === 'string') return node;
- if (typeof node === 'object' && '#text' in node) return String(node['#text']);
- return '';
-}
-
-// fast-xml-parser decodes named XML entities (& -> &) but leaves numeric
-// character references (
, é) as literal text. Nextcloud/SabreDAV encodes
-// each vCard line's trailing CR as
inside so the CRLF endings
-// survive XML line-ending normalization; without decoding, every parsed field keeps
-// a literal "
" (and an empty property renders as just "
"). Decode decimal
-// and hex references back to their characters — the vCard parser then handles the
-// restored CR/LF normally. Named entities are left for the XML parser to resolve.
-function decodeXmlCharRefs(str) {
- return str.replace(/([xX][0-9a-fA-F]+|\d+);/g, (match, code) => {
- const cp = (code[0] === 'x' || code[0] === 'X')
- ? parseInt(code.slice(1), 16)
- : parseInt(code, 10);
- // Reject out-of-range and surrogate code points; leave those references as-is.
- if (!Number.isFinite(cp) || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) return match;
- try { return String.fromCodePoint(cp); }
- catch { return match; }
+// -> addressbook-query REPORT for each book's vCards. This module owns CardDAV
+// protocol logic; HTTP transport, response limits, and SSRF fencing live in
+// carddavTransport.js.
+
+import {
+ CardDavError,
+ createDavOperation,
+ davRequest,
+} from './carddavTransport.js';
+import {
+ CARDDAV_NS,
+ DAV_NS,
+ childrenNamed,
+ onlyChildNamed,
+ parseDavMultistatus,
+ parseDavResponse,
+ successfulProperties,
+ textOfNode,
+ xmlEscape,
+} from './carddavXml.js';
+
+function normalizePercentEscapes(value) {
+ return value.replace(/%([0-9a-f]{2})/gi, (match, hex) => {
+ const byte = Number.parseInt(hex, 16);
+ const character = String.fromCharCode(byte);
+ return /[A-Za-z0-9\-._~]/.test(character)
+ ? character
+ : `%${hex.toUpperCase()}`;
});
}
-// Resolve an href (often an absolute path) against the request URL's origin.
-function absolute(href, baseUrl) {
- try { return new URL(href, baseUrl).href; }
- catch { return href; }
+export function canonicalCollectionUrl(rawUrl, baseUrl) {
+ let url;
+ try {
+ url = new URL(rawUrl, baseUrl);
+ } catch {
+ throw new CardDavError('CardDAV collection URL was invalid');
+ }
+ if (!['http:', 'https:'].includes(url.protocol)
+ || url.username || url.password || url.hash) {
+ throw new CardDavError('CardDAV collection URL must be HTTP(S) without credentials or a fragment');
+ }
+ const pathname = normalizePercentEscapes(url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`);
+ const search = normalizePercentEscapes(url.search);
+ return `${url.origin}${pathname}${search}`;
+}
+
+function recordCollectionIdentity(identity, requestUrl) {
+ const canonicalUrl = canonicalCollectionUrl(requestUrl);
+ if (identity.canonicalUrl && identity.canonicalUrl !== canonicalUrl) {
+ throw new CardDavError('CardDAV operation changed collection identity');
+ }
+ identity.canonicalUrl = canonicalUrl;
+}
+
+const DAV_MAX_DISCOVERY_RESPONSES = 1_000;
+const DAV_MAX_SYNC_PAGES = 100;
+const DAV_MAX_SYNC_MEMBERS = 50_000;
+
+function withDavOperation(url, operation, callback) {
+ if (operation) return callback(operation);
+ const ownedOperation = createDavOperation(url);
+ return ownedOperation.run(() => callback(ownedOperation));
+}
+
+async function runCardResourceOperation(operationName, callback) {
+ try {
+ return await callback();
+ } catch (error) {
+ if (error instanceof CardDavError) {
+ error.operation = operationName;
+ throw error;
+ }
+ throw new CardDavError(error.message, { operation: operationName, cause: error });
+ }
+}
+
+function hrefScopeError(message) {
+ return Object.assign(new CardDavError(message), { code: 'ERR_DAV_HREF_SCOPE' });
+}
+
+function validateResourceRedirect(collectionUrl) {
+ return redirectUrl => memberHref(redirectUrl, collectionUrl);
+}
+
+function resolveSameOriginHref(rawHref, baseUrl) {
+ if (typeof rawHref !== 'string' || !rawHref || rawHref !== rawHref.trim()) {
+ throw hrefScopeError('CardDAV href must be a non-empty URI reference');
+ }
+ if (rawHref.includes('\\')) {
+ throw hrefScopeError('CardDAV href must not contain backslashes');
+ }
+ if (rawHref.includes('#') || /^(?:[a-z][a-z\d+.-]*:)?\/\/[^/?#]*@/i.test(rawHref)) {
+ throw hrefScopeError('CardDAV href must not contain credentials or a fragment');
+ }
+ let base;
+ let resolved;
+ try {
+ base = new URL(baseUrl);
+ resolved = new URL(rawHref, base);
+ } catch {
+ throw hrefScopeError('CardDAV href was not a valid URI reference');
+ }
+ if (resolved.username || resolved.password || resolved.hash) {
+ throw hrefScopeError('CardDAV href must not contain credentials or a fragment');
+ }
+ if (resolved.origin !== base.origin) {
+ throw hrefScopeError('CardDAV href must stay on the credential origin');
+ }
+ return resolved.href;
}
-// Pure: pull a single href-valued property out of a PROPFIND multistatus, by its
-// namespace-stripped local name (e.g. 'current-user-principal'). Exported for testing.
+export function memberHref(rawHref, collectionUrl, { allowCollection = false } = {}) {
+ const rawPath = typeof rawHref === 'string' ? rawHref.split(/[?#]/, 1)[0] : '';
+ if (/(?:^|\/)(?:\.|%2e){2}(?:\/|$)/i.test(rawPath) || rawPath.includes('\\')) {
+ throw hrefScopeError('CardDAV member href escaped its collection');
+ }
+
+ const href = resolveSameOriginHref(rawHref, collectionUrl);
+ const collection = new URL(collectionUrl);
+ const member = new URL(href);
+ if (href === collection.href) {
+ if (allowCollection) return href;
+ throw hrefScopeError('CardDAV member href identified the collection itself');
+ }
+ if (!collection.pathname.endsWith('/') || !member.pathname.startsWith(collection.pathname)) {
+ throw hrefScopeError('CardDAV member href was outside its collection');
+ }
+
+ const relativePath = member.pathname.slice(collection.pathname.length);
+ const childName = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
+ if (!childName || childName.includes('/') || childName.includes('\\')
+ || /%(?:2f|5c)/i.test(childName)) {
+ throw hrefScopeError('CardDAV member href was not a direct collection child');
+ }
+ return href;
+}
+
+function nodesNamed(nodes, namespaceURI, localName) {
+ return nodes.filter(node => (
+ node.namespaceURI === namespaceURI && node.localName === localName
+ ));
+}
+
+function optionalProperty(properties, namespaceURI, localName, label) {
+ const matches = nodesNamed(properties, namespaceURI, localName);
+ if (matches.length > 1) {
+ throw new Error(`${label} contained duplicate {${namespaceURI}}${localName} properties`);
+ }
+ return matches[0] ?? null;
+}
+
+function optionalAttribute(node, namespaceURI, localName, label) {
+ const matches = (node.attributes || []).filter(attribute => (
+ attribute.namespaceURI === namespaceURI && attribute.localName === localName
+ ));
+ if (matches.length > 1) {
+ throw new Error(`${label} contained duplicate {${namespaceURI ?? ''}}${localName} attributes`);
+ }
+ return matches[0]?.value ?? null;
+}
+
+function discoveryProperties(response, label, requiredProperty) {
+ if (response.status != null) {
+ throw new CardDavError(`${label} failed (${response.status})`, { status: response.status });
+ }
+ for (const propstat of response.propstats) {
+ if (propstat.status >= 200 && propstat.status < 300) continue;
+ const failedRequired = propstat.properties.some(node => (
+ node.namespaceURI === requiredProperty.namespaceURI
+ && node.localName === requiredProperty.localName
+ ));
+ if (propstat.status !== 404 || failedRequired) {
+ throw new CardDavError(`${label} failed (${propstat.status})`, { status: propstat.status });
+ }
+ }
+ const properties = successfulProperties(response);
+ if (!optionalProperty(
+ properties,
+ requiredProperty.namespaceURI,
+ requiredProperty.localName,
+ label,
+ )) {
+ throw new Error(
+ `${label} did not include a successful {${requiredProperty.namespaceURI}}${requiredProperty.localName}`,
+ );
+ }
+ return properties;
+}
+
+// Pure: pull one exact href-valued property out of a PROPFIND multistatus.
+// `key` is mapped to its required DAV/CardDAV namespace. Exported for testing.
export function extractHref(xmlText, key, baseUrl) {
- const xml = parser.parse(xmlText);
- const response = toArray(xml?.multistatus?.response)[0];
- if (!response) return null;
- const val = propsOf(response)[key];
- const href = val?.href ?? val;
- const text = textOf(href) || (typeof href === 'string' ? href : '');
- return text ? absolute(text, baseUrl) : null;
+ const expectedNamespace = key === 'current-user-principal' ? DAV_NS
+ : key === 'addressbook-home-set' ? CARDDAV_NS : null;
+ if (!expectedNamespace) throw new TypeError(`Unsupported CardDAV discovery property: ${key}`);
+
+ const multistatus = parseDavMultistatus(xmlText, 'discovery response');
+ const responseNodes = childrenNamed(multistatus, DAV_NS, 'response');
+ if (responseNodes.length !== 1) {
+ throw new Error(`CardDAV discovery response must contain exactly one DAV response; found ${responseNodes.length}`);
+ }
+ const response = parseDavResponse(responseNodes[0], 'CardDAV discovery response');
+ resolveSameOriginHref(response.href, baseUrl);
+ if (response.status != null) {
+ if (response.status === 404) return null;
+ throw new CardDavError(`CardDAV discovery response failed (${response.status})`, {
+ status: response.status,
+ });
+ }
+ for (const propstat of response.propstats) {
+ if ((propstat.status < 200 || propstat.status >= 300) && propstat.status !== 404) {
+ throw new CardDavError(`CardDAV discovery response failed (${propstat.status})`, {
+ status: propstat.status,
+ });
+ }
+ }
+ const property = optionalProperty(
+ successfulProperties(response),
+ expectedNamespace,
+ key,
+ 'CardDAV discovery response',
+ );
+ if (!property) return null;
+ const href = textOfNode(onlyChildNamed(
+ property,
+ DAV_NS,
+ 'href',
+ `{${expectedNamespace}}${key}`,
+ ));
+ return resolveSameOriginHref(href, baseUrl);
}
// PROPFIND for a single href-valued property. `key` is the expected local name in
@@ -120,7 +240,12 @@ export function extractHref(xmlText, key, baseUrl) {
async function propfindHref(url, propXml, key, creds) {
const body = `
${propXml} `;
- return extractHref(await dav('PROPFIND', url, { ...creds, depth: 0, body }), key, url);
+ const { bodyText, requestUrl } = await davRequest(creds.operation, 'PROPFIND', url, {
+ ...creds,
+ depth: 0,
+ body,
+ });
+ return extractHref(bodyText, key, requestUrl);
}
// Find the user's principal URL. Tries the given URL, then RFC 6764 well-known
@@ -135,7 +260,9 @@ async function resolvePrincipal(serverUrl, creds) {
const principal = await propfindHref(base, ' ', 'current-user-principal', creds);
if (principal) return principal;
} catch (err) {
- if (/Authentication failed/.test(err.message)) throw err; // wrong creds — stop trying
+ if (err instanceof CardDavError && (err.status === 401 || err.code === 'ERR_DAV_HREF_SCOPE')) {
+ throw err;
+ }
lastErr = err;
}
}
@@ -144,73 +271,680 @@ async function resolvePrincipal(serverUrl, creds) {
}
// Discover every address book on the server for these credentials.
-// Returns [{ url, displayName }].
+// Returns [{ url, displayName, supportsSyncCollection }].
export async function discoverAddressBooks({ serverUrl, username, password, allowPrivate = false }) {
- await assertHostAllowed(serverUrl, allowPrivate);
- const creds = { username, password, allowPrivate };
+ const operation = createDavOperation(new URL(serverUrl).origin);
+ return operation.run(async () => {
+ const creds = { username, password, allowPrivate, operation };
- const principal = await resolvePrincipal(serverUrl, creds);
- const homeSet = await propfindHref(principal, ' ', 'addressbook-home-set', creds)
- || principal;
+ const principal = await resolvePrincipal(serverUrl, creds);
+ const homeSet = await propfindHref(principal, ' ', 'addressbook-home-set', creds)
+ || principal;
- // Enumerate collections under the home set (Depth: 1).
- const body = `
-
- `;
- const xmlText = await dav('PROPFIND', homeSet, { ...creds, depth: 1, body });
- const books = parseAddressBooks(xmlText, homeSet);
- if (!books.length) throw new Error('No address books found for this account');
- return books;
+ // Enumerate collections under the home set (Depth: 1).
+ const body = `
+
+
+ `;
+ const result = await davRequest(operation, 'PROPFIND', homeSet, { ...creds, depth: 1, body });
+ const books = parseAddressBooks(result.bodyText, result.requestUrl);
+ return books;
+ });
}
// Pure: extract address-book collections from a PROPFIND multistatus. Exported
-// for testing. Returns [{ url, displayName }].
+// for testing. Returns [{ url, displayName, supportsSyncCollection,
+// capabilities, discoveryIndex, addressData }].
export function parseAddressBooks(xmlText, baseUrl) {
- const xml = parser.parse(xmlText);
+ const multistatus = parseDavMultistatus(xmlText, 'address book discovery response');
+ const responseNodes = childrenNamed(multistatus, DAV_NS, 'response');
+ if (responseNodes.length > DAV_MAX_DISCOVERY_RESPONSES) {
+ throw new Error(
+ `CardDAV discovery exceeded the ${DAV_MAX_DISCOVERY_RESPONSES.toLocaleString('en-US')} response limit`,
+ );
+ }
const books = [];
- for (const response of toArray(xml?.multistatus?.response)) {
- const props = propsOf(response);
- const rt = props.resourcetype || {};
- if (!('addressbook' in rt)) continue; // only address book collections
- const href = textOf(response.href) || response.href;
- if (!href) continue;
- books.push({
- url: absolute(href, baseUrl),
- displayName: textOf(props.displayname) || 'Contacts',
+ const byUrl = new Map();
+ const collectionUrl = new URL(baseUrl).href;
+ let foundCollection = false;
+ for (const [index, responseNode] of responseNodes.entries()) {
+ const label = `CardDAV discovery response ${index + 1}`;
+ const response = parseDavResponse(responseNode, label);
+ const href = memberHref(response.href, collectionUrl, { allowCollection: true });
+ const canonicalUrl = canonicalCollectionUrl(href);
+ const properties = discoveryProperties(response, label, {
+ namespaceURI: DAV_NS,
+ localName: 'resourcetype',
});
+ const resourceType = optionalProperty(properties, DAV_NS, 'resourcetype', label);
+ if (resourceType.children.some(node => (
+ node.localName === 'addressbook' && node.namespaceURI !== CARDDAV_NS
+ ))) {
+ throw new Error(`${label} used a foreign addressbook namespace`);
+ }
+ const isCollection = childrenNamed(resourceType, DAV_NS, 'collection').length > 0;
+ const isAddressBook = childrenNamed(resourceType, CARDDAV_NS, 'addressbook').length > 0;
+
+ if (canonicalUrl === canonicalCollectionUrl(collectionUrl)) {
+ if (!isCollection) throw new Error(`${label} did not identify the DAV home collection`);
+ if (foundCollection) throw new Error('CardDAV discovery returned duplicate home collection self responses');
+ foundCollection = true;
+ continue;
+ }
+ if (!isAddressBook) continue;
+ if (!isCollection) throw new Error(`${label} did not identify a DAV address book collection`);
+ const displayName = optionalProperty(properties, DAV_NS, 'displayname', label);
+ const book = {
+ url: canonicalUrl,
+ displayName: textOfNode(displayName) || 'Contacts',
+ supportsSyncCollection: supportedReportsOf(properties, label).syncCollection,
+ capabilities: capabilitiesOf(properties, label),
+ discoveryIndex: books.length,
+ addressData: addressDataOf(properties, label),
+ };
+ const existing = byUrl.get(canonicalUrl);
+ if (existing) {
+ if (existing.displayName !== book.displayName
+ || existing.supportsSyncCollection !== book.supportsSyncCollection
+ || !sameCapabilities(existing.capabilities, book.capabilities)
+ || !sameAddressData(existing.addressData, book.addressData)) {
+ throw new CardDavError(`CardDAV discovery returned conflicting metadata for ${canonicalUrl}`);
+ }
+ continue;
+ }
+ byUrl.set(canonicalUrl, book);
+ books.push(book);
+ }
+ if (!foundCollection) {
+ throw new Error('CardDAV discovery did not include the required home collection self response');
}
return books;
}
-// Fetch every vCard in an address book via a filter-less addressbook-query REPORT.
+// Pure: detect the incremental REPORT methods advertised by a collection.
+export function parseSupportedReports(xmlText) {
+ const multistatus = parseDavMultistatus(xmlText, 'supported report discovery response');
+ let syncCollection = false;
+ let addressbookMultiget = false;
+ for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) {
+ const label = `CardDAV supported report response ${index + 1}`;
+ const response = parseDavResponse(responseNode, label);
+ if (response.status != null) {
+ throw new CardDavError(`${label} failed (${response.status})`, { status: response.status });
+ }
+ const reports = supportedReportsOf(successfulProperties(response), label);
+ syncCollection ||= reports.syncCollection;
+ addressbookMultiget ||= reports.addressbookMultiget;
+ }
+ return { syncCollection, addressbookMultiget };
+}
+
+function supportedReportsOf(properties, label) {
+ let syncCollection = false;
+ let addressbookMultiget = false;
+ const supported = optionalProperty(properties, DAV_NS, 'supported-report-set', label);
+ if (!supported) return { syncCollection, addressbookMultiget };
+ for (const item of childrenNamed(supported, DAV_NS, 'supported-report')) {
+ const report = onlyChildNamed(item, DAV_NS, 'report', `${label} supported-report`);
+ syncCollection ||= childrenNamed(report, DAV_NS, 'sync-collection').length > 0;
+ addressbookMultiget ||= childrenNamed(report, CARDDAV_NS, 'addressbook-multiget').length > 0;
+ }
+ return { syncCollection, addressbookMultiget };
+}
+
+function capabilitiesOf(properties, label) {
+ const current = optionalProperty(properties, DAV_NS, 'current-user-privilege-set', label);
+ if (!current) {
+ return { create: 'unknown', update: 'unknown', delete: 'unknown' };
+ }
+
+ const granted = new Set();
+ for (const privilegeNode of childrenNamed(current, DAV_NS, 'privilege')) {
+ if (privilegeNode.children.length !== 1) {
+ throw new Error(`${label} contained a malformed DAV privilege`);
+ }
+ const [privilege] = privilegeNode.children;
+ if (privilege.namespaceURI !== DAV_NS) continue;
+ granted.add(privilege.localName);
+ if (privilege.localName === 'write' || privilege.localName === 'all') {
+ granted.add('bind');
+ granted.add('write-content');
+ granted.add('unbind');
+ }
+ }
+ return {
+ create: granted.has('bind') ? 'allowed' : 'denied',
+ update: granted.has('write-content') ? 'allowed' : 'denied',
+ delete: granted.has('unbind') ? 'allowed' : 'denied',
+ };
+}
+
+function addressDataOf(properties, label) {
+ const supported = optionalProperty(properties, CARDDAV_NS, 'supported-address-data', label);
+ if (!supported) return [];
+ return childrenNamed(supported, CARDDAV_NS, 'address-data-type').map((node, index) => {
+ const entryLabel = `${label} supported address data ${index + 1}`;
+ return {
+ contentType: optionalAttribute(node, null, 'content-type', entryLabel) ?? 'text/vcard',
+ version: optionalAttribute(node, null, 'version', entryLabel) ?? '3.0',
+ };
+ });
+}
+
+function sameCapabilities(left, right) {
+ return left.create === right.create
+ && left.update === right.update
+ && left.delete === right.delete;
+}
+
+function sameAddressData(left, right) {
+ return left.length === right.length && left.every((entry, index) => (
+ entry.contentType === right[index].contentType && entry.version === right[index].version
+ ));
+}
+
+// Pure: build an RFC 6578 sync-collection REPORT body.
+export function buildSyncCollectionBody(syncToken) {
+ return `
+${xmlEscape(syncToken)}
+ 1 `;
+}
+
+// Pure: parse one RFC 6578 sync-collection response page.
+export function parseSyncPage(xmlText, baseUrl) {
+ const multistatus = parseDavMultistatus(xmlText, 'sync response');
+ const collectionUrl = new URL(baseUrl).href;
+ const events = new Map();
+ let truncated = false;
+
+ for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) {
+ const label = `CardDAV sync response ${index + 1}`;
+ const response = parseDavResponse(responseNode, label);
+
+ if (response.status != null) {
+ const href = memberHref(response.href, collectionUrl, {
+ allowCollection: response.status === 507,
+ });
+ if (response.status === 507 && href === collectionUrl) {
+ truncated = true;
+ continue;
+ }
+ if (response.status === 404) {
+ if (events.has(href)) {
+ throw new Error(`CardDAV sync response contained duplicate member href ${href}`);
+ }
+ events.set(href, { type: 'removed', href });
+ continue;
+ }
+ if (response.status < 200 || response.status >= 300) {
+ throw new CardDavError(`CardDAV sync failed for ${href} (${response.status})`, {
+ status: response.status,
+ });
+ }
+ throw new Error(`${label} did not include requested sync properties`);
+ }
+
+ const failedPropstat = response.propstats.find(({ status }) => status < 200 || status >= 300);
+ const href = memberHref(response.href, collectionUrl, { allowCollection: Boolean(failedPropstat) });
+ if (failedPropstat) {
+ throw new CardDavError(`CardDAV sync failed for ${href} (${failedPropstat.status})`, {
+ status: failedPropstat.status,
+ });
+ }
+ if (events.has(href)) {
+ throw new Error(`CardDAV sync response contained duplicate member href ${href}`);
+ }
+ const etag = optionalProperty(successfulProperties(response), DAV_NS, 'getetag', label);
+ if (!etag) {
+ throw new Error(`${label} did not include a successful DAV getetag`);
+ }
+ events.set(href, {
+ type: 'changed',
+ href,
+ etag: textOfNode(etag),
+ });
+ }
+
+ const tokenNodes = childrenNamed(multistatus, DAV_NS, 'sync-token');
+ const nextToken = tokenNodes.length === 1 ? textOfNode(tokenNodes[0]) : '';
+ if (tokenNodes.length !== 1 || !nextToken.trim()) {
+ if (truncated) {
+ throw new Error('CardDAV sync response was truncated without a continuation token');
+ }
+ throw new Error('CardDAV sync response did not include a usable sync token');
+ }
+ return {
+ changed: [...events.values()]
+ .filter(event => event.type === 'changed')
+ .map(({ href, etag: eventEtag }) => ({ href, etag: eventEtag })),
+ removed: [...events.values()]
+ .filter(event => event.type === 'removed')
+ .map(({ href }) => ({ href })),
+ nextToken,
+ truncated,
+ };
+}
+
+// Pure: build an RFC 6352 addressbook-multiget REPORT body.
+export function buildMultigetBody(hrefs) {
+ const requested = hrefs.map(href => `${xmlEscape(href)} `).join('');
+ return `
+
+ ${requested} `;
+}
+
+// Fetch one RFC 6578 page without opening any database transaction.
+export async function fetchSyncPage({
+ url,
+ syncToken,
+ username,
+ password,
+ allowPrivate = false,
+ operation,
+ identity,
+}) {
+ return withDavOperation(url, operation, async activeOperation => {
+ const body = buildSyncCollectionBody(syncToken);
+ const { bodyText, requestUrl } = await davRequest(activeOperation, 'REPORT', url, {
+ username,
+ password,
+ depth: 0,
+ body,
+ allowPrivate,
+ });
+ if (identity) recordCollectionIdentity(identity, requestUrl);
+ return parseSyncPage(bodyText, requestUrl);
+ });
+}
+
+// Fetch one RFC 6352 multiget batch without opening any database transaction.
+export async function fetchCardsByHref({
+ url,
+ hrefs,
+ username,
+ password,
+ allowPrivate = false,
+ operation,
+ identity,
+}) {
+ if (!Array.isArray(hrefs) || hrefs.length === 0) {
+ throw new CardDavError('CardDAV multiget requires a non-empty href list');
+ }
+ const collectionUrl = identity?.canonicalUrl || url;
+ const normalizedHrefs = hrefs.map(href => memberHref(href, collectionUrl));
+ const requestedHrefs = new Set(normalizedHrefs);
+ if (requestedHrefs.size !== normalizedHrefs.length) {
+ throw new CardDavError('CardDAV multiget hrefs must be unique after normalization');
+ }
+
+ return withDavOperation(url, operation, async activeOperation => {
+ const body = buildMultigetBody(normalizedHrefs);
+ const { bodyText, requestUrl } = await davRequest(activeOperation, 'REPORT', url, {
+ username,
+ password,
+ depth: 0,
+ body,
+ allowPrivate,
+ });
+ if (identity) recordCollectionIdentity(identity, requestUrl);
+ const results = parseMultigetCards(bodyText, requestUrl);
+ const resultCounts = new Map();
+ for (const result of results) {
+ resultCounts.set(result.href, (resultCounts.get(result.href) || 0) + 1);
+ }
+ for (const returnedHref of resultCounts.keys()) {
+ if (!requestedHrefs.has(returnedHref)) {
+ throw new CardDavError(`CardDAV multiget returned an unrequested response for ${returnedHref}`);
+ }
+ }
+ for (const requestedHref of requestedHrefs) {
+ const count = resultCounts.get(requestedHref) || 0;
+ if (count !== 1) {
+ throw new CardDavError(
+ `CardDAV multiget returned ${count} terminal responses for ${requestedHref}`,
+ );
+ }
+ }
+ return results;
+ });
+}
+
+async function fetchSnapshotPlan({ url, syncToken, identity, ...creds }) {
+ return {
+ expectedRemoteToken: syncToken ?? null,
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ upserts: await fetchAddressBookCards({ url, identity, ...creds }),
+ removedHrefs: [],
+ };
+}
+
+async function fetchSyncPlan({ url, syncToken, identity, ...creds }) {
+ const events = new Map();
+ const seenContinuationTokens = new Set();
+ let pageToken = syncToken ?? '';
+ let page;
+ let pageCount = 0;
+
+ while (true) {
+ if (pageCount === DAV_MAX_SYNC_PAGES) {
+ throw new CardDavError(`CardDAV sync exceeded the ${DAV_MAX_SYNC_PAGES} page limit`);
+ }
+ seenContinuationTokens.add(pageToken);
+ page = await fetchSyncPage({ url, syncToken: pageToken, identity, ...creds });
+ pageCount++;
+ for (const [eventList, disposition] of [
+ [page.changed, 'changed'],
+ [page.removed, 'removed'],
+ ]) {
+ for (const event of eventList) {
+ if (!events.has(event.href)) {
+ if (events.size === DAV_MAX_SYNC_MEMBERS) {
+ throw new CardDavError(
+ `CardDAV sync exceeded the ${DAV_MAX_SYNC_MEMBERS.toLocaleString('en-US')} member limit`,
+ );
+ }
+ }
+ events.set(event.href, disposition);
+ }
+ }
+ if (!page.truncated) break;
+ if (seenContinuationTokens.has(page.nextToken)) {
+ throw new CardDavError('CardDAV sync continuation token cycle detected');
+ }
+ pageToken = page.nextToken;
+ }
+
+ const changedHrefs = [...events]
+ .filter(([, disposition]) => disposition === 'changed')
+ .map(([href]) => href);
+ const upserts = [];
+ for (let offset = 0; offset < changedHrefs.length; offset += 100) {
+ const cards = await fetchCardsByHref({
+ url,
+ hrefs: changedHrefs.slice(offset, offset + 100),
+ identity,
+ ...creds,
+ });
+ for (const card of cards) {
+ if (card.status === 404) events.set(card.href, 'removed');
+ else upserts.push(card);
+ }
+ }
+
+ return {
+ expectedRemoteToken: syncToken ?? null,
+ nextRemoteToken: page.nextToken,
+ capability: 'sync-collection',
+ replaceAll: syncToken == null || syncToken === '',
+ upserts,
+ removedHrefs: [...events]
+ .filter(([, disposition]) => disposition === 'removed')
+ .map(([href]) => href),
+ };
+}
+
+// Build one complete network plan before callers perform any database write.
+export async function fetchAddressBookDelta({
+ url,
+ syncToken,
+ supportsSyncCollection,
+ ...creds
+}) {
+ const observedUrl = canonicalCollectionUrl(url);
+ const identity = { canonicalUrl: null };
+ const operation = createDavOperation(url);
+ return operation.run(async () => {
+ const operationCreds = { ...creds, operation };
+ let plan;
+ if (!supportsSyncCollection) {
+ plan = await fetchSnapshotPlan({ url, syncToken, identity, ...operationCreds });
+ } else {
+ try {
+ plan = await fetchSyncPlan({ url, syncToken, identity, ...operationCreds });
+ } catch (error) {
+ if (!(error instanceof CardDavError)
+ || (error.requestStatus !== 405 && error.requestStatus !== 501)) {
+ throw error;
+ }
+ plan = await fetchSnapshotPlan({ url, syncToken, identity, ...operationCreds });
+ }
+ }
+ return { ...plan, collectionIdentity: { observedUrl, canonicalUrl: identity.canonicalUrl } };
+ });
+}
+
+// Fetch every vCard in an address book via an addressbook-query REPORT.
// Returns [{ href, etag, vcard }].
-export async function fetchAddressBookCards({ url, username, password, allowPrivate = false }) {
- await assertHostAllowed(url, allowPrivate);
- const body = `
-
- `;
- const xmlText = await dav('REPORT', url, { username, password, depth: 1, body, allowPrivate });
- return parseCards(xmlText, url);
+export async function fetchAddressBookCards({
+ url,
+ username,
+ password,
+ allowPrivate = false,
+ operation,
+ identity,
+}) {
+ return withDavOperation(url, operation, async activeOperation => {
+ const body = `
+
+
+
+ `;
+ const { bodyText, requestUrl } = await davRequest(activeOperation, 'REPORT', url, {
+ username,
+ password,
+ depth: 1,
+ body,
+ allowPrivate,
+ });
+ if (identity) recordCollectionIdentity(identity, requestUrl);
+ return parseCards(bodyText, requestUrl);
+ });
+}
+
+export async function fetchCardResource({
+ url,
+ href,
+ username,
+ password,
+ allowPrivate = false,
+ operation,
+}) {
+ return runCardResourceOperation('fetch', () => {
+ const resourceUrl = memberHref(href, url);
+ return withDavOperation(url, operation, async activeOperation => {
+ const result = await davRequest(activeOperation, 'GET', resourceUrl, {
+ username,
+ password,
+ headers: { Accept: 'text/vcard' },
+ errorOperation: 'fetch',
+ validateRedirect: validateResourceRedirect(url),
+ allowPrivate,
+ });
+ const finalHref = memberHref(result.requestUrl, url);
+ const etag = result.headers.get('etag');
+ if (!etag || !result.bodyText.trim()) {
+ throw new CardDavError('CardDAV resource did not include a vCard and ETag', {
+ operation: 'fetch',
+ });
+ }
+ return { href: finalHref, etag, vcard: result.bodyText };
+ });
+ });
+}
+
+export async function putCardResource({
+ url,
+ href,
+ etag,
+ vcard,
+ username,
+ password,
+ allowPrivate = false,
+ operation,
+}) {
+ const operationName = etag == null ? 'create' : 'update';
+ return runCardResourceOperation(operationName, () => {
+ if (typeof etag === 'string' && etag.trim() === '*') {
+ throw new CardDavError('CardDAV resource update requires a stored ETag', {
+ operation: operationName,
+ });
+ }
+ const resourceUrl = memberHref(href, url);
+ if (typeof vcard !== 'string') {
+ throw new CardDavError('CardDAV resource write requires a vCard', {
+ operation: operationName,
+ });
+ }
+ return withDavOperation(url, operation, async activeOperation => {
+ const conditionalHeader = operationName === 'create'
+ ? { 'If-None-Match': '*' }
+ : { 'If-Match': etag };
+ const result = await davRequest(activeOperation, 'PUT', resourceUrl, {
+ username,
+ password,
+ body: vcard,
+ headers: {
+ 'Content-Type': 'text/vcard; charset=utf-8',
+ ...conditionalHeader,
+ },
+ errorOperation: operationName,
+ validateRedirect: validateResourceRedirect(url),
+ allowPrivate,
+ });
+ const finalHref = memberHref(result.requestUrl, url);
+ return { href: finalHref, etag: result.headers.get('etag') };
+ });
+ });
+}
+
+export async function deleteCardResource({
+ url,
+ href,
+ etag,
+ username,
+ password,
+ allowPrivate = false,
+ operation,
+}) {
+ return runCardResourceOperation('delete', () => {
+ if (typeof etag === 'string' && etag.trim() === '*') {
+ throw new CardDavError('CardDAV resource delete requires a stored ETag', {
+ operation: 'delete',
+ });
+ }
+ const resourceUrl = memberHref(href, url);
+ if (typeof etag !== 'string') {
+ throw new CardDavError('CardDAV resource delete requires an ETag', {
+ operation: 'delete',
+ });
+ }
+ return withDavOperation(url, operation, async activeOperation => {
+ const result = await davRequest(activeOperation, 'DELETE', resourceUrl, {
+ username,
+ password,
+ headers: { 'If-Match': etag },
+ acceptedStatuses: [404],
+ errorOperation: 'delete',
+ validateRedirect: validateResourceRedirect(url),
+ allowPrivate,
+ });
+ return { href: memberHref(result.requestUrl, url) };
+ });
+ });
+}
+
+function extractCard(response, href, label, errorContext) {
+ const failedPropstat = response.propstats.find(({ status }) => status < 200 || status >= 300);
+ if (failedPropstat) {
+ throw new CardDavError(
+ `CardDAV ${errorContext} failed for ${href} (${failedPropstat.status})`,
+ { status: failedPropstat.status },
+ );
+ }
+ const properties = successfulProperties(response);
+ const etag = optionalProperty(properties, DAV_NS, 'getetag', label);
+ const addressData = optionalProperty(properties, CARDDAV_NS, 'address-data', label);
+ if (errorContext === 'snapshot' && (!etag || !addressData)) {
+ throw new Error(`${label} did not include successful DAV getetag and CardDAV address-data`);
+ }
+ const vcard = textOfNode(addressData);
+ if (errorContext === 'snapshot' && !vcard.trim()) {
+ throw new Error(`${label} did not include non-empty CardDAV address-data`);
+ }
+ if (errorContext === 'multiget' && (!etag || !addressData || !vcard.trim())) {
+ throw new CardDavError(
+ `CardDAV multiget response for ${href} did not include non-empty address-data`,
+ );
+ }
+ return { href, etag: textOfNode(etag), vcard };
}
// Pure: extract vCards from an addressbook-query/REPORT multistatus. Exported for
// testing. Returns [{ href, etag, vcard }].
export function parseCards(xmlText, baseUrl) {
- const xml = parser.parse(xmlText);
- const responses = toArray(xml?.multistatus?.response);
- if (responses.some(response => /\b507\b/.test(textOf(response.status)))) {
- throw new Error('CardDAV server returned a truncated address book response');
- }
+ const multistatus = parseDavMultistatus(xmlText, 'address book snapshot response');
const cards = [];
- for (const response of responses) {
- const props = propsOf(response);
- const vcard = decodeXmlCharRefs(textOf(props['address-data'])).trim();
- if (!vcard) continue; // collection self-entry or a non-vCard resource
- cards.push({
- href: absolute(textOf(response.href) || response.href, baseUrl),
- etag: (textOf(props.getetag) || '').replace(/"/g, ''),
- vcard,
- });
+ const collectionUrl = new URL(baseUrl).href;
+
+ for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) {
+ const label = `CardDAV snapshot response ${index + 1}`;
+ const response = parseDavResponse(responseNode, label);
+ const href = memberHref(response.href, collectionUrl, { allowCollection: true });
+ const isCollection = href === collectionUrl;
+
+ if (response.status != null) {
+ if (response.status === 507) {
+ throw new Error('CardDAV server returned a truncated address book response');
+ }
+ if (response.status < 200 || response.status >= 300) {
+ throw new CardDavError(`CardDAV snapshot failed for ${href} (${response.status})`, {
+ status: response.status,
+ });
+ }
+ if (isCollection) continue;
+ throw new Error(`${label} did not include requested card properties`);
+ }
+
+ if (isCollection) {
+ const failedSelfPropstat = response.propstats.find(({ status }) => (
+ status !== 404 && (status < 200 || status >= 300)
+ ));
+ if (failedSelfPropstat) {
+ throw new CardDavError(`CardDAV snapshot failed for ${href} (${failedSelfPropstat.status})`, {
+ status: failedSelfPropstat.status,
+ });
+ }
+ continue;
+ }
+
+ cards.push(extractCard(response, href, label, 'snapshot'));
}
return cards;
}
+
+// Pure: parse cards and missing resources from an RFC 6352 multiget response.
+export function parseMultigetCards(xmlText, baseUrl) {
+ const multistatus = parseDavMultistatus(xmlText, 'multiget response');
+ const collectionUrl = new URL(baseUrl).href;
+ const results = [];
+ for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) {
+ const label = `CardDAV multiget response ${index + 1}`;
+ const response = parseDavResponse(responseNode, label);
+ const href = memberHref(response.href, collectionUrl);
+ if (response.status != null) {
+ if (response.status === 404) {
+ results.push({ href, status: response.status });
+ continue;
+ }
+ throw new CardDavError(`CardDAV multiget failed for ${href} (${response.status})`, {
+ status: response.status,
+ });
+ }
+
+ results.push(extractCard(response, href, label, 'multiget'));
+ }
+ return results;
+}
diff --git a/backend/src/services/carddavClient.protocol.test.js b/backend/src/services/carddavClient.protocol.test.js
new file mode 100644
index 0000000..b0b3bfa
--- /dev/null
+++ b/backend/src/services/carddavClient.protocol.test.js
@@ -0,0 +1,1749 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { safeFetch } from './safeFetch.js';
+import { CardDavError } from './carddavTransport.js';
+import {
+ buildMultigetBody,
+ buildSyncCollectionBody,
+ discoverAddressBooks,
+ fetchAddressBookDelta,
+ fetchAddressBookCards,
+ fetchCardResource,
+ fetchCardsByHref,
+ fetchSyncPage,
+ deleteCardResource,
+ parseMultigetCards,
+ parseSupportedReports,
+ parseSyncPage,
+ putCardResource,
+} from './carddavClient.js';
+
+vi.mock('./hostValidation.js', () => ({
+ validateHost: vi.fn().mockResolvedValue(null),
+}));
+
+vi.mock('./safeFetch.js', () => ({
+ safeFetch: vi.fn(),
+}));
+
+const BASE = 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/';
+
+function davResponse(xml, status = 207, statusText = 'Multi-Status', url, responseHeaders) {
+ const encoded = new TextEncoder().encode(xml);
+ let sent = false;
+ const bodyRead = vi.fn(async () => {
+ if (!sent && encoded.byteLength > 0) {
+ sent = true;
+ return { done: false, value: encoded };
+ }
+ return { done: true, value: undefined };
+ });
+ return {
+ body: {
+ getReader: () => ({
+ cancel: vi.fn().mockResolvedValue(undefined),
+ read: bodyRead,
+ releaseLock: vi.fn(),
+ }),
+ },
+ bodyRead,
+ headers: new Headers({
+ ...responseHeaders,
+ 'Content-Length': String(encoded.byteLength),
+ }),
+ status,
+ statusText,
+ ok: status >= 200 && status < 300,
+ url,
+ };
+}
+
+function syncPageXml(hrefs, nextToken, { truncated = false } = {}) {
+ return syncEventsXml(
+ hrefs.map((href, index) => ({ href, etag: `sync-${index}` })),
+ nextToken,
+ { truncated },
+ );
+}
+
+function multigetXml(hrefs) {
+ const cards = hrefs.map((href, index) => `${href}
+ W/"card-${index}"
+ BEGIN:VCARD
+UID:${index}
+FN:Contact ${index}
+END:VCARD
+ HTTP/1.1 200 OK `).join('');
+ return `${cards} `;
+}
+
+function syncEventsXml(events, nextToken, { truncated = false } = {}) {
+ const responses = events.map((event, index) => {
+ if (event.status === 404) {
+ return `${event.href} HTTP/1.1 404 Not Found `;
+ }
+ return `${event.href}
+ ${event.etag ?? `delta-${index}`}
+ HTTP/1.1 200 OK `;
+ }).join('');
+ const continuation = truncated
+ ? './ HTTP/1.1 507 Insufficient Storage '
+ : '';
+ return `${responses}${continuation}${nextToken} `;
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ safeFetch.mockReset();
+ vi.clearAllMocks();
+});
+
+describe('parseSupportedReports', () => {
+ it('recognizes sync-collection and addressbook-multiget across namespace prefixes', () => {
+ const xml = `
+ /dav/contacts/
+
+
+
+
+ HTTP/1.1 200 OK
+ `;
+
+ expect(parseSupportedReports(xml)).toEqual({
+ syncCollection: true,
+ addressbookMultiget: true,
+ });
+ });
+
+ it('reports unadvertised capabilities as unsupported', () => {
+ const xml = `/dav/contacts/
+
+ HTTP/1.1 200 OK `;
+
+ expect(parseSupportedReports(xml)).toEqual({
+ syncCollection: false,
+ addressbookMultiget: false,
+ });
+ });
+});
+
+describe('supported report discovery', () => {
+ it('requests supported-report-set and returns sync-collection support per book', async () => {
+ const principal = `/
+ /dav/principals/user/
+ HTTP/1.1 200 OK `;
+ const home = `/dav/principals/user/
+ /dav/addressbooks/user/
+ HTTP/1.1 200 OK `;
+ const books = `
+ /dav/addressbooks/user/
+
+ HTTP/1.1 200 OK
+ /dav/addressbooks/user/contacts/
+
+ Contacts
+
+
+ HTTP/1.1 200 OK
+ `;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(principal))
+ .mockResolvedValueOnce(davResponse(home))
+ .mockResolvedValueOnce(davResponse(books));
+
+ await expect(discoverAddressBooks({
+ serverUrl: 'https://cloud.example.com/',
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual([{
+ url: 'https://cloud.example.com/dav/addressbooks/user/contacts/',
+ displayName: 'Contacts',
+ supportsSyncCollection: true,
+ capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' },
+ discoveryIndex: 0,
+ addressData: [],
+ }]);
+
+ expect(safeFetch).toHaveBeenCalledTimes(3);
+ expect(safeFetch.mock.calls[2][1].body).toContain(' ');
+ expect(safeFetch.mock.calls[2][1].body).toContain(' ');
+ expect(safeFetch.mock.calls[2][1].body).toContain(' ');
+ });
+
+ it('returns an empty array for a complete home-set enumeration with no books', async () => {
+ const principal = `/
+ /dav/principals/user/
+ HTTP/1.1 200 OK
+ `;
+ const home = `
+ /dav/principals/user/
+ /dav/addressbooks/user/
+ HTTP/1.1 200 OK `;
+ const emptyBooks = `
+ /dav/addressbooks/user/
+
+ HTTP/1.1 200 OK
+ `;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(principal))
+ .mockResolvedValueOnce(davResponse(home))
+ .mockResolvedValueOnce(davResponse(emptyBooks));
+
+ await expect(discoverAddressBooks({
+ serverUrl: 'https://cloud.example.com/',
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual([]);
+ expect(safeFetch).toHaveBeenCalledTimes(3);
+ });
+
+ it.each([
+ { phase: 'principal', expectedCalls: 1 },
+ { phase: 'home set', expectedCalls: 2 },
+ { phase: 'address book', expectedCalls: 3 },
+ ])('rejects a cross-origin $phase href after $expectedCalls same-origin request(s)', async ({
+ phase,
+ expectedCalls,
+ }) => {
+ const principalHref = phase === 'principal'
+ ? 'https://evil.example.test/principal/'
+ : '/dav/principals/user/';
+ const homeHref = phase === 'home set'
+ ? 'https://evil.example.test/addressbooks/user/'
+ : '/dav/addressbooks/user/';
+ const bookHref = phase === 'address book'
+ ? 'https://evil.example.test/addressbooks/user/contacts/'
+ : '/dav/addressbooks/user/contacts/';
+ const principal = `/
+ ${principalHref}
+ HTTP/1.1 200 OK
+ `;
+ const home = `
+ /dav/principals/user/
+ ${homeHref}
+ HTTP/1.1 200 OK `;
+ const books = `
+ /dav/addressbooks/user/
+
+ HTTP/1.1 200 OK
+ ${bookHref}
+
+ HTTP/1.1 200 OK
+ `;
+ for (const xml of [principal, home, books].slice(0, expectedCalls)) {
+ safeFetch.mockResolvedValueOnce(davResponse(xml));
+ }
+
+ await expect(discoverAddressBooks({
+ serverUrl: 'https://cloud.example.com/',
+ username: 'user',
+ password: 'app-password',
+ allowPrivate: true,
+ })).rejects.toThrow(/origin/i);
+ expect(safeFetch).toHaveBeenCalledTimes(expectedCalls);
+ expect(safeFetch.mock.calls.every(([url]) => new URL(url).origin === 'https://cloud.example.com'))
+ .toBe(true);
+ });
+
+ it.each([
+ { name: 'empty fragment', href: '/dav/principals/user/#' },
+ { name: 'empty userinfo', href: 'https://@cloud.example.com/dav/principals/user/' },
+ ])('rejects a principal href with $name before the next request', async ({ href }) => {
+ const principal = `/
+ ${href}
+ HTTP/1.1 200 OK
+ `;
+ safeFetch.mockResolvedValueOnce(davResponse(principal));
+
+ await expect(discoverAddressBooks({
+ serverUrl: 'https://cloud.example.com/',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' });
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it.each([
+ {
+ phase: 'principal',
+ principalHref: String.raw`https:\\@cloud.example.com/dav/principals/user/`,
+ homeHref: '/dav/addressbooks/user/',
+ expectedCalls: 1,
+ },
+ {
+ phase: 'home set',
+ principalHref: '/dav/principals/user/',
+ homeHref: String.raw`https:\\@cloud.example.com/dav/addressbooks/user/`,
+ expectedCalls: 2,
+ },
+ ])('rejects a raw backslash $phase href before the next request', async ({
+ principalHref,
+ homeHref,
+ expectedCalls,
+ }) => {
+ const principal = `/
+ ${principalHref}
+ HTTP/1.1 200 OK
+ `;
+ const home = `
+ /dav/principals/user/
+ ${homeHref}
+ HTTP/1.1 200 OK `;
+ for (const xml of [principal, home].slice(0, expectedCalls)) {
+ safeFetch.mockResolvedValueOnce(davResponse(xml));
+ }
+
+ await expect(discoverAddressBooks({
+ serverUrl: 'https://cloud.example.com/',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' });
+ expect(safeFetch).toHaveBeenCalledTimes(expectedCalls);
+ });
+});
+
+describe('conditional CardDAV resource operations', () => {
+ it.each(['"strong"', 'W/"weak"'])(
+ 'fetches a vCard with opaque ETag %s',
+ async etag => {
+ const href = `${BASE}fetched.vcf`;
+ const vcard = 'BEGIN:VCARD\nVERSION:4.0\nUID:fetched\nEND:VCARD';
+ safeFetch.mockResolvedValueOnce(davResponse(
+ vcard,
+ 200,
+ 'OK',
+ href,
+ { ETag: etag, 'Content-Type': 'text/vcard; charset=utf-8' },
+ ));
+
+ await expect(fetchCardResource({
+ url: BASE,
+ href,
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual({ href, etag, vcard });
+
+ const [, options] = safeFetch.mock.calls[0];
+ const headers = new Headers(options.headers);
+ expect(options.method).toBe('GET');
+ expect(headers.get('accept')).toBe('text/vcard');
+ expect(headers.has('content-type')).toBe(false);
+ },
+ );
+
+ it('creates with If-None-Match and returns the final URL and response ETag', async () => {
+ const href = `${BASE}created.vcf`;
+ const vcard = 'BEGIN:VCARD\nVERSION:4.0\nUID:created\nEND:VCARD';
+ safeFetch.mockResolvedValueOnce(davResponse(
+ '',
+ 201,
+ 'Created',
+ href,
+ { ETag: '"created-1"' },
+ ));
+
+ await expect(putCardResource({
+ url: BASE,
+ href: 'created.vcf',
+ vcard,
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual({ href, etag: '"created-1"' });
+
+ const [requestUrl, options] = safeFetch.mock.calls[0];
+ const headers = new Headers(options.headers);
+ expect(requestUrl).toBe(href);
+ expect(options.method).toBe('PUT');
+ expect(options.body).toBe(vcard);
+ expect(headers.get('content-type')).toBe('text/vcard; charset=utf-8');
+ expect(headers.get('if-none-match')).toBe('*');
+ expect(headers.has('if-match')).toBe(false);
+ });
+
+ it('updates with the stored opaque If-Match value', async () => {
+ const href = `${BASE}updated.vcf`;
+ const vcard = 'BEGIN:VCARD\nVERSION:3.0\nUID:updated\nEND:VCARD';
+ safeFetch.mockResolvedValueOnce(davResponse(
+ '',
+ 204,
+ 'No Content',
+ href,
+ { ETag: 'W/"updated-2"' },
+ ));
+
+ await expect(putCardResource({
+ url: BASE,
+ href,
+ etag: 'W/"stored-1"',
+ vcard,
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual({ href, etag: 'W/"updated-2"' });
+
+ const headers = new Headers(safeFetch.mock.calls[0][1].headers);
+ expect(headers.get('if-match')).toBe('W/"stored-1"');
+ expect(headers.has('if-none-match')).toBe(false);
+ });
+
+ it('deletes with If-Match and treats a missing resource as success', async () => {
+ const href = `${BASE}already-gone.vcf`;
+ safeFetch.mockResolvedValueOnce(davResponse('', 404, 'Not Found', href));
+
+ await expect(deleteCardResource({
+ url: BASE,
+ href,
+ etag: '"stored-delete"',
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual({ href });
+
+ const [, options] = safeFetch.mock.calls[0];
+ const headers = new Headers(options.headers);
+ expect(options.method).toBe('DELETE');
+ expect(headers.get('if-match')).toBe('"stored-delete"');
+ expect(headers.has('content-type')).toBe(false);
+ });
+
+ it.each([
+ ['update', 'exact', () => putCardResource({
+ url: BASE,
+ href: 'wildcard-update.vcf',
+ etag: '*',
+ vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user',
+ password: 'app-password',
+ })],
+ ['update', 'OWS-padded', () => putCardResource({
+ url: BASE,
+ href: 'wildcard-update.vcf',
+ etag: ' * ',
+ vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user',
+ password: 'app-password',
+ })],
+ ['delete', 'exact', () => deleteCardResource({
+ url: BASE,
+ href: 'wildcard-delete.vcf',
+ etag: '*',
+ username: 'user',
+ password: 'app-password',
+ })],
+ ['delete', 'OWS-padded', () => deleteCardResource({
+ url: BASE,
+ href: 'wildcard-delete.vcf',
+ etag: '\t*\t',
+ username: 'user',
+ password: 'app-password',
+ })],
+ ])('rejects %s wildcard If-Match (%s) before requesting', async (operation, _form, request) => {
+ safeFetch.mockRejectedValueOnce(new Error('transport should not be reached'));
+
+ await expect(request()).rejects.toMatchObject({
+ name: 'CardDavError',
+ operation,
+ });
+ expect(safeFetch).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['fetch', 403, () => fetchCardResource({
+ url: BASE, href: 'blocked.vcf', username: 'user', password: 'app-password',
+ })],
+ ['create', 405, () => putCardResource({
+ url: BASE, href: 'blocked.vcf', vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user', password: 'app-password',
+ })],
+ ['update', 403, () => putCardResource({
+ url: BASE, href: 'blocked.vcf', etag: '"stored"', vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user', password: 'app-password',
+ })],
+ ['delete', 405, () => deleteCardResource({
+ url: BASE, href: 'blocked.vcf', etag: '"stored"',
+ username: 'user', password: 'app-password',
+ })],
+ ])('preserves rejected %s operation on HTTP %i', async (operation, status, request) => {
+ safeFetch.mockResolvedValueOnce(davResponse('', status, 'Rejected', `${BASE}blocked.vcf`));
+
+ await expect(request()).rejects.toMatchObject({
+ name: 'CardDavError',
+ operation,
+ status,
+ });
+ });
+
+ it.each([409, 412, 423, 429, 500, 503])(
+ 'keeps HTTP %i as a typed conditional update error',
+ async status => {
+ safeFetch.mockResolvedValueOnce(davResponse('', status, 'Rejected', `${BASE}blocked.vcf`));
+
+ await expect(putCardResource({
+ url: BASE,
+ href: 'blocked.vcf',
+ etag: '"stored"',
+ vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toSatisfy(error => (
+ error instanceof CardDavError
+ && error.operation === 'update'
+ && error.status === status
+ ));
+ },
+ );
+
+ it('rejects a same-origin redirect outside the collection scope', async () => {
+ const redirected = 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/sibling.vcf';
+ safeFetch.mockResolvedValueOnce(davResponse('', 201, 'Created', redirected));
+
+ await expect(putCardResource({
+ url: BASE,
+ href: 'created.vcf',
+ vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ code: 'ERR_DAV_HREF_SCOPE',
+ operation: 'create',
+ });
+ });
+
+ it('rejects a final cross-origin resource URL with the fetch operation', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(
+ 'BEGIN:VCARD\nEND:VCARD',
+ 200,
+ 'OK',
+ 'https://evil.example.test/stolen.vcf',
+ { ETag: '"stolen"' },
+ ));
+
+ await expect(fetchCardResource({
+ url: BASE,
+ href: 'card.vcf',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ operation: 'fetch',
+ cause: { code: 'ERR_CROSS_ORIGIN_REDIRECT' },
+ });
+ });
+
+ it('rejects an out-of-scope resource href before sending credentials', async () => {
+ await expect(putCardResource({
+ url: BASE,
+ href: 'nested/card.vcf',
+ vcard: 'BEGIN:VCARD\nEND:VCARD',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ code: 'ERR_DAV_HREF_SCOPE',
+ operation: 'create',
+ });
+
+ expect(safeFetch).not.toHaveBeenCalled();
+ });
+});
+
+describe('initial sync network planner', () => {
+ it('uses the trusted final collection URL for alias members and identity', async () => {
+ const observedUrl = 'https://cloud.example.com/books/alias/';
+ const canonicalUrl = 'https://cloud.example.com/books/canonical/';
+ safeFetch
+ .mockResolvedValueOnce(davResponse(
+ syncPageXml(['contact.vcf'], 'opaque-after'),
+ 207,
+ 'Multi-Status',
+ canonicalUrl,
+ ))
+ .mockResolvedValueOnce(davResponse(
+ multigetXml(['contact.vcf']),
+ 207,
+ 'Multi-Status',
+ canonicalUrl,
+ ));
+
+ const plan = await fetchAddressBookDelta({
+ url: observedUrl,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan.collectionIdentity).toEqual({ observedUrl, canonicalUrl });
+ expect(plan.upserts[0].href).toBe(`${canonicalUrl}contact.vcf`);
+ expect(safeFetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('follows continuation tokens and multigets 205 hrefs in fixed 100, 100, and 5 batches', async () => {
+ const hrefs = Array.from({ length: 205 }, (_, index) => `${BASE}${index}.vcf`);
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml(hrefs.slice(0, 100), 'middle-1', { truncated: true })))
+ .mockResolvedValueOnce(davResponse(syncPageXml(hrefs.slice(100, 200), 'middle-2', { truncated: true })))
+ .mockResolvedValueOnce(davResponse(syncPageXml(hrefs.slice(200), 'final-token')))
+ .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(0, 100))))
+ .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(100, 200))))
+ .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(200))));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(fetchSyncPage).toBeTypeOf('function');
+ expect(fetchCardsByHref).toBeTypeOf('function');
+ expect(plan).toMatchObject({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'final-token',
+ capability: 'sync-collection',
+ replaceAll: true,
+ removedHrefs: [],
+ });
+ expect(plan.upserts).toHaveLength(205);
+ expect(plan.upserts[0].etag).toBe('W/"card-0"');
+
+ const bodies = safeFetch.mock.calls.map(([, options]) => options.body);
+ expect(bodies.slice(0, 3)).toEqual([
+ expect.stringContaining(' '),
+ expect.stringContaining('middle-1 '),
+ expect.stringContaining('middle-2 '),
+ ]);
+ expect(bodies.slice(3).map(body => body.match(//g)?.length || 0))
+ .toEqual([100, 100, 5]);
+ expect(safeFetch.mock.calls.every(([, options]) => options.depth == null)).toBe(true);
+ expect(safeFetch.mock.calls.every(([, options]) => options.headers.Depth === '0')).toBe(true);
+ });
+
+ it('stops after multiget batch 2 fails', async () => {
+ const hrefs = Array.from({ length: 205 }, (_, index) => `${BASE}${index}.vcf`);
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml(hrefs, 'final-token')))
+ .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(0, 100))))
+ .mockRejectedValueOnce(new Error('multiget batch 2 failed'));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('multiget batch 2 failed');
+
+ expect(safeFetch).toHaveBeenCalledTimes(3);
+ });
+
+ it('rejects a final sync page that is not a DAV multistatus', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse('proxy error'));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('DAV multistatus');
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('rejects a complete final sync page without a usable sync token', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], ' ')));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('usable sync token');
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it.each([405, 501])('does not downgrade a per-resource %i response', async status => {
+ const statusText = status === 405 ? 'Method Not Allowed' : 'Not Implemented';
+ const xml = `
+ blocked.vcf HTTP/1.1 ${status} ${statusText}
+ must-not-advance
+ `;
+ safeFetch.mockResolvedValueOnce(davResponse(xml));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ name: 'CardDavError',
+ status,
+ });
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it.each([403, 500])('rejects a sync resource with a %i propstat without accepting its token', async status => {
+ const xml = `
+ failed.vcf stale
+ HTTP/1.1 ${status} Failed
+ must-not-advance
+ `;
+ safeFetch.mockResolvedValueOnce(davResponse(xml));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ name: 'CardDavError',
+ status,
+ });
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('uses the guarded full query for unsupported books', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(multigetXml([`${BASE}one.vcf`])));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-existing-token',
+ supportsSyncCollection: false,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toMatchObject({
+ expectedRemoteToken: 'opaque-existing-token',
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ collectionIdentity: { observedUrl: BASE, canonicalUrl: BASE },
+ replaceAll: true,
+ removedHrefs: [],
+ });
+ expect(plan.upserts).toHaveLength(1);
+ expect(safeFetch).toHaveBeenCalledOnce();
+ expect(safeFetch.mock.calls[0][1].body).toContain(' {
+ safeFetch.mockResolvedValueOnce(davResponse(' '));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: false,
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual({
+ expectedRemoteToken: 'opaque-before',
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ collectionIdentity: { observedUrl: BASE, canonicalUrl: BASE },
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ });
+
+ const body = safeFetch.mock.calls[0][1].body;
+ expect(body.match(/ /g)).toHaveLength(1);
+ expect(body).toContain(' ');
+ expect(body.indexOf(' ')).toBeGreaterThan(body.indexOf(''));
+ });
+
+ it.each([405, 501])('downgrades an advertised sync report returning %i', async status => {
+ safeFetch
+ .mockResolvedValueOnce(davResponse('', status, status === 405 ? 'Method Not Allowed' : 'Not Implemented'))
+ .mockResolvedValueOnce(davResponse(multigetXml([`${BASE}one.vcf`])));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toMatchObject({
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ });
+ expect(safeFetch).toHaveBeenCalledTimes(2);
+ expect(safeFetch.mock.calls[1][1].body).toContain(' {
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([`${BASE}one.vcf`], 'final-token')))
+ .mockResolvedValueOnce(davResponse('', 405, 'Method Not Allowed'))
+ .mockResolvedValueOnce(davResponse(multigetXml([`${BASE}one.vcf`])));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan.capability).toBe('snapshot');
+ expect(safeFetch).toHaveBeenCalledTimes(3);
+ });
+
+ it.each([
+ { status: 401, statusText: 'Unauthorized', body: '', precondition: null },
+ { status: 403, statusText: 'Forbidden', body: '', precondition: null },
+ {
+ status: 403,
+ statusText: 'Forbidden',
+ body: ' ',
+ precondition: 'valid-sync-token',
+ },
+ ])('does not downgrade strict HTTP failure $status/$precondition', async failure => {
+ safeFetch.mockResolvedValueOnce(davResponse(
+ failure.body,
+ failure.status,
+ failure.statusText,
+ ));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ status: failure.status,
+ precondition: failure.precondition,
+ });
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('does not downgrade a timeout', async () => {
+ const timeout = new Error('request expired');
+ timeout.name = 'TimeoutError';
+ safeFetch.mockRejectedValueOnce(timeout);
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: null,
+ cause: timeout,
+ });
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('does not downgrade a sync-page parse failure', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], ' ', { truncated: true })));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: null,
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('truncated without a continuation token');
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+});
+
+describe('stored-token delta network planner', () => {
+ it('returns a no-change delta without issuing a multiget', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], 'opaque-after')));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toEqual({
+ expectedRemoteToken: 'opaque-before',
+ nextRemoteToken: 'opaque-after',
+ capability: 'sync-collection',
+ collectionIdentity: { observedUrl: BASE, canonicalUrl: BASE },
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ });
+ expect(safeFetch).toHaveBeenCalledOnce();
+ expect(safeFetch.mock.calls[0][1].body).toContain('opaque-before ');
+ });
+
+ it('allows a final no-change page to return the stored token', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], 'opaque-same')));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-same',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toMatchObject({
+ nextRemoteToken: 'opaque-same',
+ upserts: [],
+ removedHrefs: [],
+ });
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('rejects a repeated continuation token after one request', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(
+ syncPageXml([], 'opaque-same', { truncated: true }),
+ ));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-same',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/token.*cycle|repeated.*token/i);
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('rejects an A to B to A continuation cycle after two requests', async () => {
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([], 'B', { truncated: true })))
+ .mockResolvedValueOnce(davResponse(syncPageXml([], 'A', { truncated: true })));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'A',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/token.*cycle|repeated.*token/i);
+
+ expect(safeFetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('round-trips numeric-looking continuation tokens as exact opaque strings', async () => {
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([], '00123', { truncated: true })))
+ .mockResolvedValueOnce(davResponse(syncPageXml([], 'true', { truncated: true })))
+ .mockResolvedValueOnce(davResponse(syncPageXml([], '1e3')));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(safeFetch.mock.calls.map(([, options]) => options.body)).toEqual([
+ expect.stringContaining('opaque-before '),
+ expect.stringContaining('00123 '),
+ expect.stringContaining('true '),
+ ]);
+ expect(plan.nextRemoteToken).toBe('1e3');
+ });
+
+ it('fetches one changed href for a stored-token delta', async () => {
+ const href = `${BASE}changed.vcf`;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([href], 'opaque-after')))
+ .mockResolvedValueOnce(davResponse(multigetXml([href])));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toMatchObject({
+ expectedRemoteToken: 'opaque-before',
+ nextRemoteToken: 'opaque-after',
+ replaceAll: false,
+ removedHrefs: [],
+ });
+ expect(plan.upserts).toHaveLength(1);
+ expect(safeFetch).toHaveBeenCalledTimes(2);
+ expect(safeFetch.mock.calls[1][1].body).toContain(`${href} `);
+ });
+
+ it('keeps a response-level 404 as a delta removal without a multiget', async () => {
+ const href = `${BASE}gone.vcf`;
+ safeFetch.mockResolvedValueOnce(davResponse(syncEventsXml([
+ { href, status: 404 },
+ ], 'opaque-after')));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toMatchObject({
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [href],
+ });
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('turns a multiget 404 into a delta removal', async () => {
+ const href = `${BASE}vanished.vcf`;
+ const missing = `${href}
+ HTTP/1.1 404 Not Found `;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([href], 'opaque-after')))
+ .mockResolvedValueOnce(davResponse(missing));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toMatchObject({
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [href],
+ });
+ });
+
+ it('deduplicates paged delta events using each href latest disposition', async () => {
+ const changedThenRemoved = `${BASE}removed-later.vcf`;
+ const removedThenChanged = `${BASE}changed-later.vcf`;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncEventsXml([
+ { href: changedThenRemoved },
+ { href: removedThenChanged, status: 404 },
+ ], 'middle-token', { truncated: true })))
+ .mockResolvedValueOnce(davResponse(syncEventsXml([
+ { href: changedThenRemoved, status: 404 },
+ { href: removedThenChanged },
+ ], 'opaque-after')))
+ .mockResolvedValueOnce(davResponse(multigetXml([removedThenChanged])));
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan).toMatchObject({
+ expectedRemoteToken: 'opaque-before',
+ nextRemoteToken: 'opaque-after',
+ replaceAll: false,
+ removedHrefs: [changedThenRemoved],
+ });
+ expect(plan.upserts.map(card => card.href)).toEqual([removedThenChanged]);
+ expect(safeFetch.mock.calls.map(([, options]) => options.body)).toEqual([
+ expect.stringContaining('opaque-before '),
+ expect.stringContaining('middle-token '),
+ expect.stringContaining(`${removedThenChanged} `),
+ ]);
+ });
+
+ it('rejects duplicate member events within one delta page', async () => {
+ const href = `${BASE}same-page.vcf`;
+ safeFetch.mockResolvedValueOnce(davResponse(syncEventsXml([
+ { href },
+ { href, status: 404 },
+ ], 'opaque-after')));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/duplicate|same page/i);
+
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+
+ it('does not build a delta after a later sync page fails', async () => {
+ const href = `${BASE}changed.vcf`;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([href], 'middle-token', { truncated: true })))
+ .mockRejectedValueOnce(new Error('delta page 2 failed'));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('delta page 2 failed');
+
+ expect(safeFetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not build a delta after a multiget batch fails', async () => {
+ const href = `${BASE}changed.vcf`;
+ safeFetch
+ .mockResolvedValueOnce(davResponse(syncPageXml([href], 'opaque-after')))
+ .mockRejectedValueOnce(new Error('delta multiget failed'));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('delta multiget failed');
+ });
+
+ it('does not build a delta after a sync page parse failure', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse('not a multistatus'));
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('DAV multistatus');
+ });
+
+ it('accepts exactly 100 sync pages when the last page is final', async () => {
+ let page = 0;
+ safeFetch.mockImplementation(() => {
+ page++;
+ return Promise.resolve(davResponse(syncPageXml(
+ [],
+ `page-${page}`,
+ { truncated: page < 100 },
+ )));
+ });
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'stored-token',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan.nextRemoteToken).toBe('page-100');
+ expect(safeFetch).toHaveBeenCalledTimes(100);
+ });
+
+ it('rejects before issuing a 101st sync page request', async () => {
+ let page = 0;
+ safeFetch.mockImplementation(() => {
+ if (page === 100) {
+ return Promise.reject(new Error('unexpected 101st sync request'));
+ }
+ page++;
+ return Promise.resolve(davResponse(syncPageXml(
+ [],
+ `page-${page}`,
+ { truncated: true },
+ )));
+ });
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'stored-token',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/100.*page|page.*limit/i);
+
+ expect(safeFetch).toHaveBeenCalledTimes(100);
+ });
+
+ it('accepts exactly 50,000 distinct removed members', async () => {
+ let page = 0;
+ safeFetch.mockImplementation(() => {
+ const start = page * 500;
+ const events = Array.from({ length: 500 }, (_, index) => ({
+ href: `${BASE}removed-${start + index}.vcf`,
+ status: 404,
+ }));
+ page++;
+ return Promise.resolve(davResponse(syncEventsXml(
+ events,
+ `page-${page}`,
+ { truncated: page < 100 },
+ )));
+ });
+
+ const plan = await fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ expect(plan.removedHrefs).toHaveLength(50_000);
+ expect(safeFetch).toHaveBeenCalledTimes(100);
+ });
+
+ it('rejects the 50,001st distinct member before any multiget', async () => {
+ let page = 0;
+ safeFetch.mockImplementation(() => {
+ const start = page * 500;
+ const pageSize = page === 99 ? 501 : 500;
+ const hrefs = Array.from(
+ { length: pageSize },
+ (_, index) => `${BASE}changed-${start + index}.vcf`,
+ );
+ page++;
+ return Promise.resolve(davResponse(syncPageXml(
+ hrefs,
+ `page-${page}`,
+ { truncated: page < 100 },
+ )));
+ });
+
+ await expect(fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/50,?000|object.*limit|member.*limit/i);
+
+ expect(safeFetch).toHaveBeenCalledTimes(100);
+ expect(safeFetch.mock.calls.every(([, options]) => (
+ options.body.includes(' {
+ vi.useFakeTimers();
+ const hrefs = Array.from({ length: 1_100 }, (_, index) => `${BASE}slow-${index}.vcf`);
+ let callIndex = 0;
+ safeFetch.mockImplementation((_url, options) => {
+ const index = callIndex++;
+ const xml = index === 0
+ ? syncPageXml(hrefs, 'opaque-after')
+ : multigetXml(hrefs.slice((index - 1) * 100, index * 100));
+ return new Promise((resolve, reject) => {
+ let timer;
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(options.signal.reason);
+ };
+ timer = setTimeout(() => {
+ options.signal.removeEventListener('abort', onAbort);
+ resolve(davResponse(xml));
+ }, 29_000);
+ options.signal.addEventListener('abort', onAbort, { once: true });
+ });
+ });
+
+ const request = fetchAddressBookDelta({
+ url: BASE,
+ syncToken: 'opaque-before',
+ supportsSyncCollection: true,
+ username: 'user',
+ password: 'app-password',
+ });
+ const rejection = expect(request).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'CardDAV server did not respond (timed out)',
+ });
+
+ await vi.advanceTimersByTimeAsync(300_001);
+ await rejection;
+
+ expect(safeFetch).toHaveBeenCalledTimes(11);
+ expect(safeFetch.mock.calls.some(([, options]) => (
+ options.body.includes(' {
+ it('builds an initial sync-collection request with an empty token and sync level 1', () => {
+ const body = buildSyncCollectionBody('');
+
+ expect(body).toContain(' ');
+ expect(body).toContain('1 ');
+ expect(body).toContain(' ');
+ });
+
+ it('XML-escapes opaque sync tokens', () => {
+ const body = buildSyncCollectionBody('https://opaque.example/token?x=1&y=');
+
+ expect(body).toContain('https://opaque.example/token?x=1&y=<two>');
+ expect(body).not.toContain('x=1&y=');
+ });
+
+ it('builds an addressbook-multiget request with XML-escaped hrefs', () => {
+ const body = buildMultigetBody(['/dav/a&b.vcf', '/dav/.vcf']);
+
+ expect(body).toContain(' ');
+ expect(body).toContain(' ');
+ expect(body).toContain('/dav/a&b.vcf ');
+ expect(body).toContain('/dav/<c>.vcf ');
+ });
+});
+
+describe('parseSyncPage', () => {
+ it('parses changed resources, response-level removals, and 507 continuation', () => {
+ const xml = `
+ a.vcf "remote-a"
+ HTTP/1.1 200 OK
+ gone.vcf HTTP/1.1 404 Not Found
+ ./ HTTP/1.1 507 Insufficient Storage
+ https://opaque.example/token?x=1&y=2
+ `;
+
+ expect(parseSyncPage(xml, BASE)).toEqual({
+ changed: [{ href: `${BASE}a.vcf`, etag: '"remote-a"' }],
+ removed: [{ href: `${BASE}gone.vcf` }],
+ nextToken: 'https://opaque.example/token?x=1&y=2',
+ truncated: true,
+ });
+ });
+
+ it('classifies a response-level 507 against the final request URL', async () => {
+ const finalUrl = new URL('../canonical/', BASE).href;
+ const originalHrefXml = `
+ ${BASE}
+ HTTP/1.1 507 Insufficient Storage
+ must-not-continue
+ `;
+ const finalHrefXml = `
+ ./
+ HTTP/1.1 507 Insufficient Storage
+ continue-from-final-url
+ `;
+ safeFetch
+ .mockResolvedValueOnce({ ...davResponse(originalHrefXml), url: finalUrl })
+ .mockResolvedValueOnce({ ...davResponse(finalHrefXml), url: finalUrl });
+
+ await expect(fetchSyncPage({
+ url: BASE,
+ syncToken: 'opaque-before',
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' });
+ await expect(fetchSyncPage({
+ url: BASE,
+ syncToken: 'opaque-before',
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toMatchObject({
+ nextToken: 'continue-from-final-url',
+ truncated: true,
+ });
+ });
+
+ it('rejects a complete initial page without a usable sync token', () => {
+ const xml = ` `;
+
+ expect(() => parseSyncPage(xml, BASE)).toThrow('usable sync token');
+ });
+
+ it('rejects a response without a DAV multistatus document', () => {
+ expect(() => parseSyncPage('proxy error', BASE))
+ .toThrow('DAV multistatus');
+ });
+
+ it('rejects a failed propstat even when another propstat succeeds', () => {
+ const xml = `a&b.vcf
+ stale HTTP/1.1 404 Not Found
+ "current" HTTP/1.1 200 OK
+ next `;
+
+ expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 404,
+ }));
+ });
+
+ it('keeps a changed resource whose strong ETag has an empty opaque value', () => {
+ const xml = `empty.vcf
+ "" HTTP/1.1 200 OK
+ next `;
+
+ expect(parseSyncPage(xml, BASE).changed).toEqual([
+ { href: `${BASE}empty.vcf`, etag: '""' },
+ ]);
+ });
+
+ it('throws for an unexpected response-level status without returning its token', () => {
+ const xml = `
+ blocked.vcf HTTP/1.1 403 Forbidden
+ must-not-advance
+ `;
+
+ expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 403,
+ }));
+ });
+
+ it('rejects a 507 propstat on the request URI', () => {
+ const xml = `
+ ./
+ HTTP/1.1 507 Insufficient Storage
+ continue-from-here
+ `;
+
+ expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 507,
+ }));
+ });
+
+ it('rejects a response-level 507 on a collection member', () => {
+ const xml = `
+ member.vcf
+ HTTP/1.1 507 Insufficient Storage
+ must-not-continue
+ `;
+
+ expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 507,
+ }));
+ });
+
+ it.each([
+ { name: 'missing', href: '' },
+ { name: 'sibling', href: '/remote.php/dav/addressbooks/users/brmiller/sibling/' },
+ { name: 'nested', href: 'nested/member.vcf' },
+ ])('rejects a response-level 507 with a $name href', ({ href }) => {
+ const hrefElement = href ? `${href} ` : '';
+ const xml = `
+ ${hrefElement}HTTP/1.1 507 Insufficient Storage
+ must-not-continue
+ `;
+
+ expect(() => parseSyncPage(xml, BASE)).toThrow();
+ });
+
+ it('rejects a truncated page without a usable continuation token', () => {
+ const xml = `
+ ./ HTTP/1.1 507 Insufficient Storage
+
+ `;
+
+ expect(() => parseSyncPage(xml, BASE))
+ .toThrow('CardDAV sync response was truncated without a continuation token');
+ });
+});
+
+describe('parseMultigetCards', () => {
+ it('returns successful cards and response-level 404s in response order', () => {
+ const xml = `
+ a&b.vcf
+ "remote-a" BEGIN:VCARD
+UID:a
+FN:A & B
+END:VCARD
+ HTTP/1.1 200 OK
+ gone.vcf HTTP/1.1 404 Not Found
+ `;
+
+ expect(parseMultigetCards(xml, BASE)).toEqual([
+ {
+ href: `${BASE}a&b.vcf`,
+ etag: '"remote-a"',
+ vcard: 'BEGIN:VCARD\nUID:a\nFN:A & B\nEND:VCARD',
+ },
+ { href: `${BASE}gone.vcf`, status: 404 },
+ ]);
+ });
+
+ it('throws a typed error for an unexpected per-resource status', () => {
+ const xml = `
+ blocked.vcf HTTP/1.1 403 Forbidden
+ `;
+
+ expect(() => parseMultigetCards(xml, BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ message: `CardDAV multiget failed for ${BASE}blocked.vcf (403)`,
+ status: 403,
+ precondition: null,
+ }));
+ });
+
+ it('rejects a successful resource without non-empty address-data', () => {
+ const xml = `
+ empty.vcf
+ "empty"
+ HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseMultigetCards(xml, BASE)).toThrow('address-data');
+ });
+
+ it.each([
+ {
+ name: 'wrong-namespace address-data',
+ properties: `etag BEGIN:VCARD
+FN:Poison
+END:VCARD `,
+ },
+ {
+ name: 'missing DAV getetag',
+ properties: `BEGIN:VCARD
+FN:Missing ETag
+END:VCARD `,
+ },
+ {
+ name: 'wrong-namespace getetag',
+ properties: `etag BEGIN:VCARD
+FN:Poison
+END:VCARD `,
+ },
+ ])('rejects a successful resource with $name', ({ properties }) => {
+ const xml = `partial.vcf
+ ${properties} HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseMultigetCards(xml, BASE)).toThrow(/getetag|address-data/i);
+ });
+
+ it('rejects a propstat-level 404 instead of treating it as a removal', () => {
+ const xml = `
+ gone.vcf
+ stale BEGIN:VCARD
+FN:Stale
+END:VCARD
+ HTTP/1.1 404 Not Found
+ `;
+
+ expect(() => parseMultigetCards(xml, BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 404,
+ }));
+ });
+});
+
+describe('fetchCardsByHref completeness', () => {
+ it('rejects an empty multiget input before requesting it', async () => {
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: [],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/non-empty|href/i);
+
+ expect(safeFetch).not.toHaveBeenCalled();
+ });
+
+ it('rejects duplicate normalized multiget inputs before requesting them', async () => {
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: ['card.vcf', `${BASE}card.vcf`],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/duplicate|unique/i);
+
+ expect(safeFetch).not.toHaveBeenCalled();
+ });
+
+ it('normalizes a relative requested href once for the body and result identity', async () => {
+ const href = `${BASE}card.vcf`;
+ safeFetch.mockResolvedValueOnce(davResponse(multigetXml([href])));
+
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: ['card.vcf'],
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual([expect.objectContaining({ href })]);
+
+ expect(safeFetch.mock.calls[0][1].body).toContain(`${href} `);
+ expect(safeFetch.mock.calls[0][1].body).not.toContain('card.vcf ');
+ });
+
+ it('rejects a batch when a requested href has no terminal response', async () => {
+ safeFetch.mockResolvedValueOnce(davResponse(' '));
+
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: [`${BASE}missing.vcf`],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('missing.vcf');
+ });
+
+ it('rejects a batch when a requested href has multiple terminal responses', async () => {
+ const href = `${BASE}duplicate.vcf`;
+ safeFetch.mockResolvedValueOnce(davResponse(multigetXml([href, href])));
+
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: [href],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow('duplicate.vcf');
+ });
+
+ it('rejects a batch containing an extra unrequested result', async () => {
+ const requested = `${BASE}requested.vcf`;
+ const extra = `${BASE}extra.vcf`;
+ safeFetch.mockResolvedValueOnce(davResponse(multigetXml([requested, extra])));
+
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: [requested],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toThrow(/extra|unrequested|terminal/i);
+ });
+
+ it.each([
+ ['sibling', '/remote.php/dav/addressbooks/users/brmiller/sibling.vcf'],
+ ['parent', '../parent.vcf'],
+ ['nested', 'nested/card.vcf'],
+ ['cross-origin', 'https://evil.example.test/card.vcf'],
+ ['credential-bearing', 'https://user:secret@cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/card.vcf'],
+ ['fragment', 'card.vcf#fragment'],
+ ])('rejects an out-of-scope %s multiget response', async (_name, responseHref) => {
+ const xml = multigetXml([responseHref]);
+ safeFetch.mockResolvedValueOnce(davResponse(xml));
+
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: [`${BASE}card.vcf`],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' });
+ });
+
+ it('rejects an out-of-scope requested href before requesting it', async () => {
+ await expect(fetchCardsByHref({
+ url: BASE,
+ hrefs: ['nested/card.vcf'],
+ username: 'user',
+ password: 'app-password',
+ })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' });
+
+ expect(safeFetch).not.toHaveBeenCalled();
+ });
+});
+
+describe('CardDavError', () => {
+ it('preserves status, DAV precondition, cause, and response body parsing', async () => {
+ const responseBody = ` `;
+ const response = davResponse(responseBody, 403, 'Forbidden');
+ safeFetch.mockResolvedValue(response);
+
+ const request = fetchAddressBookCards({
+ url: BASE,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ await expect(request).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'CardDAV request failed (403 Forbidden)',
+ status: 403,
+ requestStatus: 403,
+ precondition: 'valid-sync-token',
+ });
+ expect(response.bodyRead).toHaveBeenCalledTimes(2);
+
+ const cause = new Error('socket closed');
+ expect(new CardDavError('failed', { status: 500, cause })).toMatchObject({
+ name: 'CardDavError',
+ status: 500,
+ precondition: null,
+ cause,
+ });
+ });
+
+ it('keeps the existing user-facing authentication message', async () => {
+ const response = davResponse('', 401, 'Unauthorized');
+ safeFetch.mockResolvedValue(response);
+
+ const request = fetchAddressBookCards({
+ url: BASE,
+ username: 'user',
+ password: 'wrong-password',
+ });
+
+ await expect(request).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'Authentication failed — check the username and app password',
+ status: 401,
+ });
+ expect(response.bodyRead).toHaveBeenCalledOnce();
+ });
+
+ it('wraps timeouts with the timeout message and original cause', async () => {
+ const cause = new Error('request expired');
+ cause.name = 'TimeoutError';
+ safeFetch.mockRejectedValue(cause);
+
+ const request = fetchAddressBookCards({
+ url: BASE,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ await expect(request).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'CardDAV server did not respond (timed out)',
+ status: null,
+ precondition: null,
+ cause,
+ });
+ });
+
+ it('wraps network failures with the reachability message and original cause', async () => {
+ const cause = new Error('socket closed');
+ safeFetch.mockRejectedValue(cause);
+
+ const request = fetchAddressBookCards({
+ url: BASE,
+ username: 'user',
+ password: 'app-password',
+ });
+
+ await expect(request).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'Could not reach the CardDAV server: socket closed',
+ status: null,
+ precondition: null,
+ cause,
+ });
+ });
+
+ it('short-circuits principal discovery on a typed 401 response', async () => {
+ const response = davResponse('', 401, 'Unauthorized');
+ safeFetch.mockResolvedValue(response);
+
+ const request = discoverAddressBooks({
+ serverUrl: BASE,
+ username: 'user',
+ password: 'wrong-password',
+ });
+
+ await expect(request).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'Authentication failed — check the username and app password',
+ status: 401,
+ });
+ expect(safeFetch).toHaveBeenCalledOnce();
+ });
+});
diff --git a/backend/src/services/carddavClient.test.js b/backend/src/services/carddavClient.test.js
index 1fd1814..e02261f 100644
--- a/backend/src/services/carddavClient.test.js
+++ b/backend/src/services/carddavClient.test.js
@@ -1,8 +1,46 @@
import { describe, it, expect } from 'vitest';
-import { parseAddressBooks, parseCards, extractHref } from './carddavClient.js';
+import {
+ canonicalCollectionUrl,
+ parseAddressBooks,
+ parseCards,
+ extractHref,
+} from './carddavClient.js';
import { parseVCard } from '../utils/vcard.js';
const BASE = 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/';
+const BOOK_BASE = 'https://cloud.example.com/dav/c/';
+const CONTACTS_BASE = 'https://cloud.example.com/dav/contacts/';
+
+describe('canonicalCollectionUrl', () => {
+ it.each([
+ ['HTTPS://Example.COM:443/a/../%62ooks?view=%7e', 'https://example.com/books/?view=~'],
+ ['https://example.com/books', 'https://example.com/books/'],
+ ['https://example.com/a%2fb', 'https://example.com/a%2Fb/'],
+ ['/books', 'https://example.com/books/'],
+ ])('canonicalizes %s', (raw, expected) => {
+ expect(canonicalCollectionUrl(raw, 'https://example.com/')).toBe(expected);
+ });
+
+ it.each([
+ ['https://example.com:8443/books', 'https://example.com:8443/books/'],
+ ['https://example.com/Books', 'https://example.com/Books/'],
+ ['https://example.com/books//', 'https://example.com/books//'],
+ ['https://example.com/a/b/', 'https://example.com/a/b/'],
+ ['https://example.com/books?b=2&a=1', 'https://example.com/books/?b=2&a=1'],
+ ['https://example.com/books?a=1&b=2', 'https://example.com/books/?a=1&b=2'],
+ ])('preserves distinct identity for %s', (value, expected) => {
+ expect(canonicalCollectionUrl(value)).toBe(expected);
+ });
+
+ it.each([
+ 'not a url',
+ 'ftp://example.com/books',
+ 'https://user:pass@example.com/books',
+ 'https://example.com/books#fragment',
+ ])('rejects invalid collection URL %s', value => {
+ expect(() => canonicalCollectionUrl(value)).toThrow();
+ });
+});
describe('extractHref (discovery)', () => {
it('pulls current-user-principal href, resolving relative to origin', () => {
@@ -31,9 +69,117 @@ describe('extractHref (discovery)', () => {
HTTP/1.1 404 Not Found `;
expect(extractHref(xml, 'current-user-principal', BASE)).toBeNull();
});
+
+ it('does not accept a same-local-name property from a foreign namespace', () => {
+ const xml = `
+ /remote.php/dav/
+ /poison/
+ HTTP/1.1 200 OK
+ `;
+
+ expect(extractHref(xml, 'current-user-principal', BASE)).toBeNull();
+ });
});
describe('parseAddressBooks', () => {
+ it('preserves discovery order, direct privileges, and ordered address-data attributes', () => {
+ const xml = `
+ ${BASE}
+
+ HTTP/1.1 200 OK
+ personal/
+
+ Personal
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 200 OK
+ shared/
+
+ Shared
+ HTTP/1.1 200 OK
+ `;
+
+ expect(parseAddressBooks(xml, BASE)).toEqual([
+ {
+ url: `${BASE}personal/`,
+ displayName: 'Personal',
+ supportsSyncCollection: false,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'denied' },
+ discoveryIndex: 0,
+ addressData: [
+ { contentType: 'text/vcard', version: '4.0' },
+ { contentType: 'text/x-vcard', version: '3.0' },
+ { contentType: 'text/vcard', version: '3.0' },
+ { contentType: 'text/x-vcard', version: '3.0' },
+ { contentType: 'text/vcard', version: '4.0' },
+ ],
+ },
+ {
+ url: `${BASE}shared/`,
+ displayName: 'Shared',
+ supportsSyncCollection: false,
+ capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' },
+ discoveryIndex: 1,
+ addressData: [],
+ },
+ ]);
+ });
+
+ it('expands DAV write and all aggregate privileges', () => {
+ const bookResponse = (href, privilege) => `${href}
+
+
+
+ HTTP/1.1 200 OK `;
+ const xml = `
+ ${BASE}
+ HTTP/1.1 200 OK
+
+ ${bookResponse('write/', 'write')}${bookResponse('all/', 'all')}
+ `;
+
+ expect(parseAddressBooks(xml, BASE).map(book => book.capabilities)).toEqual([
+ { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ ]);
+ });
+
+ it('collapses equivalent collection spellings while preserving first response order', () => {
+ const xml = `
+ ${BASE} HTTP/1.1 200 OK
+ /remote.php/dav/addressbooks/users/brmiller/%63ontacts Contacts HTTP/1.1 200 OK
+ https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/ Contacts HTTP/1.1 200 OK
+ `;
+
+ expect(parseAddressBooks(xml, BASE)).toEqual([{
+ url: 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/',
+ displayName: 'Contacts',
+ supportsSyncCollection: false,
+ capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' },
+ discoveryIndex: 0,
+ addressData: [],
+ }]);
+ });
+
+ it('rejects equivalent duplicates with conflicting metadata', () => {
+ const xml = `
+ ${BASE} HTTP/1.1 200 OK
+ /remote.php/dav/addressbooks/users/brmiller/contacts/ Contacts HTTP/1.1 200 OK
+ /remote.php/dav/addressbooks/users/brmiller/%63ontacts Other HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseAddressBooks(xml, BASE)).toThrow(/canonical|conflict|metadata/i);
+ });
+
it('returns only addressbook collections, resolving relative hrefs', () => {
const xml = `
@@ -47,33 +193,155 @@ describe('parseAddressBooks', () => {
Contacts
42
+
+
+
HTTP/1.1 200 OK
`;
const books = parseAddressBooks(xml, BASE);
- expect(books).toHaveLength(1); // the plain collection is excluded
- expect(books[0].displayName).toBe('Contacts');
- expect(books[0].url).toBe('https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/');
+ expect(books).toEqual([{
+ url: 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/',
+ displayName: 'Contacts',
+ supportsSyncCollection: true,
+ capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' },
+ discoveryIndex: 0,
+ addressData: [],
+ }]);
});
it('is namespace-prefix agnostic (uppercase D:/C:)', () => {
+ const xml = `
+ /dav/
+ HTTP/1.1 200 OK
+ /dav/work/
+ Work
+ HTTP/1.1 200 OK
+
+ `;
+ const books = parseAddressBooks(xml, 'https://cloud.example.com/dav/');
+ expect(books).toEqual([{
+ url: 'https://cloud.example.com/dav/work/',
+ displayName: 'Work',
+ supportsSyncCollection: false,
+ capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' },
+ discoveryIndex: 0,
+ addressData: [],
+ }]);
+ });
+
+ it('returns an authoritative empty list when only the home collection self response exists', () => {
+ const xml = `${BASE}
+
+ HTTP/1.1 200 OK
+ `;
+
+ expect(parseAddressBooks(xml, BASE)).toEqual([]);
+ });
+
+ it('accepts literal at signs and percent-encoded delimiters in collection path segments', () => {
+ const hrefs = ['book@team/', 'book%40team/', 'book%23team/'];
+ const responses = hrefs.map(href => `${href}
+
+ HTTP/1.1 200 OK
+ `).join('');
const xml = `
- /dav/work/
- Work
- HTTP/1.1 200 OK
-
- `;
- const books = parseAddressBooks(xml, BASE);
- expect(books).toHaveLength(1);
- expect(books[0].displayName).toBe('Work');
+ ${BASE}
+ HTTP/1.1 200 OK
+ ${responses}`;
+
+ expect(parseAddressBooks(xml, BASE).map(book => book.url)).toEqual(
+ hrefs.map(href => new URL(href, BASE).href),
+ );
+ });
+
+ it('ignores a complete non-addressbook child after examining its resourcetype', () => {
+ const xml = `${BASE}
+
+ HTTP/1.1 200 OK
+ notes.txt
+ HTTP/1.1 200 OK
+ `;
+
+ expect(parseAddressBooks(xml, BASE)).toEqual([]);
+ });
+
+ it('rejects an empty multistatus without the required home collection self response', () => {
+ expect(() => parseAddressBooks(' ', BASE))
+ .toThrow(/self response|home collection/i);
+ });
+
+ it.each([
+ {
+ name: 'address book missing its href',
+ response: `
+
+ HTTP/1.1 200 OK `,
+ },
+ {
+ name: 'foreign addressbook lookalike',
+ response: `poison/
+
+ HTTP/1.1 200 OK `,
+ },
+ {
+ name: 'malformed propstat',
+ response: `broken/
+ `,
+ },
+ {
+ name: 'member outside the home collection',
+ response: `/remote.php/dav/addressbooks/users/brmiller-evil/contacts/
+
+ HTTP/1.1 200 OK `,
+ },
+ ])('rejects incomplete discovery: $name', ({ response }) => {
+ const xml = `${BASE}
+
+ HTTP/1.1 200 OK ${response} `;
+
+ expect(() => parseAddressBooks(xml, BASE)).toThrow();
+ });
+
+ it('rejects the 1,001st discovery response', () => {
+ const responses = Array.from({ length: 1_000 }, (_, index) => `
+ book-${index}/
+
+ HTTP/1.1 200 OK `).join('');
+ const xml = `
+ ${BASE}
+ HTTP/1.1 200 OK
+ ${responses} `;
+
+ expect(() => parseAddressBooks(xml, BASE)).toThrow(/1,000|discovery response/i);
});
});
describe('parseCards', () => {
+ it('accepts an exact empty DAV multistatus as a complete snapshot', () => {
+ expect(parseCards(' ', BOOK_BASE)).toEqual([]);
+ });
+
+ it('ignores a canonical collection self response expressed as ./', () => {
+ const xml = `./
+ collection
+ HTTP/1.1 200 OK `;
+
+ expect(parseCards(xml, BOOK_BASE)).toEqual([]);
+ });
+
+ it.each([
+ '
proxy error',
+ ' ',
+ ])('rejects a non-DAV snapshot document', xml => {
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow(/DAV multistatus/);
+ });
+
it('rejects a truncated address-book response', () => {
const xml = `
/dav/c/uid1.vcf
- BEGIN:VCARD
+ etag BEGIN:VCARD
UID:uid1
FN:Jane Doe
END:VCARD HTTP/1.1 200 OK
@@ -81,11 +349,11 @@ END:VCARD HTTP/1.1 200 OK /dav/c/ HTTP/1.1 507 Insufficient Storage
`;
- expect(() => parseCards(xml, BASE))
+ expect(() => parseCards(xml, BOOK_BASE))
.toThrow('CardDAV server returned a truncated address book response');
});
- it('extracts vCards + etags and skips entries without address-data', () => {
+ it('extracts vCards and ignores a well-formed canonical collection self response', () => {
const xml = `
/dav/contacts/
@@ -94,7 +362,7 @@ END:VCARDHTTP/1.1 200 OK
/dav/contacts/uid1.vcf
- "abc123"
+ W/"abc123"
BEGIN:VCARD
VERSION:3.0
UID:uid1
@@ -104,9 +372,9 @@ END:VCARD
HTTP/1.1 200 OK
`;
- const cards = parseCards(xml, BASE);
+ const cards = parseCards(xml, CONTACTS_BASE);
expect(cards).toHaveLength(1); // the collection self-entry (no address-data) is skipped
- expect(cards[0].etag).toBe('abc123'); // quotes stripped
+ expect(cards[0].etag).toBe('W/"abc123"');
expect(cards[0].url ?? cards[0].href).toContain('uid1.vcf');
expect(cards[0].vcard.startsWith('BEGIN:VCARD')).toBe(true);
@@ -117,6 +385,146 @@ END:VCARD
expect(parsed.emails[0].value).toBe('john@example.com');
});
+ it('rejects a failed member propstat instead of returning a partial snapshot', () => {
+ const xml = `
+ /dav/c/stale.vcf
+ stale BEGIN:VCARD
+UID:stale
+FN:Stale Contact
+END:VCARD
+ HTTP/1.1 404 Not Found
+ /dav/c/current.vcf
+ current BEGIN:VCARD
+UID:current
+FN:Current Contact
+END:VCARD
+ HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 404,
+ }));
+ });
+
+ it.each([
+ {
+ name: 'missing href',
+ response: `etag
+ BEGIN:VCARD\nFN:Missing href\nEND:VCARD
+ HTTP/1.1 200 OK `,
+ },
+ {
+ name: 'propstat missing status',
+ response: `broken.vcf
+ etag BEGIN:VCARD\nFN:Broken\nEND:VCARD
+ `,
+ },
+ ])('rejects a malformed snapshot response: $name', ({ response }) => {
+ const xml = `
+ ${response} `;
+
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow();
+ });
+
+ it('rejects a failed response-level member status', () => {
+ const xml = `gone.vcf
+ HTTP/1.1 404 Not Found `;
+
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow(expect.objectContaining({
+ name: 'CardDavError',
+ status: 404,
+ }));
+ });
+
+ it.each([
+ {
+ name: 'missing CardDAV address-data',
+ properties: 'etag ',
+ },
+ {
+ name: 'foreign address-data lookalike',
+ properties: `etag BEGIN:VCARD
+FN:Poison
+END:VCARD `,
+ },
+ {
+ name: 'missing DAV getetag',
+ properties: `BEGIN:VCARD
+FN:No ETag
+END:VCARD `,
+ },
+ ])('rejects a partial snapshot member: $name', ({ properties }) => {
+ const xml = `partial.vcf
+ ${properties} HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow();
+ });
+
+ it.each([
+ 'https://evil.example.test/dav/c/card.vcf',
+ 'https://user:secret@cloud.example.com/dav/c/card.vcf',
+ 'card.vcf#fragment',
+ '/dav/c-evil/card.vcf',
+ '/dav/c/../escape.vcf',
+ 'nested/card.vcf',
+ 'nested%2Fcard.vcf',
+ 'nested%5Ccard.vcf',
+ ])('rejects a snapshot member outside direct collection scope: %s', href => {
+ const xml = `
+ ${href} etag
+ BEGIN:VCARD\nFN:Out of scope\nEND:VCARD
+ HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow(/href|scope|collection|origin/i);
+ });
+
+ it.each([
+ { name: 'empty fragment', href: 'card.vcf#' },
+ { name: 'empty userinfo', href: 'https://@cloud.example.com/dav/c/card.vcf' },
+ ])('rejects a snapshot href with $name', ({ href }) => {
+ const xml = `
+ ${href} etag
+ BEGIN:VCARD\nFN:Out of scope\nEND:VCARD
+ HTTP/1.1 200 OK
+ `;
+
+ expect(() => parseCards(xml, BOOK_BASE)).toThrow(/credentials|fragment/i);
+ });
+
+ it('accepts literal at signs and percent-encoded delimiters in member path segments', () => {
+ const hrefs = ['person@company.vcf', 'person%40company.vcf', 'card%23.vcf'];
+ const responses = hrefs.map(href => `${href}
+ etag
+ BEGIN:VCARD\nFN:Valid member\nEND:VCARD
+ HTTP/1.1 200 OK `).join('');
+ const xml = `
+ ${responses} `;
+
+ expect(parseCards(xml, BOOK_BASE).map(card => card.href)).toEqual(
+ hrefs.map(href => new URL(href, BOOK_BASE).href),
+ );
+ });
+
+ it('preserves numeric-looking ETags and exact vCard whitespace after entity decoding', () => {
+ const xml = `
+ opaque.vcf?rev=001
+ 0007 BEGIN:VCARD
+FN:A & B
+END:VCARD
+ HTTP/1.1 200 OK
+ `;
+
+ expect(parseCards(xml, BOOK_BASE)).toEqual([{
+ href: `${BOOK_BASE}opaque.vcf?rev=001`,
+ etag: '0007',
+ vcard: ' BEGIN:VCARD\nFN:A & B\nEND:VCARD ',
+ }]);
+ });
+
it('maps a Nextcloud vCard with grouped (item1.EMAIL) properties', () => {
const xml = `
/dav/c/g.vcf
@@ -132,7 +540,7 @@ END:VCARD
HTTP/1.1 200 OK
`;
- const parsed = parseVCard(parseCards(xml, BASE)[0].vcard);
+ const parsed = parseVCard(parseCards(xml, BOOK_BASE)[0].vcard);
expect(parsed.uid).toBe('grp-1');
expect(parsed.emails[0].value).toBe('jane@example.com'); // grouped email is not lost
expect(parsed.phones[0].value).toBe('+15551234567');
@@ -148,32 +556,33 @@ END:VCARD
HTTP/1.1 200 OK
`;
- const cards = parseCards(xml, BASE);
+ const cards = parseCards(xml, BOOK_BASE);
expect(cards[0].vcard).toContain('Tom & Jerry');
});
- it('decodes numeric character references (Nextcloud encodes vCard CRLF as
)', () => {
- // SabreDAV/Nextcloud emits each line's trailing CR as the numeric reference
- //
inside ; fast-xml-parser leaves those literal, so without
- // decoding every parsed field would end in "
" and an empty property would
- // render as just "
" (issue #242). Decimal/hex refs must both be handled.
+ it('decodes numeric XML character references inside address-data', () => {
const xml = `
- /dav/c/n.vcf
- n
+ /dav/c/numeric.vcf
+ e
BEGIN:VCARD
-VERSION:3.0
-FN:Andrée
-NOTE:
-END:VCARD
-
+FN:Jane Doe
+PHOTO;ENCODING=b;TYPE=PNG:AQID
+END:VCARD
HTTP/1.1 200 OK
`;
- const card = parseCards(xml, BASE)[0];
- expect(card.vcard).not.toContain('
'); // CR references decoded away
- const parsed = parseVCard(card.vcard);
- expect(parsed.displayName).toBe('Andrée'); // decimal é decoded, no trailing junk
- expect(parsed.notes).toBeNull(); // empty NOTE no longer "
"
+
+ const card = parseCards(xml, BOOK_BASE)[0];
+ expect(card.vcard).toBe([
+ 'BEGIN:VCARD',
+ 'FN:Jane Doe',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ 'END:VCARD',
+ ].join('\r\n'));
+ expect(parseVCard(card.vcard)).toMatchObject({
+ displayName: 'Jane Doe',
+ photoData: 'data:image/png;base64,AQID',
+ });
});
it('parses a large book whose response exceeds 1000 XML entity references', () => {
@@ -193,7 +602,7 @@ END:VCARD
HTTP/1.1 200 OK
`).join('');
const xml = `${responses} `;
- const cards = parseCards(xml, BASE);
+ const cards = parseCards(xml, BOOK_BASE);
expect(cards).toHaveLength(N);
expect(cards[0].vcard).toContain('Tom <0> Ltd'); // entities still decoded
});
diff --git a/backend/src/services/carddavConflictService.js b/backend/src/services/carddavConflictService.js
new file mode 100644
index 0000000..a2957c7
--- /dev/null
+++ b/backend/src/services/carddavConflictService.js
@@ -0,0 +1,387 @@
+import { createHash } from 'node:crypto';
+
+import {
+ contactFromVCardDocument,
+ localContactHash,
+ localVCardEtag,
+ parseVCardDocument,
+ primaryEmail,
+ semanticVCardHash,
+} from '../utils/vcardProperties.js';
+import {
+ deleteCardResource,
+ fetchCardResource,
+ putCardResource,
+} from './carddavClient.js';
+import { query, withTransaction } from './db.js';
+import {
+ confirmedRemotePayload,
+ insertContact,
+ updateStoredContact,
+} from './carddavContactService.js';
+import {
+ refreshUnresolvedConflict,
+ resolveCarddavConflict,
+ rotateBookToken,
+ typedError,
+} from './carddavMappingState.js';
+import { resolveCarddavCredentials } from './carddavTransport.js';
+
+const RESOLUTIONS = new Set(['keep-mailflow', 'keep-carddav']);
+
+function isoDate(value) {
+ return value?.toISOString?.() ?? value ?? null;
+}
+
+function publicSnapshot(vcard, tombstone) {
+ if (tombstone) return { tombstone: true, hasPhoto: false, contact: null };
+ const document = parseVCardDocument(vcard);
+ const contact = contactFromVCardDocument(document);
+ delete contact.photoData;
+ return {
+ tombstone: false,
+ hasPhoto: document.properties.some(property => property.name === 'PHOTO'),
+ contact,
+ };
+}
+
+function publicConflict(row) {
+ return {
+ id: row.id,
+ href: row.href,
+ status: row.status,
+ resolution: row.resolution ?? null,
+ createdAt: isoDate(row.created_at),
+ updatedAt: isoDate(row.updated_at),
+ resolvedAt: isoDate(row.resolved_at),
+ local: publicSnapshot(row.local_vcard, row.local_tombstone),
+ remote: publicSnapshot(row.remote_vcard, row.remote_tombstone),
+ };
+}
+
+const PUBLIC_CONFLICT_COLUMNS = `
+ conflict.id, conflict.href, conflict.status, conflict.resolution,
+ conflict.local_vcard, conflict.remote_vcard,
+ conflict.local_tombstone, conflict.remote_tombstone,
+ conflict.created_at, conflict.updated_at, conflict.resolved_at`;
+
+export async function listConflicts(userId) {
+ const { rows } = await query(
+ `SELECT ${PUBLIC_CONFLICT_COLUMNS}
+ FROM carddav_conflicts conflict
+ JOIN carddav_remote_objects mapping
+ ON mapping.address_book_id = conflict.address_book_id
+ AND mapping.href = conflict.href
+ JOIN address_books remote_book ON remote_book.id = mapping.address_book_id
+ JOIN user_integrations integration
+ ON integration.user_id = remote_book.user_id
+ AND integration.provider = 'carddav'
+ WHERE integration.user_id = $1 AND conflict.user_id = $1
+ AND conflict.status = 'unresolved'
+ ORDER BY conflict.updated_at DESC, conflict.id`,
+ [userId],
+ );
+ return rows.map(publicConflict);
+}
+
+export async function getConflict(userId, id) {
+ const { rows: [row] } = await query(
+ `SELECT ${PUBLIC_CONFLICT_COLUMNS}
+ FROM carddav_conflicts conflict
+ LEFT JOIN carddav_remote_objects mapping
+ ON mapping.address_book_id = conflict.address_book_id
+ AND mapping.href = conflict.href
+ JOIN address_books remote_book ON remote_book.id = conflict.address_book_id
+ JOIN user_integrations integration
+ ON integration.user_id = remote_book.user_id
+ AND integration.provider = 'carddav'
+ WHERE integration.user_id = $1 AND conflict.user_id = $1
+ AND conflict.id = $2
+ AND (conflict.status = 'resolved' OR mapping.address_book_id IS NOT NULL)`,
+ [userId, id],
+ );
+ return row ? publicConflict(row) : null;
+}
+
+function resolutionStateSql(lock = false) {
+ const mappingJoin = lock ? 'JOIN' : 'LEFT JOIN';
+ return `SELECT conflict.*, mapping.mapping_revision::text, mapping.mapping_status,
+ contact.id AS contact_id, contact.uid AS contact_uid,
+ contact.etag AS contact_etag,
+ contact.address_book_id AS local_address_book_id,
+ remote_book.external_url AS remote_book_url,
+ integration.config,
+ integration.config->>'connectionGeneration' AS connection_generation
+ FROM carddav_conflicts conflict
+ ${mappingJoin} carddav_remote_objects mapping
+ ON mapping.address_book_id = conflict.address_book_id
+ AND mapping.href = conflict.href
+ JOIN address_books remote_book
+ ON remote_book.id = conflict.address_book_id
+ LEFT JOIN contacts contact
+ ON contact.id = mapping.local_contact_id
+ AND contact.user_id = remote_book.user_id
+ JOIN user_integrations integration
+ ON integration.user_id = remote_book.user_id
+ AND integration.provider = 'carddav'
+ WHERE integration.user_id = $1 AND conflict.user_id = $1
+ AND conflict.id = $2
+ AND (conflict.status = 'resolved' OR mapping.address_book_id IS NOT NULL)
+ ${lock ? 'FOR UPDATE OF conflict, mapping, integration' : ''}`;
+}
+
+async function readResolutionState(executor, userId, id, lock = false) {
+ const { rows: [state] } = await executor.query(
+ resolutionStateSql(lock),
+ [userId, id],
+ );
+ return state || null;
+}
+
+function staleConflict(id) {
+ return typedError(
+ 'CardDAV conflict is stale or already resolved',
+ 'ERR_CARDDAV_CONFLICT_STALE',
+ { conflictId: id },
+ );
+}
+
+function assertUnresolved(state, id) {
+ if (!state) {
+ throw typedError('CardDAV conflict not found', 'ERR_CARDDAV_CONFLICT_NOT_FOUND');
+ }
+ if (state.status !== 'unresolved') throw staleConflict(id);
+}
+
+function sameResolutionFence(state, preflight) {
+ return state
+ && state.status === 'unresolved'
+ && state.contact_id === preflight.contact_id
+ && state.contact_etag === preflight.contact_etag
+ && String(state.mapping_revision) === String(preflight.mapping_revision)
+ && state.connection_generation === preflight.connection_generation;
+}
+
+async function credentials(state) {
+ const resolved = await resolveCarddavCredentials(state.config);
+ if (!state.remote_book_url || !resolved.username || !resolved.password) {
+ throw typedError(
+ 'Stored CardDAV credentials could not be read',
+ 'ERR_CARDDAV_CREDENTIALS',
+ );
+ }
+ return resolved;
+}
+
+async function fetchLatestRemote(state, creds) {
+ try {
+ const remote = await fetchCardResource({
+ url: state.remote_book_url,
+ href: state.href,
+ ...creds,
+ });
+ return { ...remote, tombstone: false };
+ } catch (error) {
+ if (error?.status === 404) {
+ return { href: state.href, etag: null, vcard: null, tombstone: true };
+ }
+ throw error;
+ }
+}
+
+function canonicalPayload(state, remote) {
+ const localUid = state.contact_uid
+ ?? createHash('sha256').update(state.href).digest('hex');
+ const payload = confirmedRemotePayload(localUid, remote.vcard);
+ const remoteContact = contactFromVCardDocument(payload.document);
+ const localVCard = remoteContact.uid === state.contact_uid ? remote.vcard : payload.vcard;
+ const localEtag = localVCard === payload.vcard ? payload.etag : localVCardEtag(localVCard);
+ return {
+ ...payload,
+ remoteContact,
+ localContact: { ...payload, uid: localUid },
+ localVCard,
+ localEtag,
+ vcard: localVCard,
+ etag: localEtag,
+ };
+}
+
+async function updateLocalContact(client, state, payload, userId) {
+ const row = await updateStoredContact(
+ client,
+ userId,
+ state.contact_id,
+ payload,
+ null,
+ { returning: 'id', onMissing: () => staleConflict(state.id) },
+ );
+ return row.id;
+}
+
+async function insertLocalContact(client, state, payload, userId) {
+ const row = await insertContact(
+ client,
+ userId,
+ state.address_book_id,
+ payload,
+ { returning: 'id' },
+ );
+ if (!row) throw staleConflict(state.id);
+ return row.id;
+}
+
+async function bumpLocalBook(client, state, userId, addressBookId = null) {
+ const rowCount = await rotateBookToken(
+ client,
+ userId,
+ addressBookId ?? state.local_address_book_id,
+ );
+ if (rowCount !== 1) throw staleConflict(state.id);
+}
+
+function assertMappingApplied(result, conflictId) {
+ if (!result.ok) throw staleConflict(conflictId);
+}
+
+async function commitResolution(userId, preflight, resolution, remote) {
+ const payload = remote.tombstone ? null : canonicalPayload(preflight, remote);
+ return withTransaction(async client => {
+ await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+ const state = await readResolutionState(client, userId, preflight.id, true);
+ if (!sameResolutionFence(state, preflight)) throw staleConflict(preflight.id);
+
+ if (remote.tombstone) {
+ const result = await resolveCarddavConflict(client, {
+ addressBookId: state.address_book_id,
+ href: state.href,
+ expectedMappingRevision: state.mapping_revision,
+ conflictId: state.id,
+ userId,
+ resolution,
+ remoteTombstone: true,
+ });
+ assertMappingApplied(result, state.id);
+ if (state.contact_id) {
+ const deleted = await client.query(
+ 'DELETE FROM contacts WHERE id = $1 AND user_id = $2',
+ [state.contact_id, userId],
+ );
+ if (deleted.rowCount !== 1) throw staleConflict(state.id);
+ await bumpLocalBook(client, state, userId);
+ }
+ return publicConflict({ ...state, ...result.conflict });
+ }
+
+ const localContactId = state.contact_id
+ ? await updateLocalContact(client, state, payload, userId)
+ : await insertLocalContact(client, state, payload, userId);
+ const result = await resolveCarddavConflict(client, {
+ addressBookId: state.address_book_id,
+ href: state.href,
+ expectedMappingRevision: state.mapping_revision,
+ conflictId: state.id,
+ userId,
+ resolution,
+ remoteTombstone: false,
+ remoteEtag: remote.etag,
+ vcard: remote.vcard,
+ primaryEmail: primaryEmail(payload.remoteContact),
+ localContactId,
+ vcardVersion: payload.document.version,
+ remoteSemanticHash: semanticVCardHash(payload.document),
+ localContactHash: localContactHash(payload.localContact),
+ });
+ assertMappingApplied(result, state.id);
+ await bumpLocalBook(
+ client,
+ state,
+ userId,
+ state.local_address_book_id ?? state.address_book_id,
+ );
+ return publicConflict({ ...state, ...result.conflict });
+ });
+}
+
+async function refreshConflictAfter412(userId, preflight, remote) {
+ const document = remote.tombstone ? null : parseVCardDocument(remote.vcard);
+ await withTransaction(async client => {
+ await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+ const state = await readResolutionState(client, userId, preflight.id, true);
+ if (!sameResolutionFence(state, preflight)) throw staleConflict(preflight.id);
+ const result = await refreshUnresolvedConflict(client, {
+ addressBookId: state.address_book_id,
+ href: state.href,
+ expectedMappingRevision: state.mapping_revision,
+ userId,
+ baseLocalHash: state.base_local_hash,
+ remoteEtag: remote.etag,
+ primaryEmail: document ? primaryEmail(contactFromVCardDocument(document)) : null,
+ vcardVersion: document?.version ?? null,
+ remoteSemanticHash: document ? semanticVCardHash(document) : null,
+ preserveLocalSnapshot: true,
+ localVCard: undefined,
+ remoteVCard: remote.vcard,
+ localTombstone: undefined,
+ remoteTombstone: remote.tombstone,
+ });
+ assertMappingApplied(result, state.id);
+ if (result.conflict?.id !== state.id) throw staleConflict(state.id);
+ });
+}
+
+export async function resolveConflict(userId, id, resolution) {
+ if (!RESOLUTIONS.has(resolution)) {
+ throw typedError(
+ 'Invalid CardDAV conflict resolution',
+ 'ERR_CARDDAV_CONFLICT_RESOLUTION',
+ );
+ }
+
+ const preflight = await readResolutionState({ query }, userId, id);
+ assertUnresolved(preflight, id);
+ const creds = await credentials(preflight);
+
+ if (resolution === 'keep-carddav') {
+ const remote = await fetchLatestRemote(preflight, creds);
+ return commitResolution(userId, preflight, resolution, remote);
+ }
+
+ const latest = await fetchLatestRemote(preflight, creds);
+ try {
+ if (preflight.local_tombstone) {
+ if (!latest.tombstone) {
+ await deleteCardResource({
+ url: preflight.remote_book_url,
+ href: preflight.href,
+ etag: latest.etag,
+ ...creds,
+ });
+ }
+ } else {
+ await putCardResource({
+ url: preflight.remote_book_url,
+ href: preflight.href,
+ etag: latest.etag,
+ vcard: preflight.local_vcard,
+ ...creds,
+ });
+ }
+ } catch (error) {
+ if (error?.status !== 412) throw error;
+ const refreshed = await fetchLatestRemote(preflight, creds);
+ await refreshConflictAfter412(userId, preflight, refreshed);
+ throw staleConflict(id);
+ }
+ const canonical = await fetchLatestRemote(preflight, creds);
+ return commitResolution(userId, preflight, resolution, canonical);
+}
+
+export async function deleteResolvedConflictsBefore(client, cutoff) {
+ const result = await client.query(
+ `DELETE FROM carddav_conflicts
+ WHERE status = 'resolved' AND resolved_at < $1`,
+ [cutoff],
+ );
+ return result.rowCount;
+}
diff --git a/backend/src/services/carddavConflictService.test.js b/backend/src/services/carddavConflictService.test.js
new file mode 100644
index 0000000..d196450
--- /dev/null
+++ b/backend/src/services/carddavConflictService.test.js
@@ -0,0 +1,717 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ decrypt: vi.fn(value => value),
+ deleteCardResource: vi.fn(),
+ fetchCardResource: vi.fn(),
+ getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })),
+ putCardResource: vi.fn(),
+ query: vi.fn(),
+ withTransaction: vi.fn(),
+}));
+
+vi.mock('./db.js', () => ({
+ query: mocks.query,
+ withTransaction: mocks.withTransaction,
+}));
+vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt }));
+vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy }));
+vi.mock('./carddavClient.js', () => ({
+ deleteCardResource: mocks.deleteCardResource,
+ fetchCardResource: mocks.fetchCardResource,
+ putCardResource: mocks.putCardResource,
+}));
+
+import * as conflictService from './carddavConflictService.js';
+import { refreshUnresolvedConflict } from './carddavMappingState.js';
+
+const recordCarddavConflict = async (client, change) => {
+ const result = await refreshUnresolvedConflict(client, change);
+ return result.ok ? result.conflict : result;
+};
+
+const BOOK_ID = '00000000-0000-4000-8000-000000000002';
+const CONFLICT_ID = '00000000-0000-4000-8000-000000000009';
+const USER_ID = '00000000-0000-4000-8000-000000000001';
+const OTHER_USER_ID = '00000000-0000-4000-8000-000000000008';
+const HREF = 'https://dav.example.test/books/default/contact.vcf';
+const LOCAL_BOOK_ID = '00000000-0000-4000-8000-000000000003';
+
+const photoVCard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:contact-1',
+ 'FN:Ada Lovelace',
+ 'N:Lovelace;Ada;;;',
+ 'EMAIL;TYPE=WORK:ada@example.test',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ 'END:VCARD',
+ '',
+].join('\r\n');
+
+const remoteVCard = photoVCard
+ .replace('FN:Ada Lovelace', 'FN:Ada Byron')
+ .replace('N:Lovelace;Ada', 'N:Byron;Ada')
+ .replace('PHOTO;ENCODING=b;TYPE=PNG:AQID\r\n', '');
+
+const uriPhotoVCard = remoteVCard.replace(
+ 'END:VCARD\r\n',
+ 'PHOTO;VALUE=URI:https://images.example.test/private.jpg\r\nEND:VCARD\r\n',
+);
+
+function conflictRow(overrides = {}) {
+ return {
+ id: CONFLICT_ID,
+ href: HREF,
+ status: 'unresolved',
+ resolution: null,
+ local_vcard: photoVCard,
+ remote_vcard: remoteVCard,
+ local_tombstone: false,
+ remote_tombstone: false,
+ created_at: new Date('2026-07-01T00:00:00.000Z'),
+ updated_at: new Date('2026-07-02T00:00:00.000Z'),
+ resolved_at: null,
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'private-user',
+ password: 'encrypted-secret',
+ },
+ ...overrides,
+ };
+}
+
+function resolutionRow(overrides = {}) {
+ return conflictRow({
+ address_book_id: BOOK_ID,
+ mapping_revision: '7',
+ mapping_status: 'conflict',
+ contact_id: '00000000-0000-4000-8000-000000000004',
+ contact_uid: 'contact-1',
+ contact_etag: 'local-etag-before',
+ local_address_book_id: LOCAL_BOOK_ID,
+ remote_book_url: 'https://dav.example.test/books/default/',
+ connection_generation: 'generation-current',
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'carddav-user',
+ password: 'encrypted-password',
+ connectionGeneration: 'generation-current',
+ },
+ ...overrides,
+ });
+}
+
+function resolutionClient(state = resolutionRow()) {
+ return {
+ query: vi.fn(async (sql, params) => {
+ if (/FROM carddav_conflicts[\s\S]+FOR UPDATE/.test(sql)) {
+ return { rows: [state], rowCount: 1 };
+ }
+ if (/SELECT local_vcard, local_tombstone/.test(sql)) {
+ return {
+ rows: [{
+ local_vcard: state.local_vcard,
+ local_tombstone: state.local_tombstone,
+ }],
+ rowCount: 1,
+ };
+ }
+ if (/UPDATE carddav_conflicts/.test(sql)) {
+ return {
+ rows: [{ ...state, status: 'resolved', resolution: params[1] }],
+ rowCount: 1,
+ };
+ }
+ if (/UPDATE carddav_remote_objects/.test(sql)) {
+ return { rows: [{ mapping_revision: '8' }], rowCount: 1 };
+ }
+ if (/DELETE FROM carddav_remote_objects/.test(sql)) {
+ return { rows: [{ mapping_revision: '7' }], rowCount: 1 };
+ }
+ if (/INSERT INTO carddav_conflicts/.test(sql)) {
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ if (/UPDATE contacts SET/.test(sql)) {
+ return { rows: [{ id: state.contact_id }], rowCount: 1 };
+ }
+ if (/INSERT INTO contacts/.test(sql)) {
+ return {
+ rows: [{ id: '00000000-0000-4000-8000-000000000005' }],
+ rowCount: 1,
+ };
+ }
+ if (/DELETE FROM contacts/.test(sql)) return { rows: [], rowCount: 1 };
+ if (/UPDATE address_books/.test(sql)) return { rows: [], rowCount: 1 };
+ if (/DELETE FROM carddav_conflicts/.test(sql)) return { rows: [], rowCount: 0 };
+ return { rows: [], rowCount: 0 };
+ }),
+ };
+}
+
+function conflictClient() {
+ return {
+ query: vi.fn(async sql => ({
+ rows: sql.includes('INSERT INTO carddav_conflicts')
+ ? [{ id: CONFLICT_ID, status: 'unresolved' }]
+ : [],
+ rowCount: 1,
+ })),
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('conflict reads', () => {
+ it('lists only conflicts owned through the CardDAV integration and mapping', async () => {
+ mocks.query.mockResolvedValueOnce({ rows: [conflictRow()] });
+
+ expect(conflictService.listConflicts).toBeTypeOf('function');
+ const conflicts = await conflictService.listConflicts(USER_ID);
+
+ expect(mocks.query).toHaveBeenCalledOnce();
+ expect(mocks.query.mock.calls[0][0]).toMatch(
+ /FROM carddav_conflicts[\s\S]+JOIN carddav_remote_objects[\s\S]+JOIN user_integrations/,
+ );
+ expect(mocks.query.mock.calls[0][1]).toEqual([USER_ID]);
+ expect(conflicts).toEqual([{
+ id: CONFLICT_ID,
+ href: HREF,
+ status: 'unresolved',
+ resolution: null,
+ createdAt: '2026-07-01T00:00:00.000Z',
+ updatedAt: '2026-07-02T00:00:00.000Z',
+ resolvedAt: null,
+ local: {
+ tombstone: false,
+ hasPhoto: true,
+ contact: expect.objectContaining({
+ uid: 'contact-1',
+ displayName: 'Ada Lovelace',
+ }),
+ },
+ remote: {
+ tombstone: false,
+ hasPhoto: false,
+ contact: expect.objectContaining({
+ uid: 'contact-1',
+ displayName: 'Ada Byron',
+ }),
+ },
+ }]);
+ expect(JSON.stringify(conflicts)).not.toMatch(
+ /private-user|encrypted-secret|serverUrl|password|photoData|BEGIN:VCARD/,
+ );
+ });
+
+ it('returns no detail when the integration-plus-mapping ownership chain does not match', async () => {
+ mocks.query.mockResolvedValueOnce({ rows: [] });
+
+ expect(conflictService.getConflict).toBeTypeOf('function');
+ await expect(conflictService.getConflict(OTHER_USER_ID, CONFLICT_ID)).resolves.toBeNull();
+
+ expect(mocks.query.mock.calls[0][0]).toMatch(
+ /FROM carddav_conflicts[\s\S]+JOIN carddav_remote_objects[\s\S]+JOIN user_integrations/,
+ );
+ expect(mocks.query.mock.calls[0][1]).toEqual([OTHER_USER_ID, CONFLICT_ID]);
+ });
+
+ it('represents tombstones without parsing or exposing a raw snapshot', async () => {
+ mocks.query.mockResolvedValueOnce({
+ rows: [conflictRow({
+ local_vcard: null,
+ local_tombstone: true,
+ })],
+ });
+
+ const conflict = await conflictService.getConflict(USER_ID, CONFLICT_ID);
+
+ expect(conflict.local).toEqual({
+ tombstone: true,
+ hasPhoto: false,
+ contact: null,
+ });
+ });
+
+ it('reports URI-backed PHOTO presence without exposing or fetching its value', async () => {
+ mocks.query.mockResolvedValueOnce({
+ rows: [conflictRow({ remote_vcard: uriPhotoVCard })],
+ });
+
+ const conflict = await conflictService.getConflict(USER_ID, CONFLICT_ID);
+
+ expect(conflict.remote).toMatchObject({
+ tombstone: false,
+ hasPhoto: true,
+ contact: expect.not.objectContaining({ photoData: expect.anything() }),
+ });
+ expect(JSON.stringify(conflict.remote)).not.toMatch(
+ /images\.example\.test|private\.jpg|BEGIN:VCARD/,
+ );
+ });
+});
+
+describe('conflict resolution validation', () => {
+ it.each([
+ undefined,
+ null,
+ '',
+ 'keep-mailflow ',
+ 'KEEP-CARDDAV',
+ 'merge',
+ ])('rejects non-enum resolution %j before database or remote I/O', async resolution => {
+ expect(conflictService.resolveConflict).toBeTypeOf('function');
+
+ await expect(conflictService.resolveConflict(USER_ID, CONFLICT_ID, resolution))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_CONFLICT_RESOLUTION' });
+
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ });
+
+ it('rejects an already-resolved transition as stale before remote I/O', async () => {
+ mocks.query.mockResolvedValueOnce({
+ rows: [resolutionRow({
+ status: 'resolved',
+ resolution: 'keep-carddav',
+ resolved_at: new Date('2026-07-03T00:00:00.000Z'),
+ })],
+ });
+
+ await expect(conflictService.resolveConflict(
+ USER_ID,
+ CONFLICT_ID,
+ 'keep-mailflow',
+ )).rejects.toMatchObject({ code: 'ERR_CARDDAV_CONFLICT_STALE' });
+
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ });
+});
+
+describe('conflict resolution lifecycle', () => {
+ it('keep-mailflow uses the latest ETag, canonical GET, then one atomic resolve', async () => {
+ const preflight = resolutionRow();
+ const client = resolutionClient(preflight);
+ let insideTransaction = false;
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+ mocks.fetchCardResource
+ .mockImplementationOnce(async () => {
+ expect(insideTransaction).toBe(false);
+ return { href: HREF, etag: '"latest-before"', vcard: remoteVCard };
+ })
+ .mockImplementationOnce(async () => {
+ expect(insideTransaction).toBe(false);
+ return { href: HREF, etag: '"canonical-after"', vcard: photoVCard };
+ });
+ mocks.putCardResource.mockImplementationOnce(async () => {
+ expect(insideTransaction).toBe(false);
+ return { href: HREF, etag: '"provisional"' };
+ });
+ mocks.withTransaction.mockImplementationOnce(async callback => {
+ insideTransaction = true;
+ try {
+ return await callback(client);
+ } finally {
+ insideTransaction = false;
+ }
+ });
+
+ await expect(conflictService.resolveConflict(
+ USER_ID,
+ CONFLICT_ID,
+ 'keep-mailflow',
+ )).resolves.toMatchObject({
+ id: CONFLICT_ID,
+ status: 'resolved',
+ resolution: 'keep-mailflow',
+ });
+
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.putCardResource).toHaveBeenCalledWith({
+ url: preflight.remote_book_url,
+ href: HREF,
+ etag: '"latest-before"',
+ vcard: photoVCard,
+ username: 'carddav-user',
+ password: 'encrypted-password',
+ allowPrivate: false,
+ });
+ expect(mocks.putCardResource.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.fetchCardResource.mock.invocationCallOrder[1]);
+ expect(mocks.fetchCardResource.mock.invocationCallOrder[1])
+ .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]);
+
+ const sql = client.query.mock.calls.map(([statement]) => statement);
+ expect(sql.some(statement => /UPDATE contacts SET/.test(statement))).toBe(true);
+ expect(sql.some(statement => /UPDATE carddav_remote_objects/.test(statement))).toBe(true);
+ expect(sql.some(statement => /UPDATE carddav_conflicts/.test(statement))).toBe(true);
+ });
+
+ it('keep-mailflow conditionally deletes a local tombstone before canonical confirmation', async () => {
+ const preflight = resolutionRow({
+ local_vcard: null,
+ local_tombstone: true,
+ });
+ const client = resolutionClient(preflight);
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+ mocks.fetchCardResource
+ .mockResolvedValueOnce({ href: HREF, etag: '"latest-before"', vcard: remoteVCard })
+ .mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 }));
+ mocks.withTransaction.mockImplementationOnce(callback => callback(client));
+
+ await conflictService.resolveConflict(USER_ID, CONFLICT_ID, 'keep-mailflow');
+
+ expect(mocks.deleteCardResource).toHaveBeenCalledWith({
+ url: preflight.remote_book_url,
+ href: HREF,
+ etag: '"latest-before"',
+ username: 'carddav-user',
+ password: 'encrypted-password',
+ allowPrivate: false,
+ });
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ expect(client.query.mock.calls.some(([sql]) => /DELETE FROM contacts/.test(sql))).toBe(true);
+ });
+
+ it('keep-carddav fetches a fresh snapshot before applying and resolving locally', async () => {
+ const preflight = resolutionRow();
+ const client = resolutionClient(preflight);
+ const losslessRemoteVCard = remoteVCard
+ .replace('UID:contact-1', 'UID:remote-contact')
+ .replace('END:VCARD\r\n', 'X-CUSTOM-KEEP;VALUE=TEXT:x\r\nEND:VCARD\r\n');
+ let insideTransaction = false;
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+ mocks.fetchCardResource.mockImplementationOnce(async () => {
+ expect(insideTransaction).toBe(false);
+ return { href: HREF, etag: '"fresh-remote"', vcard: losslessRemoteVCard };
+ });
+ mocks.withTransaction.mockImplementationOnce(async callback => {
+ insideTransaction = true;
+ try {
+ return await callback(client);
+ } finally {
+ insideTransaction = false;
+ }
+ });
+
+ await expect(conflictService.resolveConflict(
+ USER_ID,
+ CONFLICT_ID,
+ 'keep-carddav',
+ )).resolves.toMatchObject({
+ id: CONFLICT_ID,
+ status: 'resolved',
+ resolution: 'keep-carddav',
+ });
+
+ expect(mocks.fetchCardResource).toHaveBeenCalledOnce();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ expect(mocks.fetchCardResource.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]);
+ const contactUpdate = client.query.mock.calls.find(([sql]) => /UPDATE contacts SET/.test(sql));
+ expect(contactUpdate[1][1]).toContain('UID:contact-1');
+ expect(contactUpdate[1][1]).toContain('X-CUSTOM-KEEP;VALUE=TEXT:x');
+ expect(contactUpdate[1]).toEqual(expect.arrayContaining([
+ 'Ada Byron', preflight.contact_id, USER_ID,
+ ]));
+ });
+
+ it('keep-carddav recreates a missing local tombstone from the fresh snapshot', async () => {
+ const preflight = resolutionRow({
+ contact_id: null,
+ contact_uid: null,
+ contact_etag: null,
+ local_address_book_id: null,
+ local_vcard: null,
+ local_tombstone: true,
+ });
+ const client = resolutionClient(preflight);
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+ mocks.fetchCardResource.mockResolvedValueOnce({
+ href: HREF,
+ etag: '"fresh-remote"',
+ vcard: remoteVCard,
+ });
+ mocks.withTransaction.mockImplementationOnce(callback => callback(client));
+
+ await conflictService.resolveConflict(USER_ID, CONFLICT_ID, 'keep-carddav');
+
+ const insert = client.query.mock.calls.find(([sql]) => /INSERT INTO contacts/.test(sql));
+ expect(insert).toBeDefined();
+ expect(insert[1].slice(0, 2)).toEqual([BOOK_ID, USER_ID]);
+ const mapping = client.query.mock.calls.find(([sql]) => (
+ /UPDATE carddav_remote_objects/.test(sql)
+ ));
+ expect(mapping[1]).toContain('00000000-0000-4000-8000-000000000005');
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/UPDATE address_books/),
+ [BOOK_ID, USER_ID],
+ );
+ });
+
+ it('refreshes the same unresolved conflict after a concurrent 412', async () => {
+ const preflight = resolutionRow();
+ const client = resolutionClient(preflight);
+ const concurrentVCard = remoteVCard.replace('FN:Ada Byron', 'FN:Concurrent Remote');
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+ mocks.fetchCardResource
+ .mockResolvedValueOnce({ href: HREF, etag: '"latest-before"', vcard: remoteVCard })
+ .mockResolvedValueOnce({ href: HREF, etag: '"concurrent"', vcard: concurrentVCard });
+ mocks.putCardResource.mockRejectedValueOnce(
+ Object.assign(new Error('Precondition failed'), { status: 412 }),
+ );
+ mocks.withTransaction.mockImplementationOnce(callback => callback(client));
+
+ await expect(conflictService.resolveConflict(
+ USER_ID,
+ CONFLICT_ID,
+ 'keep-mailflow',
+ )).rejects.toMatchObject({
+ code: 'ERR_CARDDAV_CONFLICT_STALE',
+ conflictId: CONFLICT_ID,
+ });
+
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ const refresh = client.query.mock.calls.find(([sql]) => (
+ /INSERT INTO carddav_conflicts/.test(sql)
+ ));
+ expect(refresh[1]).toEqual(expect.arrayContaining([
+ BOOK_ID,
+ HREF,
+ USER_ID,
+ '"concurrent"',
+ concurrentVCard,
+ ]));
+ expect(client.query.mock.calls.some(([sql]) => (
+ /UPDATE carddav_conflicts[\s\S]+status = 'resolved'/.test(sql)
+ ))).toBe(false);
+ });
+});
+
+describe('resolved conflict cleanup', () => {
+ it('deletes only resolved rows strictly older than the supplied cutoff', async () => {
+ const cutoff = new Date('2026-06-12T12:00:00.000Z');
+ const client = {
+ query: vi.fn(async () => ({ rows: [], rowCount: 3 })),
+ };
+
+ expect(conflictService.deleteResolvedConflictsBefore).toBeTypeOf('function');
+ await expect(conflictService.deleteResolvedConflictsBefore(client, cutoff)).resolves.toBe(3);
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /DELETE FROM carddav_conflicts[\s\S]+status = 'resolved'[\s\S]+resolved_at < \$1/,
+ ),
+ [cutoff],
+ );
+ expect(client.query.mock.calls[0][0]).not.toMatch(/status = 'unresolved'/);
+ });
+});
+
+describe('conflict snapshot limits', () => {
+ it('allows exactly 2 MiB of non-tombstone UTF-8 snapshots', async () => {
+ await expect(recordCarddavConflict(conflictClient(), {
+ addressBookId: BOOK_ID,
+ href: 'https://dav.example.test/books/default/contact.vcf',
+ expectedMappingRevision: '7',
+ userId: USER_ID,
+ localVCard: '🙂'.repeat(256 * 1024),
+ remoteVCard: '🙂'.repeat(256 * 1024),
+ })).resolves.toMatchObject({ id: CONFLICT_ID });
+ });
+
+ it('rejects 2 MiB + 1 before any conflict query', async () => {
+ const client = conflictClient();
+
+ await expect(recordCarddavConflict(client, {
+ addressBookId: BOOK_ID,
+ href: 'https://dav.example.test/books/default/contact.vcf',
+ expectedMappingRevision: '7',
+ userId: USER_ID,
+ localVCard: '🙂'.repeat(256 * 1024),
+ remoteVCard: '🙂'.repeat(256 * 1024) + 'x',
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_CONFLICT_TOO_LARGE' });
+
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['local', {
+ localVCard: 'a'.repeat(2 * 1024 * 1024 + 1),
+ remoteVCard: 'remote',
+ localTombstone: true,
+ }],
+ ['remote', {
+ localVCard: 'local',
+ remoteVCard: 'b'.repeat(2 * 1024 * 1024 + 1),
+ remoteTombstone: true,
+ }],
+ ])('excludes a %s tombstone snapshot from the byte total', async (_side, snapshots) => {
+ await expect(recordCarddavConflict(conflictClient(), {
+ addressBookId: BOOK_ID,
+ href: 'https://dav.example.test/books/default/contact.vcf',
+ expectedMappingRevision: '7',
+ userId: USER_ID,
+ ...snapshots,
+ })).resolves.toMatchObject({ id: CONFLICT_ID });
+ });
+});
+
+describe('recordCarddavConflict', () => {
+ it('requires the caller to supply the locked mapping revision', async () => {
+ const client = conflictClient();
+
+ await expect(recordCarddavConflict(client, {
+ addressBookId: BOOK_ID,
+ href: 'https://dav.example.test/books/default/contact.vcf',
+ userId: USER_ID,
+ localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n',
+ remoteVCard: 'BEGIN:VCARD\r\nFN:Remote\r\nEND:VCARD\r\n',
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_MAPPING_REVISION_REQUIRED' });
+
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it('upserts the one unresolved conflict and marks its mapping conflicted', async () => {
+ const conflict = {
+ addressBookId: BOOK_ID,
+ href: 'https://dav.example.test/books/default/contact.vcf',
+ expectedMappingRevision: '7',
+ userId: USER_ID,
+ baseLocalHash: 'base-hash',
+ remoteEtag: '"remote-2"',
+ localVCard: 'BEGIN:VCARD\r\nFN:Rejected\r\nEND:VCARD\r\n',
+ remoteVCard: 'BEGIN:VCARD\r\nFN:Remote\r\nEND:VCARD\r\n',
+ localTombstone: false,
+ remoteTombstone: false,
+ };
+ const client = {
+ query: vi.fn(async sql => {
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await expect(recordCarddavConflict(client, conflict)).resolves.toEqual({
+ id: CONFLICT_ID,
+ status: 'unresolved',
+ });
+
+ const insert = client.query.mock.calls.find(([sql]) => (
+ sql.includes('INSERT INTO carddav_conflicts')
+ ));
+ expect(insert[0]).toContain("ON CONFLICT (address_book_id, href) WHERE status = 'unresolved'");
+ expect(insert[1]).toEqual([
+ BOOK_ID,
+ conflict.href,
+ USER_ID,
+ 'base-hash',
+ '"remote-2"',
+ conflict.localVCard,
+ conflict.remoteVCard,
+ false,
+ false,
+ ]);
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /UPDATE carddav_remote_objects[\s\S]+mapping_status = 'conflict'[\s\S]+mapping_revision = mapping_revision \+ 1/,
+ ),
+ [BOOK_ID, conflict.href, '7'],
+ );
+ });
+
+ it('records deletion snapshots with explicit tombstones', async () => {
+ const client = {
+ query: vi.fn(async sql => ({
+ rows: sql.includes('INSERT INTO carddav_conflicts')
+ ? [{ id: CONFLICT_ID, status: 'unresolved' }]
+ : [],
+ rowCount: 1,
+ })),
+ };
+
+ await recordCarddavConflict(client, {
+ addressBookId: BOOK_ID,
+ href: 'https://dav.example.test/books/default/contact.vcf',
+ expectedMappingRevision: '7',
+ userId: USER_ID,
+ baseLocalHash: 'base-hash',
+ remoteEtag: null,
+ localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n',
+ remoteVCard: null,
+ localTombstone: false,
+ remoteTombstone: true,
+ });
+
+ const insert = client.query.mock.calls.find(([sql]) => (
+ sql.includes('INSERT INTO carddav_conflicts')
+ ));
+ expect(insert[1].slice(-2)).toEqual([false, true]);
+ });
+
+ it('refreshes the caller-supplied local snapshot on repeated wrapper calls', async () => {
+ const href = 'https://dav.example.test/books/default/repeated.vcf';
+ const inserts = [];
+ let storedLocal = null;
+ const client = {
+ query: vi.fn(async (sql, params) => {
+ if (sql.includes('SELECT local_vcard, local_tombstone')) {
+ return { rows: storedLocal ? [storedLocal] : [], rowCount: Number(Boolean(storedLocal)) };
+ }
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: inserts.length === 0 ? '8' : '9' }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ inserts.push([sql, params]);
+ storedLocal ??= { local_vcard: params[5], local_tombstone: params[7] };
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+ const shared = {
+ addressBookId: BOOK_ID,
+ href,
+ userId: USER_ID,
+ baseLocalHash: 'base-hash',
+ remoteEtag: '"remote"',
+ remoteVCard: 'BEGIN:VCARD\r\nFN:Remote\r\nEND:VCARD\r\n',
+ remoteTombstone: false,
+ };
+
+ await recordCarddavConflict(client, {
+ ...shared,
+ expectedMappingRevision: '7',
+ localVCard: 'BEGIN:VCARD\r\nFN:First Local\r\nEND:VCARD\r\n',
+ localTombstone: false,
+ });
+ await recordCarddavConflict(client, {
+ ...shared,
+ expectedMappingRevision: '8',
+ localVCard: null,
+ localTombstone: true,
+ });
+
+ expect(inserts).toHaveLength(2);
+ expect(inserts[1][0]).toMatch(/local_vcard = EXCLUDED\.local_vcard/);
+ expect(inserts[1][0]).toMatch(/local_tombstone = EXCLUDED\.local_tombstone/);
+ expect(inserts[1][1].slice(5, 9)).toEqual([
+ null,
+ shared.remoteVCard,
+ true,
+ false,
+ ]);
+ });
+});
diff --git a/backend/src/services/carddavContactService.integration.test.js b/backend/src/services/carddavContactService.integration.test.js
new file mode 100644
index 0000000..24a1715
--- /dev/null
+++ b/backend/src/services/carddavContactService.integration.test.js
@@ -0,0 +1,1252 @@
+import { createHash, randomUUID } from 'node:crypto';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import pg from 'pg';
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { createCarddavFixtureServer } from './carddavFixtureServer.js';
+import {
+ assertMinimumPostgresVersion,
+ createTestDatabase,
+ dropTestDatabase,
+ postgresTestContext,
+ productionDatabaseEnvironment,
+} from './postgresTestHelpers.js';
+import { generateVCard } from '../utils/vcard.js';
+import {
+ contactFromVCardDocument,
+ localContactHash,
+ parseVCardDocument,
+ semanticVCardHash,
+} from '../utils/vcardProperties.js';
+
+const { Client } = pg;
+const { databaseUrl, connectionStringFor } = postgresTestContext(
+ 'CardDAV contact integration tests',
+);
+
+const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
+const databaseName = `carddav_contacts_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
+const encryptionKey = '22'.repeat(32);
+const productionEnvironment = productionDatabaseEnvironment(encryptionKey);
+const credentials = {
+ username: 'fixture-user',
+ password: 'fixture-password',
+};
+const MAX_VCARD_BYTES = 1024 * 1024;
+const MAX_CONTENT_LINE_BYTES = 64 * 1024;
+
+let adminClient;
+let databaseClient;
+let productionDb;
+let contactService;
+let contactsRouter;
+let encrypt;
+let activeFixture;
+let beforeBegin;
+let afterCommit;
+let nextInstrumentedClientId = 1;
+let transactionEvents = [];
+
+async function instrumentTransactions() {
+ const instrumentClient = client => {
+ if (client.carddavContactOriginalQuery) return client;
+
+ client.carddavContactOriginalQuery = client.query.bind(client);
+ const clientId = nextInstrumentedClientId++;
+ client.query = (text, ...args) => {
+ if (typeof args.at(-1) === 'function') {
+ return client.carddavContactOriginalQuery(text, ...args);
+ }
+ const sql = typeof text === 'string' ? text.trim().toUpperCase() : '';
+ return (async () => {
+ if (sql === 'BEGIN' && beforeBegin) await beforeBegin();
+ const result = await client.carddavContactOriginalQuery(text, ...args);
+ if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') {
+ transactionEvents.push({
+ clientId,
+ sql,
+ networkRequests: activeFixture?.counters.requests ?? 0,
+ });
+ }
+ if (sql === 'COMMIT' && afterCommit) await afterCommit();
+ return result;
+ })();
+ };
+ return client;
+ };
+ productionDb.pool.on('connect', instrumentClient);
+ const client = await productionDb.pool.connect();
+ instrumentClient(client);
+ client.release();
+}
+
+function resetObservation(fixture) {
+ activeFixture = fixture;
+ beforeBegin = null;
+ afterCommit = null;
+ transactionEvents = [];
+ fixture?.reset();
+}
+
+function expectNoNetworkInsideTransactions() {
+ const openTransactions = new Map();
+ let completed = 0;
+ for (const event of transactionEvents) {
+ if (event.sql === 'BEGIN') {
+ expect(openTransactions.has(event.clientId)).toBe(false);
+ openTransactions.set(event.clientId, event);
+ continue;
+ }
+ const openTransaction = openTransactions.get(event.clientId);
+ expect(openTransaction).toBeDefined();
+ expect(event.networkRequests).toBe(openTransaction.networkRequests);
+ openTransactions.delete(event.clientId);
+ completed++;
+ }
+ expect(openTransactions.size).toBe(0);
+ expect(completed).toBeGreaterThan(0);
+}
+
+function vcard(uid, displayName, email = `${uid}@example.test`) {
+ return [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${uid}`,
+ `FN:${displayName}`,
+ `EMAIL:${email}`,
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+}
+
+function sizedVCard(uid, size) {
+ const start = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${uid}\r\nFN:Limit\r\n`;
+ const end = 'END:VCARD\r\n';
+ let remaining = size - Buffer.byteLength(start) - Buffer.byteLength(end);
+ const lines = [];
+
+ while (remaining > 0) {
+ let lineBytes = Math.min(MAX_CONTENT_LINE_BYTES + 2, remaining);
+ const tail = remaining - lineBytes;
+ if (tail > 0 && tail < 4) lineBytes -= 4 - tail;
+ lines.push(`X:${'a'.repeat(lineBytes - 4)}\r\n`);
+ remaining -= lineBytes;
+ }
+
+ return start + lines.join('') + end;
+}
+
+function draft(displayName, email) {
+ return {
+ displayName,
+ firstName: displayName.split(' ')[0],
+ lastName: displayName.split(' ').slice(1).join(' ') || null,
+ emails: [{ value: email, type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ };
+}
+
+async function authoritativeState(userId) {
+ const { rows: addressBooks } = await databaseClient.query(`
+ SELECT id, name, source, external_url, sync_token,
+ remote_create_capability, remote_update_capability, remote_delete_capability
+ FROM address_books
+ WHERE user_id = $1
+ ORDER BY source, external_url NULLS FIRST, id
+ `, [userId]);
+ const { rows: contacts } = await databaseClient.query(`
+ SELECT id, address_book_id, user_id, uid, vcard, etag, display_name,
+ first_name, last_name, primary_email, emails, phones, organization,
+ notes, photo_data, additional_fields, is_auto, send_count, last_sent
+ FROM contacts
+ WHERE user_id = $1
+ ORDER BY address_book_id, uid
+ `, [userId]);
+ const { rows: remoteObjects } = await databaseClient.query(`
+ SELECT remote.address_book_id, remote.href, remote.remote_etag, remote.vcard,
+ remote.primary_email, remote.local_contact_id,
+ remote.mapping_status, remote.vcard_version,
+ remote.remote_semantic_hash, remote.local_contact_hash,
+ remote.mapping_revision::text
+ FROM carddav_remote_objects remote
+ JOIN address_books book ON book.id = remote.address_book_id
+ WHERE book.user_id = $1
+ ORDER BY remote.address_book_id, remote.href
+ `, [userId]);
+ const { rows: conflicts } = await databaseClient.query(`
+ SELECT id, address_book_id, href, user_id, base_local_hash, remote_etag,
+ local_vcard, remote_vcard, local_tombstone, remote_tombstone,
+ status, resolution, resolved_by, resolved_at
+ FROM carddav_conflicts
+ WHERE user_id = $1
+ ORDER BY address_book_id, href
+ `, [userId]);
+ return { addressBooks, contacts, remoteObjects, conflicts };
+}
+
+async function pendingIntentState(userId) {
+ const { rows } = await databaseClient.query(`
+ SELECT remote.href, remote.pending_operation, remote.pending_vcard,
+ remote.pending_local_hash, remote.pending_remote_semantic_hash,
+ remote.pending_started_at IS NOT NULL AS pending_started
+ FROM carddav_remote_objects remote
+ JOIN address_books book ON book.id = remote.address_book_id
+ WHERE book.user_id = $1 AND remote.pending_operation IS NOT NULL
+ ORDER BY remote.address_book_id, remote.href
+ `, [userId]);
+ return rows;
+}
+
+async function seedUser() {
+ const userId = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-contact-${userId}`],
+ );
+ return userId;
+}
+
+async function seedConnectedUser(fixture) {
+ const userId = await seedUser();
+ const connectionGeneration = randomUUID();
+ const { rows: [localBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Personal') RETURNING id",
+ [userId],
+ );
+ await databaseClient.query(`
+ INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)
+ `, [userId, JSON.stringify({
+ serverUrl: fixture.serverUrl,
+ username: credentials.username,
+ password: encrypt(credentials.password),
+ connectionGeneration,
+ })]);
+ return { userId, connectionGeneration, localBookId: localBook.id };
+}
+
+async function seedMappedContact(fixture, name = 'Mapped Before') {
+ const seeded = await seedConnectedUser(fixture);
+ const uid = randomUUID();
+ const rawVCard = vcard(uid, name);
+ const href = fixture.href(`${uid}.vcf`);
+ const remoteEtag = '"mapped-1"';
+ const { rows: [remoteBook] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed')
+ RETURNING id
+ `, [seeded.userId, new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href]);
+ const { rows: [contact] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, additional_fields, is_auto
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,'[]'::jsonb,'[]'::jsonb,false)
+ RETURNING id, etag
+ `, [
+ seeded.localBookId,
+ seeded.userId,
+ uid,
+ rawVCard,
+ createHash('md5').update(rawVCard).digest('hex'),
+ name,
+ `${uid}@example.test`,
+ JSON.stringify([{ value: `${uid}@example.test`, type: 'other', primary: true }]),
+ ]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, vcard_version,
+ remote_semantic_hash, local_contact_hash, last_synced_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,'synced','3.0','remote-hash','local-hash',NOW())
+ `, [
+ remoteBook.id,
+ href,
+ remoteEtag,
+ rawVCard,
+ `${uid}@example.test`,
+ contact.id,
+ ]);
+ fixture.putContact(href, remoteEtag, rawVCard);
+ return { ...seeded, uid, href, remoteEtag, remoteBookId: remoteBook.id, contact };
+}
+
+// Reproduce the state a sync pull leaves behind: the local contacts row holds the
+// LOSSY re-serialized vCard (generateVCard drops unmodeled properties) while the
+// carddav_remote_objects row retains the FULL remote vCard. The local UID is the
+// href hash, exactly as desiredAutomaticContact derives it.
+async function seedImportedContact(fixture, remoteVCard, remoteEtag = '"imported-1"') {
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href(`${randomUUID()}.vcf`);
+ const document = parseVCardDocument(remoteVCard);
+ const projected = contactFromVCardDocument(document);
+ const uid = createHash('sha256').update(href).digest('hex');
+ const primaryEmail = projected.emails?.[0]?.value ?? null;
+ const desired = {
+ ...projected,
+ uid,
+ primaryEmail,
+ additionalFields: projected.additionalFields || [],
+ };
+ const localVCard = generateVCard(desired);
+ const localEtag = createHash('md5').update(localVCard).digest('hex');
+ const { rows: [remoteBook] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed')
+ RETURNING id
+ `, [seeded.userId, new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href]);
+ const { rows: [contact] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ first_name, last_name, primary_email, emails, phones,
+ organization, notes, additional_fields, is_auto
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14::jsonb,false)
+ RETURNING id, etag
+ `, [
+ seeded.localBookId,
+ seeded.userId,
+ uid,
+ localVCard,
+ localEtag,
+ desired.displayName,
+ desired.firstName,
+ desired.lastName,
+ primaryEmail,
+ JSON.stringify(desired.emails || []),
+ JSON.stringify(desired.phones || []),
+ desired.organization,
+ desired.notes,
+ JSON.stringify(desired.additionalFields || []),
+ ]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, vcard_version,
+ remote_semantic_hash, local_contact_hash, last_synced_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,'synced',$7,$8,$9,NOW())
+ `, [
+ remoteBook.id,
+ href,
+ remoteEtag,
+ remoteVCard,
+ primaryEmail,
+ contact.id,
+ document.version,
+ semanticVCardHash(document),
+ localContactHash(desired),
+ ]);
+ fixture.putContact(href, remoteEtag, remoteVCard);
+ return {
+ ...seeded,
+ uid,
+ href,
+ remoteEtag,
+ remoteBookId: remoteBook.id,
+ contact,
+ localVCard,
+ remoteVCard,
+ };
+}
+
+describe('CardDAV contact mutations against PostgreSQL 16 and HTTP', () => {
+ beforeAll(async () => {
+ adminClient = new Client({ connectionString: databaseUrl });
+ await adminClient.connect();
+ await createTestDatabase(adminClient, databaseName);
+
+ const connectionString = connectionStringFor(databaseName);
+ databaseClient = new Client({ connectionString });
+ await databaseClient.connect();
+ await assertMinimumPostgresVersion(databaseClient);
+
+ productionEnvironment.configure(connectionString);
+ productionDb = await import('./db.js');
+ const { runMigrationsWithPool } = await import('./migrations.js');
+ await runMigrationsWithPool(productionDb.pool, migrationsDirectory);
+ await productionDb.pool.query(`
+ INSERT INTO system_settings (key, value) VALUES ('allow_private_hosts', 'true')
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ `);
+ ({ encrypt } = await import('./encryption.js'));
+ contactService = await import('./carddavContactService.js');
+ ({ default: contactsRouter } = await import('../routes/contacts.js'));
+ await instrumentTransactions();
+ }, 120_000);
+
+ beforeEach(() => {
+ activeFixture = null;
+ beforeBegin = null;
+ afterCommit = null;
+ transactionEvents = [];
+ });
+
+ afterAll(async () => {
+ activeFixture = null;
+ await productionDb?.pool.end();
+ await databaseClient?.end();
+ if (adminClient) {
+ await dropTestDatabase(adminClient, databaseName);
+ await adminClient.end();
+ }
+ productionEnvironment.restore();
+ }, 120_000);
+
+ it('executes local create, update, and delete SQL with authoritative read-back', async () => {
+ const userId = await seedUser();
+ resetObservation(null);
+
+ const created = await contactService.createContact(
+ userId,
+ draft('Local Created', 'local@example.test'),
+ );
+ const afterCreate = await authoritativeState(userId);
+ expect(afterCreate.addressBooks).toHaveLength(1);
+ expect(afterCreate.contacts).toEqual([
+ expect.objectContaining({
+ id: created.id,
+ address_book_id: afterCreate.addressBooks[0].id,
+ user_id: userId,
+ uid: created.uid,
+ display_name: 'Local Created',
+ primary_email: 'local@example.test',
+ is_auto: false,
+ }),
+ ]);
+ expect(afterCreate.remoteObjects).toEqual([]);
+ expect(afterCreate.conflicts).toEqual([]);
+ expectNoNetworkInsideTransactions();
+
+ resetObservation(null);
+ const updated = await contactService.updateContact(
+ userId,
+ created.id,
+ draft('Local Updated', 'updated@example.test'),
+ );
+ const afterUpdate = await authoritativeState(userId);
+ expect(updated.display_name).toBe('Local Updated');
+ expect(afterUpdate.contacts).toEqual([
+ expect.objectContaining({
+ id: created.id,
+ display_name: 'Local Updated',
+ primary_email: 'updated@example.test',
+ }),
+ ]);
+ expect(afterUpdate.addressBooks[0].sync_token)
+ .not.toBe(afterCreate.addressBooks[0].sync_token);
+ expect(afterUpdate.remoteObjects).toEqual([]);
+ expect(afterUpdate.conflicts).toEqual([]);
+ expectNoNetworkInsideTransactions();
+
+ resetObservation(null);
+ await expect(contactService.deleteContact(userId, created.id))
+ .resolves.toEqual({ ok: true });
+ const afterDelete = await authoritativeState(userId);
+ expect(afterDelete.addressBooks).toHaveLength(1);
+ expect(afterDelete.addressBooks[0].sync_token)
+ .not.toBe(afterUpdate.addressBooks[0].sync_token);
+ expect(afterDelete.contacts).toEqual([]);
+ expect(afterDelete.remoteObjects).toEqual([]);
+ expect(afterDelete.conflicts).toEqual([]);
+ expectNoNetworkInsideTransactions();
+ });
+
+ it('executes mapped vCard create, replace, and delete with conditional fixture HTTP', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const { userId, localBookId } = await seedConnectedUser(fixture);
+ const uid = randomUUID();
+
+ try {
+ resetObservation(fixture);
+ const created = await contactService.createContactFromVCard(userId, {
+ localAddressBookId: localBookId,
+ uid,
+ rawVCard: vcard(uid, 'Remote Created'),
+ });
+ const afterCreate = await authoritativeState(userId);
+ expect(afterCreate.addressBooks).toHaveLength(2);
+ expect(afterCreate.contacts).toEqual([
+ expect.objectContaining({
+ id: created.id,
+ address_book_id: localBookId,
+ uid,
+ display_name: 'Remote Created',
+ }),
+ ]);
+ expect(afterCreate.remoteObjects).toEqual([
+ expect.objectContaining({
+ local_contact_id: created.id,
+ mapping_status: 'synced',
+ mapping_revision: '0',
+ }),
+ ]);
+ expect(afterCreate.conflicts).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ requests: 5,
+ propfind: 3,
+ create: 1,
+ update: 0,
+ delete: 0,
+ fetch: 1,
+ });
+ const createRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(createRequest).toMatchObject({
+ ifMatch: undefined,
+ ifNoneMatch: '*',
+ });
+ expectNoNetworkInsideTransactions();
+
+ resetObservation(fixture);
+ const beforeReplace = await authoritativeState(userId);
+ const expectedRemoteEtag = beforeReplace.remoteObjects[0].remote_etag;
+ const replaced = await contactService.replaceContactFromVCard(userId, {
+ localAddressBookId: localBookId,
+ uid,
+ rawVCard: vcard(uid, 'Remote Replaced'),
+ expectedLocalEtag: beforeReplace.contacts[0].etag,
+ });
+ const afterReplace = await authoritativeState(userId);
+ expect(replaced.display_name).toBe('Remote Replaced');
+ expect(afterReplace.contacts).toEqual([
+ expect.objectContaining({
+ id: created.id,
+ display_name: 'Remote Replaced',
+ }),
+ ]);
+ expect(afterReplace.remoteObjects).toEqual([
+ expect.objectContaining({
+ local_contact_id: created.id,
+ mapping_status: 'synced',
+ mapping_revision: '2',
+ }),
+ ]);
+ expect(afterReplace.conflicts).toEqual([]);
+ expect(await pendingIntentState(userId)).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ requests: 2,
+ create: 0,
+ update: 1,
+ delete: 0,
+ fetch: 1,
+ });
+ const updateRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(updateRequest).toMatchObject({
+ ifMatch: expectedRemoteEtag,
+ ifNoneMatch: undefined,
+ });
+ expectNoNetworkInsideTransactions();
+
+ resetObservation(fixture);
+ const expectedDeleteEtag = afterReplace.remoteObjects[0].remote_etag;
+ await expect(contactService.deleteContactFromVCard(userId, {
+ localAddressBookId: localBookId,
+ uid,
+ expectedLocalEtag: afterReplace.contacts[0].etag,
+ })).resolves.toEqual({ ok: true });
+ const afterDelete = await authoritativeState(userId);
+ expect(afterDelete.addressBooks).toHaveLength(2);
+ expect(afterDelete.contacts).toEqual([]);
+ expect(afterDelete.remoteObjects).toEqual([]);
+ expect(afterDelete.conflicts).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ requests: 2,
+ create: 0,
+ update: 0,
+ delete: 1,
+ fetch: 1,
+ });
+ const deleteRequest = fixture.requests.find(request => request.method === 'DELETE');
+ expect(deleteRequest.ifMatch).toBe(expectedDeleteEtag);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('atomically resolves concurrent mapped create-only requests for one book and UID', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const { userId, localBookId } = await seedConnectedUser(fixture);
+ const uid = randomUUID();
+ let preflightCommits = 0;
+ let releasePreflightCommits;
+ const bothPreflightCommits = new Promise(resolve => { releasePreflightCommits = resolve; });
+
+ try {
+ resetObservation(fixture);
+ afterCommit = async () => {
+ preflightCommits++;
+ if (preflightCommits === 2) {
+ afterCommit = null;
+ releasePreflightCommits();
+ }
+ await bothPreflightCommits;
+ };
+ const createOnly = () => contactService.createContactFromVCard(userId, {
+ localAddressBookId: localBookId,
+ uid,
+ rawVCard: vcard(uid, 'Concurrent Remote Create'),
+ expectedAbsent: true,
+ });
+
+ const outcomes = await Promise.allSettled([createOnly(), createOnly()]);
+ const statuses = outcomes.map(outcome => (
+ outcome.status === 'fulfilled'
+ ? 201
+ : outcome.reason.code === 'ERR_LOCAL_PRECONDITION_FAILED' ? 412 : null
+ )).sort();
+ const after = await authoritativeState(userId);
+ const putRequests = fixture.requests.filter(request => request.method === 'PUT');
+
+ expect(statuses).toEqual([201, 412]);
+ expect(after.contacts).toEqual([
+ expect.objectContaining({ address_book_id: localBookId, uid }),
+ ]);
+ expect(after.remoteObjects).toEqual([
+ expect.objectContaining({
+ local_contact_id: after.contacts[0].id,
+ href: fixture.href(`${uid}.vcf`),
+ mapping_status: 'synced',
+ }),
+ ]);
+ expect(after.conflicts).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ requests: 9,
+ propfind: 6,
+ create: 1,
+ update: 0,
+ delete: 0,
+ fetch: 1,
+ });
+ expect(putRequests).toHaveLength(2);
+ expect(putRequests).toEqual([
+ expect.objectContaining({
+ path: new URL(fixture.href(`${uid}.vcf`)).pathname,
+ ifMatch: undefined,
+ ifNoneMatch: '*',
+ }),
+ expect.objectContaining({
+ path: new URL(fixture.href(`${uid}.vcf`)).pathname,
+ ifMatch: undefined,
+ ifNoneMatch: '*',
+ }),
+ ]);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ afterCommit = null;
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('rejects a stale local ETag before HTTP and preserves all authoritative rows', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedMappedContact(fixture);
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ await expect(contactService.replaceContactFromVCard(seeded.userId, {
+ localAddressBookId: seeded.localBookId,
+ uid: seeded.uid,
+ rawVCard: vcard(seeded.uid, 'Rejected Local ETag'),
+ expectedLocalEtag: 'stale-local-etag',
+ })).rejects.toMatchObject({ code: 'ERR_LOCAL_ETAG_MISMATCH' });
+ expect(await authoritativeState(seeded.userId)).toEqual(before);
+ expect(fixture.counters.requests).toBe(0);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ });
+
+ // The recovery fetch after a 412 must reject a malformed or oversized REMOTE
+ // snapshot before it can persist. (A replace now overlays the client body onto the
+ // retained remote vCard, so the pending intent is bounded by the retained document
+ // rather than the raw client body; the pending-intent 1 MiB and conflict-snapshot
+ // 2 MiB size limits are unit-tested at their exact boundaries in
+ // carddavMappingState.test.js.)
+ it.each([
+ {
+ label: 'malformed remote snapshot',
+ remoteVCard: () => 'not a vCard',
+ expectedError: /BEGIN:VCARD/,
+ },
+ {
+ label: 'oversized remote snapshot',
+ remoteVCard: uid => sizedVCard(uid, MAX_VCARD_BYTES + 1),
+ expectedError: /1 MiB/,
+ },
+ ])('handles mapped 412 with $label before forbidden persistence', async ({
+ remoteVCard,
+ expectedError,
+ }) => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedMappedContact(fixture);
+ const rejected = vcard(seeded.uid, 'Rejected Local');
+ const latest = remoteVCard(seeded.uid);
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ fixture.putContact(seeded.href, '"remote-2"', latest);
+ fixture.queueWrite('PUT', { status: 412 });
+
+ const error = await contactService.replaceContactFromVCard(seeded.userId, {
+ localAddressBookId: seeded.localBookId,
+ uid: seeded.uid,
+ rawVCard: rejected,
+ expectedLocalEtag: seeded.contact.etag,
+ }).catch(value => value);
+
+ const after = await authoritativeState(seeded.userId);
+ expect(error.message).toMatch(expectedError);
+ expect(after.addressBooks).toEqual(before.addressBooks);
+ expect(after.contacts).toEqual(before.contacts);
+ expect(after.remoteObjects).toEqual([{
+ ...before.remoteObjects[0],
+ mapping_status: 'pending_push',
+ mapping_revision: '1',
+ }]);
+ // For an unmapped-property-free client body overlaid onto a matching retained
+ // document, the overlay reproduces the client body byte-for-byte, so the
+ // pending intent snapshots exactly what the client sent.
+ expect(await pendingIntentState(seeded.userId)).toEqual([
+ expect.objectContaining({
+ href: seeded.href,
+ pending_operation: 'update',
+ pending_vcard: rejected,
+ pending_local_hash: expect.any(String),
+ pending_remote_semantic_hash: expect.any(String),
+ pending_started: true,
+ }),
+ ]);
+ expect(after.conflicts).toEqual([]);
+ expect(fixture.counters.requests).toBe(2);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('rolls back final SQL when the mapping revision changes after remote update', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedMappedContact(fixture);
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ beforeBegin = async () => {
+ if (fixture.counters.update !== 1) return;
+ beforeBegin = null;
+ await databaseClient.query(`
+ UPDATE carddav_remote_objects
+ SET mapping_revision = mapping_revision + 1
+ WHERE address_book_id = $1 AND href = $2
+ `, [seeded.remoteBookId, seeded.href]);
+ };
+
+ await expect(contactService.updateContact(
+ seeded.userId,
+ seeded.contact.id,
+ draft('Rejected Mapping Revision', `${seeded.uid}@example.test`),
+ )).rejects.toBeInstanceOf(contactService.CardDavAmbiguousWriteError);
+
+ const after = await authoritativeState(seeded.userId);
+ expect(after.addressBooks).toEqual(before.addressBooks);
+ expect(after.contacts).toEqual(before.contacts);
+ expect(after.remoteObjects).toEqual([{
+ ...before.remoteObjects[0],
+ mapping_status: 'pending_push',
+ mapping_revision: String(Number(before.remoteObjects[0].mapping_revision) + 2),
+ }]);
+ expect(after.conflicts).toEqual(before.conflicts);
+ expect(await pendingIntentState(seeded.userId)).toEqual([
+ expect.objectContaining({
+ href: seeded.href,
+ pending_operation: 'update',
+ pending_vcard: expect.stringContaining('FN:Rejected Mapping Revision'),
+ pending_local_hash: expect.any(String),
+ pending_remote_semantic_hash: expect.any(String),
+ pending_started: true,
+ }),
+ ]);
+ expect(fixture.counters).toMatchObject({
+ requests: 2,
+ update: 1,
+ fetch: 1,
+ });
+ const updateRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(updateRequest.ifMatch).toBe(seeded.remoteEtag);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ });
+
+ it('fences throttle rollback when the connection changes after the 429', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedMappedContact(fixture);
+ const replacementGeneration = randomUUID();
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ fixture.queueWrite('PUT', {
+ status: 429,
+ headers: { 'Retry-After': '120' },
+ });
+ let beginCount = 0;
+ beforeBegin = async () => {
+ beginCount++;
+ if (beginCount !== 2) return;
+ beforeBegin = null;
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = jsonb_set(
+ config, '{connectionGeneration}', to_jsonb($2::text), true
+ ), updated_at = NOW()
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId, replacementGeneration]);
+ };
+
+ const error = await contactService.updateContact(
+ seeded.userId,
+ seeded.contact.id,
+ draft('Replaced During Throttle', `${seeded.uid}@example.test`),
+ ).catch(result => result);
+
+ expect(error).toMatchObject({ code: 'ERR_CARDDAV_FINAL_FENCE' });
+ const after = await authoritativeState(seeded.userId);
+ expect(after.addressBooks).toEqual(before.addressBooks);
+ expect(after.contacts).toEqual(before.contacts);
+ expect(after.remoteObjects).toEqual([{
+ ...before.remoteObjects[0],
+ mapping_status: 'pending_push',
+ mapping_revision: String(Number(before.remoteObjects[0].mapping_revision) + 1),
+ }]);
+ expect(after.conflicts).toEqual(before.conflicts);
+ expect(await pendingIntentState(seeded.userId)).toEqual([
+ expect.objectContaining({
+ href: seeded.href,
+ pending_operation: 'update',
+ pending_vcard: expect.stringContaining('FN:Replaced During Throttle'),
+ pending_local_hash: expect.any(String),
+ pending_remote_semantic_hash: expect.any(String),
+ pending_started: true,
+ }),
+ ]);
+ const { rows: [integration] } = await databaseClient.query(`
+ SELECT config FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId]);
+ expect(integration.config).toMatchObject({
+ connectionGeneration: replacementGeneration,
+ });
+ expect(integration.config).not.toHaveProperty('retryAfterAt');
+ expect(fixture.requests.map(request => request.method)).toEqual(['PUT']);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ beforeBegin = null;
+ await fixture.close();
+ }
+ });
+
+ it('rolls back a forced final transaction failure after one confirmed remote update', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedMappedContact(fixture);
+
+ await databaseClient.query(`
+ CREATE FUNCTION fail_carddav_contact_update() RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.display_name = 'Forced Final Failure' THEN
+ RAISE EXCEPTION 'forced final transaction failure';
+ END IF;
+ RETURN NEW;
+ END
+ $$
+ `);
+ await databaseClient.query(`
+ CREATE TRIGGER fail_carddav_contact_update
+ BEFORE UPDATE ON contacts
+ FOR EACH ROW EXECUTE FUNCTION fail_carddav_contact_update()
+ `);
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ await expect(contactService.updateContact(
+ seeded.userId,
+ seeded.contact.id,
+ draft('Forced Final Failure', `${seeded.uid}@example.test`),
+ )).rejects.toBeInstanceOf(contactService.CardDavAmbiguousWriteError);
+
+ const after = await authoritativeState(seeded.userId);
+ expect(after.addressBooks).toEqual(before.addressBooks);
+ expect(after.contacts).toEqual(before.contacts);
+ expect(after.remoteObjects).toEqual([{
+ ...before.remoteObjects[0],
+ mapping_status: 'pending_push',
+ mapping_revision: String(Number(before.remoteObjects[0].mapping_revision) + 1),
+ }]);
+ expect(after.conflicts).toEqual(before.conflicts);
+ expect(await pendingIntentState(seeded.userId)).toEqual([
+ expect.objectContaining({
+ href: seeded.href,
+ pending_operation: 'update',
+ pending_vcard: expect.stringContaining('FN:Forced Final Failure'),
+ pending_local_hash: expect.any(String),
+ pending_remote_semantic_hash: expect.any(String),
+ pending_started: true,
+ }),
+ ]);
+ expect(fixture.counters).toMatchObject({
+ requests: 2,
+ update: 1,
+ fetch: 1,
+ });
+ const updateRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(updateRequest.ifMatch).toBe(seeded.remoteEtag);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await databaseClient.query('DROP TRIGGER fail_carddav_contact_update ON contacts');
+ await databaseClient.query('DROP FUNCTION fail_carddav_contact_update()');
+ await fixture.close();
+ }
+ });
+
+ it('retains unmodeled remote vCard properties when editing a sync-imported contact', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const remoteVCard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:remote-server-uid-123',
+ 'FN:Imported Person',
+ 'EMAIL:imported@example.test',
+ 'CATEGORIES:Friends,VIP',
+ 'X-CUSTOM-FLAG:keep-me',
+ 'TZ:America/New_York',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ const seeded = await seedImportedContact(fixture, remoteVCard);
+
+ try {
+ // Sanity: the lossy local vCard dropped the unmodeled properties the pull
+ // never modeled, so the retained mapping vCard is the only lossless copy.
+ expect(seeded.localVCard).not.toContain('CATEGORIES');
+ expect(seeded.localVCard).not.toContain('X-CUSTOM-FLAG');
+ expect(seeded.localVCard).not.toContain('TZ:');
+
+ resetObservation(fixture);
+ const updated = await contactService.updateContact(
+ seeded.userId,
+ seeded.contact.id,
+ { displayName: 'Imported Renamed' },
+ );
+ expect(updated.display_name).toBe('Imported Renamed');
+
+ const putRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(putRequest).toBeDefined();
+ // The user's edit landed on the remote resource...
+ expect(putRequest.body).toContain('FN:Imported Renamed');
+ // ...and the unmodeled remote properties survived the round-trip.
+ expect(putRequest.body).toContain('CATEGORIES:Friends,VIP');
+ expect(putRequest.body).toContain('X-CUSTOM-FLAG:keep-me');
+ expect(putRequest.body).toContain('TZ:America/New_York');
+
+ const after = await authoritativeState(seeded.userId);
+ expect(after.conflicts).toEqual([]);
+ expect(await pendingIntentState(seeded.userId)).toEqual([]);
+ // The confirmed local + mapping vCards now both retain the properties too.
+ expect(after.contacts[0].vcard).toContain('CATEGORIES:Friends,VIP');
+ expect(after.remoteObjects[0].vcard).toContain('X-CUSTOM-FLAG:keep-me');
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('merges a native client replace: modeled full-state, unmodeled name-level, remote UID kept', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const remoteUid = 'remote-server-uid-777';
+ const remoteVCard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${remoteUid}`,
+ 'FN:Native Person',
+ 'EMAIL:native@example.test',
+ 'ORG:Remote Org',
+ 'CATEGORIES:Colleagues,Board',
+ 'X-SURVIVE-ME:keep-this',
+ 'TZ:Europe/Berlin',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ const seeded = await seedImportedContact(fixture, remoteVCard);
+
+ try {
+ // A native client renamed the contact, CHANGED an unmodeled property (CATEGORIES),
+ // ADDED one (X-CLIENT-NOTE), round-tripped TZ, and — as a stripping client would —
+ // dropped an unmodeled property (X-SURVIVE-ME) AND a modeled one (ORG).
+ const clientBody = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${seeded.uid}`,
+ 'FN:Native Renamed',
+ 'EMAIL:native@example.test',
+ 'CATEGORIES:Colleagues,VIP',
+ 'TZ:Europe/Berlin',
+ 'X-CLIENT-NOTE:added-by-client',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+
+ resetObservation(fixture);
+ const replaced = await contactService.replaceContactFromVCard(seeded.userId, {
+ localAddressBookId: seeded.localBookId,
+ uid: seeded.uid,
+ rawVCard: clientBody,
+ expectedLocalEtag: seeded.contact.etag,
+ });
+ expect(replaced.display_name).toBe('Native Renamed');
+
+ const putRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(putRequest).toBeDefined();
+ // Unmodeled: names present in the client body win (edit + add reach the remote)...
+ expect(putRequest.body).toContain('FN:Native Renamed');
+ expect(putRequest.body).toContain('CATEGORIES:Colleagues,VIP');
+ expect(putRequest.body).toContain('X-CLIENT-NOTE:added-by-client');
+ expect(putRequest.body).toContain('TZ:Europe/Berlin');
+ // ...and a name the stripping client OMITTED survives from the retained document.
+ expect(putRequest.body).toContain('X-SURVIVE-ME:keep-this');
+ // Modeled: full-state from the client, so the omitted ORG is removed.
+ expect(putRequest.body).not.toContain('ORG:');
+ // The outgoing document keeps the remote-owned UID, never the local key.
+ expect(contactFromVCardDocument(parseVCardDocument(putRequest.body)).uid).toBe(remoteUid);
+
+ const after = await authoritativeState(seeded.userId);
+ expect(after.conflicts).toEqual([]);
+ expect(await pendingIntentState(seeded.userId)).toEqual([]);
+ expect(contactFromVCardDocument(parseVCardDocument(after.remoteObjects[0].vcard)).uid)
+ .toBe(remoteUid);
+ expect(after.remoteObjects[0].vcard).toContain('X-SURVIVE-ME:keep-this');
+ expect(after.contacts[0].uid).toBe(seeded.uid);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // Seed a mapped contact whose retained document has `retainedProps`, PUT a client body
+ // whose extra lines are `clientProps` (FN/EMAIL held constant so only the merge varies),
+ // and return the outgoing PUT document.
+ async function mappedReplacePut(fixture, retainedProps, clientProps) {
+ const remoteVCard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:merge-remote-uid', 'FN:Merge Person',
+ 'EMAIL:merge@example.test', ...retainedProps, 'END:VCARD', '',
+ ].join('\r\n');
+ const seeded = await seedImportedContact(fixture, remoteVCard);
+ const clientBody = [
+ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${seeded.uid}`, 'FN:Merge Person',
+ 'EMAIL:merge@example.test', ...clientProps, 'END:VCARD', '',
+ ].join('\r\n');
+ resetObservation(fixture);
+ await contactService.replaceContactFromVCard(seeded.userId, {
+ localAddressBookId: seeded.localBookId, uid: seeded.uid,
+ rawVCard: clientBody, expectedLocalEtag: seeded.contact.etag,
+ });
+ return parseVCardDocument(fixture.requests.find(request => request.method === 'PUT').body);
+ }
+
+ it('merges repeated unmodeled instances all-or-nothing per property name', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ // Client submits ONE X-CUSTOM-TAG → exactly the client's one on the wire (its full
+ // instance set replaces the retained set for that name).
+ const replaced = await mappedReplacePut(
+ fixture,
+ ['X-CUSTOM-TAG:one', 'X-CUSTOM-TAG:two', 'X-CUSTOM-TAG:three'],
+ ['X-CUSTOM-TAG:only'],
+ );
+ const tags = replaced.properties.filter(p => p.name === 'X-CUSTOM-TAG').map(p => p.rawValue);
+ expect(tags).toEqual(['only']);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('keeps all retained instances of a name the client omits', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const replaced = await mappedReplacePut(
+ fixture,
+ ['X-CUSTOM-TAG:one', 'X-CUSTOM-TAG:two', 'X-CUSTOM-TAG:three'],
+ [],
+ );
+ const tags = replaced.properties.filter(p => p.name === 'X-CUSTOM-TAG').map(p => p.rawValue);
+ expect(tags).toEqual(['one', 'two', 'three']);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('survives a grouped unmodeled property with its whole item group, including X-ABLABEL', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ // Client (stripping) omits the whole grouped unmodeled property.
+ const replaced = await mappedReplacePut(
+ fixture,
+ ['item1.X-CUSTOM-FIELD:custom-value', 'item1.X-ABLABEL:Custom Label'],
+ [],
+ );
+ const field = replaced.properties.find(p => p.name === 'X-CUSTOM-FIELD');
+ const label = replaced.properties.find(p => p.name === 'X-ABLABEL');
+ expect(field?.rawValue).toBe('custom-value');
+ // The whole group survives atomically — the label is not orphaned away.
+ expect(label?.rawValue).toBe('Custom Label');
+ expect(field.group.toLowerCase()).toBe(label.group.toLowerCase());
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('re-prefixes a surviving group that collides with a group the client body reuses', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ // Retained item1 is an unmodeled grouped survivor the client omits; the client reuses
+ // item1 for its own modeled Additional field (URL). The survivor must move off item1.
+ const replaced = await mappedReplacePut(
+ fixture,
+ ['item1.X-CUSTOM-FIELD:custom-value', 'item1.X-ABLABEL:Custom Label'],
+ ['item1.URL:https://client.example.test/', 'item1.X-ABLABEL:Client Link'],
+ );
+ const field = replaced.properties.find(p => p.name === 'X-CUSTOM-FIELD');
+ const url = replaced.properties.find(p => p.name === 'URL');
+ expect(field?.rawValue).toBe('custom-value');
+ expect(url?.rawValue).toContain('client.example.test');
+ // The client keeps item1; the surviving group is re-prefixed to a different group.
+ expect(url.group.toLowerCase()).toBe('item1');
+ expect(field.group.toLowerCase()).not.toBe('item1');
+ // ...and it carries its own label under the new prefix.
+ const survivorLabel = replaced.properties.find(
+ p => p.name === 'X-ABLABEL' && p.group?.toLowerCase() === field.group.toLowerCase());
+ expect(survivorLabel?.rawValue).toBe('Custom Label');
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('does not duplicate the modeled main when a mixed Apple item-group survives (W7)', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ // Standard Apple layout: a MODELED main (ADR) grouped with an unmodeled sibling
+ // (X-ABADR) and its label. The client edits the ADR and, as a stripping client,
+ // omits the X-ABADR it does not understand.
+ const replaced = await mappedReplacePut(
+ fixture,
+ ['item1.ADR:;;123 Old St;;;;', 'item1.X-ABADR:us', 'item1.X-ABLABEL:Home'],
+ ['item1.ADR:;;456 New Ave;;;;', 'item1.X-ABLABEL:Home'],
+ );
+ // Exactly ONE ADR on the wire — the client's; the retained modeled main is NOT
+ // re-emitted alongside it (no duplicate address).
+ const adrs = replaced.properties.filter(p => p.name === 'ADR');
+ expect(adrs).toHaveLength(1);
+ expect(adrs[0].rawValue).toContain('456 New Ave');
+ // The unmodeled annotation and its label still survive (accepted: possibly stale).
+ const abadr = replaced.properties.find(p => p.name === 'X-ABADR');
+ expect(abadr?.rawValue).toBe('us');
+ const survivorLabel = replaced.properties.find(
+ p => p.name === 'X-ABLABEL' && p.group?.toLowerCase() === abadr.group?.toLowerCase());
+ expect(survivorLabel?.rawValue).toBe('Home');
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ function listContacts(userId, query) {
+ const handler = contactsRouter.stack
+ .find(layer => layer.route?.path === '/' && layer.route.methods.get)
+ .route.stack.at(-1).handle;
+ return new Promise((resolve, reject) => {
+ handler(
+ { query, session: { userId } },
+ {
+ json: resolve,
+ status: () => ({ json: body => reject(new Error(JSON.stringify(body))) }),
+ },
+ ).catch(reject);
+ });
+ }
+
+ // Contacts that all tie on the list's sort key (no name, no email, same is_auto and
+ // send_count) — the tie is what an unstable sort resolves differently per window.
+ async function seedTiedContacts(userId, size) {
+ const { rows: [book] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Paging') RETURNING id",
+ [userId],
+ );
+ await databaseClient.query(`
+ INSERT INTO contacts (address_book_id, user_id, uid, vcard, etag, is_auto, send_count)
+ SELECT $1, $2, 'page-' || i, 'BEGIN:VCARD\r\nEND:VCARD', 'etag-' || i, false, 0
+ FROM generate_series(1, $3) AS i
+ `, [book.id, userId, size]);
+ }
+
+ // LIMIT/OFFSET over an ORDER BY with no unique tiebreaker leaves windows free to
+ // overlap, which drops rows from the union — a contact no amount of paging can reach.
+ it('pages contacts into disjoint windows that reach every contact', async () => {
+ const userId = await seedUser();
+ // The production book's size: enough rows that PostgreSQL plans the deeper offsets
+ // differently from the first window, which is when an unstable sort reorders ties.
+ const size = 900;
+ const pageSize = 100;
+ await seedTiedContacts(userId, size);
+
+ const windows = [];
+ for (let offset = 0; offset < size; offset += pageSize) {
+ windows.push(await listContacts(userId, { limit: pageSize, offset }));
+ }
+
+ const ids = windows.flatMap(page => page.contacts.map(contact => contact.id));
+ expect(windows.every(page => page.total === size)).toBe(true);
+ expect(windows.every(page => page.contacts.length === pageSize)).toBe(true);
+ expect(ids).toHaveLength(size);
+ expect(new Set(ids).size).toBe(size);
+ }, 120_000);
+
+ it('returns a stable window for a repeated offset and stops past the end', async () => {
+ const userId = await seedUser();
+ await seedTiedContacts(userId, 120);
+
+ const [first, repeat, past] = await Promise.all([
+ listContacts(userId, { limit: 50, offset: 50 }),
+ listContacts(userId, { limit: 50, offset: 50 }),
+ listContacts(userId, { limit: 50, offset: 120 }),
+ ]);
+
+ expect(repeat.contacts.map(contact => contact.id))
+ .toEqual(first.contacts.map(contact => contact.id));
+ expect(past.contacts).toEqual([]);
+ expect(past.total).toBe(120);
+ }, 120_000);
+});
diff --git a/backend/src/services/carddavContactService.js b/backend/src/services/carddavContactService.js
new file mode 100644
index 0000000..9c07fcf
--- /dev/null
+++ b/backend/src/services/carddavContactService.js
@@ -0,0 +1,1489 @@
+import { randomUUID } from 'node:crypto';
+
+import {
+ ADDITIONAL_PROPERTIES,
+ SERVER_OWNED_PROPERTIES,
+ allocateItemGroup,
+ contactFromVCardDocument,
+ groupKey,
+ localContactHash,
+ localVCardEtag,
+ overlayContactOnVCard,
+ parseVCardDocument,
+ presentedVCard,
+ primaryEmail,
+ semanticVCardHash,
+ serializeVCardDocument,
+ withDocumentUid,
+} from '../utils/vcardProperties.js';
+
+// Property names (case-normalized) MailFlow projects into modeled columns / Additional
+// fields. Everything else is an unmodeled property for the two-tier replace merge.
+const MODELED_PROPERTY_NAMES = new Set([
+ 'VERSION', 'UID', 'FN', 'N', 'EMAIL', 'TEL', 'ORG', 'NOTE', 'PHOTO', 'X-ABLABEL',
+ ...ADDITIONAL_PROPERTIES,
+]);
+import {
+ deleteCardResource,
+ discoverAddressBooks,
+ fetchCardResource,
+ putCardResource,
+} from './carddavClient.js';
+import {
+ CardDavError,
+ activeRetryAfterAt,
+ resolveCarddavCredentials,
+} from './carddavTransport.js';
+import {
+ applyConfirmedRemoteContact,
+ applyRemoteTombstone,
+ lockCarddavIntegration,
+ lockCarddavMapping,
+ persistDiscoveredBook,
+ persistDeniedBookCapability,
+ persistPendingMutationIntent,
+ refreshUnresolvedConflict,
+ rotateBookToken,
+ restorePendingMutationIntent,
+ typedError,
+} from './carddavMappingState.js';
+import { query, withTransaction } from './db.js';
+
+const API_CONTACT_COLUMNS = `
+ id, uid, display_name, first_name, last_name, primary_email,
+ emails, phones, organization, notes, photo_data, additional_fields,
+ is_auto, send_count, last_sent, etag, created_at, updated_at`;
+const DRAFT_FIELDS = [
+ ['displayName', 'display_name'],
+ ['firstName', 'first_name'],
+ ['lastName', 'last_name'],
+ ['emails', 'emails'],
+ ['phones', 'phones'],
+ ['organization', 'organization'],
+ ['notes', 'notes'],
+ ['photoData', 'photo_data'],
+ ['additionalFields', 'additional_fields'],
+];
+
+export const CARDDAV_CONTACT_ERROR_STATUS = Object.freeze({
+ ERR_CONTACT_VALIDATION: 400,
+ ERR_CONTACT_UID_MISMATCH: 400,
+ ERR_CONTACT_NOT_FOUND: 404,
+ ERR_ADDRESS_BOOK_NOT_FOUND: 404,
+ ERR_CONTACT_EXISTS: 409,
+ ERR_CARDDAV_CONFLICT: 409,
+ ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_FINAL_FENCE: 503,
+ ERR_CARDDAV_STALE_GENERATION: 503,
+ ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
+ ERR_CARDDAV_PENDING_INTENT: 409,
+ '23505': 409,
+});
+
+export class CardDavConflictError extends Error {
+ constructor(conflictId, options = {}) {
+ super('The CardDAV contact changed before this write completed', options);
+ this.name = 'CardDavConflictError';
+ this.code = 'ERR_CARDDAV_CONFLICT';
+ this.conflictId = conflictId;
+ }
+}
+
+export class CardDavAmbiguousWriteError extends Error {
+ constructor(operation, details = {}, options = {}) {
+ super('The CardDAV write succeeded, but MailFlow could not confirm its local state', options);
+ this.name = 'CardDavAmbiguousWriteError';
+ this.code = 'ERR_CARDDAV_AMBIGUOUS_WRITE';
+ this.operation = operation;
+ Object.assign(this, details);
+ }
+}
+
+function normalizedDocumentContact(document) {
+ const contact = contactFromVCardDocument(document);
+ return { ...contact, primaryEmail: primaryEmail(contact) };
+}
+
+function contactPayload(contact, document, vcard) {
+ return {
+ ...contact,
+ primaryEmail: primaryEmail(contact),
+ document,
+ vcard,
+ etag: localVCardEtag(vcard),
+ remoteSemanticHash: semanticVCardHash(document),
+ localContactHash: localContactHash(contact),
+ };
+}
+
+function draftWithCurrent(current, draft) {
+ const merged = { uid: current.uid };
+ for (const [camel, snake] of DRAFT_FIELDS) {
+ merged[camel] = Object.hasOwn(draft || {}, camel) ? draft[camel] : current[snake];
+ }
+ merged.emails ??= [];
+ merged.phones ??= [];
+ merged.additionalFields ??= [];
+ return merged;
+}
+
+function validateContact(contact) {
+ if (!Array.isArray(contact.emails)) throw typedError('emails must be an array', 'ERR_CONTACT_VALIDATION');
+ if (!Array.isArray(contact.phones)) throw typedError('phones must be an array', 'ERR_CONTACT_VALIDATION');
+ if (!Array.isArray(contact.additionalFields)) {
+ throw typedError('additionalFields must be an array', 'ERR_CONTACT_VALIDATION');
+ }
+ if (!contact.displayName && !primaryEmail(contact)) {
+ throw typedError('A name or email address is required', 'ERR_CONTACT_VALIDATION');
+ }
+}
+
+function normalizedCreateDraft(draft) {
+ const normalized = {
+ displayName: null,
+ firstName: null,
+ lastName: null,
+ emails: [],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ ...(draft || {}),
+ };
+ validateContact(normalized);
+ return normalized;
+}
+
+function payloadForDraft(uid, draft, version = null, retainedDocument = null, options = {}) {
+ const contact = { uid, ...draft };
+ validateContact(contact);
+ const document = retainedDocument
+ ? { ...retainedDocument, version: version ?? retainedDocument.version }
+ : { version: version ?? '3.0', properties: [] };
+ const updatedDocument = overlayContactOnVCard(document, contact, options);
+ const vcard = serializeVCardDocument(updatedDocument);
+ return contactPayload(normalizedDocumentContact(updatedDocument), updatedDocument, vcard);
+}
+
+function parseClientVCard(rawVCard) {
+ try {
+ return parseVCardDocument(rawVCard);
+ } catch (cause) {
+ // A malformed client body is a request error, not a server fault: give it a
+ // typed code so the route maps it to 400 instead of a generic 500.
+ throw typedError(cause.message || 'The vCard body could not be parsed', 'ERR_CONTACT_VALIDATION', { cause });
+ }
+}
+
+function payloadForRawVCard(uid, rawVCard) {
+ if (typeof uid !== 'string' || !uid) throw typedError('Contact UID is required', 'ERR_CONTACT_VALIDATION');
+ if (typeof rawVCard !== 'string') throw typedError('rawVCard must be a string', 'ERR_CONTACT_VALIDATION');
+ const document = parseClientVCard(rawVCard);
+ const contact = normalizedDocumentContact(document);
+ if (contact.uid !== uid) {
+ throw typedError('The vCard UID does not match the resource UID', 'ERR_CONTACT_UID_MISMATCH');
+ }
+ validateContact(contact);
+ return contactPayload(contact, document, rawVCard);
+}
+
+// Confirmed-remote projection after a mapped write. The remote resource owns the vCard
+// UID, so the caller stores the retained remote document verbatim in the mapping
+// while the LOCAL contact keeps its stable local key: the projection is re-keyed onto
+// localUid, its vCard is re-serialized with the local UID (still lossless), and
+// localContactHash reflects the local key. document/remoteSemanticHash stay on the
+// remote UID for the mapping. For a push-origin/create contact localUid equals the
+// remote UID and this is a no-op re-key (unlike payloadForRawVCard, no UID-match check).
+export function confirmedRemotePayload(localUid, remoteVcard) {
+ const document = parseVCardDocument(remoteVcard);
+ const remoteContact = normalizedDocumentContact(document);
+ validateContact(remoteContact);
+ const localContact = { ...remoteContact, uid: localUid };
+ const localVcard = serializeVCardDocument(overlayContactOnVCard(document, localContact));
+ return contactPayload(localContact, document, localVcard);
+}
+
+// A mapped CardDAV-server PUT is a two-tier merge (not a full replacement).
+// - MODELED fields: the client body is authoritative full state — it received them all,
+// so an omitted modeled field is a deliberate removal.
+// - UNMODELED properties: a property-NAME-level merge — names the client body includes
+// win (all of that name's instances, replacing the retained set), names it omits survive
+// from the retained document, so a client that strips properties it does not understand
+// cannot silently delete them. Survivorship is atomic PER GROUP: when a grouped
+// unmodeled property survives, its whole itemN group (including the group's X-ABLABEL and
+// grouped parameters) survives; if the client reuses that group prefix, the survivor is
+// re-prefixed to a fresh itemN.
+// Server-owned metadata (REV/PRODID/…) is never replayed. UID is re-keyed to the retained
+// remote UID.
+// DOCUMENTED LIMITATIONS: deleting an unmodeled property through a DAV client is unsupported
+// (name-absent survives — not silent loss, just not a delete); and the per-name merge is
+// all-or-nothing (a client submitting one instance of a repeated name replaces the whole
+// retained instance set for that name — last-writer-wins per property name).
+function mergedReplacePayload(clientDocument, retainedDocument) {
+ const clientNames = new Set(clientDocument.properties.map(property => property.name));
+ const retainedUidProperty = retainedDocument.properties.find(property => property.name === 'UID');
+ const withUid = withDocumentUid(clientDocument, retainedUidProperty);
+ const usedGroupKeys = new Set(withUid.properties.map(property => groupKey(property.group)).filter(Boolean));
+
+ // Bucket retained non-server-owned properties by group.
+ const retainedGroups = new Map();
+ for (const property of retainedDocument.properties) {
+ if (SERVER_OWNED_PROPERTIES.has(property.name)) continue;
+ const key = groupKey(property.group);
+ if (!retainedGroups.has(key)) retainedGroups.set(key, []);
+ retainedGroups.get(key).push(property);
+ }
+
+ const survivors = [];
+ for (const [key, groupProperties] of retainedGroups) {
+ if (key === '') {
+ // Ungrouped: each surviving unmodeled property (name the client omitted) stands alone.
+ for (const property of groupProperties) {
+ if (!MODELED_PROPERTY_NAMES.has(property.name) && !clientNames.has(property.name)) {
+ survivors.push(property);
+ }
+ }
+ continue;
+ }
+ // A grouped property survives iff the group has an unmodeled member whose name the client
+ // omitted. Then the group survives, but W7: emit ONLY its unmodeled members and its
+ // X-ABLABEL — NEVER a modeled main (ADR/URL/TEL/…) the client already owns via its
+ // full-state modeled fields, which would duplicate that property on the wire (the standard
+ // Apple item1.ADR + item1.X-ABADR + item1.X-ABLABEL layout). ACCEPTED CONSEQUENCE: a
+ // surviving unmodeled annotation may be stale relative to a client-edited modeled main —
+ // consistent with the can't-delete-unmodeled stance (keep it, do not silently lose it).
+ // Keep the group's unmodeled members the client omitted (present-name-wins) plus
+ // its X-ABLABEL (group-scoped, so it labels the survivor even when the client also uses
+ // one for a different group). Modeled mains are never re-emitted.
+ const kept = groupProperties.filter(property => (
+ property.name === 'X-ABLABEL'
+ || (!MODELED_PROPERTY_NAMES.has(property.name) && !clientNames.has(property.name))
+ ));
+ const survives = kept.some(property => property.name !== 'X-ABLABEL');
+ if (!survives) continue;
+ let prefix;
+ if (usedGroupKeys.has(key)) {
+ prefix = allocateItemGroup(usedGroupKeys); // collision with a client group → re-prefix
+ } else {
+ usedGroupKeys.add(key);
+ prefix = groupProperties[0].group;
+ }
+ for (const property of kept) survivors.push({ ...property, group: prefix });
+ }
+
+ const properties = [...withUid.properties, ...survivors]
+ .filter(property => !SERVER_OWNED_PROPERTIES.has(property.name));
+ const outgoing = { ...withUid, properties };
+ return contactPayload(normalizedDocumentContact(outgoing), outgoing, serializeVCardDocument(outgoing));
+}
+
+// Edits to a synced contact overlay onto the retained lossless remote vCard so
+// unmodeled properties (CATEGORIES, KEY, TZ, X-*, …) survive the pull→edit→PUT
+// round-trip. The local contacts.vcard is a lossy re-serialization from the pull
+// (generateVCard keeps only modeled properties); it only backs edits made before
+// a CardDAV mapping exists.
+function retainedEditDocument(contact) {
+ return parseVCardDocument(contact.mapping_vcard ?? contact.vcard);
+}
+
+function mappingBookId(contact) {
+ return contact.mapping_address_book_id ?? (contact.href ? contact.address_book_id : null);
+}
+
+function localBookId(contact) {
+ return contact.local_address_book_id ?? contact.address_book_id;
+}
+
+function remoteBookUrl(contact) {
+ return contact.remote_book_url ?? contact.external_url;
+}
+
+function capability(contact, operation) {
+ return contact[`remote_${operation}_capability`] ?? 'unknown';
+}
+
+function assertWritable(contact, operation) {
+ if (capability(contact, operation) === 'denied') {
+ throw typedError(`This CardDAV address book does not allow ${operation}`, 'ERR_CARDDAV_READ_ONLY');
+ }
+ if (contact.mapping_status === 'conflict' && contact.conflict_id) {
+ throw new CardDavConflictError(contact.conflict_id);
+ }
+}
+
+async function readIntegration(client, userId, lock = false) {
+ if (lock) return lockCarddavIntegration(client, userId, { requireServerUrl: true });
+ const { rows: [integration] } = await client.query(
+ `SELECT id, config
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ `,
+ [userId],
+ );
+ return integration?.config?.serverUrl ? integration : null;
+}
+
+async function readContact(client, userId, { contactId, localAddressBookId, uid }) {
+ const conditions = contactId
+ ? 'c.id = $2'
+ : 'c.address_book_id = $2 AND c.uid = $3';
+ const params = contactId
+ ? [userId, contactId]
+ : [userId, localAddressBookId, uid];
+ const { rows: [contact] } = await client.query(
+ `SELECT c.*,
+ c.address_book_id AS local_address_book_id,
+ mapping.address_book_id AS mapping_address_book_id,
+ mapping.href, mapping.remote_etag, mapping.mapping_status,
+ mapping.vcard AS mapping_vcard,
+ mapping.vcard_version, mapping.remote_semantic_hash,
+ mapping.local_contact_hash, mapping.mapping_revision,
+ mapping.pending_operation, mapping.pending_vcard,
+ mapping.pending_local_hash, mapping.pending_remote_semantic_hash,
+ mapping.pending_started_at, mapping.updated_at AS mapping_updated_at,
+ remote_book.external_url AS remote_book_url,
+ remote_book.remote_create_capability,
+ remote_book.remote_update_capability,
+ remote_book.remote_delete_capability,
+ conflict.id AS conflict_id
+ FROM contacts c
+ JOIN address_books local_book ON local_book.id = c.address_book_id
+ LEFT JOIN carddav_remote_objects mapping
+ ON mapping.local_contact_id = c.id
+ AND mapping.mapping_status <> 'pending_materialization'
+ LEFT JOIN address_books remote_book ON remote_book.id = mapping.address_book_id
+ LEFT JOIN carddav_conflicts conflict
+ ON conflict.address_book_id = mapping.address_book_id
+ AND conflict.href = mapping.href
+ AND conflict.status = 'unresolved'
+ WHERE c.user_id = $1 AND ${conditions}`,
+ params,
+ );
+ return contact || null;
+}
+
+async function readOwnedBook(client, userId, addressBookId) {
+ const { rows: [book] } = await client.query(
+ `SELECT id, source
+ FROM address_books
+ WHERE id = $1 AND user_id = $2`,
+ [addressBookId, userId],
+ );
+ return book || null;
+}
+
+async function credentials(integration) {
+ const config = integration.config;
+ const resolved = await resolveCarddavCredentials(config);
+ const { password } = resolved;
+ if (!password) throw typedError('Stored CardDAV credentials could not be read', 'ERR_CARDDAV_CREDENTIALS');
+ return {
+ serverUrl: config.serverUrl,
+ ...resolved,
+ connectionGeneration: config.connectionGeneration ?? null,
+ };
+}
+
+async function discoverCreateContext(userId, integration) {
+ assertRetryEligible(integration, 'create');
+ const creds = await credentials(integration);
+ try {
+ const books = await discoverAddressBooks({
+ serverUrl: creds.serverUrl,
+ ...resourceCredentials(creds),
+ });
+ return { books, creds };
+ } catch (error) {
+ if (isThrottle(error)) {
+ await recordThrottle(
+ userId,
+ integration.config.connectionGeneration ?? null,
+ error,
+ );
+ }
+ throw error;
+ }
+}
+
+function selectedCreateBook(books) {
+ if (!Array.isArray(books)) throw typedError('A fresh CardDAV book snapshot is required', 'ERR_CARDDAV_BOOKS');
+ const selected = books.find(book => book.capabilities?.create === 'allowed')
+ || books.find(book => (book.capabilities?.create ?? 'unknown') === 'unknown');
+ if (!selected) throw typedError('No writable CardDAV address book was discovered', 'ERR_CARDDAV_READ_ONLY');
+ return selected;
+}
+
+function selectedVCardVersion(book) {
+ const advertised = (book.addressData || []).map(entry => entry.version);
+ return advertised.length > 0 && advertised.every(version => version === '4.0') ? '4.0' : '3.0';
+}
+
+function resourceCredentials(creds) {
+ return {
+ username: creds.username,
+ password: creds.password,
+ allowPrivate: creds.allowPrivate,
+ };
+}
+
+function safeLocation(url) {
+ const parsed = URL.parse(url);
+ return { origin: parsed?.origin ?? null, path: parsed?.pathname ?? null };
+}
+
+function logMutation({ operation, contactId, addressBookId, href, status, retryDecision, conflictTransition, startedAt }) {
+ console.info('[carddav-contact-mutation]', {
+ operation,
+ contactId: contactId ?? null,
+ addressBookId: addressBookId ?? null,
+ ...safeLocation(href),
+ status: status ?? null,
+ retryDecision: retryDecision ?? null,
+ conflictTransition: conflictTransition ?? null,
+ durationMs: Date.now() - startedAt,
+ });
+}
+
+async function ensureLocalBook(client, userId) {
+ const { rows: [book] } = await client.query(
+ `INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Personal')
+ ON CONFLICT (user_id, name) DO UPDATE SET updated_at = address_books.updated_at
+ RETURNING id`,
+ [userId],
+ );
+ return book.id;
+}
+
+export function contactValues(payload) {
+ return [
+ payload.uid,
+ payload.vcard,
+ payload.etag,
+ payload.displayName,
+ payload.firstName,
+ payload.lastName,
+ payload.primaryEmail,
+ JSON.stringify(payload.emails || []),
+ JSON.stringify(payload.phones || []),
+ payload.organization,
+ payload.notes,
+ payload.photoData,
+ JSON.stringify(payload.additionalFields || []),
+ ];
+}
+
+export async function insertContact(client, userId, addressBookId, payload, {
+ returning = API_CONTACT_COLUMNS,
+} = {}) {
+ const { rows: [row] } = await client.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag,
+ display_name, first_name, last_name, primary_email,
+ emails, phones, organization, notes, photo_data, additional_fields, is_auto
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb,false)
+ RETURNING ${returning}`,
+ [addressBookId, userId, ...contactValues(payload)],
+ );
+ return row;
+}
+
+function isLocalContactUidConflict(error) {
+ return error?.code === '23505'
+ && error.constraint === 'contacts_address_book_id_uid_key';
+}
+
+export async function updateStoredContact(
+ client,
+ userId,
+ contactId,
+ payload,
+ expectedEtag = null,
+ { returning = API_CONTACT_COLUMNS, onMissing = null } = {},
+) {
+ const etagFence = expectedEtag == null ? '' : 'AND etag = $16';
+ const params = [...contactValues(payload), contactId, userId];
+ if (expectedEtag != null) params.push(expectedEtag);
+ const { rows: [row] } = await client.query(
+ `UPDATE contacts SET
+ uid = $1, vcard = $2, etag = $3,
+ display_name = $4, first_name = $5, last_name = $6,
+ primary_email = $7, emails = $8::jsonb, phones = $9::jsonb,
+ organization = $10, notes = $11, photo_data = $12,
+ additional_fields = $13::jsonb, is_auto = false, updated_at = NOW()
+ WHERE id = $14 AND user_id = $15 ${etagFence}
+ RETURNING ${returning}`,
+ params,
+ );
+ if (!row) {
+ if (onMissing) throw onMissing();
+ if (expectedEtag != null) {
+ throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH');
+ }
+ throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ }
+ return row;
+}
+
+async function bumpLocalBook(client, userId, addressBookId) {
+ await rotateBookToken(client, userId, addressBookId);
+}
+
+async function createLocal(client, userId, localAddressBookId, payload, expectedAbsent = false) {
+ const addressBookId = localAddressBookId ?? await ensureLocalBook(client, userId);
+ let row;
+ try {
+ row = await insertContact(client, userId, addressBookId, payload);
+ } catch (error) {
+ if (expectedAbsent === true && isLocalContactUidConflict(error)) {
+ throw typedError('The contact already exists', 'ERR_LOCAL_PRECONDITION_FAILED');
+ }
+ throw error;
+ }
+ await bumpLocalBook(client, userId, addressBookId);
+ return row;
+}
+
+async function updateLocal(client, userId, contact, payload, expectedEtag = null) {
+ const row = await updateStoredContact(
+ client,
+ userId,
+ contact.id,
+ payload,
+ expectedEtag,
+ );
+ await bumpLocalBook(client, userId, localBookId(contact));
+ return row;
+}
+
+async function deleteLocal(client, userId, contact, expectedEtag = null) {
+ const etagFence = expectedEtag == null ? '' : 'AND etag = $3';
+ const params = [contact.id, userId];
+ if (expectedEtag != null) params.push(expectedEtag);
+ const deleted = await client.query(
+ `DELETE FROM contacts
+ WHERE id = $1 AND user_id = $2 ${etagFence}
+ RETURNING address_book_id`,
+ params,
+ );
+ if (deleted.rowCount !== 1) {
+ if (expectedEtag != null) {
+ throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH');
+ }
+ throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ }
+ await bumpLocalBook(client, userId, localBookId(contact));
+ return { ok: true };
+}
+
+async function serializable(callback) {
+ return withTransaction(async client => {
+ await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+ return callback(client);
+ });
+}
+
+async function confirmedCommit(operation, details, callback) {
+ try {
+ return await callback();
+ } catch (cause) {
+ throw new CardDavAmbiguousWriteError(operation, details, { cause });
+ }
+}
+
+function sameGeneration(integration, generation) {
+ return (integration?.config?.connectionGeneration ?? null) === generation;
+}
+
+function assertRetryEligible(integration, operation) {
+ const retryAfterAt = activeRetryAfterAt(integration?.config);
+ if (!retryAfterAt) return;
+ throw new CardDavError('CardDAV requests are throttled until Retry-After eligibility', {
+ status: 429,
+ operation,
+ retryAfterAt,
+ });
+}
+
+function isThrottle(error) {
+ return error instanceof CardDavError && error.status === 429;
+}
+
+async function persistRetryAfter(client, integration, retryAfterAt) {
+ if (!retryAfterAt) return;
+ const result = await client.query(
+ `UPDATE user_integrations
+ SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text), true),
+ updated_at = NOW()
+ WHERE id = $1
+ AND config->>'connectionGeneration' IS NOT DISTINCT FROM $3`,
+ [
+ integration.id,
+ retryAfterAt,
+ integration.config.connectionGeneration ?? null,
+ ],
+ );
+ if (result.rowCount !== 1) {
+ throw typedError('CardDAV connection changed after throttling', 'ERR_CARDDAV_FINAL_FENCE');
+ }
+}
+
+async function recordThrottle(userId, generation, error) {
+ if (!error.retryAfterAt) return;
+ await withTransaction(async client => {
+ const integration = await readIntegration(client, userId, true);
+ if (!sameGeneration(integration, generation)) {
+ throw typedError('CardDAV connection changed after throttling', 'ERR_CARDDAV_FINAL_FENCE');
+ }
+ await persistRetryAfter(client, integration, error.retryAfterAt);
+ });
+}
+
+async function rollbackMappedThrottle(userId, preflight, error) {
+ await withTransaction(async client => {
+ const integration = await readIntegration(client, userId, true);
+ if (!sameGeneration(integration, preflight.connectionGeneration)) {
+ throw typedError('CardDAV connection changed after throttling', 'ERR_CARDDAV_FINAL_FENCE');
+ }
+ assertMappingApplied(await restorePendingMutationIntent(client, {
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ expectedMappingRevision: preflight.mappingRevision,
+ operation: preflight.pendingOperation,
+ pendingVCard: preflight.pendingVCard,
+ pendingLocalHash: preflight.pendingLocalHash,
+ pendingRemoteSemanticHash: preflight.pendingRemoteSemanticHash,
+ pendingStartedAt: preflight.pendingStartedAt,
+ previousMappingStatus: preflight.previousMappingStatus,
+ previousMappingRevision: preflight.previousMappingRevision,
+ previousUpdatedAt: preflight.previousMappingUpdatedAt,
+ }));
+ await persistRetryAfter(client, integration, error.retryAfterAt);
+ });
+}
+
+async function lockMutationState(client, userId, preflight) {
+ const integration = await readIntegration(client, userId, true);
+ const mapping = await lockCarddavMapping(client, {
+ userId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ });
+ return { integration, mapping };
+}
+
+function finalFenceMatches(state, preflight) {
+ return sameGeneration(state.integration, preflight.connectionGeneration)
+ && state.mapping
+ && String(state.mapping.mapping_revision) === String(preflight.mappingRevision);
+}
+
+function assertFinalFence(state, preflight) {
+ if (!finalFenceMatches(state, preflight)) {
+ throw typedError('CardDAV mapping changed after the remote write', 'ERR_CARDDAV_FINAL_FENCE');
+ }
+}
+
+function assertMappingApplied(result) {
+ if (result.ok) return result;
+ throw typedError('CardDAV mapping changed after the remote write', 'ERR_CARDDAV_FINAL_FENCE');
+}
+
+function storedLocalContactHash(contact) {
+ return localContactHash(draftWithCurrent(contact, {}));
+}
+
+async function commitRemoteCreate({ userId, preflight, localAddressBookId, book, remote }) {
+ return confirmedCommit('create', {
+ contactId: preflight.contactId ?? null,
+ href: remote.href,
+ }, async () => {
+ const payload = payloadForRawVCard(preflight.uid, remote.vcard);
+ return serializable(async client => {
+ const integration = await readIntegration(client, userId, true);
+ if (!sameGeneration(integration, preflight.connectionGeneration)) {
+ throw typedError('CardDAV connection changed after the remote write', 'ERR_CARDDAV_FINAL_FENCE');
+ }
+ const localBook = localAddressBookId ?? await ensureLocalBook(client, userId);
+ const remoteBook = await persistDiscoveredBook(client, { userId, ...book });
+ const row = preflight.contactId
+ ? await updateStoredContact(
+ client,
+ userId,
+ preflight.contactId,
+ payload,
+ preflight.localEtag,
+ )
+ : await insertContact(client, userId, localBook, payload);
+ assertMappingApplied(await applyConfirmedRemoteContact(client, {
+ addressBookId: remoteBook.id,
+ href: remote.href,
+ expectedMappingRevision: null,
+ remoteEtag: remote.etag,
+ vcard: remote.vcard,
+ primaryEmail: payload.primaryEmail,
+ localContactId: row.id,
+ vcardVersion: payload.document.version,
+ remoteSemanticHash: payload.remoteSemanticHash,
+ localContactHash: payload.localContactHash,
+ }));
+ await bumpLocalBook(client, userId, localBook);
+ return row;
+ });
+ });
+}
+
+function isNotFound(error) {
+ return error instanceof CardDavError && error.status === 404;
+}
+
+export async function fetchCreated({ book, href, uid, creds }) {
+ try {
+ const remote = await fetchCardResource({
+ url: book.url,
+ href,
+ ...resourceCredentials(creds),
+ });
+ payloadForRawVCard(uid, remote.vcard);
+ return { kind: 'found', remote };
+ } catch (cause) {
+ if (isNotFound(cause)) return { kind: 'missing' };
+ return { kind: 'unknown', cause };
+ }
+}
+
+function ambiguousCreate(book, href, outcome) {
+ const cause = outcome.kind === 'unknown'
+ ? outcome.cause
+ : new CardDavError('Created CardDAV resource was not found', {
+ status: 404,
+ operation: 'fetch',
+ });
+ return new CardDavAmbiguousWriteError('create', {
+ href: new URL(href, book.url).href,
+ }, { cause });
+}
+
+async function canonicalCreate({ book, uid, payload, creds }) {
+ const href = `${uid}.vcf`;
+ const options = {
+ url: book.url,
+ href,
+ vcard: payload.vcard,
+ ...resourceCredentials(creds),
+ };
+ let firstPut = 'accepted';
+ try {
+ await putCardResource(options);
+ } catch (error) {
+ if (error instanceof CardDavError && error.status != null) throw error;
+ firstPut = 'ambiguous';
+ }
+
+ switch (firstPut) {
+ case 'accepted': {
+ const final = await fetchCreated({ book, href, uid, creds });
+ if (final.kind === 'found') {
+ return { remote: final.remote, retryDecision: 'not-retried' };
+ }
+ throw ambiguousCreate(book, href, final);
+ }
+ case 'ambiguous': {
+ const recovery = await fetchCreated({ book, href, uid, creds });
+ switch (recovery.kind) {
+ case 'found':
+ return { remote: recovery.remote, retryDecision: 'recovered-after-ambiguous' };
+ case 'unknown':
+ throw ambiguousCreate(book, href, recovery);
+ case 'missing':
+ break;
+ }
+
+ let retryDecision = 'retried-after-confirmed-missing';
+ try {
+ await putCardResource(options);
+ } catch (retryError) {
+ if (
+ retryError instanceof CardDavError
+ && retryError.status != null
+ && retryError.status !== 412
+ ) {
+ throw retryError;
+ }
+ retryDecision = 'recovered-after-bounded-retry';
+ }
+ const final = await fetchCreated({ book, href, uid, creds });
+ switch (final.kind) {
+ case 'found':
+ return { remote: final.remote, retryDecision };
+ case 'missing':
+ case 'unknown':
+ throw ambiguousCreate(book, href, final);
+ }
+ break;
+ }
+ }
+ throw new Error('Unreachable create outcome');
+}
+
+async function remoteCreate({ userId, preflight, localAddressBookId, book, payload, creds }) {
+ const activeCredentials = creds || await credentials(preflight.integration);
+ const startedAt = Date.now();
+ try {
+ const { remote, retryDecision } = await canonicalCreate({
+ book,
+ uid: preflight.uid,
+ payload,
+ creds: activeCredentials,
+ });
+ const row = await commitRemoteCreate({ userId, preflight, localAddressBookId, book, remote });
+ logMutation({
+ operation: 'create', contactId: row.id, addressBookId: null,
+ href: remote.href, status: 200, retryDecision, startedAt,
+ });
+ return row;
+ } catch (error) {
+ if (isThrottle(error)) {
+ await recordThrottle(userId, preflight.connectionGeneration, error);
+ }
+ if (
+ preflight.expectedAbsent === true
+ && error instanceof CardDavError
+ && error.status === 412
+ ) {
+ logMutation({
+ operation: 'create', contactId: preflight.contactId, addressBookId: null,
+ href: book.url, status: 412, retryDecision: 'not-retried', startedAt,
+ });
+ throw typedError('The contact already exists', 'ERR_LOCAL_PRECONDITION_FAILED');
+ }
+ if (error instanceof CardDavError && (error.status === 403 || error.status === 405)) {
+ await recordDeniedCreate(userId, preflight.connectionGeneration, book);
+ }
+ logMutation({
+ operation: 'create', contactId: preflight.contactId, addressBookId: null,
+ href: book.url, status: error.status, retryDecision: 'not-retried', startedAt,
+ });
+ throw error;
+ }
+}
+
+async function recordDeniedCreate(userId, generation, book) {
+ await withTransaction(async client => {
+ const integration = await readIntegration(client, userId, true);
+ if (!sameGeneration(integration, generation)) return;
+ const storedBook = await persistDiscoveredBook(client, {
+ userId,
+ ...book,
+ preserveCapabilities: true,
+ });
+ await persistDeniedBookCapability(client, {
+ userId,
+ addressBookId: storedBook.id,
+ capability: 'create',
+ });
+ });
+}
+
+async function recordDeniedMapped(userId, preflight, operation) {
+ await withTransaction(async client => {
+ const state = await lockMutationState(client, userId, preflight);
+ if (!finalFenceMatches(state, preflight)) return;
+ await persistDeniedBookCapability(client, {
+ userId,
+ addressBookId: preflight.addressBookId,
+ capability: operation,
+ });
+ });
+}
+
+async function latestRemote(preflight, creds) {
+ try {
+ const remote = await fetchCardResource({
+ url: preflight.url,
+ href: preflight.href,
+ ...resourceCredentials(creds),
+ });
+ return { ...remote, tombstone: false };
+ } catch (error) {
+ if (isNotFound(error)) {
+ return { href: preflight.href, etag: null, vcard: null, tombstone: true };
+ }
+ throw error;
+ }
+}
+
+async function commitPendingRecovery({ userId, preflight, remote }) {
+ let conflict;
+ const value = await serializable(async client => {
+ const state = await lockMutationState(client, userId, preflight);
+ assertFinalFence(state, preflight);
+ const intent = state.mapping;
+ if (!intent.pending_operation) {
+ throw typedError('The pending CardDAV intent is missing', 'ERR_CARDDAV_FINAL_FENCE');
+ }
+ const contact = await readContact(client, userId, { contactId: preflight.contactId });
+ const currentLocalHash = contact ? storedLocalContactHash(contact) : null;
+ const localMatches = currentLocalHash === intent.pending_local_hash;
+ const remotePayload = remote.payload ?? null;
+ const remoteMatches = intent.pending_operation === 'delete'
+ ? remote.tombstone
+ : !remote.tombstone
+ && remotePayload.remoteSemanticHash === intent.pending_remote_semantic_hash;
+
+ if (remoteMatches && localMatches) {
+ if (intent.pending_operation === 'delete') {
+ assertMappingApplied(await applyRemoteTombstone(client, {
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ expectedMappingRevision: preflight.mappingRevision,
+ }));
+ const deleted = await client.query(
+ 'DELETE FROM contacts WHERE id = $1 AND user_id = $2',
+ [preflight.contactId, userId],
+ );
+ if (deleted.rowCount !== 1) {
+ throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ }
+ await bumpLocalBook(client, userId, preflight.localAddressBookId);
+ return { ok: true };
+ }
+ const row = await updateStoredContact(
+ client,
+ userId,
+ preflight.contactId,
+ remotePayload,
+ );
+ assertMappingApplied(await applyConfirmedRemoteContact(client, {
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ expectedMappingRevision: preflight.mappingRevision,
+ remoteEtag: remote.etag,
+ vcard: remote.vcard,
+ primaryEmail: remotePayload.primaryEmail,
+ localContactId: preflight.contactId,
+ vcardVersion: remotePayload.document.version,
+ remoteSemanticHash: remotePayload.remoteSemanticHash,
+ localContactHash: remotePayload.localContactHash,
+ }));
+ await bumpLocalBook(client, userId, preflight.localAddressBookId);
+ return row;
+ }
+
+ const preserveCurrentLocal = !localMatches && contact;
+ // A later keep-mailflow resolution pushes this snapshot verbatim, so preserve the
+ // current local contact losslessly onto the retained remote vCard — and keep the
+ // retained REMOTE UID (preserveDocumentUid), never the local key, so
+ // resolution can't rewrite the remote resource's UID. The pending-intent branch is
+ // already the overlaid payload vCard (which already carries the remote UID).
+ const localVCard = preserveCurrentLocal
+ ? presentedVCard(contact, { preserveDocumentUid: true })
+ : intent.pending_vcard;
+ const result = await refreshUnresolvedConflict(client, {
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ expectedMappingRevision: preflight.mappingRevision,
+ userId,
+ baseLocalHash: intent.local_contact_hash,
+ remoteEtag: remote.etag,
+ localVCard,
+ remoteVCard: remote.vcard,
+ localTombstone: preserveCurrentLocal ? false : intent.pending_operation === 'delete',
+ remoteTombstone: remote.tombstone,
+ });
+ assertMappingApplied(result);
+ conflict = result.conflict;
+ return null;
+ });
+ if (conflict) throw new CardDavConflictError(conflict.id);
+ return value;
+}
+
+async function recoverMappedIntent(userId, preflight, activeCredentials) {
+ const creds = activeCredentials || await credentials(preflight.integration);
+ let remote;
+ try {
+ remote = await latestRemote(preflight, creds);
+ } catch (cause) {
+ if (isThrottle(cause)) {
+ await recordThrottle(userId, preflight.connectionGeneration, cause);
+ }
+ throw new CardDavAmbiguousWriteError(preflight.pendingOperation, {
+ contactId: preflight.contactId,
+ href: preflight.href,
+ }, { cause });
+ }
+ if (!remote.tombstone) {
+ remote = { ...remote, payload: confirmedRemotePayload(preflight.uid, remote.vcard) };
+ }
+ try {
+ return await commitPendingRecovery({ userId, preflight, remote });
+ } catch (error) {
+ if (error instanceof CardDavConflictError) throw error;
+ throw new CardDavAmbiguousWriteError(preflight.pendingOperation, {
+ contactId: preflight.contactId,
+ href: preflight.href,
+ }, { cause: error });
+ }
+}
+
+async function mappedUpdate(userId, preflight, payload) {
+ const creds = await credentials(preflight.integration);
+ const startedAt = Date.now();
+ let writeError;
+ if (!preflight.recoveryOnly) {
+ try {
+ await putCardResource({
+ url: preflight.url,
+ href: preflight.href,
+ etag: preflight.remoteEtag,
+ vcard: payload.vcard,
+ ...resourceCredentials(creds),
+ });
+ } catch (error) {
+ if (isThrottle(error)) {
+ await rollbackMappedThrottle(userId, preflight, error);
+ logMutation({
+ operation: preflight.pendingOperation,
+ contactId: preflight.contactId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ status: error.status,
+ retryDecision: 'scheduled-after-throttle',
+ startedAt,
+ });
+ throw error;
+ }
+ writeError = error;
+ if (error instanceof CardDavError && (error.status === 403 || error.status === 405)) {
+ await recordDeniedMapped(userId, preflight, 'update');
+ }
+ }
+ }
+ try {
+ const row = await recoverMappedIntent(userId, preflight, creds);
+ logMutation({
+ operation: preflight.pendingOperation,
+ contactId: preflight.contactId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ status: writeError?.status ?? 200,
+ retryDecision: preflight.recoveryOnly ? 'read-only-recovery' : 'not-retried',
+ startedAt,
+ });
+ return row;
+ } catch (error) {
+ logMutation({
+ operation: preflight.pendingOperation,
+ contactId: preflight.contactId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ status: writeError?.status,
+ retryDecision: 'not-retried',
+ conflictTransition: error instanceof CardDavConflictError
+ ? 'created-or-refreshed'
+ : null,
+ startedAt,
+ });
+ throw error;
+ }
+}
+
+async function mappedDelete(userId, preflight) {
+ const creds = await credentials(preflight.integration);
+ const startedAt = Date.now();
+ let writeError;
+ if (!preflight.recoveryOnly) {
+ try {
+ await deleteCardResource({
+ url: preflight.url,
+ href: preflight.href,
+ etag: preflight.remoteEtag,
+ ...resourceCredentials(creds),
+ });
+ } catch (error) {
+ if (isThrottle(error)) {
+ await rollbackMappedThrottle(userId, preflight, error);
+ logMutation({
+ operation: preflight.pendingOperation,
+ contactId: preflight.contactId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ status: error.status,
+ retryDecision: 'scheduled-after-throttle',
+ startedAt,
+ });
+ throw error;
+ }
+ writeError = error;
+ if (error instanceof CardDavError && (error.status === 403 || error.status === 405)) {
+ await recordDeniedMapped(userId, preflight, 'delete');
+ }
+ }
+ }
+ try {
+ const result = await recoverMappedIntent(userId, preflight, creds);
+ logMutation({
+ operation: preflight.pendingOperation,
+ contactId: preflight.contactId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ status: writeError?.status ?? 204,
+ retryDecision: preflight.recoveryOnly ? 'read-only-recovery' : 'not-retried',
+ startedAt,
+ });
+ return result;
+ } catch (error) {
+ logMutation({
+ operation: preflight.pendingOperation,
+ contactId: preflight.contactId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ status: writeError?.status,
+ retryDecision: 'not-retried',
+ conflictTransition: error instanceof CardDavConflictError
+ ? 'created-or-refreshed'
+ : null,
+ startedAt,
+ });
+ throw error;
+ }
+}
+
+function mappedPreflight(integration, contact) {
+ return {
+ integration,
+ connectionGeneration: integration.config.connectionGeneration ?? null,
+ contactId: contact.id,
+ uid: contact.uid,
+ localAddressBookId: localBookId(contact),
+ addressBookId: mappingBookId(contact),
+ url: remoteBookUrl(contact),
+ href: contact.href,
+ remoteEtag: contact.remote_etag,
+ mappingRevision: contact.mapping_revision,
+ pendingOperation: contact.pending_operation ?? null,
+ pendingVCard: contact.pending_vcard ?? null,
+ pendingLocalHash: contact.pending_local_hash ?? null,
+ pendingRemoteSemanticHash: contact.pending_remote_semantic_hash ?? null,
+ pendingStartedAt: contact.pending_started_at ?? null,
+ mappingStatus: contact.mapping_status,
+ mappingUpdatedAt: contact.mapping_updated_at,
+ };
+}
+
+async function prepareMappedMutation(client, userId, integration, contact, operation, payload = null) {
+ const preflight = mappedPreflight(integration, contact);
+ if (preflight.pendingOperation) {
+ const sameIntent = preflight.pendingOperation === operation
+ && (operation === 'delete'
+ || payload?.remoteSemanticHash === preflight.pendingRemoteSemanticHash);
+ if (!sameIntent) {
+ throw typedError(
+ 'A CardDAV mutation is already awaiting confirmation',
+ 'ERR_CARDDAV_PENDING_INTENT',
+ { operation: preflight.pendingOperation },
+ );
+ }
+ return { ...preflight, recoveryOnly: true };
+ }
+ const pendingLocalHash = storedLocalContactHash(contact);
+ const applied = await persistPendingMutationIntent(client, {
+ userId,
+ addressBookId: preflight.addressBookId,
+ href: preflight.href,
+ expectedMappingRevision: preflight.mappingRevision,
+ operation,
+ pendingVCard: payload?.vcard ?? null,
+ pendingLocalHash,
+ pendingRemoteSemanticHash: payload?.remoteSemanticHash ?? null,
+ });
+ assertMappingApplied(applied);
+ return {
+ ...preflight,
+ previousMappingRevision: preflight.mappingRevision,
+ previousMappingStatus: preflight.mappingStatus,
+ previousMappingUpdatedAt: preflight.mappingUpdatedAt,
+ mappingRevision: applied.mappingRevision,
+ pendingOperation: operation,
+ pendingVCard: payload?.vcard ?? null,
+ pendingLocalHash,
+ pendingRemoteSemanticHash: payload?.remoteSemanticHash ?? null,
+ pendingStartedAt: applied.pendingStartedAt,
+ payload,
+ };
+}
+
+async function supportsPendingIntentSchema() {
+ const { rows: [schema] } = await query(
+ `SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'carddav_remote_objects'
+ AND column_name = 'pending_operation'
+ ) AS supports_pending_intent`,
+ );
+ return schema?.supports_pending_intent === true;
+}
+
+export async function recoverPendingCarddavMutations(userId, { integration, creds } = {}) {
+ if (!integration?.config?.serverUrl) {
+ throw typedError('CardDAV is not connected', 'ERR_CARDDAV_NOT_CONNECTED');
+ }
+ assertRetryEligible(integration, 'sync');
+ if (!await supportsPendingIntentSchema()) return [];
+ const { rows } = await query(
+ `SELECT c.*,
+ c.address_book_id AS local_address_book_id,
+ mapping.address_book_id AS mapping_address_book_id,
+ mapping.href, mapping.remote_etag, mapping.mapping_status,
+ mapping.vcard_version, mapping.remote_semantic_hash,
+ mapping.local_contact_hash, mapping.mapping_revision::text,
+ mapping.pending_operation, mapping.pending_vcard,
+ mapping.pending_local_hash, mapping.pending_remote_semantic_hash,
+ mapping.pending_started_at, mapping.updated_at AS mapping_updated_at,
+ remote_book.external_url AS remote_book_url
+ FROM carddav_remote_objects mapping
+ JOIN contacts c ON c.id = mapping.local_contact_id
+ JOIN address_books remote_book ON remote_book.id = mapping.address_book_id
+ WHERE c.user_id = $1 AND mapping.pending_operation IS NOT NULL
+ ORDER BY mapping.address_book_id, mapping.href`,
+ [userId],
+ );
+ const recovered = [];
+ for (const contact of rows) {
+ const preflight = { ...mappedPreflight(integration, contact), recoveryOnly: true };
+ try {
+ recovered.push(await recoverMappedIntent(userId, preflight, creds));
+ } catch (error) {
+ if (!(error instanceof CardDavConflictError)) throw error;
+ recovered.push({ conflictId: error.conflictId });
+ }
+ }
+ return recovered;
+}
+
+export async function createContact(userId, draft) {
+ const validatedDraft = normalizedCreateDraft(draft);
+ const uid = randomUUID();
+ const preflight = await withTransaction(async client => ({
+ integration: await readIntegration(client, userId),
+ uid,
+ }));
+ if (!preflight.integration) {
+ const payload = payloadForDraft(uid, validatedDraft);
+ return withTransaction(client => createLocal(client, userId, null, payload));
+ }
+ const { books, creds } = await discoverCreateContext(userId, preflight.integration);
+ const selected = selectedCreateBook(books);
+ const payload = payloadForDraft(uid, validatedDraft, selectedVCardVersion(selected));
+ return remoteCreate({
+ userId,
+ preflight: {
+ ...preflight,
+ connectionGeneration: preflight.integration.config.connectionGeneration ?? null,
+ },
+ localAddressBookId: null,
+ book: selected,
+ payload,
+ creds,
+ });
+}
+
+export async function updateContact(userId, contactId, draft) {
+ let localResult;
+ const prepared = await withTransaction(async client => {
+ const integration = await readIntegration(client, userId);
+ if (integration) assertRetryEligible(integration, 'update');
+ const contact = await readContact(client, userId, { contactId });
+ if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ if (!integration || !contact.href) {
+ const payload = payloadForDraft(
+ contact.uid,
+ draftWithCurrent(contact, draft),
+ undefined,
+ parseVCardDocument(contact.vcard),
+ );
+ localResult = await updateLocal(client, userId, contact, payload);
+ return null;
+ }
+ assertWritable(contact, 'update');
+ // The outgoing document preserves the retained remote UID: UID is
+ // remote-owned identity, so a Mailflow edit never rewrites it on the server.
+ const payload = payloadForDraft(
+ contact.uid,
+ draftWithCurrent(contact, draft),
+ undefined,
+ retainedEditDocument(contact),
+ { preserveDocumentUid: true },
+ );
+ return prepareMappedMutation(client, userId, integration, contact, 'update', payload);
+ });
+ return prepared ? mappedUpdate(userId, prepared, prepared.payload) : localResult;
+}
+
+export async function deleteContact(userId, contactId) {
+ let localResult;
+ const prepared = await withTransaction(async client => {
+ const integration = await readIntegration(client, userId);
+ if (integration) assertRetryEligible(integration, 'delete');
+ const contact = await readContact(client, userId, { contactId });
+ if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ if (!integration || !contact.href) {
+ localResult = await deleteLocal(client, userId, contact);
+ return null;
+ }
+ assertWritable(contact, 'delete');
+ return prepareMappedMutation(client, userId, integration, contact, 'delete');
+ });
+ return prepared ? mappedDelete(userId, prepared) : localResult;
+}
+
+export async function exportExistingContact(userId, contactId, { books, expectedGeneration }) {
+ const selected = selectedCreateBook(books);
+ const prepared = await withTransaction(async client => {
+ const integration = await readIntegration(client, userId);
+ if (!integration) throw typedError('CardDAV is not connected', 'ERR_CARDDAV_NOT_CONNECTED');
+ assertRetryEligible(integration, 'create');
+ const actualGeneration = integration.config.connectionGeneration ?? null;
+ if (expectedGeneration !== undefined && actualGeneration !== expectedGeneration) {
+ throw typedError(
+ 'The CardDAV connection changed before export',
+ 'ERR_CARDDAV_STALE_GENERATION',
+ {
+ expectedConnectionGeneration: expectedGeneration,
+ actualConnectionGeneration: actualGeneration,
+ },
+ );
+ }
+ const contact = await readContact(client, userId, { contactId });
+ if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ if (contact.href) throw typedError('Contact already has a CardDAV mapping', 'ERR_CARDDAV_ALREADY_MAPPED');
+ return {
+ integration,
+ connectionGeneration: integration.config.connectionGeneration ?? null,
+ uid: contact.uid,
+ contactId: contact.id,
+ localAddressBookId: localBookId(contact),
+ localEtag: contact.etag,
+ payload: payloadForDraft(
+ contact.uid,
+ draftWithCurrent(contact, {}),
+ selectedVCardVersion(selected),
+ parseVCardDocument(contact.vcard),
+ ),
+ };
+ });
+ return remoteCreate({
+ userId,
+ preflight: prepared,
+ localAddressBookId: prepared.localAddressBookId,
+ book: selected,
+ payload: prepared.payload,
+ });
+}
+
+export async function createContactFromVCard(userId, {
+ localAddressBookId,
+ uid,
+ rawVCard,
+ expectedAbsent,
+}) {
+ const payload = payloadForRawVCard(uid, rawVCard);
+ const requiresAbsent = expectedAbsent === true;
+ const prepared = await withTransaction(async client => {
+ const book = await readOwnedBook(client, userId, localAddressBookId);
+ if (!book) throw typedError('Address book not found', 'ERR_ADDRESS_BOOK_NOT_FOUND');
+ const existing = await readContact(client, userId, { localAddressBookId, uid });
+ if (existing && requiresAbsent) {
+ throw typedError('The contact already exists', 'ERR_LOCAL_PRECONDITION_FAILED');
+ }
+ if (existing) throw typedError('Contact already exists', 'ERR_CONTACT_EXISTS');
+ const integration = await readIntegration(client, userId);
+ if (!integration) {
+ return {
+ local: await createLocal(
+ client,
+ userId,
+ localAddressBookId,
+ payload,
+ requiresAbsent,
+ ),
+ };
+ }
+ return {
+ integration,
+ connectionGeneration: integration.config.connectionGeneration ?? null,
+ uid,
+ expectedAbsent: requiresAbsent,
+ };
+ });
+ if (prepared.local) return prepared.local;
+ const { books, creds } = await discoverCreateContext(userId, prepared.integration);
+ const selected = selectedCreateBook(books);
+ return remoteCreate({
+ userId,
+ preflight: prepared,
+ localAddressBookId,
+ book: selected,
+ payload,
+ creds,
+ });
+}
+
+export async function replaceContactFromVCard(userId, {
+ localAddressBookId,
+ uid,
+ rawVCard,
+ expectedLocalEtag,
+}) {
+ const clientPayload = payloadForRawVCard(uid, rawVCard);
+ let localResult;
+ const prepared = await withTransaction(async client => {
+ const integration = await readIntegration(client, userId);
+ if (integration) assertRetryEligible(integration, 'update');
+ const contact = await readContact(client, userId, { localAddressBookId, uid });
+ if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ if (contact.etag !== expectedLocalEtag) {
+ throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH');
+ }
+ if (!integration || !contact.href) {
+ // Unmapped/local-only: the client's document is the complete resource.
+ localResult = await updateLocal(client, userId, contact, clientPayload, expectedLocalEtag);
+ return null;
+ }
+ assertWritable(contact, 'update');
+ // Mapped: two-tier merge. Modeled fields are full-state from the client;
+ // unmodeled properties merge by name (present-wins, absent survives); the remote UID
+ // is preserved. The route requires a conditional (If-Match) request for this path.
+ const payload = mergedReplacePayload(clientPayload.document, retainedEditDocument(contact));
+ return prepareMappedMutation(client, userId, integration, contact, 'update', payload);
+ });
+ return prepared ? mappedUpdate(userId, prepared, prepared.payload) : localResult;
+}
+
+export async function deleteContactFromVCard(userId, {
+ localAddressBookId,
+ uid,
+ expectedLocalEtag,
+}) {
+ let localResult;
+ const prepared = await withTransaction(async client => {
+ const integration = await readIntegration(client, userId);
+ if (integration) assertRetryEligible(integration, 'delete');
+ const contact = await readContact(client, userId, { localAddressBookId, uid });
+ if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND');
+ if (contact.etag !== expectedLocalEtag) {
+ throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH');
+ }
+ if (!integration || !contact.href) {
+ localResult = await deleteLocal(client, userId, contact, expectedLocalEtag);
+ return null;
+ }
+ assertWritable(contact, 'delete');
+ return prepareMappedMutation(client, userId, integration, contact, 'delete');
+ });
+ return prepared ? mappedDelete(userId, prepared) : localResult;
+}
diff --git a/backend/src/services/carddavContactService.test.js b/backend/src/services/carddavContactService.test.js
new file mode 100644
index 0000000..50bce3a
--- /dev/null
+++ b/backend/src/services/carddavContactService.test.js
@@ -0,0 +1,1371 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ localContactHash,
+ parseVCardDocument,
+ semanticVCardHash,
+} from '../utils/vcardProperties.js';
+
+const runtime = vi.hoisted(() => ({ inTransaction: false, events: [] }));
+const mocks = vi.hoisted(() => ({
+ decrypt: vi.fn(value => value),
+ deleteCardResource: vi.fn(),
+ discoverAddressBooks: vi.fn(),
+ fetchCardResource: vi.fn(),
+ getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })),
+ putCardResource: vi.fn(),
+ query: vi.fn(),
+ randomUUID: vi.fn(),
+ withTransaction: vi.fn(),
+}));
+
+vi.mock('node:crypto', async importOriginal => ({
+ ...await importOriginal(),
+ randomUUID: mocks.randomUUID,
+}));
+vi.mock('./db.js', () => ({ query: mocks.query, withTransaction: mocks.withTransaction }));
+vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt }));
+vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy }));
+vi.mock('./carddavClient.js', async importOriginal => ({
+ ...await importOriginal(),
+ deleteCardResource: mocks.deleteCardResource,
+ discoverAddressBooks: mocks.discoverAddressBooks,
+ fetchCardResource: mocks.fetchCardResource,
+ putCardResource: mocks.putCardResource,
+}));
+const {
+ CARDDAV_CONTACT_ERROR_STATUS,
+ CardDavAmbiguousWriteError,
+ CardDavConflictError,
+ createContact,
+ createContactFromVCard,
+ deleteContact,
+ deleteContactFromVCard,
+ exportExistingContact,
+ fetchCreated,
+ recoverPendingCarddavMutations,
+ replaceContactFromVCard,
+ updateContact,
+} = await import('./carddavContactService.js');
+const { CardDavError } = await vi.importActual('./carddavTransport.js');
+
+const USER_ID = '00000000-0000-4000-8000-000000000001';
+const BOOK_ID = '00000000-0000-4000-8000-000000000002';
+const LOCAL_BOOK_ID = '00000000-0000-4000-8000-000000000003';
+const CONTACT_ID = '00000000-0000-4000-8000-000000000004';
+const UID = '00000000-0000-4000-8000-000000000005';
+const CONFLICT_ID = '00000000-0000-4000-8000-000000000006';
+const BOOK_URL = 'https://dav.example.test/books/default/';
+const HREF = `${BOOK_URL}${UID}.vcf`;
+const BASE_VCARD = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:Before\r\nEND:VCARD\r\n`;
+const CANONICAL_VCARD = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:After\r\nEND:VCARD\r\n`;
+
+it('exports the shared CardDAV contact error status mappings', () => {
+ expect(CARDDAV_CONTACT_ERROR_STATUS).toEqual({
+ ERR_CONTACT_VALIDATION: 400,
+ ERR_CONTACT_UID_MISMATCH: 400,
+ ERR_CONTACT_NOT_FOUND: 404,
+ ERR_ADDRESS_BOOK_NOT_FOUND: 404,
+ ERR_CONTACT_EXISTS: 409,
+ ERR_CARDDAV_CONFLICT: 409,
+ ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_FINAL_FENCE: 503,
+ ERR_CARDDAV_STALE_GENERATION: 503,
+ ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
+ ERR_CARDDAV_PENDING_INTENT: 409,
+ '23505': 409,
+ });
+});
+
+const draft = (overrides = {}) => ({
+ displayName: 'After',
+ firstName: null,
+ lastName: null,
+ emails: [{ value: 'after@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ additionalFields: [],
+ ...overrides,
+});
+
+const confirmedRow = (overrides = {}) => ({
+ id: CONTACT_ID,
+ uid: UID,
+ display_name: 'After',
+ first_name: null,
+ last_name: null,
+ primary_email: 'after@example.test',
+ emails: draft().emails,
+ phones: [],
+ organization: null,
+ notes: null,
+ photo_data: null,
+ additional_fields: [],
+ is_auto: false,
+ send_count: 0,
+ last_sent: null,
+ etag: 'local-etag-after',
+ created_at: new Date('2026-07-12T00:00:00Z'),
+ updated_at: new Date('2026-07-12T00:00:00Z'),
+ ...overrides,
+});
+
+const integration = {
+ id: '00000000-0000-4000-8000-000000000007',
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'secret',
+ connectionGeneration: 'generation-1',
+ },
+};
+
+const mapped = (overrides = {}) => ({
+ id: CONTACT_ID,
+ uid: UID,
+ address_book_id: BOOK_ID,
+ user_id: USER_ID,
+ vcard: BASE_VCARD,
+ etag: 'local-etag-before',
+ display_name: 'Before',
+ first_name: null,
+ last_name: null,
+ primary_email: 'before@example.test',
+ emails: [{ value: 'before@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photo_data: null,
+ additional_fields: [],
+ source: 'carddav',
+ external_url: BOOK_URL,
+ remote_create_capability: 'allowed',
+ remote_update_capability: 'allowed',
+ remote_delete_capability: 'allowed',
+ href: HREF,
+ remote_etag: '"remote-1"',
+ mapping_status: 'synced',
+ vcard_version: '3.0',
+ remote_semantic_hash: 'remote-hash-before',
+ local_contact_hash: 'local-hash-before',
+ mapping_revision: '3',
+ ...overrides,
+});
+
+const book = (overrides = {}) => ({
+ url: BOOK_URL,
+ displayName: 'Default',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ addressData: [{ contentType: 'text/vcard', version: '3.0' }],
+ ...overrides,
+});
+
+const mappedLocalHash = (contact = mapped()) => localContactHash({
+ uid: contact.uid,
+ displayName: contact.display_name,
+ firstName: contact.first_name,
+ lastName: contact.last_name,
+ emails: contact.emails,
+ phones: contact.phones,
+ organization: contact.organization,
+ notes: contact.notes,
+ photoData: contact.photo_data,
+ additionalFields: contact.additional_fields,
+});
+
+const pendingUpdate = (vcard, overrides = {}) => mapped({
+ mapping_status: 'pending_push',
+ mapping_revision: '4',
+ pending_operation: 'update',
+ pending_vcard: vcard,
+ pending_local_hash: mappedLocalHash(),
+ pending_remote_semantic_hash: semanticVCardHash(parseVCardDocument(vcard)),
+ pending_started_at: new Date('2026-07-12T12:00:00.000Z'),
+ ...overrides,
+});
+
+const pendingDelete = (overrides = {}) => mapped({
+ mapping_status: 'pending_push',
+ mapping_revision: '4',
+ pending_operation: 'delete',
+ pending_vcard: null,
+ pending_local_hash: mappedLocalHash(),
+ pending_remote_semantic_hash: null,
+ pending_started_at: new Date('2026-07-12T12:00:00.000Z'),
+ ...overrides,
+});
+
+function protocolMock(mock, result) {
+ mock.mockImplementation(async () => {
+ expect(runtime.inTransaction).toBe(false);
+ runtime.events.push(mock === mocks.putCardResource ? 'remote:put'
+ : mock === mocks.fetchCardResource ? 'remote:get' : 'remote:delete');
+ if (result instanceof Error) throw result;
+ return typeof result === 'function' ? result() : result;
+ });
+}
+
+function transactions(...handlers) {
+ let index = 0;
+ mocks.withTransaction.mockImplementation(async callback => {
+ runtime.inTransaction = true;
+ runtime.events.push(`tx:${index + 1}:begin`);
+ const client = { query: vi.fn(handlers[index++] || (async () => ({ rows: [], rowCount: 0 }))) };
+ try {
+ const result = await callback(client);
+ runtime.events.push(`tx:${index}:commit`);
+ return result;
+ } finally {
+ runtime.inTransaction = false;
+ }
+ });
+}
+
+function preflightHandler({ contact = mapped(), connected = true } = {}) {
+ return async sql => {
+ const activeContact = typeof contact === 'function' ? contact() : contact;
+ if (sql.includes('FROM user_integrations')) return { rows: connected ? [integration] : [] };
+ if (sql.includes('FROM contacts c')) return { rows: activeContact ? [activeContact] : [] };
+ if (sql.includes('FROM carddav_remote_objects mapping') && sql.includes('FOR UPDATE')) {
+ return { rows: activeContact ? [activeContact] : [] };
+ }
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '4' }], rowCount: 1 };
+ }
+ if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: [confirmedRow()], rowCount: 1 };
+ if (sql.includes('DELETE FROM contacts')) {
+ return { rows: [{ address_book_id: activeContact.address_book_id }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ };
+}
+
+function commitHandler({ row = confirmedRow(), revisionMatches = true, mapping } = {}) {
+ return async sql => {
+ const attemptedVCard = mocks.putCardResource.mock.calls[0]?.[0]?.vcard;
+ const activeMapping = typeof mapping === 'function'
+ ? mapping()
+ : mapping ?? (attemptedVCard ? pendingUpdate(attemptedVCard) : pendingDelete());
+ if (sql.startsWith('SET TRANSACTION')) return { rows: [], rowCount: 0 };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) {
+ return { rows: revisionMatches ? [activeMapping] : [mapped({ mapping_revision: '5' })] };
+ }
+ if (sql.includes('FROM contacts c')) return { rows: [activeMapping] };
+ if (sql.includes('INSERT INTO address_books')) {
+ return { rows: [{ id: sql.includes("'Personal'") ? LOCAL_BOOK_ID : BOOK_ID }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '0' }], rowCount: 1 };
+ }
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '4' }], rowCount: 1 };
+ }
+ if (sql.includes('DELETE FROM carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '3' }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
+ if (sql.includes('DELETE FROM contacts')) return { rows: [], rowCount: 1 };
+ return { rows: [], rowCount: 1 };
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ runtime.inTransaction = false;
+ runtime.events = [];
+ mocks.randomUUID.mockReturnValue(UID);
+});
+
+describe('local-only contact mutations', () => {
+ it('creates locally without discovery when no remote account exists', async () => {
+ const row = confirmedRow();
+ transactions(
+ async sql => (sql.includes('FROM user_integrations')
+ ? { rows: [] }
+ : { rows: [], rowCount: 1 }),
+ async sql => {
+ if (sql.includes('INSERT INTO address_books')) return { rows: [{ id: LOCAL_BOOK_ID }] };
+ if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: [row], rowCount: 1 };
+ return { rows: [], rowCount: 1 };
+ },
+ );
+
+ await expect(createContact(USER_ID, draft())).resolves.toEqual(row);
+ expect(mocks.discoverAddressBooks).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
+ it('updates and deletes unmapped contacts in one local transaction', async () => {
+ transactions(
+ preflightHandler({ contact: mapped({ href: null, source: 'local' }) }),
+ preflightHandler({ contact: mapped({ href: null, source: 'local' }) }),
+ );
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toMatchObject({ id: CONTACT_ID });
+ await expect(deleteContact(USER_ID, CONTACT_ID)).resolves.toEqual({ ok: true });
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ });
+
+ it('atomically fences local CardDAV replaces with the supplied ETag', async () => {
+ let updates = 0;
+ const updateSql = [];
+ const handler = async (sql, params) => {
+ if (sql.includes('FROM user_integrations')) return { rows: [] };
+ if (sql.includes('FROM contacts c')) {
+ return { rows: [mapped({ href: null, source: 'local', address_book_id: LOCAL_BOOK_ID })] };
+ }
+ if (sql.includes('UPDATE contacts SET')) {
+ updates++;
+ updateSql.push({ sql, params });
+ return updates === 1
+ ? { rows: [confirmedRow()], rowCount: 1 }
+ : { rows: [], rowCount: 0 };
+ }
+ return { rows: [], rowCount: 1 };
+ };
+ transactions(handler, handler);
+
+ const mutation = {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard: CANONICAL_VCARD,
+ expectedLocalEtag: 'local-etag-before',
+ };
+ await expect(replaceContactFromVCard(USER_ID, mutation)).resolves.toEqual(confirmedRow());
+ await expect(replaceContactFromVCard(USER_ID, mutation)).rejects.toMatchObject({
+ code: 'ERR_LOCAL_ETAG_MISMATCH',
+ });
+
+ expect(updateSql).toHaveLength(2);
+ expect(updateSql.every(({ sql }) => /AND etag = \$16/.test(sql))).toBe(true);
+ expect(updateSql.every(({ params }) => params.at(-1) === 'local-etag-before')).toBe(true);
+ });
+
+ it('atomically fences local CardDAV deletes with the supplied ETag', async () => {
+ let deletes = 0;
+ const deleteSql = [];
+ const handler = async (sql, params) => {
+ if (sql.includes('FROM user_integrations')) return { rows: [] };
+ if (sql.includes('FROM contacts c')) {
+ return { rows: [mapped({ href: null, source: 'local', address_book_id: LOCAL_BOOK_ID })] };
+ }
+ if (sql.includes('DELETE FROM contacts')) {
+ deletes++;
+ deleteSql.push({ sql, params });
+ return { rows: [], rowCount: deletes === 1 ? 1 : 0 };
+ }
+ return { rows: [], rowCount: 1 };
+ };
+ transactions(handler, handler);
+
+ const mutation = {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ expectedLocalEtag: 'local-etag-before',
+ };
+ await expect(deleteContactFromVCard(USER_ID, mutation)).resolves.toEqual({ ok: true });
+ await expect(deleteContactFromVCard(USER_ID, mutation)).rejects.toMatchObject({
+ code: 'ERR_LOCAL_ETAG_MISMATCH',
+ });
+
+ expect(deleteSql).toHaveLength(2);
+ expect(deleteSql.every(({ sql }) => /AND etag = \$3/.test(sql))).toBe(true);
+ expect(deleteSql.every(({ params }) => params.at(-1) === 'local-etag-before')).toBe(true);
+ });
+});
+
+describe('remote-first mapped lifecycle', () => {
+ it.each([
+ ['create', () => createContact(USER_ID, draft())],
+ ['update', () => updateContact(USER_ID, CONTACT_ID, draft())],
+ ['delete', () => deleteContact(USER_ID, CONTACT_ID)],
+ ])('blocks %s before pending intent or network while Retry-After is active', async (
+ operation,
+ mutate,
+ ) => {
+ const retryAfterAt = '2999-07-12T12:00:00.000Z';
+ const throttledIntegration = {
+ ...integration,
+ config: { ...integration.config, retryAfterAt },
+ };
+ transactions(async sql => {
+ if (sql.includes('FROM user_integrations')) return { rows: [throttledIntegration] };
+ if (sql.includes('FROM contacts c')) return { rows: [mapped()] };
+ return { rows: [], rowCount: 1 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+
+ await expect(mutate()).rejects.toMatchObject({
+ name: 'CardDavError',
+ operation,
+ retryAfterAt,
+ status: 429,
+ });
+
+ expect(mocks.discoverAddressBooks).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ });
+
+ it('skips pending-intent recovery on the transitional schema', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes('information_schema.columns')) {
+ return { rows: [{ supports_pending_intent: false }] };
+ }
+ throw new Error('queried pending-intent columns on the transitional schema');
+ });
+
+ await expect(recoverPendingCarddavMutations(USER_ID, { integration }))
+ .resolves.toEqual([]);
+
+ expect(mocks.query).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ });
+
+ it('loads pending intents on the contracted schema', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes('information_schema.columns')) {
+ return { rows: [{ supports_pending_intent: true }] };
+ }
+ if (sql.includes('mapping.pending_operation')) return { rows: [] };
+ throw new Error('unexpected pending-intent recovery query');
+ });
+
+ await expect(recoverPendingCarddavMutations(USER_ID, { integration }))
+ .resolves.toEqual([]);
+
+ expect(mocks.query).toHaveBeenCalledTimes(2);
+ expect(mocks.query.mock.calls[1][0]).toContain('mapping.pending_operation');
+ });
+
+ it('updates only after preflight commit, conditional PUT, and canonical GET', async () => {
+ transactions(preflightHandler(), commitHandler());
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' });
+ protocolMock(mocks.fetchCardResource, () => ({
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: mocks.putCardResource.mock.calls[0][0].vcard,
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow());
+
+ expect(runtime.events).toEqual([
+ 'tx:1:begin', 'tx:1:commit', 'remote:put', 'remote:get', 'tx:2:begin', 'tx:2:commit',
+ ]);
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({
+ url: BOOK_URL,
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: expect.stringContaining('FN:After'),
+ }));
+ });
+
+ it.each([
+ ['remote write', mocks.putCardResource],
+ ['canonical fetch', mocks.fetchCardResource],
+ ])('does not start the compare-and-commit after %s failure', async (_label, failedMock) => {
+ transactions(preflightHandler());
+ protocolMock(mocks.putCardResource, failedMock === mocks.putCardResource
+ ? new CardDavError('write failed', { status: 500 })
+ : { href: HREF, etag: '"intermediate"' });
+ protocolMock(mocks.fetchCardResource, new CardDavError('fetch failed', { status: 500 }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf(
+ CardDavAmbiguousWriteError,
+ );
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+
+ it('returns a typed ambiguous result and preserves baselines on revision mismatch', async () => {
+ const finalClient = { query: vi.fn(commitHandler({ revisionMatches: false })) };
+ mocks.withTransaction
+ .mockImplementationOnce(async callback => callback({ query: vi.fn(preflightHandler()) }))
+ .mockImplementationOnce(async callback => callback(finalClient));
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf(
+ CardDavAmbiguousWriteError,
+ );
+ expect(finalClient.query.mock.calls.some(([sql]) => (
+ /UPDATE carddav_remote_objects/.test(sql) && /remote_semantic_hash/.test(sql)
+ ))).toBe(false);
+ });
+
+ it('does not compensate or repeat a confirmed remote write when final commit fails', async () => {
+ transactions(preflightHandler(), async sql => {
+ if (sql.startsWith('SET TRANSACTION')) return { rows: [] };
+ throw new Error('commit path unavailable');
+ });
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf(
+ CardDavAmbiguousWriteError,
+ );
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ });
+
+ it('treats remote delete 404 as confirmed before removing local state', async () => {
+ transactions(preflightHandler(), commitHandler({ row: null }));
+ protocolMock(mocks.deleteCardResource, { href: HREF, status: 404 });
+ protocolMock(mocks.fetchCardResource, new CardDavError('missing', { status: 404 }));
+
+ await expect(deleteContact(USER_ID, CONTACT_ID)).resolves.toEqual({ ok: true });
+ expect(runtime.events).toEqual([
+ 'tx:1:begin', 'tx:1:commit', 'remote:delete', 'remote:get',
+ 'tx:2:begin', 'tx:2:commit',
+ ]);
+ });
+
+ it('fails a denied mapped operation before local or remote mutation', async () => {
+ transactions(preflightHandler({
+ contact: mapped({ remote_update_capability: 'denied' }),
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toMatchObject({
+ code: 'ERR_CARDDAV_READ_ONLY',
+ });
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+
+ it('rejects a different interactive update while an intent awaits recovery', async () => {
+ transactions(preflightHandler({ contact: pendingUpdate(CANONICAL_VCARD) }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft({ displayName: 'Different Edit' })))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_PENDING_INTENT' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ });
+
+ it('recovers a timeout after an applied PUT with one PUT total', async () => {
+ let attemptedVCard;
+ transactions(
+ preflightHandler(),
+ async (sql, params) => {
+ if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) {
+ return { rows: [pendingUpdate(attemptedVCard)] };
+ }
+ return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params);
+ },
+ );
+ mocks.putCardResource.mockImplementation(async options => {
+ expect(runtime.inTransaction).toBe(false);
+ runtime.events.push('remote:put');
+ attemptedVCard = options.vcard;
+ throw new CardDavError('response timed out', { operation: 'update' });
+ });
+ mocks.fetchCardResource.mockImplementation(async () => ({
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: attemptedVCard,
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow());
+
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).toHaveBeenCalledOnce();
+ });
+
+ it('recovers a canonical GET failure on retry without a second PUT', async () => {
+ let attemptedVCard;
+ transactions(
+ preflightHandler(),
+ preflightHandler({ contact: () => pendingUpdate(attemptedVCard) }),
+ async (sql, params) => {
+ if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) {
+ return { rows: [pendingUpdate(attemptedVCard)] };
+ }
+ return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params);
+ },
+ );
+ mocks.putCardResource.mockImplementation(async options => {
+ attemptedVCard = options.vcard;
+ return { href: HREF, etag: '"intermediate"' };
+ });
+ mocks.fetchCardResource
+ .mockRejectedValueOnce(new CardDavError('canonical GET failed', { status: 500 }))
+ .mockImplementationOnce(async () => ({
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: attemptedVCard,
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf(
+ CardDavAmbiguousWriteError,
+ );
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow());
+
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ });
+
+ it('recovers a failed final transaction on retry without a second PUT', async () => {
+ let attemptedVCard;
+ transactions(
+ preflightHandler(),
+ async () => { throw new Error('final transaction unavailable'); },
+ preflightHandler({ contact: () => pendingUpdate(attemptedVCard) }),
+ async (sql, params) => {
+ if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) {
+ return { rows: [pendingUpdate(attemptedVCard)] };
+ }
+ return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params);
+ },
+ );
+ mocks.putCardResource.mockImplementation(async options => {
+ attemptedVCard = options.vcard;
+ return { href: HREF, etag: '"intermediate"' };
+ });
+ mocks.fetchCardResource.mockImplementation(async () => ({
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: attemptedVCard,
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf(
+ CardDavAmbiguousWriteError,
+ );
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow());
+
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ });
+
+ it('recovers an applied DELETE with a lost response using one DELETE total', async () => {
+ transactions(preflightHandler(), commitHandler({ row: null, mapping: pendingDelete() }));
+ protocolMock(
+ mocks.deleteCardResource,
+ new CardDavError('response timed out', { operation: 'delete' }),
+ );
+ protocolMock(mocks.fetchCardResource, new CardDavError('missing', { status: 404 }));
+
+ await expect(deleteContact(USER_ID, CONTACT_ID)).resolves.toEqual({ ok: true });
+
+ expect(mocks.deleteCardResource).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).toHaveBeenCalledOnce();
+ });
+
+ it('turns a concurrent local edit after intent creation into exactly one lossless conflict', async () => {
+ let attemptedVCard;
+ let conflictInsert;
+ // The retained remote vCard carries unmodeled server properties the lossy
+ // contacts.vcard drops.
+ const retained = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:Before\r\nCATEGORIES:VIP\r\nX-KEEP:me\r\nEND:VCARD\r\n`;
+ const concurrentlyEdited = () => pendingUpdate(attemptedVCard, {
+ display_name: 'Concurrent Local Edit',
+ mapping_vcard: retained,
+ });
+ transactions(
+ preflightHandler({ contact: () => mapped({ mapping_vcard: retained }) }),
+ preflightHandler({ contact: concurrentlyEdited }),
+ async (sql, params) => {
+ if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) {
+ return { rows: [pendingUpdate(attemptedVCard)] };
+ }
+ if (sql.includes('FROM contacts c')) return { rows: [concurrentlyEdited()] };
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ conflictInsert = params;
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params);
+ },
+ );
+ mocks.putCardResource.mockImplementation(async options => {
+ attemptedVCard = options.vcard;
+ return { href: HREF, etag: '"intermediate"' };
+ });
+ mocks.fetchCardResource
+ .mockRejectedValueOnce(new CardDavError('canonical GET failed', { status: 500 }))
+ .mockImplementationOnce(async () => ({
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: attemptedVCard,
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf(
+ CardDavAmbiguousWriteError,
+ );
+ const error = await updateContact(USER_ID, CONTACT_ID, draft()).catch(value => value);
+
+ expect(error).toBeInstanceOf(CardDavConflictError);
+ expect(error.conflictId).toBe(CONFLICT_ID);
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ // The preserved-current-local snapshot overlays the edited contact onto the
+ // retained remote vCard: the local edit AND the unmodeled properties survive, so
+ // a later keep-mailflow resolution does not strip them.
+ const localVCard = conflictInsert[5];
+ expect(localVCard).toContain('FN:Concurrent Local Edit');
+ expect(localVCard).toContain('CATEGORIES:VIP');
+ expect(localVCard).toContain('X-KEEP:me');
+ });
+});
+
+describe('stale remote writes', () => {
+ it('records one durable conflict with the rejected draft and latest remote snapshot', async () => {
+ const stale = new CardDavError('precondition failed', { status: 412, operation: 'update' });
+ const conflictClient = { query: vi.fn(commitHandler()) };
+ mocks.withTransaction
+ .mockImplementationOnce(async callback => callback({ query: vi.fn(preflightHandler()) }))
+ .mockImplementationOnce(async callback => callback(conflictClient));
+ protocolMock(mocks.putCardResource, stale);
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: BASE_VCARD,
+ });
+
+ const error = await updateContact(USER_ID, CONTACT_ID, draft()).catch(value => value);
+ expect(error).toBeInstanceOf(CardDavConflictError);
+ expect(error.conflictId).toBe(CONFLICT_ID);
+ const conflictInsert = conflictClient.query.mock.calls.find(([sql]) => (
+ sql.includes('INSERT INTO carddav_conflicts')
+ ));
+ expect(conflictInsert[1]).toEqual([
+ BOOK_ID,
+ HREF,
+ USER_ID,
+ 'local-hash-before',
+ '"remote-2"',
+ expect.stringContaining('FN:After'),
+ BASE_VCARD,
+ false,
+ false,
+ ]);
+ });
+
+ it.each([
+ ['malformed', 'not a vCard', /BEGIN:VCARD/],
+ [
+ 'oversized',
+ `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:${'x'.repeat(1024 * 1024)}\r\nEND:VCARD\r\n`,
+ /1 MiB|64 KiB/,
+ ],
+ ])('rejects a %s latest remote vCard before opening the conflict transaction', async (
+ _kind,
+ remoteVCard,
+ expectedMessage,
+ ) => {
+ const stale = new CardDavError('precondition failed', { status: 412, operation: 'update' });
+ transactions(preflightHandler(), commitHandler());
+ protocolMock(mocks.putCardResource, stale);
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: remoteVCard,
+ });
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toThrow(expectedMessage);
+
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+});
+
+describe('remote creates and export', () => {
+ it.each([
+ {
+ label: 'found',
+ result: { href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD },
+ expected: { kind: 'found', remote: expect.objectContaining({ href: HREF }) },
+ },
+ {
+ label: 'missing',
+ result: new CardDavError('missing', { status: 404 }),
+ expected: { kind: 'missing' },
+ },
+ {
+ label: 'unknown',
+ result: new CardDavError('failed', { status: 500 }),
+ expected: { kind: 'unknown', cause: expect.objectContaining({ status: 500 }) },
+ },
+ ])('classifies the deterministic create fetch as $label', async ({ result, expected }) => {
+ protocolMock(mocks.fetchCardResource, result);
+
+ await expect(fetchCreated({
+ book: book(),
+ href: `${UID}.vcf`,
+ uid: UID,
+ creds: { username: 'user', password: 'secret', allowPrivate: false },
+ })).resolves.toEqual(expected);
+ });
+
+ it.each([409, 500])('does not retry an authoritative create HTTP %i result', async status => {
+ transactions(preflightHandler({ contact: null }));
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ protocolMock(mocks.putCardResource, new CardDavError('create rejected', {
+ status,
+ operation: 'create',
+ }));
+
+ await expect(createContact(USER_ID, draft())).rejects.toMatchObject({ status });
+
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ });
+
+ it('validates an interactive draft before discovery or any external request', async () => {
+ transactions(preflightHandler({ contact: null }));
+
+ await expect(createContact(USER_ID, draft({ displayName: '', emails: [] })))
+ .rejects.toMatchObject({ code: 'ERR_CONTACT_VALIDATION' });
+
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ expect(mocks.discoverAddressBooks).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
+ it('discovers, selects the first writable book, and uses one UID for the href and vCard', async () => {
+ const denied = book({
+ url: 'https://dav.example.test/books/denied/',
+ capabilities: { create: 'denied', update: 'denied', delete: 'denied' },
+ });
+ const versionFour = book({
+ addressData: [{ contentType: 'text/vcard', version: '4.0' }],
+ });
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ mocks.discoverAddressBooks.mockResolvedValue([denied, versionFour]);
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD.replace('VERSION:3.0', 'VERSION:4.0'),
+ });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce();
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({
+ url: BOOK_URL,
+ href: `${UID}.vcf`,
+ vcard: expect.stringMatching(new RegExp(`VERSION:4\\.0[\\s\\S]+UID:${UID}`)),
+ }));
+ });
+
+ it('checks the deterministic UID href after an ambiguous create before retrying PUT', async () => {
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ protocolMock(mocks.putCardResource, new CardDavError('connection reset', { operation: 'create' }));
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.fetchCardResource).toHaveBeenCalledOnce();
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ });
+
+ it('retries the same deterministic href only after an ambiguous PUT is confirmed absent', async () => {
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ mocks.putCardResource
+ .mockRejectedValueOnce(new CardDavError('connection reset', { operation: 'create' }))
+ .mockResolvedValueOnce({ href: HREF, etag: '"created"' });
+ mocks.fetchCardResource
+ .mockRejectedValueOnce(new CardDavError('missing', { status: 404 }))
+ .mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.putCardResource).toHaveBeenCalledTimes(2);
+ expect(mocks.putCardResource.mock.calls.map(([options]) => options.href))
+ .toEqual([`${UID}.vcf`, `${UID}.vcf`]);
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ });
+
+ it('recovers an ambiguous bounded retry with one final deterministic GET', async () => {
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ mocks.putCardResource
+ .mockRejectedValueOnce(new CardDavError('first reset', { operation: 'create' }))
+ .mockRejectedValueOnce(new CardDavError('second reset', { operation: 'create' }));
+ mocks.fetchCardResource
+ .mockRejectedValueOnce(new CardDavError('missing', { status: 404 }))
+ .mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.putCardResource).toHaveBeenCalledTimes(2);
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ });
+
+ it('recovers retry 412 with one final deterministic GET and one local materialization', async () => {
+ let contactInserts = 0;
+ let mappingUpserts = 0;
+ transactions(
+ preflightHandler({ contact: null }),
+ async sql => {
+ if (sql.startsWith('SET TRANSACTION')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes("VALUES ($1, 'Personal')")) return { rows: [{ id: LOCAL_BOOK_ID }] };
+ if (sql.includes('INSERT INTO address_books')) return { rows: [{ id: BOOK_ID }] };
+ if (sql.includes('INSERT INTO contacts')) {
+ contactInserts++;
+ return { rows: [confirmedRow()], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_remote_objects')) mappingUpserts++;
+ return { rows: [], rowCount: 1 };
+ },
+ );
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ mocks.putCardResource
+ .mockRejectedValueOnce(new CardDavError('connection reset', { operation: 'create' }))
+ .mockRejectedValueOnce(new CardDavError('already exists', { status: 412, operation: 'create' }));
+ mocks.fetchCardResource
+ .mockRejectedValueOnce(new CardDavError('missing', { status: 404 }))
+ .mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.putCardResource).toHaveBeenCalledTimes(2);
+ expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2);
+ expect(contactInserts).toBe(1);
+ expect(mappingUpserts).toBe(1);
+ });
+
+ it('defaults to vCard 3.0 when discovery advertises mixed versions', async () => {
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ mocks.discoverAddressBooks.mockResolvedValue([book({
+ addressData: [
+ { contentType: 'text/x-vcard', version: '3.0' },
+ { contentType: 'text/vcard', version: '4.0' },
+ ],
+ })]);
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({
+ vcard: expect.stringContaining('VERSION:3.0'),
+ }));
+ });
+
+ it('exports with the caller snapshot and does not rediscover', async () => {
+ transactions(preflightHandler({ contact: mapped({ href: null, source: 'local' }) }), commitHandler());
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await exportExistingContact(USER_ID, CONTACT_ID, { books: [book()] });
+
+ expect(mocks.discoverAddressBooks).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ });
+
+ it('rejects an export planned by a stale connection generation before remote I/O', async () => {
+ const replacement = {
+ ...integration,
+ config: { ...integration.config, connectionGeneration: 'generation-2' },
+ };
+ transactions(async sql => {
+ if (sql.includes('FROM user_integrations')) return { rows: [replacement] };
+ if (sql.includes('FROM contacts c')) {
+ return { rows: [mapped({ href: null, source: 'local' })] };
+ }
+ return { rows: [], rowCount: 1 };
+ });
+
+ await expect(exportExistingContact(USER_ID, CONTACT_ID, {
+ books: [book()],
+ expectedGeneration: 'generation-1',
+ })).rejects.toMatchObject({
+ code: 'ERR_CARDDAV_STALE_GENERATION',
+ expectedConnectionGeneration: 'generation-1',
+ actualConnectionGeneration: 'generation-2',
+ });
+ expect(mocks.discoverAddressBooks).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ {
+ sourceVersion: '3.0',
+ addressData: [{ contentType: 'text/vcard', version: '4.0' }],
+ expectedVersion: '4.0',
+ },
+ {
+ sourceVersion: '4.0',
+ addressData: [
+ { contentType: 'text/x-vcard', version: '3.0' },
+ { contentType: 'text/vcard', version: '4.0' },
+ ],
+ expectedVersion: '3.0',
+ },
+ ])('exports retained vCard $sourceVersion as the selected book version $expectedVersion', async ({
+ sourceVersion,
+ addressData,
+ expectedVersion,
+ }) => {
+ const retained = BASE_VCARD.replace('VERSION:3.0', `VERSION:${sourceVersion}`);
+ const canonical = CANONICAL_VCARD.replace('VERSION:3.0', `VERSION:${expectedVersion}`);
+ transactions(
+ preflightHandler({ contact: mapped({ href: null, source: 'local', vcard: retained }) }),
+ commitHandler(),
+ );
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: canonical,
+ });
+
+ await exportExistingContact(USER_ID, CONTACT_ID, { books: [book({ addressData })] });
+
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({
+ vcard: expect.stringContaining(`VERSION:${expectedVersion}`),
+ }));
+ });
+
+ it('fences export finalization against a concurrent local contact change', async () => {
+ let sawLocalEtagFence = false;
+ transactions(
+ preflightHandler({ contact: mapped({ href: null, source: 'local' }) }),
+ async sql => {
+ if (sql.startsWith('SET TRANSACTION')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes('INSERT INTO address_books')) return { rows: [{ id: BOOK_ID }] };
+ if (sql.includes('UPDATE contacts')) {
+ sawLocalEtagFence = /AND etag = \$16/.test(sql);
+ return { rows: [], rowCount: 0 };
+ }
+ return { rows: [], rowCount: 1 };
+ },
+ );
+ mocks.discoverAddressBooks.mockImplementation(() => {
+ throw new Error('export must use its supplied snapshot');
+ });
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await expect(exportExistingContact(USER_ID, CONTACT_ID, { books: [book()] }))
+ .rejects.toBeInstanceOf(CardDavAmbiguousWriteError);
+ expect(sawLocalEtagFence).toBe(true);
+ });
+
+ it('allocates a non-conflicting local name when materializing the selected remote book', async () => {
+ let remoteBookAttempts = 0;
+ transactions(
+ preflightHandler({ contact: null }),
+ async sql => {
+ if (sql.startsWith('SET TRANSACTION')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes("VALUES ($1, 'Personal')")) return { rows: [{ id: LOCAL_BOOK_ID }] };
+ if (sql.includes('INSERT INTO address_books')) {
+ remoteBookAttempts++;
+ if (remoteBookAttempts === 1) {
+ throw Object.assign(new Error('duplicate book name'), { code: '23505' });
+ }
+ return { rows: [{ id: BOOK_ID }] };
+ }
+ if (/RETURNING\s+id,\s*uid/.test(sql)) {
+ return { rows: [confirmedRow()], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ },
+ );
+ mocks.discoverAddressBooks.mockResolvedValue([book({ displayName: 'Personal' })]);
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await createContact(USER_ID, draft());
+
+ expect(remoteBookAttempts).toBe(2);
+ });
+
+ it('persists only create denial after a real rejected create on an unseen remote book', async () => {
+ let materializedBook = false;
+ let capabilitySql = '';
+ transactions(
+ preflightHandler({ contact: null }),
+ async sql => {
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes('INSERT INTO address_books')) {
+ materializedBook = true;
+ return { rows: [{ id: BOOK_ID }] };
+ }
+ if (sql.includes('UPDATE address_books')) capabilitySql = sql;
+ return { rows: [], rowCount: 1 };
+ },
+ );
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ protocolMock(mocks.putCardResource, new CardDavError('forbidden', {
+ status: 403,
+ operation: 'create',
+ }));
+
+ await expect(createContact(USER_ID, draft())).rejects.toMatchObject({ status: 403 });
+
+ expect(materializedBook).toBe(true);
+ expect(capabilitySql).toContain("remote_create_capability = 'denied'");
+ expect(capabilitySql).not.toMatch(/remote_(?:update|delete)_capability = 'denied'/);
+ });
+
+ it('preserves existing update and delete denials when create is rejected', async () => {
+ let materializeSql = '';
+ let denialSql = '';
+ transactions(
+ preflightHandler({ contact: null }),
+ async sql => {
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes('INSERT INTO address_books')) {
+ materializeSql = sql;
+ return { rows: [{ id: BOOK_ID }] };
+ }
+ if (sql.includes('UPDATE address_books')) denialSql = sql;
+ return { rows: [], rowCount: 1 };
+ },
+ );
+ mocks.discoverAddressBooks.mockResolvedValue([book({
+ capabilities: { create: 'unknown', update: 'allowed', delete: 'unknown' },
+ })]);
+ protocolMock(mocks.putCardResource, new CardDavError('forbidden', {
+ status: 403,
+ operation: 'create',
+ }));
+
+ await expect(createContact(USER_ID, draft())).rejects.toMatchObject({ status: 403 });
+
+ const conflictUpdate = materializeSql.split('DO UPDATE SET')[1];
+ expect(conflictUpdate).not.toMatch(/remote_(?:create|update|delete)_capability/);
+ expect(denialSql).toContain("remote_create_capability = 'denied'");
+ expect(denialSql).not.toMatch(/remote_(?:update|delete)_capability/);
+ });
+
+ it('uses the established CardDAV fallback when a remote book has no display name', async () => {
+ let storedName = null;
+ transactions(
+ preflightHandler({ contact: null }),
+ async (sql, params) => {
+ if (sql.startsWith('SET TRANSACTION')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ if (sql.includes("VALUES ($1, 'Personal')")) return { rows: [{ id: LOCAL_BOOK_ID }] };
+ if (sql.includes('INSERT INTO address_books')) {
+ storedName = params[1];
+ return { rows: [{ id: BOOK_ID }] };
+ }
+ if (sql.includes('INSERT INTO contacts')) {
+ return { rows: [confirmedRow()], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ },
+ );
+ mocks.discoverAddressBooks.mockResolvedValue([book({ displayName: '' })]);
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await createContact(USER_ID, draft());
+
+ expect(storedName).toBe('CardDAV');
+ });
+});
+
+describe('MailFlow CardDAV-server seams', () => {
+ it('stores the preferred email when it is not listed first', async () => {
+ const rawVCard = BASE_VCARD
+ .replace('VERSION:3.0', 'VERSION:4.0')
+ .replace(
+ 'FN:Before\r\n',
+ 'FN:Before\r\nEMAIL:first@example.test\r\nEMAIL;PREF=1: SECOND@EXAMPLE.TEST \r\n',
+ );
+ let insertParams;
+ transactions(async (sql, params) => {
+ if (sql.includes('FROM user_integrations')) return { rows: [] };
+ if (sql.includes('FROM address_books')) return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] };
+ if (sql.includes('INSERT INTO contacts')) {
+ insertParams = params;
+ return { rows: [confirmedRow({ uid: UID })], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ });
+
+ await createContactFromVCard(USER_ID, {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard,
+ });
+
+ expect(insertParams[8]).toBe('second@example.test');
+ });
+
+ it('preserves a client UID and raw vCard when creating through a local book', async () => {
+ transactions(async sql => {
+ if (sql.includes('FROM user_integrations')) return { rows: [] };
+ if (sql.includes('FROM address_books')) return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] };
+ if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: [confirmedRow({ uid: UID })] };
+ return { rows: [], rowCount: 1 };
+ });
+
+ await createContactFromVCard(USER_ID, {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard: BASE_VCARD,
+ });
+
+ const calls = mocks.withTransaction.mock.calls;
+ expect(calls).toHaveLength(1);
+ expect(mocks.randomUUID).not.toHaveBeenCalled();
+ });
+
+ it('translates the local book/UID unique race for create-only CardDAV PUT', async () => {
+ const duplicate = Object.assign(new Error('duplicate contact UID'), {
+ code: '23505',
+ constraint: 'contacts_address_book_id_uid_key',
+ });
+ transactions(async sql => {
+ if (sql.includes('FROM address_books')) {
+ return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] };
+ }
+ if (sql.includes('FROM contacts c')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [] };
+ if (sql.includes('INSERT INTO contacts')) throw duplicate;
+ return { rows: [], rowCount: 1 };
+ });
+
+ await expect(createContactFromVCard(USER_ID, {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard: BASE_VCARD,
+ expectedAbsent: true,
+ })).rejects.toMatchObject({ code: 'ERR_LOCAL_PRECONDITION_FAILED' });
+ });
+
+ it('leaves unrelated local create SQL failures unchanged', async () => {
+ const unrelated = Object.assign(new Error('unrelated unique failure'), {
+ code: '23505',
+ constraint: 'contacts_pkey',
+ });
+ transactions(async sql => {
+ if (sql.includes('FROM address_books')) {
+ return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] };
+ }
+ if (sql.includes('FROM contacts c')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [] };
+ if (sql.includes('INSERT INTO contacts')) throw unrelated;
+ return { rows: [], rowCount: 1 };
+ });
+
+ await expect(createContactFromVCard(USER_ID, {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard: BASE_VCARD,
+ expectedAbsent: true,
+ })).rejects.toBe(unrelated);
+ });
+
+ it('translates an authoritative remote create-only 412 without local mutation', async () => {
+ transactions(async sql => {
+ if (sql.includes('FROM address_books')) {
+ return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] };
+ }
+ if (sql.includes('FROM contacts c')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ return { rows: [], rowCount: 1 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([book()]);
+ protocolMock(mocks.putCardResource, new CardDavError('already exists', {
+ status: 412,
+ operation: 'create',
+ }));
+
+ await expect(createContactFromVCard(USER_ID, {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard: BASE_VCARD,
+ expectedAbsent: true,
+ })).rejects.toMatchObject({ code: 'ERR_LOCAL_PRECONDITION_FAILED' });
+
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({
+ href: `${UID}.vcf`,
+ }));
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+
+ it('rejects a stale local ETag before an external request', async () => {
+ transactions(preflightHandler({ contact: mapped({ etag: 'current-local-etag' }) }));
+
+ await expect(replaceContactFromVCard(USER_ID, {
+ localAddressBookId: BOOK_ID,
+ uid: UID,
+ rawVCard: CANONICAL_VCARD,
+ expectedLocalEtag: 'stale-local-etag',
+ })).rejects.toMatchObject({ code: 'ERR_LOCAL_ETAG_MISMATCH' });
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
+ it('resolves delete ownership by local book plus UID and enforces the ETag', async () => {
+ transactions(preflightHandler(), commitHandler({ row: null }));
+ protocolMock(mocks.deleteCardResource, { href: HREF, status: 204 });
+ protocolMock(mocks.fetchCardResource, new CardDavError('missing', { status: 404 }));
+
+ await expect(deleteContactFromVCard(USER_ID, {
+ localAddressBookId: BOOK_ID,
+ uid: UID,
+ expectedLocalEtag: 'local-etag-before',
+ })).resolves.toEqual({ ok: true });
+ expect(mocks.deleteCardResource).toHaveBeenCalledOnce();
+ });
+
+ it('selects create versus replace from local ownership instead of a caller contact ID', async () => {
+ transactions(preflightHandler(), commitHandler());
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: CANONICAL_VCARD,
+ });
+
+ await replaceContactFromVCard(USER_ID, {
+ localAddressBookId: BOOK_ID,
+ uid: UID,
+ rawVCard: CANONICAL_VCARD,
+ expectedLocalEtag: 'local-etag-before',
+ });
+
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({
+ etag: '"remote-1"',
+ }));
+ });
+});
diff --git a/backend/src/services/carddavFixtureServer.js b/backend/src/services/carddavFixtureServer.js
new file mode 100644
index 0000000..86e4afc
--- /dev/null
+++ b/backend/src/services/carddavFixtureServer.js
@@ -0,0 +1,397 @@
+import http from 'node:http';
+import { xmlEscape } from './carddavXml.js';
+
+const PRINCIPAL_PATH = '/principals/fixture-user/';
+const HOME_PATH = '/addressbooks/fixture-user/';
+const BOOK_PATH = `${HOME_PATH}contacts/`;
+
+function unescapeXml(value) {
+ return String(value)
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/>/g, '>')
+ .replace(/</g, '<')
+ .replace(/&/g, '&');
+}
+
+function multistatus(content) {
+ return `
+
+${content}
+ `;
+}
+
+function propResponse(href, properties) {
+ return `${xmlEscape(href)}
+${properties}
+ HTTP/1.1 200 OK `;
+}
+
+function cardResponse(contact) {
+ return propResponse(contact.href, `${xmlEscape(contact.etag)}
+${xmlEscape(contact.vcard)} `);
+}
+
+function removedResponse(href) {
+ return `${xmlEscape(href)}
+HTTP/1.1 404 Not Found `;
+}
+
+function readBody(request) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ request.on('data', chunk => chunks.push(chunk));
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
+ request.on('error', reject);
+ });
+}
+
+function send(response, status, body = '', headers = {}) {
+ const responseHeaders = { ...headers };
+ if (body && !Object.keys(responseHeaders).some(name => name.toLowerCase() === 'content-type')) {
+ responseHeaders['Content-Type'] = 'application/xml; charset=utf-8';
+ }
+ response.writeHead(status, responseHeaders);
+ response.end(body);
+}
+
+export function createCarddavFixtureServer() {
+ const contacts = new Map();
+ const syncResponses = new Map();
+ const multigetResponses = [];
+ const discoveryResponses = [];
+ const writeResponses = new Map();
+ const redirects = new Map();
+ let origin;
+ let writeRevision = 0;
+ const counters = {
+ requests: 0,
+ propfind: 0,
+ sync: 0,
+ multiget: 0,
+ addressbookQuery: 0,
+ fetch: 0,
+ create: 0,
+ update: 0,
+ delete: 0,
+ requestUri507: 0,
+ snapshotFilters: [],
+ syncTokens: [],
+ multigetSizes: [],
+ };
+ const requests = [];
+
+ function absoluteHref(href) {
+ return new URL(href, `${origin}${BOOK_PATH}`).href;
+ }
+
+ function queueSync(token, scriptedResponse) {
+ const queue = syncResponses.get(token) || [];
+ queue.push(scriptedResponse);
+ syncResponses.set(token, queue);
+ }
+
+ function takeSync(token) {
+ const queue = syncResponses.get(token);
+ if (!queue?.length) {
+ throw new Error(`No CardDAV fixture response queued for token ${JSON.stringify(token)}`);
+ }
+ return queue.shift();
+ }
+
+ function takeWrite(method) {
+ const queue = writeResponses.get(method);
+ return queue?.shift();
+ }
+
+ const server = http.createServer(async (request, response) => {
+ try {
+ const body = await readBody(request);
+ const url = new URL(request.url, origin || 'http://127.0.0.1');
+ counters.requests++;
+ requests.push({
+ method: request.method,
+ origin: url.origin,
+ path: url.pathname,
+ authorization: request.headers.authorization,
+ accept: request.headers.accept,
+ contentType: request.headers['content-type'],
+ depth: request.headers.depth,
+ ifMatch: request.headers['if-match'],
+ ifNoneMatch: request.headers['if-none-match'],
+ body,
+ });
+
+ const redirectKey = `${request.method} ${url.pathname}`;
+ const redirectQueue = redirects.get(redirectKey);
+ if (redirectQueue?.length) {
+ const redirect = redirectQueue.shift();
+ response.writeHead(redirect.status || 301, { Location: redirect.location });
+ response.end();
+ return;
+ }
+
+ if (request.method === 'PROPFIND') {
+ counters.propfind++;
+ if (url.pathname === '/' || url.pathname === '/dav/') {
+ return send(response, 207, multistatus(propResponse(
+ url.pathname,
+ `${PRINCIPAL_PATH} `,
+ )));
+ }
+ if (url.pathname === PRINCIPAL_PATH) {
+ return send(response, 207, multistatus(propResponse(
+ PRINCIPAL_PATH,
+ `${HOME_PATH} `,
+ )));
+ }
+ if (url.pathname === HOME_PATH) {
+ const scripted = discoveryResponses.shift();
+ if (scripted?.status && scripted.status !== 207) {
+ return send(
+ response,
+ scripted.status,
+ scripted.rawBody || '',
+ scripted.headers || {},
+ );
+ }
+ if (scripted && Object.hasOwn(scripted, 'rawBody')) {
+ return send(response, scripted.status || 207, scripted.rawBody);
+ }
+ const reports = `
+
+
+ `;
+ const homeResponse = propResponse(
+ HOME_PATH,
+ ' ',
+ );
+ const books = scripted && Object.hasOwn(scripted, 'books')
+ ? scripted.books
+ : [{ href: BOOK_PATH, displayName: 'Fixture Contacts' }];
+ const bookResponses = books.map(book => {
+ const privileges = Object.hasOwn(book, 'privileges')
+ ? book.privileges
+ : ['bind', 'write-content', 'unbind'];
+ const addressData = Object.hasOwn(book, 'addressData')
+ ? book.addressData
+ : [
+ { contentType: 'text/vcard', version: '4.0' },
+ { contentType: 'text/vcard', version: '3.0' },
+ ];
+ const privilegeProperty = privileges === false ? '' : `
+${privileges.map(privilege => (
+ ` `
+)).join('')} `;
+ const addressDataProperty = addressData === false ? '' : `
+${addressData.map(entry => (
+ ` `
+)).join('')} `;
+ return propResponse(
+ book.href || BOOK_PATH,
+ `
+${xmlEscape(book.displayName || 'Fixture Contacts')} ${
+ book.reports === false ? '' : reports
+}${privilegeProperty}${addressDataProperty}`,
+ );
+ });
+ return send(response, 207, multistatus([homeResponse, ...bookResponses].join('\n')));
+ }
+ }
+
+ if (['GET', 'PUT', 'DELETE'].includes(request.method)
+ && url.pathname.startsWith(HOME_PATH)
+ && !url.pathname.endsWith('/')) {
+ const href = url.href;
+ const existing = contacts.get(href);
+ const scripted = takeWrite(request.method);
+
+ if (scripted) {
+ return send(
+ response,
+ scripted.status,
+ scripted.rawBody || '',
+ scripted.headers || {},
+ );
+ }
+
+ if (request.method === 'GET') {
+ counters.fetch++;
+ if (!existing) return send(response, 404);
+ return send(response, 200, existing.vcard, {
+ 'Content-Type': 'text/vcard; charset=utf-8',
+ ETag: existing.etag,
+ });
+ }
+
+ if (request.method === 'PUT') {
+ const creating = request.headers['if-none-match'] === '*'
+ && request.headers['if-match'] == null;
+ const updating = request.headers['if-match'] != null
+ && request.headers['if-none-match'] == null;
+ if (!creating && !updating) return send(response, 428);
+ if (creating && existing) return send(response, 412);
+ if (updating && (!existing || request.headers['if-match'] !== existing.etag)) {
+ return send(response, 412);
+ }
+ if (!request.headers['content-type']?.startsWith('text/vcard')) {
+ return send(response, 415);
+ }
+ const etag = `"fixture-write-${++writeRevision}"`;
+ contacts.set(href, { href, etag, vcard: body });
+ counters[creating ? 'create' : 'update']++;
+ return send(response, creating ? 201 : 204, '', { ETag: etag });
+ }
+
+ counters.delete++;
+ if (!existing) return send(response, 404);
+ if (request.headers['if-match'] !== existing.etag) return send(response, 412);
+ contacts.delete(href);
+ return send(response, 204);
+ }
+
+ if (request.method === 'REPORT'
+ && url.pathname.startsWith(HOME_PATH)
+ && url.pathname !== HOME_PATH) {
+ if (body.includes('([\s\S]*?)<\/sync-token>/)?.[1] || '');
+ counters.syncTokens.push(token);
+ const scripted = takeSync(token);
+ if (scripted.waitFor) {
+ scripted.reached?.();
+ await scripted.waitFor;
+ }
+ if (Object.hasOwn(scripted, 'rawBody')) {
+ return send(response, scripted.status || 207, scripted.rawBody);
+ }
+ if (scripted.status && scripted.status !== 207) {
+ const error = scripted.precondition
+ ? ` `
+ : '';
+ return send(response, scripted.status, error);
+ }
+ const events = (scripted.events || []).map(event => {
+ const href = event.rawHref ?? absoluteHref(event.href);
+ if (event.status === 404) return removedResponse(href);
+ return propResponse(
+ href,
+ `${xmlEscape(event.etag)} `,
+ );
+ });
+ if (scripted.truncated) {
+ counters.requestUri507++;
+ events.push(`${BOOK_PATH}
+HTTP/1.1 507 Insufficient Storage `);
+ }
+ return send(response, 207, multistatus(
+ `${events.join('\n')}${xmlEscape(scripted.nextToken)} `,
+ ));
+ }
+
+ if (body.includes('([\s\S]*?)<\/D:href>/g)]
+ .map(match => absoluteHref(unescapeXml(match[1])));
+ counters.multigetSizes.push(hrefs.length);
+ const scripted = multigetResponses.shift();
+ if (scripted?.status && scripted.status !== 207) {
+ return send(response, scripted.status, scripted.rawBody || '');
+ }
+ if (scripted && Object.hasOwn(scripted, 'rawBody')) {
+ return send(response, scripted.status || 207, scripted.rawBody);
+ }
+ const responses = hrefs.map(href => {
+ const contact = contacts.get(href);
+ return contact ? cardResponse(contact) : removedResponse(href);
+ });
+ return send(response, 207, multistatus(responses.join('\n')));
+ }
+
+ if (body.includes('/g)?.length || 0;
+ counters.snapshotFilters.push(filterCount);
+ if (filterCount !== 1) {
+ return send(response, 400, 'snapshot query requires one CardDAV filter ');
+ }
+ const responses = [...contacts.values()]
+ .sort((a, b) => a.href.localeCompare(b.href))
+ .map(cardResponse);
+ return send(response, 207, multistatus(responses.join('\n')));
+ }
+ }
+
+ return send(response, 404);
+ } catch (error) {
+ return send(response, 500, `${xmlEscape(error.message)} `);
+ }
+ });
+
+ return {
+ counters,
+ requests,
+ get serverUrl() { return `${origin}/`; },
+ href(name) { return absoluteHref(name); },
+ putContact(href, etag, vcard) {
+ const absolute = absoluteHref(href);
+ contacts.set(absolute, { href: absolute, etag, vcard });
+ },
+ deleteContact(href) {
+ contacts.delete(absoluteHref(href));
+ },
+ reset() {
+ counters.requests = 0;
+ counters.propfind = 0;
+ counters.sync = 0;
+ counters.multiget = 0;
+ counters.addressbookQuery = 0;
+ counters.fetch = 0;
+ counters.create = 0;
+ counters.update = 0;
+ counters.delete = 0;
+ counters.requestUri507 = 0;
+ counters.snapshotFilters.length = 0;
+ counters.syncTokens.length = 0;
+ counters.multigetSizes.length = 0;
+ requests.length = 0;
+ syncResponses.clear();
+ multigetResponses.length = 0;
+ discoveryResponses.length = 0;
+ writeResponses.clear();
+ redirects.clear();
+ },
+ queueSync,
+ queueDiscovery(scriptedResponse) {
+ discoveryResponses.push(scriptedResponse);
+ },
+ queueRedirect(method, path, location, status = 301) {
+ const key = `${method} ${path}`;
+ const queue = redirects.get(key) || [];
+ queue.push({ location, status });
+ redirects.set(key, queue);
+ },
+ queueWrite(method, scriptedResponse) {
+ const queue = writeResponses.get(method) || [];
+ queue.push(scriptedResponse);
+ writeResponses.set(method, queue);
+ },
+ queueMultiget(scriptedResponse) {
+ multigetResponses.push(scriptedResponse);
+ },
+ async listen() {
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ origin = `http://127.0.0.1:${server.address().port}`;
+ },
+ async close() {
+ if (!server.listening) return;
+ server.closeAllConnections();
+ await new Promise((resolve, reject) => server.close(error => (
+ error ? reject(error) : resolve()
+ )));
+ },
+ };
+}
diff --git a/backend/src/services/carddavMappingState.js b/backend/src/services/carddavMappingState.js
new file mode 100644
index 0000000..f50e07c
--- /dev/null
+++ b/backend/src/services/carddavMappingState.js
@@ -0,0 +1,508 @@
+export function typedError(message, code, details = {}) {
+ return Object.assign(new Error(message), { code }, details);
+}
+
+export async function rotateBookToken(client, userId, addressBookId) {
+ const result = await client.query(
+ `UPDATE address_books
+ SET sync_token = gen_random_uuid()::text, updated_at = NOW()
+ WHERE id = $1 AND user_id = $2`,
+ [addressBookId, userId],
+ );
+ return result.rowCount;
+}
+
+export const UNKNOWN_CARDDAV_CAPABILITIES = Object.freeze({
+ create: 'unknown',
+ update: 'unknown',
+ delete: 'unknown',
+});
+
+export function normalizeCarddavCapabilities(capabilities) {
+ return { ...UNKNOWN_CARDDAV_CAPABILITIES, ...(capabilities || {}) };
+}
+
+export async function lockCarddavIntegration(client, userId, { requireServerUrl = false } = {}) {
+ const serverUrlFilter = requireServerUrl
+ ? "AND NULLIF(config->>'serverUrl', '') IS NOT NULL"
+ : '';
+ const { rows: [integration] } = await client.query(
+ `SELECT id, config
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ ${serverUrlFilter}
+ FOR UPDATE`,
+ [userId],
+ );
+ return integration || null;
+}
+
+export function assertConflictSnapshotsWithinLimit({
+ localVCard,
+ remoteVCard,
+ localTombstone = false,
+ remoteTombstone = false,
+}) {
+ const bytes = (localTombstone ? 0 : Buffer.byteLength(localVCard, 'utf8'))
+ + (remoteTombstone ? 0 : Buffer.byteLength(remoteVCard, 'utf8'));
+ if (bytes > 2 * 1024 * 1024) {
+ throw typedError(
+ 'CardDAV conflict snapshots exceed 2 MiB',
+ 'ERR_CARDDAV_CONFLICT_TOO_LARGE',
+ );
+ }
+}
+
+function requireExpectedRevision(change) {
+ if (Object.hasOwn(change, 'expectedMappingRevision')
+ && change.expectedMappingRevision !== undefined) return;
+ throw typedError(
+ 'An expected CardDAV mapping revision is required',
+ 'ERR_CARDDAV_MAPPING_REVISION_REQUIRED',
+ );
+}
+
+function staleResult(change) {
+ return {
+ ok: false,
+ stale: true,
+ code: 'ERR_CARDDAV_MAPPING_STALE',
+ addressBookId: change.addressBookId,
+ href: change.href,
+ expectedMappingRevision: String(change.expectedMappingRevision),
+ };
+}
+
+export async function lockCarddavMapping(client, { userId, addressBookId, href }) {
+ const { rows: [mapping] } = await client.query(
+ `SELECT mapping.*, contact.address_book_id AS local_address_book_id,
+ contact.id AS contact_id
+ FROM carddav_remote_objects mapping
+ JOIN contacts contact ON contact.id = mapping.local_contact_id
+ WHERE mapping.address_book_id = $1 AND mapping.href = $2
+ AND contact.user_id = $3
+ FOR UPDATE OF mapping, contact`,
+ [addressBookId, href, userId],
+ );
+ return mapping || null;
+}
+
+export async function persistPendingMutationIntent(client, change) {
+ requireExpectedRevision(change);
+ if (change.operation !== 'update' && change.operation !== 'delete') {
+ throw typedError('Invalid pending CardDAV operation', 'ERR_CARDDAV_PENDING_OPERATION');
+ }
+ if (!change.pendingLocalHash
+ || (change.operation === 'update'
+ && (!change.pendingVCard || !change.pendingRemoteSemanticHash))
+ || (change.operation === 'delete'
+ && (change.pendingVCard != null || change.pendingRemoteSemanticHash != null))) {
+ throw typedError('Invalid pending CardDAV intent', 'ERR_CARDDAV_PENDING_INTENT');
+ }
+ if (change.pendingVCard
+ && Buffer.byteLength(change.pendingVCard, 'utf8') > 1024 * 1024) {
+ throw typedError(
+ 'Pending CardDAV vCard exceeds 1 MiB',
+ 'ERR_CARDDAV_PENDING_INTENT_TOO_LARGE',
+ );
+ }
+ const mapping = await lockCarddavMapping(client, change);
+ if (!mapping
+ || String(mapping.mapping_revision) !== String(change.expectedMappingRevision)) {
+ return staleResult(change);
+ }
+ if (mapping.pending_operation) {
+ throw typedError(
+ 'A CardDAV mutation is already awaiting confirmation',
+ 'ERR_CARDDAV_PENDING_INTENT',
+ { operation: mapping.pending_operation },
+ );
+ }
+ const result = await client.query(
+ `UPDATE carddav_remote_objects SET
+ mapping_status = 'pending_push',
+ pending_operation = $4, pending_vcard = $5,
+ pending_local_hash = $6, pending_remote_semantic_hash = $7,
+ pending_started_at = NOW(),
+ mapping_revision = mapping_revision + 1,
+ updated_at = NOW()
+ WHERE address_book_id = $1 AND href = $2
+ AND mapping_revision = $3::bigint
+ AND pending_operation IS NULL
+ RETURNING mapping_revision::text, pending_started_at::text`,
+ [
+ change.addressBookId,
+ change.href,
+ String(change.expectedMappingRevision),
+ change.operation,
+ change.pendingVCard ?? null,
+ change.pendingLocalHash,
+ change.pendingRemoteSemanticHash ?? null,
+ ],
+ );
+ if (result.rowCount !== 1) return staleResult(change);
+ return {
+ ok: true,
+ mappingRevision: String(
+ result.rows[0]?.mapping_revision
+ ?? (BigInt(change.expectedMappingRevision) + 1n),
+ ),
+ pendingStartedAt: result.rows[0]?.pending_started_at,
+ };
+}
+
+export async function restorePendingMutationIntent(client, change) {
+ requireExpectedRevision(change);
+ const result = await client.query(
+ `UPDATE carddav_remote_objects SET
+ mapping_status = $8,
+ pending_operation = NULL, pending_vcard = NULL,
+ pending_local_hash = NULL, pending_remote_semantic_hash = NULL,
+ pending_started_at = NULL,
+ mapping_revision = $9::bigint,
+ updated_at = $10
+ WHERE address_book_id = $1 AND href = $2
+ AND mapping_revision = $3::bigint
+ AND mapping_status = 'pending_push'
+ AND pending_operation = $4
+ AND pending_vcard IS NOT DISTINCT FROM $5
+ AND pending_local_hash IS NOT DISTINCT FROM $6
+ AND pending_remote_semantic_hash IS NOT DISTINCT FROM $7
+ AND pending_started_at = $11::timestamptz
+ RETURNING mapping_revision::text`,
+ [
+ change.addressBookId,
+ change.href,
+ String(change.expectedMappingRevision),
+ change.operation,
+ change.pendingVCard ?? null,
+ change.pendingLocalHash,
+ change.pendingRemoteSemanticHash ?? null,
+ change.previousMappingStatus,
+ String(change.previousMappingRevision),
+ change.previousUpdatedAt,
+ change.pendingStartedAt,
+ ],
+ );
+ if (result.rowCount !== 1) return staleResult(change);
+ return {
+ ok: true,
+ mappingRevision: String(
+ result.rows[0]?.mapping_revision ?? change.previousMappingRevision,
+ ),
+ };
+}
+
+async function applyConfirmedRemoteContactState(client, change, clearUnresolvedConflict) {
+ requireExpectedRevision(change);
+ const mappingStatus = change.mappingStatus ?? 'synced';
+ if (mappingStatus !== 'synced' && mappingStatus !== 'pending_push') {
+ throw typedError('Invalid confirmed CardDAV mapping status', 'ERR_CARDDAV_MAPPING_STATUS');
+ }
+ let result;
+ if (change.expectedMappingRevision === null) {
+ result = await client.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, vcard_version,
+ remote_semantic_hash, local_contact_hash, last_synced_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,'synced',$7,$8,$9,NOW())
+ ON CONFLICT (address_book_id, href) DO NOTHING
+ RETURNING mapping_revision::text`,
+ [
+ change.addressBookId,
+ change.href,
+ change.remoteEtag ?? null,
+ change.vcard,
+ change.primaryEmail ?? null,
+ change.localContactId,
+ change.vcardVersion,
+ change.remoteSemanticHash,
+ change.localContactHash,
+ ],
+ );
+ } else {
+ const clearPendingIntent = change.supportsPendingIntent === false
+ || change.preservePendingIntent === true
+ ? ''
+ : `, pending_operation = NULL,
+ pending_vcard = NULL, pending_local_hash = NULL,
+ pending_remote_semantic_hash = NULL, pending_started_at = NULL`;
+ const clearLegacyProjection = change.clearLegacyProjection === true
+ ? ', legacy_projection = NULL'
+ : '';
+ result = await client.query(
+ `UPDATE carddav_remote_objects SET
+ href = $1, remote_etag = $2, vcard = $3, primary_email = $4,
+ local_contact_id = $5, mapping_status = '${mappingStatus}', vcard_version = $6,
+ remote_semantic_hash = $7, local_contact_hash = $8,
+ mapping_revision = mapping_revision + 1,
+ last_synced_at = NOW(), last_push_error_code = NULL,
+ last_push_error_at = NULL${clearPendingIntent}${clearLegacyProjection},
+ updated_at = NOW()
+ WHERE address_book_id = $9 AND href = $1
+ AND mapping_revision = $10::bigint
+ RETURNING mapping_revision::text`,
+ [
+ change.href,
+ change.remoteEtag ?? null,
+ change.vcard,
+ change.primaryEmail ?? null,
+ change.localContactId,
+ change.vcardVersion,
+ change.remoteSemanticHash,
+ change.localContactHash,
+ change.addressBookId,
+ String(change.expectedMappingRevision),
+ ],
+ );
+ }
+ if (result.rowCount !== 1) return staleResult(change);
+ const revision = result.rows[0]?.mapping_revision
+ ?? (change.expectedMappingRevision === null
+ ? '0'
+ : String(BigInt(change.expectedMappingRevision) + 1n));
+ if (clearUnresolvedConflict) {
+ await client.query(
+ `DELETE FROM carddav_conflicts
+ WHERE address_book_id = $1 AND href = $2 AND status = 'unresolved'`,
+ [change.addressBookId, change.href],
+ );
+ }
+ return { ok: true, mappingRevision: String(revision) };
+}
+
+export async function applyConfirmedRemoteContact(client, change) {
+ return applyConfirmedRemoteContactState(client, change, true);
+}
+
+async function applyRemoteTombstoneState(client, change, clearUnresolvedConflict) {
+ requireExpectedRevision(change);
+ const result = await client.query(
+ `DELETE FROM carddav_remote_objects
+ WHERE address_book_id = $1 AND href = $2
+ AND mapping_revision = $3::bigint
+ RETURNING mapping_revision::text`,
+ [change.addressBookId, change.href, String(change.expectedMappingRevision)],
+ );
+ if (result.rowCount !== 1) return staleResult(change);
+ if (clearUnresolvedConflict) {
+ await client.query(
+ `DELETE FROM carddav_conflicts
+ WHERE address_book_id = $1 AND href = $2 AND status = 'unresolved'`,
+ [change.addressBookId, change.href],
+ );
+ }
+ return {
+ ok: true,
+ mappingRevision: String(result.rows[0]?.mapping_revision ?? change.expectedMappingRevision),
+ };
+}
+
+export async function applyRemoteTombstone(client, change) {
+ return applyRemoteTombstoneState(client, change, true);
+}
+
+export async function resolveCarddavConflict(client, change) {
+ requireExpectedRevision(change);
+ const mapping = change.remoteTombstone
+ ? await applyRemoteTombstoneState(client, change, false)
+ : await applyConfirmedRemoteContactState(client, change, false);
+ if (!mapping.ok) return mapping;
+ const result = await client.query(
+ `UPDATE carddav_conflicts
+ SET status = 'resolved', resolution = $2, resolved_by = $3,
+ resolved_at = NOW(), updated_at = NOW()
+ WHERE id = $1 AND status = 'unresolved'
+ AND address_book_id = $4 AND href = $5 AND user_id = $3
+ RETURNING *`,
+ [
+ change.conflictId,
+ change.resolution,
+ change.userId,
+ change.addressBookId,
+ change.href,
+ ],
+ );
+ if (result.rowCount !== 1) return staleResult(change);
+ return { ...mapping, conflict: result.rows[0] };
+}
+
+export async function refreshUnresolvedConflict(client, change) {
+ requireExpectedRevision(change);
+ const preserveLocalSnapshot = change.preserveLocalSnapshot === true;
+ let localVCard = change.localVCard ?? null;
+ let localTombstone = change.localTombstone ?? false;
+ if (preserveLocalSnapshot) {
+ const { rows: [existingConflict] } = await client.query(
+ `SELECT local_vcard, local_tombstone
+ FROM carddav_conflicts
+ WHERE address_book_id = $1 AND href = $2 AND status = 'unresolved'`,
+ [change.addressBookId, change.href],
+ );
+ if (!existingConflict) {
+ throw typedError(
+ 'The unresolved CardDAV conflict local snapshot is missing',
+ 'ERR_CARDDAV_CONFLICT_MISSING',
+ );
+ }
+ localVCard = existingConflict.local_vcard;
+ localTombstone = existingConflict.local_tombstone;
+ }
+ assertConflictSnapshotsWithinLimit({
+ localVCard,
+ remoteVCard: change.remoteVCard,
+ localTombstone,
+ remoteTombstone: change.remoteTombstone,
+ });
+ const localSnapshotUpdate = preserveLocalSnapshot
+ ? ''
+ : `
+ local_vcard = EXCLUDED.local_vcard,
+ local_tombstone = EXCLUDED.local_tombstone,`;
+ const clearPendingIntent = change.supportsPendingIntent === false
+ ? ''
+ : `pending_operation = NULL, pending_vcard = NULL,
+ pending_local_hash = NULL, pending_remote_semantic_hash = NULL,
+ pending_started_at = NULL,
+ `;
+ const mapping = await client.query(
+ `UPDATE carddav_remote_objects SET
+ mapping_status = 'conflict',
+ ${clearPendingIntent}mapping_revision = mapping_revision + 1,
+ updated_at = NOW()
+ WHERE address_book_id = $1 AND href = $2
+ AND mapping_revision = $3::bigint
+ RETURNING mapping_revision::text`,
+ [
+ change.addressBookId,
+ change.href,
+ String(change.expectedMappingRevision),
+ ],
+ );
+ if (mapping.rowCount !== 1) return staleResult(change);
+ const revision = mapping.rows[0]?.mapping_revision
+ ?? String(BigInt(change.expectedMappingRevision) + 1n);
+ const { rows: [conflict] } = await client.query(
+ `INSERT INTO carddav_conflicts (
+ address_book_id, href, user_id, base_local_hash, remote_etag,
+ local_vcard, remote_vcard, local_tombstone, remote_tombstone
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ ON CONFLICT (address_book_id, href) WHERE status = 'unresolved'
+ DO UPDATE SET
+ base_local_hash = EXCLUDED.base_local_hash,
+ remote_etag = EXCLUDED.remote_etag,${localSnapshotUpdate}
+ remote_vcard = EXCLUDED.remote_vcard,
+ remote_tombstone = EXCLUDED.remote_tombstone,
+ updated_at = NOW()
+ RETURNING id, status`,
+ [
+ change.addressBookId,
+ change.href,
+ change.userId,
+ change.baseLocalHash ?? null,
+ change.remoteEtag ?? null,
+ localVCard,
+ change.remoteVCard ?? null,
+ localTombstone,
+ change.remoteTombstone ?? false,
+ ],
+ );
+ return { ok: true, mappingRevision: String(revision), conflict };
+}
+
+export async function persistDiscoveredBook(client, book) {
+ const capabilities = normalizeCarddavCapabilities(book.capabilities);
+ const displayName = book.displayName || 'CardDAV';
+ const capabilityUpdate = book.preserveCapabilities === true
+ ? ''
+ : `,
+ remote_create_capability = EXCLUDED.remote_create_capability,
+ remote_update_capability = EXCLUDED.remote_update_capability,
+ remote_delete_capability = EXCLUDED.remote_delete_capability`;
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const name = attempt === 0 ? displayName : `${displayName} (${attempt + 1})`;
+ await client.query('SAVEPOINT carddav_book_name');
+ try {
+ const { rows: [stored] } = await client.query(
+ `INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability
+ ) VALUES ($1,$2,'carddav',$3,$4,$5,$6)
+ ON CONFLICT (user_id, external_url)
+ WHERE source = 'carddav' AND external_url IS NOT NULL
+ DO UPDATE SET
+ external_url = EXCLUDED.external_url${capabilityUpdate},
+ updated_at = NOW()
+ RETURNING id, external_url, remote_sync_token,
+ remote_sync_revision::text, sync_token,
+ remote_projection_fingerprint`,
+ [
+ book.userId,
+ name,
+ book.url,
+ capabilities.create,
+ capabilities.update,
+ capabilities.delete,
+ ],
+ );
+ await client.query('RELEASE SAVEPOINT carddav_book_name');
+ return stored;
+ } catch (error) {
+ if (error.code !== '23505') throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_book_name');
+ await client.query('RELEASE SAVEPOINT carddav_book_name');
+ }
+ }
+ throw new Error(`Could not create a local address book for "${displayName}"`);
+}
+
+const CAPABILITY_COLUMNS = {
+ create: 'remote_create_capability',
+ update: 'remote_update_capability',
+ delete: 'remote_delete_capability',
+};
+
+export async function persistDeniedBookCapability(client, {
+ userId,
+ addressBookId,
+ capability,
+}) {
+ const column = CAPABILITY_COLUMNS[capability];
+ if (!column) {
+ throw typedError('Invalid CardDAV book capability', 'ERR_CARDDAV_BOOK_CAPABILITY');
+ }
+ return client.query(
+ `UPDATE address_books
+ SET ${column} = 'denied', updated_at = NOW()
+ WHERE id = $1 AND user_id = $2`,
+ [addressBookId, userId],
+ );
+}
+
+export async function advanceDiscoveredBookState(client, state) {
+ const capabilities = normalizeCarddavCapabilities(state.capabilities);
+ return client.query(`
+ UPDATE address_books SET
+ external_url = COALESCE($6, external_url),
+ remote_sync_token = $2,
+ remote_sync_capability = $3,
+ remote_sync_revision = remote_sync_revision + 1,
+ remote_projection_fingerprint = $4,
+ remote_create_capability = $7,
+ remote_update_capability = $8,
+ remote_delete_capability = $9,
+ updated_at = NOW()
+ WHERE id = $1 AND remote_sync_revision = $5::bigint
+ `, [
+ state.addressBookId,
+ state.remoteSyncToken,
+ state.remoteSyncCapability,
+ state.remoteProjectionFingerprint,
+ state.expectedRemoteRevision,
+ state.canonicalUrl,
+ capabilities.create,
+ capabilities.update,
+ capabilities.delete,
+ ]);
+}
diff --git a/backend/src/services/carddavMappingState.test.js b/backend/src/services/carddavMappingState.test.js
new file mode 100644
index 0000000..7c67246
--- /dev/null
+++ b/backend/src/services/carddavMappingState.test.js
@@ -0,0 +1,562 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ advanceDiscoveredBookState,
+ applyConfirmedRemoteContact,
+ applyRemoteTombstone,
+ lockCarddavMapping,
+ persistDiscoveredBook,
+ persistPendingMutationIntent,
+ persistDeniedBookCapability,
+ refreshUnresolvedConflict,
+} from './carddavMappingState.js';
+import * as mappingState from './carddavMappingState.js';
+
+const USER_ID = '00000000-0000-4000-8000-000000000001';
+const BOOK_ID = '00000000-0000-4000-8000-000000000002';
+const CONTACT_ID = '00000000-0000-4000-8000-000000000003';
+const CONFLICT_ID = '00000000-0000-4000-8000-000000000004';
+const HREF = 'https://dav.example.test/addressbooks/default/contact.vcf';
+
+function change(overrides = {}) {
+ return {
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ remoteEtag: '"remote-2"',
+ vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact\r\nFN:Remote\r\nEND:VCARD\r\n',
+ primaryEmail: 'remote@example.test',
+ localContactId: CONTACT_ID,
+ vcardVersion: '3.0',
+ remoteSemanticHash: 'remote-hash-2',
+ localContactHash: 'local-hash-2',
+ ...overrides,
+ };
+}
+
+describe('lockCarddavMapping', () => {
+ it('locks the mapping and linked contact for the owned identity', async () => {
+ const mapping = { href: HREF, mapping_revision: '7', contact_id: CONTACT_ID };
+ const client = { query: vi.fn(async () => ({ rows: [mapping] })) };
+ await expect(lockCarddavMapping(client, {
+ userId: USER_ID, addressBookId: BOOK_ID, href: HREF,
+ })).resolves.toEqual(mapping);
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/FOR UPDATE OF mapping, contact/),
+ [BOOK_ID, HREF, USER_ID],
+ );
+ });
+});
+
+describe('mapping revision compare-and-commit', () => {
+ it('persists one bounded update intent while advancing the locked mapping revision', async () => {
+ const pendingStartedAt = '2026-07-12 12:00:00.123456+00';
+ const client = { query: vi.fn(async sql => {
+ if (sql.includes('FOR UPDATE OF mapping, contact')) {
+ return { rows: [{
+ ...change(),
+ mapping_revision: '7',
+ pending_operation: null,
+ }] };
+ }
+ return { rows: [{ mapping_revision: '8', pending_started_at: pendingStartedAt }], rowCount: 1 };
+ }) };
+
+ await expect(persistPendingMutationIntent(client, {
+ userId: USER_ID,
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ operation: 'update',
+ pendingVCard: change().vcard,
+ pendingLocalHash: 'local-hash-before',
+ pendingRemoteSemanticHash: 'remote-hash-2',
+ })).resolves.toEqual({ ok: true, mappingRevision: '8', pendingStartedAt });
+
+ const update = client.query.mock.calls.find(([sql]) => (
+ sql.includes('UPDATE carddav_remote_objects')
+ ));
+ expect(update[0]).toMatch(/pending_operation = \$4[\s\S]+pending_started_at = NOW\(\)/);
+ expect(update[0]).toContain("mapping_status = 'pending_push'");
+ expect(update[0]).toContain('mapping_revision = mapping_revision + 1');
+ expect(update[1]).toEqual([
+ BOOK_ID,
+ HREF,
+ '7',
+ 'update',
+ change().vcard,
+ 'local-hash-before',
+ 'remote-hash-2',
+ ]);
+ });
+
+ it('rejects a pending update vCard one byte over 1 MiB before locking the mapping', async () => {
+ const client = { query: vi.fn() };
+ const oversized = `BEGIN:VCARD\r\n${'X'.repeat(1024 * 1024)}\r\nEND:VCARD\r\n`;
+ expect(Buffer.byteLength(oversized, 'utf8')).toBeGreaterThan(1024 * 1024);
+
+ await expect(persistPendingMutationIntent(client, {
+ userId: USER_ID,
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ operation: 'update',
+ pendingVCard: oversized,
+ pendingLocalHash: 'local-hash-before',
+ pendingRemoteSemanticHash: 'remote-hash-2',
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_PENDING_INTENT_TOO_LARGE' });
+ // Rejected by the size gate before any lock query touches the database.
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it('admits a pending update vCard at exactly 1 MiB past the size gate to the mapping lock', async () => {
+ const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) };
+ const frame = Buffer.byteLength('BEGIN:VCARD\r\n\r\nEND:VCARD\r\n', 'utf8');
+ const exact = `BEGIN:VCARD\r\n${'X'.repeat(1024 * 1024 - frame)}\r\nEND:VCARD\r\n`;
+ expect(Buffer.byteLength(exact, 'utf8')).toBe(1024 * 1024);
+
+ // Exactly at the limit passes the size gate and reaches the mapping lock, which
+ // reports the mapping stale here (rows: []) rather than raising the size error.
+ await expect(persistPendingMutationIntent(client, {
+ userId: USER_ID,
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ operation: 'update',
+ pendingVCard: exact,
+ pendingLocalHash: 'local-hash-before',
+ pendingRemoteSemanticHash: 'remote-hash-2',
+ })).resolves.toMatchObject({ ok: false });
+ expect(client.query).toHaveBeenCalled();
+ });
+
+ it('rejects a second intent while the locked mapping already has one', async () => {
+ const client = { query: vi.fn(async () => ({ rows: [{
+ mapping_revision: '7',
+ pending_operation: 'delete',
+ pending_started_at: new Date('2026-07-01T00:00:00.000Z'),
+ }] })) };
+
+ await expect(persistPendingMutationIntent(client, {
+ userId: USER_ID,
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ operation: 'update',
+ pendingVCard: change().vcard,
+ pendingLocalHash: 'local-hash-before',
+ pendingRemoteSemanticHash: 'remote-hash-2',
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_PENDING_INTENT' });
+
+ expect(client.query).toHaveBeenCalledOnce();
+ });
+
+ it('restores only the matching pending intent to its exact confirmed mapping state', async () => {
+ const previousUpdatedAt = new Date('2026-07-12T11:59:00.000Z');
+ const pendingStartedAt = '2026-07-12 12:00:00.123456+00';
+ const client = { query: vi.fn(async () => ({
+ rows: [{ mapping_revision: '7' }],
+ rowCount: 1,
+ })) };
+
+ await expect(mappingState.restorePendingMutationIntent(client, {
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '8',
+ operation: 'update',
+ pendingVCard: change().vcard,
+ pendingLocalHash: 'local-hash-before',
+ pendingRemoteSemanticHash: 'remote-hash-2',
+ pendingStartedAt,
+ previousMappingStatus: 'synced',
+ previousMappingRevision: '7',
+ previousUpdatedAt,
+ })).resolves.toEqual({ ok: true, mappingRevision: '7' });
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /UPDATE carddav_remote_objects[\s\S]+mapping_status = \$8[\s\S]+mapping_revision = \$9::bigint[\s\S]+updated_at = \$10[\s\S]+mapping_revision = \$3::bigint[\s\S]+pending_operation = \$4[\s\S]+pending_vcard IS NOT DISTINCT FROM \$5[\s\S]+pending_local_hash IS NOT DISTINCT FROM \$6[\s\S]+pending_remote_semantic_hash IS NOT DISTINCT FROM \$7[\s\S]+pending_started_at = \$11::timestamptz/,
+ ),
+ [
+ BOOK_ID,
+ HREF,
+ '8',
+ 'update',
+ change().vcard,
+ 'local-hash-before',
+ 'remote-hash-2',
+ 'synced',
+ '7',
+ previousUpdatedAt,
+ pendingStartedAt,
+ ],
+ );
+ });
+
+ it('reports a stale rollback fence without clobbering an intervening mapping change', async () => {
+ const pendingStartedAt = '2026-07-12 12:00:00.123456+00';
+ const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) };
+
+ await expect(mappingState.restorePendingMutationIntent(client, {
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '8',
+ operation: 'delete',
+ pendingVCard: null,
+ pendingLocalHash: 'local-hash-before',
+ pendingRemoteSemanticHash: null,
+ pendingStartedAt,
+ previousMappingStatus: 'pending_push',
+ previousMappingRevision: '7',
+ previousUpdatedAt: new Date('2026-07-12T11:59:00.000Z'),
+ })).resolves.toEqual({
+ ok: false,
+ stale: true,
+ code: 'ERR_CARDDAV_MAPPING_STALE',
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '8',
+ });
+
+ expect(client.query).toHaveBeenCalledOnce();
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringContaining('pending_started_at = $11::timestamptz'),
+ expect.arrayContaining([pendingStartedAt]),
+ );
+ });
+
+ it('persists a confirmed remote snapshot and returns its incremented revision', async () => {
+ const client = { query: vi.fn(async sql => (
+ sql.includes('DELETE FROM carddav_conflicts')
+ ? { rows: [], rowCount: 0 }
+ : { rows: [{ mapping_revision: '8' }], rowCount: 1 }
+ )) };
+ await expect(applyConfirmedRemoteContact(client, change())).resolves.toEqual({
+ ok: true, mappingRevision: '8',
+ });
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /UPDATE carddav_remote_objects[\s\S]+mapping_status = 'synced'[\s\S]+mapping_revision = mapping_revision \+ 1[\s\S]+mapping_revision = \$10::bigint[\s\S]+RETURNING mapping_revision::text/,
+ ),
+ [HREF, '"remote-2"', change().vcard, 'remote@example.test', CONTACT_ID,
+ '3.0', 'remote-hash-2', 'local-hash-2', BOOK_ID, '7'],
+ );
+ });
+
+ it('retains a local-only change as pending push while confirming harmless remote drift', async () => {
+ const client = { query: vi.fn(async sql => (
+ sql.includes('DELETE FROM carddav_conflicts')
+ ? { rows: [], rowCount: 0 }
+ : { rows: [{ mapping_revision: '8' }], rowCount: 1 }
+ )) };
+
+ await applyConfirmedRemoteContact(client, change({
+ mappingStatus: 'pending_push',
+ localContactHash: 'confirmed-local-hash',
+ supportsPendingIntent: true,
+ preservePendingIntent: true,
+ }));
+
+ const update = client.query.mock.calls.find(([sql]) => (
+ sql.includes('UPDATE carddav_remote_objects')
+ ));
+ expect(update[0]).toContain("mapping_status = 'pending_push'");
+ expect(update[1]).toContain('confirmed-local-hash');
+ expect(update[0]).not.toContain('pending_operation = NULL');
+ });
+
+ it('clears an unresolved conflict after a public remote-tombstone transition', async () => {
+ const client = { query: vi.fn(async sql => (
+ sql.includes('DELETE FROM carddav_remote_objects')
+ ? { rows: [{ mapping_revision: '7' }], rowCount: 1 }
+ : { rows: [], rowCount: 1 }
+ )) };
+
+ await expect(applyRemoteTombstone(client, {
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ })).resolves.toMatchObject({ ok: true });
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /DELETE FROM carddav_conflicts[\s\S]+status = 'unresolved'/,
+ ),
+ [BOOK_ID, HREF],
+ );
+ });
+
+ it.each([
+ ['confirmed contact', applyConfirmedRemoteContact, change()],
+ ['remote tombstone', applyRemoteTombstone, {
+ addressBookId: BOOK_ID, href: HREF, expectedMappingRevision: '7',
+ }],
+ ])('returns typed stale state for a missed %s revision', async (_case, apply, input) => {
+ const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) };
+ await expect(apply(client, input)).resolves.toEqual({
+ ok: false,
+ stale: true,
+ code: 'ERR_CARDDAV_MAPPING_STALE',
+ addressBookId: BOOK_ID,
+ href: HREF,
+ expectedMappingRevision: '7',
+ });
+ });
+
+ it.each([
+ ['confirmed contact', applyConfirmedRemoteContact, change({ expectedMappingRevision: undefined })],
+ ['remote tombstone', applyRemoteTombstone, { addressBookId: BOOK_ID, href: HREF }],
+ ['conflict refresh', refreshUnresolvedConflict, change({ expectedMappingRevision: undefined })],
+ ])('requires an expected revision for every %s write', async (_case, apply, input) => {
+ const client = { query: vi.fn() };
+ await expect(apply(client, input)).rejects.toMatchObject({
+ code: 'ERR_CARDDAV_MAPPING_REVISION_REQUIRED',
+ });
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['confirmed contact', false],
+ ['remote tombstone', true],
+ ])('owns the conflict transition together with the %s mapping CAS', async (
+ _case,
+ remoteTombstone,
+ ) => {
+ const resolved = {
+ id: CONFLICT_ID,
+ status: 'resolved',
+ resolution: 'keep-carddav',
+ };
+ const client = { query: vi.fn(async sql => {
+ if (sql.includes('UPDATE carddav_conflicts')) {
+ return { rows: [resolved], rowCount: 1 };
+ }
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '8' }], rowCount: 1 };
+ }
+ if (sql.includes('DELETE FROM carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '7' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 0 };
+ }) };
+
+ expect(mappingState.resolveCarddavConflict).toBeTypeOf('function');
+ await expect(mappingState.resolveCarddavConflict(client, change({
+ conflictId: CONFLICT_ID,
+ userId: USER_ID,
+ resolution: 'keep-carddav',
+ remoteTombstone,
+ }))).resolves.toMatchObject({
+ ok: true,
+ conflict: resolved,
+ mappingRevision: remoteTombstone ? '7' : '8',
+ });
+
+ const statements = client.query.mock.calls.map(([sql]) => sql);
+ const conflictIndex = statements.findIndex(sql => sql.includes('UPDATE carddav_conflicts'));
+ const mappingIndex = statements.findIndex(sql => (
+ sql.includes(`${remoteTombstone ? 'DELETE FROM' : 'UPDATE'} carddav_remote_objects`)
+ ));
+ expect(mappingIndex).toBeGreaterThanOrEqual(0);
+ expect(conflictIndex).toBeGreaterThan(mappingIndex);
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/UPDATE carddav_conflicts[\s\S]+status = 'resolved'[\s\S]+status = 'unresolved'/),
+ [CONFLICT_ID, 'keep-carddav', USER_ID, BOOK_ID, HREF],
+ );
+ });
+});
+
+describe('refreshUnresolvedConflict', () => {
+ it('omits pending-intent columns while refreshing a transitional-schema conflict', async () => {
+ const client = { query: vi.fn(async sql => {
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '8' }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 0 };
+ }) };
+
+ await refreshUnresolvedConflict(client, change({
+ userId: USER_ID,
+ localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n',
+ remoteVCard: change().vcard,
+ supportsPendingIntent: false,
+ }));
+
+ const update = client.query.mock.calls.find(([sql]) => (
+ sql.includes('UPDATE carddav_remote_objects')
+ ));
+ expect(update[0]).not.toContain('pending_operation');
+ });
+
+ it('refreshes the same conflict while advancing the mapping once', async () => {
+ const client = { query: vi.fn(async sql => {
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '8' }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 0 };
+ }) };
+ await expect(refreshUnresolvedConflict(client, change({
+ userId: USER_ID,
+ baseLocalHash: 'confirmed-local-hash',
+ localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n',
+ remoteVCard: change().vcard,
+ localTombstone: false,
+ remoteTombstone: false,
+ }))).resolves.toEqual({
+ ok: true,
+ mappingRevision: '8',
+ conflict: { id: CONFLICT_ID, status: 'unresolved' },
+ });
+ const conflictInsert = client.query.mock.calls.find(([sql]) => (
+ sql.includes('INSERT INTO carddav_conflicts')
+ ));
+ expect(conflictInsert[0]).toContain(
+ "ON CONFLICT (address_book_id, href) WHERE status = 'unresolved'",
+ );
+ });
+
+ it('preserves the durable local snapshot while refreshing the remote side', async () => {
+ const durableLocalVCard = 'BEGIN:VCARD\r\nFN:Durable Local\r\nEND:VCARD\r\n';
+ const client = { query: vi.fn(async sql => {
+ if (sql.includes('SELECT local_vcard, local_tombstone')) {
+ return { rows: [{
+ local_vcard: durableLocalVCard,
+ local_tombstone: true,
+ }], rowCount: 1 };
+ }
+ if (sql.includes('UPDATE carddav_remote_objects')) {
+ return { rows: [{ mapping_revision: '8' }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_conflicts')) {
+ return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 0 };
+ }) };
+
+ await refreshUnresolvedConflict(client, change({
+ userId: USER_ID,
+ baseLocalHash: 'confirmed-local-hash',
+ preserveLocalSnapshot: true,
+ localVCard: undefined,
+ remoteVCard: change().vcard,
+ localTombstone: undefined,
+ remoteTombstone: false,
+ }));
+
+ const conflictInsert = client.query.mock.calls.find(([sql]) => (
+ sql.includes('INSERT INTO carddav_conflicts')
+ ));
+ expect(conflictInsert[0]).not.toMatch(/local_vcard = EXCLUDED\.local_vcard/);
+ expect(conflictInsert[0]).not.toMatch(/local_tombstone = EXCLUDED\.local_tombstone/);
+ expect(conflictInsert[1].slice(5, 9)).toEqual([
+ durableLocalVCard,
+ change().vcard,
+ true,
+ false,
+ ]);
+ const mappingUpdate = client.query.mock.calls.find(([sql]) => (
+ sql.includes('UPDATE carddav_remote_objects')
+ ));
+ expect(mappingUpdate[0]).toMatch(/pending_operation = NULL[\s\S]+pending_started_at = NULL/);
+ });
+});
+
+describe('persistDiscoveredBook', () => {
+ it('persists capabilities without overwriting sync state', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ const client = { query: vi.fn(async () => ({ rows: [stored], rowCount: 1 })) };
+ await expect(persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'denied', delete: 'unknown' },
+ })).resolves.toEqual(stored);
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /INSERT INTO address_books[\s\S]+remote_create_capability[\s\S]+ON CONFLICT[\s\S]+remote_create_capability = EXCLUDED.remote_create_capability/,
+ ),
+ [USER_ID, 'Remote', stored.external_url, 'allowed', 'denied', 'unknown'],
+ );
+ });
+
+ it('owns a single denied capability update without changing its siblings', async () => {
+ const client = { query: vi.fn(async () => ({ rows: [{ id: BOOK_ID }], rowCount: 1 })) };
+
+ await persistDeniedBookCapability(client, {
+ userId: USER_ID,
+ addressBookId: BOOK_ID,
+ capability: 'update',
+ });
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /UPDATE address_books[\s\S]+remote_update_capability = 'denied'[\s\S]+WHERE id = \$1 AND user_id = \$2/,
+ ),
+ [BOOK_ID, USER_ID],
+ );
+ expect(client.query.mock.calls[0][0]).not.toMatch(/remote_(?:create|delete)_capability =/);
+ });
+
+ it('owns guarded remote token and observed-capability advancement', async () => {
+ const client = { query: vi.fn(async () => ({ rows: [], rowCount: 1 })) };
+
+ await advanceDiscoveredBookState(client, {
+ addressBookId: BOOK_ID,
+ expectedRemoteRevision: '4',
+ canonicalUrl: 'https://dav.example.test/addressbooks/default/',
+ remoteSyncToken: 'opaque-token-2',
+ remoteSyncCapability: 'supported',
+ remoteProjectionFingerprint: 'projection-fingerprint',
+ capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' },
+ });
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /UPDATE address_books[\s\S]+remote_sync_token = \$2[\s\S]+remote_create_capability = \$7[\s\S]+remote_sync_revision = \$5::bigint/,
+ ),
+ [
+ BOOK_ID,
+ 'opaque-token-2',
+ 'supported',
+ 'projection-fingerprint',
+ '4',
+ 'https://dav.example.test/addressbooks/default/',
+ 'allowed',
+ 'unknown',
+ 'denied',
+ ],
+ );
+ });
+});
+
+describe('assertConflictSnapshotsWithinLimit', () => {
+ const HALF = 'x'.repeat(1024 * 1024);
+
+ it('accepts combined local and remote snapshots at exactly 2 MiB', () => {
+ expect(() => mappingState.assertConflictSnapshotsWithinLimit({
+ localVCard: HALF,
+ remoteVCard: HALF,
+ })).not.toThrow();
+ });
+
+ it('rejects combined snapshots one byte over 2 MiB', () => {
+ expect(() => mappingState.assertConflictSnapshotsWithinLimit({
+ localVCard: `${HALF}x`,
+ remoteVCard: HALF,
+ })).toThrow(/2 MiB/);
+ });
+
+ it('excludes a tombstoned side from the combined size', () => {
+ expect(() => mappingState.assertConflictSnapshotsWithinLimit({
+ localVCard: null,
+ remoteVCard: 'x'.repeat(2 * 1024 * 1024),
+ localTombstone: true,
+ })).not.toThrow();
+ });
+});
diff --git a/backend/src/services/carddavMigration.db.test.js b/backend/src/services/carddavMigration.db.test.js
new file mode 100644
index 0000000..a25b6bb
--- /dev/null
+++ b/backend/src/services/carddavMigration.db.test.js
@@ -0,0 +1,1129 @@
+import { randomUUID } from 'crypto';
+import { mkdtemp, readdir, rm, symlink, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+import pg from 'pg';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { runMigrationsWithPool } from './migrations.js';
+import {
+ applyTestMigrations,
+ assertMinimumPostgresVersion,
+ createTestDatabase,
+ dropTestDatabase,
+ postgresTestContext,
+} from './postgresTestHelpers.js';
+
+const { Client, Pool } = pg;
+const { databaseUrl, connectionStringFor } = postgresTestContext('CardDAV migration tests');
+const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
+const databaseSuffix = `${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
+const databaseNames = {
+ upgrade: `carddav_upgrade_${databaseSuffix}`,
+ fresh: `carddav_fresh_${databaseSuffix}`,
+ runner: `carddav_runner_${databaseSuffix}`,
+ failure: `carddav_failure_${databaseSuffix}`,
+ populatedDisjoint: `carddav_populated_disjoint_${databaseSuffix}`,
+ populatedCollision: `carddav_populated_collision_${databaseSuffix}`,
+ populatedFirst: `carddav_populated_first_${databaseSuffix}`,
+ emptyDuplicates: `carddav_empty_duplicates_${databaseSuffix}`,
+};
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+let adminClient;
+
+async function applyMigrations(client, firstVersion, lastVersion) {
+ await applyTestMigrations(client, {
+ migrationsDirectory,
+ first: firstVersion,
+ through: lastVersion,
+ transactionPerMigration: true,
+ });
+}
+
+async function createMigrationDirectory(firstVersion, lastVersion) {
+ const directory = await mkdtemp(join(tmpdir(), 'mailflow-migrations-'));
+ const filenames = (await readdir(migrationsDirectory))
+ .filter(filename => /^\d{4}_.+\.sql$/.test(filename))
+ .filter(filename => filename.slice(0, 4) >= firstVersion)
+ .filter(filename => filename.slice(0, 4) <= lastVersion);
+
+ await Promise.all(filenames.map(filename => symlink(
+ join(migrationsDirectory, filename),
+ join(directory, filename),
+ )));
+ return directory;
+}
+
+async function readCardDavRows(client, userId) {
+ const { rows: books } = await client.query(`
+ SELECT *
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY id
+ `, [userId]);
+ const { rows: contacts } = await client.query(`
+ SELECT *
+ FROM contacts
+ WHERE user_id = $1
+ ORDER BY id
+ `, [userId]);
+
+ return { books, contacts };
+}
+
+async function withDatabase(name, callback) {
+ const client = new Client({ connectionString: connectionStringFor(name) });
+ await client.connect();
+ try {
+ await callback(client);
+ } finally {
+ await client.end();
+ }
+}
+
+async function readColumns(client, tableName) {
+ const { rows } = await client.query(`
+ SELECT column_name, data_type, is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = $1
+ ORDER BY ordinal_position
+ `, [tableName]);
+
+ return Object.fromEntries(rows.map(({ column_name, ...column }) => [column_name, column]));
+}
+
+async function readPrimaryKey(client, tableName) {
+ const { rows } = await client.query(`
+ SELECT key_column_usage.column_name
+ FROM information_schema.table_constraints
+ JOIN information_schema.key_column_usage
+ USING (constraint_catalog, constraint_schema, constraint_name, table_catalog, table_schema, table_name)
+ WHERE table_schema = 'public'
+ AND table_name = $1
+ AND constraint_type = 'PRIMARY KEY'
+ ORDER BY key_column_usage.ordinal_position
+ `, [tableName]);
+
+ return rows.map(row => row.column_name);
+}
+
+async function readForeignKeys(client, tableName) {
+ const { rows } = await client.query(`
+ SELECT source_attribute.attname AS column_name,
+ target_table.relname AS referenced_table,
+ target_attribute.attname AS referenced_column,
+ CASE constraint_row.confdeltype
+ WHEN 'a' THEN 'NO ACTION'
+ WHEN 'r' THEN 'RESTRICT'
+ WHEN 'c' THEN 'CASCADE'
+ WHEN 'n' THEN 'SET NULL'
+ WHEN 'd' THEN 'SET DEFAULT'
+ END AS delete_rule
+ FROM pg_constraint constraint_row
+ JOIN pg_class source_table ON source_table.oid = constraint_row.conrelid
+ JOIN pg_namespace source_schema ON source_schema.oid = source_table.relnamespace
+ JOIN pg_class target_table ON target_table.oid = constraint_row.confrelid
+ JOIN LATERAL unnest(constraint_row.conkey, constraint_row.confkey)
+ WITH ORDINALITY AS key_columns(source_attnum, target_attnum, position) ON true
+ JOIN pg_attribute source_attribute
+ ON source_attribute.attrelid = source_table.oid
+ AND source_attribute.attnum = key_columns.source_attnum
+ JOIN pg_attribute target_attribute
+ ON target_attribute.attrelid = target_table.oid
+ AND target_attribute.attnum = key_columns.target_attnum
+ WHERE source_schema.nspname = 'public'
+ AND source_table.relname = $1
+ AND constraint_row.contype = 'f'
+ ORDER BY source_attribute.attname
+ `, [tableName]);
+
+ return rows;
+}
+
+async function readChecks(client, tableNames) {
+ const { rows } = await client.query(`
+ SELECT table_constraints.table_name, check_constraints.check_clause
+ FROM information_schema.table_constraints
+ JOIN information_schema.check_constraints
+ USING (constraint_catalog, constraint_schema, constraint_name)
+ WHERE table_constraints.table_schema = 'public'
+ AND table_constraints.table_name = ANY($1::text[])
+ AND table_constraints.constraint_type = 'CHECK'
+ ORDER BY table_constraints.table_name, table_constraints.constraint_name
+ `, [tableNames]);
+
+ return rows;
+}
+
+async function readIndexDefinitions(client, indexNames) {
+ const { rows } = await client.query(`
+ SELECT indexname, indexdef
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND indexname = ANY($1::text[])
+ ORDER BY indexname
+ `, [indexNames]);
+
+ return Object.fromEntries(rows.map(({ indexname, indexdef }) => [
+ indexname,
+ indexdef.replace(/^CREATE (UNIQUE )?INDEX \S+ ON \S+ USING btree /, '$1'),
+ ]));
+}
+
+async function expectIncrementalSchema(client) {
+ const addressBookColumns = await readColumns(client, 'address_books');
+ expect(addressBookColumns).toMatchObject({
+ remote_sync_token: { data_type: 'text', is_nullable: 'YES' },
+ remote_sync_capability: { data_type: 'text', is_nullable: 'NO' },
+ remote_sync_revision: { data_type: 'bigint', is_nullable: 'NO' },
+ remote_projection_fingerprint: { data_type: 'text', is_nullable: 'YES' },
+ });
+
+ const remoteObjectColumns = await readColumns(client, 'carddav_remote_objects');
+ expect(remoteObjectColumns).toMatchObject({
+ address_book_id: { data_type: 'uuid', is_nullable: 'NO' },
+ href: { data_type: 'text', is_nullable: 'NO' },
+ remote_etag: { data_type: 'text', is_nullable: 'YES' },
+ vcard: { data_type: 'text', is_nullable: 'NO' },
+ primary_email: { data_type: 'text', is_nullable: 'YES' },
+ disposition: { data_type: 'text', is_nullable: 'NO' },
+ local_contact_id: { data_type: 'uuid', is_nullable: 'YES' },
+ merge_before: { data_type: 'jsonb', is_nullable: 'YES' },
+ merge_applied: { data_type: 'jsonb', is_nullable: 'YES' },
+ created_at: { data_type: 'timestamp with time zone', is_nullable: 'NO' },
+ updated_at: { data_type: 'timestamp with time zone', is_nullable: 'NO' },
+ });
+
+ expect(await readPrimaryKey(client, 'carddav_remote_objects'))
+ .toEqual(['address_book_id', 'href']);
+
+ expect(await readForeignKeys(client, 'carddav_remote_objects')).toEqual([
+ {
+ column_name: 'address_book_id',
+ referenced_table: 'address_books',
+ referenced_column: 'id',
+ delete_rule: 'CASCADE',
+ },
+ {
+ column_name: 'local_contact_id',
+ referenced_table: 'contacts',
+ referenced_column: 'id',
+ delete_rule: 'SET NULL',
+ },
+ ]);
+
+ expect(await readChecks(client, ['address_books', 'carddav_remote_objects'])).toEqual(
+ expect.arrayContaining([
+ {
+ table_name: 'address_books',
+ check_clause: "((remote_sync_capability = ANY (ARRAY['unknown'::text, 'sync-collection'::text, 'snapshot'::text])))",
+ },
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: "((disposition = ANY (ARRAY['separate'::text, 'merge'::text, 'skip'::text])))",
+ },
+ ]),
+ );
+
+ const indexDefinitions = await readIndexDefinitions(client, [
+ 'carddav_one_remote_book_idx',
+ 'carddav_remote_object_contact_idx',
+ 'carddav_remote_object_email_idx',
+ 'carddav_one_merge_source_per_contact_idx',
+ ]);
+ const definitions = Object.values(indexDefinitions);
+ expect(definitions).toContainEqual(expect.stringContaining(
+ 'UNIQUE (user_id, external_url) WHERE',
+ ));
+ expect(definitions).toContainEqual(expect.stringContaining(
+ "WHERE ((disposition = 'merge'::text) AND (local_contact_id IS NOT NULL))",
+ ));
+ expect(definitions).toContain(
+ "UNIQUE (user_id, external_url) WHERE ((source = 'carddav'::text) AND (external_url IS NOT NULL))",
+ );
+ expect(definitions).toContain(
+ "UNIQUE (local_contact_id) WHERE ((disposition = 'merge'::text) AND (local_contact_id IS NOT NULL))",
+ );
+ expect(definitions).toContain(
+ '(local_contact_id) WHERE (local_contact_id IS NOT NULL)',
+ );
+ expect(definitions).toContain('(address_book_id, primary_email)');
+
+ const userId = randomUUID();
+ const contactId = randomUUID();
+ await client.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-migration-${userId}`],
+ );
+ const { rows: [addressBook] } = await client.query(`
+ INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES ($1, 'Remote', 'carddav', $2)
+ RETURNING id, remote_sync_revision
+ `, [userId, `https://carddav.example.test/${userId}`]);
+ expect(addressBook.remote_sync_revision).toBe('0');
+
+ await client.query(`
+ INSERT INTO contacts (id, address_book_id, user_id, uid, vcard, display_name)
+ VALUES ($1, $2, $3, 'indexed-contact', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'Indexed Contact')
+ `, [contactId, addressBook.id, userId]);
+ await client.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, vcard, primary_email, disposition, local_contact_id
+ ) VALUES
+ ($1, '/indexed.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'indexed@example.test', 'separate', $2),
+ ($1, '/skipped.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n', NULL, 'skip', NULL)
+ `, [addressBook.id, contactId]);
+
+ await client.query('BEGIN');
+ try {
+ await client.query('SET LOCAL enable_seqscan = off');
+ const { rows } = await client.query(`
+ EXPLAIN (FORMAT JSON)
+ SELECT href FROM carddav_remote_objects WHERE local_contact_id = $1
+ `, [contactId]);
+ expect(JSON.stringify(rows[0]['QUERY PLAN'])).toContain(
+ '"Index Name":"carddav_remote_object_contact_idx"',
+ );
+ } finally {
+ await client.query('ROLLBACK');
+ }
+}
+
+async function expectBidirectionalSchema(client) {
+ const addressBookColumns = await readColumns(client, 'address_books');
+ expect(addressBookColumns).toMatchObject({
+ remote_create_capability: {
+ data_type: 'text',
+ is_nullable: 'NO',
+ column_default: "'unknown'::text",
+ },
+ remote_update_capability: {
+ data_type: 'text',
+ is_nullable: 'NO',
+ column_default: "'unknown'::text",
+ },
+ remote_delete_capability: {
+ data_type: 'text',
+ is_nullable: 'NO',
+ column_default: "'unknown'::text",
+ },
+ });
+
+ const contactColumns = await readColumns(client, 'contacts');
+ expect(contactColumns.additional_fields).toEqual({
+ data_type: 'jsonb',
+ is_nullable: 'NO',
+ column_default: "'[]'::jsonb",
+ });
+
+ const mappingColumns = await readColumns(client, 'carddav_remote_objects');
+ expect(mappingColumns).toEqual({
+ address_book_id: { data_type: 'uuid', is_nullable: 'NO', column_default: null },
+ href: { data_type: 'text', is_nullable: 'NO', column_default: null },
+ remote_etag: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ vcard: { data_type: 'text', is_nullable: 'NO', column_default: null },
+ primary_email: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ disposition: {
+ data_type: 'text',
+ is_nullable: 'NO',
+ column_default: "'separate'::text",
+ },
+ local_contact_id: { data_type: 'uuid', is_nullable: 'YES', column_default: null },
+ merge_before: { data_type: 'jsonb', is_nullable: 'YES', column_default: null },
+ merge_applied: { data_type: 'jsonb', is_nullable: 'YES', column_default: null },
+ created_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'NO',
+ column_default: 'now()',
+ },
+ updated_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'NO',
+ column_default: 'now()',
+ },
+ mapping_status: {
+ data_type: 'text',
+ is_nullable: 'NO',
+ column_default: "'pending_materialization'::text",
+ },
+ vcard_version: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ remote_semantic_hash: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ local_contact_hash: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ mapping_revision: { data_type: 'bigint', is_nullable: 'NO', column_default: '0' },
+ last_synced_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'YES',
+ column_default: null,
+ },
+ last_push_error_code: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ last_push_error_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'YES',
+ column_default: null,
+ },
+ legacy_projection: { data_type: 'jsonb', is_nullable: 'YES', column_default: null },
+ });
+
+ const conflictColumns = await readColumns(client, 'carddav_conflicts');
+ expect(conflictColumns).toEqual({
+ id: { data_type: 'uuid', is_nullable: 'NO', column_default: 'gen_random_uuid()' },
+ address_book_id: { data_type: 'uuid', is_nullable: 'NO', column_default: null },
+ href: { data_type: 'text', is_nullable: 'NO', column_default: null },
+ user_id: { data_type: 'uuid', is_nullable: 'NO', column_default: null },
+ base_local_hash: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ remote_etag: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ local_vcard: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ remote_vcard: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ local_tombstone: { data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
+ remote_tombstone: { data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
+ status: { data_type: 'text', is_nullable: 'NO', column_default: "'unresolved'::text" },
+ resolution: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ resolved_by: { data_type: 'uuid', is_nullable: 'YES', column_default: null },
+ resolved_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'YES',
+ column_default: null,
+ },
+ created_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'NO',
+ column_default: 'now()',
+ },
+ updated_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'NO',
+ column_default: 'now()',
+ },
+ });
+
+ expect(await readPrimaryKey(client, 'carddav_conflicts')).toEqual(['id']);
+ expect(await readForeignKeys(client, 'carddav_conflicts')).toEqual([
+ {
+ column_name: 'address_book_id',
+ referenced_table: 'carddav_remote_objects',
+ referenced_column: 'address_book_id',
+ delete_rule: 'CASCADE',
+ },
+ {
+ column_name: 'href',
+ referenced_table: 'carddav_remote_objects',
+ referenced_column: 'href',
+ delete_rule: 'CASCADE',
+ },
+ {
+ column_name: 'resolved_by',
+ referenced_table: 'users',
+ referenced_column: 'id',
+ delete_rule: 'SET NULL',
+ },
+ {
+ column_name: 'user_id',
+ referenced_table: 'users',
+ referenced_column: 'id',
+ delete_rule: 'CASCADE',
+ },
+ ]);
+
+ const checks = await readChecks(client, [
+ 'address_books',
+ 'carddav_remote_objects',
+ 'carddav_conflicts',
+ ]);
+ expect(checks).toEqual(expect.arrayContaining([
+ {
+ table_name: 'address_books',
+ check_clause: "((remote_create_capability = ANY (ARRAY['unknown'::text, 'allowed'::text, 'denied'::text])))",
+ },
+ {
+ table_name: 'address_books',
+ check_clause: "((remote_update_capability = ANY (ARRAY['unknown'::text, 'allowed'::text, 'denied'::text])))",
+ },
+ {
+ table_name: 'address_books',
+ check_clause: "((remote_delete_capability = ANY (ARRAY['unknown'::text, 'allowed'::text, 'denied'::text])))",
+ },
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: "((mapping_status = ANY (ARRAY['pending_materialization'::text, 'synced'::text, 'pending_push'::text, 'conflict'::text])))",
+ },
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: '((mapping_revision >= 0))',
+ },
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: "(((vcard_version IS NULL) OR (vcard_version = ANY (ARRAY['3.0'::text, '4.0'::text]))))",
+ },
+ {
+ table_name: 'carddav_conflicts',
+ check_clause: "((status = ANY (ARRAY['unresolved'::text, 'resolved'::text])))",
+ },
+ {
+ table_name: 'carddav_conflicts',
+ check_clause: "(((resolution IS NULL) OR (resolution = ANY (ARRAY['keep-mailflow'::text, 'keep-carddav'::text]))))",
+ },
+ {
+ table_name: 'carddav_conflicts',
+ check_clause: '((local_tombstone OR (local_vcard IS NOT NULL)))',
+ },
+ {
+ table_name: 'carddav_conflicts',
+ check_clause: '((remote_tombstone OR (remote_vcard IS NOT NULL)))',
+ },
+ {
+ table_name: 'carddav_conflicts',
+ check_clause: "((((status = 'unresolved'::text) AND (resolution IS NULL) AND (resolved_by IS NULL) AND (resolved_at IS NULL)) OR ((status = 'resolved'::text) AND (resolution IS NOT NULL) AND (resolved_at IS NOT NULL))))",
+ },
+ ]));
+
+ const indexDefinitions = await readIndexDefinitions(client, [
+ 'carddav_one_active_mapping_per_contact_idx',
+ 'carddav_one_unresolved_conflict_per_mapping_idx',
+ ]);
+ expect(indexDefinitions.carddav_one_active_mapping_per_contact_idx).toBe(
+ "UNIQUE (local_contact_id) WHERE ((local_contact_id IS NOT NULL) AND (mapping_status <> 'pending_materialization'::text))",
+ );
+ expect(indexDefinitions.carddav_one_unresolved_conflict_per_mapping_idx).toBe(
+ "UNIQUE (address_book_id, href) WHERE (status = 'unresolved'::text)",
+ );
+}
+
+async function expectContractedBidirectionalSchema(client) {
+ const mappingColumns = await readColumns(client, 'carddav_remote_objects');
+ expect(mappingColumns).not.toHaveProperty('disposition');
+ expect(mappingColumns).not.toHaveProperty('merge_before');
+ expect(mappingColumns).not.toHaveProperty('merge_applied');
+ expect(mappingColumns).not.toHaveProperty('legacy_projection');
+ expect(mappingColumns).toMatchObject({
+ local_contact_id: { data_type: 'uuid', is_nullable: 'YES', column_default: null },
+ mapping_status: {
+ data_type: 'text',
+ is_nullable: 'NO',
+ column_default: "'pending_materialization'::text",
+ },
+ remote_semantic_hash: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ local_contact_hash: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ pending_operation: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ pending_vcard: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ pending_local_hash: { data_type: 'text', is_nullable: 'YES', column_default: null },
+ pending_remote_semantic_hash: {
+ data_type: 'text',
+ is_nullable: 'YES',
+ column_default: null,
+ },
+ pending_started_at: {
+ data_type: 'timestamp with time zone',
+ is_nullable: 'YES',
+ column_default: null,
+ },
+ });
+
+ const checks = await readChecks(client, ['carddav_remote_objects']);
+ expect(checks).toEqual(expect.arrayContaining([
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: "(((pending_operation IS NULL) OR (pending_operation = ANY (ARRAY['update'::text, 'delete'::text]))))",
+ },
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: "((((pending_operation IS NULL) AND (pending_vcard IS NULL) AND (pending_local_hash IS NULL) AND (pending_remote_semantic_hash IS NULL) AND (pending_started_at IS NULL)) OR ((pending_operation = 'update'::text) AND (pending_vcard IS NOT NULL) AND (pending_local_hash IS NOT NULL) AND (pending_remote_semantic_hash IS NOT NULL) AND (pending_started_at IS NOT NULL)) OR ((pending_operation = 'delete'::text) AND (pending_vcard IS NULL) AND (pending_local_hash IS NOT NULL) AND (pending_remote_semantic_hash IS NULL) AND (pending_started_at IS NOT NULL))))",
+ },
+ ]));
+
+ const indexDefinitions = await readIndexDefinitions(client, [
+ 'carddav_one_active_mapping_per_contact_idx',
+ 'carddav_one_merge_source_per_contact_idx',
+ ]);
+ expect(indexDefinitions.carddav_one_active_mapping_per_contact_idx).toBe(
+ "UNIQUE (local_contact_id) WHERE ((local_contact_id IS NOT NULL) AND (mapping_status <> 'pending_materialization'::text))",
+ );
+ expect(indexDefinitions).not.toHaveProperty('carddav_one_merge_source_per_contact_idx');
+}
+
+async function expectConflictRetentionSchema(client) {
+ expect(await readForeignKeys(client, 'carddav_conflicts')).toEqual([
+ {
+ column_name: 'address_book_id',
+ referenced_table: 'address_books',
+ referenced_column: 'id',
+ delete_rule: 'CASCADE',
+ },
+ {
+ column_name: 'resolved_by',
+ referenced_table: 'users',
+ referenced_column: 'id',
+ delete_rule: 'SET NULL',
+ },
+ {
+ column_name: 'user_id',
+ referenced_table: 'users',
+ referenced_column: 'id',
+ delete_rule: 'CASCADE',
+ },
+ ]);
+}
+
+it('keeps migration versions unique and orders the CardDAV lifecycle migrations', async () => {
+ const filenames = (await readdir(migrationsDirectory))
+ .filter(filename => /^\d{4}_.+\.sql$/.test(filename))
+ .sort();
+ const versions = filenames.map(filename => filename.slice(0, 4));
+ const duplicateVersions = versions.filter((version, index) => versions.indexOf(version) !== index);
+ const upstreamIndex = filenames.indexOf('0033_gtd_pets_custom.sql');
+ const incrementalIndex = filenames.indexOf('0034_carddav_incremental_sync.sql');
+ const expandIndex = filenames.indexOf('0035_carddav_bidirectional_sync.sql');
+ const contractIndex = filenames.indexOf('0036_carddav_bidirectional_cleanup.sql');
+ const retentionIndex = filenames.indexOf('0037_carddav_conflict_retention.sql');
+
+ expect(duplicateVersions).toEqual([]);
+ expect(upstreamIndex).toBeGreaterThanOrEqual(0);
+ expect(incrementalIndex).toBe(upstreamIndex + 1);
+ expect(expandIndex).toBe(incrementalIndex + 1);
+ expect(contractIndex).toBe(expandIndex + 1);
+ expect(retentionIndex).toBe(contractIndex + 1);
+});
+
+describe('CardDAV schema migrations', () => {
+ beforeAll(async () => {
+ adminClient = new Client({ connectionString: databaseUrl });
+ await adminClient.connect();
+
+ for (const name of Object.values(databaseNames)) {
+ await createTestDatabase(adminClient, name);
+ }
+ }, 120_000);
+
+ afterAll(async () => {
+ if (!adminClient) return;
+
+ for (const name of Object.values(databaseNames)) {
+ await dropTestDatabase(adminClient, name);
+ }
+ await adminClient.end();
+ }, 120_000);
+
+ it('runs the production migration loop idempotently against a fresh database', async () => {
+ const migrationPool = new Pool({
+ connectionString: connectionStringFor(databaseNames.runner),
+ });
+ try {
+ await runMigrationsWithPool(migrationPool, migrationsDirectory);
+ await runMigrationsWithPool(migrationPool, migrationsDirectory);
+
+ const versions = await migrationPool.query(
+ 'SELECT version FROM schema_migrations ORDER BY version',
+ );
+ expect(versions.rows.map(row => row.version))
+ .toContain('0037_carddav_conflict_retention');
+ expect(new Set(versions.rows.map(row => row.version)).size)
+ .toBe(versions.rows.length);
+ } finally {
+ await migrationPool.end();
+ }
+ }, 120_000);
+
+ it('rolls back a failing production migration and releases its advisory lock', async () => {
+ const failingDirectory = await mkdtemp(join(tmpdir(), 'mailflow-migrations-'));
+ const migrationPool = new Pool({
+ connectionString: connectionStringFor(databaseNames.failure),
+ });
+ const lockClient = new Client({
+ connectionString: connectionStringFor(databaseNames.failure),
+ });
+ await writeFile(
+ join(failingDirectory, '9000_failing.sql'),
+ 'CREATE TABLE rollback_probe (id integer);\nSELECT missing_column FROM rollback_probe;\n',
+ );
+
+ try {
+ await expect(runMigrationsWithPool(migrationPool, failingDirectory)).rejects.toThrow();
+
+ const versions = await migrationPool.query(
+ 'SELECT version FROM schema_migrations ORDER BY version',
+ );
+ expect(versions.rows.map(row => row.version)).not.toContain('9000_failing');
+ const { rows: [probe] } = await migrationPool.query(
+ "SELECT to_regclass('public.rollback_probe') AS relation",
+ );
+ expect(probe.relation).toBeNull();
+
+ await lockClient.connect();
+ const { rows: [lock] } = await lockClient.query(
+ 'SELECT pg_try_advisory_lock(7418291834) AS acquired',
+ );
+ expect(lock.acquired).toBe(true);
+ await lockClient.query('SELECT pg_advisory_unlock(7418291834)');
+ } finally {
+ await lockClient.end().catch(() => {});
+ await migrationPool.end();
+ await rm(failingDirectory, { recursive: true, force: true });
+ }
+ }, 120_000);
+
+ it.each([
+ {
+ databaseName: databaseNames.populatedDisjoint,
+ label: 'disjoint contact UIDs',
+ contactUids: ['disjoint-a', 'disjoint-b'],
+ },
+ {
+ databaseName: databaseNames.populatedCollision,
+ label: 'colliding contact UIDs',
+ contactUids: ['same-uid', 'same-uid'],
+ },
+ ])('aborts 0034 without changing populated duplicates with $label', async ({
+ databaseName,
+ contactUids,
+ }) => {
+ const through0033Directory = await createMigrationDirectory('0001', '0033');
+ const migration0034Directory = await createMigrationDirectory('0034', '0034');
+ const migrationPool = new Pool({ connectionString: connectionStringFor(databaseName) });
+ const userId = randomUUID();
+ const earlierBookId = '10000000-0000-4000-8000-000000000001';
+ const laterBookId = '10000000-0000-4000-8000-000000000002';
+
+ try {
+ await runMigrationsWithPool(migrationPool, through0033Directory);
+ await migrationPool.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-populated-${userId}`],
+ );
+ await migrationPool.query(`
+ INSERT INTO address_books (id, user_id, name, source, external_url, created_at)
+ VALUES
+ ($1, $3, 'Earlier', 'carddav', 'https://dav.example.test/populated',
+ '2026-01-01T00:00:00Z'),
+ ($2, $3, 'Later', 'carddav', 'https://dav.example.test/populated',
+ '2026-01-02T00:00:00Z')
+ `, [earlierBookId, laterBookId, userId]);
+ await migrationPool.query(`
+ INSERT INTO contacts (address_book_id, user_id, uid, display_name)
+ VALUES
+ ($1, $3, $4, 'Earlier Contact'),
+ ($2, $3, $5, 'Later Contact')
+ `, [earlierBookId, laterBookId, userId, ...contactUids]);
+ const before = await readCardDavRows(migrationPool, userId);
+
+ await expect(runMigrationsWithPool(migrationPool, migration0034Directory))
+ .rejects.toThrow('cannot consolidate populated duplicate CardDAV address books');
+
+ expect(await readCardDavRows(migrationPool, userId)).toEqual(before);
+ const { rows: [index] } = await migrationPool.query(
+ "SELECT to_regclass('public.carddav_one_remote_book_idx') AS relation",
+ );
+ expect(index.relation).toBeNull();
+ const { rows: versions } = await migrationPool.query(
+ "SELECT version FROM schema_migrations WHERE version = '0034_carddav_incremental_sync'",
+ );
+ expect(versions).toEqual([]);
+ } finally {
+ await migrationPool.end();
+ await rm(through0033Directory, { recursive: true, force: true });
+ await rm(migration0034Directory, { recursive: true, force: true });
+ }
+ }, 120_000);
+
+ it('consolidates an empty later duplicate while preserving the populated first book', async () => {
+ const through0033Directory = await createMigrationDirectory('0001', '0033');
+ const migration0034Directory = await createMigrationDirectory('0034', '0034');
+ const migrationPool = new Pool({
+ connectionString: connectionStringFor(databaseNames.populatedFirst),
+ });
+ const userId = randomUUID();
+ const keptBookId = '10000000-0000-4000-8000-000000000001';
+ const removedBookId = '10000000-0000-4000-8000-000000000002';
+
+ try {
+ await runMigrationsWithPool(migrationPool, through0033Directory);
+ await migrationPool.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-populated-first-${userId}`],
+ );
+ await migrationPool.query(`
+ INSERT INTO address_books (id, user_id, name, source, external_url, created_at)
+ VALUES
+ ($1, $3, 'Earlier', 'carddav', 'https://dav.example.test/populated-first',
+ '2026-01-01T00:00:00Z'),
+ ($2, $3, 'Later', 'carddav', 'https://dav.example.test/populated-first',
+ '2026-01-02T00:00:00Z')
+ `, [keptBookId, removedBookId, userId]);
+ const { rows: [contact] } = await migrationPool.query(`
+ INSERT INTO contacts (address_book_id, user_id, uid, display_name)
+ VALUES ($1, $2, 'kept-contact', 'Kept Contact')
+ RETURNING id
+ `, [keptBookId, userId]);
+
+ await runMigrationsWithPool(migrationPool, migration0034Directory);
+
+ expect(await readCardDavRows(migrationPool, userId)).toMatchObject({
+ books: [{ id: keptBookId }],
+ contacts: [{ id: contact.id, address_book_id: keptBookId }],
+ });
+ } finally {
+ await migrationPool.end();
+ await rm(through0033Directory, { recursive: true, force: true });
+ await rm(migration0034Directory, { recursive: true, force: true });
+ }
+ }, 120_000);
+
+ it('collapses empty duplicates by created_at and id and reruns 0034 as a no-op', async () => {
+ const through0033Directory = await createMigrationDirectory('0001', '0033');
+ const migration0034Directory = await createMigrationDirectory('0034', '0034');
+ const migrationPool = new Pool({
+ connectionString: connectionStringFor(databaseNames.emptyDuplicates),
+ });
+ const userId = randomUUID();
+ const keptBookId = '10000000-0000-4000-8000-000000000001';
+
+ try {
+ await runMigrationsWithPool(migrationPool, through0033Directory);
+ await migrationPool.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-empty-${userId}`],
+ );
+ await migrationPool.query(`
+ INSERT INTO address_books (id, user_id, name, source, external_url, created_at)
+ VALUES
+ ($1, $4, 'Same Time Lower ID', 'carddav', $5, '2026-01-01T00:00:00Z'),
+ ($2, $4, 'Same Time Higher ID', 'carddav', $5, '2026-01-01T00:00:00Z'),
+ ($3, $4, 'Later', 'carddav', $5, '2026-01-02T00:00:00Z')
+ `, [
+ keptBookId,
+ '10000000-0000-4000-8000-000000000002',
+ '10000000-0000-4000-8000-000000000003',
+ userId,
+ 'https://dav.example.test/empty',
+ ]);
+
+ await runMigrationsWithPool(migrationPool, migration0034Directory);
+ const { rows: booksAfterFirstRun } = await migrationPool.query(`
+ SELECT id
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY created_at, id
+ `, [userId]);
+ expect(booksAfterFirstRun).toEqual([{ id: keptBookId }]);
+ const { rows: [index] } = await migrationPool.query(
+ "SELECT to_regclass('public.carddav_one_remote_book_idx') AS relation",
+ );
+ expect(index.relation).toBe('carddav_one_remote_book_idx');
+
+ await runMigrationsWithPool(migrationPool, migration0034Directory);
+ expect((await migrationPool.query(`
+ SELECT id
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY created_at, id
+ `, [userId])).rows).toEqual(booksAfterFirstRun);
+ const { rows: versions } = await migrationPool.query(
+ "SELECT version FROM schema_migrations WHERE version = '0034_carddav_incremental_sync'",
+ );
+ expect(versions).toEqual([{ version: '0034_carddav_incremental_sync' }]);
+ } finally {
+ await migrationPool.end();
+ await rm(through0033Directory, { recursive: true, force: true });
+ await rm(migration0034Directory, { recursive: true, force: true });
+ }
+ }, 120_000);
+
+ it('upgrades a duplicate schema at migration 0033', async () => {
+ await withDatabase(databaseNames.upgrade, async client => {
+ await assertMinimumPostgresVersion(client);
+ await applyMigrations(client, '0001', '0033');
+
+ const userId = randomUUID();
+ const secondMissingUserId = randomUUID();
+ const existingStringUserId = randomUUID();
+ const existingNullUserId = randomUUID();
+ const nonCarddavUserId = randomUUID();
+ const earliestBookId = randomUUID();
+ const secondBookId = randomUUID();
+ const thirdBookId = randomUUID();
+ const externalUrl = 'https://carddav.example.test/books/shared';
+ await client.query(`
+ INSERT INTO users (id, username)
+ VALUES
+ ($1, $6),
+ ($2, $7),
+ ($3, $8),
+ ($4, $9),
+ ($5, $10)
+ `, [
+ userId,
+ secondMissingUserId,
+ existingStringUserId,
+ existingNullUserId,
+ nonCarddavUserId,
+ `carddav-upgrade-${userId}`,
+ `carddav-upgrade-${secondMissingUserId}`,
+ `carddav-upgrade-${existingStringUserId}`,
+ `carddav-upgrade-${existingNullUserId}`,
+ `carddav-upgrade-${nonCarddavUserId}`,
+ ]);
+ await client.query(`
+ INSERT INTO address_books (id, user_id, name, source, external_url, created_at)
+ VALUES
+ ($1, $4, 'Earliest', 'carddav', $5, '2026-01-01T00:00:00Z'),
+ ($2, $4, 'Second', 'carddav', $5, '2026-01-02T00:00:00Z'),
+ ($3, $4, 'Third', 'carddav', $5, '2026-01-03T00:00:00Z')
+ `, [earliestBookId, secondBookId, thirdBookId, userId, externalUrl]);
+ await client.query(`
+ INSERT INTO user_integrations (user_id, provider, config)
+ VALUES
+ ($1, 'carddav', '{"serverUrl":"https://dav.example.test","nested":{"keep":true}}'::jsonb),
+ ($2, 'carddav', '{"username":"second-missing","nested":{"preserve":true}}'::jsonb),
+ ($3, 'carddav', '{"connectionGeneration":"legacy-generation","keep":"value"}'::jsonb),
+ ($4, 'carddav', '{"connectionGeneration":null,"keep":"value"}'::jsonb),
+ ($5, 'caldav-test', '{"keep":"non-carddav"}'::jsonb)
+ `, [
+ userId,
+ secondMissingUserId,
+ existingStringUserId,
+ existingNullUserId,
+ nonCarddavUserId,
+ ]);
+
+ await applyMigrations(client, '0034', '0034');
+
+ const { rows: books } = await client.query(`
+ SELECT id, remote_sync_token, remote_sync_capability, remote_sync_revision
+ FROM address_books
+ WHERE user_id = $1 AND external_url = $2
+ ORDER BY created_at, id
+ `, [userId, externalUrl]);
+ expect(books).toEqual([{
+ id: earliestBookId,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ }]);
+
+ const { rows: integrations } = await client.query(`
+ SELECT provider, config
+ FROM user_integrations
+ WHERE user_id = $1
+ ORDER BY provider
+ `, [userId]);
+ const carddavConfig = integrations.find(row => row.provider === 'carddav').config;
+ expect(carddavConfig.connectionGeneration).toMatch(UUID_PATTERN);
+ expect(carddavConfig).toEqual({
+ connectionGeneration: carddavConfig.connectionGeneration,
+ nested: { keep: true },
+ serverUrl: 'https://dav.example.test',
+ });
+
+ const { rows: generationRows } = await client.query(`
+ SELECT user_id, provider, config
+ FROM user_integrations
+ WHERE user_id = ANY($1::uuid[])
+ ORDER BY user_id, provider
+ `, [[
+ secondMissingUserId,
+ existingStringUserId,
+ existingNullUserId,
+ nonCarddavUserId,
+ ]]);
+ const generationConfigs = new Map(generationRows.map(row => [row.user_id, row.config]));
+ const secondMissingConfig = generationConfigs.get(secondMissingUserId);
+ expect(secondMissingConfig.connectionGeneration).toMatch(UUID_PATTERN);
+ expect(secondMissingConfig.connectionGeneration).not.toBe(carddavConfig.connectionGeneration);
+ expect(secondMissingConfig).toEqual({
+ connectionGeneration: secondMissingConfig.connectionGeneration,
+ nested: { preserve: true },
+ username: 'second-missing',
+ });
+ expect(generationConfigs.get(existingStringUserId)).toEqual({
+ connectionGeneration: 'legacy-generation',
+ keep: 'value',
+ });
+ expect(generationConfigs.get(existingNullUserId)).toEqual({
+ connectionGeneration: null,
+ keep: 'value',
+ });
+ expect(generationConfigs.get(nonCarddavUserId)).toEqual({ keep: 'non-carddav' });
+
+ const separateContactId = randomUUID();
+ const mergeContactId = randomUUID();
+ await client.query(`
+ INSERT INTO contacts (id, address_book_id, user_id, uid, vcard, display_name)
+ VALUES
+ ($1, $3, $4, 'legacy-separate', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'Legacy Separate'),
+ ($2, $3, $4, 'legacy-merge', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'Legacy Merge')
+ `, [separateContactId, mergeContactId, earliestBookId, userId]);
+ await client.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email, disposition,
+ local_contact_id, merge_before, merge_applied
+ ) VALUES
+ ($1, '/legacy-separate.vcf', 'separate-etag', 'BEGIN:VCARD\r\nEND:VCARD\r\n',
+ 'separate@example.test', 'separate', $2, NULL, NULL),
+ ($1, '/legacy-merge.vcf', 'merge-etag', 'BEGIN:VCARD\r\nEND:VCARD\r\n',
+ 'merge@example.test', 'merge', $3,
+ '{"display_name":"Before"}'::jsonb, '{"display_name":"Remote"}'::jsonb),
+ ($1, '/legacy-skip.vcf', 'skip-etag', 'BEGIN:VCARD\r\nEND:VCARD\r\n',
+ 'skip@example.test', 'skip', $2, NULL, NULL)
+ `, [earliestBookId, separateContactId, mergeContactId]);
+
+ await applyMigrations(client, '0035', '0035');
+
+ const { rows: legacyMappings } = await client.query(`
+ SELECT href, disposition, local_contact_id, merge_before, merge_applied,
+ mapping_status, mapping_revision, remote_semantic_hash,
+ local_contact_hash, legacy_projection
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1
+ ORDER BY href
+ `, [earliestBookId]);
+ expect(legacyMappings).toEqual([
+ {
+ href: '/legacy-merge.vcf',
+ disposition: 'merge',
+ local_contact_id: mergeContactId,
+ merge_before: { display_name: 'Before' },
+ merge_applied: { display_name: 'Remote' },
+ mapping_status: 'pending_materialization',
+ mapping_revision: '0',
+ remote_semantic_hash: null,
+ local_contact_hash: null,
+ legacy_projection: {
+ disposition: 'merge',
+ merge_before: { display_name: 'Before' },
+ merge_applied: { display_name: 'Remote' },
+ },
+ },
+ {
+ href: '/legacy-separate.vcf',
+ disposition: 'separate',
+ local_contact_id: separateContactId,
+ merge_before: null,
+ merge_applied: null,
+ mapping_status: 'pending_materialization',
+ mapping_revision: '0',
+ remote_semantic_hash: null,
+ local_contact_hash: null,
+ legacy_projection: {
+ disposition: 'separate',
+ merge_before: null,
+ merge_applied: null,
+ },
+ },
+ {
+ href: '/legacy-skip.vcf',
+ disposition: 'skip',
+ local_contact_id: separateContactId,
+ merge_before: null,
+ merge_applied: null,
+ mapping_status: 'pending_materialization',
+ mapping_revision: '0',
+ remote_semantic_hash: null,
+ local_contact_hash: null,
+ legacy_projection: {
+ disposition: 'skip',
+ merge_before: null,
+ merge_applied: null,
+ },
+ },
+ ]);
+
+ await client.query(`
+ UPDATE carddav_remote_objects
+ SET mapping_status = 'synced'
+ WHERE address_book_id = $1 AND href = '/legacy-separate.vcf'
+ `, [earliestBookId]);
+ await expect(client.query(`
+ UPDATE carddav_remote_objects
+ SET mapping_status = 'synced'
+ WHERE address_book_id = $1 AND href = '/legacy-skip.vcf'
+ `, [earliestBookId])).rejects.toMatchObject({ code: '23505' });
+ await client.query(`
+ UPDATE carddav_remote_objects
+ SET mapping_status = 'pending_materialization'
+ WHERE address_book_id = $1 AND href = '/legacy-separate.vcf'
+ `, [earliestBookId]);
+
+ const { rows: [defaultedMapping] } = await client.query(`
+ INSERT INTO carddav_remote_objects (address_book_id, href, vcard)
+ VALUES ($1, '/defaulted.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n')
+ RETURNING disposition, mapping_status, mapping_revision
+ `, [earliestBookId]);
+ expect(defaultedMapping).toEqual({
+ disposition: 'separate',
+ mapping_status: 'pending_materialization',
+ mapping_revision: '0',
+ });
+
+ await expectIncrementalSchema(client);
+ await expectBidirectionalSchema(client);
+
+ await applyMigrations(client, '0036', '0036');
+ await expectContractedBidirectionalSchema(client);
+ await applyMigrations(client, '0037', '0037');
+ await expectConflictRetentionSchema(client);
+ await expect(client.query(`
+ UPDATE carddav_remote_objects
+ SET pending_operation = 'delete', pending_started_at = NOW()
+ WHERE address_book_id = $1 AND href = '/defaulted.vcf'
+ `, [earliestBookId])).rejects.toMatchObject({ code: '23514' });
+ const { rows: [deleteIntent] } = await client.query(`
+ UPDATE carddav_remote_objects
+ SET pending_operation = 'delete', pending_local_hash = 'local-delete-hash',
+ pending_started_at = NOW()
+ WHERE address_book_id = $1 AND href = '/defaulted.vcf'
+ RETURNING pending_operation, pending_vcard, pending_local_hash,
+ pending_remote_semantic_hash, pending_started_at IS NOT NULL AS started
+ `, [earliestBookId]);
+ expect(deleteIntent).toEqual({
+ pending_operation: 'delete',
+ pending_vcard: null,
+ pending_local_hash: 'local-delete-hash',
+ pending_remote_semantic_hash: null,
+ started: true,
+ });
+ const { rows: [contractedIntegration] } = await client.query(`
+ SELECT config FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [userId]);
+ expect(contractedIntegration.config).not.toHaveProperty('dupMode');
+ });
+ }, 120_000);
+
+ it('creates the retained-conflict schema from migrations 0001 through 0037', async () => {
+ await withDatabase(databaseNames.fresh, async client => {
+ await assertMinimumPostgresVersion(client);
+ const freshUserId = randomUUID();
+ await applyMigrations(client, '0001', '0033');
+ await client.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [freshUserId, `carddav-fresh-${freshUserId}`],
+ );
+ await client.query(`
+ INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)
+ `, [freshUserId, JSON.stringify({
+ serverUrl: 'https://fresh.example.test/dav/',
+ username: 'fresh-user',
+ dupMode: 'skip',
+ intervalMin: 45,
+ nested: { preserve: true },
+ })]);
+ await applyMigrations(client, '0034', '0037');
+ const { rows: [freshIntegration] } = await client.query(`
+ SELECT config
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [freshUserId]);
+ expect(freshIntegration.config.connectionGeneration).toMatch(UUID_PATTERN);
+ expect(freshIntegration.config).toEqual({
+ connectionGeneration: freshIntegration.config.connectionGeneration,
+ intervalMin: 45,
+ nested: { preserve: true },
+ serverUrl: 'https://fresh.example.test/dav/',
+ username: 'fresh-user',
+ });
+ await expectContractedBidirectionalSchema(client);
+ await expectConflictRetentionSchema(client);
+ });
+ }, 120_000);
+});
diff --git a/backend/src/services/carddavProjection.js b/backend/src/services/carddavProjection.js
new file mode 100644
index 0000000..e5ccbcd
--- /dev/null
+++ b/backend/src/services/carddavProjection.js
@@ -0,0 +1,63 @@
+export const normalizeEmail = value => value?.toLowerCase().trim() || null;
+const nonBlank = value => typeof value === 'string' && value.trim() ? value : null;
+
+function compareRemote(left, right) {
+ const discoveryOrder = Number(left.discoveryIndex ?? 0) - Number(right.discoveryIndex ?? 0);
+ return discoveryOrder || left.href.localeCompare(right.href);
+}
+
+function exactlyOne(candidates) {
+ return candidates.length === 1 ? candidates[0] : null;
+}
+
+export function planAutomaticProjection({ remoteObjects, mappings, localContacts }) {
+ const orderedRemote = [...remoteObjects].sort(compareRemote);
+ const orderedLocal = [...localContacts].sort((left, right) => (
+ String(left.id).localeCompare(String(right.id))
+ ));
+ const localById = new Map(orderedLocal.map(contact => [contact.id, contact]));
+ const mappingByHref = new Map(mappings.map(mapping => [mapping.href, mapping]));
+ const claimedLocalIds = new Set(mappings
+ .map(mapping => mapping.localContactId)
+ .filter(Boolean));
+ const available = orderedLocal.filter(contact => (
+ (contact.isAuto === false || contact.is_auto === false)
+ && !claimedLocalIds.has(contact.id)
+ ));
+ const links = [];
+ const imports = [];
+
+ for (const remote of orderedRemote) {
+ const mapping = mappingByHref.get(remote.href);
+ if (mapping?.localContactId && localById.has(mapping.localContactId)) {
+ links.push({ href: remote.href, localContactId: mapping.localContactId });
+ continue;
+ }
+
+ const candidates = available.filter(contact => !claimedLocalIds.has(contact.id));
+ const uid = nonBlank(remote.contact?.uid);
+ let target = uid
+ ? exactlyOne(candidates.filter(contact => nonBlank(contact.uid) === uid))
+ : null;
+ if (!target) {
+ const email = normalizeEmail(remote.contact?.primaryEmail);
+ if (email) {
+ target = exactlyOne(candidates.filter(contact => (
+ normalizeEmail(contact.primaryEmail) === email
+ )));
+ }
+ }
+
+ if (!target) {
+ imports.push({ href: remote.href });
+ continue;
+ }
+ claimedLocalIds.add(target.id);
+ links.push({ href: remote.href, localContactId: target.id });
+ }
+
+ const exports = available
+ .filter(contact => !claimedLocalIds.has(contact.id))
+ .map(contact => ({ localContactId: contact.id }));
+ return { links, imports, exports };
+}
diff --git a/backend/src/services/carddavProjection.test.js b/backend/src/services/carddavProjection.test.js
new file mode 100644
index 0000000..98231c7
--- /dev/null
+++ b/backend/src/services/carddavProjection.test.js
@@ -0,0 +1,156 @@
+import { describe, expect, it } from 'vitest';
+import { planAutomaticProjection } from './carddavProjection.js';
+
+const remoteObject = (name, overrides = {}) => {
+ const object = {
+ href: `https://dav.example.test/book/${name}.vcf`,
+ discoveryIndex: 0,
+ contact: {
+ uid: name,
+ primaryEmail: `${name}@example.com`,
+ },
+ };
+ return {
+ ...object,
+ ...overrides,
+ contact: { ...object.contact, ...overrides.contact },
+ };
+};
+
+const localContact = (id, overrides = {}) => ({
+ id,
+ isAuto: false,
+ uid: `local-${id}`,
+ primaryEmail: `${id}@example.com`,
+ ...overrides,
+});
+
+describe('planAutomaticProjection', () => {
+ it('keeps an existing mapping before considering automatic matches', () => {
+ const remote = remoteObject('mapped', {
+ contact: { uid: 'shared-uid', primaryEmail: 'shared@example.com' },
+ });
+ expect(planAutomaticProjection({
+ remoteObjects: [remote],
+ mappings: [{ href: remote.href, localContactId: 'mapped-local' }],
+ localContacts: [
+ localContact('mapped-local'),
+ localContact('unmapped-match', {
+ uid: 'shared-uid', primaryEmail: 'shared@example.com',
+ }),
+ ],
+ })).toEqual({
+ links: [{ href: remote.href, localContactId: 'mapped-local' }],
+ imports: [],
+ exports: [{ localContactId: 'unmapped-match' }],
+ });
+ });
+
+ it('links by one exact non-blank UID before a primary-email candidate', () => {
+ const remote = remoteObject('uid-first', {
+ contact: { uid: 'stable-uid', primaryEmail: 'email-match@example.com' },
+ });
+ expect(planAutomaticProjection({
+ remoteObjects: [remote],
+ mappings: [],
+ localContacts: [
+ localContact('email-match', { primaryEmail: ' EMAIL-MATCH@example.com ' }),
+ localContact('uid-match', { uid: 'stable-uid', primaryEmail: 'other@example.com' }),
+ ],
+ })).toEqual({
+ links: [{ href: remote.href, localContactId: 'uid-match' }],
+ imports: [],
+ exports: [{ localContactId: 'email-match' }],
+ });
+ });
+
+ it('links only one normalized primary-email candidate', () => {
+ const unique = remoteObject('unique', {
+ contact: { uid: 'remote-unique', primaryEmail: ' UNIQUE@example.com ' },
+ });
+ const ambiguous = remoteObject('ambiguous', {
+ contact: { uid: 'remote-ambiguous', primaryEmail: 'shared@example.com' },
+ });
+ expect(planAutomaticProjection({
+ remoteObjects: [ambiguous, unique],
+ mappings: [],
+ localContacts: [
+ localContact('unique', { uid: 'local-unique', primaryEmail: 'unique@EXAMPLE.COM' }),
+ localContact('shared-a', { uid: 'local-a', primaryEmail: 'shared@example.com' }),
+ localContact('shared-b', { uid: 'local-b', primaryEmail: ' SHARED@example.com ' }),
+ ],
+ })).toEqual({
+ links: [{ href: unique.href, localContactId: 'unique' }],
+ imports: [{ href: ambiguous.href }],
+ exports: [{ localContactId: 'shared-a' }, { localContactId: 'shared-b' }],
+ });
+ });
+
+ it('never matches prohibited fields, blanks, or auto contacts', () => {
+ const remote = remoteObject('prohibited', {
+ contact: {
+ uid: ' ', primaryEmail: '', displayName: 'Same',
+ phones: [{ value: '+15550000001' }], organization: 'Same Org',
+ },
+ });
+ expect(planAutomaticProjection({
+ remoteObjects: [remote],
+ mappings: [],
+ localContacts: [
+ localContact('explicit', {
+ uid: '', primaryEmail: '', displayName: 'Same',
+ phones: [{ value: '+15550000001' }], organization: 'Same Org',
+ }),
+ localContact('auto', {
+ uid: ' ', primaryEmail: '', displayName: 'Same', isAuto: true,
+ }),
+ ],
+ })).toEqual({
+ links: [],
+ imports: [{ href: remote.href }],
+ exports: [{ localContactId: 'explicit' }],
+ });
+ });
+
+ it('does not link an ambiguous UID or reuse one local contact', () => {
+ const first = remoteObject('a', {
+ contact: { uid: 'shared-uid', primaryEmail: 'a@example.com' },
+ });
+ const second = remoteObject('b', {
+ contact: { uid: 'shared-uid', primaryEmail: 'b@example.com' },
+ });
+ expect(planAutomaticProjection({
+ remoteObjects: [second, first],
+ mappings: [],
+ localContacts: [
+ localContact('candidate-a', { uid: 'shared-uid', primaryEmail: 'none-a@example.com' }),
+ localContact('candidate-b', { uid: 'shared-uid', primaryEmail: 'none-b@example.com' }),
+ ],
+ })).toEqual({
+ links: [],
+ imports: [{ href: first.href }, { href: second.href }],
+ exports: [{ localContactId: 'candidate-a' }, { localContactId: 'candidate-b' }],
+ });
+ });
+
+ it('orders books, hrefs, and local IDs without mutating inputs', () => {
+ const remoteObjects = [
+ remoteObject('z', { discoveryIndex: 2 }),
+ remoteObject('b', { discoveryIndex: 0 }),
+ remoteObject('a', { discoveryIndex: 0 }),
+ ];
+ const mappings = [];
+ const localContacts = [localContact('z-local'), localContact('a-local')];
+ const before = structuredClone({ remoteObjects, mappings, localContacts });
+ expect(planAutomaticProjection({ remoteObjects, mappings, localContacts })).toEqual({
+ links: [],
+ imports: [
+ { href: remoteObjects[2].href },
+ { href: remoteObjects[1].href },
+ { href: remoteObjects[0].href },
+ ],
+ exports: [{ localContactId: 'a-local' }, { localContactId: 'z-local' }],
+ });
+ expect({ remoteObjects, mappings, localContacts }).toEqual(before);
+ });
+});
diff --git a/backend/src/services/carddavSync.db.test.js b/backend/src/services/carddavSync.db.test.js
new file mode 100644
index 0000000..38158cd
--- /dev/null
+++ b/backend/src/services/carddavSync.db.test.js
@@ -0,0 +1,4091 @@
+import { createHash, randomUUID } from 'node:crypto';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import pg from 'pg';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import {
+ applyTestMigrations,
+ assertMinimumPostgresVersion,
+ createTestDatabase,
+ dropTestDatabase,
+ postgresTestContext,
+ waitForPostgresState,
+} from './postgresTestHelpers.js';
+
+const mocks = vi.hoisted(() => ({
+ decrypt: vi.fn(value => value),
+ discoverAddressBooks: vi.fn(),
+ fetchAddressBookDelta: vi.fn(),
+ getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })),
+ parseVCard: vi.fn(),
+ query: vi.fn(),
+ withTransaction: vi.fn(),
+}));
+
+vi.mock('./db.js', () => ({
+ query: mocks.query,
+ withTransaction: mocks.withTransaction,
+}));
+vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt }));
+vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy }));
+vi.mock('./carddavClient.js', () => ({
+ discoverAddressBooks: mocks.discoverAddressBooks,
+ fetchAddressBookDelta: mocks.fetchAddressBookDelta,
+}));
+vi.mock('../utils/vcard.js', async importOriginal => {
+ const original = await importOriginal();
+ mocks.parseVCard.mockImplementation(original.parseVCard);
+ return { ...original, parseVCard: mocks.parseVCard };
+});
+
+const carddavSync = await import('./carddavSync.js');
+const carddavMappingState = await import('./carddavMappingState.js');
+const { generateVCard } = await vi.importActual('../utils/vcard.js');
+const {
+ localContactHash,
+ parseVCardDocument,
+ semanticVCardHash,
+} = await vi.importActual('../utils/vcardProperties.js');
+const { Client } = pg;
+
+const USER_ID = '00000000-0000-4000-8000-000000000001';
+const BOOK_URL = 'https://dav.example.test/addressbooks/default/';
+const CONNECTION_GENERATION = 'generation-current';
+
+function completePlan(overrides = {}) {
+ return {
+ userId: USER_ID,
+ book: { url: BOOK_URL, displayName: 'Remote' },
+ connectionGeneration: CONNECTION_GENERATION,
+ expectedRemoteRevision: '0',
+ expectedRemoteToken: null,
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ ...overrides,
+ };
+}
+
+function remoteCard(name, email) {
+ const href = `${BOOK_URL}${name}.vcf`;
+ const vcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:remote-${name}`,
+ `FN:${name}`,
+ `N:${name};${name};;;`,
+ `EMAIL:${email}`,
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ return {
+ href,
+ remoteEtag: `W/"remote-etag-${name}"`,
+ vcard,
+ contact: {
+ uid: `remote-${name}`,
+ displayName: name,
+ firstName: name,
+ lastName: name,
+ primaryEmail: email,
+ emails: [{ value: email, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ },
+ };
+}
+
+const { databaseUrl, connectionStringFor } = postgresTestContext('CardDAV transaction tests');
+const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
+const databaseName = `carddav_sync_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
+let adminClient;
+let databaseClient;
+
+function deferred() {
+ let resolve;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+
+async function applyMigrations(client, through = '0035') {
+ await applyTestMigrations(client, { migrationsDirectory, through });
+}
+
+async function beginApply(plan, queryOverride) {
+ await databaseClient.query('BEGIN');
+ try {
+ const client = queryOverride
+ ? { query: (sql, params) => queryOverride(databaseClient, sql, params) }
+ : databaseClient;
+ const result = await carddavSync.applyBookDelta(client, plan);
+ await databaseClient.query('COMMIT');
+ return result;
+ } catch (error) {
+ await databaseClient.query('ROLLBACK');
+ throw error;
+ }
+}
+
+function useDatabaseTransactions(queryOverride) {
+ mocks.withTransaction.mockImplementation(async callback => {
+ await databaseClient.query('BEGIN');
+ try {
+ const client = queryOverride
+ ? { query: (sql, params) => queryOverride(databaseClient, sql, params) }
+ : databaseClient;
+ const result = await callback(client);
+ await databaseClient.query('COMMIT');
+ return result;
+ } catch (error) {
+ await databaseClient.query('ROLLBACK');
+ throw error;
+ }
+ });
+}
+
+async function persistedState(userId = USER_ID) {
+ const { rows: books } = await databaseClient.query(`
+ SELECT id, external_url, sync_token, remote_sync_token, remote_sync_capability,
+ remote_sync_revision::text, remote_projection_fingerprint
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY external_url
+ `, [userId]);
+ const { rows: contacts } = await databaseClient.query(`
+ SELECT address_book_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, organization, notes, photo_data
+ FROM contacts
+ WHERE user_id = $1
+ ORDER BY address_book_id, uid
+ `, [userId]);
+ const { rows: ledger } = await databaseClient.query(`
+ SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, o.primary_email, o.disposition,
+ o.local_contact_id, o.merge_before, o.merge_applied
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ ORDER BY o.href
+ `, [userId]);
+ const { rows: integrations } = await databaseClient.query(`
+ SELECT provider, config
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [userId]);
+ return { books, contacts, ledger, integrations };
+}
+
+async function persistedLifecycleState(userId) {
+ const state = await persistedState(userId);
+ const { rows: allBooks } = await databaseClient.query(`
+ SELECT id, source, external_url, sync_token, remote_sync_token,
+ remote_sync_capability, remote_sync_revision::text,
+ remote_projection_fingerprint
+ FROM address_books
+ WHERE user_id = $1
+ ORDER BY id
+ `, [userId]);
+ return { ...state, allBooks };
+}
+
+async function seedLifecycleUser() {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/a/`;
+ const secondRemoteUrl = `https://dav.example.test/addressbooks/${userId}/b/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-lifecycle-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text,
+ 'lastError', 'before-error',
+ 'bookCount', 1,
+ 'contactCount', 1
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [localBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Lifecycle Personal') RETURNING id, sync_token",
+ [userId],
+ );
+ const { rows: [unrelatedBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Lifecycle Unrelated') RETURNING id, sync_token",
+ [userId],
+ );
+ const targetVcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:lifecycle-target',
+ 'FN:Lifecycle Original',
+ 'EMAIL:lifecycle-duplicate@example.test',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ const { rows: [target] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, 'lifecycle-target', $3, $4, 'Lifecycle Original', 'Lifecycle',
+ 'lifecycle-duplicate@example.test', $5::jsonb, '[]'::jsonb, false
+ )
+ RETURNING id
+ `, [
+ localBook.id,
+ userId,
+ targetVcard,
+ createHash('md5').update(targetVcard).digest('hex'),
+ JSON.stringify([{
+ value: 'lifecycle-duplicate@example.test', type: 'other', primary: true,
+ }]),
+ ]);
+ const secondTargetVcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:lifecycle-target-b',
+ 'FN:Lifecycle Original B',
+ 'EMAIL:lifecycle-duplicate-b@example.test',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ const { rows: [secondTarget] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, 'lifecycle-target-b', $3, $4, 'Lifecycle Original B', 'Lifecycle',
+ 'lifecycle-duplicate-b@example.test', $5::jsonb, '[]'::jsonb, false
+ )
+ RETURNING id
+ `, [
+ localBook.id,
+ userId,
+ secondTargetVcard,
+ createHash('md5').update(secondTargetVcard).digest('hex'),
+ JSON.stringify([{
+ value: 'lifecycle-duplicate-b@example.test', type: 'other', primary: true,
+ }]),
+ ]);
+ const card = {
+ ...remoteCard('lifecycle-remote', 'lifecycle-duplicate@example.test'),
+ href: `${remoteUrl}lifecycle.vcf`,
+ };
+ const skippedCard = {
+ ...remoteCard('lifecycle-b-skip', 'lifecycle-duplicate-b@example.test'),
+ href: `${secondRemoteUrl}lifecycle-b-skip.vcf`,
+ };
+ const separateCard = {
+ ...remoteCard('lifecycle-separate', 'lifecycle-separate@example.test'),
+ href: `${remoteUrl}lifecycle-separate.vcf`,
+ };
+ const secondMergeCard = {
+ ...remoteCard('lifecycle-b-merge', 'lifecycle-duplicate-b@example.test'),
+ href: `${secondRemoteUrl}lifecycle-b-merge.vcf`,
+ };
+ for (const [url, name, upserts] of [
+ [remoteUrl, 'Lifecycle Remote A', [card, separateCard]],
+ [secondRemoteUrl, 'Lifecycle Remote B', [secondMergeCard, skippedCard]],
+ ]) {
+ await beginApply(completePlan({
+ userId,
+ book: { url, displayName: name },
+ connectionGeneration: generation,
+ collectionIdentity: { observedUrl: url, canonicalUrl: url },
+ upserts,
+ }));
+ }
+ const editedTargetVcard = targetVcard.replace(
+ 'END:VCARD\r\n',
+ 'NOTE:local lifecycle edit\r\nEND:VCARD\r\n',
+ );
+ const editedSecondTargetVcard = secondTargetVcard.replace(
+ 'END:VCARD\r\n',
+ 'NOTE:local lifecycle edit B\r\nEND:VCARD\r\n',
+ );
+ await databaseClient.query(
+ `UPDATE contacts SET
+ notes = CASE id
+ WHEN $1 THEN 'local lifecycle edit'
+ WHEN $2 THEN 'local lifecycle edit B'
+ END,
+ vcard = CASE id WHEN $1 THEN $3 ELSE $4 END,
+ etag = CASE id WHEN $1 THEN $5 ELSE $6 END
+ WHERE id = ANY($7::uuid[])`,
+ [
+ target.id,
+ secondTarget.id,
+ editedTargetVcard,
+ editedSecondTargetVcard,
+ createHash('md5').update(editedTargetVcard).digest('hex'),
+ createHash('md5').update(editedSecondTargetVcard).digest('hex'),
+ [target.id, secondTarget.id],
+ ],
+ );
+ const { rows: [seededLocalBook] } = await databaseClient.query(
+ 'SELECT id, sync_token FROM address_books WHERE id = $1',
+ [localBook.id],
+ );
+ const { rows: remoteBooks } = await databaseClient.query(
+ `SELECT id, external_url, sync_token, remote_sync_revision::text
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY external_url`,
+ [userId],
+ );
+ return {
+ userId,
+ generation,
+ remoteUrl,
+ secondRemoteUrl,
+ remoteBooks,
+ localBook: seededLocalBook,
+ unrelatedBook,
+ target,
+ secondTarget,
+ };
+}
+
+async function seedLegacyCrossBookUser() {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const [bookAId, bookBId] = [randomUUID(), randomUUID()].sort();
+ const bookAUrl = `https://dav.example.test/addressbooks/${userId}/legacy-a/`;
+ const bookBUrl = `https://dav.example.test/addressbooks/${userId}/legacy-b/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-legacy-cross-book-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: books } = await databaseClient.query(
+ `INSERT INTO address_books (id, user_id, name, source, external_url)
+ VALUES
+ ($1, $3, 'Legacy A', 'carddav', $4),
+ ($2, $3, 'Legacy B', 'carddav', $5)
+ RETURNING id, external_url, sync_token, remote_sync_revision::text`,
+ [bookAId, bookBId, userId, bookAUrl, bookBUrl],
+ );
+ const { rows: [unrelatedBook] } = await databaseClient.query(
+ `INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Legacy Unrelated') RETURNING id, sync_token`,
+ [userId],
+ );
+ const aMerge = {
+ ...remoteCard('legacy-a-merge', 'legacy-merge@example.test'),
+ href: `${bookAUrl}merge.vcf`,
+ };
+ const aSkip = {
+ ...remoteCard('legacy-a-skip', 'legacy-skip@example.test'),
+ href: `${bookAUrl}skip.vcf`,
+ };
+ const bMerge = {
+ ...remoteCard('legacy-b-merge', 'legacy-merge@example.test'),
+ href: `${bookBUrl}merge.vcf`,
+ };
+ const bSkip = {
+ ...remoteCard('legacy-b-skip', 'legacy-skip@example.test'),
+ href: `${bookBUrl}skip.vcf`,
+ };
+ for (const [book, cards] of [
+ [{ url: bookAUrl, displayName: 'Legacy A' }, [aMerge, aSkip]],
+ [{ url: bookBUrl, displayName: 'Legacy B' }, [bMerge, bSkip]],
+ ]) {
+ await beginApply(completePlan({
+ userId,
+ book,
+ connectionGeneration: generation,
+ collectionIdentity: { observedUrl: book.url, canonicalUrl: book.url },
+ upserts: cards,
+ }));
+ }
+ const bookA = books.find(book => book.id === bookAId);
+ const bookB = books.find(book => book.id === bookBId);
+ const { rows: bContacts } = await databaseClient.query(
+ `SELECT c.id, c.uid, c.primary_email, c.emails
+ FROM contacts c
+ WHERE c.address_book_id = $1
+ ORDER BY c.primary_email`,
+ [bookB.id],
+ );
+ const bMergeContact = bContacts.find(contact => (
+ contact.primary_email === 'legacy-merge@example.test'
+ ));
+ const bSkipContact = bContacts.find(contact => (
+ contact.primary_email === 'legacy-skip@example.test'
+ ));
+ const mergeBefore = {
+ displayName: bMerge.contact.displayName,
+ firstName: bMerge.contact.firstName,
+ lastName: bMerge.contact.lastName,
+ phones: bMerge.contact.phones,
+ organization: bMerge.contact.organization,
+ notes: bMerge.contact.notes,
+ photoData: bMerge.contact.photoData,
+ };
+ const mergeApplied = {
+ displayName: aMerge.contact.displayName,
+ firstName: aMerge.contact.firstName,
+ lastName: aMerge.contact.lastName,
+ phones: aMerge.contact.phones,
+ organization: aMerge.contact.organization,
+ notes: aMerge.contact.notes,
+ photoData: aMerge.contact.photoData,
+ };
+ const mergedVcard = generateVCard({
+ uid: bMergeContact.uid,
+ primaryEmail: bMergeContact.primary_email,
+ emails: bMergeContact.emails,
+ ...mergeApplied,
+ });
+ await databaseClient.query(
+ `UPDATE contacts SET
+ display_name = $2, first_name = $3, last_name = $4,
+ phones = $5::jsonb, organization = $6, notes = $7, photo_data = $8,
+ vcard = $9, etag = $10
+ WHERE id = $1`,
+ [
+ bMergeContact.id,
+ mergeApplied.displayName,
+ mergeApplied.firstName,
+ mergeApplied.lastName,
+ JSON.stringify(mergeApplied.phones),
+ mergeApplied.organization,
+ mergeApplied.notes,
+ mergeApplied.photoData,
+ mergedVcard,
+ createHash('md5').update(mergedVcard).digest('hex'),
+ ],
+ );
+ const { rows: aObjects } = await databaseClient.query(
+ `SELECT href, local_contact_id
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1`,
+ [bookA.id],
+ );
+ const aMergeObject = aObjects.find(object => object.href === aMerge.href);
+ const aSkipObject = aObjects.find(object => object.href === aSkip.href);
+ await databaseClient.query(
+ `UPDATE carddav_remote_objects SET
+ disposition = 'skip', local_contact_id = NULL,
+ merge_before = NULL, merge_applied = NULL,
+ mapping_status = 'pending_materialization',
+ legacy_projection = jsonb_build_object(
+ 'disposition', disposition,
+ 'merge_before', merge_before,
+ 'merge_applied', merge_applied
+ )
+ WHERE address_book_id = $1`,
+ [bookB.id],
+ );
+ await databaseClient.query(
+ `UPDATE carddav_remote_objects SET
+ disposition = 'merge', local_contact_id = $3,
+ merge_before = $4::jsonb, merge_applied = $5::jsonb,
+ mapping_status = 'pending_materialization',
+ legacy_projection = jsonb_build_object(
+ 'disposition', 'merge',
+ 'merge_before', $4::jsonb,
+ 'merge_applied', $5::jsonb
+ )
+ WHERE address_book_id = $1 AND href = $2`,
+ [
+ bookA.id,
+ aMerge.href,
+ bMergeContact.id,
+ JSON.stringify(mergeBefore),
+ JSON.stringify(mergeApplied),
+ ],
+ );
+ await databaseClient.query(
+ `UPDATE carddav_remote_objects SET
+ disposition = 'skip', local_contact_id = $3,
+ merge_before = NULL, merge_applied = NULL,
+ mapping_status = 'pending_materialization',
+ legacy_projection = jsonb_build_object(
+ 'disposition', 'skip',
+ 'merge_before', NULL,
+ 'merge_applied', NULL
+ )
+ WHERE address_book_id = $1 AND href = $2`,
+ [bookA.id, aSkip.href, bSkipContact.id],
+ );
+ await databaseClient.query(
+ 'DELETE FROM contacts WHERE id = ANY($1::uuid[])',
+ [[aMergeObject.local_contact_id, aSkipObject.local_contact_id]],
+ );
+ const { rows: seededBooks } = await databaseClient.query(
+ `SELECT id, external_url, sync_token, remote_sync_revision::text
+ FROM address_books WHERE id = ANY($1::uuid[]) ORDER BY id`,
+ [[bookA.id, bookB.id]],
+ );
+ return {
+ userId,
+ generation,
+ bookA: seededBooks.find(book => book.id === bookA.id),
+ bookB: seededBooks.find(book => book.id === bookB.id),
+ bookAUrl,
+ bookBUrl,
+ unrelatedBook,
+ aMerge,
+ aSkip,
+ bMerge,
+ bSkip,
+ bMergeContact,
+ bSkipContact,
+ };
+}
+
+async function probeBackendLock(pid) {
+ const { rows } = await adminClient.query(
+ 'SELECT wait_event_type FROM pg_stat_activity WHERE pid = $1',
+ [pid],
+ );
+ const state = { wait_event_type: rows[0]?.wait_event_type ?? null };
+ return { done: state.wait_event_type === 'Lock', state };
+}
+
+async function applyWithClient(client, plan, queryOverride) {
+ await client.query('BEGIN');
+ try {
+ const transactionClient = queryOverride
+ ? { query: (sql, params) => queryOverride(client, sql, params) }
+ : client;
+ const result = await carddavSync.applyBookDelta(transactionClient, plan);
+ await client.query('COMMIT');
+ return result;
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ }
+}
+
+async function automaticState(userId) {
+ const { rows: books } = await databaseClient.query(
+ `SELECT id, source, external_url, sync_token, remote_sync_token,
+ remote_sync_revision::text
+ FROM address_books WHERE user_id = $1 ORDER BY id`,
+ [userId],
+ );
+ const { rows: contacts } = await databaseClient.query(
+ `SELECT id, address_book_id, display_name, photo_data, vcard, etag
+ FROM contacts WHERE user_id = $1 ORDER BY id`,
+ [userId],
+ );
+ const { rows: ledger } = await databaseClient.query(
+ `SELECT o.address_book_id, o.href, o.disposition, o.local_contact_id,
+ o.merge_before, o.merge_applied
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1 ORDER BY o.href`,
+ [userId],
+ );
+ const { rows: [integration] } = await databaseClient.query(
+ `SELECT config FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [userId],
+ );
+ return { books, contacts, ledger, integration };
+}
+
+async function seedAutomaticPair() {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const [bookAId, bookBId] = [randomUUID(), randomUUID()].sort();
+ const bookAUrl = `https://dav.example.test/addressbooks/${userId}/automatic-a/`;
+ const bookBUrl = `https://dav.example.test/addressbooks/${userId}/automatic-b/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-automatic-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [targetBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Automatic Target') RETURNING id",
+ [userId],
+ );
+ const { rows: [unrelatedBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Automatic Unrelated') RETURNING id",
+ [userId],
+ );
+ await databaseClient.query(
+ `INSERT INTO address_books (id, user_id, name, source, external_url)
+ VALUES ($1, $3, 'Automatic Remote A', 'carddav', $4),
+ ($2, $3, 'Automatic Remote B', 'carddav', $5)`,
+ [bookAId, bookBId, userId, bookAUrl, bookBUrl],
+ );
+ const target = {
+ uid: `automatic-target-${userId}`,
+ displayName: 'Automatic Original',
+ firstName: 'Automatic',
+ lastName: 'Original',
+ primaryEmail: `automatic-duplicate-${userId}@example.test`,
+ emails: [{
+ value: `automatic-duplicate-${userId}@example.test`, type: 'other', primary: true,
+ }],
+ phones: [],
+ organization: null,
+ notes: 'Automatic local note',
+ photoData: null,
+ };
+ const targetVcard = generateVCard(target);
+ const { rows: [targetContact] } = await databaseClient.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, organization, notes, photo_data, is_auto
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '[]'::jsonb,
+ NULL, $11, NULL, false
+ ) RETURNING id`,
+ [
+ targetBook.id, userId, target.uid, targetVcard,
+ createHash('md5').update(targetVcard).digest('hex'), target.displayName,
+ target.firstName, target.lastName, target.primaryEmail,
+ JSON.stringify(target.emails), target.notes,
+ ],
+ );
+ const duplicate = {
+ ...remoteCard('Automatic Remote Duplicate', target.primaryEmail),
+ href: `${bookAUrl}duplicate.vcf`,
+ };
+ const unique = {
+ ...remoteCard('Automatic Remote Unique', `automatic-unique-${userId}@example.test`),
+ href: `${bookBUrl}unique.vcf`,
+ };
+ for (const [url, name, card, token] of [
+ [bookAUrl, 'Automatic Remote A', duplicate, 'automatic-a-token'],
+ [bookBUrl, 'Automatic Remote B', unique, 'automatic-b-token'],
+ ]) {
+ await beginApply(completePlan({
+ userId,
+ book: { url, displayName: name },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '0',
+ expectedRemoteToken: null,
+ nextRemoteToken: token,
+ capability: 'sync-collection',
+ collectionIdentity: { observedUrl: url, canonicalUrl: url },
+ upserts: [card],
+ }));
+ }
+ return {
+ userId,
+ generation,
+ bookAId,
+ bookBId,
+ bookAUrl,
+ bookBUrl,
+ targetBook,
+ unrelatedBook,
+ targetContact,
+ target,
+ targetVcard,
+ duplicate,
+ unique,
+ before: await automaticState(userId),
+ };
+}
+
+async function seedLargeIncrementalUser() {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/large/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-large-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'serverUrl', 'https://dav.example.test/',
+ 'username', 'large-user',
+ 'password', 'encrypted',
+ 'connectionGeneration', $2::text,
+ 'contactCount', 10000
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [book] } = await databaseClient.query(
+ `INSERT INTO address_books (
+ user_id, name, source, external_url, remote_sync_token,
+ remote_sync_capability, remote_sync_revision, remote_projection_fingerprint
+ ) VALUES ($1, 'Large Remote', 'carddav', $2, 'large-token-0',
+ 'sync-collection', 0, $3)
+ RETURNING id, sync_token`,
+ [
+ userId,
+ remoteUrl,
+ createHash('sha256').update(JSON.stringify([])).digest('hex'),
+ ],
+ );
+ await databaseClient.query(`
+ WITH input AS (
+ SELECT
+ i,
+ $2::text || 'large-' || lpad(i::text, 5, '0') || '.vcf' AS href,
+ 'large-' || lpad(i::text, 5, '0') AS remote_uid,
+ 'Large ' || lpad(i::text, 5, '0') AS display_name,
+ 'Large' AS first_name,
+ lpad(i::text, 5, '0') AS last_name,
+ 'l' || lpad(i::text, 5, '0') || '@example.test' AS email
+ FROM generate_series(1, 10000) AS series(i)
+ ), materialized AS (
+ SELECT *,
+ 'BEGIN:VCARD' || E'\r\n' ||
+ 'VERSION:3.0' || E'\r\n' ||
+ 'UID:' || remote_uid || E'\r\n' ||
+ 'FN:' || display_name || E'\r\n' ||
+ 'N:' || last_name || ';' || first_name || ';;;' || E'\r\n' ||
+ 'EMAIL:' || email || E'\r\n' ||
+ 'END:VCARD' || E'\r\n' AS remote_vcard,
+ encode(sha256(convert_to(href, 'UTF8')), 'hex') AS local_uid
+ FROM input
+ ), local_materialized AS (
+ SELECT *,
+ 'BEGIN:VCARD' || E'\r\n' ||
+ 'VERSION:3.0' || E'\r\n' ||
+ 'UID:' || local_uid || E'\r\n' ||
+ 'FN:' || display_name || E'\r\n' ||
+ 'N:' || last_name || ';' || first_name || ';;;' || E'\r\n' ||
+ 'EMAIL;TYPE=OTHER:' || email || E'\r\n' ||
+ 'END:VCARD' || E'\r\n' AS local_vcard
+ FROM materialized
+ ), inserted AS (
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ first_name, last_name, primary_email, emails, phones, is_auto
+ )
+ SELECT $1, $3, local_uid, local_vcard, md5(local_vcard), display_name,
+ first_name, last_name, email,
+ jsonb_build_array(jsonb_build_object(
+ 'value', email, 'type', 'other', 'primary', true
+ )), '[]'::jsonb, false
+ FROM local_materialized
+ RETURNING id, uid
+ )
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ disposition, local_contact_id
+ )
+ SELECT $1, materialized.href, '"large-' || lpad(materialized.i::text, 5, '0') || '-1"',
+ materialized.remote_vcard, materialized.email, 'separate', inserted.id
+ FROM materialized
+ JOIN inserted ON inserted.uid = materialized.local_uid
+ `, [book.id, remoteUrl, userId]);
+ return { userId, generation, remoteUrl, book };
+}
+
+describe('CardDAV full snapshot transaction', () => {
+ beforeAll(async () => {
+ adminClient = new Client({ connectionString: databaseUrl });
+ await adminClient.connect();
+ await createTestDatabase(adminClient, databaseName);
+
+ databaseClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ await databaseClient.connect();
+ await assertMinimumPostgresVersion(databaseClient);
+ await applyMigrations(databaseClient);
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [USER_ID, `carddav-sync-${databaseName}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [USER_ID, CONNECTION_GENERATION],
+ );
+
+ const { rows: [localBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Personal') RETURNING id",
+ [USER_ID],
+ );
+ const targetVcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:local-target',
+ 'FN:Local Target',
+ 'EMAIL:duplicate@example.test',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES ($1, $2, 'local-target', $3, $4, 'Local Target', 'Local',
+ 'duplicate@example.test', $5::jsonb, '[]'::jsonb, false)
+ `, [
+ localBook.id,
+ USER_ID,
+ targetVcard,
+ createHash('md5').update(targetVcard).digest('hex'),
+ JSON.stringify([{ value: 'duplicate@example.test', type: 'other', primary: true }]),
+ ]);
+ }, 120_000);
+
+ afterAll(async () => {
+ if (databaseClient) await databaseClient.end();
+ if (adminClient) {
+ await dropTestDatabase(adminClient, databaseName);
+ await adminClient.end();
+ }
+ }, 120_000);
+
+ it('materializes upgraded separate, merge, and skip mappings atomically on PostgreSQL 16', async () => {
+ const transitionDatabase = `carddav_materialize_${process.pid}_${randomUUID()
+ .replaceAll('-', '').slice(0, 10)}`;
+ let transitionClient;
+ try {
+ await createTestDatabase(adminClient, transitionDatabase);
+ transitionClient = new Client({ connectionString: connectionStringFor(transitionDatabase) });
+ await transitionClient.connect();
+ await applyMigrations(transitionClient, '0034');
+
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const localBookId = randomUUID();
+ const modes = ['separate', 'merge', 'skip'];
+ const books = modes.map(mode => ({
+ id: randomUUID(),
+ mode,
+ url: `https://dav.example.test/addressbooks/${userId}/${mode}/`,
+ }));
+ const cards = books.map(book => ({
+ ...remoteCard(`legacy-${book.mode}`, `legacy-${book.mode}@example.test`),
+ href: `${book.url}contact.vcf`,
+ }));
+
+ await transitionClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-materialize-${userId}`],
+ );
+ await transitionClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text, 'contactCount', 2
+ ))`,
+ [userId, generation],
+ );
+ await transitionClient.query(
+ `INSERT INTO address_books (id, user_id, name)
+ VALUES ($1, $2, 'Legacy Explicit')`,
+ [localBookId, userId],
+ );
+ for (const book of books) {
+ await transitionClient.query(
+ `INSERT INTO address_books (id, user_id, name, source, external_url)
+ VALUES ($1, $2, $3, 'carddav', $4)`,
+ [book.id, userId, `Legacy ${book.mode}`, book.url],
+ );
+ }
+
+ const localContacts = new Map();
+ for (const mode of ['separate', 'merge']) {
+ const card = cards.find(candidate => candidate.href.includes(`/${mode}/`));
+ const addressBookId = mode === 'separate'
+ ? books.find(book => book.mode === mode).id
+ : localBookId;
+ const uid = `legacy-${mode}-local`;
+ const vcard = generateVCard({ ...card.contact, uid });
+ const { rows: [contact] } = await transitionClient.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, organization, notes, photo_data,
+ is_auto
+ ) VALUES (
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,false
+ ) RETURNING id`,
+ [
+ addressBookId, userId, uid, vcard,
+ createHash('md5').update(vcard).digest('hex'),
+ card.contact.displayName, card.contact.firstName, card.contact.lastName,
+ card.contact.primaryEmail, JSON.stringify(card.contact.emails),
+ JSON.stringify(card.contact.phones), card.contact.organization,
+ card.contact.notes, card.contact.photoData,
+ ],
+ );
+ localContacts.set(mode, contact.id);
+ }
+
+ for (const [index, mode] of modes.entries()) {
+ const mergeBefore = mode === 'merge' ? { display_name: 'Legacy Before' } : null;
+ const mergeApplied = mode === 'merge' ? { display_name: 'Legacy Remote' } : null;
+ await transitionClient.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email, disposition,
+ local_contact_id, merge_before, merge_applied
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb)`,
+ [
+ books[index].id, cards[index].href, cards[index].remoteEtag,
+ cards[index].vcard, cards[index].contact.primaryEmail, mode,
+ localContacts.get(mode) || null, JSON.stringify(mergeBefore),
+ JSON.stringify(mergeApplied),
+ ],
+ );
+ }
+
+ await applyTestMigrations(transitionClient, {
+ migrationsDirectory,
+ first: '0035',
+ through: '0035',
+ });
+ const { rows: pending } = await transitionClient.query(
+ `SELECT disposition, mapping_status, legacy_projection
+ FROM carddav_remote_objects ORDER BY disposition`,
+ );
+ expect(pending).toEqual([
+ expect.objectContaining({
+ disposition: 'merge',
+ mapping_status: 'pending_materialization',
+ legacy_projection: expect.objectContaining({ disposition: 'merge' }),
+ }),
+ expect.objectContaining({
+ disposition: 'separate',
+ mapping_status: 'pending_materialization',
+ legacy_projection: expect.objectContaining({ disposition: 'separate' }),
+ }),
+ expect.objectContaining({
+ disposition: 'skip',
+ mapping_status: 'pending_materialization',
+ legacy_projection: expect.objectContaining({ disposition: 'skip' }),
+ }),
+ ]);
+
+ for (const [index, book] of books.entries()) {
+ await transitionClient.query('BEGIN');
+ try {
+ await carddavSync.applyBookDelta(transitionClient, completePlan({
+ userId,
+ connectionGeneration: generation,
+ book: { url: book.url, displayName: `Legacy ${book.mode}` },
+ collectionIdentity: { observedUrl: book.url, canonicalUrl: book.url },
+ upserts: [cards[index]],
+ }));
+ await transitionClient.query('COMMIT');
+ } catch (error) {
+ await transitionClient.query('ROLLBACK');
+ throw error;
+ }
+ }
+
+ const { rows: committed } = await transitionClient.query(
+ `SELECT o.href, o.disposition, o.vcard, o.local_contact_id, o.mapping_status,
+ o.vcard_version, o.remote_semantic_hash, o.local_contact_hash,
+ o.legacy_projection, o.last_synced_at,
+ c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones,
+ c.organization, c.notes, c.photo_data, c.additional_fields
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ ORDER BY o.disposition`,
+ );
+ expect(committed).toHaveLength(3);
+ for (const mapping of committed) {
+ expect(mapping).toMatchObject({
+ mapping_status: 'synced',
+ vcard_version: '3.0',
+ legacy_projection: null,
+ last_synced_at: expect.any(Date),
+ });
+ expect(mapping.remote_semantic_hash)
+ .toBe(semanticVCardHash(parseVCardDocument(mapping.vcard)));
+ expect(mapping.local_contact_hash).toBe(localContactHash(mapping));
+ }
+
+ const rollbackBook = books.find(book => book.mode === 'separate');
+ const rollbackCard = cards.find(card => card.href.includes('/separate/'));
+ const rollbackAttempt = {
+ ...rollbackCard,
+ remoteEtag: '"rollback-attempt"',
+ vcard: rollbackCard.vcard
+ .replace('FN:legacy-separate', 'FN:Rollback Attempt')
+ .replace('END:VCARD\r\n', 'NOTE:must roll back\r\nEND:VCARD\r\n'),
+ contact: {
+ ...rollbackCard.contact,
+ displayName: 'Rollback Attempt',
+ notes: 'must roll back',
+ },
+ };
+ await transitionClient.query(
+ `UPDATE carddav_remote_objects
+ SET mapping_status = 'pending_materialization',
+ remote_semantic_hash = NULL,
+ local_contact_hash = NULL,
+ last_synced_at = NULL,
+ legacy_projection = jsonb_build_object(
+ 'disposition', disposition,
+ 'merge_before', merge_before,
+ 'merge_applied', merge_applied
+ )
+ WHERE address_book_id = $1`,
+ [rollbackBook.id],
+ );
+ const readRollbackState = async () => {
+ const { rows: [mapping] } = await transitionClient.query(
+ `SELECT href, remote_etag, vcard, primary_email, disposition,
+ local_contact_id, merge_before, merge_applied, mapping_status,
+ mapping_revision::text, vcard_version, remote_semantic_hash,
+ local_contact_hash, legacy_projection, last_synced_at
+ FROM carddav_remote_objects WHERE address_book_id = $1`,
+ [rollbackBook.id],
+ );
+ const { rows: [contact] } = await transitionClient.query(
+ `SELECT id, address_book_id, user_id, uid, vcard, etag, display_name,
+ first_name, last_name, primary_email, emails, phones, organization,
+ notes, photo_data, additional_fields, is_auto
+ FROM contacts WHERE id = $1`,
+ [mapping.local_contact_id],
+ );
+ const { rows: [book] } = await transitionClient.query(
+ `SELECT remote_sync_token, remote_sync_revision::text
+ FROM address_books WHERE id = $1`,
+ [rollbackBook.id],
+ );
+ return { mapping, contact, book };
+ };
+ const beforeRollback = await readRollbackState();
+ let mappingWritten = false;
+ await transitionClient.query('BEGIN');
+ try {
+ const failingClient = {
+ query: async (sql, params) => {
+ if (mappingWritten && /UPDATE address_books SET/.test(sql)) {
+ throw new Error('forced post-materialization failure');
+ }
+ const result = await transitionClient.query(sql, params);
+ if (/(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) {
+ mappingWritten = true;
+ }
+ return result;
+ },
+ };
+ await expect(carddavSync.applyBookDelta(failingClient, completePlan({
+ userId,
+ connectionGeneration: generation,
+ book: { url: rollbackBook.url, displayName: 'Legacy separate' },
+ expectedRemoteRevision: beforeRollback.book.remote_sync_revision,
+ expectedRemoteToken: beforeRollback.book.remote_sync_token,
+ collectionIdentity: {
+ observedUrl: rollbackBook.url,
+ canonicalUrl: rollbackBook.url,
+ },
+ upserts: [rollbackAttempt],
+ }))).rejects.toThrow('forced post-materialization failure');
+ } finally {
+ await transitionClient.query('ROLLBACK');
+ }
+ expect(mappingWritten).toBe(true);
+ expect(await readRollbackState()).toEqual(beforeRollback);
+ } finally {
+ if (transitionClient) await transitionClient.end();
+ await dropTestDatabase(adminClient, transitionDatabase);
+ }
+ }, 120_000);
+
+ it('uses the contracted 0036 schema even when config contains a stray dupMode key', async () => {
+ const contractedDatabase = `carddav_contracted_${process.pid}_${randomUUID()
+ .replaceAll('-', '').slice(0, 10)}`;
+ let contractedClient;
+ try {
+ await createTestDatabase(adminClient, contractedDatabase);
+ contractedClient = new Client({
+ connectionString: connectionStringFor(contractedDatabase),
+ });
+ await contractedClient.connect();
+ await applyMigrations(contractedClient, '0036');
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/contracted/`;
+ await contractedClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-contracted-${userId}`],
+ );
+ await contractedClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text, 'dupMode', 'merge'
+ ))`,
+ [userId, generation],
+ );
+
+ await contractedClient.query('BEGIN');
+ try {
+ const contractedCard = remoteCard('contracted', 'contracted@example.test');
+ await expect(carddavSync.applyBookDelta(contractedClient, completePlan({
+ userId,
+ connectionGeneration: generation,
+ book: { url: remoteUrl, displayName: 'Contracted' },
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [{
+ ...contractedCard,
+ href: `${remoteUrl}contracted.vcf`,
+ }],
+ }))).resolves.toMatchObject({ remote: 1, updated: 1, removed: 0 });
+ await contractedClient.query('COMMIT');
+ } catch (error) {
+ await contractedClient.query('ROLLBACK');
+ throw error;
+ }
+ const { rows: [beforeStale] } = await contractedClient.query(`
+ SELECT o.*, o.mapping_revision::text AS revision
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ `, [userId]);
+ await contractedClient.query('BEGIN');
+ const stale = await carddavMappingState.applyConfirmedRemoteContact(contractedClient, {
+ addressBookId: beforeStale.address_book_id,
+ href: beforeStale.href,
+ expectedMappingRevision: String(BigInt(beforeStale.revision) + 1n),
+ remoteEtag: '"stale-must-not-write"',
+ vcard: beforeStale.vcard,
+ primaryEmail: beforeStale.primary_email,
+ localContactId: beforeStale.local_contact_id,
+ vcardVersion: beforeStale.vcard_version,
+ remoteSemanticHash: beforeStale.remote_semantic_hash,
+ localContactHash: beforeStale.local_contact_hash,
+ });
+ await contractedClient.query('COMMIT');
+ expect(stale).toMatchObject({
+ ok: false,
+ stale: true,
+ code: 'ERR_CARDDAV_MAPPING_STALE',
+ expectedMappingRevision: String(BigInt(beforeStale.revision) + 1n),
+ });
+ const { rows: [afterStale] } = await contractedClient.query(`
+ SELECT o.*, o.mapping_revision::text AS revision
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ `, [userId]);
+ expect(afterStale).toEqual(beforeStale);
+ } finally {
+ if (contractedClient) await contractedClient.end();
+ await dropTestDatabase(adminClient, contractedDatabase);
+ }
+ }, 120_000);
+
+ it('rotates connection generation only for committed identity or auth replacements', async () => {
+ const userId = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-connection-${userId}`],
+ );
+ useDatabaseTransactions();
+
+ const initial = await carddavSync.replaceCarddavConnection(userId, {
+ serverUrl: 'https://dav-a.example.test/',
+ username: 'user-a',
+ password: 'encrypted-a',
+ intervalMin: 60,
+ });
+ expect(initial.connectionGeneration).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+ );
+
+ const interval = await carddavSync.patchCarddavConnection(userId, { intervalMin: 30 });
+ expect(interval).toMatchObject({
+ intervalMin: 30,
+ connectionGeneration: initial.connectionGeneration,
+ });
+
+ const server = await carddavSync.patchCarddavConnection(userId, {
+ serverUrl: 'https://dav-b.example.test/',
+ });
+ expect(server.connectionGeneration).not.toBe(initial.connectionGeneration);
+ const username = await carddavSync.patchCarddavConnection(userId, {
+ username: 'user-b',
+ });
+ expect(username.connectionGeneration).not.toBe(server.connectionGeneration);
+ const password = await carddavSync.patchCarddavConnection(userId, {
+ password: 'encrypted-b',
+ });
+ expect(password.connectionGeneration).not.toBe(username.connectionGeneration);
+ expect(password).toMatchObject({
+ serverUrl: 'https://dav-b.example.test/',
+ username: 'user-b',
+ password: 'encrypted-b',
+ intervalMin: 30,
+ });
+
+ const patched = await carddavSync.patchCarddavConnection(userId, { dupMode: 'merge' });
+ expect(patched.connectionGeneration).toBe(password.connectionGeneration);
+ expect(patched).not.toHaveProperty('dupMode');
+
+ const replacement = await carddavSync.replaceCarddavConnection(userId, {
+ serverUrl: 'https://dav-c.example.test/',
+ username: 'user-c',
+ password: 'encrypted-c',
+ intervalMin: 45,
+ });
+ expect(replacement.connectionGeneration).not.toBe(password.connectionGeneration);
+ expect(replacement).toMatchObject({
+ serverUrl: 'https://dav-c.example.test/',
+ username: 'user-c',
+ password: 'encrypted-c',
+ intervalMin: 45,
+ lastError: null,
+ });
+ }, 120_000);
+
+ it('invalidates remote book identity state while preserving password-only state', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/identity/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-identity-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)`,
+ [userId, JSON.stringify({
+ serverUrl: 'https://dav.example.test/',
+ username: 'identity-a',
+ password: 'encrypted-a',
+ intervalMin: 60,
+ connectionGeneration: generation,
+ })],
+ );
+ await beginApply(completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Identity Remote' },
+ connectionGeneration: generation,
+ nextRemoteToken: 'identity-token',
+ capability: 'sync-collection',
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [{
+ ...remoteCard('identity-retained', 'identity-retained@example.test'),
+ href: `${remoteUrl}retained.vcf`,
+ }],
+ }));
+ const beforePassword = await persistedLifecycleState(userId);
+ useDatabaseTransactions();
+
+ const passwordConfig = await carddavSync.patchCarddavConnection(userId, {
+ password: 'encrypted-b',
+ });
+ const afterPassword = await persistedLifecycleState(userId);
+ expect(passwordConfig.connectionGeneration).not.toBe(generation);
+ expect({
+ books: afterPassword.books,
+ contacts: afterPassword.contacts,
+ ledger: afterPassword.ledger,
+ allBooks: afterPassword.allBooks,
+ }).toEqual({
+ books: beforePassword.books,
+ contacts: beforePassword.contacts,
+ ledger: beforePassword.ledger,
+ allBooks: beforePassword.allBooks,
+ });
+
+ const usernameConfig = await carddavSync.patchCarddavConnection(userId, {
+ username: 'identity-b',
+ });
+ const afterUsername = await persistedLifecycleState(userId);
+ expect(usernameConfig.connectionGeneration).not.toBe(passwordConfig.connectionGeneration);
+ expect(afterUsername.contacts).toEqual(afterPassword.contacts);
+ expect(afterUsername.ledger).toEqual(afterPassword.ledger);
+ expect(afterUsername.books).toEqual([{
+ ...afterPassword.books[0],
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: String(Number(afterPassword.books[0].remote_sync_revision) + 1),
+ remote_projection_fingerprint: null,
+ }]);
+ expect(afterUsername.allBooks).toEqual([{
+ ...afterPassword.allBooks[0],
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: String(Number(afterPassword.allBooks[0].remote_sync_revision) + 1),
+ remote_projection_fingerprint: null,
+ }]);
+ }, 120_000);
+
+ it('rolls back identity invalidation with a failed connection patch', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-identity-rollback-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)`,
+ [userId, JSON.stringify({
+ serverUrl: 'https://dav-before.example.test/',
+ username: 'identity-before',
+ password: 'encrypted-before',
+ connectionGeneration: generation,
+ })],
+ );
+ await databaseClient.query(
+ `INSERT INTO address_books (
+ user_id, name, source, external_url, remote_sync_token,
+ remote_sync_capability, remote_sync_revision, remote_projection_fingerprint
+ ) VALUES ($1, 'Identity Rollback', 'carddav',
+ 'https://dav-before.example.test/addressbooks/identity/',
+ 'identity-token-before', 'sync-collection', 9, 'fingerprint-before')`,
+ [userId],
+ );
+ const before = await persistedLifecycleState(userId);
+ let identityInvalidated = false;
+ useDatabaseTransactions(async (client, sql, params) => {
+ if (/UPDATE address_books[\s\S]+remote_sync_capability = 'unknown'/.test(sql)) {
+ identityInvalidated = true;
+ }
+ if (/UPDATE user_integrations/.test(sql)) throw new Error('forced identity patch failure');
+ return client.query(sql, params);
+ });
+
+ await expect(carddavSync.patchCarddavConnection(userId, {
+ serverUrl: 'https://dav-after.example.test/',
+ })).rejects.toThrow('forced identity patch failure');
+ expect(identityInvalidated).toBe(true);
+ expect(await persistedLifecycleState(userId)).toEqual(before);
+ }, 120_000);
+
+ it('classifies 10,000 mappings deterministically while writing only changed objects', async () => {
+ const fixture = await seedLargeIncrementalUser();
+ const timestampsBefore = await databaseClient.query(`
+ SELECT href, created_at, updated_at
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1
+ ORDER BY href
+ `, [fixture.book.id]);
+ const noChangeSql = [];
+ const noChange = await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.remoteUrl, displayName: 'Large Remote' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteToken: 'large-token-0',
+ nextRemoteToken: 'large-token-1',
+ capability: 'sync-collection',
+ replaceAll: false,
+ }), async (client, sql, params) => {
+ noChangeSql.push([sql, params]);
+ return client.query(sql, params);
+ });
+ expect(noChange).toMatchObject({ changedBookIds: [], ledgerChanged: false });
+ expect(noChangeSql.some(([sql]) => (
+ /FROM carddav_remote_objects/.test(sql) && /ORDER BY b\.id, o\.href/.test(sql)
+ ))).toBe(true);
+ expect(noChangeSql.some(([sql]) => /FROM contacts/.test(sql))).toBe(true);
+ expect(noChangeSql.some(([sql]) => (
+ /(?:INSERT INTO|DELETE FROM|UPDATE) carddav_remote_objects/.test(sql)
+ ))).toBe(false);
+ const timestampsAfterNoChange = await databaseClient.query(`
+ SELECT href, created_at, updated_at
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1
+ ORDER BY href
+ `, [fixture.book.id]);
+ expect(timestampsAfterNoChange.rows).toEqual(timestampsBefore.rows);
+
+ const changedHref = `${fixture.remoteUrl}large-05000.vcf`;
+ const changed = {
+ ...remoteCard('Large Changed', 'l05000@example.test'),
+ href: changedHref,
+ remoteEtag: '"large-05000-2"',
+ };
+ const unrelatedBefore = await databaseClient.query(`
+ SELECT o.*, c.*
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE o.address_book_id = $1 AND o.href = $2
+ `, [fixture.book.id, `${fixture.remoteUrl}large-00001.vcf`]);
+ const updateSql = [];
+ await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.remoteUrl, displayName: 'Large Remote' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: '1',
+ expectedRemoteToken: 'large-token-1',
+ nextRemoteToken: 'large-token-2',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [changed],
+ }), async (client, sql, params) => {
+ updateSql.push([sql, params]);
+ return client.query(sql, params);
+ });
+ const ledgerReads = updateSql.filter(([sql]) => (
+ /SELECT[\s\S]+FROM carddav_remote_objects/.test(sql)
+ ));
+ expect(ledgerReads.length).toBeGreaterThan(0);
+ expect(ledgerReads.some(([sql]) => /ORDER BY b\.id, o\.href/.test(sql))).toBe(true);
+ expect(updateSql.some(([sql]) => /FROM contacts/.test(sql))).toBe(true);
+ const ledgerUpserts = updateSql.filter(([sql]) => (
+ /UPDATE carddav_remote_objects/.test(sql)
+ ));
+ expect(ledgerUpserts).toHaveLength(1);
+ expect(ledgerUpserts[0][1]).toContain(changedHref);
+ expect(await databaseClient.query(`
+ SELECT o.*, c.*
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE o.address_book_id = $1 AND o.href = $2
+ `, [fixture.book.id, `${fixture.remoteUrl}large-00001.vcf`]))
+ .toEqual(unrelatedBefore);
+
+ const removalSql = [];
+ await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.remoteUrl, displayName: 'Large Remote' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: '2',
+ expectedRemoteToken: 'large-token-2',
+ nextRemoteToken: 'large-token-3',
+ capability: 'sync-collection',
+ replaceAll: false,
+ removedHrefs: [changedHref],
+ }), async (client, sql, params) => {
+ removalSql.push([sql, params]);
+ return client.query(sql, params);
+ });
+ const ledgerDeletes = removalSql.filter(([sql]) => (
+ /DELETE FROM carddav_remote_objects/.test(sql)
+ ));
+ expect(ledgerDeletes).toHaveLength(1);
+ expect(ledgerDeletes[0][1]).toEqual([fixture.book.id, changedHref, '1']);
+ expect(removalSql.some(([sql]) => (
+ /SELECT[\s\S]+FROM carddav_remote_objects/.test(sql)
+ && /ORDER BY b\.id, o\.href/.test(sql)
+ ))).toBe(true);
+ const { rows: [{ count }] } = await databaseClient.query(
+ 'SELECT COUNT(*)::int AS count FROM carddav_remote_objects WHERE address_book_id = $1',
+ [fixture.book.id],
+ );
+ expect(count).toBe(9999);
+
+ const { rows: [newTargetBook] } = await databaseClient.query(
+ `INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Large New Target') RETURNING id, sync_token`,
+ [fixture.userId],
+ );
+ const fingerprintReconciliationSql = [];
+ await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.remoteUrl, displayName: 'Large Remote' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: '3',
+ expectedRemoteToken: 'large-token-3',
+ nextRemoteToken: 'large-token-4',
+ capability: 'sync-collection',
+ replaceAll: false,
+ }), async (client, sql, params) => {
+ fingerprintReconciliationSql.push([sql, params]);
+ return client.query(sql, params);
+ });
+ expect(fingerprintReconciliationSql.some(([sql]) => (
+ /FROM carddav_remote_objects/.test(sql) && /ORDER BY b\.id, o\.href/.test(sql)
+ ))).toBe(true);
+ expect(fingerprintReconciliationSql.some(([sql]) => (
+ /FROM contacts/.test(sql) && !/ANY\(/.test(sql)
+ ))).toBe(true);
+ const { rows: [reconciledBook] } = await databaseClient.query(
+ `SELECT remote_projection_fingerprint
+ FROM address_books WHERE id = $1`,
+ [fixture.book.id],
+ );
+ expect(reconciledBook.remote_projection_fingerprint).toBe(
+ createHash('sha256').update(JSON.stringify([
+ [newTargetBook.id, newTargetBook.sync_token],
+ ])).digest('hex'),
+ );
+
+ const finalizerSql = [];
+ useDatabaseTransactions(async (client, sql, params) => {
+ finalizerSql.push([sql, params]);
+ return client.query(sql, params);
+ });
+ await carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: fixture.generation,
+ seenUrls: [fixture.remoteUrl],
+ status: { lastError: null },
+ });
+ expect(finalizerSql.some(([sql]) => /FROM carddav_remote_objects/.test(sql))).toBe(true);
+ expect(finalizerSql.some(([sql]) => /FROM contacts/.test(sql))).toBe(false);
+ }, 120_000);
+
+ it('rolls back a failed connection patch without changing config or generation', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-connection-rollback-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)`,
+ [userId, JSON.stringify({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted-before',
+ intervalMin: 60,
+ connectionGeneration: generation,
+ })],
+ );
+ const before = await persistedState(userId);
+ useDatabaseTransactions(async (client, sql, params) => {
+ if (/UPDATE user_integrations/.test(sql)) throw new Error('forced connection patch failure');
+ return client.query(sql, params);
+ });
+
+ await expect(carddavSync.patchCarddavConnection(userId, {
+ password: 'encrypted-after',
+ })).rejects.toThrow('forced connection patch failure');
+ expect(await persistedState(userId)).toEqual(before);
+ }, 120_000);
+
+ it('rejects a password patch preflighted against a replaced generation', async () => {
+ const userId = randomUUID();
+ const preflightedGeneration = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-password-preflight-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)`,
+ [userId, JSON.stringify({
+ serverUrl: 'https://dav-a.example.test/',
+ username: 'user-a',
+ password: 'encrypted-a',
+ intervalMin: 60,
+ connectionGeneration: preflightedGeneration,
+ })],
+ );
+ useDatabaseTransactions();
+ const replacement = await carddavSync.replaceCarddavConnection(userId, {
+ serverUrl: 'https://dav-b.example.test/',
+ username: 'user-b',
+ password: 'encrypted-b',
+ intervalMin: 30,
+ });
+ const beforeStalePatch = await persistedState(userId);
+
+ await expect(carddavSync.patchCarddavConnection(
+ userId,
+ { password: 'encrypted-after-preflight' },
+ preflightedGeneration,
+ )).rejects.toMatchObject({
+ name: 'StaleCarddavPlanError',
+ expectedConnectionGeneration: preflightedGeneration,
+ actualConnectionGeneration: replacement.connectionGeneration,
+ });
+ expect(await persistedState(userId)).toEqual(beforeStalePatch);
+ }, 120_000);
+
+ it('records failure status only for the exact expected generation', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-failure-status-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)`,
+ [userId, JSON.stringify({
+ serverUrl: 'https://dav.example.test/',
+ connectionGeneration: generation,
+ lastError: 'before',
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
+ })],
+ );
+ mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params));
+
+ expect(await carddavSync.recordCarddavSyncFailure(
+ userId, 'stale-generation', new Error('stale error'),
+ )).toBe(false);
+ expect(await carddavSync.recordCarddavSyncFailure(
+ userId, generation, new Error('current error'),
+ )).toBe(true);
+ const { rows: [{ config }] } = await databaseClient.query(
+ `SELECT config FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [userId],
+ );
+ expect(config).toMatchObject({
+ connectionGeneration: generation,
+ lastError: 'current error',
+ });
+ expect(config.lastSyncAt).not.toBe('2026-01-01T00:00:00.000Z');
+
+ await databaseClient.query(
+ `UPDATE user_integrations
+ SET config = jsonb_set(config, '{connectionGeneration}', 'null'::jsonb)
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [userId],
+ );
+ expect(await carddavSync.recordCarddavSyncFailure(
+ userId, null, new Error('legacy null error'),
+ )).toBe(true);
+ }, 120_000);
+
+ it('fences old planned work after production connection replacement', async () => {
+ vi.clearAllMocks();
+ const fixture = await seedLifecycleUser();
+ await databaseClient.query(
+ `UPDATE user_integrations
+ SET config = config || $2::jsonb
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId, JSON.stringify({
+ serverUrl: 'https://dav-a.example.test/',
+ username: 'user-a',
+ password: 'encrypted-a',
+ intervalMin: 60,
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ })],
+ );
+ const before = await persistedLifecycleState(fixture.userId);
+ const staleCard = {
+ ...remoteCard('replacement-stale', 'replacement-stale@example.test'),
+ href: `${fixture.remoteUrl}replacement-stale.vcf`,
+ };
+ const oldApplyRequested = deferred();
+ const releaseOldApply = deferred();
+ let transactionCalls = 0;
+ mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params));
+ mocks.discoverAddressBooks.mockResolvedValue([{
+ url: fixture.remoteUrl,
+ displayName: 'Lifecycle Remote A',
+ supportsSyncCollection: true,
+ }]);
+ mocks.fetchAddressBookDelta.mockImplementationOnce(async request => ({
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: 'replacement-must-not-commit',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [staleCard],
+ removedHrefs: [],
+ }));
+ mocks.withTransaction.mockImplementation(async callback => {
+ transactionCalls++;
+ if (transactionCalls === 1) {
+ oldApplyRequested.resolve();
+ await releaseOldApply.promise;
+ }
+ await databaseClient.query('BEGIN');
+ try {
+ const result = await callback(databaseClient);
+ await databaseClient.query('COMMIT');
+ return result;
+ } catch (error) {
+ await databaseClient.query('ROLLBACK');
+ throw error;
+ }
+ });
+
+ const oldSync = carddavSync.syncUser(fixture.userId);
+ await oldApplyRequested.promise;
+ const replacement = await carddavSync.replaceCarddavConnection(fixture.userId, {
+ serverUrl: 'https://dav-b.example.test/',
+ username: 'user-b',
+ password: 'encrypted-b',
+ intervalMin: 30,
+ });
+ const afterReplacement = await persistedLifecycleState(fixture.userId);
+ releaseOldApply.resolve();
+
+ await expect(oldSync).resolves.toMatchObject({
+ ok: false,
+ error: 'CardDAV sync plan is stale',
+ });
+ expect(replacement).toEqual({
+ serverUrl: 'https://dav-b.example.test/',
+ username: 'user-b',
+ password: 'encrypted-b',
+ intervalMin: 30,
+ connectionGeneration: expect.stringMatching(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+ ),
+ lastError: null,
+ contactCount: before.integrations[0].config.contactCount,
+ });
+ expect(replacement.connectionGeneration).not.toBe(fixture.generation);
+ expect(afterReplacement.integrations).toEqual([{
+ provider: 'carddav',
+ config: replacement,
+ }]);
+ expect(afterReplacement.contacts).toEqual(before.contacts);
+ expect(afterReplacement.ledger).toEqual(before.ledger);
+ expect(afterReplacement.books).toEqual(before.books.map(book => ({
+ ...book,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: String(Number(book.remote_sync_revision) + 1),
+ remote_projection_fingerprint: null,
+ })));
+ expect(afterReplacement.allBooks).toEqual(before.allBooks.map(book => (
+ book.source === 'carddav' ? {
+ ...book,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: String(Number(book.remote_sync_revision) + 1),
+ remote_projection_fingerprint: null,
+ } : book
+ )));
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(afterReplacement);
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(2);
+ expect(mocks.query).toHaveBeenCalledWith(
+ expect.stringMatching(/UPDATE user_integrations[\s\S]+connectionGeneration/),
+ [fixture.userId, expect.stringContaining('CardDAV sync plan is stale'), fixture.generation],
+ );
+ }, 120_000);
+
+ it('fences old planned work after production projection-aware disconnect', async () => {
+ vi.clearAllMocks();
+ const fixture = await seedLifecycleUser();
+ await databaseClient.query(
+ `UPDATE user_integrations
+ SET config = config || $2::jsonb
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId, JSON.stringify({
+ serverUrl: 'https://dav-a.example.test/',
+ username: 'user-a',
+ password: 'encrypted-a',
+ intervalMin: 60,
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ })],
+ );
+ const before = await persistedLifecycleState(fixture.userId);
+ const staleCard = {
+ ...remoteCard('disconnect-stale', 'disconnect-stale@example.test'),
+ href: `${fixture.remoteUrl}disconnect-stale.vcf`,
+ };
+ const oldApplyRequested = deferred();
+ const releaseOldApply = deferred();
+ let transactionCalls = 0;
+ mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params));
+ mocks.discoverAddressBooks.mockResolvedValue([{
+ url: fixture.remoteUrl,
+ displayName: 'Lifecycle Remote A',
+ supportsSyncCollection: true,
+ }]);
+ mocks.fetchAddressBookDelta.mockImplementationOnce(async request => ({
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: 'disconnect-must-not-commit',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [staleCard],
+ removedHrefs: [],
+ }));
+ mocks.withTransaction.mockImplementation(async callback => {
+ transactionCalls++;
+ if (transactionCalls === 1) {
+ oldApplyRequested.resolve();
+ await releaseOldApply.promise;
+ }
+ await databaseClient.query('BEGIN');
+ try {
+ const result = await callback(databaseClient);
+ await databaseClient.query('COMMIT');
+ return result;
+ } catch (error) {
+ await databaseClient.query('ROLLBACK');
+ throw error;
+ }
+ });
+
+ const oldSync = carddavSync.syncUser(fixture.userId);
+ await oldApplyRequested.promise;
+ await expect(carddavSync.disconnectCarddavAccount(fixture.userId)).resolves.toBe(true);
+ const afterDisconnect = await persistedLifecycleState(fixture.userId);
+ releaseOldApply.resolve();
+
+ await expect(oldSync).resolves.toMatchObject({
+ ok: false,
+ error: 'CardDAV sync plan is stale',
+ });
+ expect(afterDisconnect.integrations).toEqual([]);
+ expect(afterDisconnect.books).toEqual([]);
+ expect(afterDisconnect.ledger).toEqual([]);
+ expect(afterDisconnect.allBooks.map(book => ({
+ id: book.id,
+ tokenChanged: book.sync_token !== before.allBooks.find(beforeBook => (
+ beforeBook.id === book.id
+ )).sync_token,
+ }))).toEqual([
+ { id: fixture.localBook.id, tokenChanged: false },
+ { id: fixture.unrelatedBook.id, tokenChanged: false },
+ ].sort((left, right) => left.id.localeCompare(right.id)));
+ expect(afterDisconnect.contacts.find(contact => contact.uid === 'lifecycle-target'))
+ .toMatchObject({
+ display_name: 'Lifecycle Original',
+ notes: 'local lifecycle edit',
+ });
+ expect(afterDisconnect.contacts.find(contact => contact.uid === 'lifecycle-target-b'))
+ .toMatchObject({
+ display_name: 'Lifecycle Original B',
+ notes: 'local lifecycle edit B',
+ });
+ const { rows: [orphans] } = await databaseClient.query(
+ `SELECT COUNT(*)::int AS count
+ FROM carddav_remote_objects o
+ LEFT JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.id IS NULL`,
+ );
+ expect(orphans.count).toBe(0);
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(afterDisconnect);
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(2);
+ expect(mocks.query).toHaveBeenCalledWith(
+ expect.stringMatching(/UPDATE user_integrations[\s\S]+connectionGeneration/),
+ [fixture.userId, expect.stringContaining('CardDAV sync plan is stale'), fixture.generation],
+ );
+ }, 120_000);
+
+ it('restarts from the integration lock when the PostgreSQL projection footprint expands', async () => {
+ vi.clearAllMocks();
+ const fixture = await seedAutomaticPair();
+ await databaseClient.query(
+ `UPDATE user_integrations
+ SET config = config || $2::jsonb
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId, JSON.stringify({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ })],
+ );
+ const source = fixture.before.books.find(book => book.id === fixture.bookAId);
+ mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params));
+ mocks.discoverAddressBooks.mockResolvedValue([{
+ url: source.external_url,
+ displayName: 'Automatic Remote A',
+ supportsSyncCollection: true,
+ }]);
+ mocks.fetchAddressBookDelta.mockImplementation(async request => ({
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: request.syncToken,
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ }));
+ const targetBooksLocked = deferred();
+ const releaseApply = deferred();
+ let blocked = false;
+ useDatabaseTransactions(async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (!blocked && /source <> 'carddav'[\s\S]+FOR UPDATE/.test(sql)) {
+ blocked = true;
+ targetBooksLocked.resolve();
+ await releaseApply.promise;
+ }
+ return result;
+ });
+
+ const pending = carddavSync.syncUser(fixture.userId);
+ await targetBooksLocked.promise;
+ const concurrent = new Client({ connectionString: connectionStringFor(databaseName) });
+ await concurrent.connect();
+ await concurrent.query(
+ `INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Concurrent footprint target')`,
+ [fixture.userId],
+ );
+ await concurrent.end();
+ releaseApply.resolve();
+
+ await expect(pending).resolves.toMatchObject({ ok: true, bookCount: 1 });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(3);
+ }, 120_000);
+
+ it('persists projection fingerprint from final eligible target tokens', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-fingerprint-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: localBooks } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Fingerprint Target'), ($1, 'Fingerprint Unrelated')
+ RETURNING id, name, sync_token
+ `, [userId]);
+ const targetBook = localBooks.find(book => book.name === 'Fingerprint Target');
+ const unrelatedBook = localBooks.find(book => book.name === 'Fingerprint Unrelated');
+ const targetVcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:fingerprint-target',
+ 'FN:Fingerprint Original',
+ 'EMAIL:fingerprint@example.test',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, 'fingerprint-target', $3, $4, 'Fingerprint Original',
+ 'fingerprint@example.test', $5::jsonb, '[]'::jsonb, false
+ )
+ `, [
+ targetBook.id,
+ userId,
+ targetVcard,
+ createHash('md5').update(targetVcard).digest('hex'),
+ JSON.stringify([{
+ value: 'fingerprint@example.test', type: 'other', primary: true,
+ }]),
+ ]);
+
+ await beginApply(completePlan({
+ userId,
+ connectionGeneration: generation,
+ book: { url: remoteUrl, displayName: 'Fingerprint Remote' },
+ upserts: [{
+ ...remoteCard('fingerprint-merge', 'fingerprint@example.test'),
+ href: `${remoteUrl}fingerprint-merge.vcf`,
+ }],
+ }));
+
+ const { rows: finalBooks } = await databaseClient.query(`
+ SELECT id, source, sync_token, remote_projection_fingerprint
+ FROM address_books
+ WHERE user_id = $1
+ ORDER BY id
+ `, [userId]);
+ const finalTarget = finalBooks.find(book => book.id === targetBook.id);
+ const finalUnrelated = finalBooks.find(book => book.id === unrelatedBook.id);
+ const remoteBook = finalBooks.find(book => book.source === 'carddav');
+ const fingerprintInputs = [
+ [finalTarget.id, finalTarget.sync_token],
+ [finalUnrelated.id, finalUnrelated.sync_token],
+ ].sort(([left], [right]) => left.localeCompare(right));
+ const expectedFingerprint = createHash('sha256')
+ .update(JSON.stringify(fingerprintInputs))
+ .digest('hex');
+
+ expect({
+ targetTokenRotated: finalTarget.sync_token !== targetBook.sync_token,
+ unrelatedTokenStable: finalUnrelated.sync_token === unrelatedBook.sync_token,
+ fingerprint: remoteBook.remote_projection_fingerprint,
+ }).toEqual({
+ targetTokenRotated: false,
+ unrelatedTokenStable: true,
+ fingerprint: expectedFingerprint,
+ });
+ }, 120_000);
+
+ it('persists complete provenance, applies a delta, and rolls back exact local and remote tokens', async () => {
+ const cards = [
+ remoteCard('a-separate', 'new@example.test'),
+ remoteCard('b-merge', 'duplicate@example.test'),
+ remoteCard('c-skip', 'duplicate@example.test'),
+ ];
+ const plan = completePlan({ upserts: cards });
+
+ const first = await beginApply(plan);
+ const afterFirst = await persistedState();
+
+ expect(first).toMatchObject({ remote: 3, updated: 2, removed: 0 });
+ expect(first).not.toHaveProperty('count');
+ expect(first.changedBookIds).not.toEqual([]);
+ expect(first).not.toHaveProperty('visibleChanged');
+ expect(afterFirst.ledger.map(row => ({
+ href: row.href,
+ remoteEtag: row.remote_etag,
+ hasLocalContact: row.local_contact_id !== null,
+ }))).toEqual([
+ {
+ href: cards[0].href,
+ remoteEtag: cards[0].remoteEtag,
+ hasLocalContact: true,
+ },
+ {
+ href: cards[1].href,
+ remoteEtag: cards[1].remoteEtag,
+ hasLocalContact: true,
+ },
+ {
+ href: cards[2].href,
+ remoteEtag: cards[2].remoteEtag,
+ hasLocalContact: true,
+ },
+ ]);
+ const { rows: confirmedMappings } = await databaseClient.query(
+ `SELECT href, mapping_status, remote_semantic_hash, local_contact_hash,
+ legacy_projection, last_synced_at
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1
+ ORDER BY href`,
+ [afterFirst.books[0].id],
+ );
+ expect(confirmedMappings).toHaveLength(3);
+ for (const mapping of confirmedMappings) {
+ expect(mapping).toMatchObject({
+ mapping_status: 'synced',
+ remote_semantic_hash: expect.any(String),
+ local_contact_hash: expect.any(String),
+ legacy_projection: null,
+ last_synced_at: expect.any(Date),
+ });
+ }
+ const mirrored = afterFirst.contacts.find(contact => contact.primary_email === 'new@example.test');
+ expect(mirrored.uid).toBe(createHash('sha256').update(cards[0].href).digest('hex'));
+ expect(mirrored.uid).not.toBe(cards[0].contact.uid);
+ expect(mirrored.vcard).toContain(`UID:${mirrored.uid}\r\n`);
+ expect(mirrored.vcard).toContain('FN:a-separate\r\n');
+ expect(mirrored.etag).toBe(createHash('md5').update(mirrored.vcard).digest('hex'));
+ const linked = afterFirst.contacts.find(contact => contact.uid === 'local-target');
+ expect(linked).toMatchObject({
+ display_name: 'Local Target',
+ primary_email: 'duplicate@example.test',
+ });
+ expect(linked.vcard).toContain('FN:Local Target\r\n');
+ const secondImport = afterFirst.contacts.find(contact => (
+ contact.uid === createHash('sha256').update(cards[2].href).digest('hex')
+ ));
+ expect(secondImport).toMatchObject({
+ display_name: 'c-skip',
+ primary_email: 'duplicate@example.test',
+ });
+
+ const unchanged = await beginApply({
+ ...plan,
+ expectedRemoteRevision: afterFirst.books[0].remote_sync_revision,
+ });
+ const afterUnchanged = await persistedState();
+ expect(unchanged.changedBookIds).toEqual([]);
+ expect(unchanged).not.toHaveProperty('visibleChanged');
+ expect(afterUnchanged.books[0].sync_token).toBe(afterFirst.books[0].sync_token);
+
+ const tokenPlan = completePlan({
+ expectedRemoteRevision: afterUnchanged.books[0].remote_sync_revision,
+ nextRemoteToken: ' opaque-token-before ',
+ upserts: cards,
+ });
+ await beginApply(tokenPlan);
+ const beforeDelta = await persistedState();
+ expect(beforeDelta.books[0].remote_sync_token).toBe(' opaque-token-before ');
+
+ const changed = remoteCard('a-separate', 'new@example.test');
+ changed.contact.displayName = 'Changed Name';
+ changed.contact.firstName = 'Changed';
+ changed.vcard = changed.vcard.replaceAll('a-separate', 'Changed Name');
+ const changedPlan = completePlan({
+ expectedRemoteRevision: beforeDelta.books[0].remote_sync_revision,
+ expectedRemoteToken: ' opaque-token-before ',
+ nextRemoteToken: 'remote-token-after',
+ replaceAll: false,
+ upserts: [changed],
+ removedHrefs: [cards[2].href],
+ });
+ let contactMutated = false;
+ await expect(beginApply(changedPlan, async (client, sql, params) => {
+ if (/(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) {
+ expect(contactMutated).toBe(true);
+ throw new Error('forced post-contact failure');
+ }
+ const result = await client.query(sql, params);
+ if (sql.includes('INSERT INTO contacts') || sql.includes('UPDATE contacts SET')
+ || sql.includes('DELETE FROM contacts')) {
+ contactMutated = true;
+ }
+ return result;
+ })).rejects.toThrow('forced post-contact failure');
+
+ expect(await persistedState()).toEqual(beforeDelta);
+ }, 120_000);
+
+ it('replaces a canonical collection alias in place and rolls the rename back atomically', async () => {
+ const aliasUrl = 'https://dav.example.test/addressbooks/alias-pg/';
+ const canonicalUrl = 'https://dav.example.test/addressbooks/canonical-pg/';
+ const retained = {
+ ...remoteCard('alias-retained', 'alias-retained@example.test'),
+ href: `${canonicalUrl}retained.vcf`,
+ };
+ const removed = {
+ ...remoteCard('alias-removed', 'alias-removed@example.test'),
+ href: `${canonicalUrl}removed.vcf`,
+ };
+ await beginApply(completePlan({
+ book: { url: aliasUrl, displayName: 'Alias PG' },
+ nextRemoteToken: 'alias-token-before',
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl },
+ upserts: [retained, removed],
+ }));
+ const { rows: [beforeBook] } = await databaseClient.query(
+ `SELECT id, remote_sync_revision::text
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`,
+ [USER_ID, aliasUrl],
+ );
+ expect(beforeBook.remote_sync_revision).toBe('1');
+
+ const changed = {
+ ...retained,
+ remoteEtag: 'W/"canonical-etag"',
+ vcard: retained.vcard.replaceAll('alias-retained', 'canonical-retained'),
+ contact: {
+ ...retained.contact,
+ displayName: 'canonical-retained',
+ firstName: 'canonical-retained',
+ lastName: 'canonical-retained',
+ },
+ };
+ await beginApply(completePlan({
+ book: { url: aliasUrl, displayName: 'Alias PG' },
+ expectedRemoteRevision: '1',
+ expectedRemoteToken: 'alias-token-before',
+ nextRemoteToken: 'canonical-token-after',
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl },
+ upserts: [changed],
+ }));
+
+ const { rows: books } = await databaseClient.query(
+ `SELECT id, external_url, remote_sync_token, remote_sync_revision::text
+ FROM address_books
+ WHERE id = $1 OR (user_id = $2 AND external_url = $3)
+ ORDER BY id`,
+ [beforeBook.id, USER_ID, aliasUrl],
+ );
+ expect(books).toEqual([{
+ id: beforeBook.id,
+ external_url: canonicalUrl,
+ remote_sync_token: 'canonical-token-after',
+ remote_sync_revision: '2',
+ }]);
+ const { rows: ledger } = await databaseClient.query(
+ `SELECT address_book_id, href, remote_etag
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1
+ ORDER BY href`,
+ [beforeBook.id],
+ );
+ expect(ledger).toEqual([{
+ address_book_id: beforeBook.id,
+ href: changed.href,
+ remote_etag: changed.remoteEtag,
+ }]);
+
+ const beforeRollback = await persistedState();
+ await expect(beginApply(completePlan({
+ book: { url: canonicalUrl, displayName: 'Alias PG' },
+ expectedRemoteRevision: '2',
+ expectedRemoteToken: 'canonical-token-after',
+ nextRemoteToken: 'rollback-token',
+ collectionIdentity: {
+ observedUrl: canonicalUrl,
+ canonicalUrl: `${canonicalUrl}renamed/`,
+ },
+ upserts: [changed],
+ }), async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/UPDATE address_books SET/.test(sql)) throw new Error('forced post-rename failure');
+ return result;
+ })).rejects.toThrow('forced post-rename failure');
+ expect(await persistedState()).toEqual(beforeRollback);
+ }, 120_000);
+
+ it('rejects a canonical URL conflict before mutating projection state', async () => {
+ const aliasUrl = 'https://dav.example.test/addressbooks/conflict-alias/';
+ const canonicalUrl = 'https://dav.example.test/addressbooks/conflict-canonical/';
+ await beginApply(completePlan({
+ book: { url: canonicalUrl, displayName: 'Canonical Owner' },
+ collectionIdentity: { observedUrl: canonicalUrl, canonicalUrl },
+ }));
+ await beginApply(completePlan({
+ book: { url: aliasUrl, displayName: 'Conflicting Alias' },
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl },
+ }));
+ const { rows: [owner] } = await databaseClient.query(
+ `SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`,
+ [USER_ID, canonicalUrl],
+ );
+ const before = await persistedState();
+
+ const error = await beginApply(completePlan({
+ book: { url: aliasUrl, displayName: 'Conflicting Alias' },
+ expectedRemoteRevision: '1',
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl },
+ })).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ reason: 'canonical-url-conflict',
+ observedUrl: aliasUrl,
+ canonicalUrl,
+ conflictingBookId: owner.id,
+ });
+ expect(await persistedState()).toEqual(before);
+ }, 120_000);
+
+ it('turns a concurrent canonical insert into typed stale state with exact rollback', async () => {
+ const aliasUrl = 'https://dav.example.test/addressbooks/racing-alias/';
+ const canonicalUrl = 'https://dav.example.test/addressbooks/racing-canonical/';
+ await beginApply(completePlan({
+ book: { url: aliasUrl, displayName: 'Racing Alias' },
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl },
+ upserts: [remoteCard('racing-alias', 'racing-alias@example.test')],
+ }));
+ const { rows: [beforeAlias] } = await databaseClient.query(
+ `SELECT id, external_url, remote_sync_revision::text, remote_sync_token, sync_token
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`,
+ [USER_ID, aliasUrl],
+ );
+ const booksLocked = deferred();
+ const releaseApply = deferred();
+ const applyPromise = beginApply(completePlan({
+ book: { url: aliasUrl, displayName: 'Racing Alias' },
+ expectedRemoteRevision: beforeAlias.remote_sync_revision,
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl },
+ }), async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/external_url = ANY\(\$2::text\[\]\)/.test(sql)) {
+ booksLocked.resolve();
+ await releaseApply.promise;
+ }
+ return result;
+ });
+
+ await booksLocked.promise;
+ const concurrent = new Client({ connectionString: connectionStringFor(databaseName) });
+ await concurrent.connect();
+ const { rows: [canonicalBook] } = await concurrent.query(
+ `INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES ($1, 'Racing Canonical', 'carddav', $2)
+ RETURNING id`,
+ [USER_ID, canonicalUrl],
+ );
+ await concurrent.end();
+ releaseApply.resolve();
+
+ const error = await applyPromise.catch(caught => caught);
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ reason: 'canonical-url-conflict',
+ observedUrl: aliasUrl,
+ canonicalUrl,
+ conflictingBookId: canonicalBook.id,
+ });
+ const { rows: [afterAlias] } = await databaseClient.query(
+ `SELECT id, external_url, remote_sync_revision::text, remote_sync_token, sync_token
+ FROM address_books
+ WHERE id = $1`,
+ [beforeAlias.id],
+ );
+ expect(afterAlias).toEqual(beforeAlias);
+ }, 120_000);
+
+ it('rejects reciprocal alias replacements without a book-lock deadlock', async () => {
+ const firstUrl = 'https://dav.example.test/addressbooks/reciprocal-a/';
+ const secondUrl = 'https://dav.example.test/addressbooks/reciprocal-b/';
+ for (const [url, name] of [[firstUrl, 'A'], [secondUrl, 'B']]) {
+ await beginApply(completePlan({
+ book: { url, displayName: `Reciprocal ${name}` },
+ collectionIdentity: { observedUrl: url, canonicalUrl: url },
+ }));
+ }
+ const before = await persistedState();
+ const firstClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ const secondClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ await Promise.all([firstClient.connect(), secondClient.connect()]);
+
+ async function applyReciprocal(client, observedUrl, canonicalUrl) {
+ await client.query('BEGIN');
+ try {
+ await client.query("SET LOCAL lock_timeout = '5s'");
+ const result = await carddavSync.applyBookDelta(client, completePlan({
+ book: { url: observedUrl, displayName: 'Reciprocal' },
+ expectedRemoteRevision: '1',
+ collectionIdentity: { observedUrl, canonicalUrl },
+ }));
+ await client.query('COMMIT');
+ return result;
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ }
+ }
+
+ const results = await Promise.allSettled([
+ applyReciprocal(firstClient, firstUrl, secondUrl),
+ applyReciprocal(secondClient, secondUrl, firstUrl),
+ ]);
+ await Promise.all([firstClient.end(), secondClient.end()]);
+
+ expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']);
+ for (const result of results) {
+ expect(result.reason).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(result.reason).toMatchObject({ reason: 'canonical-url-conflict' });
+ expect(result.reason.code).not.toBe('40P01');
+ }
+ expect(await persistedState()).toEqual(before);
+ }, 120_000);
+
+ it('finalizes a canonical alias replacement without pruning the renamed book', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const aliasUrl = `https://dav.example.test/addressbooks/${userId}/alias/`;
+ const canonicalUrl = `https://dav.example.test/addressbooks/${userId}/canonical/`;
+ const card = {
+ ...remoteCard('alias-finalize', 'alias-finalize@example.test'),
+ href: `${canonicalUrl}alias-finalize.vcf`,
+ };
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-alias-finalize-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ await beginApply(completePlan({
+ userId,
+ book: { url: aliasUrl, displayName: 'Alias Finalize' },
+ connectionGeneration: generation,
+ nextRemoteToken: 'alias-before',
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl },
+ upserts: [card],
+ }));
+ const { rows: [aliasBook] } = await databaseClient.query(
+ `SELECT id, remote_sync_revision::text
+ FROM address_books WHERE user_id = $1 AND external_url = $2`,
+ [userId, aliasUrl],
+ );
+ await beginApply(completePlan({
+ userId,
+ book: { url: aliasUrl, displayName: 'Alias Finalize' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: aliasBook.remote_sync_revision,
+ expectedRemoteToken: 'alias-before',
+ nextRemoteToken: 'canonical-after',
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl },
+ upserts: [card],
+ }));
+ const beforeFinalize = await persistedLifecycleState(userId);
+ useDatabaseTransactions();
+
+ await carddavSync.finalizeCarddavSync(userId, {
+ connectionGeneration: generation,
+ seenUrls: [canonicalUrl],
+ status: {
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ lastError: null,
+ bookCount: 1,
+ contactCount: 1,
+ },
+ });
+
+ const afterFinalize = await persistedLifecycleState(userId);
+ expect(afterFinalize.books).toEqual(beforeFinalize.books);
+ expect(afterFinalize.contacts).toEqual(beforeFinalize.contacts);
+ expect(afterFinalize.ledger).toEqual(beforeFinalize.ledger);
+ expect(afterFinalize.books).toHaveLength(1);
+ expect(afterFinalize.books[0]).toMatchObject({
+ id: aliasBook.id,
+ external_url: canonicalUrl,
+ remote_sync_token: 'canonical-after',
+ });
+ }, 120_000);
+
+ it('finalizes a valid empty account without rewriting linked explicit contacts', async () => {
+ const fixture = await seedLifecycleUser();
+ useDatabaseTransactions();
+ const { rows: [beforeLocal] } = await databaseClient.query(
+ 'SELECT sync_token FROM address_books WHERE id = $1',
+ [fixture.localBook.id],
+ );
+ const status = {
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ lastError: null,
+ bookCount: 0,
+ contactCount: 0,
+ };
+
+ await carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: fixture.generation,
+ seenUrls: [],
+ status,
+ });
+
+ const { rows: remoteBooks } = await databaseClient.query(
+ "SELECT id FROM address_books WHERE user_id = $1 AND source = 'carddav'",
+ [fixture.userId],
+ );
+ expect(remoteBooks).toEqual([]);
+ const { rows: [restored] } = await databaseClient.query(
+ `SELECT display_name, notes, vcard, etag
+ FROM contacts WHERE id = $1`,
+ [fixture.target.id],
+ );
+ expect(restored).toMatchObject({
+ display_name: 'Lifecycle Original',
+ notes: 'local lifecycle edit',
+ });
+ expect(restored.vcard).toContain('FN:Lifecycle Original\r\n');
+ expect(restored.vcard).toContain('NOTE:local lifecycle edit\r\n');
+ expect(restored.etag).toBe(createHash('md5').update(restored.vcard).digest('hex'));
+ const { rows: tokens } = await databaseClient.query(
+ `SELECT id, sync_token FROM address_books
+ WHERE id = ANY($1::uuid[]) ORDER BY id`,
+ [[fixture.localBook.id, fixture.unrelatedBook.id]],
+ );
+ expect(tokens.find(row => row.id === fixture.localBook.id).sync_token)
+ .toBe(beforeLocal.sync_token);
+ expect(tokens.find(row => row.id === fixture.unrelatedBook.id).sync_token)
+ .toBe(fixture.unrelatedBook.sync_token);
+ const { rows: [integration] } = await databaseClient.query(
+ `SELECT config FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId],
+ );
+ expect(integration.config).toMatchObject(status);
+ }, 120_000);
+
+ it('finalizes valid empty status for a legacy null connection generation', async () => {
+ const fixture = await seedLifecycleUser();
+ await databaseClient.query(
+ `UPDATE user_integrations
+ SET config = jsonb_set(config, '{connectionGeneration}', 'null'::jsonb)
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId],
+ );
+ useDatabaseTransactions();
+ const status = {
+ lastSyncAt: '2026-07-10T13:00:00.000Z',
+ lastError: null,
+ bookCount: 0,
+ contactCount: 0,
+ };
+
+ await carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: null,
+ seenUrls: [],
+ status,
+ });
+
+ const { rows: remoteBooks } = await databaseClient.query(
+ `SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'`,
+ [fixture.userId],
+ );
+ expect(remoteBooks).toEqual([]);
+ const { rows: [integration] } = await databaseClient.query(
+ `SELECT config FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId],
+ );
+ expect(integration.config).toMatchObject({
+ ...status,
+ connectionGeneration: null,
+ });
+ }, 120_000);
+
+ it('prunes one of multiple remote books with the exact lifecycle token and revision set', async () => {
+ const fixture = await seedLifecycleUser();
+ const staleBook = fixture.remoteBooks.find(book => book.external_url === fixture.remoteUrl);
+ const survivingBook = fixture.remoteBooks.find(book => (
+ book.external_url === fixture.secondRemoteUrl
+ ));
+ useDatabaseTransactions();
+
+ await carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: fixture.generation,
+ seenUrls: [fixture.secondRemoteUrl],
+ status: {
+ lastSyncAt: '2026-07-10T14:00:00.000Z',
+ lastError: null,
+ bookCount: 1,
+ contactCount: 0,
+ },
+ });
+
+ const { rows: books } = await databaseClient.query(
+ `SELECT id, sync_token, remote_sync_revision::text
+ FROM address_books
+ WHERE id = ANY($1::uuid[]) ORDER BY id`,
+ [[
+ staleBook.id,
+ survivingBook.id,
+ fixture.localBook.id,
+ fixture.unrelatedBook.id,
+ ]],
+ );
+ expect(books.find(book => book.id === staleBook.id)).toBeUndefined();
+ expect(books.find(book => book.id === survivingBook.id)).toEqual({
+ id: survivingBook.id,
+ sync_token: survivingBook.sync_token,
+ remote_sync_revision: survivingBook.remote_sync_revision,
+ });
+ expect(books.find(book => book.id === fixture.localBook.id).sync_token)
+ .toBe(fixture.localBook.sync_token);
+ expect(books.find(book => book.id === fixture.unrelatedBook.id).sync_token)
+ .toBe(fixture.unrelatedBook.sync_token);
+ const { rows: [restored] } = await databaseClient.query(
+ `SELECT display_name, notes, vcard, etag
+ FROM contacts WHERE id = $1`,
+ [fixture.target.id],
+ );
+ expect(restored).toMatchObject({
+ display_name: 'Lifecycle Original',
+ notes: 'local lifecycle edit',
+ });
+ expect(restored.etag).toBe(createHash('md5').update(restored.vcard).digest('hex'));
+ }, 120_000);
+
+ it('prunes a stale book without reclassifying an untouched surviving mapping', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const staleUrl = `https://dav.example.test/addressbooks/${userId}/stale/`;
+ const survivorUrl = `https://dav.example.test/addressbooks/${userId}/survivor/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-reproject-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: books } = await databaseClient.query(
+ `INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES
+ ($1, 'Stale Source', 'carddav', $2),
+ ($1, 'Surviving Source', 'carddav', $3)
+ RETURNING id, external_url, sync_token`,
+ [userId, staleUrl, survivorUrl],
+ );
+ const staleBook = books.find(book => book.external_url === staleUrl);
+ const survivorBook = books.find(book => book.external_url === survivorUrl);
+ const vcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:shared-remote', 'FN:Shared Remote',
+ 'EMAIL:shared-remote@example.test', 'END:VCARD', '',
+ ].join('\r\n');
+ const localVcard = generateVCard({
+ uid: 'shared-projected',
+ displayName: 'Shared Remote',
+ firstName: 'Shared',
+ lastName: 'Remote',
+ primaryEmail: 'shared-remote@example.test',
+ emails: [{ value: 'shared-remote@example.test', type: 'other', primary: true }],
+ phones: [],
+ });
+ const { rows: [projected] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, 'shared-projected', $3, $4, 'Shared Remote', 'Shared', 'Remote',
+ 'shared-remote@example.test', $5::jsonb, '[]'::jsonb, false
+ ) RETURNING id
+ `, [
+ staleBook.id,
+ userId,
+ localVcard,
+ createHash('md5').update(localVcard).digest('hex'),
+ JSON.stringify([{
+ value: 'shared-remote@example.test', type: 'other', primary: true,
+ }]),
+ ]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ disposition, local_contact_id
+ ) VALUES
+ ($1, $2, '"stale"', $3, 'shared-remote@example.test', 'separate', $4),
+ ($5, $6, '"survivor"', $3, 'shared-remote@example.test', 'skip', NULL)
+ `, [
+ staleBook.id,
+ `${staleUrl}shared.vcf`,
+ vcard,
+ projected.id,
+ survivorBook.id,
+ `${survivorUrl}shared.vcf`,
+ ]);
+ useDatabaseTransactions();
+
+ await carddavSync.finalizeCarddavSync(userId, {
+ connectionGeneration: generation,
+ seenUrls: [survivorUrl],
+ status: {
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ lastError: null,
+ bookCount: 1,
+ contactCount: 1,
+ },
+ });
+
+ const { rows: [survivorObject] } = await databaseClient.query(
+ `SELECT disposition, local_contact_id
+ FROM carddav_remote_objects WHERE address_book_id = $1`,
+ [survivorBook.id],
+ );
+ expect(survivorObject).toMatchObject({
+ disposition: 'skip',
+ local_contact_id: null,
+ });
+ const { rows: [survivorAfter] } = await databaseClient.query(
+ `SELECT sync_token, remote_sync_revision::text
+ FROM address_books WHERE id = $1`,
+ [survivorBook.id],
+ );
+ expect(survivorAfter.sync_token).toBe(survivorBook.sync_token);
+ expect(survivorAfter.remote_sync_revision).toBe('0');
+ const { rows: staleAfter } = await databaseClient.query(
+ 'SELECT id FROM address_books WHERE id = $1',
+ [staleBook.id],
+ );
+ expect(staleAfter).toEqual([]);
+ }, 120_000);
+
+ it('materializes ordered legacy cross-book mappings into unique automatic owners', async () => {
+ const fixture = await seedLegacyCrossBookUser();
+ for (const [book, cards] of [
+ [fixture.bookA, [fixture.aMerge, fixture.aSkip]],
+ [fixture.bookB, [fixture.bMerge, fixture.bSkip]],
+ ]) {
+ await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: book.external_url, displayName: 'Legacy Repair' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: book.remote_sync_revision,
+ collectionIdentity: {
+ observedUrl: book.external_url,
+ canonicalUrl: book.external_url,
+ },
+ upserts: cards,
+ }));
+ }
+
+ const { rows: books } = await databaseClient.query(
+ `SELECT id, sync_token, remote_sync_revision::text
+ FROM address_books
+ WHERE id = ANY($1::uuid[]) ORDER BY id`,
+ [[fixture.bookA.id, fixture.bookB.id, fixture.unrelatedBook.id]],
+ );
+ const bookA = books.find(book => book.id === fixture.bookA.id);
+ const bookB = books.find(book => book.id === fixture.bookB.id);
+ const unrelated = books.find(book => book.id === fixture.unrelatedBook.id);
+ expect(bookA.sync_token).not.toBe(fixture.bookA.sync_token);
+ expect(bookB.sync_token).not.toBe(fixture.bookB.sync_token);
+ expect(unrelated.sync_token).toBe(fixture.unrelatedBook.sync_token);
+ expect(bookA.remote_sync_revision)
+ .toBe(String(BigInt(fixture.bookA.remote_sync_revision) + 1n));
+ expect(bookB.remote_sync_revision)
+ .toBe(String(BigInt(fixture.bookB.remote_sync_revision) + 1n));
+ const { rows: repairedObjects } = await databaseClient.query(
+ `SELECT o.href, o.mapping_status, o.legacy_projection, o.local_contact_id,
+ c.address_book_id AS contact_book_id
+ FROM carddav_remote_objects o
+ LEFT JOIN contacts c ON c.id = o.local_contact_id
+ WHERE o.address_book_id = $1 ORDER BY o.href`,
+ [fixture.bookA.id],
+ );
+ expect(repairedObjects).toEqual([
+ {
+ href: fixture.aMerge.href,
+ mapping_status: 'synced',
+ legacy_projection: null,
+ local_contact_id: expect.any(String),
+ contact_book_id: fixture.bookA.id,
+ },
+ {
+ href: fixture.aSkip.href,
+ mapping_status: 'synced',
+ legacy_projection: null,
+ local_contact_id: expect.any(String),
+ contact_book_id: fixture.bookA.id,
+ },
+ ]);
+ const { rows: [restored] } = await databaseClient.query(
+ `SELECT display_name, vcard, etag
+ FROM contacts WHERE id = $1`,
+ [fixture.bMergeContact.id],
+ );
+ expect(restored.display_name).toBe(fixture.bMerge.contact.displayName);
+ expect(restored.vcard).toContain(`FN:${fixture.bMerge.contact.displayName}\r\n`);
+ expect(restored.etag).toBe(createHash('md5').update(restored.vcard).digest('hex'));
+ }, 120_000);
+
+ it('prunes a legacy sibling after materializing the surviving automatic owners', async () => {
+ const fixture = await seedLegacyCrossBookUser();
+ await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.bookA.external_url, displayName: 'Legacy Survivor' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: fixture.bookA.remote_sync_revision,
+ collectionIdentity: {
+ observedUrl: fixture.bookA.external_url,
+ canonicalUrl: fixture.bookA.external_url,
+ },
+ upserts: [fixture.aMerge, fixture.aSkip],
+ }));
+ const { rows: [materializedBook] } = await databaseClient.query(
+ `SELECT id, sync_token, remote_sync_revision::text
+ FROM address_books WHERE id = $1`,
+ [fixture.bookA.id],
+ );
+ const { rows: [unrelatedBefore] } = await databaseClient.query(
+ `SELECT id, source, external_url, sync_token, remote_sync_token,
+ remote_sync_capability, remote_sync_revision::text,
+ remote_projection_fingerprint
+ FROM address_books WHERE id = $1`,
+ [fixture.unrelatedBook.id],
+ );
+ useDatabaseTransactions();
+
+ await carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: fixture.generation,
+ seenUrls: [fixture.bookAUrl],
+ status: {
+ lastSyncAt: '2026-07-10T16:00:00.000Z',
+ lastError: null,
+ bookCount: 1,
+ contactCount: 2,
+ },
+ });
+
+ const { rows: books } = await databaseClient.query(
+ `SELECT id, sync_token, remote_sync_revision::text
+ FROM address_books
+ WHERE id = ANY($1::uuid[]) ORDER BY id`,
+ [[fixture.bookA.id, fixture.bookB.id]],
+ );
+ expect(books).toEqual([{
+ id: fixture.bookA.id,
+ sync_token: materializedBook.sync_token,
+ remote_sync_revision: materializedBook.remote_sync_revision,
+ }]);
+ const { rows: [unrelatedAfter] } = await databaseClient.query(
+ `SELECT id, source, external_url, sync_token, remote_sync_token,
+ remote_sync_capability, remote_sync_revision::text,
+ remote_projection_fingerprint
+ FROM address_books WHERE id = $1`,
+ [fixture.unrelatedBook.id],
+ );
+ expect(unrelatedAfter).toEqual(unrelatedBefore);
+ const { rows: objects } = await databaseClient.query(
+ `SELECT o.href, o.mapping_status, o.legacy_projection, o.local_contact_id,
+ c.address_book_id AS contact_book_id,
+ c.display_name, c.vcard, c.etag
+ FROM carddav_remote_objects o
+ LEFT JOIN contacts c ON c.id = o.local_contact_id
+ WHERE o.address_book_id = $1 ORDER BY o.href`,
+ [fixture.bookA.id],
+ );
+ expect(objects.map(object => ({
+ href: object.href,
+ mapping_status: object.mapping_status,
+ legacy_projection: object.legacy_projection,
+ contact_book_id: object.contact_book_id,
+ }))).toEqual([
+ {
+ href: fixture.aMerge.href,
+ mapping_status: 'synced',
+ legacy_projection: null,
+ contact_book_id: fixture.bookA.id,
+ },
+ {
+ href: fixture.aSkip.href,
+ mapping_status: 'synced',
+ legacy_projection: null,
+ contact_book_id: fixture.bookA.id,
+ },
+ ]);
+ const oldTargetIds = new Set([
+ fixture.bMergeContact.id,
+ fixture.bSkipContact.id,
+ ]);
+ for (const object of objects) {
+ expect(oldTargetIds.has(object.local_contact_id)).toBe(false);
+ const card = object.href === fixture.aMerge.href ? fixture.aMerge : fixture.aSkip;
+ expect(object.display_name).toBe(card.contact.displayName);
+ expect(object.vcard).toContain(`FN:${card.contact.displayName}\r\n`);
+ expect(object.etag).toBe(createHash('md5').update(object.vcard).digest('hex'));
+ }
+ const { rows: oldTargets } = await databaseClient.query(
+ 'SELECT id FROM contacts WHERE id = ANY($1::uuid[])',
+ [[fixture.bMergeContact.id, fixture.bSkipContact.id]],
+ );
+ expect(oldTargets).toEqual([]);
+ }, 120_000);
+
+ it('refreshes cached ledger primary email during complete reclassification', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/cached/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-cached-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [book] } = await databaseClient.query(
+ `INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES ($1, 'Cached Source', 'carddav', $2)
+ RETURNING id`,
+ [userId, remoteUrl],
+ );
+ const vcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:cached-primary', 'FN:Cached Primary',
+ 'EMAIL:fresh@example.test', 'END:VCARD', '',
+ ].join('\r\n');
+ await databaseClient.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email, disposition
+ ) VALUES ($1, $2, '"cached"', $3, 'stale@example.test', 'skip')`,
+ [book.id, `${remoteUrl}cached.vcf`, vcard],
+ );
+ await beginApply(completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Cached Source' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '0',
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [{
+ href: `${remoteUrl}cached.vcf`,
+ remoteEtag: '"cached"',
+ vcard,
+ contact: {
+ ...remoteCard('cached-primary', 'fresh@example.test').contact,
+ displayName: 'Cached Primary',
+ },
+ }],
+ }));
+
+ const { rows: [object] } = await databaseClient.query(
+ `SELECT primary_email, mapping_status, local_contact_id
+ FROM carddav_remote_objects WHERE address_book_id = $1`,
+ [book.id],
+ );
+ expect(object).toMatchObject({
+ primary_email: 'fresh@example.test',
+ mapping_status: 'synced',
+ local_contact_id: expect.any(String),
+ });
+ }, 120_000);
+
+ it('repairs legacy ledger-only state through complete reclassification', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/ledger-only/`;
+ const card = {
+ ...remoteCard('ledger-only', 'ledger-only@example.test'),
+ href: `${remoteUrl}ledger-only.vcf`,
+ };
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-ledger-only-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ await beginApply(completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Ledger Only' },
+ connectionGeneration: generation,
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [card],
+ }));
+ const { rows: [before] } = await databaseClient.query(
+ `SELECT id, sync_token, remote_sync_revision::text
+ FROM address_books WHERE user_id = $1 AND external_url = $2`,
+ [userId, remoteUrl],
+ );
+ await databaseClient.query(
+ `UPDATE carddav_remote_objects SET primary_email = 'stale@example.test'
+ WHERE address_book_id = $1`,
+ [before.id],
+ );
+ await beginApply(completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Ledger Only' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: before.remote_sync_revision,
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [card],
+ }));
+
+ const { rows: [after] } = await databaseClient.query(
+ `SELECT b.sync_token, b.remote_sync_revision::text, o.primary_email
+ FROM address_books b
+ JOIN carddav_remote_objects o ON o.address_book_id = b.id
+ WHERE b.id = $1`,
+ [before.id],
+ );
+ expect(after).toEqual({
+ sync_token: before.sync_token,
+ remote_sync_revision: String(BigInt(before.remote_sync_revision) + 1n),
+ primary_email: 'ledger-only@example.test',
+ });
+ }, 120_000);
+
+ it('retains an unmapped explicit contact as an automatic export intent', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/unowned/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-unowned-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [book] } = await databaseClient.query(
+ `INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES ($1, 'Unowned Source', 'carddav', $2)
+ RETURNING id, sync_token, remote_sync_revision::text`,
+ [userId, remoteUrl],
+ );
+ const vcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:unowned-visible', 'FN:Unowned Visible',
+ 'EMAIL:unowned@example.test', 'END:VCARD', '',
+ ].join('\r\n');
+ await databaseClient.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, 'unowned-visible', $3, $4, 'Unowned Visible',
+ 'unowned@example.test', $5::jsonb, '[]'::jsonb, false
+ )`,
+ [
+ book.id,
+ userId,
+ vcard,
+ createHash('md5').update(vcard).digest('hex'),
+ JSON.stringify([{
+ value: 'unowned@example.test', type: 'other', primary: true,
+ }]),
+ ],
+ );
+ const applied = await beginApply(completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Unowned Source' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: book.remote_sync_revision,
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ }));
+
+ const { rows: [after] } = await databaseClient.query(
+ `SELECT sync_token, remote_sync_revision::text,
+ (SELECT count(*)::int FROM contacts WHERE address_book_id = $1) AS contact_count
+ FROM address_books WHERE id = $1`,
+ [book.id],
+ );
+ expect(after).toEqual({
+ sync_token: book.sync_token,
+ remote_sync_revision: '1',
+ contact_count: 1,
+ });
+ expect(applied).not.toHaveProperty('exports');
+ }, 120_000);
+
+ it('preserves a target book inserted while finalization prunes stale remote books', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/footprint/`;
+ const email = 'expanded-footprint@example.test';
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-footprint-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const card = {
+ ...remoteCard('expanded-footprint', email),
+ href: `${remoteUrl}expanded-footprint.vcf`,
+ };
+ await beginApply(completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Footprint Source' },
+ connectionGeneration: generation,
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [card],
+ }));
+ const staleUrl = `https://dav.example.test/addressbooks/${userId}/stale/`;
+ const { rows: [staleBook] } = await databaseClient.query(
+ `INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES ($1, 'Stale Source', 'carddav', $2) RETURNING id`,
+ [userId, staleUrl],
+ );
+ const booksLocked = deferred();
+ const releaseFinalizer = deferred();
+ useDatabaseTransactions(async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/SELECT id, source, external_url, sync_token[\s\S]+FROM address_books/.test(sql)) {
+ booksLocked.resolve();
+ await releaseFinalizer.promise;
+ }
+ return result;
+ });
+ const finalizer = carddavSync.finalizeCarddavSync(userId, {
+ connectionGeneration: generation,
+ seenUrls: [remoteUrl],
+ status: {
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ lastError: null,
+ bookCount: 1,
+ contactCount: 1,
+ },
+ });
+
+ await booksLocked.promise;
+ const concurrent = new Client({ connectionString: connectionStringFor(databaseName) });
+ await concurrent.connect();
+ const { rows: [targetBook] } = await concurrent.query(
+ `INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Concurrent Target') RETURNING id, sync_token`,
+ [userId],
+ );
+ const targetVcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:concurrent-target', 'FN:Concurrent Original',
+ `EMAIL:${email}`, 'END:VCARD', '',
+ ].join('\r\n');
+ const { rows: [target] } = await concurrent.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, 'concurrent-target', $3, $4, 'Concurrent Original',
+ $5, $6::jsonb, '[]'::jsonb, false
+ ) RETURNING id`,
+ [
+ targetBook.id,
+ userId,
+ targetVcard,
+ createHash('md5').update(targetVcard).digest('hex'),
+ email,
+ JSON.stringify([{ value: email, type: 'other', primary: true }]),
+ ],
+ );
+ await concurrent.end();
+ releaseFinalizer.resolve();
+
+ await expect(finalizer).resolves.toBe(1);
+ const { rows: [targetAfter] } = await databaseClient.query(
+ `SELECT c.display_name, b.sync_token
+ FROM contacts c JOIN address_books b ON b.id = c.address_book_id
+ WHERE c.id = $1`,
+ [target.id],
+ );
+ expect(targetAfter).toEqual({
+ display_name: 'Concurrent Original',
+ sync_token: targetBook.sync_token,
+ });
+ const { rows: [objectAfter] } = await databaseClient.query(
+ `SELECT disposition, local_contact_id
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1 AND o.href = $2`,
+ [userId, card.href],
+ );
+ expect(objectAfter).toMatchObject({
+ disposition: 'separate',
+ local_contact_id: expect.any(String),
+ });
+ const { rows: staleAfter } = await databaseClient.query(
+ 'SELECT id FROM address_books WHERE id = $1',
+ [staleBook.id],
+ );
+ expect(staleAfter).toEqual([]);
+ }, 120_000);
+
+ it('fences finalization by generation before pruning or status mutation', async () => {
+ const fixture = await seedLifecycleUser();
+ useDatabaseTransactions();
+ const before = await persistedLifecycleState(fixture.userId);
+
+ const error = await carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: 'stale-generation',
+ seenUrls: [],
+ status: {
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ lastError: null,
+ bookCount: 0,
+ contactCount: 0,
+ },
+ }).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ expectedConnectionGeneration: 'stale-generation',
+ actualConnectionGeneration: fixture.generation,
+ });
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(before);
+ }, 120_000);
+
+ it('disconnects multiple remote books with the exact lifecycle token set', async () => {
+ const fixture = await seedLifecycleUser();
+ useDatabaseTransactions();
+ expect(fixture.remoteBooks).toHaveLength(2);
+ const { rows: seededObjects } = await databaseClient.query(
+ `SELECT mapping_status, local_contact_id FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1 ORDER BY o.href`,
+ [fixture.userId],
+ );
+ expect(seededObjects).toHaveLength(4);
+ expect(seededObjects.every(object => object.mapping_status === 'synced')).toBe(true);
+ expect(new Set(seededObjects.map(object => object.local_contact_id)).size).toBe(4);
+ const { rows: [beforeLocal] } = await databaseClient.query(
+ 'SELECT sync_token FROM address_books WHERE id = $1',
+ [fixture.localBook.id],
+ );
+
+ await expect(carddavSync.disconnectCarddavAccount(fixture.userId))
+ .resolves.toBe(true);
+
+ const { rows: integrations } = await databaseClient.query(
+ `SELECT id FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'`,
+ [fixture.userId],
+ );
+ expect(integrations).toEqual([]);
+ const { rows: remoteBooks } = await databaseClient.query(
+ "SELECT id FROM address_books WHERE user_id = $1 AND source = 'carddav'",
+ [fixture.userId],
+ );
+ expect(remoteBooks).toEqual([]);
+ const { rows: [restored] } = await databaseClient.query(
+ `SELECT display_name, notes FROM contacts WHERE id = $1`,
+ [fixture.target.id],
+ );
+ expect(restored).toEqual({
+ display_name: 'Lifecycle Original',
+ notes: 'local lifecycle edit',
+ });
+ const { rows: [secondRestored] } = await databaseClient.query(
+ `SELECT display_name, notes FROM contacts WHERE id = $1`,
+ [fixture.secondTarget.id],
+ );
+ expect(secondRestored).toEqual({
+ display_name: 'Lifecycle Original B',
+ notes: 'local lifecycle edit B',
+ });
+ const { rows: tokens } = await databaseClient.query(
+ `SELECT id, sync_token FROM address_books
+ WHERE id = ANY($1::uuid[]) ORDER BY id`,
+ [[fixture.localBook.id, fixture.unrelatedBook.id]],
+ );
+ expect(tokens.find(row => row.id === fixture.localBook.id).sync_token)
+ .toBe(beforeLocal.sync_token);
+ expect(tokens.find(row => row.id === fixture.unrelatedBook.id).sync_token)
+ .toBe(fixture.unrelatedBook.sync_token);
+ const afterFirstDisconnect = await persistedLifecycleState(fixture.userId);
+ await expect(carddavSync.disconnectCarddavAccount(fixture.userId))
+ .resolves.toBe(false);
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(afterFirstDisconnect);
+ }, 120_000);
+
+ it('rolls back projection-aware finalization when the status write fails', async () => {
+ const fixture = await seedLifecycleUser();
+ const before = await persistedLifecycleState(fixture.userId);
+ useDatabaseTransactions(async (client, sql, params) => {
+ if (/UPDATE user_integrations/.test(sql)) throw new Error('forced finalizer status failure');
+ return client.query(sql, params);
+ });
+
+ await expect(carddavSync.finalizeCarddavSync(fixture.userId, {
+ connectionGeneration: fixture.generation,
+ seenUrls: [],
+ status: {
+ lastSyncAt: '2026-07-10T12:00:00.000Z',
+ lastError: null,
+ bookCount: 0,
+ contactCount: 0,
+ },
+ })).rejects.toThrow('forced finalizer status failure');
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(before);
+ }, 120_000);
+
+ it('rolls back projection-aware disconnect when integration deletion fails', async () => {
+ const fixture = await seedLifecycleUser();
+ const before = await persistedLifecycleState(fixture.userId);
+ useDatabaseTransactions(async (client, sql, params) => {
+ if (/DELETE FROM user_integrations/.test(sql)) {
+ throw new Error('forced disconnect deletion failure');
+ }
+ return client.query(sql, params);
+ });
+
+ await expect(carddavSync.disconnectCarddavAccount(fixture.userId))
+ .rejects.toThrow('forced disconnect deletion failure');
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(before);
+ }, 120_000);
+
+ it('keeps automatic projection ownership idempotent across an empty incremental replay', async () => {
+ const fixture = await seedAutomaticPair();
+ const before = await automaticState(fixture.userId);
+ const beforeBooks = new Map(before.books.map(book => [book.id, book]));
+ const sourceBefore = beforeBooks.get(fixture.bookAId);
+ useDatabaseTransactions();
+
+ const replay = await beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.bookAUrl, displayName: 'Automatic Remote A' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: sourceBefore.remote_sync_revision,
+ expectedRemoteToken: sourceBefore.remote_sync_token,
+ nextRemoteToken: 'automatic-replay-token',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: {
+ observedUrl: fixture.bookAUrl,
+ canonicalUrl: fixture.bookAUrl,
+ },
+ }));
+
+ expect(replay).toMatchObject({
+ changedBookIds: [],
+ ledgerChanged: false,
+ updated: 0,
+ removed: 0,
+ });
+ const after = await automaticState(fixture.userId);
+ expect(after.contacts).toEqual(before.contacts);
+ expect(after.ledger).toEqual(before.ledger);
+ expect(after.integration).toEqual(before.integration);
+ const afterBooks = new Map(after.books.map(book => [book.id, book]));
+ expect(afterBooks.get(fixture.bookAId)).toEqual({
+ ...sourceBefore,
+ remote_sync_token: 'automatic-replay-token',
+ remote_sync_revision: String(BigInt(sourceBefore.remote_sync_revision) + 1n),
+ });
+ for (const id of [fixture.bookBId, fixture.targetBook.id, fixture.unrelatedBook.id]) {
+ expect(afterBooks.get(id)).toEqual(beforeBooks.get(id));
+ }
+ const { rows: mappings } = await databaseClient.query(
+ `SELECT href, mapping_status, remote_semantic_hash, local_contact_hash,
+ legacy_projection, local_contact_id
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ ORDER BY href`,
+ [fixture.userId],
+ );
+ expect(mappings).toHaveLength(2);
+ expect(mappings.every(mapping => (
+ mapping.mapping_status === 'synced'
+ && mapping.remote_semantic_hash
+ && mapping.local_contact_hash
+ && mapping.legacy_projection === null
+ && mapping.local_contact_id
+ ))).toBe(true);
+ expect(mappings.find(mapping => mapping.href === fixture.duplicate.href).local_contact_id)
+ .toBe(fixture.targetContact.id);
+ }, 120_000);
+
+ it('rolls back automatic contact and mapping writes when the book revision update fails', async () => {
+ const fixture = await seedAutomaticPair();
+ const before = await persistedLifecycleState(fixture.userId);
+ const sourceBefore = before.allBooks.find(book => book.id === fixture.bookBId);
+ const changed = {
+ ...fixture.unique,
+ remoteEtag: '"automatic-rollback"',
+ vcard: fixture.unique.vcard.replace('FN:Automatic Remote Unique', 'FN:Automatic Rollback'),
+ contact: { ...fixture.unique.contact, displayName: 'Automatic Rollback' },
+ };
+ let mappingWritten = false;
+
+ await expect(beginApply(completePlan({
+ userId: fixture.userId,
+ book: { url: fixture.bookBUrl, displayName: 'Automatic Remote B' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: sourceBefore.remote_sync_revision,
+ expectedRemoteToken: sourceBefore.remote_sync_token,
+ nextRemoteToken: 'automatic-rollback-token',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: {
+ observedUrl: fixture.bookBUrl,
+ canonicalUrl: fixture.bookBUrl,
+ },
+ upserts: [changed],
+ }), async (client, sql, params) => {
+ if (mappingWritten && /UPDATE address_books SET/.test(sql)) {
+ throw new Error('forced automatic projection failure');
+ }
+ const result = await client.query(sql, params);
+ if (/(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) {
+ mappingWritten = true;
+ }
+ return result;
+ })).rejects.toThrow('forced automatic projection failure');
+ expect(mappingWritten).toBe(true);
+ expect(await persistedLifecycleState(fixture.userId)).toEqual(before);
+ }, 120_000);
+
+ it('serializes two NULL-token snapshot plans by revision CAS before projection reads', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const remoteUrl = `https://dav.example.test/addressbooks/${userId}/snapshot-cas/`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-snapshot-cas-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ await databaseClient.query(
+ `INSERT INTO address_books (user_id, name, source, external_url)
+ VALUES ($1, 'Snapshot CAS', 'carddav', $2)`,
+ [userId, remoteUrl],
+ );
+ const winnerCard = {
+ ...remoteCard('Snapshot Winner', `snapshot-${userId}@example.test`),
+ href: `${remoteUrl}winner.vcf`,
+ };
+ const loserCard = {
+ ...remoteCard('Snapshot Loser', `snapshot-${userId}@example.test`),
+ href: `${remoteUrl}loser.vcf`,
+ };
+ const plan = card => completePlan({
+ userId,
+ book: { url: remoteUrl, displayName: 'Snapshot CAS' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '0',
+ expectedRemoteToken: null,
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl },
+ upserts: [card],
+ });
+ const firstClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ const secondClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ await Promise.all([firstClient.connect(), secondClient.connect()]);
+ await Promise.all([
+ firstClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"),
+ secondClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"),
+ ]);
+ const { rows: [{ pid: secondPid }] } = await secondClient.query(
+ 'SELECT pg_backend_pid() AS pid',
+ );
+ const firstLocked = deferred();
+ const releaseFirst = deferred();
+ const first = applyWithClient(firstClient, plan(winnerCard), async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) {
+ firstLocked.resolve();
+ await releaseFirst.promise;
+ }
+ return result;
+ });
+ await firstLocked.promise;
+ const secondQueries = [];
+ const second = applyWithClient(secondClient, plan(loserCard), async (client, sql, params) => {
+ secondQueries.push(sql);
+ return client.query(sql, params);
+ });
+ await waitForPostgresState({
+ description: 'second snapshot transaction to block on the revision lock',
+ probe: () => probeBackendLock(secondPid),
+ });
+ releaseFirst.resolve();
+
+ await expect(first).resolves.toMatchObject({ updated: 1 });
+ const stale = await second.catch(error => error);
+ expect(stale).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(stale).toMatchObject({ expectedRemoteRevision: '0', actualRemoteRevision: '1' });
+ expect(secondQueries.some(sql => /FROM contacts|FROM carddav_remote_objects/.test(sql)))
+ .toBe(false);
+ const afterWinner = await persistedLifecycleState(userId);
+ const sourceAfterWinner = afterWinner.allBooks.find(book => book.source === 'carddav');
+ expect(sourceAfterWinner).toMatchObject({
+ remote_sync_token: null,
+ remote_sync_revision: '1',
+ });
+ expect(afterWinner.contacts).toHaveLength(1);
+ expect(afterWinner.contacts[0].display_name).toBe(winnerCard.contact.displayName);
+
+ const stableToken = sourceAfterWinner.sync_token;
+ await applyWithClient(firstClient, {
+ ...plan(winnerCard),
+ expectedRemoteRevision: '1',
+ });
+ const afterNoChange = await persistedLifecycleState(userId);
+ expect(afterNoChange.allBooks.find(book => book.source === 'carddav')).toMatchObject({
+ remote_sync_token: null,
+ remote_sync_revision: '2',
+ sync_token: stableToken,
+ });
+ expect(afterNoChange.contacts).toEqual(afterWinner.contacts);
+ expect(afterNoChange.ledger).toEqual(afterWinner.ledger);
+ await Promise.all([firstClient.end(), secondClient.end()]);
+ }, 120_000);
+
+ it('rolls back exact state before and after the final remote revision update', async () => {
+ for (const boundary of ['before-remote-update', 'after-remote-update']) {
+ const fixture = await seedAutomaticPair();
+ const before = await persistedLifecycleState(fixture.userId);
+ const source = before.allBooks.find(book => book.id === fixture.bookAId);
+ const changed = {
+ ...fixture.duplicate,
+ remoteEtag: `"${boundary}"`,
+ vcard: fixture.duplicate.vcard.replace(
+ `FN:${fixture.duplicate.contact.displayName}`,
+ `FN:${boundary}`,
+ ),
+ contact: { ...fixture.duplicate.contact, displayName: boundary },
+ };
+ const plan = completePlan({
+ userId: fixture.userId,
+ book: { url: source.external_url, displayName: 'Automatic Remote A' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: source.remote_sync_revision,
+ expectedRemoteToken: source.remote_sync_token,
+ nextRemoteToken: `${boundary}-token`,
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: {
+ observedUrl: source.external_url,
+ canonicalUrl: source.external_url,
+ },
+ upserts: [changed],
+ });
+
+ await expect(beginApply(plan, async (client, sql, params) => {
+ if (/external_url = COALESCE\(\$6, external_url\)/.test(sql)) {
+ if (boundary === 'before-remote-update') throw new Error(boundary);
+ await client.query(sql, params);
+ throw new Error(boundary);
+ }
+ return client.query(sql, params);
+ })).rejects.toThrow(boundary);
+ expect(await persistedLifecycleState(fixture.userId), boundary).toEqual(before);
+ }
+ }, 120_000);
+
+ it('updates automatic import photos while preserving a linked explicit contact through unlink', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const separateUrl = `https://dav.example.test/addressbooks/${userId}/photo-separate/`;
+ const mergeUrl = `https://dav.example.test/addressbooks/${userId}/photo-merge/`;
+ const duplicateEmail = `photo-target-${userId}@example.test`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-photo-parity-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [targetBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Photo Target') RETURNING id, sync_token",
+ [userId],
+ );
+ const target = {
+ uid: `photo-target-${userId}`,
+ displayName: 'Photo Original',
+ primaryEmail: duplicateEmail,
+ emails: [{ value: duplicateEmail, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: 'photo local note',
+ photoData: null,
+ };
+ const targetVcard = generateVCard(target);
+ const targetEtag = createHash('md5').update(targetVcard).digest('hex');
+ const { rows: [targetContact] } = await databaseClient.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, notes, photo_data, is_auto
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, $9, NULL, false
+ ) RETURNING id`,
+ [
+ targetBook.id, userId, target.uid, targetVcard, targetEtag,
+ target.displayName, duplicateEmail, JSON.stringify(target.emails), target.notes,
+ ],
+ );
+ const photoCard = (url, uid, name, email, photoData, remoteEtag) => {
+ const contact = {
+ uid,
+ displayName: name,
+ primaryEmail: email,
+ emails: [{ value: email, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData,
+ };
+ return {
+ href: `${url}${uid}.vcf`,
+ remoteEtag,
+ vcard: generateVCard(contact),
+ contact,
+ };
+ };
+ const jpeg = 'data:image/jpeg;base64,AQID';
+ const png = 'data:image/png;base64,BAUG';
+ const separateJpeg = photoCard(
+ separateUrl, 'photo-separate', 'Photo Separate',
+ `photo-separate-${userId}@example.test`, jpeg, '"photo-separate-1"',
+ );
+ const mergeJpeg = photoCard(
+ mergeUrl, 'photo-merge', 'Photo Merge', duplicateEmail, jpeg, '"photo-merge-1"',
+ );
+ for (const [url, name, card, token] of [
+ [separateUrl, 'Photo Separate', separateJpeg, 'photo-separate-token-1'],
+ [mergeUrl, 'Photo Merge', mergeJpeg, 'photo-merge-token-1'],
+ ]) {
+ await beginApply(completePlan({
+ userId,
+ book: { url, displayName: name },
+ connectionGeneration: generation,
+ nextRemoteToken: token,
+ capability: 'sync-collection',
+ collectionIdentity: { observedUrl: url, canonicalUrl: url },
+ upserts: [card],
+ }));
+ }
+ const initial = await persistedLifecycleState(userId);
+ const separateBook = initial.allBooks.find(book => book.external_url === separateUrl);
+ const mergeBook = initial.allBooks.find(book => book.external_url === mergeUrl);
+ const initialSeparate = initial.contacts.find(row => row.address_book_id === separateBook.id);
+ const initialTarget = initial.contacts.find(row => row.address_book_id === targetBook.id);
+ expect(initialSeparate.photo_data).toBe(jpeg);
+ expect(initialSeparate.vcard).toContain('PHOTO;ENCODING=b;TYPE=JPEG:AQID\r\n');
+ expect(initialSeparate.etag)
+ .toBe(createHash('md5').update(initialSeparate.vcard).digest('hex'));
+ expect(initialTarget).toMatchObject({
+ display_name: target.displayName,
+ photo_data: null,
+ vcard: targetVcard,
+ etag: targetEtag,
+ });
+ expect(initial.ledger.find(mapping => mapping.href === mergeJpeg.href)).toMatchObject({
+ local_contact_id: targetContact.id,
+ vcard: expect.stringContaining('PHOTO;ENCODING=b;TYPE=JPEG:AQID\r\n'),
+ });
+
+ const separatePng = photoCard(
+ separateUrl, 'photo-separate', 'Photo Separate', separateJpeg.contact.primaryEmail,
+ png, '"photo-separate-2"',
+ );
+ const mergePng = photoCard(
+ mergeUrl, 'photo-merge', 'Photo Merge', duplicateEmail, png, '"photo-merge-2"',
+ );
+ for (const [url, card, expectedToken, nextToken] of [
+ [separateUrl, separatePng, 'photo-separate-token-1', 'photo-separate-token-2'],
+ [mergeUrl, mergePng, 'photo-merge-token-1', 'photo-merge-token-2'],
+ ]) {
+ await beginApply(completePlan({
+ userId,
+ book: { url, displayName: 'Photo' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '1',
+ expectedRemoteToken: expectedToken,
+ nextRemoteToken: nextToken,
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: url, canonicalUrl: url },
+ upserts: [card],
+ }));
+ }
+ const updated = await persistedLifecycleState(userId);
+ const updatedSeparateBook = updated.allBooks.find(book => book.id === separateBook.id);
+ const updatedMergeBook = updated.allBooks.find(book => book.id === mergeBook.id);
+ const updatedTargetBook = updated.allBooks.find(book => book.id === targetBook.id);
+ const updatedSeparate = updated.contacts.find(row => row.address_book_id === separateBook.id);
+ const updatedTarget = updated.contacts.find(row => row.address_book_id === targetBook.id);
+ expect(updatedSeparate.photo_data).toBe(png);
+ expect(updatedSeparate.vcard).toContain('PHOTO;ENCODING=b;TYPE=PNG:BAUG\r\n');
+ expect(updatedSeparate.etag)
+ .toBe(createHash('md5').update(updatedSeparate.vcard).digest('hex'));
+ expect(updatedTarget).toEqual(initialTarget);
+ expect(updated.ledger.find(mapping => mapping.href === mergePng.href)).toMatchObject({
+ local_contact_id: targetContact.id,
+ vcard: expect.stringContaining('PHOTO;ENCODING=b;TYPE=PNG:BAUG\r\n'),
+ });
+ expect(updatedSeparateBook.sync_token).not.toBe(separateBook.sync_token);
+ expect(updatedMergeBook.sync_token).toBe(mergeBook.sync_token);
+ expect(updatedTargetBook.sync_token)
+ .toBe(initial.allBooks.find(book => book.id === targetBook.id).sync_token);
+
+ await beginApply(completePlan({
+ userId,
+ book: { url: mergeUrl, displayName: 'Photo Merge' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '2',
+ expectedRemoteToken: 'photo-merge-token-2',
+ nextRemoteToken: 'photo-merge-token-3',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: mergeUrl, canonicalUrl: mergeUrl },
+ upserts: [mergePng],
+ }));
+ const noOp = await persistedLifecycleState(userId);
+ const noOpTarget = noOp.contacts.find(row => row.address_book_id === targetBook.id);
+ expect(noOpTarget).toEqual(updatedTarget);
+ expect(noOp.allBooks.find(book => book.id === targetBook.id).sync_token)
+ .toBe(updatedTargetBook.sync_token);
+ expect(noOp.allBooks.find(book => book.id === mergeBook.id)).toMatchObject({
+ sync_token: updatedMergeBook.sync_token,
+ remote_sync_revision: '3',
+ remote_sync_token: 'photo-merge-token-3',
+ });
+
+ await beginApply(completePlan({
+ userId,
+ book: { url: mergeUrl, displayName: 'Photo Merge' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '3',
+ expectedRemoteToken: 'photo-merge-token-3',
+ nextRemoteToken: 'photo-merge-token-4',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: mergeUrl, canonicalUrl: mergeUrl },
+ upserts: [],
+ removedHrefs: [mergePng.href],
+ }));
+ const restored = await persistedLifecycleState(userId);
+ const restoredTarget = restored.contacts.find(row => row.address_book_id === targetBook.id);
+ expect(restoredTarget).toEqual(initialTarget);
+ expect(restored.ledger.some(mapping => mapping.href === mergePng.href)).toBe(false);
+ expect(restored.allBooks.find(book => book.id === targetBook.id).sync_token)
+ .toBe(updatedTargetBook.sync_token);
+ expect(restored.allBooks.find(book => book.id === mergeBook.id).sync_token)
+ .toBe(updatedMergeBook.sync_token);
+ }, 120_000);
+
+ it('preserves local edits in both target-lock orderings', async () => {
+ for (const ordering of ['local-first', 'sync-first']) {
+ const fixture = await seedAutomaticPair();
+ const before = await automaticState(fixture.userId);
+ const source = before.books.find(book => book.id === fixture.bookAId);
+ const changed = {
+ ...fixture.duplicate,
+ remoteEtag: `"local-edit-${ordering}"`,
+ vcard: fixture.duplicate.vcard.replace(
+ `FN:${fixture.duplicate.contact.displayName}`,
+ `FN:Sync ${ordering}`,
+ ),
+ contact: { ...fixture.duplicate.contact, displayName: `Sync ${ordering}` },
+ };
+ const plan = completePlan({
+ userId: fixture.userId,
+ book: { url: source.external_url, displayName: 'Automatic Remote A' },
+ connectionGeneration: fixture.generation,
+ expectedRemoteRevision: source.remote_sync_revision,
+ expectedRemoteToken: source.remote_sync_token,
+ nextRemoteToken: `local-edit-${ordering}-token`,
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: {
+ observedUrl: source.external_url,
+ canonicalUrl: source.external_url,
+ },
+ upserts: [changed],
+ });
+ const syncClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ const localClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ await Promise.all([syncClient.connect(), localClient.connect()]);
+ await Promise.all([
+ syncClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"),
+ localClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"),
+ ]);
+
+ if (ordering === 'local-first') {
+ const sourceLocked = deferred();
+ const releaseSync = deferred();
+ const sync = applyWithClient(syncClient, plan, async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) {
+ sourceLocked.resolve();
+ await releaseSync.promise;
+ }
+ return result;
+ });
+ await sourceLocked.promise;
+ const localVcard = generateVCard({
+ ...fixture.target,
+ notes: 'local edit before target lock',
+ });
+ await localClient.query('BEGIN');
+ await localClient.query(
+ `UPDATE address_books SET sync_token = gen_random_uuid()::text
+ WHERE id = $1`,
+ [fixture.targetBook.id],
+ );
+ await localClient.query(
+ `UPDATE contacts SET notes = 'local edit before target lock',
+ vcard = $2, etag = $3
+ WHERE id = $1`,
+ [
+ fixture.targetContact.id,
+ localVcard,
+ createHash('md5').update(localVcard).digest('hex'),
+ ],
+ );
+ await localClient.query('COMMIT');
+ releaseSync.resolve();
+ await expect(sync).resolves.toMatchObject({ updated: 0, remote: 1 });
+ const { rows: [contact] } = await databaseClient.query(
+ `SELECT display_name, notes, vcard, etag FROM contacts WHERE id = $1`,
+ [fixture.targetContact.id],
+ );
+ expect(contact).toMatchObject({
+ display_name: fixture.target.displayName,
+ notes: 'local edit before target lock',
+ vcard: localVcard,
+ etag: createHash('md5').update(localVcard).digest('hex'),
+ });
+ } else {
+ const contactsLocked = deferred();
+ const releaseSync = deferred();
+ const sync = applyWithClient(syncClient, plan, async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/SELECT c\.id[\s\S]+c\.uid[\s\S]+FOR UPDATE OF c/.test(sql)) {
+ contactsLocked.resolve();
+ await releaseSync.promise;
+ }
+ return result;
+ });
+ await contactsLocked.promise;
+ const localVcard = generateVCard({
+ ...fixture.target,
+ displayName: 'Local edit after sync lock',
+ });
+ const { rows: [{ pid: localPid }] } = await localClient.query(
+ 'SELECT pg_backend_pid() AS pid',
+ );
+ const local = (async () => {
+ await localClient.query('BEGIN');
+ try {
+ await localClient.query(
+ `UPDATE address_books SET sync_token = gen_random_uuid()::text
+ WHERE id = $1`,
+ [fixture.targetBook.id],
+ );
+ await localClient.query(
+ `UPDATE contacts SET display_name = $2, vcard = $3, etag = $4
+ WHERE id = $1`,
+ [
+ fixture.targetContact.id,
+ 'Local edit after sync lock',
+ localVcard,
+ createHash('md5').update(localVcard).digest('hex'),
+ ],
+ );
+ await localClient.query('COMMIT');
+ } catch (error) {
+ await localClient.query('ROLLBACK');
+ throw error;
+ }
+ })();
+ await waitForPostgresState({
+ description: 'local edit transaction to block on the target lock',
+ probe: () => probeBackendLock(localPid),
+ });
+ releaseSync.resolve();
+ await expect(sync).resolves.toMatchObject({ updated: 0, remote: 1 });
+ await expect(local).resolves.toBeUndefined();
+ const { rows: [contact] } = await databaseClient.query(
+ `SELECT display_name, vcard, etag FROM contacts WHERE id = $1`,
+ [fixture.targetContact.id],
+ );
+ expect(contact).toEqual({
+ display_name: 'Local edit after sync lock',
+ vcard: localVcard,
+ etag: createHash('md5').update(localVcard).digest('hex'),
+ });
+ }
+ await Promise.all([syncClient.end(), localClient.end()]);
+ }
+ }, 120_000);
+
+ it('serializes two automatic source applies without deadlock in both source orders', async () => {
+ for (const initiation of ['low-source-first', 'high-source-first']) {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const [lowId, highId] = [randomUUID(), randomUUID()].sort();
+ const urls = new Map([
+ [lowId, `https://dav.example.test/addressbooks/${userId}/c17-low/`],
+ [highId, `https://dav.example.test/addressbooks/${userId}/c17-high/`],
+ ]);
+ const duplicateEmail = `c17-${userId}@example.test`;
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-c17-${initiation}-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ const { rows: [targetBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'C17 Target') RETURNING id, sync_token",
+ [userId],
+ );
+ const target = {
+ uid: `c17-target-${userId}`,
+ displayName: 'C17 Original',
+ primaryEmail: duplicateEmail,
+ emails: [{ value: duplicateEmail, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ };
+ const targetVcard = generateVCard(target);
+ await databaseClient.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, false
+ )`,
+ [
+ targetBook.id, userId, target.uid, targetVcard,
+ createHash('md5').update(targetVcard).digest('hex'), target.displayName,
+ duplicateEmail, JSON.stringify(target.emails),
+ ],
+ );
+ await databaseClient.query(
+ `INSERT INTO address_books (id, user_id, name, source, external_url)
+ VALUES ($1, $3, 'C17 Low', 'carddav', $4),
+ ($2, $3, 'C17 High', 'carddav', $5)`,
+ [lowId, highId, userId, urls.get(lowId), urls.get(highId)],
+ );
+ const cards = new Map();
+ for (const [id, label] of [[lowId, 'low'], [highId, 'high']]) {
+ const contact = {
+ ...remoteCard('C17 Initial', duplicateEmail),
+ href: `${urls.get(id)}${label}.vcf`,
+ };
+ cards.set(id, contact);
+ await beginApply(completePlan({
+ userId,
+ book: { url: urls.get(id), displayName: `C17 ${label}` },
+ connectionGeneration: generation,
+ nextRemoteToken: `c17-${label}-token-1`,
+ capability: 'sync-collection',
+ collectionIdentity: { observedUrl: urls.get(id), canonicalUrl: urls.get(id) },
+ upserts: [contact],
+ }));
+ }
+ const before = await automaticState(userId);
+ const beforeBooks = new Map(before.books.map(book => [book.id, book]));
+ const applyClients = new Map();
+ const applyPids = new Map();
+ for (const id of [lowId, highId]) {
+ const client = new Client({ connectionString: connectionStringFor(databaseName) });
+ await client.connect();
+ await client.query("SET lock_timeout = '3s'; SET statement_timeout = '8s'");
+ applyClients.set(id, client);
+ const { rows: [{ pid }] } = await client.query('SELECT pg_backend_pid() AS pid');
+ applyPids.set(id, pid);
+ }
+ const reached = new Map([[lowId, deferred()], [highId, deferred()]]);
+ const release = deferred();
+ const startApply = id => {
+ const label = id === lowId ? 'low' : 'high';
+ const source = beforeBooks.get(id);
+ return applyWithClient(
+ applyClients.get(id),
+ completePlan({
+ userId,
+ book: { url: urls.get(id), displayName: `C17 ${label}` },
+ connectionGeneration: generation,
+ expectedRemoteRevision: source.remote_sync_revision,
+ expectedRemoteToken: source.remote_sync_token,
+ nextRemoteToken: `c17-${label}-token-2`,
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: urls.get(id), canonicalUrl: urls.get(id) },
+ }),
+ async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) {
+ reached.get(id).resolve();
+ await release.promise;
+ }
+ return result;
+ },
+ );
+ };
+ const order = initiation === 'low-source-first'
+ ? [lowId, highId]
+ : [highId, lowId];
+ const applies = [];
+ applies.push(startApply(order[0]));
+ await reached.get(order[0]).promise;
+ applies.push(startApply(order[1]));
+ await waitForPostgresState({
+ description: 'second source apply to block on the source lock',
+ probe: () => probeBackendLock(applyPids.get(order[1])),
+ });
+
+ release.resolve();
+ const settled = await Promise.allSettled(applies);
+ expect(settled.every(result => (
+ result.status === 'fulfilled' || result.reason?.code !== '40P01'
+ )), initiation).toBe(true);
+ expect(settled, initiation).toEqual([
+ expect.objectContaining({ status: 'fulfilled' }),
+ expect.objectContaining({ status: 'fulfilled' }),
+ ]);
+
+ const after = await automaticState(userId);
+ expect(after.integration).toEqual(before.integration);
+ expect(after.contacts).toEqual(before.contacts);
+ expect(after.ledger).toEqual(before.ledger);
+ for (const id of [lowId, highId]) {
+ const sourceBefore = beforeBooks.get(id);
+ const sourceAfter = after.books.find(book => book.id === id);
+ const label = id === lowId ? 'low' : 'high';
+ expect(sourceAfter).toMatchObject({
+ remote_sync_token: `c17-${label}-token-2`,
+ remote_sync_revision: String(BigInt(sourceBefore.remote_sync_revision) + 1n),
+ sync_token: sourceBefore.sync_token,
+ });
+ }
+ await Promise.all([...applyClients.values()].map(client => client.end()));
+ }
+ }, 120_000);
+
+ it('blocks a later automatic apply and rejects its stale revision after the winner commits', async () => {
+ vi.clearAllMocks();
+ const userId = randomUUID();
+ const generation = randomUUID();
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-automatic-lock-${userId}`],
+ );
+ await databaseClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, generation],
+ );
+ await beginApply(completePlan({
+ userId,
+ connectionGeneration: generation,
+ nextRemoteToken: 'automatic-token-before',
+ }));
+ const { rows: [sourceBook] } = await databaseClient.query(
+ `SELECT remote_sync_revision::text
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`,
+ [userId, BOOK_URL],
+ );
+
+ const laterClient = new Client({ connectionString: connectionStringFor(databaseName) });
+ await laterClient.connect();
+ const { rows: [{ pid: laterPid }] } = await laterClient.query(
+ 'SELECT pg_backend_pid() AS pid',
+ );
+
+ const applyHoldingLocks = deferred();
+ const releaseApply = deferred();
+ const applyPromise = beginApply(completePlan({
+ userId,
+ connectionGeneration: generation,
+ expectedRemoteRevision: sourceBook.remote_sync_revision,
+ expectedRemoteToken: 'automatic-token-before',
+ nextRemoteToken: 'automatic-token-after',
+ replaceAll: false,
+ }), async (client, sql, params) => {
+ const result = await client.query(sql, params);
+ if (/FROM user_integrations[\s\S]+FOR UPDATE/.test(sql)) {
+ applyHoldingLocks.resolve();
+ await releaseApply.promise;
+ }
+ return result;
+ });
+
+ await applyHoldingLocks.promise;
+ const laterPromise = applyWithClient(laterClient, completePlan({
+ userId,
+ connectionGeneration: generation,
+ expectedRemoteRevision: sourceBook.remote_sync_revision,
+ expectedRemoteToken: 'automatic-token-before',
+ nextRemoteToken: 'later-automatic-token',
+ replaceAll: false,
+ }));
+ await waitForPostgresState({
+ description: 'later automatic apply to block behind the integration lock',
+ probe: () => probeBackendLock(laterPid),
+ });
+ releaseApply.resolve();
+
+ await expect(applyPromise).resolves.toMatchObject({ remote: 0 });
+ await expect(laterPromise).rejects.toMatchObject({
+ name: 'StaleCarddavPlanError',
+ expectedRemoteRevision: sourceBook.remote_sync_revision,
+ actualRemoteRevision: String(BigInt(sourceBook.remote_sync_revision) + 1n),
+ });
+ const { rows: [book] } = await databaseClient.query(
+ `SELECT remote_sync_token, remote_sync_revision::text
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`,
+ [userId, BOOK_URL],
+ );
+ expect(book).toEqual({
+ remote_sync_token: 'automatic-token-after',
+ remote_sync_revision: String(BigInt(sourceBook.remote_sync_revision) + 1n),
+ });
+ await laterClient.end();
+ }, 120_000);
+
+});
diff --git a/backend/src/services/carddavSync.integration.test.js b/backend/src/services/carddavSync.integration.test.js
new file mode 100644
index 0000000..85fc956
--- /dev/null
+++ b/backend/src/services/carddavSync.integration.test.js
@@ -0,0 +1,4281 @@
+import { createHash, randomUUID } from 'node:crypto';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import bcrypt from 'bcryptjs';
+import express from 'express';
+import pg from 'pg';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { createCarddavFixtureServer } from './carddavFixtureServer.js';
+import {
+ applyTestMigrations,
+ assertMinimumPostgresVersion,
+ createTestDatabase,
+ dropTestDatabase,
+ postgresTestContext,
+ productionDatabaseEnvironment,
+ waitForPostgresState,
+} from './postgresTestHelpers.js';
+import {
+ deleteCardResource,
+ discoverAddressBooks,
+ fetchAddressBookDelta,
+ fetchCardResource,
+ putCardResource,
+} from './carddavClient.js';
+import { generateVCard, parseVCard } from '../utils/vcard.js';
+import {
+ localContactHash,
+ parseVCardDocument,
+ presentedEtag,
+ presentedVCard,
+ semanticVCardHash,
+} from '../utils/vcardProperties.js';
+
+const credentials = {
+ username: 'fixture-user',
+ password: 'fixture-password',
+ allowPrivate: true,
+};
+
+function vcard(uid, name) {
+ return [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${uid}`,
+ `FN:${name}`,
+ `EMAIL:${uid}@example.test`,
+ 'END:VCARD',
+ ].join('\n');
+}
+
+describe('CardDAV sync against a real HTTP fixture', () => {
+ let fixture;
+ let book;
+
+ beforeAll(async () => {
+ fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ [book] = await discoverAddressBooks({ serverUrl: fixture.serverUrl, ...credentials });
+ });
+
+ afterAll(async () => {
+ await fixture?.close();
+ });
+
+ it('exercises initial, incremental, recovery, paging, and snapshot plans', async () => {
+ const alpha = fixture.href('alpha.vcf');
+ const beta = fixture.href('beta.vcf');
+ const gamma = fixture.href('gamma.vcf');
+ const delta = fixture.href('delta.vcf');
+ const collectionIdentity = {
+ observedUrl: book.url,
+ canonicalUrl: book.url,
+ };
+
+ fixture.putContact(alpha, '"alpha-1"', vcard('alpha', 'Alpha One'));
+ fixture.queueSync('', {
+ events: [{ href: alpha, etag: '"alpha-1"' }],
+ nextToken: '00123',
+ });
+ const initial = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: null,
+ });
+ expect(initial).toEqual({
+ expectedRemoteToken: null,
+ nextRemoteToken: '00123',
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [{ href: alpha, etag: '"alpha-1"', vcard: vcard('alpha', 'Alpha One') }],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+
+ fixture.queueSync('00123', {
+ events: [],
+ nextToken: 'opaque:2/unchanged',
+ });
+ const unchanged = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: '00123',
+ });
+ expect(unchanged).toEqual({
+ expectedRemoteToken: '00123',
+ nextRemoteToken: 'opaque:2/unchanged',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+ expect(fixture.counters.multiget).toBe(1);
+
+ fixture.putContact(beta, '"beta-1"', vcard('beta', 'Beta One'));
+ fixture.queueSync('opaque:2/unchanged', {
+ events: [{ href: beta, etag: '"beta-1"' }],
+ nextToken: 'opaque:3/add',
+ });
+ const added = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: 'opaque:2/unchanged',
+ });
+ expect(added).toEqual({
+ expectedRemoteToken: 'opaque:2/unchanged',
+ nextRemoteToken: 'opaque:3/add',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [{ href: beta, etag: '"beta-1"', vcard: vcard('beta', 'Beta One') }],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+
+ fixture.putContact(alpha, '"alpha-2"', vcard('alpha', 'Alpha Two'));
+ fixture.queueSync('opaque:3/add', {
+ events: [{ href: alpha, etag: '"alpha-2"' }],
+ nextToken: 'opaque:4/edit',
+ });
+ const edited = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: 'opaque:3/add',
+ });
+ expect(edited).toEqual({
+ expectedRemoteToken: 'opaque:3/add',
+ nextRemoteToken: 'opaque:4/edit',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [{ href: alpha, etag: '"alpha-2"', vcard: vcard('alpha', 'Alpha Two') }],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+
+ fixture.deleteContact(beta);
+ fixture.queueSync('opaque:4/edit', {
+ events: [{ href: beta, status: 404 }],
+ nextToken: 'opaque:5/delete',
+ });
+ const deleted = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: 'opaque:4/edit',
+ });
+ expect(deleted).toEqual({
+ expectedRemoteToken: 'opaque:4/edit',
+ nextRemoteToken: 'opaque:5/delete',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [beta],
+ collectionIdentity,
+ });
+
+ fixture.putContact(gamma, '"gamma-1"', vcard('gamma', 'Gamma One'));
+ fixture.putContact(delta, '"delta-1"', vcard('delta', 'Delta One'));
+ fixture.queueSync('opaque:5/delete', {
+ events: [{ href: gamma, etag: '"gamma-1"' }],
+ nextToken: 'opaque:page-2',
+ truncated: true,
+ });
+ fixture.queueSync('opaque:page-2', {
+ events: [{ href: delta, etag: '"delta-1"' }],
+ nextToken: 'opaque:6/paged',
+ });
+ const paged = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: 'opaque:5/delete',
+ });
+ expect(paged).toEqual({
+ expectedRemoteToken: 'opaque:5/delete',
+ nextRemoteToken: 'opaque:6/paged',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [
+ { href: gamma, etag: '"gamma-1"', vcard: vcard('gamma', 'Gamma One') },
+ { href: delta, etag: '"delta-1"', vcard: vcard('delta', 'Delta One') },
+ ],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+
+ fixture.queueSync('opaque:invalid', {
+ status: 403,
+ precondition: 'valid-sync-token',
+ });
+ await expect(fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: 'opaque:invalid',
+ })).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 403,
+ precondition: 'valid-sync-token',
+ });
+ fixture.queueSync('', {
+ events: [
+ { href: alpha, etag: '"alpha-2"' },
+ { href: gamma, etag: '"gamma-1"' },
+ { href: delta, etag: '"delta-1"' },
+ ],
+ nextToken: 'opaque:7/reconciled',
+ });
+ const reconciled = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: '',
+ });
+ expect(reconciled).toEqual({
+ expectedRemoteToken: '',
+ nextRemoteToken: 'opaque:7/reconciled',
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [
+ { href: alpha, etag: '"alpha-2"', vcard: vcard('alpha', 'Alpha Two') },
+ { href: gamma, etag: '"gamma-1"', vcard: vcard('gamma', 'Gamma One') },
+ { href: delta, etag: '"delta-1"', vcard: vcard('delta', 'Delta One') },
+ ],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+
+ fixture.queueSync('opaque:7/reconciled', { status: 405 });
+ const snapshot = await fetchAddressBookDelta({
+ ...book,
+ ...credentials,
+ syncToken: 'opaque:7/reconciled',
+ });
+ expect(snapshot).toEqual({
+ expectedRemoteToken: 'opaque:7/reconciled',
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ upserts: [
+ { href: alpha, etag: '"alpha-2"', vcard: vcard('alpha', 'Alpha Two') },
+ { href: delta, etag: '"delta-1"', vcard: vcard('delta', 'Delta One') },
+ { href: gamma, etag: '"gamma-1"', vcard: vcard('gamma', 'Gamma One') },
+ ],
+ removedHrefs: [],
+ collectionIdentity,
+ });
+
+ expect(fixture.counters).toMatchObject({
+ requests: 19,
+ propfind: 3,
+ sync: 10,
+ multiget: 5,
+ addressbookQuery: 1,
+ requestUri507: 1,
+ snapshotFilters: [1],
+ syncTokens: [
+ '',
+ '00123',
+ 'opaque:2/unchanged',
+ 'opaque:3/add',
+ 'opaque:4/edit',
+ 'opaque:5/delete',
+ 'opaque:page-2',
+ 'opaque:invalid',
+ '',
+ 'opaque:7/reconciled',
+ ],
+ multigetSizes: [1, 1, 1, 2, 3],
+ });
+ expect(initial.nextRemoteToken).toBe('00123');
+ expect(fixture.counters.syncTokens).toContain('00123');
+ const fixtureOrigin = new URL(fixture.serverUrl).origin;
+ expect(fixture.requests.filter(request => request.authorization)
+ .every(request => request.origin === fixtureOrigin)).toBe(true);
+ expect(fixture.requests.every(request => request.authorization === 'Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ=')).toBe(true);
+ expect(fixture.requests.filter(request => request.method === 'REPORT')
+ .every(request => request.depth === '0' || request.depth === '1')).toBe(true);
+ });
+
+ it('discovers writable metadata and enforces conditional resource writes', async () => {
+ fixture.reset();
+ expect(book).toMatchObject({
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ discoveryIndex: 0,
+ addressData: [
+ { contentType: 'text/vcard', version: '4.0' },
+ { contentType: 'text/vcard', version: '3.0' },
+ ],
+ });
+ const href = fixture.href('conditional.vcf');
+ const createdCard = vcard('conditional', 'Conditional Create');
+
+ const created = await putCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ vcard: createdCard,
+ });
+ expect(created).toEqual({ href, etag: expect.any(String) });
+ await expect(fetchCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ })).resolves.toEqual({ href, etag: created.etag, vcard: createdCard });
+
+ await expect(putCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ etag: '"stale"',
+ vcard: vcard('conditional', 'Rejected Update'),
+ })).rejects.toMatchObject({ operation: 'update', status: 412 });
+
+ fixture.queueWrite('PUT', { status: 423, rawBody: 'locked ' });
+ await expect(putCardResource({
+ ...credentials,
+ url: book.url,
+ href: fixture.href('locked.vcf'),
+ vcard: vcard('locked', 'Locked Create'),
+ })).rejects.toMatchObject({ operation: 'create', status: 423 });
+
+ const updatedCard = vcard('conditional', 'Conditional Update');
+ const updated = await putCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ etag: created.etag,
+ vcard: updatedCard,
+ });
+ await expect(fetchCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ })).resolves.toEqual({ href, etag: updated.etag, vcard: updatedCard });
+
+ await expect(deleteCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ etag: updated.etag,
+ })).resolves.toEqual({ href });
+ await expect(deleteCardResource({
+ ...credentials,
+ url: book.url,
+ href,
+ etag: updated.etag,
+ })).resolves.toEqual({ href });
+
+ const resourceRequests = fixture.requests.filter(request => (
+ request.method === 'GET' || request.method === 'PUT' || request.method === 'DELETE'
+ ));
+ expect(resourceRequests.map(request => ({
+ method: request.method,
+ accept: request.accept,
+ contentType: request.contentType,
+ ifMatch: request.ifMatch,
+ ifNoneMatch: request.ifNoneMatch,
+ }))).toEqual([
+ {
+ method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8',
+ ifMatch: undefined, ifNoneMatch: '*',
+ },
+ {
+ method: 'GET', accept: 'text/vcard', contentType: undefined,
+ ifMatch: undefined, ifNoneMatch: undefined,
+ },
+ {
+ method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8',
+ ifMatch: '"stale"', ifNoneMatch: undefined,
+ },
+ {
+ method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8',
+ ifMatch: undefined, ifNoneMatch: '*',
+ },
+ {
+ method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8',
+ ifMatch: created.etag, ifNoneMatch: undefined,
+ },
+ {
+ method: 'GET', accept: 'text/vcard', contentType: undefined,
+ ifMatch: undefined, ifNoneMatch: undefined,
+ },
+ {
+ method: 'DELETE', accept: '*/*', contentType: undefined,
+ ifMatch: updated.etag, ifNoneMatch: undefined,
+ },
+ {
+ method: 'DELETE', accept: '*/*', contentType: undefined,
+ ifMatch: updated.etag, ifNoneMatch: undefined,
+ },
+ ]);
+ });
+
+ it('rejects a resource redirect before the sibling endpoint receives the write', async () => {
+ fixture.reset();
+ const sourceHref = fixture.href('redirect-source.vcf');
+ const sourcePath = new URL(sourceHref).pathname;
+ const siblingPath = '/addressbooks/fixture-user/sibling/redirect-target.vcf';
+ const siblingHref = new URL(siblingPath, fixture.serverUrl).href;
+ fixture.queueRedirect('PUT', sourcePath, siblingPath, 307);
+
+ try {
+ await expect(putCardResource({
+ ...credentials,
+ url: book.url,
+ href: sourceHref,
+ vcard: vcard('redirect-source', 'Redirect Source'),
+ })).rejects.toMatchObject({
+ code: 'ERR_DAV_HREF_SCOPE',
+ operation: 'create',
+ });
+
+ expect(fixture.requests.filter(request => request.path === sourcePath)).toHaveLength(1);
+ expect(fixture.requests.filter(request => request.path === siblingPath)).toHaveLength(0);
+ } finally {
+ fixture.deleteContact(siblingHref);
+ }
+ });
+});
+
+const { databaseUrl, connectionStringFor } = postgresTestContext(
+ 'CardDAV production integration tests',
+);
+
+const { Client } = pg;
+const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
+const databaseName = `carddav_sync_e2e_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
+const encryptionKey = '11'.repeat(32);
+const productionEnvironment = productionDatabaseEnvironment(encryptionKey);
+let adminClient;
+let databaseClient;
+let productionDb;
+let carddavSync;
+let carddavContactService;
+let carddavConflictService;
+let carddavConflictsRouter;
+let encrypt;
+
+async function applyMigrations(client) {
+ await applyTestMigrations(client, { migrationsDirectory });
+}
+
+async function projectionState(userId, { timestamps = false } = {}) {
+ const { rows: books } = await databaseClient.query(`
+ SELECT id, source, external_url, sync_token, remote_sync_token,
+ remote_sync_capability, remote_sync_revision::text,
+ remote_projection_fingerprint, created_at, updated_at
+ FROM address_books
+ WHERE user_id = $1
+ ORDER BY source, external_url NULLS FIRST, id
+ `, [userId]);
+ const { rows: contacts } = await databaseClient.query(`
+ SELECT id, address_book_id, user_id, uid, vcard, etag, display_name,
+ first_name, last_name, primary_email, emails, phones, organization,
+ notes, photo_data, created_at, updated_at
+ FROM contacts
+ WHERE user_id = $1
+ ORDER BY address_book_id, uid
+ `, [userId]);
+ const { rows: ledger } = await databaseClient.query(`
+ SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, o.primary_email,
+ o.local_contact_id,
+ o.created_at, o.updated_at
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ ORDER BY o.address_book_id, o.href
+ `, [userId]);
+ if (timestamps) return { books, contacts, ledger };
+ const strip = rows => rows.map(row => {
+ const stable = { ...row };
+ delete stable.created_at;
+ delete stable.updated_at;
+ return stable;
+ });
+ return { books: strip(books), contacts: strip(contacts), ledger: strip(ledger) };
+}
+
+async function integrationState(userId, { timestamps = false } = {}) {
+ const { rows } = await databaseClient.query(`
+ SELECT id, provider, config, updated_at
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [userId]);
+ if (timestamps) return rows;
+ return rows.map(row => {
+ const stable = { ...row };
+ delete stable.updated_at;
+ return stable;
+ });
+}
+
+async function failureBoundaryState(userId) {
+ const projection = await projectionState(userId, { timestamps: true });
+ const { rows: mappings } = await databaseClient.query(`
+ SELECT mapping.address_book_id, mapping.href, mapping.remote_etag,
+ mapping.vcard, mapping.primary_email, mapping.local_contact_id,
+ mapping.mapping_status, mapping.vcard_version,
+ mapping.remote_semantic_hash, mapping.local_contact_hash,
+ mapping.mapping_revision::text, mapping.pending_operation,
+ mapping.pending_vcard, mapping.pending_local_hash,
+ mapping.pending_remote_semantic_hash, mapping.pending_started_at,
+ mapping.created_at, mapping.updated_at
+ FROM carddav_remote_objects mapping
+ JOIN address_books book ON book.id = mapping.address_book_id
+ WHERE book.user_id = $1
+ ORDER BY mapping.address_book_id, mapping.href
+ `, [userId]);
+ const { rows: conflicts } = await databaseClient.query(`
+ SELECT id, address_book_id, href, user_id, base_local_hash, remote_etag,
+ local_vcard, remote_vcard, local_tombstone, remote_tombstone,
+ status, resolution, resolved_by, resolved_at, created_at, updated_at
+ FROM carddav_conflicts
+ WHERE user_id = $1
+ ORDER BY address_book_id, href
+ `, [userId]);
+ return {
+ projection,
+ mappings,
+ conflicts,
+ integrations: await integrationState(userId, { timestamps: true }),
+ };
+}
+
+async function seedConnectedUser(fixture, overrides = {}) {
+ const userId = randomUUID();
+ const connectionGeneration = overrides.connectionGeneration || randomUUID();
+ const password = overrides.password || 'fixture-password';
+ const encryptedPassword = encrypt(password);
+ await databaseClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-e2e-${userId}`],
+ );
+ const config = {
+ serverUrl: fixture.serverUrl,
+ username: overrides.username || 'fixture-user',
+ password: encryptedPassword,
+ intervalMin: 60,
+ connectionGeneration,
+ lastError: 'seeded-error',
+ bookCount: 0,
+ contactCount: 0,
+ };
+ await databaseClient.query(`
+ INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)
+ `, [userId, JSON.stringify(config)]);
+ return { userId, config, connectionGeneration, encryptedPassword, password };
+}
+
+function remoteVcard(uid, name, email = `${uid}@example.test`) {
+ return [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${uid}`,
+ `FN:${name}`,
+ `EMAIL:${email}`,
+ 'END:VCARD',
+ ].join('\n');
+}
+
+function remotePhotoVcard(uid, name, email, photoLine) {
+ return [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${uid}`,
+ `FN:${name}`,
+ `EMAIL:${email}`,
+ photoLine,
+ 'END:VCARD',
+ ].join('\n');
+}
+
+function expectedLocalContact(href, vcard, bookId, userId, id) {
+ const parsed = parseVCard(vcard);
+ const uid = createHash('sha256').update(href).digest('hex');
+ const localVcard = generateVCard({ ...parsed, uid });
+ return {
+ id,
+ address_book_id: bookId,
+ user_id: userId,
+ uid,
+ vcard: localVcard,
+ etag: createHash('md5').update(localVcard).digest('hex'),
+ display_name: parsed.displayName,
+ first_name: parsed.firstName,
+ last_name: parsed.lastName,
+ primary_email: parsed.emails[0].value,
+ emails: parsed.emails,
+ phones: parsed.phones,
+ organization: parsed.organization,
+ notes: parsed.notes,
+ photo_data: parsed.photoData,
+ };
+}
+
+function deferred() {
+ let resolve;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+
+async function listenOnLocalhost(app) {
+ return new Promise((resolve, reject) => {
+ const server = app.listen(0, '127.0.0.1', () => resolve(server));
+ server.once('error', reject);
+ });
+}
+
+async function closeServer(server) {
+ server.closeAllConnections();
+ await new Promise((resolve, reject) => server.close(error => (
+ error ? reject(error) : resolve()
+ )));
+}
+
+async function listenConflictApi() {
+ const app = express();
+ app.use(express.json());
+ app.use((request, response, next) => {
+ request.session = { userId: request.get('X-Test-User-Id') };
+ next();
+ });
+ app.use('/conflicts', carddavConflictsRouter);
+ return listenOnLocalhost(app);
+}
+
+async function seedSingleRemoteContact(fixture, userId, {
+ uid = 'seeded',
+ name = 'Seeded Contact',
+ etag = '"seeded-1"',
+ token = 'seeded-token',
+} = {}) {
+ const href = fixture.href(`${uid}.vcf`);
+ const card = remoteVcard(uid, name);
+ fixture.putContact(href, etag, card);
+ fixture.queueSync('', { events: [{ href, etag }], nextToken: token });
+ const result = await carddavSync.syncUser(userId);
+ expect(result).toMatchObject({ ok: true, bookCount: 1, contactCount: 1 });
+ return { href, card, etag, token };
+}
+
+async function seedMappedExplicitContact(fixture, userId) {
+ const uid = randomUUID();
+ const contact = {
+ uid,
+ displayName: 'Retry After Before',
+ firstName: 'Retry',
+ lastName: 'After Before',
+ emails: [{ value: 'retry-after@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ };
+ const card = generateVCard(contact);
+ const href = fixture.href(`${uid}.vcf`);
+ const remoteEtag = '"retry-after-1"';
+ const { rows: [localBook] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Retry After Local')
+ RETURNING id
+ `, [userId]);
+ const { rows: [remoteBook] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability
+ ) VALUES ($1, 'Retry After Remote', 'carddav', $2, 'allowed', 'allowed', 'allowed')
+ RETURNING id
+ `, [userId, fixture.href('')]);
+ const { rows: [row] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, additional_fields, is_auto
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '[]'::jsonb,
+ '[]'::jsonb, false
+ )
+ RETURNING id
+ `, [
+ localBook.id,
+ userId,
+ uid,
+ card,
+ createHash('md5').update(card).digest('hex'),
+ contact.displayName,
+ contact.firstName,
+ contact.lastName,
+ contact.emails[0].value,
+ JSON.stringify(contact.emails),
+ ]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, vcard_version,
+ remote_semantic_hash, local_contact_hash, last_synced_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, 'synced', '3.0', $7, $8, NOW())
+ `, [
+ remoteBook.id,
+ href,
+ remoteEtag,
+ card,
+ contact.emails[0].value,
+ row.id,
+ semanticVCardHash(parseVCardDocument(card)),
+ localContactHash(contact),
+ ]);
+ fixture.putContact(href, remoteEtag, card);
+ return { ...row, uid, href };
+}
+
+async function seedUnmappedExplicitContact(userId, {
+ uid = randomUUID(),
+ displayName = 'Unmapped Explicit',
+} = {}) {
+ const contact = {
+ uid,
+ displayName,
+ emails: [{ value: `${uid}@example.test`, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ };
+ const card = generateVCard(contact);
+ const { rows: [book] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Unmapped Explicit Local') RETURNING id
+ `, [userId]);
+ const { rows: [row] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, additional_fields, is_auto
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, '[]'::jsonb, false
+ )
+ RETURNING id
+ `, [
+ book.id,
+ userId,
+ uid,
+ card,
+ createHash('md5').update(card).digest('hex'),
+ displayName,
+ contact.emails[0].value,
+ JSON.stringify(contact.emails),
+ ]);
+ return { ...row, uid, card };
+}
+
+async function seedResolutionConflict(fixture, userId, {
+ uid,
+ remoteCard,
+ remoteEtag = `"${uid}-2"`,
+ remoteTombstone = false,
+}) {
+ const initial = await seedSingleRemoteContact(fixture, userId, {
+ uid,
+ name: `${uid} Initial`,
+ token: `${uid}-token`,
+ });
+ const { rows: [mapping] } = await databaseClient.query(`
+ SELECT mapping.address_book_id, mapping.href, mapping.local_contact_id,
+ mapping.local_contact_hash, mapping.mapping_revision::text,
+ contact.vcard AS local_vcard, contact.display_name AS local_display_name
+ FROM carddav_remote_objects mapping
+ JOIN contacts contact ON contact.id = mapping.local_contact_id
+ WHERE mapping.href = $1 AND contact.user_id = $2
+ `, [initial.href, userId]);
+ const { rows: [conflictedMapping] } = await databaseClient.query(`
+ UPDATE carddav_remote_objects
+ SET mapping_status = 'conflict', mapping_revision = mapping_revision + 1,
+ updated_at = NOW()
+ WHERE address_book_id = $1 AND href = $2
+ RETURNING mapping_revision::text
+ `, [mapping.address_book_id, mapping.href]);
+ if (remoteTombstone) fixture.deleteContact(initial.href);
+ else fixture.putContact(initial.href, remoteEtag, remoteCard);
+ const { rows: [conflict] } = await databaseClient.query(`
+ INSERT INTO carddav_conflicts (
+ address_book_id, href, user_id, base_local_hash, remote_etag,
+ local_vcard, remote_vcard, local_tombstone, remote_tombstone
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,false,$8)
+ RETURNING id
+ `, [
+ mapping.address_book_id,
+ mapping.href,
+ userId,
+ mapping.local_contact_hash,
+ remoteTombstone ? null : remoteEtag,
+ mapping.local_vcard,
+ remoteTombstone ? null : remoteCard,
+ remoteTombstone,
+ ]);
+ return {
+ ...mapping,
+ mappingRevision: conflictedMapping.mapping_revision,
+ conflictId: conflict.id,
+ initial,
+ };
+}
+
+async function seedTwoRemoteBooks(fixture, userId) {
+ const bookAPath = '/addressbooks/fixture-user/contacts-a/';
+ const bookBPath = '/addressbooks/fixture-user/contacts-b/';
+ const bookAUrl = new URL(bookAPath, fixture.serverUrl).href;
+ const bookBUrl = new URL(bookBPath, fixture.serverUrl).href;
+ const hrefA = new URL('person-a.vcf', bookAUrl).href;
+ const hrefB = new URL('person-b.vcf', bookBUrl).href;
+ const cardA = remoteVcard('person-a', 'Person A');
+ const cardB = remoteVcard('person-b', 'Person B');
+ fixture.queueDiscovery({ books: [
+ { href: bookAPath, displayName: 'Contacts A' },
+ { href: bookBPath, displayName: 'Contacts B' },
+ ] });
+ fixture.putContact(hrefA, '"person-a-1"', cardA);
+ fixture.putContact(hrefB, '"person-b-1"', cardB);
+ fixture.queueSync('', {
+ events: [{ href: hrefA, etag: '"person-a-1"' }],
+ nextToken: 'two-book-a-1',
+ });
+ fixture.queueSync('', {
+ events: [{ href: hrefB, etag: '"person-b-1"' }],
+ nextToken: 'two-book-b-1',
+ });
+ expect(await carddavSync.syncUser(userId)).toMatchObject({
+ ok: true, bookCount: 2, contactCount: 2,
+ });
+ const state = await projectionState(userId);
+ return {
+ bookAPath,
+ bookBPath,
+ bookAUrl,
+ bookBUrl,
+ hrefA,
+ hrefB,
+ cardA,
+ cardB,
+ bookA: state.books.find(book => book.external_url === bookAUrl),
+ bookB: state.books.find(book => book.external_url === bookBUrl),
+ };
+}
+
+describe('production CardDAV HTTP to PostgreSQL 16', () => {
+ beforeAll(async () => {
+ adminClient = new Client({ connectionString: databaseUrl });
+ await adminClient.connect();
+ await createTestDatabase(adminClient, databaseName);
+ const connectionString = connectionStringFor(databaseName);
+ databaseClient = new Client({ connectionString });
+ await databaseClient.connect();
+ await assertMinimumPostgresVersion(databaseClient);
+ await applyMigrations(databaseClient);
+ await databaseClient.query(`
+ INSERT INTO system_settings (key, value) VALUES ('allow_private_hosts', 'true')
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ `);
+ productionEnvironment.configure(connectionString);
+ productionDb = await import('./db.js');
+ productionEnvironment.configurePool(productionDb.pool);
+ carddavSync = await import('./carddavSync.js');
+ carddavContactService = await import('./carddavContactService.js');
+ carddavConflictService = await import('./carddavConflictService.js');
+ ({ default: carddavConflictsRouter } = await import('../routes/carddavConflicts.js'));
+ ({ encrypt } = await import('./encryption.js'));
+ }, 120_000);
+
+ afterAll(async () => {
+ await productionDb?.pool.end();
+ await databaseClient?.end();
+ if (adminClient) {
+ await dropTestDatabase(adminClient, databaseName);
+ await adminClient.end();
+ }
+ productionEnvironment.restore();
+ }, 120_000);
+
+ it('schedules a valid Retry-After after 429 without replaying or changing confirmed state', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedMappedExplicitContact(fixture, seeded.userId);
+ const before = await failureBoundaryState(seeded.userId);
+ fixture.reset();
+ fixture.queueWrite('PUT', {
+ status: 429,
+ headers: { 'Retry-After': '120' },
+ });
+
+ const startedAt = Date.now();
+ const error = await carddavContactService.updateContact(
+ seeded.userId,
+ contact.id,
+ { displayName: 'Retry After Attempted' },
+ ).catch(value => value);
+ const finishedAt = Date.now();
+ const afterThrottle = await failureBoundaryState(seeded.userId);
+ const retryAfterAt = afterThrottle.integrations[0].config.retryAfterAt;
+
+ expect.soft(error).toMatchObject({
+ name: 'CardDavError',
+ operation: 'update',
+ status: 429,
+ retryAfterAt,
+ });
+ expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 120_000);
+ expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 120_000);
+ expect.soft(afterThrottle.projection).toEqual(before.projection);
+ expect.soft(afterThrottle.mappings).toEqual(before.mappings);
+ expect.soft(afterThrottle.conflicts).toEqual(before.conflicts);
+ expect.soft(afterThrottle.integrations).toEqual([{
+ ...before.integrations[0],
+ config: { ...before.integrations[0].config, retryAfterAt },
+ updated_at: expect.any(Date),
+ }]);
+ expect.soft(afterThrottle.integrations[0].updated_at.getTime())
+ .toBeGreaterThanOrEqual(before.integrations[0].updated_at.getTime());
+ expect.soft(fixture.requests.map(request => request.method)).toEqual(['PUT']);
+
+ const immediateMutation = await carddavContactService.updateContact(
+ seeded.userId,
+ contact.id,
+ { displayName: 'Retry After Attempted Again' },
+ ).catch(value => value);
+ const immediateSync = await carddavSync.syncUser(seeded.userId);
+
+ expect.soft(immediateMutation).toMatchObject({
+ name: 'CardDavError',
+ operation: 'update',
+ status: 429,
+ retryAfterAt,
+ });
+ expect.soft(immediateSync).toMatchObject({
+ ok: false,
+ retryAfterAt,
+ });
+ expect.soft(fixture.requests.map(request => request.method)).toEqual(['PUT']);
+ expect.soft(await failureBoundaryState(seeded.userId)).toEqual(afterThrottle);
+
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text)),
+ updated_at = NOW()
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId, '2000-01-01T00:00:00.000Z']);
+ fixture.reset();
+ fixture.queueSync('', {
+ events: [{ href: contact.href, etag: '"retry-after-1"' }],
+ nextToken: 'retry-after-cleared',
+ });
+
+ expect.soft(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ expect.soft((await integrationState(seeded.userId))[0].config.retryAfterAt).toBeNull();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('retains a fresh export Retry-After instead of clearing it from stale sync config', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const local = await seedUnmappedExplicitContact(seeded.userId, {
+ displayName: 'Fresh Export Throttle',
+ });
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text))
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId, '2000-01-01T00:00:00.000Z']);
+ fixture.queueSync('', { events: [], nextToken: 'fresh-export-throttle' });
+ fixture.queueWrite('PUT', {
+ status: 429,
+ headers: { 'Retry-After': '120' },
+ });
+ const startedAt = Date.now();
+
+ const result = await carddavSync.syncUser(seeded.userId);
+ const finishedAt = Date.now();
+ const [integration] = await integrationState(seeded.userId);
+ const retryAfterAt = integration.config.retryAfterAt;
+
+ expect.soft(result).toMatchObject({ ok: false, retryAfterAt });
+ expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 120_000);
+ expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 120_000);
+ expect.soft(fixture.requests
+ .filter(request => ['GET', 'PUT', 'DELETE'].includes(request.method))
+ .map(request => request.method)).toEqual(['PUT']);
+ const projection = await projectionState(seeded.userId);
+ expect.soft(projection.contacts.find(contact => contact.id === local.id)).toMatchObject({
+ vcard: local.card,
+ display_name: 'Fresh Export Throttle',
+ });
+ expect.soft(projection.ledger).toEqual([]);
+
+ fixture.reset();
+ expect.soft(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: false,
+ retryAfterAt,
+ });
+ expect.soft(fixture.requests).toEqual([]);
+ expect.soft((await integrationState(seeded.userId))[0].config.retryAfterAt)
+ .toBe(retryAfterAt);
+ await fixture.close();
+ }, 120_000);
+
+ it.each([
+ ['missing update', undefined, 'PUT'],
+ ['malformed delete', 'tomorrow', 'DELETE'],
+ ])('keeps confirmed state after 429 with %s Retry-After', async (_label, value, method) => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedMappedExplicitContact(fixture, seeded.userId);
+ const before = await failureBoundaryState(seeded.userId);
+ fixture.reset();
+ fixture.queueWrite(method, {
+ status: 429,
+ ...(value === undefined ? {} : { headers: { 'Retry-After': value } }),
+ });
+
+ const error = await (method === 'PUT'
+ ? carddavContactService.updateContact(
+ seeded.userId,
+ contact.id,
+ { displayName: 'Invalid Retry After Attempted' },
+ )
+ : carddavContactService.deleteContact(seeded.userId, contact.id)
+ ).catch(result => result);
+
+ expect.soft(error).toMatchObject({
+ name: 'CardDavError',
+ operation: method === 'PUT' ? 'update' : 'delete',
+ retryAfterAt: null,
+ status: 429,
+ });
+ expect.soft(await failureBoundaryState(seeded.userId)).toEqual(before);
+ expect.soft(fixture.requests.map(request => request.method)).toEqual([method]);
+ await fixture.close();
+ }, 120_000);
+
+ it('records valid Retry-After for create without materializing or retrying the resource', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const before = await failureBoundaryState(seeded.userId);
+ fixture.queueWrite('PUT', {
+ status: 429,
+ headers: { 'Retry-After': '60' },
+ });
+ const draft = {
+ displayName: 'Throttled Create',
+ firstName: 'Throttled',
+ lastName: 'Create',
+ emails: [{ value: 'throttled-create@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ };
+ const startedAt = Date.now();
+
+ const error = await carddavContactService.createContact(seeded.userId, draft)
+ .catch(result => result);
+ const finishedAt = Date.now();
+ const after = await failureBoundaryState(seeded.userId);
+ const retryAfterAt = after.integrations[0].config.retryAfterAt;
+
+ expect.soft(error).toMatchObject({
+ name: 'CardDavError',
+ operation: 'create',
+ retryAfterAt,
+ status: 429,
+ });
+ expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 60_000);
+ expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 60_000);
+ expect.soft(after.projection).toEqual(before.projection);
+ expect.soft(after.mappings).toEqual(before.mappings);
+ expect.soft(after.conflicts).toEqual(before.conflicts);
+ expect.soft(after.integrations).toEqual([{
+ ...before.integrations[0],
+ config: { ...before.integrations[0].config, retryAfterAt },
+ updated_at: expect.any(Date),
+ }]);
+ expect.soft(fixture.requests.map(request => request.method))
+ .toEqual(['PROPFIND', 'PROPFIND', 'PROPFIND', 'PUT']);
+
+ const immediate = await carddavContactService.createContact(seeded.userId, draft)
+ .catch(result => result);
+ expect.soft(immediate).toMatchObject({
+ name: 'CardDavError',
+ operation: 'create',
+ retryAfterAt,
+ status: 429,
+ });
+ expect.soft(fixture.requests.map(request => request.method))
+ .toEqual(['PROPFIND', 'PROPFIND', 'PROPFIND', 'PUT']);
+ expect.soft(await failureBoundaryState(seeded.userId)).toEqual(after);
+ await fixture.close();
+ }, 120_000);
+
+ it('records discovery Retry-After before create and blocks immediate discovery', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const draft = {
+ displayName: 'Discovery Throttle',
+ emails: [{ value: 'discovery-throttle@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ };
+ fixture.queueDiscovery({
+ status: 429,
+ headers: { 'Retry-After': '120' },
+ });
+ const startedAt = Date.now();
+
+ const error = await carddavContactService.createContact(seeded.userId, draft)
+ .catch(result => result);
+ const finishedAt = Date.now();
+ const retryAfterAt = error.retryAfterAt;
+
+ expect.soft(error).toMatchObject({
+ name: 'CardDavError',
+ operation: null,
+ retryAfterAt,
+ status: 429,
+ });
+ expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 120_000);
+ expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 120_000);
+ expect.soft((await integrationState(seeded.userId))[0].config.retryAfterAt)
+ .toBe(retryAfterAt);
+ expect.soft(fixture.requests.map(request => request.method))
+ .toEqual(['PROPFIND', 'PROPFIND', 'PROPFIND']);
+ expect.soft((await projectionState(seeded.userId)).contacts).toEqual([]);
+
+ fixture.reset();
+ await expect(carddavContactService.createContact(seeded.userId, draft)).rejects.toMatchObject({
+ name: 'CardDavError',
+ operation: 'create',
+ retryAfterAt,
+ status: 429,
+ });
+ expect.soft(fixture.requests).toEqual([]);
+ await fixture.close();
+ }, 120_000);
+
+ it('records canonical-GET Retry-After while retaining initial and recovery-only intent', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedMappedExplicitContact(fixture, seeded.userId);
+ const attempted = { displayName: 'Canonical GET Throttle' };
+ fixture.reset();
+ fixture.queueWrite('GET', {
+ status: 429,
+ headers: { 'Retry-After': '120' },
+ });
+
+ const firstError = await carddavContactService.updateContact(
+ seeded.userId,
+ contact.id,
+ attempted,
+ ).catch(result => result);
+ const afterFirst = await failureBoundaryState(seeded.userId);
+ const firstRetryAfterAt = afterFirst.integrations[0].config.retryAfterAt;
+
+ expect.soft(firstError).toMatchObject({ name: 'CardDavAmbiguousWriteError' });
+ expect.soft(firstError.cause).toMatchObject({
+ name: 'CardDavError', retryAfterAt: firstRetryAfterAt, status: 429,
+ });
+ expect.soft(afterFirst.mappings).toEqual([
+ expect.objectContaining({
+ mapping_status: 'pending_push',
+ pending_operation: 'update',
+ pending_started_at: expect.any(Date),
+ }),
+ ]);
+ expect.soft(fixture.requests.map(request => request.method)).toEqual(['PUT', 'GET']);
+
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text))
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId, '2000-01-01T00:00:00.000Z']);
+ const beforeRecovery = await failureBoundaryState(seeded.userId);
+ fixture.reset();
+ fixture.queueWrite('GET', {
+ status: 429,
+ headers: { 'Retry-After': '240' },
+ });
+
+ const recoveryError = await carddavContactService.updateContact(
+ seeded.userId,
+ contact.id,
+ attempted,
+ ).catch(result => result);
+ const afterRecovery = await failureBoundaryState(seeded.userId);
+ const retryAfterAt = afterRecovery.integrations[0].config.retryAfterAt;
+
+ expect.soft(recoveryError).toMatchObject({ name: 'CardDavAmbiguousWriteError' });
+ expect.soft(recoveryError.cause).toMatchObject({
+ name: 'CardDavError', retryAfterAt, status: 429,
+ });
+ expect.soft(fixture.requests.map(request => request.method)).toEqual(['GET']);
+ expect.soft(afterRecovery.projection).toEqual(beforeRecovery.projection);
+ expect.soft(afterRecovery.mappings).toEqual(beforeRecovery.mappings);
+ expect.soft(afterRecovery.conflicts).toEqual(beforeRecovery.conflicts);
+ expect.soft(Date.parse(retryAfterAt)).toBeGreaterThan(Date.parse(firstRetryAfterAt));
+
+ fixture.reset();
+ await expect(carddavContactService.updateContact(
+ seeded.userId,
+ contact.id,
+ attempted,
+ )).rejects.toMatchObject({ status: 429, retryAfterAt });
+ expect.soft(fixture.requests).toEqual([]);
+ expect.soft((await failureBoundaryState(seeded.userId)).mappings)
+ .toEqual(afterRecovery.mappings);
+ await fixture.close();
+ }, 120_000);
+
+ it('makes no export request when the connection is replaced between apply and export', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const replacementGeneration = randomUUID();
+ const { rows: [localBook] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Generation Export Local') RETURNING id
+ `, [seeded.userId]);
+ const contact = {
+ uid: 'generation-export-local',
+ displayName: 'Generation Export Local',
+ emails: [{ value: 'generation-export@example.test', type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ };
+ const card = generateVCard(contact);
+ const { rows: [localContact] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, is_auto
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, false)
+ RETURNING id
+ `, [
+ localBook.id,
+ seeded.userId,
+ contact.uid,
+ card,
+ createHash('md5').update(card).digest('hex'),
+ contact.displayName,
+ contact.emails[0].value,
+ JSON.stringify(contact.emails),
+ ]);
+ fixture.queueSync('', { events: [], nextToken: 'generation-export-token' });
+ await databaseClient.query(`
+ CREATE FUNCTION replace_generation_before_export() RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ BEGIN
+ UPDATE user_integrations
+ SET config = jsonb_set(
+ config, '{connectionGeneration}', to_jsonb('${replacementGeneration}'::text)
+ )
+ WHERE user_id = NEW.user_id AND provider = 'carddav';
+ RETURN NEW;
+ END
+ $$
+ `);
+ await databaseClient.query(`
+ CREATE TRIGGER replace_generation_before_export
+ AFTER UPDATE OF remote_sync_revision ON address_books
+ FOR EACH ROW WHEN (NEW.source = 'carddav')
+ EXECUTE FUNCTION replace_generation_before_export()
+ `);
+
+ try {
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: false,
+ error: 'CardDAV sync plan is stale',
+ });
+ expect(fixture.requests.filter(request => (
+ request.method === 'GET' || request.method === 'PUT' || request.method === 'DELETE'
+ ))).toEqual([]);
+ const state = await projectionState(seeded.userId);
+ expect(state.ledger).toEqual([]);
+ expect(state.contacts.find(row => row.id === localContact.id)).toMatchObject({
+ vcard: card,
+ display_name: contact.displayName,
+ });
+ expect((await integrationState(seeded.userId))[0].config.connectionGeneration)
+ .toBe(replacementGeneration);
+ } finally {
+ await databaseClient.query('DROP TRIGGER replace_generation_before_export ON address_books');
+ await databaseClient.query('DROP FUNCTION replace_generation_before_export()');
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('syncUser recovers once from valid-sync-token and commits one full reconciliation', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const unrelatedBook = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Unrelated') RETURNING id, sync_token
+ `, [seeded.userId]);
+ const retainedHref = fixture.href('retained.vcf');
+ const changedHref = fixture.href('changed.vcf');
+ const removedHref = fixture.href('removed.vcf');
+ const retainedVcard = remoteVcard('retained', 'Retained Contact');
+ const changedBeforeVcard = remoteVcard('changed', 'Changed Before');
+ const removedVcard = remoteVcard('removed', 'Removed Contact');
+ fixture.putContact(retainedHref, '"retained-1"', retainedVcard);
+ fixture.putContact(changedHref, '"changed-1"', changedBeforeVcard);
+ fixture.putContact(removedHref, '"removed-1"', removedVcard);
+ fixture.queueSync('', {
+ events: [
+ { href: retainedHref, etag: '"retained-1"' },
+ { href: changedHref, etag: '"changed-1"' },
+ { href: removedHref, etag: '"removed-1"' },
+ ],
+ nextToken: 'seed-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 3,
+ });
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'stored-invalid-token', remote_sync_revision = 7
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ const before = await projectionState(seeded.userId);
+ const remoteBook = before.books.find(row => row.source === 'carddav');
+ const retainedContact = before.contacts.find(row => row.primary_email === 'retained@example.test');
+ const changedContact = before.contacts.find(row => row.primary_email === 'changed@example.test');
+ const changedVcard = remoteVcard('changed', 'Changed After');
+ const addedVcard = remoteVcard('added', 'Added Contact');
+ const addedHref = fixture.href('added.vcf');
+ fixture.putContact(changedHref, '"changed-2"', changedVcard);
+ fixture.putContact(addedHref, '"added-1"', addedVcard);
+ fixture.deleteContact(removedHref);
+ fixture.reset();
+ fixture.queueSync('stored-invalid-token', {
+ status: 403,
+ rawBody: ' ',
+ });
+ fixture.queueSync('', {
+ events: [
+ { href: retainedHref, etag: '"retained-1"' },
+ { href: changedHref, etag: '"changed-2"' },
+ { href: addedHref, etag: '"added-1"' },
+ ],
+ nextToken: 'recovered-token',
+ });
+
+ const result = await carddavSync.syncUser(seeded.userId);
+
+ expect(result).toEqual({
+ ok: true,
+ bookCount: 1,
+ contactCount: 3,
+ remote: 3,
+ fetched: 3,
+ updated: 2,
+ removed: 1,
+ fallback: 1,
+ exportFailures: [],
+ });
+ const after = await projectionState(seeded.userId);
+ const afterBook = after.books.find(row => row.id === remoteBook.id);
+ expect(afterBook).toEqual({
+ ...remoteBook,
+ remote_sync_token: 'recovered-token',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '8',
+ sync_token: expect.any(String),
+ remote_projection_fingerprint: createHash('sha256').update(JSON.stringify([[
+ unrelatedBook.rows[0].id,
+ unrelatedBook.rows[0].sync_token,
+ ]])).digest('hex'),
+ });
+ expect(afterBook.sync_token).not.toBe(remoteBook.sync_token);
+ expect(after.books.find(row => row.id === unrelatedBook.rows[0].id)).toEqual({
+ id: unrelatedBook.rows[0].id,
+ source: 'local',
+ external_url: null,
+ sync_token: unrelatedBook.rows[0].sync_token,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ remote_projection_fingerprint: null,
+ });
+ const addedContact = after.contacts.find(row => row.primary_email === 'added@example.test');
+ expect(after.contacts).toEqual([
+ expectedLocalContact(retainedHref, retainedVcard, remoteBook.id, seeded.userId, retainedContact.id),
+ expectedLocalContact(changedHref, changedVcard, remoteBook.id, seeded.userId, changedContact.id),
+ expectedLocalContact(addedHref, addedVcard, remoteBook.id, seeded.userId, addedContact.id),
+ ].sort((a, b) => a.uid.localeCompare(b.uid)));
+ expect(after.ledger).toEqual([
+ [addedHref, '"added-1"', addedVcard, 'added@example.test', addedContact.id],
+ [changedHref, '"changed-2"', changedVcard, 'changed@example.test', changedContact.id],
+ [retainedHref, '"retained-1"', retainedVcard, 'retained@example.test', retainedContact.id],
+ ].map(([href, etag, card, email, contactId]) => ({
+ address_book_id: remoteBook.id,
+ href,
+ remote_etag: etag,
+ vcard: card,
+ primary_email: email,
+ local_contact_id: contactId,
+ })));
+ const [integration] = await integrationState(seeded.userId);
+ expect(integration.provider).toBe('carddav');
+ expect(integration.config).toEqual({
+ ...seeded.config,
+ lastError: null,
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 3,
+ exportFailures: [],
+ });
+ expect(integration.config.password).toBe(seeded.encryptedPassword);
+ expect(integration.config.password).toMatch(/^enc:v1:/);
+ expect(integration.config.password).not.toBe(seeded.password);
+ expect(Number.isNaN(Date.parse(integration.config.lastSyncAt))).toBe(false);
+ expect(fixture.requests.map(request => ({
+ method: request.method,
+ path: request.path,
+ depth: request.depth,
+ token: request.body.match(/([\s\S]*?)<\/sync-token>/)?.[1],
+ }))).toEqual([
+ { method: 'PROPFIND', path: '/', depth: '0', token: undefined },
+ { method: 'PROPFIND', path: '/principals/fixture-user/', depth: '0', token: undefined },
+ { method: 'PROPFIND', path: '/addressbooks/fixture-user/', depth: '1', token: undefined },
+ { method: 'REPORT', path: '/addressbooks/fixture-user/contacts/', depth: '0', token: 'stored-invalid-token' },
+ { method: 'REPORT', path: '/addressbooks/fixture-user/contacts/', depth: '0', token: '' },
+ { method: 'REPORT', path: '/addressbooks/fixture-user/contacts/', depth: '0', token: undefined },
+ ]);
+ expect(fixture.requests.every(request => (
+ request.authorization === 'Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ='
+ ))).toBe(true);
+ expect(fixture.counters).toMatchObject({
+ requests: 6,
+ propfind: 3,
+ sync: 2,
+ multiget: 1,
+ syncTokens: ['stored-invalid-token', ''],
+ multigetSizes: [3],
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('valid-sync-token recovery is bounded when the empty-token recovery fails again', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedSingleRemoteContact(fixture, seeded.userId);
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'invalid-twice', remote_sync_revision = 4
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ const before = await projectionState(seeded.userId, { timestamps: true });
+ fixture.reset();
+ fixture.queueSync('invalid-twice', { status: 403, precondition: 'valid-sync-token' });
+ fixture.queueSync('', { status: 403, precondition: 'valid-sync-token' });
+
+ const failed = await carddavSync.syncUser(seeded.userId);
+
+ expect(failed).toEqual({
+ ok: false,
+ error: 'CardDAV request failed (403 Forbidden)',
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ });
+ expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before);
+ const [failedIntegration] = await integrationState(seeded.userId);
+ expect(failedIntegration.config).toEqual({
+ ...seeded.config,
+ lastError: 'CardDAV request failed (403 Forbidden)',
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(fixture.counters).toMatchObject({
+ requests: 5,
+ propfind: 3,
+ sync: 2,
+ multiget: 0,
+ syncTokens: ['invalid-twice', ''],
+ });
+
+ fixture.reset();
+ fixture.queueSync('invalid-twice', {
+ events: [{ href: contact.href, etag: contact.etag }],
+ nextToken: 'guard-released-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ expect(fixture.counters.syncTokens).toEqual(['invalid-twice']);
+ await fixture.close();
+ }, 120_000);
+
+ it('malformed empty-token recovery page leaves projection exact and releases the guard', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedSingleRemoteContact(fixture, seeded.userId);
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'invalid-malformed', remote_sync_revision = 9
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ const before = await projectionState(seeded.userId, { timestamps: true });
+ fixture.reset();
+ fixture.queueSync('invalid-malformed', { status: 403, precondition: 'valid-sync-token' });
+ fixture.queueSync('', {
+ status: 207,
+ rawBody: ' ',
+ });
+
+ const failed = await carddavSync.syncUser(seeded.userId);
+
+ expect(failed.ok).toBe(false);
+ expect(failed.error).toMatch(/multistatus/i);
+ expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before);
+ const [failedIntegration] = await integrationState(seeded.userId);
+ expect(failedIntegration.config).toEqual({
+ ...seeded.config,
+ lastError: failed.error,
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(fixture.counters).toMatchObject({
+ requests: 5,
+ propfind: 3,
+ sync: 2,
+ multiget: 0,
+ syncTokens: ['invalid-malformed', ''],
+ });
+
+ fixture.reset();
+ fixture.queueSync('invalid-malformed', {
+ events: [{ href: contact.href, etag: contact.etag }],
+ nextToken: 'malformed-guard-released',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ await fixture.close();
+ }, 120_000);
+
+ it('late multiget recovery failure rolls back the complete production plan', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ await seedSingleRemoteContact(fixture, seeded.userId);
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'invalid-late-batch', remote_sync_revision = 12
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ const before = await projectionState(seeded.userId, { timestamps: true });
+ const events = [];
+ for (let index = 0; index < 101; index++) {
+ const uid = `batch-${String(index).padStart(3, '0')}`;
+ const href = fixture.href(`${uid}.vcf`);
+ const card = remoteVcard(uid, `Batch ${index}`);
+ const etag = `"${uid}-1"`;
+ fixture.putContact(href, etag, card);
+ events.push({ href, etag });
+ }
+ fixture.reset();
+ fixture.queueSync('invalid-late-batch', { status: 403, precondition: 'valid-sync-token' });
+ fixture.queueSync('', { events, nextToken: 'must-not-commit' });
+ fixture.queueMultiget({});
+ fixture.queueMultiget({ status: 500, rawBody: 'late batch failed ' });
+
+ const failed = await carddavSync.syncUser(seeded.userId);
+
+ expect(failed).toEqual({
+ ok: false,
+ error: 'CardDAV request failed (500 Internal Server Error)',
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ });
+ expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before);
+ const [failedIntegration] = await integrationState(seeded.userId);
+ expect(failedIntegration.config).toEqual({
+ ...seeded.config,
+ lastError: failed.error,
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(fixture.counters).toMatchObject({
+ requests: 7,
+ propfind: 3,
+ sync: 2,
+ multiget: 2,
+ syncTokens: ['invalid-late-batch', ''],
+ multigetSizes: [100, 1],
+ });
+ fixture.reset();
+ fixture.queueSync('invalid-late-batch', { status: 403, precondition: 'valid-sync-token' });
+ fixture.queueSync('', {
+ events: [{ href: before.ledger[0].href, etag: before.ledger[0].remote_etag }],
+ nextToken: 'late-batch-guard-released',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ await fixture.close();
+ }, 120_000);
+
+ it('rollback from a database trigger preserves projection and writes only guarded failure status', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedSingleRemoteContact(fixture, seeded.userId);
+ const before = await projectionState(seeded.userId, { timestamps: true });
+ const changedCard = remoteVcard('seeded', 'Trigger Changed');
+ fixture.putContact(contact.href, '"seeded-2"', changedCard);
+ fixture.reset();
+ fixture.queueSync(contact.token, {
+ events: [{ href: contact.href, etag: '"seeded-2"' }],
+ nextToken: 'trigger-must-not-commit',
+ });
+ await databaseClient.query(`
+ CREATE FUNCTION task24_force_book_rollback() RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ BEGIN
+ RAISE EXCEPTION 'forced sync rollback';
+ END
+ $$
+ `);
+ await databaseClient.query(`
+ CREATE TRIGGER task24_force_book_rollback
+ BEFORE UPDATE ON address_books
+ FOR EACH ROW EXECUTE FUNCTION task24_force_book_rollback()
+ `);
+
+ const failed = await carddavSync.syncUser(seeded.userId);
+ await databaseClient.query('DROP TRIGGER task24_force_book_rollback ON address_books');
+ await databaseClient.query('DROP FUNCTION task24_force_book_rollback()');
+
+ expect(failed).toEqual({
+ ok: false,
+ error: 'forced sync rollback',
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ });
+ expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before);
+ const [failedIntegration] = await integrationState(seeded.userId);
+ expect(failedIntegration.config).toEqual({
+ ...seeded.config,
+ lastError: 'forced sync rollback',
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(fixture.counters).toMatchObject({
+ requests: 5,
+ propfind: 3,
+ sync: 1,
+ multiget: 1,
+ syncTokens: [contact.token],
+ multigetSizes: [1],
+ });
+
+ fixture.reset();
+ fixture.queueSync(contact.token, {
+ events: [{ href: contact.href, etag: '"seeded-2"' }],
+ nextToken: 'trigger-released-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ await fixture.close();
+ }, 120_000);
+
+ it('stale recovery CAS retries once and never regresses concurrent token or revision state', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedSingleRemoteContact(fixture, seeded.userId);
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'stored-invalid-cas', remote_sync_revision = 7
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ const before = await projectionState(seeded.userId);
+ const changedCard = remoteVcard('seeded', 'Stale Changed');
+ fixture.putContact(contact.href, '"stale-2"', changedCard);
+ const firstBarrier = deferred();
+ const firstReached = deferred();
+ const secondBarrier = deferred();
+ const secondReached = deferred();
+ fixture.reset();
+ fixture.queueSync('stored-invalid-cas', {
+ status: 403,
+ precondition: 'valid-sync-token',
+ });
+ fixture.queueSync('', {
+ events: [{ href: contact.href, etag: '"stale-2"' }],
+ nextToken: 'stale-plan-one',
+ waitFor: firstBarrier.promise,
+ reached: firstReached.resolve,
+ });
+ fixture.queueSync('concurrent-token-one', {
+ events: [{ href: contact.href, etag: '"stale-2"' }],
+ nextToken: 'stale-plan-two',
+ waitFor: secondBarrier.promise,
+ reached: secondReached.resolve,
+ });
+
+ const pending = carddavSync.syncUser(seeded.userId);
+ await firstReached.promise;
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'concurrent-token-one', remote_sync_revision = 8
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ firstBarrier.resolve();
+ await secondReached.promise;
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = 'concurrent-token-two', remote_sync_revision = 9
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+ secondBarrier.resolve();
+
+ const failed = await pending;
+
+ expect(failed).toEqual({
+ ok: false,
+ error: 'CardDAV sync plan is stale',
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ });
+ const after = await projectionState(seeded.userId);
+ expect(after).toEqual({
+ ...before,
+ books: before.books.map(book => book.source === 'carddav' ? {
+ ...book,
+ remote_sync_token: 'concurrent-token-two',
+ remote_sync_revision: '9',
+ } : book),
+ });
+ expect(after.contacts).toHaveLength(1);
+ expect(after.contacts[0].display_name).toBe('Seeded Contact');
+ expect(after.ledger).toHaveLength(1);
+ expect(after.ledger[0].remote_etag).toBe(contact.etag);
+ const [failedIntegration] = await integrationState(seeded.userId);
+ expect(failedIntegration.config).toEqual({
+ ...seeded.config,
+ lastError: 'CardDAV sync plan is stale',
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(fixture.counters).toMatchObject({
+ requests: 8,
+ propfind: 3,
+ sync: 3,
+ multiget: 2,
+ syncTokens: ['stored-invalid-cas', '', 'concurrent-token-one'],
+ multigetSizes: [1, 1],
+ });
+ fixture.reset();
+ fixture.queueSync('concurrent-token-two', {
+ events: [],
+ nextToken: 'stale-guard-released',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ await fixture.close();
+ }, 120_000);
+
+ it('initial no-change changed removed and snapshot filter paths advance one revision each', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href('lifecycle.vcf');
+ const initialCard = remoteVcard('lifecycle', 'Lifecycle Initial');
+ fixture.putContact(href, '"lifecycle-1"', initialCard);
+ fixture.queueSync('', {
+ events: [{ href, etag: '"lifecycle-1"' }],
+ nextToken: 'lifecycle-1',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toEqual({
+ ok: true, bookCount: 1, contactCount: 1,
+ remote: 1, fetched: 1, updated: 1, removed: 0,
+ fallback: 0, exportFailures: [],
+ });
+ const initial = await projectionState(seeded.userId);
+ const initialBook = initial.books.find(row => row.source === 'carddav');
+ expect(initialBook.remote_sync_revision).toBe('1');
+
+ fixture.reset();
+ fixture.queueSync('lifecycle-1', { events: [], nextToken: 'lifecycle-2' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, fetched: 0, updated: 0, removed: 0,
+ });
+ const unchanged = await projectionState(seeded.userId);
+ expect(unchanged.books.find(row => row.id === initialBook.id)).toMatchObject({
+ remote_sync_token: 'lifecycle-2',
+ remote_sync_revision: '2',
+ sync_token: initialBook.sync_token,
+ });
+ expect(unchanged.contacts).toEqual(initial.contacts);
+ expect(unchanged.ledger).toEqual(initial.ledger);
+ expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(1);
+ expect(fixture.counters.multiget).toBe(0);
+
+ const changedCard = remoteVcard('lifecycle', 'Lifecycle Changed');
+ fixture.putContact(href, '"lifecycle-2"', changedCard);
+ fixture.reset();
+ fixture.queueSync('lifecycle-2', {
+ events: [{ href, etag: '"lifecycle-2"' }],
+ nextToken: 'lifecycle-3',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, fetched: 1, updated: 1, removed: 0,
+ });
+ const changed = await projectionState(seeded.userId);
+ const changedBook = changed.books.find(row => row.id === initialBook.id);
+ expect(changedBook).toMatchObject({
+ remote_sync_token: 'lifecycle-3',
+ remote_sync_revision: '3',
+ });
+ expect(changedBook.sync_token).not.toBe(initialBook.sync_token);
+ expect(changed.contacts[0].display_name).toBe('Lifecycle Changed');
+ expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(1);
+
+ fixture.deleteContact(href);
+ fixture.reset();
+ fixture.queueSync('lifecycle-3', {
+ events: [{ href, status: 404 }],
+ nextToken: 'lifecycle-4',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, fetched: 0, updated: 0, removed: 1,
+ });
+ const removed = await projectionState(seeded.userId);
+ const removedBook = removed.books.find(row => row.id === initialBook.id);
+ expect(removedBook).toMatchObject({
+ remote_sync_token: 'lifecycle-4',
+ remote_sync_revision: '4',
+ });
+ expect(removedBook.sync_token).not.toBe(changedBook.sync_token);
+ expect(removed.contacts).toEqual([]);
+ expect(removed.ledger).toEqual([]);
+ expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(0);
+
+ fixture.reset();
+ fixture.queueSync('lifecycle-4', { status: 405 });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, fetched: 0, updated: 0, removed: 0, fallback: 1,
+ });
+ const snapshot = await projectionState(seeded.userId);
+ expect(snapshot.books.find(row => row.id === initialBook.id)).toEqual({
+ ...removedBook,
+ remote_sync_token: null,
+ remote_sync_capability: 'snapshot',
+ remote_sync_revision: '5',
+ });
+ expect(snapshot.contacts).toEqual([]);
+ expect(snapshot.ledger).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ propfind: 3,
+ sync: 1,
+ multiget: 0,
+ addressbookQuery: 1,
+ snapshotFilters: [1],
+ syncTokens: ['lifecycle-4'],
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('reconciles the mapped-contact pull matrix without disturbing durable conflicts', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const cases = ['additional', 'pending-etag', 'conflict-edit', 'conflict-delete', 'sibling'];
+ const hrefs = Object.fromEntries(cases.map(name => [name, fixture.href(`${name}.vcf`)]));
+ const cards = Object.fromEntries(cases.map(name => [
+ name,
+ remoteVcard(name, `${name} Initial`),
+ ]));
+ for (const name of cases) fixture.putContact(hrefs[name], `"${name}-1"`, cards[name]);
+ fixture.queueSync('', {
+ events: cases.map(name => ({ href: hrefs[name], etag: `"${name}-1"` })),
+ nextToken: 'mapping-matrix-1',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, contactCount: 5,
+ });
+
+ const { rows: initialMappings } = await databaseClient.query(`
+ SELECT o.href, o.address_book_id, o.local_contact_id,
+ o.remote_semantic_hash, o.local_contact_hash
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ ORDER BY o.href
+ `, [seeded.userId]);
+ const initialByHref = new Map(initialMappings.map(mapping => [mapping.href, mapping]));
+ const localNames = {
+ 'pending-etag': 'Pending Local Edit',
+ 'conflict-edit': 'Conflict Local Edit',
+ 'conflict-delete': 'Delete Conflict Local Edit',
+ };
+ for (const [name, displayName] of Object.entries(localNames)) {
+ await databaseClient.query(
+ `UPDATE contacts SET display_name = $1, updated_at = NOW() WHERE id = $2`,
+ [displayName, initialByHref.get(hrefs[name]).local_contact_id],
+ );
+ }
+ await databaseClient.query(`
+ UPDATE carddav_remote_objects
+ SET mapping_status = CASE
+ WHEN href = $1 THEN 'pending_push'
+ WHEN href = ANY($2::text[]) THEN 'conflict'
+ ELSE mapping_status
+ END,
+ mapping_revision = 10
+ WHERE address_book_id = $3
+ `, [
+ hrefs['pending-etag'],
+ [hrefs['conflict-edit'], hrefs['conflict-delete']],
+ initialMappings[0].address_book_id,
+ ]);
+ const conflictIds = {};
+ const conflictLocalSnapshots = {};
+ for (const name of ['conflict-edit', 'conflict-delete']) {
+ const mapping = initialByHref.get(hrefs[name]);
+ const localVCard = remoteVcard(name, localNames[name]);
+ const localTombstone = name === 'conflict-delete';
+ const { rows: [conflict] } = await databaseClient.query(`
+ INSERT INTO carddav_conflicts (
+ address_book_id, href, user_id, base_local_hash, remote_etag,
+ local_vcard, remote_vcard, local_tombstone, remote_tombstone
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false)
+ RETURNING id
+ `, [
+ mapping.address_book_id,
+ mapping.href,
+ seeded.userId,
+ mapping.local_contact_hash,
+ `"${name}-1"`,
+ localVCard,
+ cards[name],
+ localTombstone,
+ ]);
+ conflictIds[name] = conflict.id;
+ conflictLocalSnapshots[name] = { localVCard, localTombstone };
+ }
+
+ const additionalCard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:additional',
+ 'FN:additional Initial',
+ 'EMAIL:additional@example.test',
+ 'item1.URL:https://example.test/profile',
+ 'item1.X-ABLabel:Portfolio',
+ 'END:VCARD',
+ ].join('\n');
+ const conflictEditCard = remoteVcard('conflict-edit', 'Conflict Remote Edit');
+ const siblingCard = remoteVcard('sibling', 'Sibling Remote Edit');
+ fixture.putContact(hrefs.additional, '"additional-2"', additionalCard);
+ fixture.putContact(hrefs['pending-etag'], '"pending-etag-2"', cards['pending-etag']);
+ fixture.putContact(hrefs['conflict-edit'], '"conflict-edit-2"', conflictEditCard);
+ fixture.deleteContact(hrefs['conflict-delete']);
+ fixture.putContact(hrefs.sibling, '"sibling-2"', siblingCard);
+ fixture.reset();
+ fixture.queueSync('mapping-matrix-1', {
+ events: [
+ { href: hrefs.additional, etag: '"additional-2"' },
+ { href: hrefs['pending-etag'], etag: '"pending-etag-2"' },
+ { href: hrefs['conflict-edit'], etag: '"conflict-edit-2"' },
+ { href: hrefs['conflict-delete'], status: 404 },
+ { href: hrefs.sibling, etag: '"sibling-2"' },
+ ],
+ nextToken: 'mapping-matrix-2',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, contactCount: 5,
+ });
+ const { rows: state } = await databaseClient.query(`
+ SELECT o.href, o.remote_etag, o.vcard, o.mapping_status,
+ o.remote_semantic_hash, o.local_contact_hash,
+ o.pending_operation, o.pending_vcard, o.pending_local_hash,
+ o.pending_remote_semantic_hash, o.pending_started_at,
+ o.mapping_revision::text, c.uid, c.display_name, c.first_name,
+ c.last_name, c.emails, c.phones, c.organization, c.notes,
+ c.photo_data, c.additional_fields,
+ conflict.id AS conflict_id, conflict.remote_etag AS conflict_remote_etag,
+ conflict.remote_vcard AS conflict_remote_vcard,
+ conflict.local_vcard AS conflict_local_vcard,
+ conflict.local_tombstone,
+ conflict.remote_tombstone
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ LEFT JOIN carddav_conflicts conflict
+ ON conflict.address_book_id = o.address_book_id
+ AND conflict.href = o.href AND conflict.status = 'unresolved'
+ WHERE c.user_id = $1
+ ORDER BY o.href
+ `, [seeded.userId]);
+ const byHref = new Map(state.map(mapping => [mapping.href, mapping]));
+ const { rows: [bookState] } = await databaseClient.query(`
+ SELECT remote_sync_token FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [seeded.userId]);
+
+ expect(bookState.remote_sync_token).toBe('mapping-matrix-2');
+ expect(byHref.get(hrefs.additional)).toMatchObject({
+ remote_etag: '"additional-2"',
+ vcard: additionalCard,
+ mapping_status: 'synced',
+ remote_semantic_hash: semanticVCardHash(parseVCardDocument(additionalCard)),
+ mapping_revision: '11',
+ display_name: 'additional Initial',
+ additional_fields: [expect.objectContaining({
+ kind: 'url', label: 'Portfolio', value: 'https://example.test/profile',
+ })],
+ conflict_id: null,
+ });
+ const storedContactHash = contact => localContactHash({
+ uid: contact.uid,
+ displayName: contact.display_name,
+ firstName: contact.first_name,
+ lastName: contact.last_name,
+ emails: contact.emails,
+ phones: contact.phones,
+ organization: contact.organization,
+ notes: contact.notes,
+ photoData: contact.photo_data,
+ additionalFields: contact.additional_fields,
+ });
+ expect(byHref.get(hrefs.additional).local_contact_hash).toBe(
+ storedContactHash(byHref.get(hrefs.additional)),
+ );
+ expect(byHref.get(hrefs['pending-etag'])).toMatchObject({
+ remote_etag: '"pending-etag-2"',
+ vcard: cards['pending-etag'],
+ mapping_status: 'pending_push',
+ remote_semantic_hash: initialByHref.get(hrefs['pending-etag']).remote_semantic_hash,
+ local_contact_hash: initialByHref.get(hrefs['pending-etag']).local_contact_hash,
+ mapping_revision: '11',
+ display_name: localNames['pending-etag'],
+ pending_operation: null,
+ pending_vcard: null,
+ pending_local_hash: null,
+ pending_remote_semantic_hash: null,
+ pending_started_at: null,
+ conflict_id: null,
+ });
+ expect(byHref.get(hrefs['conflict-edit'])).toMatchObject({
+ remote_etag: '"conflict-edit-1"',
+ vcard: cards['conflict-edit'],
+ mapping_status: 'conflict',
+ remote_semantic_hash: initialByHref.get(hrefs['conflict-edit']).remote_semantic_hash,
+ local_contact_hash: initialByHref.get(hrefs['conflict-edit']).local_contact_hash,
+ mapping_revision: '11',
+ display_name: localNames['conflict-edit'],
+ conflict_id: conflictIds['conflict-edit'],
+ conflict_local_vcard: conflictLocalSnapshots['conflict-edit'].localVCard,
+ local_tombstone: conflictLocalSnapshots['conflict-edit'].localTombstone,
+ conflict_remote_etag: '"conflict-edit-2"',
+ conflict_remote_vcard: conflictEditCard,
+ remote_tombstone: false,
+ });
+ expect(byHref.get(hrefs['conflict-delete'])).toMatchObject({
+ remote_etag: '"conflict-delete-1"',
+ vcard: cards['conflict-delete'],
+ mapping_status: 'conflict',
+ remote_semantic_hash: initialByHref.get(hrefs['conflict-delete']).remote_semantic_hash,
+ local_contact_hash: initialByHref.get(hrefs['conflict-delete']).local_contact_hash,
+ mapping_revision: '11',
+ display_name: localNames['conflict-delete'],
+ conflict_id: conflictIds['conflict-delete'],
+ conflict_local_vcard: conflictLocalSnapshots['conflict-delete'].localVCard,
+ local_tombstone: conflictLocalSnapshots['conflict-delete'].localTombstone,
+ conflict_remote_etag: null,
+ conflict_remote_vcard: null,
+ remote_tombstone: true,
+ });
+ expect(byHref.get(hrefs.sibling)).toMatchObject({
+ remote_etag: '"sibling-2"',
+ vcard: siblingCard,
+ mapping_status: 'synced',
+ remote_semantic_hash: semanticVCardHash(parseVCardDocument(siblingCard)),
+ mapping_revision: '11',
+ display_name: 'Sibling Remote Edit',
+ conflict_id: null,
+ });
+ expect(byHref.get(hrefs.sibling).local_contact_hash).toBe(
+ storedContactHash(byHref.get(hrefs.sibling)),
+ );
+ await fixture.close();
+ }, 120_000);
+
+ async function createPushOriginContact(fixture, userId, draft) {
+ const created = await carddavContactService.createContact(userId, {
+ firstName: null,
+ lastName: null,
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ ...draft,
+ });
+ const { rows: [row] } = await databaseClient.query(`
+ SELECT o.address_book_id, o.href, o.remote_etag, o.vcard,
+ o.remote_semantic_hash, o.local_contact_hash, o.mapping_status,
+ o.mapping_revision::text,
+ c.id AS contact_id, c.address_book_id AS contact_book_id, c.uid,
+ c.display_name, c.first_name, c.last_name, c.organization, c.notes,
+ c.emails, c.phones, c.photo_data, c.additional_fields,
+ cb.source AS contact_book_source
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ JOIN address_books cb ON cb.id = c.address_book_id
+ WHERE c.user_id = $1
+ `, [userId]);
+ // A push-origin mapping links a contact that lives OUTSIDE the CardDAV book.
+ expect(row.contact_book_source).not.toBe('carddav');
+ expect(row.contact_book_id).not.toBe(row.address_book_id);
+ expect(row.contact_id).toBe(created.id);
+ return { created, mapping: row };
+ }
+
+ async function pushOriginState(userId) {
+ const { rows: [row] } = await databaseClient.query(`
+ SELECT o.address_book_id, o.href, o.remote_etag, o.vcard,
+ o.remote_semantic_hash, o.local_contact_hash, o.mapping_status,
+ o.mapping_revision::text,
+ c.id AS contact_id, c.address_book_id AS contact_book_id, c.uid,
+ c.display_name, c.first_name, c.last_name, c.organization, c.notes,
+ c.emails, c.phones, c.photo_data, c.additional_fields
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE c.user_id = $1
+ `, [userId]);
+ return row;
+ }
+
+ function contactRowHash(row) {
+ return localContactHash({
+ uid: row.uid,
+ displayName: row.display_name,
+ firstName: row.first_name,
+ lastName: row.last_name,
+ emails: row.emails,
+ phones: row.phones,
+ organization: row.organization,
+ notes: row.notes,
+ photoData: row.photo_data,
+ additionalFields: row.additional_fields,
+ });
+ }
+
+ it('applies a remote edit to a push-origin contact and stays converged', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const { created, mapping: before } = await createPushOriginContact(fixture, seeded.userId, {
+ displayName: 'Push Origin',
+ firstName: 'Push',
+ lastName: 'Origin',
+ emails: [{ value: 'push-origin@example.test', type: 'work', primary: true }],
+ organization: 'Origin Co',
+ });
+
+ // A remote-only edit of the resource MailFlow created: same UID, new content.
+ const editedVcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${created.uid}`,
+ 'FN:Push Origin',
+ 'EMAIL:push-origin@example.test',
+ 'ORG:Edited Remotely',
+ 'NOTE:remote-only-edit',
+ 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(before.href, '"push-origin-edited"', editedVcard);
+ fixture.queueSync('', {
+ events: [{ rawHref: before.href, etag: '"push-origin-edited"' }],
+ nextToken: 'push-origin-edited-token',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const after = await pushOriginState(seeded.userId);
+ // The local contact adopted the edit in place: same IDs, same local book.
+ expect(after.contact_id).toBe(before.contact_id);
+ expect(after.contact_book_id).toBe(before.contact_book_id);
+ expect(after.uid).toBe(before.uid);
+ expect(after.organization).toBe('Edited Remotely');
+ expect(after.notes).toBe('remote-only-edit');
+ // The mapping advanced its ETag, retained the lossless remote vCard, stays synced.
+ expect(after.remote_etag).toBe('"push-origin-edited"');
+ expect(after.vcard).toBe(editedVcard);
+ expect(after.mapping_status).toBe('synced');
+ expect(after.remote_semantic_hash)
+ .toBe(semanticVCardHash(parseVCardDocument(editedVcard)));
+ // Hashes converge: the mapping's local hash matches the stored contact.
+ expect(after.local_contact_hash).toBe(contactRowHash(after));
+ // No spurious conflict.
+ const { rows: conflicts } = await databaseClient.query(
+ "SELECT 1 FROM carddav_conflicts WHERE user_id = $1 AND status = 'unresolved'",
+ [seeded.userId],
+ );
+ expect(conflicts).toEqual([]);
+
+ // A byte-identical incremental no-change sync leaves the mapping + contact untouched.
+ fixture.queueSync('push-origin-edited-token', {
+ events: [],
+ nextToken: 'push-origin-noop-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ const settled = await pushOriginState(seeded.userId);
+ expect(settled).toMatchObject({
+ remote_etag: after.remote_etag,
+ vcard: after.vcard,
+ remote_semantic_hash: after.remote_semantic_hash,
+ local_contact_hash: after.local_contact_hash,
+ mapping_status: 'synced',
+ mapping_revision: after.mapping_revision,
+ organization: 'Edited Remotely',
+ notes: 'remote-only-edit',
+ });
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('creates a conflict when a push-origin contact is edited on both sides', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const { created, mapping: before } = await createPushOriginContact(fixture, seeded.userId, {
+ displayName: 'Push Both',
+ emails: [{ value: 'push-both@example.test', type: 'work', primary: true }],
+ organization: 'Origin Co',
+ });
+
+ // Concurrent local edit (no push yet) plus a remote-only edit of the same resource.
+ await databaseClient.query(
+ 'UPDATE contacts SET organization = $1, updated_at = NOW() WHERE id = $2',
+ ['Local Edit', created.id],
+ );
+ const remoteVcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${created.uid}`,
+ 'FN:Push Both',
+ 'EMAIL:push-both@example.test',
+ 'ORG:Remote Edit',
+ 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(before.href, '"push-both-remote"', remoteVcard);
+ fixture.queueSync('', {
+ events: [{ rawHref: before.href, etag: '"push-both-remote"' }],
+ nextToken: 'push-both-token',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // The simultaneous edit surfaces as a normal conflict; the local edit is retained.
+ const { rows: [conflict] } = await databaseClient.query(
+ `SELECT status, remote_tombstone, local_tombstone
+ FROM carddav_conflicts WHERE user_id = $1 AND href = $2`,
+ [seeded.userId, before.href],
+ );
+ expect(conflict).toMatchObject({
+ status: 'unresolved',
+ remote_tombstone: false,
+ local_tombstone: false,
+ });
+ const state = await pushOriginState(seeded.userId);
+ expect(state.mapping_status).toBe('conflict');
+ expect(state.organization).toBe('Local Edit');
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('snapshots a lossless local vCard when sync creates a conflict, so keep-mailflow preserves unmodeled properties', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+
+ // Import a remote contact whose retained vCard carries unmodeled server
+ // properties the local projection drops.
+ const uid = 'conflict-lossless';
+ const href = fixture.href(`${uid}.vcf`);
+ const importedCard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${uid}`,
+ 'FN:Conflict Lossless',
+ 'EMAIL:conflict-lossless@example.test',
+ 'CATEGORIES:Friends,VIP',
+ 'X-CUSTOM-FLAG:keep-me',
+ 'TZ:America/New_York',
+ 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"lossless-1"', importedCard);
+ fixture.queueSync('', { events: [{ href, etag: '"lossless-1"' }], nextToken: 'lossless-token' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, contactCount: 1 });
+
+ const { rows: [imported] } = await databaseClient.query(`
+ SELECT c.id, c.vcard, o.vcard AS mapping_vcard
+ FROM contacts c
+ JOIN carddav_remote_objects o ON o.local_contact_id = c.id
+ WHERE c.user_id = $1
+ `, [seeded.userId]);
+ // Precondition: the local contacts.vcard is lossy; only the mapping is lossless.
+ expect(imported.vcard).not.toContain('CATEGORIES');
+ expect(imported.mapping_vcard).toContain('CATEGORIES:Friends,VIP');
+
+ // A local edit and a concurrent remote edit make sync raise a conflict.
+ await databaseClient.query(
+ 'UPDATE contacts SET organization = $1, updated_at = NOW() WHERE id = $2',
+ ['Local Edit', imported.id],
+ );
+ const remoteEdit = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${uid}`,
+ 'FN:Conflict Remote Edit',
+ 'EMAIL:conflict-lossless@example.test',
+ 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"lossless-2"', remoteEdit);
+ fixture.queueSync('lossless-token', {
+ events: [{ href, etag: '"lossless-2"' }],
+ nextToken: 'lossless-token-2',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const { rows: [conflict] } = await databaseClient.query(
+ `SELECT id, local_vcard, status FROM carddav_conflicts
+ WHERE user_id = $1 AND href = $2`,
+ [seeded.userId, href],
+ );
+ expect(conflict.status).toBe('unresolved');
+ // The snapshot overlays the current local contact onto the retained remote
+ // vCard: the local edit is present AND the unmodeled properties survive.
+ expect(conflict.local_vcard).toContain('ORG:Local Edit');
+ expect(conflict.local_vcard).toContain('CATEGORIES:Friends,VIP');
+ expect(conflict.local_vcard).toContain('X-CUSTOM-FLAG:keep-me');
+ expect(conflict.local_vcard).toContain('TZ:America/New_York');
+
+ // keep-mailflow pushes that snapshot verbatim, so the unmodeled properties
+ // reach the remote instead of being stripped.
+ fixture.reset();
+ fixture.putContact(href, '"lossless-2"', remoteEdit);
+ await carddavConflictService.resolveConflict(seeded.userId, conflict.id, 'keep-mailflow');
+
+ const putRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(putRequest).toBeDefined();
+ expect(putRequest.body).toContain('ORG:Local Edit');
+ expect(putRequest.body).toContain('CATEGORIES:Friends,VIP');
+ expect(putRequest.body).toContain('X-CUSTOM-FLAG:keep-me');
+ expect(putRequest.body).toContain('TZ:America/New_York');
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('removes a push-origin contact when its remote resource is deleted', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const { created, mapping } = await createPushOriginContact(fixture, seeded.userId, {
+ displayName: 'Push Delete',
+ emails: [{ value: 'push-delete@example.test', type: 'work', primary: true }],
+ });
+
+ // Delete the remote resource, then sync with no concurrent local change.
+ fixture.reset();
+ fixture.deleteContact(mapping.href);
+ fixture.queueSync('', { events: [], nextToken: 'push-delete-token' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // The local contact and its mapping are removed once, matching a pull-origin delete.
+ const { rows: contactRows } = await databaseClient.query(
+ 'SELECT 1 FROM contacts WHERE id = $1',
+ [created.id],
+ );
+ expect(contactRows).toEqual([]);
+ const { rows: mappingRows } = await databaseClient.query(`
+ SELECT 1 FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ `, [seeded.userId]);
+ expect(mappingRows).toEqual([]);
+ // MailFlow must not resurrect the resource it saw deleted.
+ expect(fixture.counters.create).toBe(0);
+ expect(fixture.counters.update).toBe(0);
+ const { rows: conflicts } = await databaseClient.query(
+ "SELECT 1 FROM carddav_conflicts WHERE user_id = $1 AND status = 'unresolved'",
+ [seeded.userId],
+ );
+ expect(conflicts).toEqual([]);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('rotates the book sync token and advances the served ETag when a remote-only unmodeled change lands', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href('etag-lifecycle.vcf');
+ const first = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:etag-lifecycle-remote', 'FN:Etag Person',
+ 'EMAIL:etag@example.test', 'CATEGORIES:Alpha', 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"etag-1"', first);
+ fixture.queueSync('', { events: [{ href, etag: '"etag-1"' }], nextToken: 'etag-token-1' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const servedRow = async () => {
+ const { rows: [row] } = await databaseClient.query(`
+ SELECT c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones,
+ c.organization, c.notes, c.photo_data, c.additional_fields, c.vcard, c.etag,
+ mapping.vcard AS mapping_vcard, mapping.address_book_id AS book_id
+ FROM contacts c
+ JOIN carddav_remote_objects mapping ON mapping.local_contact_id = c.id
+ WHERE c.user_id = $1
+ `, [seeded.userId]);
+ return row;
+ };
+ const before = await servedRow();
+ const bookBefore = await databaseClient.query(
+ 'SELECT sync_token FROM address_books WHERE id = $1', [before.book_id]);
+
+ // A remote-only change to an UNMODELED property (CATEGORIES): the modeled columns
+ // and contacts.etag stay identical, but the presented document changes.
+ const second = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:etag-lifecycle-remote', 'FN:Etag Person',
+ 'EMAIL:etag@example.test', 'CATEGORIES:Beta', 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"etag-2"', second);
+ fixture.reset();
+ fixture.queueSync('etag-token-1', { events: [{ href, etag: '"etag-2"' }], nextToken: 'etag-token-2' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const after = await servedRow();
+ const bookAfter = await databaseClient.query(
+ 'SELECT sync_token FROM address_books WHERE id = $1', [before.book_id]);
+
+ // Modeled state is unchanged...
+ expect(after.etag).toBe(before.etag);
+ // ...but the presented document (hence the served ETag) advanced...
+ expect(presentedVCard(after)).toContain('CATEGORIES:Beta');
+ expect(presentedEtag(after)).not.toBe(presentedEtag(before));
+ // ...so the book sync token (getctag) must rotate for pollers to re-fetch.
+ expect(bookAfter.rows[0].sync_token).not.toBe(bookBefore.rows[0].sync_token);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('preserves the remote UID when a Mailflow edit of an email-merged contact syncs, keeping it locally authoritative', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const { rows: [localBook] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Email Merge Local') RETURNING id
+ `, [seeded.userId]);
+ const local = {
+ uid: 'email-merge-local',
+ displayName: 'Merge Person',
+ emails: [{ value: 'email-merge@example.test', type: 'work', primary: true }],
+ phones: [], organization: 'Local Co', notes: null, photoData: null,
+ };
+ const localVcard = generateVCard(local);
+ const { rows: [localRow] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, organization,
+ primary_email, emails, phones, additional_fields, is_auto
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'[]'::jsonb,'[]'::jsonb,false)
+ RETURNING id
+ `, [
+ localBook.id, seeded.userId, local.uid, localVcard,
+ createHash('md5').update(localVcard).digest('hex'),
+ local.displayName, local.organization, local.emails[0].value,
+ JSON.stringify(local.emails),
+ ]);
+
+ // The remote carries a DISTINCT, server-owned UID but the same primary email,
+ // so the initial sync email-merges it to the local contact (locally authoritative).
+ const remoteUid = 'email-merge-remote-uid';
+ const href = fixture.href('email-merge.vcf');
+ const remoteVcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${remoteUid}`, 'FN:Merge Person',
+ 'EMAIL:email-merge@example.test', 'CATEGORIES:Remote', 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"merge-1"', remoteVcard);
+ fixture.queueSync('', { events: [{ href, etag: '"merge-1"' }], nextToken: 'merge-token-1' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const mappingUidAfterMerge = await databaseClient.query(
+ 'SELECT vcard FROM carddav_remote_objects o JOIN address_books b ON b.id = o.address_book_id WHERE b.user_id = $1',
+ [seeded.userId],
+ );
+ expect(parseVCard(mappingUidAfterMerge.rows[0].vcard).uid).toBe(remoteUid);
+
+ // The user edits the merged contact in Mailflow.
+ fixture.reset();
+ fixture.putContact(href, '"merge-1"', remoteVcard);
+ await carddavContactService.updateContact(seeded.userId, localRow.id, {
+ organization: 'Edited In Mailflow',
+ });
+
+ // The outgoing PUT must keep the remote-owned UID — never coerce it to the local key.
+ const putRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(putRequest).toBeDefined();
+ expect(parseVCard(putRequest.body).uid).toBe(remoteUid);
+
+ const afterEdit = await databaseClient.query(
+ 'SELECT o.vcard AS mapping_vcard, c.uid AS local_uid FROM carddav_remote_objects o JOIN contacts c ON c.id = o.local_contact_id JOIN address_books b ON b.id = o.address_book_id WHERE b.user_id = $1',
+ [seeded.userId],
+ );
+ expect(parseVCard(afterEdit.rows[0].mapping_vcard).uid).toBe(remoteUid);
+ expect(afterEdit.rows[0].local_uid).toBe(local.uid);
+
+ // A subsequent remote delete must only UNLINK an email-merged contact, never
+ // remove it — provenance stayed distinct through the edit.
+ fixture.reset();
+ fixture.deleteContact(href);
+ fixture.queueSync('merge-token-1', { events: [{ href, status: 404 }], nextToken: 'merge-token-2' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const survivor = await databaseClient.query(
+ 'SELECT display_name, organization FROM contacts WHERE id = $1',
+ [localRow.id],
+ );
+ expect(survivor.rows).toEqual([
+ { display_name: 'Merge Person', organization: 'Edited In Mailflow' },
+ ]);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('keeps the remote UID when keep-mailflow resolves an email-merged conflict, staying locally authoritative', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const { rows: [localBook] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Email Merge Conflict Local') RETURNING id
+ `, [seeded.userId]);
+ const local = {
+ uid: 'em-conflict-local',
+ displayName: 'Merge Conflict',
+ emails: [{ value: 'em-conflict@example.test', type: 'work', primary: true }],
+ phones: [], organization: 'Local Co', notes: null, photoData: null,
+ };
+ const localVcard = generateVCard(local);
+ const { rows: [localRow] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, organization,
+ primary_email, emails, phones, additional_fields, is_auto
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'[]'::jsonb,'[]'::jsonb,false)
+ RETURNING id
+ `, [
+ localBook.id, seeded.userId, local.uid, localVcard,
+ createHash('md5').update(localVcard).digest('hex'),
+ local.displayName, local.organization, local.emails[0].value,
+ JSON.stringify(local.emails),
+ ]);
+
+ const remoteUid = 'em-conflict-remote-uid';
+ const href = fixture.href('em-conflict.vcf');
+ const remoteVcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${remoteUid}`, 'FN:Merge Conflict',
+ 'EMAIL:em-conflict@example.test', 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"emc-1"', remoteVcard);
+ fixture.queueSync('', { events: [{ href, etag: '"emc-1"' }], nextToken: 'emc-token-1' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // Concurrent local edit + remote edit → sync raises a conflict for this email-merged
+ // contact, snapshotting the local vCard that keep-mailflow will push verbatim.
+ await databaseClient.query(
+ 'UPDATE contacts SET organization = $1, updated_at = NOW() WHERE id = $2',
+ ['Local Edit', localRow.id],
+ );
+ const remoteEdit = [
+ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${remoteUid}`, 'FN:Remote Edit',
+ 'EMAIL:em-conflict@example.test', 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"emc-2"', remoteEdit);
+ fixture.queueSync('emc-token-1', { events: [{ href, etag: '"emc-2"' }], nextToken: 'emc-token-2' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const { rows: [conflict] } = await databaseClient.query(
+ `SELECT id FROM carddav_conflicts WHERE user_id = $1 AND href = $2 AND status = 'unresolved'`,
+ [seeded.userId, href],
+ );
+ expect(conflict).toBeDefined();
+
+ // keep-mailflow pushes the snapshot; it must carry the ORIGINAL remote UID.
+ fixture.reset();
+ fixture.putContact(href, '"emc-2"', remoteEdit);
+ await carddavConflictService.resolveConflict(seeded.userId, conflict.id, 'keep-mailflow');
+
+ const putRequest = fixture.requests.find(request => request.method === 'PUT');
+ expect(putRequest).toBeDefined();
+ expect(parseVCard(putRequest.body).uid).toBe(remoteUid);
+
+ // The mapping keeps the remote UID and the contact keeps its local key, so it
+ // remains locally authoritative — a later remote delete only unlinks it.
+ const afterResolve = await databaseClient.query(
+ 'SELECT o.vcard AS mapping_vcard, c.uid AS local_uid FROM carddav_remote_objects o JOIN contacts c ON c.id = o.local_contact_id JOIN address_books b ON b.id = o.address_book_id WHERE b.user_id = $1',
+ [seeded.userId],
+ );
+ expect(parseVCard(afterResolve.rows[0].mapping_vcard).uid).toBe(remoteUid);
+ expect(afterResolve.rows[0].local_uid).toBe(local.uid);
+
+ fixture.reset();
+ fixture.deleteContact(href);
+ fixture.queueSync('emc-token-2', { events: [{ href, status: 404 }], nextToken: 'emc-token-3' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ const survivor = await databaseClient.query('SELECT 1 FROM contacts WHERE id = $1', [localRow.id]);
+ expect(survivor.rows).toEqual([{ '?column?': 1 }]);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('applies a 507-truncated multi-page sync as one delta and never drops unlisted contacts', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ // An existing local contact, imported by a first sync, that no later page mentions.
+ await seedSingleRemoteContact(fixture, seeded.userId, {
+ uid: 'multipage-survivor', name: 'Survivor', token: 'multipage-token-1',
+ });
+
+ // A remote delta delivered across TWO pages: page 1 is 507-truncated with a
+ // continuation token, page 2 completes it.
+ const hrefA = fixture.href('multipage-a.vcf');
+ const hrefB = fixture.href('multipage-b.vcf');
+ fixture.putContact(hrefA, '"a-1"', remoteVcard('multipage-a', 'Page One'));
+ fixture.putContact(hrefB, '"b-1"', remoteVcard('multipage-b', 'Page Two'));
+ fixture.queueSync('multipage-token-1', {
+ events: [{ href: hrefA, etag: '"a-1"' }],
+ nextToken: 'multipage-page-2',
+ truncated: true,
+ });
+ fixture.queueSync('multipage-page-2', {
+ events: [{ href: hrefB, etag: '"b-1"' }],
+ nextToken: 'multipage-token-final',
+ });
+
+ const before507 = fixture.counters.requestUri507;
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // The 507-truncated page was actually exercised as one welded delta.
+ expect(fixture.counters.requestUri507).toBe(before507 + 1);
+ const state = await projectionState(seeded.userId);
+ const names = state.contacts.map(contact => contact.display_name).sort();
+ // Both pages' contacts imported AND the unlisted survivor is never dropped.
+ expect(names).toEqual(['Page One', 'Page Two', 'Survivor']);
+ // The remote token advances only once, to the final page's token.
+ const book = state.books.find(row => row.source === 'carddav');
+ expect(book.remote_sync_token).toBe('multipage-token-final');
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('retains an owned resolved tombstone for repeat 409 and fixed cleanup', async () => {
+ const fixture = createCarddavFixtureServer();
+ let apiServer;
+ await fixture.listen();
+ try {
+ const owner = await seedConnectedUser(fixture);
+ const foreign = await seedConnectedUser(fixture);
+ const seeded = await seedResolutionConflict(fixture, owner.userId, {
+ uid: 'retained-tombstone',
+ remoteTombstone: true,
+ });
+ fixture.reset();
+ apiServer = await listenConflictApi();
+ const origin = `http://127.0.0.1:${apiServer.address().port}`;
+ const resolve = userId => fetch(
+ `${origin}/conflicts/${seeded.conflictId}/resolve`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Test-User-Id': userId,
+ },
+ body: JSON.stringify({ resolution: 'keep-carddav' }),
+ },
+ );
+
+ const first = await resolve(owner.userId);
+ expect(first.status).toBe(200);
+ expect(await first.json()).toMatchObject({
+ id: seeded.conflictId,
+ status: 'resolved',
+ resolution: 'keep-carddav',
+ remote: { tombstone: true },
+ });
+ expect(fixture.counters).toMatchObject({ fetch: 1, update: 0, delete: 0 });
+
+ const { rows: [retained] } = await databaseClient.query(`
+ SELECT conflict.status, conflict.resolution,
+ conflict.resolved_at IS NOT NULL AS resolved,
+ mapping.href AS mapping_href, contact.id AS contact_id
+ FROM carddav_conflicts conflict
+ LEFT JOIN carddav_remote_objects mapping
+ ON mapping.address_book_id = conflict.address_book_id
+ AND mapping.href = conflict.href
+ LEFT JOIN contacts contact ON contact.id = $2
+ WHERE conflict.id = $1
+ `, [seeded.conflictId, seeded.local_contact_id]);
+ expect(retained).toEqual({
+ status: 'resolved',
+ resolution: 'keep-carddav',
+ resolved: true,
+ mapping_href: null,
+ contact_id: null,
+ });
+
+ const detail = await fetch(`${origin}/conflicts/${seeded.conflictId}`, {
+ headers: { 'X-Test-User-Id': owner.userId },
+ });
+ expect(detail.status).toBe(200);
+ expect(await detail.json()).toMatchObject({
+ id: seeded.conflictId,
+ status: 'resolved',
+ });
+ expect((await resolve(foreign.userId)).status).toBe(404);
+ expect((await resolve(owner.userId)).status).toBe(409);
+
+ await carddavConflictService.deleteResolvedConflictsBefore(
+ databaseClient,
+ new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+ );
+ const recent = await databaseClient.query(
+ 'SELECT id FROM carddav_conflicts WHERE id = $1',
+ [seeded.conflictId],
+ );
+ expect(recent.rowCount).toBe(1);
+ await databaseClient.query(
+ "UPDATE carddav_conflicts SET resolved_at = NOW() - INTERVAL '31 days' WHERE id = $1",
+ [seeded.conflictId],
+ );
+ await carddavConflictService.deleteResolvedConflictsBefore(
+ databaseClient,
+ new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+ );
+ const expired = await databaseClient.query(
+ 'SELECT id FROM carddav_conflicts WHERE id = $1',
+ [seeded.conflictId],
+ );
+ expect(expired.rowCount).toBe(0);
+ } finally {
+ if (apiServer) await closeServer(apiServer);
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('retains an aged unresolved conflict through the resolved-only cleanup', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ try {
+ const seededUser = await seedConnectedUser(fixture);
+ const seeded = await seedResolutionConflict(fixture, seededUser.userId, {
+ uid: 'aged-unresolved',
+ remoteCard: remoteVcard('aged-unresolved', 'Aged Unresolved Remote'),
+ });
+ // Age the still-unresolved conflict well past the 30-day retention window;
+ // it must survive because the cleanup only deletes resolved rows.
+ await databaseClient.query(`
+ UPDATE carddav_conflicts
+ SET created_at = NOW() - INTERVAL '60 days', updated_at = NOW() - INTERVAL '60 days'
+ WHERE id = $1
+ `, [seeded.conflictId]);
+
+ await carddavConflictService.deleteResolvedConflictsBefore(
+ databaseClient,
+ new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+ );
+
+ const { rows } = await databaseClient.query(
+ 'SELECT status, resolved_at FROM carddav_conflicts WHERE id = $1',
+ [seeded.conflictId],
+ );
+ expect(rows).toEqual([{ status: 'unresolved', resolved_at: null }]);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('reports URI PHOTO presence through the real conflict request boundary', async () => {
+ const fixture = createCarddavFixtureServer();
+ let apiServer;
+ await fixture.listen();
+ try {
+ const seededUser = await seedConnectedUser(fixture);
+ const uri = 'https://images.example.test/private.jpg';
+ const remoteCard = remotePhotoVcard(
+ 'uri-photo-conflict',
+ 'URI Photo Conflict',
+ 'uri-photo@example.test',
+ `PHOTO;VALUE=URI:${uri}`,
+ );
+ const seeded = await seedResolutionConflict(fixture, seededUser.userId, {
+ uid: 'uri-photo-conflict',
+ remoteCard,
+ });
+ apiServer = await listenConflictApi();
+ const response = await fetch(
+ `http://127.0.0.1:${apiServer.address().port}/conflicts/${seeded.conflictId}`,
+ { headers: { 'X-Test-User-Id': seededUser.userId } },
+ );
+ const body = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(JSON.parse(body)).toMatchObject({
+ id: seeded.conflictId,
+ remote: { tombstone: false, hasPhoto: true },
+ });
+ expect(body).not.toContain(uri);
+ expect(body).not.toContain('BEGIN:VCARD');
+ } finally {
+ if (apiServer) await closeServer(apiServer);
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('rolls back contact and mapping state when the conflict transition loses its CAS', async () => {
+ const fixture = createCarddavFixtureServer();
+ let apiServer;
+ await fixture.listen();
+ try {
+ const seededUser = await seedConnectedUser(fixture);
+ const remoteCard = remoteVcard('resolution-cas', 'Resolution CAS Remote');
+ const seeded = await seedResolutionConflict(fixture, seededUser.userId, {
+ uid: 'resolution-cas',
+ remoteCard,
+ });
+ await databaseClient.query(`
+ CREATE FUNCTION force_conflict_resolution_cas_miss() RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ BEGIN
+ RETURN NULL;
+ END;
+ $$
+ `);
+ await databaseClient.query(`
+ CREATE TRIGGER force_conflict_resolution_cas_miss
+ BEFORE UPDATE OF status ON carddav_conflicts
+ FOR EACH ROW
+ WHEN (OLD.status = 'unresolved' AND NEW.status = 'resolved')
+ EXECUTE FUNCTION force_conflict_resolution_cas_miss()
+ `);
+ fixture.reset();
+ apiServer = await listenConflictApi();
+ const response = await fetch(
+ `http://127.0.0.1:${apiServer.address().port}/conflicts/${seeded.conflictId}/resolve`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Test-User-Id': seededUser.userId,
+ },
+ body: JSON.stringify({ resolution: 'keep-carddav' }),
+ },
+ );
+
+ expect(response.status).toBe(409);
+ const { rows: [state] } = await databaseClient.query(`
+ SELECT mapping.mapping_revision::text, mapping.mapping_status,
+ conflict.status, conflict.resolution, conflict.resolved_at,
+ contact.display_name
+ FROM carddav_remote_objects mapping
+ JOIN carddav_conflicts conflict
+ ON conflict.address_book_id = mapping.address_book_id
+ AND conflict.href = mapping.href
+ JOIN contacts contact ON contact.id = mapping.local_contact_id
+ WHERE conflict.id = $1
+ `, [seeded.conflictId]);
+ expect(state).toEqual({
+ mapping_revision: seeded.mappingRevision,
+ mapping_status: 'conflict',
+ status: 'unresolved',
+ resolution: null,
+ resolved_at: null,
+ display_name: seeded.local_display_name,
+ });
+ expect(fixture.counters).toMatchObject({ fetch: 1, update: 0, delete: 0 });
+ } finally {
+ await databaseClient.query(
+ 'DROP TRIGGER IF EXISTS force_conflict_resolution_cas_miss ON carddav_conflicts',
+ );
+ await databaseClient.query('DROP FUNCTION IF EXISTS force_conflict_resolution_cas_miss()');
+ if (apiServer) await closeServer(apiServer);
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('recovers old pending update and delete intents after restart without replaying writes', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const initial = await seedSingleRemoteContact(fixture, seeded.userId, {
+ uid: 'restart-recovery',
+ name: 'Before Restart',
+ token: 'restart-recovery-1',
+ });
+ const { rows: [before] } = await databaseClient.query(`
+ SELECT o.address_book_id, o.href, o.local_contact_id,
+ o.local_contact_hash, o.mapping_revision::text,
+ c.uid
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE c.user_id = $1 AND o.href = $2
+ `, [seeded.userId, initial.href]);
+ const attemptedVCard = remoteVcard(
+ before.uid,
+ 'Recovered After Restart',
+ 'restart-recovery@example.test',
+ );
+ fixture.putContact(initial.href, '"restart-recovery-2"', attemptedVCard);
+ await databaseClient.query(`
+ UPDATE carddav_remote_objects SET
+ mapping_status = 'pending_push',
+ pending_operation = 'update', pending_vcard = $1,
+ pending_local_hash = $2, pending_remote_semantic_hash = $3,
+ pending_started_at = '2000-01-01T00:00:00Z',
+ mapping_revision = mapping_revision + 1
+ WHERE address_book_id = $4 AND href = $5
+ `, [
+ attemptedVCard,
+ before.local_contact_hash,
+ semanticVCardHash(parseVCardDocument(attemptedVCard)),
+ before.address_book_id,
+ before.href,
+ ]);
+
+ fixture.reset();
+ fixture.queueSync('restart-recovery-1', {
+ events: [],
+ nextToken: 'restart-recovery-2',
+ });
+ await expect(carddavSync.syncUser(seeded.userId)).resolves.toMatchObject({ ok: true });
+
+ expect(fixture.requests.filter(request => request.method === 'PUT')).toHaveLength(0);
+ expect(fixture.requests.filter(request => request.method === 'DELETE')).toHaveLength(0);
+ expect(fixture.requests.filter(request => request.method === 'GET')).toHaveLength(1);
+ const { rows: [updated] } = await databaseClient.query(`
+ SELECT o.mapping_status, o.pending_operation, o.pending_vcard,
+ o.pending_local_hash, o.pending_remote_semantic_hash,
+ o.pending_started_at, o.local_contact_hash,
+ c.display_name
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE c.user_id = $1 AND o.href = $2
+ `, [seeded.userId, initial.href]);
+ expect(updated).toMatchObject({
+ mapping_status: 'synced',
+ pending_operation: null,
+ pending_vcard: null,
+ pending_local_hash: null,
+ pending_remote_semantic_hash: null,
+ pending_started_at: null,
+ display_name: 'Recovered After Restart',
+ });
+
+ await databaseClient.query(`
+ UPDATE carddav_remote_objects SET
+ mapping_status = 'pending_push',
+ pending_operation = 'delete', pending_vcard = NULL,
+ pending_local_hash = $1, pending_remote_semantic_hash = NULL,
+ pending_started_at = '2000-01-01T00:00:00Z',
+ mapping_revision = mapping_revision + 1
+ WHERE address_book_id = $2 AND href = $3
+ `, [updated.local_contact_hash, before.address_book_id, before.href]);
+ fixture.deleteContact(initial.href);
+ fixture.reset();
+ fixture.queueSync('restart-recovery-2', {
+ events: [],
+ nextToken: 'restart-recovery-3',
+ });
+
+ await expect(carddavSync.syncUser(seeded.userId)).resolves.toMatchObject({ ok: true });
+
+ expect(fixture.requests.filter(request => request.method === 'PUT')).toHaveLength(0);
+ expect(fixture.requests.filter(request => request.method === 'DELETE')).toHaveLength(0);
+ expect(fixture.requests.filter(request => request.method === 'GET')).toHaveLength(1);
+ const { rows: [deleted] } = await databaseClient.query(`
+ SELECT
+ (SELECT COUNT(*)::int FROM carddav_remote_objects
+ WHERE address_book_id = $1 AND href = $2) AS mappings,
+ (SELECT COUNT(*)::int FROM contacts
+ WHERE user_id = $3 AND id = $4) AS contacts
+ `, [before.address_book_id, before.href, seeded.userId, before.local_contact_id]);
+ expect(deleted).toEqual({ mappings: 0, contacts: 0 });
+ await fixture.close();
+ }, 120_000);
+
+ it('preserves the visible count across a same-identity password replacement and no-change delta', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const contact = await seedSingleRemoteContact(fixture, seeded.userId, {
+ uid: 'password-count',
+ name: 'Password Count',
+ token: 'password-count-1',
+ });
+
+ const replacement = await carddavSync.replaceCarddavConnection(seeded.userId, {
+ serverUrl: fixture.serverUrl,
+ username: 'fixture-user',
+ password: encrypt('replacement-password'),
+ intervalMin: 60,
+ });
+ fixture.reset();
+ fixture.queueSync(contact.token, { events: [], nextToken: 'password-count-2' });
+
+ const result = await carddavSync.syncUser(seeded.userId);
+ const state = await projectionState(seeded.userId);
+ const [integration] = await integrationState(seeded.userId);
+
+ expect(result).toMatchObject({ ok: true, contactCount: 1 });
+ expect(state.contacts).toHaveLength(1);
+ expect(state.ledger).toHaveLength(1);
+ expect(integration.config).toMatchObject({
+ connectionGeneration: replacement.connectionGeneration,
+ contactCount: 1,
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('keeps the cached count aligned when one book commits before the next book fails', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const twoBooks = await seedTwoRemoteBooks(fixture, seeded.userId);
+ const changedCardB = remoteVcard('person-b', 'Person B Changed');
+ fixture.deleteContact(twoBooks.hrefA);
+ fixture.putContact(twoBooks.hrefB, '"person-b-2"', changedCardB);
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: twoBooks.bookAPath, displayName: 'Contacts A' },
+ { href: twoBooks.bookBPath, displayName: 'Contacts B' },
+ ] });
+ fixture.queueSync('two-book-a-1', {
+ events: [{ href: twoBooks.hrefA, status: 404 }],
+ nextToken: 'two-book-a-2',
+ });
+ fixture.queueSync('two-book-b-1', {
+ events: [{ href: twoBooks.hrefB, etag: '"person-b-2"' }],
+ nextToken: 'two-book-b-2',
+ });
+ await databaseClient.query(`
+ CREATE FUNCTION fail_second_count_book() RETURNS trigger
+ LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.id = '${twoBooks.bookB.id}'::uuid THEN
+ RAISE EXCEPTION 'forced second count book failure';
+ END IF;
+ RETURN NEW;
+ END
+ $$
+ `);
+ await databaseClient.query(`
+ CREATE TRIGGER fail_second_count_book
+ BEFORE UPDATE ON address_books
+ FOR EACH ROW EXECUTE FUNCTION fail_second_count_book()
+ `);
+
+ const failed = await carddavSync.syncUser(seeded.userId);
+ await databaseClient.query('DROP TRIGGER fail_second_count_book ON address_books');
+ await databaseClient.query('DROP FUNCTION fail_second_count_book()');
+ const failedState = await projectionState(seeded.userId);
+ const [failedIntegration] = await integrationState(seeded.userId);
+
+ expect(failed).toMatchObject({
+ ok: false,
+ error: 'forced second count book failure',
+ });
+ expect(failedState.contacts).toHaveLength(1);
+ expect(failedState.ledger).toHaveLength(1);
+ expect(failedIntegration.config.contactCount).toBe(1);
+
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: twoBooks.bookAPath, displayName: 'Contacts A' },
+ { href: twoBooks.bookBPath, displayName: 'Contacts B' },
+ ] });
+ fixture.queueSync('two-book-a-2', { events: [], nextToken: 'two-book-a-3' });
+ fixture.queueSync('two-book-b-1', {
+ events: [{ href: twoBooks.hrefB, etag: '"person-b-2"' }],
+ nextToken: 'two-book-b-2',
+ });
+
+ const retried = await carddavSync.syncUser(seeded.userId);
+ const retriedState = await projectionState(seeded.userId);
+ const [retriedIntegration] = await integrationState(seeded.userId);
+
+ expect(retried).toMatchObject({ ok: true, contactCount: 1 });
+ expect(retriedState.contacts).toHaveLength(1);
+ expect(retriedState.ledger).toHaveLength(1);
+ expect(retriedIntegration.config.contactCount).toBe(1);
+ await fixture.close();
+ }, 120_000);
+
+ it('returns the authoritative count after retaining one discovered book and pruning another', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const twoBooks = await seedTwoRemoteBooks(fixture, seeded.userId);
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: twoBooks.bookAPath, displayName: 'Contacts A' },
+ ] });
+ fixture.queueSync('two-book-a-1', { events: [], nextToken: 'two-book-a-2' });
+
+ const result = await carddavSync.syncUser(seeded.userId);
+ const state = await projectionState(seeded.userId);
+ const [integration] = await integrationState(seeded.userId);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 1, contactCount: 1 });
+ expect(state.books.filter(book => book.source === 'carddav')).toHaveLength(1);
+ expect(state.contacts).toHaveLength(1);
+ expect(state.ledger).toHaveLength(1);
+ expect(integration.config.contactCount).toBe(1);
+ await fixture.close();
+ }, 120_000);
+
+ it('snapshot discovery without sync-collection sends exactly one CardDAV filter', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href('snapshot-only.vcf');
+ const card = remoteVcard('snapshot-only', 'Snapshot Only');
+ fixture.putContact(href, '"snapshot-only-1"', card);
+ fixture.queueDiscovery({ books: [{
+ href: '/addressbooks/fixture-user/contacts/',
+ displayName: 'Snapshot Only',
+ reports: false,
+ }] });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toEqual({
+ ok: true,
+ bookCount: 1,
+ contactCount: 1,
+ remote: 1,
+ fetched: 1,
+ updated: 1,
+ removed: 0,
+ fallback: 1,
+ exportFailures: [],
+ });
+ const state = await projectionState(seeded.userId);
+ expect(state.books).toHaveLength(1);
+ expect(state.books[0]).toMatchObject({
+ source: 'carddav',
+ remote_sync_token: null,
+ remote_sync_capability: 'snapshot',
+ remote_sync_revision: '1',
+ });
+ expect(state.contacts).toHaveLength(1);
+ expect(state.contacts[0].primary_email).toBe('snapshot-only@example.test');
+ expect(state.ledger).toHaveLength(1);
+ expect(fixture.counters).toMatchObject({
+ requests: 4,
+ propfind: 3,
+ sync: 0,
+ multiget: 0,
+ addressbookQuery: 1,
+ snapshotFilters: [1],
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('valid empty discovery prunes projection while malformed empty discovery changes only status', async () => {
+ const emptyFixture = createCarddavFixtureServer();
+ await emptyFixture.listen();
+ const emptySeeded = await seedConnectedUser(emptyFixture);
+ await seedSingleRemoteContact(emptyFixture, emptySeeded.userId);
+ const unrelatedBook = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Empty Discovery Unrelated') RETURNING id, sync_token
+ `, [emptySeeded.userId]);
+ emptyFixture.reset();
+ emptyFixture.queueDiscovery({ books: [] });
+
+ expect(await carddavSync.syncUser(emptySeeded.userId)).toEqual({
+ ok: true,
+ bookCount: 0,
+ contactCount: 0,
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ exportFailures: [],
+ });
+ expect(await projectionState(emptySeeded.userId)).toEqual({
+ books: [{
+ id: unrelatedBook.rows[0].id,
+ source: 'local',
+ external_url: null,
+ sync_token: unrelatedBook.rows[0].sync_token,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ remote_projection_fingerprint: null,
+ }],
+ contacts: [],
+ ledger: [],
+ });
+ const [emptyIntegration] = await integrationState(emptySeeded.userId);
+ expect(emptyIntegration.config).toEqual({
+ ...emptySeeded.config,
+ lastError: null,
+ lastSyncAt: expect.any(String),
+ bookCount: 0,
+ contactCount: 0,
+ exportFailures: [],
+ });
+ expect(emptyFixture.counters).toMatchObject({
+ requests: 3,
+ propfind: 3,
+ sync: 0,
+ multiget: 0,
+ addressbookQuery: 0,
+ });
+ await emptyFixture.close();
+
+ const malformedFixture = createCarddavFixtureServer();
+ await malformedFixture.listen();
+ const malformedSeeded = await seedConnectedUser(malformedFixture);
+ await seedSingleRemoteContact(malformedFixture, malformedSeeded.userId);
+ const malformedBefore = await projectionState(malformedSeeded.userId, { timestamps: true });
+ malformedFixture.reset();
+ malformedFixture.queueDiscovery({
+ rawBody: ' ',
+ });
+
+ const malformed = await carddavSync.syncUser(malformedSeeded.userId);
+
+ expect(malformed.ok).toBe(false);
+ expect(malformed.error).toMatch(/home collection|home-set|multistatus/i);
+ expect(await projectionState(malformedSeeded.userId, { timestamps: true }))
+ .toEqual(malformedBefore);
+ const [malformedIntegration] = await integrationState(malformedSeeded.userId);
+ expect(malformedIntegration.config).toEqual({
+ ...malformedSeeded.config,
+ lastError: malformed.error,
+ lastSyncAt: expect.any(String),
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(malformedFixture.counters).toMatchObject({
+ requests: 3,
+ propfind: 3,
+ sync: 0,
+ multiget: 0,
+ addressbookQuery: 0,
+ });
+ await malformedFixture.close();
+ }, 120_000);
+
+ it('duplicate discovery aliases collapse once and conflicting duplicate metadata performs no plan', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' },
+ { href: '/addressbooks/fixture-user/contacts/./', displayName: 'Fixture Contacts' },
+ ] });
+ fixture.queueSync('', { events: [], nextToken: 'duplicate-token' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toEqual({
+ ok: true,
+ bookCount: 1,
+ contactCount: 0,
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ exportFailures: [],
+ });
+ const duplicateState = await projectionState(seeded.userId);
+ expect(duplicateState.books).toHaveLength(1);
+ expect(duplicateState.books[0]).toMatchObject({
+ source: 'carddav',
+ external_url: fixture.href(''),
+ remote_sync_token: 'duplicate-token',
+ remote_sync_revision: '1',
+ });
+ expect(duplicateState.contacts).toEqual([]);
+ expect(duplicateState.ledger).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ requests: 4,
+ propfind: 3,
+ sync: 1,
+ multiget: 0,
+ syncTokens: [''],
+ });
+ await fixture.close();
+
+ const conflictFixture = createCarddavFixtureServer();
+ await conflictFixture.listen();
+ const conflictSeeded = await seedConnectedUser(conflictFixture);
+ await seedSingleRemoteContact(conflictFixture, conflictSeeded.userId);
+ const before = await projectionState(conflictSeeded.userId, { timestamps: true });
+ conflictFixture.reset();
+ conflictFixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'First Name' },
+ { href: '/addressbooks/fixture-user/contacts/./', displayName: 'Conflicting Name' },
+ ] });
+
+ const conflict = await carddavSync.syncUser(conflictSeeded.userId);
+
+ expect(conflict.ok).toBe(false);
+ expect(conflict.error).toMatch(/conflicting.*metadata|metadata.*conflict/i);
+ expect(await projectionState(conflictSeeded.userId, { timestamps: true })).toEqual(before);
+ expect(conflictFixture.counters).toMatchObject({
+ requests: 3,
+ propfind: 3,
+ sync: 0,
+ multiget: 0,
+ });
+ await conflictFixture.close();
+ }, 120_000);
+
+ it('alias redirect performs full reconciliation and preserves the production book identity', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const aliasPath = '/addressbooks/fixture-user/alias/';
+ const canonicalPath = '/addressbooks/fixture-user/canonical/';
+ const aliasUrl = new URL(aliasPath, fixture.serverUrl).href;
+ const canonicalUrl = new URL(canonicalPath, fixture.serverUrl).href;
+ const aliasHref = new URL('person.vcf', aliasUrl).href;
+ const canonicalHref = new URL('person.vcf', canonicalUrl).href;
+ const card = remoteVcard('alias-person', 'Alias Person');
+ fixture.queueDiscovery({ books: [{ href: aliasPath, displayName: 'Alias Book' }] });
+ fixture.putContact(aliasHref, '"alias-1"', card);
+ fixture.queueSync('', {
+ events: [{ href: aliasHref, etag: '"alias-1"' }],
+ nextToken: 'alias-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ const before = await projectionState(seeded.userId);
+ const beforeBook = before.books.find(row => row.source === 'carddav');
+ expect(beforeBook.external_url).toBe(aliasUrl);
+
+ fixture.deleteContact(aliasHref);
+ fixture.putContact(canonicalHref, '"canonical-1"', card);
+ fixture.reset();
+ fixture.queueDiscovery({ books: [{ href: aliasPath, displayName: 'Alias Book' }] });
+ fixture.queueRedirect('REPORT', aliasPath, canonicalPath);
+ fixture.queueSync('alias-token', { events: [], nextToken: 'redirect-intermediate' });
+ fixture.queueSync('', {
+ events: [{ rawHref: 'person.vcf', etag: '"canonical-1"' }],
+ nextToken: 'canonical-token',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 1, fallback: 1,
+ });
+ const after = await projectionState(seeded.userId);
+ const afterBook = after.books[0];
+ expect(after.books).toEqual([{
+ ...beforeBook,
+ external_url: canonicalUrl,
+ sync_token: afterBook.sync_token,
+ remote_sync_token: 'canonical-token',
+ remote_sync_revision: '2',
+ }]);
+ expect(afterBook.sync_token).not.toBe(beforeBook.sync_token);
+ expect(after.contacts).toHaveLength(1);
+ const afterContact = after.contacts[0];
+ expect(afterContact).toEqual(expectedLocalContact(
+ canonicalHref, card, beforeBook.id, seeded.userId, afterContact.id,
+ ));
+ expect(after.ledger).toEqual([{
+ address_book_id: beforeBook.id,
+ href: canonicalHref,
+ remote_etag: '"canonical-1"',
+ vcard: card,
+ primary_email: 'alias-person@example.test',
+ local_contact_id: afterContact.id,
+ }]);
+ expect(fixture.requests.map(request => `${request.method} ${request.path}`)).toEqual([
+ 'PROPFIND /',
+ 'PROPFIND /principals/fixture-user/',
+ 'PROPFIND /addressbooks/fixture-user/',
+ `REPORT ${aliasPath}`,
+ `REPORT ${canonicalPath}`,
+ `REPORT ${canonicalPath}`,
+ `REPORT ${canonicalPath}`,
+ ]);
+ expect(fixture.counters).toMatchObject({
+ requests: 7,
+ propfind: 3,
+ sync: 2,
+ multiget: 1,
+ syncTokens: ['alias-token', ''],
+ multigetSizes: [1],
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('disconnect during paused planning restores merges and prevents old work from recreating projection', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const { rows: [localBook] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Disconnect Local') RETURNING id, sync_token
+ `, [seeded.userId]);
+ const localContact = {
+ uid: 'disconnect-local',
+ displayName: 'Disconnect Original',
+ firstName: 'Disconnect',
+ lastName: 'Original',
+ emails: [{ value: 'disconnect@example.test', type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ };
+ const localVcard = generateVCard(localContact);
+ const { rows: [insertedContact] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, organization, notes, photo_data
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '[]'::jsonb, NULL, NULL, NULL
+ ) RETURNING id
+ `, [
+ localBook.id,
+ seeded.userId,
+ localContact.uid,
+ localVcard,
+ createHash('md5').update(localVcard).digest('hex'),
+ localContact.displayName,
+ localContact.firstName,
+ localContact.lastName,
+ localContact.emails[0].value,
+ JSON.stringify(localContact.emails),
+ ]);
+ const href = fixture.href('disconnect.vcf');
+ const remoteCard = remoteVcard('disconnect-remote', 'Disconnect Remote', 'disconnect@example.test');
+ fixture.putContact(href, '"disconnect-1"', remoteCard);
+ fixture.queueSync('', {
+ events: [{ href, etag: '"disconnect-1"' }],
+ nextToken: 'disconnect-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true,
+ });
+ const linked = await projectionState(seeded.userId);
+ expect(linked.ledger[0].local_contact_id).toBe(insertedContact.id);
+ expect(linked.contacts.find(row => row.id === insertedContact.id).display_name)
+ .toBe('Disconnect Original');
+
+ const barrier = deferred();
+ const reached = deferred();
+ fixture.reset();
+ fixture.queueSync('disconnect-token', {
+ events: [],
+ nextToken: 'old-disconnect-plan',
+ waitFor: barrier.promise,
+ reached: reached.resolve,
+ });
+ const pending = carddavSync.syncUser(seeded.userId);
+ await reached.promise;
+ expect(await carddavSync.disconnectCarddavAccount(seeded.userId)).toBe(true);
+ const disconnected = await projectionState(seeded.userId);
+ expect(disconnected.books).toEqual([{
+ id: localBook.id,
+ source: 'local',
+ external_url: null,
+ sync_token: disconnected.books[0].sync_token,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ remote_projection_fingerprint: null,
+ }]);
+ expect(disconnected.books[0].sync_token).toBe(localBook.sync_token);
+ expect(disconnected.contacts).toEqual([{
+ id: insertedContact.id,
+ address_book_id: localBook.id,
+ user_id: seeded.userId,
+ uid: localContact.uid,
+ vcard: localVcard,
+ etag: createHash('md5').update(localVcard).digest('hex'),
+ display_name: localContact.displayName,
+ first_name: localContact.firstName,
+ last_name: localContact.lastName,
+ primary_email: localContact.emails[0].value,
+ emails: localContact.emails,
+ phones: [],
+ organization: null,
+ notes: null,
+ photo_data: null,
+ }]);
+ expect(disconnected.ledger).toEqual([]);
+ expect(await integrationState(seeded.userId)).toEqual([]);
+ barrier.resolve();
+
+ expect(await pending).toMatchObject({ ok: false });
+ expect(await projectionState(seeded.userId)).toEqual(disconnected);
+ expect(await integrationState(seeded.userId)).toEqual([]);
+ expect(fixture.counters).toMatchObject({
+ requests: 4,
+ propfind: 3,
+ sync: 1,
+ multiget: 0,
+ syncTokens: ['disconnect-token'],
+ multigetSizes: [],
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('photo-only sync persists bytes and production CardDAV GET and REPORT expose the same vCard and ETag', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href('photo.vcf');
+ const jpegCard = remotePhotoVcard(
+ 'photo', 'Photo Contact', 'photo@example.test', 'PHOTO;ENCODING=b;TYPE=JPEG:AQID',
+ );
+ fixture.putContact(href, '"photo-1"', jpegCard);
+ fixture.queueSync('', {
+ events: [{ href, etag: '"photo-1"' }],
+ nextToken: 'photo-token-1',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 1 });
+ const jpegState = await projectionState(seeded.userId);
+ const jpegBook = jpegState.books.find(row => row.source === 'carddav');
+ expect(jpegState.contacts[0].photo_data).toBe('data:image/jpeg;base64,AQID');
+ expect(jpegState.contacts[0].vcard).toContain('PHOTO;ENCODING=b;TYPE=JPEG:AQID\r\n');
+
+ const pngCard = remotePhotoVcard(
+ 'photo', 'Photo Contact', 'photo@example.test', 'PHOTO;ENCODING=b;TYPE=PNG:BAUG',
+ );
+ fixture.putContact(href, '"photo-2"', pngCard);
+ fixture.reset();
+ fixture.queueSync('photo-token-1', {
+ events: [{ href, etag: '"photo-2"' }],
+ nextToken: 'photo-token-2',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 1 });
+ const pngState = await projectionState(seeded.userId);
+ const pngBook = pngState.books.find(row => row.id === jpegBook.id);
+ const pngContact = pngState.contacts[0];
+ expect(pngContact.photo_data).toBe('data:image/png;base64,BAUG');
+ expect(pngContact.vcard).toContain('PHOTO;ENCODING=b;TYPE=PNG:BAUG\r\n');
+ expect(pngContact.etag).toBe(createHash('md5').update(pngContact.vcard).digest('hex'));
+ expect(pngContact.etag).not.toBe(jpegState.contacts[0].etag);
+ expect(pngBook.sync_token).not.toBe(jpegBook.sync_token);
+
+ await databaseClient.query(
+ 'UPDATE users SET password_hash = $2 WHERE id = $1',
+ [seeded.userId, await bcrypt.hash('carddav-output-password', 4)],
+ );
+ const app = express();
+ const { default: carddavRouter } = await import('../routes/carddav.js');
+ app.use('/carddav', carddavRouter);
+ const outputServer = await listenOnLocalhost(app);
+ const outputOrigin = `http://127.0.0.1:${outputServer.address().port}`;
+ const authorization = `Basic ${Buffer.from(
+ `carddav-e2e-${seeded.userId}:carddav-output-password`,
+ ).toString('base64')}`;
+ const cardPath = `/carddav/${seeded.userId}/${pngBook.id}/${encodeURIComponent(pngContact.uid)}.vcf`;
+ // The CardDAV server serves the retained remote document overlaid with the local
+ // contact (presentedVCard), so the photo round-trips losslessly. The ETag stays
+ // the local contacts.etag.
+ const { rows: [pngRow] } = await databaseClient.query(`
+ SELECT c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones,
+ c.organization, c.notes, c.photo_data, c.additional_fields, c.vcard,
+ mapping.vcard AS mapping_vcard
+ FROM contacts c
+ LEFT JOIN carddav_remote_objects mapping
+ ON mapping.local_contact_id = c.id
+ AND mapping.mapping_status <> 'pending_materialization'
+ WHERE c.id = $1
+ `, [pngContact.id]);
+ const presentedPng = presentedVCard(pngRow);
+ const servedEtag = `"${presentedEtag(pngRow)}"`;
+ expect(presentedPng).toContain('PHOTO;ENCODING=b;TYPE=PNG:BAUG');
+ const getResponse = await fetch(`${outputOrigin}${cardPath}`, {
+ headers: { Authorization: authorization },
+ });
+ expect(getResponse.status).toBe(200);
+ // The served ETag derives from the presented document.
+ expect(getResponse.headers.get('etag')).toBe(servedEtag);
+ expect(await getResponse.text()).toBe(presentedPng);
+ const reportResponse = await fetch(
+ `${outputOrigin}/carddav/${seeded.userId}/${pngBook.id}/`,
+ {
+ method: 'REPORT',
+ headers: {
+ Authorization: authorization,
+ Depth: '1',
+ 'Content-Type': 'application/xml',
+ },
+ body: ' ',
+ },
+ );
+ expect(reportResponse.status).toBe(207);
+ const reportBody = await reportResponse.text();
+ expect(reportBody).toContain(servedEtag);
+ expect(reportBody).toContain(presentedPng
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"'));
+ await closeServer(outputServer);
+
+ fixture.reset();
+ fixture.queueSync('photo-token-2', {
+ events: [{ href, etag: '"photo-2"' }],
+ nextToken: 'photo-token-3',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 0 });
+ const noOp = await projectionState(seeded.userId);
+ expect(noOp.books.find(row => row.id === pngBook.id)).toMatchObject({
+ sync_token: pngBook.sync_token,
+ remote_sync_revision: '3',
+ remote_sync_token: 'photo-token-3',
+ });
+ expect(noOp.contacts).toEqual(pngState.contacts);
+
+ const externalCard = remotePhotoVcard(
+ 'photo', 'Photo Contact', 'photo@example.test',
+ 'PHOTO;VALUE=URI:https://images.example.test/private.jpg',
+ );
+ fixture.putContact(href, '"photo-3"', externalCard);
+ fixture.reset();
+ fixture.queueSync('photo-token-3', {
+ events: [{ href, etag: '"photo-3"' }],
+ nextToken: 'photo-token-4',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 1 });
+ const external = await projectionState(seeded.userId);
+ expect(external.contacts[0].photo_data).toBeNull();
+ expect(external.contacts[0].vcard).not.toContain('PHOTO');
+ expect(external.books[0].sync_token).not.toBe(pngBook.sync_token);
+ await fixture.close();
+ }, 120_000);
+
+ it('gates and merges a mapped PUT through the real CardDAV server route', async () => {
+ const fixture = createCarddavFixtureServer();
+ let outputServer;
+ await fixture.listen();
+ try {
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href('http-merge.vcf');
+ const remoteCard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:http-merge-remote', 'FN:Http Merge',
+ 'EMAIL:http-merge@example.test', 'CATEGORIES:Original', 'X-KEEP:survive', 'END:VCARD',
+ ].join('\n');
+ fixture.putContact(href, '"http-1"', remoteCard);
+ fixture.queueSync('', { events: [{ href, etag: '"http-1"' }], nextToken: 'http-token-1' });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ const { rows: [row] } = await databaseClient.query(`
+ SELECT c.uid, c.address_book_id FROM contacts c
+ JOIN carddav_remote_objects o ON o.local_contact_id = c.id
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ `, [seeded.userId]);
+
+ await databaseClient.query('UPDATE users SET password_hash = $2 WHERE id = $1',
+ [seeded.userId, await bcrypt.hash('http-merge-password', 4)]);
+ const app = express();
+ const { default: carddavRouter } = await import('../routes/carddav.js');
+ app.use('/carddav', carddavRouter);
+ outputServer = await listenOnLocalhost(app);
+ const origin = `http://127.0.0.1:${outputServer.address().port}`;
+ const authorization = `Basic ${Buffer.from(`carddav-e2e-${seeded.userId}:http-merge-password`).toString('base64')}`;
+ const cardUrl = `${origin}/carddav/${seeded.userId}/${row.address_book_id}/${encodeURIComponent(row.uid)}.vcf`;
+ const put = (headers, body) => fetch(cardUrl, { method: 'PUT', headers: { Authorization: authorization, ...headers }, body });
+
+ const getResponse = await fetch(cardUrl, { headers: { Authorization: authorization } });
+ const servedEtag = getResponse.headers.get('etag');
+
+ const mergeBody = [
+ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${row.uid}`, 'FN:Http Merge',
+ 'EMAIL:http-merge@example.test', 'CATEGORIES:Changed', 'END:VCARD', '',
+ ].join('\r\n');
+
+ // Gate: an unconditional PUT is rejected and nothing reaches upstream.
+ fixture.reset();
+ expect((await put({}, mergeBody)).status).toBe(428);
+ // Malformed body with a valid If-Match → 400 before any network.
+ expect((await put({ 'If-Match': servedEtag, 'Content-Type': 'text/vcard' }, 'not a vcard')).status).toBe(400);
+ expect(fixture.requests.some(request => request.method === 'PUT')).toBe(false);
+
+ // Conditional merge through the gate: 204, and the upstream PUT is the two-tier merge
+ // with the remote UID preserved.
+ fixture.putContact(href, '"http-1"', remoteCard);
+ const merged = await put({ 'If-Match': servedEtag, 'Content-Type': 'text/vcard' }, mergeBody);
+ expect(merged.status).toBe(204);
+ const upstream = fixture.requests.find(request => request.method === 'PUT');
+ expect(upstream).toBeDefined();
+ expect(upstream.body).toContain('CATEGORIES:Changed'); // client's unmodeled edit lands
+ expect(upstream.body).toContain('X-KEEP:survive'); // omitted unmodeled survives
+ expect(parseVCard(upstream.body).uid).toBe('http-merge-remote'); // remote UID preserved
+ } finally {
+ if (outputServer) await closeServer(outputServer);
+ await fixture.close();
+ }
+ }, 120_000);
+
+ it('keeps an automatically linked local photo unchanged across remote photo updates', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const { rows: [localBook] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Merged Photo Local') RETURNING id, sync_token
+ `, [seeded.userId]);
+ const local = {
+ uid: 'merged-photo-local',
+ displayName: 'Merged Photo',
+ firstName: null,
+ lastName: null,
+ emails: [{ value: 'merged-photo@example.test', type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ };
+ const originalVcard = generateVCard(local);
+ const originalEtag = createHash('md5').update(originalVcard).digest('hex');
+ const { rows: [localRow] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, primary_email,
+ emails, phones, photo_data
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, NULL)
+ RETURNING id
+ `, [
+ localBook.id,
+ seeded.userId,
+ local.uid,
+ originalVcard,
+ originalEtag,
+ local.displayName,
+ local.emails[0].value,
+ JSON.stringify(local.emails),
+ ]);
+ const href = fixture.href('merged-photo.vcf');
+ const jpeg = remotePhotoVcard(
+ 'merged-photo-remote', 'Merged Photo', local.emails[0].value,
+ 'PHOTO;ENCODING=b;TYPE=JPEG:AQID',
+ );
+ fixture.putContact(href, '"merged-photo-1"', jpeg);
+ fixture.queueSync('', {
+ events: [{ href, etag: '"merged-photo-1"' }],
+ nextToken: 'merged-photo-1',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ const jpegState = await projectionState(seeded.userId);
+ const jpegLocal = jpegState.contacts.find(row => row.id === localRow.id);
+ const jpegBook = jpegState.books.find(row => row.id === localBook.id);
+ expect(jpegLocal.photo_data).toBeNull();
+ expect(jpegLocal.vcard).toBe(originalVcard);
+ expect(jpegBook.sync_token).toBe(localBook.sync_token);
+
+ const png = remotePhotoVcard(
+ 'merged-photo-remote', 'Merged Photo', local.emails[0].value,
+ 'PHOTO;ENCODING=b;TYPE=PNG:BAUG',
+ );
+ fixture.putContact(href, '"merged-photo-2"', png);
+ fixture.reset();
+ fixture.queueSync('merged-photo-1', {
+ events: [{ href, etag: '"merged-photo-2"' }],
+ nextToken: 'merged-photo-2',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ const pngState = await projectionState(seeded.userId);
+ const pngLocal = pngState.contacts.find(row => row.id === localRow.id);
+ const pngBook = pngState.books.find(row => row.id === localBook.id);
+ expect(pngLocal).toEqual(jpegLocal);
+ expect(pngBook.sync_token).toBe(jpegBook.sync_token);
+
+ fixture.reset();
+ fixture.queueSync('merged-photo-2', {
+ events: [{ href, etag: '"merged-photo-2"' }],
+ nextToken: 'merged-photo-3',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 0 });
+ const noOp = await projectionState(seeded.userId);
+ expect(noOp.books.find(row => row.id === localBook.id).sync_token).toBe(pngBook.sync_token);
+ expect(noOp.contacts.find(row => row.id === localRow.id)).toEqual(pngLocal);
+
+ fixture.deleteContact(href);
+ fixture.reset();
+ fixture.queueSync('merged-photo-3', {
+ events: [{ href, status: 404 }],
+ nextToken: 'merged-photo-4',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, removed: 0 });
+ const restored = await projectionState(seeded.userId);
+ const restoredLocal = restored.contacts.find(row => row.id === localRow.id);
+ expect(restoredLocal).toMatchObject({
+ id: localRow.id,
+ display_name: local.displayName,
+ primary_email: local.emails[0].value,
+ emails: local.emails,
+ photo_data: null,
+ });
+ expect(parseVCard(restoredLocal.vcard)).toMatchObject({
+ uid: local.uid,
+ displayName: local.displayName,
+ emails: local.emails,
+ photoData: null,
+ });
+ expect(restored.books.find(row => row.id === localBook.id).sync_token)
+ .not.toBe(pngBook.sync_token);
+ expect(restored.ledger).toEqual([expect.objectContaining({
+ href: fixture.href(`${local.uid}.vcf`),
+ vcard: restoredLocal.vcard,
+ primary_email: local.emails[0].value,
+ local_contact_id: localRow.id,
+ })]);
+ expect(fixture.counters.create).toBe(1);
+ await fixture.close();
+ }, 120_000);
+
+ it('replacement queues exactly one latest generation after old planning releases', async () => {
+ const oldFixture = createCarddavFixtureServer();
+ const newFixture = createCarddavFixtureServer();
+ await oldFixture.listen();
+ await newFixture.listen();
+ const seeded = await seedConnectedUser(oldFixture);
+ const oldContact = await seedSingleRemoteContact(oldFixture, seeded.userId, {
+ uid: 'old-generation',
+ name: 'Old Generation',
+ token: 'old-generation-token',
+ });
+ const oldState = await projectionState(seeded.userId);
+ const oldBook = oldState.books.find(row => row.source === 'carddav');
+ const oldBarrier = deferred();
+ const oldReached = deferred();
+ oldFixture.reset();
+ oldFixture.queueSync(oldContact.token, {
+ events: [],
+ nextToken: 'old-generation-must-not-commit',
+ waitFor: oldBarrier.promise,
+ reached: oldReached.resolve,
+ });
+
+ const newHref = newFixture.href('new-generation.vcf');
+ const newCard = remoteVcard('new-generation', 'New Generation');
+ newFixture.putContact(newHref, '"new-generation-1"', newCard);
+ newFixture.queueSync('', {
+ events: [{ href: newHref, etag: '"new-generation-1"' }],
+ nextToken: 'new-generation-token',
+ });
+ const oldPending = carddavSync.syncUser(seeded.userId);
+ await oldReached.promise;
+
+ const app = express();
+ app.use(express.json());
+ app.use((request, response, next) => {
+ request.session = { userId: seeded.userId };
+ next();
+ });
+ const { default: carddavAccountRouter } = await import('../routes/carddavAccount.js');
+ app.use('/carddav-account', carddavAccountRouter);
+ const accountServer = await listenOnLocalhost(app);
+ const accountOrigin = `http://127.0.0.1:${accountServer.address().port}`;
+ const replacementResponse = await fetch(`${accountOrigin}/carddav-account/connect`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ serverUrl: newFixture.serverUrl,
+ username: 'replacement-user',
+ password: 'replacement-password',
+ intervalMin: 60,
+ }),
+ });
+ expect(replacementResponse.status).toBe(200);
+ expect(await replacementResponse.json()).toEqual({
+ connected: true,
+ serverUrl: newFixture.serverUrl,
+ username: 'replacement-user',
+ intervalMin: 60,
+ lastSyncAt: null,
+ lastError: null,
+ bookCount: null,
+ contactCount: 1,
+ });
+ const [replacement] = await integrationState(seeded.userId);
+ expect(replacement.config).toEqual({
+ serverUrl: newFixture.serverUrl,
+ username: 'replacement-user',
+ password: expect.stringMatching(/^enc:v1:/),
+ intervalMin: 60,
+ connectionGeneration: expect.any(String),
+ lastError: null,
+ contactCount: 1,
+ });
+ expect(replacement.config.connectionGeneration).not.toBe(seeded.connectionGeneration);
+ expect(replacement.config.password).not.toBe('replacement-password');
+ oldBarrier.resolve();
+
+ expect(await oldPending).toEqual({
+ ok: false,
+ error: 'CardDAV sync plan is stale',
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ });
+ await waitForPostgresState({
+ description: 'queued replacement generation to finish',
+ probe: async () => {
+ const [integration] = await integrationState(seeded.userId);
+ const state = {
+ connectionGeneration: integration?.config?.connectionGeneration ?? null,
+ hasLastError: integration?.config?.lastError != null,
+ bookCount: integration?.config?.bookCount ?? null,
+ contactCount: integration?.config?.contactCount ?? null,
+ lastSyncAt: integration?.config?.lastSyncAt ?? null,
+ };
+ return {
+ done: state.connectionGeneration === replacement.config.connectionGeneration
+ && !state.hasLastError
+ && state.bookCount === 1
+ && state.contactCount === 1
+ && Boolean(state.lastSyncAt),
+ state,
+ };
+ },
+ });
+
+ const finalProjection = await projectionState(seeded.userId);
+ expect(finalProjection.books).toHaveLength(1);
+ expect(finalProjection.books[0]).toMatchObject({
+ source: 'carddav',
+ external_url: newFixture.href(''),
+ remote_sync_token: 'new-generation-token',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '1',
+ remote_projection_fingerprint: expect.any(String),
+ });
+ expect(finalProjection.books[0].id).not.toBe(oldBook.id);
+ const finalContact = finalProjection.contacts[0];
+ expect(finalProjection.contacts).toEqual([
+ expectedLocalContact(
+ newHref,
+ newCard,
+ finalProjection.books[0].id,
+ seeded.userId,
+ finalContact.id,
+ ),
+ ]);
+ expect(finalProjection.ledger).toEqual([{
+ address_book_id: finalProjection.books[0].id,
+ href: newHref,
+ remote_etag: '"new-generation-1"',
+ vcard: newCard,
+ primary_email: 'new-generation@example.test',
+ local_contact_id: finalContact.id,
+ }]);
+ const [finalIntegration] = await integrationState(seeded.userId);
+ expect(finalIntegration.config).toEqual({
+ ...replacement.config,
+ lastSyncAt: expect.any(String),
+ lastError: null,
+ bookCount: 1,
+ contactCount: 1,
+ exportFailures: [],
+ });
+ expect(Number.isNaN(Date.parse(finalIntegration.config.lastSyncAt))).toBe(false);
+ expect(oldFixture.counters).toMatchObject({
+ requests: 4,
+ propfind: 3,
+ sync: 1,
+ multiget: 0,
+ syncTokens: ['old-generation-token'],
+ });
+ expect(newFixture.counters).toMatchObject({
+ requests: 8,
+ propfind: 6,
+ sync: 1,
+ multiget: 1,
+ syncTokens: [''],
+ multigetSizes: [1],
+ });
+ const newAuthorization = `Basic ${Buffer.from(
+ 'replacement-user:replacement-password',
+ ).toString('base64')}`;
+ expect(newFixture.requests.every(request => request.authorization === newAuthorization))
+ .toBe(true);
+ expect(oldFixture.requests.every(request => (
+ request.authorization === 'Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ='
+ ))).toBe(true);
+
+ carddavSync.stopCardavUser(seeded.userId);
+ await closeServer(accountServer);
+ await oldFixture.close();
+ await newFixture.close();
+ }, 120_000);
+
+ it('changed username on the same collection invalidates the old token and fully reconciles', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture, {
+ username: 'identity-old-user',
+ password: 'identity-old-password',
+ });
+ const retainedHref = fixture.href('identity-retained.vcf');
+ const oldOnlyHref = fixture.href('identity-old-only.vcf');
+ const retainedBefore = remoteVcard('identity-retained', 'Identity Retained Before');
+ const oldOnly = remoteVcard('identity-old-only', 'Identity Old Only');
+ fixture.putContact(retainedHref, '"identity-retained-1"', retainedBefore);
+ fixture.putContact(oldOnlyHref, '"identity-old-only-1"', oldOnly);
+ fixture.queueSync('', {
+ events: [
+ { href: retainedHref, etag: '"identity-retained-1"' },
+ { href: oldOnlyHref, etag: '"identity-old-only-1"' },
+ ],
+ nextToken: 'identity-old-token',
+ });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, contactCount: 2,
+ });
+ const beforeReplacement = await projectionState(seeded.userId);
+ const beforeBook = beforeReplacement.books.find(book => book.source === 'carddav');
+
+ fixture.reset();
+ const retainedAfter = remoteVcard('identity-retained', 'Identity Retained After');
+ fixture.putContact(retainedHref, '"identity-retained-2"', retainedAfter);
+ fixture.deleteContact(oldOnlyHref);
+ fixture.queueSync('identity-old-token', {
+ events: [],
+ nextToken: 'identity-wrong-partial-token',
+ });
+ fixture.queueSync('', {
+ events: [{ href: retainedHref, etag: '"identity-retained-2"' }],
+ nextToken: 'identity-new-token',
+ });
+ const replacement = await carddavSync.replaceCarddavConnection(seeded.userId, {
+ serverUrl: fixture.serverUrl,
+ username: 'identity-new-user',
+ password: encrypt('identity-new-password'),
+ intervalMin: 60,
+ });
+ expect(replacement.connectionGeneration).not.toBe(seeded.connectionGeneration);
+ const invalidated = await projectionState(seeded.userId);
+ expect(invalidated.books.find(book => book.id === beforeBook.id)).toMatchObject({
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: String(Number(beforeBook.remote_sync_revision) + 1),
+ remote_projection_fingerprint: null,
+ });
+ expect(invalidated.contacts).toEqual(beforeReplacement.contacts);
+ expect(invalidated.ledger).toEqual(beforeReplacement.ledger);
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, contactCount: 1, removed: 1,
+ });
+ const after = await projectionState(seeded.userId);
+ expect(after.books).toHaveLength(1);
+ expect(after.books[0]).toMatchObject({
+ id: beforeBook.id,
+ remote_sync_token: 'identity-new-token',
+ remote_sync_capability: 'sync-collection',
+ });
+ expect(after.contacts).toHaveLength(1);
+ expect(after.contacts[0].display_name).toBe('Identity Retained After');
+ expect(after.ledger.map(object => object.href)).toEqual([retainedHref]);
+ expect(fixture.counters.syncTokens).toEqual(['']);
+ const expectedAuthorization = `Basic ${Buffer.from(
+ 'identity-new-user:identity-new-password',
+ ).toString('base64')}`;
+ expect(fixture.requests.filter(request => request.authorization)
+ .every(request => request.authorization === expectedAuthorization)).toBe(true);
+ await fixture.close();
+ }, 120_000);
+
+ async function materializedCarddavContactCount(userId) {
+ const { rows: [row] } = await databaseClient.query(`
+ SELECT count(*)::int AS count
+ FROM contacts c
+ JOIN address_books b ON b.id = c.address_book_id
+ WHERE c.user_id = $1 AND b.source = 'carddav'
+ `, [userId]);
+ return row.count;
+ }
+
+ // Puts `size` contacts in the remote book and scripts the snapshot that reports all of
+ // them for `syncToken` — re-callable, so a full re-fetch can be replayed after a reset.
+ function queueRemoteSnapshot(fixture, { size, syncToken = '', nextToken }) {
+ const events = [];
+ for (let index = 0; index < size; index++) {
+ const uid = `count-${index}`;
+ const href = fixture.href(`${uid}.vcf`);
+ const etag = `"etag-${uid}"`;
+ fixture.putContact(href, etag, remoteVcard(uid, `Count Contact ${index}`));
+ events.push({ href, etag });
+ }
+ fixture.queueSync(syncToken, { events, nextToken });
+ }
+
+ async function clearRemoteSyncTokens(userId) {
+ await databaseClient.query(`
+ UPDATE address_books
+ SET remote_sync_token = NULL, remote_projection_fingerprint = NULL
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [userId]);
+ }
+
+ // contactCount was accumulated (a per-book delta added to the stored total), so a
+ // full snapshot against an empty ledger added the book's contacts on top of a total that
+ // already counted them — exactly what an upgrade from the read-only sync leaves behind.
+ it('includes contacts published by the export sweep in the finalized contactCount', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ await seedUnmappedExplicitContact(seeded.userId, {
+ uid: 'export-count',
+ displayName: 'Export Count',
+ });
+ fixture.queueSync('', { events: [], nextToken: 'export-count-token' });
+
+ const result = await carddavSync.syncUser(seeded.userId);
+ const state = await projectionState(seeded.userId);
+ const [integration] = await integrationState(seeded.userId);
+
+ expect(result).toMatchObject({ ok: true, contactCount: 1, exportFailures: [] });
+ expect(state.ledger).toHaveLength(1);
+ expect(integration.config.contactCount).toBe(1);
+ await fixture.close();
+ }, 120_000);
+
+ it('recounts contactCount on a full snapshot inheriting a read-only total', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ queueRemoteSnapshot(fixture, { size: 3, nextToken: 'count-token-1' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(3);
+
+ // The state an upgrade from the read-only sync lands in: contacts are materialized and
+ // config carries their (correct) count, but the mapping ledger is new and empty, and
+ // the cleared remote token forces the next sync down the full-snapshot path.
+ await databaseClient.query(`
+ DELETE FROM carddav_remote_objects o
+ USING address_books b
+ WHERE b.id = o.address_book_id AND b.user_id = $1
+ `, [seeded.userId]);
+ await clearRemoteSyncTokens(seeded.userId);
+ fixture.reset();
+ queueRemoteSnapshot(fixture, { size: 3, nextToken: 'count-token-2' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ expect(await materializedCarddavContactCount(seeded.userId)).toBe(3);
+ expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(3);
+ await fixture.close();
+ }, 120_000);
+
+ it('heals an already inflated contactCount on the next sync', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ queueRemoteSnapshot(fixture, { size: 2, nextToken: 'inflated-token-1' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = jsonb_set(config, '{contactCount}', to_jsonb(1772))
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId]);
+ fixture.reset();
+ fixture.queueSync('inflated-token-1', { events: [], nextToken: 'inflated-token-2' });
+
+ // An idempotent sync: nothing changed remotely, so only a derived count converges.
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true,
+ contactCount: 2,
+ });
+
+ expect(await materializedCarddavContactCount(seeded.userId)).toBe(2);
+ expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(2);
+ await fixture.close();
+ }, 120_000);
+
+ it('keeps contactCount equal to the row count across repeated full re-fetches', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ queueRemoteSnapshot(fixture, { size: 4, nextToken: 'refetch-token-0' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ for (let pass = 1; pass <= 2; pass++) {
+ await clearRemoteSyncTokens(seeded.userId);
+ fixture.reset();
+ queueRemoteSnapshot(fixture, { size: 4, nextToken: `refetch-token-${pass}` });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ expect((await integrationState(seeded.userId))[0].config.contactCount)
+ .toBe(await materializedCarddavContactCount(seeded.userId));
+ }
+
+ expect(await materializedCarddavContactCount(seeded.userId)).toBe(4);
+ await fixture.close();
+ }, 120_000);
+});
diff --git a/backend/src/services/carddavSync.js b/backend/src/services/carddavSync.js
index ddbe589..1b8a064 100644
--- a/backend/src/services/carddavSync.js
+++ b/backend/src/services/carddavSync.js
@@ -1,18 +1,120 @@
// CardDAV sync orchestration + scheduler. Pulls contacts from a user's connected
// CardDAV server (provider='carddav' in user_integrations) into per-remote-book,
-// read-only local address books. One-way / read-only. Duplicate handling across
-// books is chosen by the user: 'separate' | 'merge' | 'skip'.
+// read-only local address books. Remote objects are linked to explicit local
+// contacts deterministically and unmatched contacts are imported or exported.
import crypto from 'crypto';
-import { query } from './db.js';
-import { decrypt } from './encryption.js';
-import { parseVCard } from '../utils/vcard.js';
-import { getConnectionPolicy } from './connectionPolicy.js';
-import { discoverAddressBooks, fetchAddressBookCards } from './carddavClient.js';
+import {
+ localContactHash,
+ localVCardEtag,
+ overlayContactOnVCard,
+ parseVCardDocument,
+ presentedVCard,
+ pushSafeSnapshot,
+ primaryEmail,
+ semanticVCardHash,
+ serializeVCardDocument,
+} from '../utils/vcardProperties.js';
+import { query, withTransaction } from './db.js';
+import { generateVCard, parseVCard } from '../utils/vcard.js';
+import { discoverAddressBooks, fetchAddressBookDelta } from './carddavClient.js';
+import {
+ CardDavError,
+ activeRetryAfterAt,
+ resolveCarddavCredentials,
+} from './carddavTransport.js';
+import {
+ exportExistingContact,
+ recoverPendingCarddavMutations,
+} from './carddavContactService.js';
+import { deleteResolvedConflictsBefore } from './carddavConflictService.js';
+import {
+ advanceDiscoveredBookState,
+ applyConfirmedRemoteContact,
+ applyRemoteTombstone,
+ lockCarddavIntegration,
+ normalizeCarddavCapabilities,
+ persistDiscoveredBook,
+ refreshUnresolvedConflict,
+} from './carddavMappingState.js';
+import { normalizeEmail, planAutomaticProjection } from './carddavProjection.js';
const DEFAULT_INTERVAL_MIN = 60;
+const CONFLICT_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
+const CONFLICT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const timers = new Map(); // userId -> interval id
+let conflictCleanupTimer;
const syncing = new Set(); // userIds with a sync in flight (prevents overlap)
+const activeSyncGenerations = new Map();
+const pendingReplacementSyncs = new Set();
+const RESULT_COUNTERS = [
+ 'remote', 'fetched', 'updated', 'removed', 'fallback',
+];
+
+const emptyResultCounters = () => Object.fromEntries(RESULT_COUNTERS.map(counter => [counter, 0]));
+const PLAN_FENCES = ['connectionGeneration', 'expectedRemoteRevision', 'expectedRemoteToken'];
+const STALE_PLAN_ACTIONS = Object.freeze({
+ 'invalid-plan-fence': 'abort',
+ 'not-connected': 'abort',
+ 'connection-generation-changed': 'abort',
+ 'projection-footprint-changed': 'retry-apply',
+ 'mapping-revision-changed': 'abort',
+ 'mapping-contact-missing': 'abort',
+ 'canonical-url-conflict': 'abort',
+ 'book-update-missed': 'abort',
+ 'canonical-reconciliation-required': 'abort',
+ 'observed-alias-missing': 'abort',
+ 'remote-revision-changed': 'refetch-once',
+ 'remote-token-changed': 'refetch-once',
+});
+
+function addResultCounters(total, counters) {
+ for (const counter of RESULT_COUNTERS) total[counter] += counters[counter] || 0;
+}
+
+function carddavContactCount(config) {
+ const count = Number(config?.contactCount);
+ return Number.isSafeInteger(count) && count >= 0 ? count : 0;
+}
+
+function projectionFingerprint(books) {
+ const inputs = books
+ .map(book => [book.id, book.sync_token])
+ .sort(([left], [right]) => left.localeCompare(right));
+ return crypto.createHash('sha256').update(JSON.stringify(inputs)).digest('hex');
+}
+
+function requirePlanFences(plan) {
+ if (PLAN_FENCES.every(field => Object.hasOwn(plan, field))) return;
+ throw new StaleCarddavPlanError({ reason: 'invalid-plan-fence' });
+}
+
+export function stalePlanAction(reason) {
+ if (Object.hasOwn(STALE_PLAN_ACTIONS, reason)) return STALE_PLAN_ACTIONS[reason];
+ throw new TypeError(`Unknown stale CardDAV plan reason: ${reason}`);
+}
+
+function observedCapabilities(book) {
+ return normalizeCarddavCapabilities(book.capabilities);
+}
+
+export class StaleCarddavPlanError extends Error {
+ constructor(details) {
+ stalePlanAction(details?.reason);
+ super(details?.reason === 'not-connected' ? 'not connected' : 'CardDAV sync plan is stale');
+ this.name = 'StaleCarddavPlanError';
+ Object.assign(this, details);
+ }
+}
+
+export function assertConnectionGeneration(actual, expected) {
+ if (expected === undefined || actual === expected) return;
+ throw new StaleCarddavPlanError({
+ reason: 'connection-generation-changed',
+ expectedConnectionGeneration: expected,
+ actualConnectionGeneration: actual ?? null,
+ });
+}
export async function getCardavConfig(userId) {
const r = await query(
@@ -22,179 +124,1232 @@ export async function getCardavConfig(userId) {
return r.rows[0]?.config || null;
}
-// Shallow-merge a patch into the stored JSONB config.
-export async function saveCardavConfig(userId, patch) {
- await query(
- `UPDATE user_integrations SET config = config || $2::jsonb, updated_at = NOW()
- WHERE user_id = $1 AND provider = 'carddav'`,
- [userId, JSON.stringify(patch)],
+async function invalidateCarddavBookIdentity(client, userId) {
+ const { rows: books } = await client.query(
+ `SELECT id
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY id
+ FOR UPDATE`,
+ [userId],
);
-}
-
-// Find or create the local read-only address book mirroring a remote collection,
-// keyed by external_url. Address-book names are unique per user, so on a name
-// clash we disambiguate with a suffix.
-async function ensureCardavBook(userId, book) {
- const existing = await query(
- "SELECT id FROM address_books WHERE user_id = $1 AND external_url = $2",
- [userId, book.url],
+ if (!books.length) return;
+ await client.query(
+ `UPDATE address_books
+ SET remote_sync_token = NULL,
+ remote_sync_capability = 'unknown',
+ remote_sync_revision = remote_sync_revision + 1,
+ remote_projection_fingerprint = NULL,
+ updated_at = NOW()
+ WHERE user_id = $1 AND id = ANY($2::uuid[])`,
+ [userId, books.map(book => book.id)],
);
- if (existing.rows.length) return existing.rows[0].id;
+}
- for (let attempt = 0; attempt < 20; attempt++) {
- const name = attempt === 0 ? book.displayName : `${book.displayName} (${attempt + 1})`;
- try {
- const r = await query(
- `INSERT INTO address_books (user_id, name, source, external_url)
- VALUES ($1, $2, 'carddav', $3) RETURNING id`,
- [userId, name, book.url],
+export async function replaceCarddavConnection(userId, connection) {
+ return withTransaction(async client => {
+ const integration = await lockCarddavIntegration(client, userId);
+ const contactCount = integration ? carddavContactCount(integration.config) : null;
+ if (integration) {
+ const identityChanged = integration.config?.serverUrl !== connection.serverUrl
+ || integration.config?.username !== connection.username;
+ if (identityChanged) await invalidateCarddavBookIdentity(client, userId);
+ }
+ const config = {
+ serverUrl: connection.serverUrl,
+ username: connection.username,
+ password: connection.password,
+ intervalMin: connection.intervalMin ?? DEFAULT_INTERVAL_MIN,
+ connectionGeneration: crypto.randomUUID(),
+ lastError: null,
+ };
+ if (contactCount !== null) config.contactCount = contactCount;
+ if (integration) {
+ const updated = await client.query(
+ `UPDATE user_integrations
+ SET config = $2::jsonb, updated_at = NOW()
+ WHERE id = $1`,
+ [integration.id, JSON.stringify(config)],
+ );
+ if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ } else {
+ await client.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', $2::jsonb)`,
+ [userId, JSON.stringify(config)],
);
- return r.rows[0].id;
- } catch (err) {
- if (err.code === '23505') continue; // name taken — try next suffix
- throw err;
}
+ return config;
+ });
+}
+
+export async function patchCarddavConnection(userId, patch, expectedGeneration) {
+ return withTransaction(async client => {
+ const integration = await lockCarddavIntegration(client, userId);
+ if (!integration) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ const actualGeneration = integration.config?.connectionGeneration ?? null;
+ assertConnectionGeneration(actualGeneration, expectedGeneration);
+ const identityChanged = ['serverUrl', 'username'].some(field => (
+ Object.hasOwn(patch, field) && patch[field] !== integration.config?.[field]
+ ));
+ if (identityChanged) await invalidateCarddavBookIdentity(client, userId);
+
+ const nextPatch = {};
+ for (const field of ['serverUrl', 'username', 'password', 'intervalMin']) {
+ if (Object.hasOwn(patch, field)) nextPatch[field] = patch[field];
+ }
+ if (['serverUrl', 'username', 'password'].some(field => Object.hasOwn(patch, field))) {
+ nextPatch.connectionGeneration = crypto.randomUUID();
+ nextPatch.lastError = null;
+ }
+ const config = { ...integration.config, ...nextPatch };
+ const updated = await client.query(
+ `UPDATE user_integrations
+ SET config = $2::jsonb, updated_at = NOW()
+ WHERE id = $1`,
+ [integration.id, JSON.stringify(config)],
+ );
+ if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ return config;
+ });
+}
+
+async function lockCarddavBooks(client, userId, urls) {
+ const result = await client.query(
+ `SELECT id, external_url, remote_sync_token, remote_sync_revision::text, sync_token,
+ remote_projection_fingerprint
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = ANY($2::text[])
+ ORDER BY id
+ FOR UPDATE`,
+ [userId, urls],
+ );
+ return result.rows;
+}
+
+async function lockEligibleTargetBooks(client, userId) {
+ const result = await client.query(
+ `SELECT id, source, sync_token
+ FROM address_books
+ WHERE user_id = $1 AND source <> 'carddav'
+ ORDER BY id
+ FOR UPDATE`,
+ [userId],
+ );
+ return result.rows;
+}
+
+async function validateTargetBookFootprint(client, userId, lockedBooks) {
+ const { rows: currentBooks } = await client.query(
+ `SELECT id, source, sync_token
+ FROM address_books
+ WHERE user_id = $1 AND source <> 'carddav'
+ ORDER BY id`,
+ [userId],
+ );
+ const unchanged = lockedBooks.length === currentBooks.length
+ && lockedBooks.every((book, index) => (
+ book.id === currentBooks[index].id
+ && book.sync_token === currentBooks[index].sync_token
+ ));
+ if (!unchanged) {
+ throw new StaleCarddavPlanError({ reason: 'projection-footprint-changed' });
+ }
+}
+
+async function lockProjectionContacts(client, userId, sourceBookId) {
+ const result = await client.query(`
+ SELECT c.id, c.address_book_id, ab.source AS address_book_source,
+ c.uid, c.vcard, c.etag, c.display_name, c.first_name, c.last_name,
+ c.primary_email, c.emails, c.phones, c.organization, c.notes, c.photo_data,
+ c.additional_fields, c.is_auto
+ FROM contacts c
+ JOIN address_books ab ON ab.id = c.address_book_id
+ WHERE c.user_id = $1
+ AND (c.address_book_id = $2 OR ab.source <> 'carddav')
+ ORDER BY c.id
+ FOR UPDATE OF c
+ `, [userId, sourceBookId]);
+ return result.rows;
+}
+
+async function validateProjectionFootprint(client, userId, targetBooks, contactRows, sourceBookId) {
+ const currentBooks = await client.query(
+ `SELECT id
+ FROM address_books
+ WHERE user_id = $1 AND source <> 'carddav'
+ ORDER BY id`,
+ [userId],
+ );
+ const currentContacts = await client.query(
+ `SELECT c.id
+ FROM contacts c
+ JOIN address_books ab ON ab.id = c.address_book_id
+ WHERE c.user_id = $1
+ AND (c.address_book_id = $2 OR ab.source <> 'carddav')
+ ORDER BY c.id`,
+ [userId, sourceBookId],
+ );
+ const sameIds = (left, right) => left.length === right.length
+ && left.every((row, index) => row.id === right[index].id);
+ if (sameIds(targetBooks, currentBooks.rows) && sameIds(contactRows, currentContacts.rows)) return;
+ throw new StaleCarddavPlanError({ reason: 'projection-footprint-changed' });
+}
+
+async function rotateChangedBookTokens(client, userId, changedBookIds) {
+ const ids = [...new Set(changedBookIds)].sort();
+ if (!ids.length) return [];
+ const result = await client.query(
+ `UPDATE address_books
+ SET sync_token = gen_random_uuid()::text, updated_at = NOW()
+ WHERE user_id = $1 AND id = ANY($2::uuid[])
+ RETURNING id, source, sync_token`,
+ [userId, ids],
+ );
+ if (result.rowCount !== ids.length) {
+ throw new StaleCarddavPlanError({ reason: 'projection-footprint-changed' });
}
- throw new Error(`Could not create a local address book for "${book.displayName}"`);
+ return result.rows;
}
function contactFromVCard(vcard, href) {
const c = parseVCard(vcard);
const uid = c.uid || crypto.createHash('md5').update(href).digest('hex');
- const primaryEmail = c.emails.find(e => e.primary)?.value || c.emails[0]?.value || null;
+ const preferredEmail = primaryEmail(c);
return {
uid,
- displayName: c.displayName || primaryEmail || null,
+ displayName: c.displayName || preferredEmail || null,
firstName: c.firstName, lastName: c.lastName,
- primaryEmail: primaryEmail ? primaryEmail.toLowerCase().trim() : null,
+ primaryEmail: preferredEmail,
emails: c.emails, phones: c.phones,
organization: c.organization, notes: c.notes, photoData: c.photoData,
+ additionalFields: c.additionalFields || [],
vcard,
};
}
-async function upsertCardavContact(bookId, userId, c) {
- const etag = crypto.createHash('md5').update(c.vcard).digest('hex');
- await query(`
- INSERT INTO contacts (
- address_book_id, user_id, uid, vcard, etag,
- display_name, first_name, last_name, primary_email,
- emails, phones, organization, notes, photo_data, is_auto
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,false)
- ON CONFLICT (address_book_id, uid) DO UPDATE SET
- vcard = EXCLUDED.vcard, etag = EXCLUDED.etag,
- display_name = EXCLUDED.display_name, first_name = EXCLUDED.first_name,
- last_name = EXCLUDED.last_name, primary_email = EXCLUDED.primary_email,
- emails = EXCLUDED.emails, phones = EXCLUDED.phones,
- organization = EXCLUDED.organization, notes = EXCLUDED.notes,
- photo_data = EXCLUDED.photo_data, updated_at = NOW()
- `, [
- bookId, userId, c.uid, c.vcard, etag,
- c.displayName, c.firstName, c.lastName, c.primaryEmail,
- JSON.stringify(c.emails), JSON.stringify(c.phones),
- c.organization, c.notes, c.photoData,
- ]);
-}
-
-// Enrich an existing contact (in another book) with the vCard's descriptive
-// fields. We deliberately leave primary_email/emails untouched to avoid churning
-// that book's per-book email-uniqueness index.
-async function mergeIntoExisting(id, c) {
- const etag = crypto.createHash('md5').update(c.vcard).digest('hex');
- await query(`
- UPDATE contacts SET
- display_name = $2, first_name = $3, last_name = $4,
- phones = $5::jsonb, organization = $6, notes = $7,
- photo_data = COALESCE($8, photo_data), vcard = $9, etag = $10, updated_at = NOW()
- WHERE id = $1
- `, [id, c.displayName, c.firstName, c.lastName, JSON.stringify(c.phones),
- c.organization, c.notes, c.photoData, c.vcard, etag]);
-}
-
-async function syncBook(userId, book, dupMode, creds) {
- const bookId = await ensureCardavBook(userId, book);
- const rawCards = await fetchAddressBookCards({ ...book, ...creds });
- const cards = rawCards.map(rc => contactFromVCard(rc.vcard, rc.href));
-
- // Emails present in the user's OTHER books, for cross-book duplicate handling.
- const otherEmail = new Map(); // email -> existing contact id
- if (dupMode !== 'separate') {
- const rows = await query(
- `SELECT id, primary_email FROM contacts
- WHERE user_id = $1 AND address_book_id <> $2 AND primary_email IS NOT NULL`,
- [userId, bookId],
+function contactFromRow(row, bookId) {
+ return {
+ id: row.id,
+ addressBookId: row.address_book_id,
+ inRemoteBook: row.address_book_id === bookId,
+ isCarddavProjected: row.address_book_source === 'carddav',
+ uid: row.uid,
+ vcard: row.vcard,
+ etag: row.etag,
+ displayName: row.display_name,
+ firstName: row.first_name,
+ lastName: row.last_name,
+ primaryEmail: row.primary_email,
+ emails: row.emails,
+ phones: row.phones,
+ organization: row.organization,
+ notes: row.notes,
+ photoData: row.photo_data,
+ additionalFields: row.additional_fields || [],
+ isAuto: row.is_auto,
+ };
+}
+
+// A linked local contact tracks inbound remote changes (edits and deletes) when it
+// lives in the CardDAV book (materialized from the remote) or shares the remote
+// resource's UID (MailFlow pushed the remote from this contact). A contact merged to
+// a remote only by matching email keeps a distinct UID and stays locally
+// authoritative, so remote edits never overwrite it and a remote delete only unlinks.
+function linkedContactFollowsRemote(contact, bookId, retainedVcard) {
+ if (contact.addressBookId === bookId) return true;
+ if (!retainedVcard) return false;
+ const retainedUid = parseVCard(retainedVcard).uid;
+ return Boolean(retainedUid) && retainedUid === contact.uid;
+}
+
+// The local snapshot stored on a sync-created conflict is pushed verbatim by a later
+// keep-mailflow resolution, so it must be lossless AND carry the retained remote UID.
+// Overlay the current local contact onto the retained remote vCard (the same overlay
+// updateContact/replace use). If the overlay throws, re-key the stored vCard to the remote
+// UID or fail closed (pushSafeSnapshot) — the fallback must never mint the local key
+// onto the remote resource. The !mapping.vcard guard is defensive: a real
+// mapped contact always has a retained vCard; when it is absent the contact is push-origin,
+// whose local UID already equals the remote UID, so its stored vCard is safe.
+function conflictLocalSnapshot(mapping, contact) {
+ if (!contact) return null;
+ if (!mapping.vcard) return contact.vcard;
+ const retained = parseVCardDocument(mapping.vcard);
+ try {
+ return serializeVCardDocument(
+ overlayContactOnVCard(retained, contact, { preserveDocumentUid: true }),
);
- for (const r of rows.rows) otherEmail.set(r.primary_email.toLowerCase(), r.id);
+ } catch (error) {
+ return pushSafeSnapshot(contact.vcard, retained, error);
+ }
+}
+
+function automaticMappingFromRow(row) {
+ const document = row.vcard && (!row.vcard_version || !row.remote_semantic_hash)
+ ? parseVCardDocument(row.vcard)
+ : null;
+ return {
+ addressBookId: row.address_book_id,
+ href: row.href,
+ remoteEtag: row.remote_etag,
+ vcard: row.vcard,
+ primaryEmail: row.primary_email,
+ localContactId: row.local_contact_id,
+ mappingStatus: row.mapping_status ?? 'synced',
+ vcardVersion: row.vcard_version ?? document?.version ?? null,
+ remoteSemanticHash: row.remote_semantic_hash
+ ?? (document ? semanticVCardHash(document) : null),
+ localContactHash: row.local_contact_hash,
+ mappingRevision: row.mapping_revision ?? '0',
+ legacyProjection: row.legacy_projection,
+ };
+}
+
+function desiredAutomaticContact(remote, primaryEmail, uid) {
+ const contact = {
+ ...remote.contact,
+ // Imports key the local UID to the remote href; refreshes preserve the existing
+ // UID so a push-origin contact keeps its identity across an inbound remote edit.
+ uid: uid ?? crypto.createHash('sha256').update(remote.href).digest('hex'),
+ primaryEmail,
+ additionalFields: remote.contact?.additionalFields || [],
+ };
+ const vcard = generateVCard(contact);
+ return { ...contact, vcard, etag: localVCardEtag(vcard), isAuto: false };
+}
+
+function confirmedAutomaticMapping(remote, contactId, contact) {
+ const document = parseVCardDocument(remote.vcard);
+ return {
+ href: remote.href,
+ remoteEtag: remote.remoteEtag ?? null,
+ vcard: remote.vcard,
+ primaryEmail: remote.contact?.primaryEmail ?? remote.primaryEmail ?? null,
+ localContactId: contactId,
+ vcardVersion: document.version,
+ remoteSemanticHash: semanticVCardHash(document),
+ localContactHash: localContactHash(contact),
+ };
+}
+
+function assertMappingStateApplied(result, mapping) {
+ if (result.ok) return result;
+ throw new StaleCarddavPlanError({
+ reason: 'mapping-revision-changed',
+ addressBookId: mapping.addressBookId,
+ href: mapping.href,
+ expectedMappingRevision: mapping.mappingRevision,
+ });
+}
+
+async function loadAutomaticProjectionState(client, userId, bookId, hasLegacyProjection) {
+ const legacyProjection = hasLegacyProjection
+ ? 'o.legacy_projection'
+ : 'NULL::jsonb AS legacy_projection';
+ const { rows: mappingRows } = await client.query(
+ `SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, o.primary_email,
+ o.local_contact_id, o.mapping_status, o.vcard_version,
+ o.remote_semantic_hash, o.local_contact_hash,
+ o.mapping_revision::text, ${legacyProjection}
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ ORDER BY b.id, o.href
+ FOR UPDATE OF o`,
+ [userId],
+ );
+ const contactRows = await lockProjectionContacts(client, userId, bookId);
+ return {
+ mappings: mappingRows.map(automaticMappingFromRow),
+ contacts: contactRows.map(row => contactFromRow(row, bookId)),
+ };
+}
+
+async function materializeAutomaticImport(client, userId, book, remote, primaryEmail) {
+ const desired = desiredAutomaticContact(remote, primaryEmail);
+ const result = await client.query(
+ `INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name, first_name,
+ last_name, primary_email, emails, phones, organization, notes, photo_data,
+ additional_fields, is_auto
+ ) VALUES (
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb,false
+ )
+ ON CONFLICT (address_book_id, uid) DO UPDATE SET
+ vcard = EXCLUDED.vcard, etag = EXCLUDED.etag,
+ display_name = EXCLUDED.display_name, first_name = EXCLUDED.first_name,
+ last_name = EXCLUDED.last_name, primary_email = EXCLUDED.primary_email,
+ emails = EXCLUDED.emails, phones = EXCLUDED.phones,
+ organization = EXCLUDED.organization, notes = EXCLUDED.notes,
+ photo_data = EXCLUDED.photo_data, additional_fields = EXCLUDED.additional_fields,
+ is_auto = false, updated_at = NOW()
+ RETURNING id`,
+ [
+ book.id, userId, desired.uid, desired.vcard, desired.etag,
+ desired.displayName, desired.firstName, desired.lastName, desired.primaryEmail,
+ JSON.stringify(desired.emails || []), JSON.stringify(desired.phones || []),
+ desired.organization, desired.notes, desired.photoData,
+ JSON.stringify(desired.additionalFields || []),
+ ],
+ );
+ return {
+ id: result.rows[0].id,
+ addressBookId: book.id,
+ inRemoteBook: true,
+ isCarddavProjected: true,
+ ...desired,
+ };
+}
+
+async function refreshAutomaticImport(client, userId, book, remote, current, primaryEmail) {
+ // Refresh the linked contact in place — a push-origin contact keeps its own local
+ // book and UID, a pull-origin contact keeps the book/UID it was materialized with.
+ const desired = desiredAutomaticContact(remote, primaryEmail, current.uid);
+ if (localContactHash(desired) === localContactHash(current)) {
+ return { contact: current, changed: false };
}
+ const result = await client.query(
+ `UPDATE contacts SET
+ uid = $3, vcard = $4, etag = $5, display_name = $6,
+ first_name = $7, last_name = $8, primary_email = $9,
+ emails = $10::jsonb, phones = $11::jsonb, organization = $12,
+ notes = $13, photo_data = $14, additional_fields = $15::jsonb,
+ is_auto = false, updated_at = NOW()
+ WHERE user_id = $1 AND id = $2 AND address_book_id = $16`,
+ [
+ userId, current.id, desired.uid, desired.vcard, desired.etag,
+ desired.displayName, desired.firstName, desired.lastName, desired.primaryEmail,
+ JSON.stringify(desired.emails || []), JSON.stringify(desired.phones || []),
+ desired.organization, desired.notes, desired.photoData,
+ JSON.stringify(desired.additionalFields || []), current.addressBookId,
+ ],
+ );
+ if (result.rowCount !== 1) {
+ throw new StaleCarddavPlanError({ reason: 'mapping-contact-missing' });
+ }
+ return {
+ contact: {
+ ...current,
+ ...desired,
+ addressBookId: current.addressBookId,
+ inRemoteBook: current.addressBookId === book.id,
+ isCarddavProjected: current.isCarddavProjected,
+ },
+ changed: true,
+ };
+}
- // Classify first (no writes) so we know the final set before touching the DB.
- const seenInBook = new Set();
- const toUpsert = [];
- const toMerge = []; // { id, contact }
- for (const c of cards) {
- // Avoid violating this book's (address_book_id, primary_email) uniqueness when
- // two cards in the same book share an email — keep the email on the first only.
- if (c.primaryEmail && seenInBook.has(c.primaryEmail)) c.primaryEmail = null;
- else if (c.primaryEmail) seenInBook.add(c.primaryEmail);
+// The number of CardDAV contacts materialized for a user. This is the one authority for
+// the integration's contactCount: the ledger row set, not an accumulated total.
+async function countMaterializedCarddavContacts(client, userId) {
+ const { rows: [row] } = await client.query(
+ `SELECT count(*)::int AS count
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ AND b.source = 'carddav'
+ AND o.local_contact_id IS NOT NULL
+ AND o.mapping_status <> 'pending_materialization'`,
+ [userId],
+ );
+ return row?.count ?? 0;
+}
- if (c.primaryEmail && dupMode !== 'separate' && otherEmail.has(c.primaryEmail)) {
- if (dupMode === 'skip') continue;
- if (dupMode === 'merge') { toMerge.push({ id: otherEmail.get(c.primaryEmail), contact: c }); continue; }
+// contactCount is derived, never accumulated. Adding a per-book delta to the stored total
+// double-counts a book whenever the ledger does not already account for the contacts that
+// total was computed from — which is exactly the full-snapshot path (the ledger starts
+// empty while an earlier count survives in config). Recounting also self-heals a total
+// that is already wrong.
+async function persistCarddavContactCount(client, integration, userId) {
+ const currentCount = carddavContactCount({ contactCount: integration.contact_count });
+ const nextCount = await countMaterializedCarddavContacts(client, userId);
+ if (nextCount === currentCount && integration.contact_count != null) return nextCount;
+ const updated = await client.query(
+ `UPDATE user_integrations
+ SET config = config || jsonb_build_object('contactCount', $2::int), updated_at = NOW()
+ WHERE id = $1`,
+ [integration.id, nextCount],
+ );
+ if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+}
+
+async function applyAutomaticBookProjection(client, {
+ plan,
+ integrationRow,
+ book,
+ fingerprintBooks,
+ replacingAlias,
+ canonicalUrl,
+ identity,
+}) {
+ const hasLegacyProjection = integrationRow.has_legacy_projection === true;
+ const state = await loadAutomaticProjectionState(
+ client, plan.userId, book.id, hasLegacyProjection,
+ );
+ await validateProjectionFootprint(
+ client, plan.userId, fingerprintBooks, state.contacts, book.id,
+ );
+ const contactsById = new Map(state.contacts.map(contact => [contact.id, contact]));
+ const currentMappings = state.mappings.filter(mapping => mapping.addressBookId === book.id);
+ const currentByHref = new Map(currentMappings.map(mapping => [mapping.href, mapping]));
+ const incomingByHref = new Map(plan.upserts.map(remote => [remote.href, remote]));
+ const removedHrefs = new Set(plan.removedHrefs);
+ const tombstoneHrefs = new Set(currentMappings
+ .filter(mapping => (
+ removedHrefs.has(mapping.href) || (plan.replaceAll && !incomingByHref.has(mapping.href))
+ ))
+ .map(mapping => mapping.href));
+ const protectedHrefs = new Set();
+ const pendingChanges = [];
+ const conflictChanges = [];
+ // Local (non-CardDAV) books whose contacts an inbound change touches, so their
+ // sync tokens rotate for cache invalidation alongside the CardDAV book's.
+ const changedLocalBookIds = new Set();
+ // Books whose served (presented) document changed from a remote-only edit to
+ // unmodeled properties, which leaves the modeled columns untouched: rotate so
+ // getctag pollers re-fetch and the derived served ETag advances.
+ const presentedChangedBookIds = new Set();
+ for (const mapping of currentMappings) {
+ if (hasLegacyProjection && mapping.legacyProjection) continue;
+ let remote = incomingByHref.get(mapping.href);
+ const remoteTombstone = tombstoneHrefs.has(mapping.href);
+ const contact = contactsById.get(mapping.localContactId);
+ const currentLocalHash = contact ? localContactHash(contact) : null;
+ const confirmedLocalHash = mapping.localContactHash ?? currentLocalHash;
+ const localChanged = mapping.mappingStatus === 'pending_push'
+ || currentLocalHash !== confirmedLocalHash;
+ if (!remote && !remoteTombstone) {
+ if (mapping.mappingStatus !== 'synced' || !localChanged) continue;
+ remote = {
+ href: mapping.href,
+ remoteEtag: mapping.remoteEtag,
+ vcard: mapping.vcard,
+ primaryEmail: mapping.primaryEmail,
+ contact: contactFromVCard(mapping.vcard, mapping.href),
+ };
}
- toUpsert.push(c);
+ const document = remote ? parseVCardDocument(remote.vcard) : null;
+ const remoteSemanticHash = document ? semanticVCardHash(document) : null;
+ const remoteChanged = remoteTombstone
+ || remoteSemanticHash !== mapping.remoteSemanticHash;
+ if (mapping.mappingStatus === 'conflict' || (localChanged && remoteChanged)) {
+ protectedHrefs.add(mapping.href);
+ conflictChanges.push({
+ mapping,
+ contact,
+ remote,
+ document,
+ remoteSemanticHash,
+ remoteTombstone,
+ confirmedLocalHash,
+ });
+ } else if (localChanged) {
+ protectedHrefs.add(mapping.href);
+ pendingChanges.push({
+ mapping, remote, document, remoteSemanticHash, confirmedLocalHash,
+ });
+ }
+ }
+ const removedMappings = currentMappings.filter(mapping => (
+ tombstoneHrefs.has(mapping.href) && !protectedHrefs.has(mapping.href)
+ ));
+ const removedMappingHrefs = new Set(removedMappings.map(mapping => mapping.href));
+ const removedContacts = removedMappings
+ .map(mapping => ({ mapping, contact: contactsById.get(mapping.localContactId) }))
+ .filter(entry => entry.contact
+ && linkedContactFollowsRemote(entry.contact, book.id, entry.mapping.vcard));
+ const projectedIds = removedContacts.map(entry => entry.contact.id);
+ for (const { contact } of removedContacts) {
+ if (contact.addressBookId !== book.id) changedLocalBookIds.add(contact.addressBookId);
}
+ let removed = 0;
+ if (projectedIds.length) {
+ const result = await client.query(
+ 'DELETE FROM contacts WHERE user_id = $1 AND id = ANY($2::uuid[])',
+ [plan.userId, projectedIds],
+ );
+ removed = result.rowCount;
+ for (const id of projectedIds) contactsById.delete(id);
+ }
+ if (removedMappings.length) {
+ for (const mapping of removedMappings) {
+ assertMappingStateApplied(await applyRemoteTombstone(client, {
+ addressBookId: mapping.addressBookId,
+ href: mapping.href,
+ expectedMappingRevision: mapping.mappingRevision,
+ }), mapping);
+ }
+ }
+
+ const effectiveUpserts = plan.upserts.filter(remote => !protectedHrefs.has(remote.href));
+ const upsertsByHref = new Map(effectiveUpserts.map(remote => [remote.href, remote]));
+
+ const remoteObjects = [];
+ for (const mapping of currentMappings) {
+ if (removedMappingHrefs.has(mapping.href) || upsertsByHref.has(mapping.href)) continue;
+ remoteObjects.push({
+ href: mapping.href,
+ remoteEtag: mapping.remoteEtag,
+ vcard: mapping.vcard,
+ primaryEmail: mapping.primaryEmail,
+ contact: contactFromVCard(mapping.vcard, mapping.href),
+ discoveryIndex: plan.book.discoveryIndex ?? 0,
+ });
+ }
+ for (const remote of effectiveUpserts) {
+ remoteObjects.push({ ...remote, discoveryIndex: plan.book.discoveryIndex ?? 0 });
+ }
+
+ const retainedMappings = state.mappings
+ .filter(mapping => (
+ mapping.addressBookId !== book.id || !removedMappingHrefs.has(mapping.href)
+ ))
+ .filter(mapping => mapping.localContactId && contactsById.has(mapping.localContactId))
+ .filter(mapping => mapping.legacyProjection?.disposition !== 'skip')
+ .map(mapping => ({ href: mapping.href, localContactId: mapping.localContactId }));
+ const projection = planAutomaticProjection({
+ remoteObjects,
+ mappings: retainedMappings,
+ localContacts: [...contactsById.values()],
+ });
+ const remotesByHref = new Map(remoteObjects.map(remote => [remote.href, remote]));
+ const linkedIds = new Map(projection.links.map(link => [link.href, link.localContactId]));
+ const usedEmails = new Set([...contactsById.values()]
+ .filter(contact => contact.addressBookId === book.id)
+ .map(contact => normalizeEmail(contact.primaryEmail))
+ .filter(Boolean));
+ let refreshed = 0;
+ for (const link of projection.links) {
+ const remote = remotesByHref.get(link.href);
+ const contact = contactsById.get(link.localContactId);
+ if (!upsertsByHref.has(link.href) || !contact) continue;
+ // Only meaningful for an EXISTING mapping: compare the presented document before
+ // and after this remote change (both via the overlay, so formatting is neutral). A
+ // first-time link has no prior presented representation to have "changed".
+ const retainedVcard = currentByHref.get(link.href)?.vcard;
+ const presentedChanged = Boolean(retainedVcard)
+ && presentedVCard({ ...contact, mapping_vcard: retainedVcard })
+ !== presentedVCard({ ...contact, mapping_vcard: remote.vcard });
+ if (!linkedContactFollowsRemote(contact, book.id, retainedVcard)) {
+ // Email-merged: the local contact stays authoritative and is not refreshed, but a
+ // remote change to unmodeled properties still changes the presented document.
+ if (presentedChanged) presentedChangedBookIds.add(contact.addressBookId);
+ continue;
+ }
+ const inRemoteBook = contact.addressBookId === book.id;
+ const remoteEmail = normalizeEmail(remote.contact?.primaryEmail);
+ let primaryEmail;
+ if (inRemoteBook) {
+ // Email uniqueness is per-address-book, so dedupe against the CardDAV book only.
+ const currentEmail = normalizeEmail(contact.primaryEmail);
+ if (currentEmail) usedEmails.delete(currentEmail);
+ primaryEmail = remoteEmail && !usedEmails.has(remoteEmail) ? remoteEmail : null;
+ if (primaryEmail) usedEmails.add(primaryEmail);
+ } else {
+ primaryEmail = remoteEmail;
+ }
+ const refresh = await refreshAutomaticImport(
+ client, plan.userId, book, remote, contact, primaryEmail,
+ );
+ contactsById.set(contact.id, refresh.contact);
+ if (refresh.changed && !inRemoteBook) changedLocalBookIds.add(contact.addressBookId);
+ // A remote-only unmodeled change leaves the modeled columns (refresh.changed=false)
+ // but still advances the presented document → rotate the contact's book.
+ if (!refresh.changed && presentedChanged) presentedChangedBookIds.add(contact.addressBookId);
+ refreshed += Number(refresh.changed);
+ }
+ let imported = 0;
+ for (const importPlan of projection.imports) {
+ const remote = remotesByHref.get(importPlan.href);
+ const email = normalizeEmail(remote.contact?.primaryEmail);
+ const primaryEmail = email && !usedEmails.has(email) ? email : null;
+ if (primaryEmail) usedEmails.add(primaryEmail);
+ const contact = await materializeAutomaticImport(
+ client, plan.userId, book, remote, primaryEmail,
+ );
+ contactsById.set(contact.id, contact);
+ linkedIds.set(remote.href, contact.id);
+ imported++;
+ }
+
+ const confirmed = remoteObjects.map(remote => {
+ const contactId = linkedIds.get(remote.href);
+ const contact = contactsById.get(contactId);
+ if (!contact) throw new StaleCarddavPlanError({ reason: 'mapping-contact-missing' });
+ return confirmedAutomaticMapping(remote, contactId, contact);
+ });
+ const changedMappingHrefs = new Set(effectiveUpserts.map(remote => remote.href));
+ for (const mapping of currentMappings) {
+ if (mapping.legacyProjection) changedMappingHrefs.add(mapping.href);
+ }
+ const changedMappings = confirmed.filter(mapping => changedMappingHrefs.has(mapping.href));
+ for (const mapping of changedMappings) {
+ const current = currentByHref.get(mapping.href);
+ assertMappingStateApplied(await applyConfirmedRemoteContact(client, {
+ addressBookId: book.id,
+ href: mapping.href,
+ expectedMappingRevision: current?.mappingRevision ?? null,
+ remoteEtag: mapping.remoteEtag,
+ vcard: mapping.vcard,
+ primaryEmail: mapping.primaryEmail,
+ localContactId: mapping.localContactId,
+ vcardVersion: mapping.vcardVersion,
+ remoteSemanticHash: mapping.remoteSemanticHash,
+ localContactHash: mapping.localContactHash,
+ supportsPendingIntent: !hasLegacyProjection,
+ clearLegacyProjection: hasLegacyProjection,
+ }), current || {
+ addressBookId: book.id,
+ href: mapping.href,
+ mappingRevision: null,
+ });
+ }
+ for (const change of pendingChanges) {
+ const {
+ mapping, remote, document, remoteSemanticHash, confirmedLocalHash,
+ } = change;
+ assertMappingStateApplied(await applyConfirmedRemoteContact(client, {
+ addressBookId: mapping.addressBookId,
+ href: mapping.href,
+ expectedMappingRevision: mapping.mappingRevision,
+ remoteEtag: remote.remoteEtag,
+ vcard: remote.vcard,
+ primaryEmail: remote.contact?.primaryEmail ?? remote.primaryEmail ?? null,
+ localContactId: mapping.localContactId,
+ vcardVersion: document.version,
+ remoteSemanticHash,
+ localContactHash: confirmedLocalHash,
+ mappingStatus: 'pending_push',
+ supportsPendingIntent: !hasLegacyProjection,
+ preservePendingIntent: true,
+ }), mapping);
+ }
+ for (const change of conflictChanges) {
+ const {
+ mapping, contact, remote, document, remoteSemanticHash, remoteTombstone,
+ confirmedLocalHash,
+ } = change;
+ assertMappingStateApplied(await refreshUnresolvedConflict(client, {
+ addressBookId: mapping.addressBookId,
+ href: mapping.href,
+ expectedMappingRevision: mapping.mappingRevision,
+ userId: plan.userId,
+ baseLocalHash: confirmedLocalHash,
+ remoteEtag: remote?.remoteEtag ?? null,
+ primaryEmail: remote?.contact?.primaryEmail ?? remote?.primaryEmail ?? null,
+ vcardVersion: document?.version ?? null,
+ remoteSemanticHash,
+ supportsPendingIntent: !hasLegacyProjection,
+ preserveLocalSnapshot: mapping.mappingStatus === 'conflict',
+ localVCard: mapping.mappingStatus === 'conflict'
+ ? undefined
+ : conflictLocalSnapshot(mapping, contact),
+ remoteVCard: remote?.vcard ?? null,
+ localTombstone: mapping.mappingStatus === 'conflict' ? undefined : !contact,
+ remoteTombstone,
+ }), mapping);
+ }
+
+ const updated = imported + refreshed;
+ const changedBookIds = [
+ ...(imported || refreshed || removed ? [book.id] : []),
+ ...changedLocalBookIds,
+ ...presentedChangedBookIds,
+ ];
+ const rotatedBooks = await rotateChangedBookTokens(client, plan.userId, changedBookIds);
+ const rotatedById = new Map(rotatedBooks.map(row => [row.id, row]));
+ const finalTargetBooks = fingerprintBooks.map(target => rotatedById.get(target.id) || target);
+ const fingerprint = projectionFingerprint(finalTargetBooks);
+ const capabilities = observedCapabilities(plan.book);
+ const nextRemoteToken = Object.hasOwn(plan, 'nextRemoteToken')
+ ? plan.nextRemoteToken
+ : book.remote_sync_token;
+ let updatedBook;
+ if (replacingAlias) await client.query('SAVEPOINT carddav_alias_replace');
+ try {
+ updatedBook = await advanceDiscoveredBookState(client, {
+ addressBookId: book.id,
+ remoteSyncToken: nextRemoteToken,
+ remoteSyncCapability: plan.capability,
+ remoteProjectionFingerprint: fingerprint,
+ expectedRemoteRevision: plan.expectedRemoteRevision,
+ canonicalUrl,
+ capabilities,
+ });
+ } catch (error) {
+ if (error.code !== '23505' || !replacingAlias) throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_alias_replace');
+ const conflict = await client.query(
+ `SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2
+ FOR UPDATE`,
+ [plan.userId, canonicalUrl],
+ );
+ await client.query('RELEASE SAVEPOINT carddav_alias_replace');
+ throw new StaleCarddavPlanError({
+ reason: 'canonical-url-conflict',
+ observedUrl: identity.observedUrl,
+ canonicalUrl,
+ conflictingBookId: conflict.rows[0]?.id,
+ });
+ }
+ if (replacingAlias) await client.query('RELEASE SAVEPOINT carddav_alias_replace');
+ if (updatedBook.rowCount !== 1) {
+ throw new StaleCarddavPlanError({ reason: 'book-update-missed', bookId: book.id });
+ }
+ await persistCarddavContactCount(client, integrationRow, plan.userId);
+ return {
+ changedBookIds,
+ ledgerChanged: removedMappings.length > 0 || changedMappings.length > 0
+ || pendingChanges.length > 0 || conflictChanges.length > 0,
+ updated,
+ removed,
+ remote: confirmed.length,
+ };
+}
- const presentUids = toUpsert.map(c => c.uid);
- // Delete stale rows BEFORE upserting so a uid/email freed this round can't collide
- // with an incoming card (e.g. an email moving to a newly-created contact).
- await query(
- `DELETE FROM contacts WHERE address_book_id = $1 AND uid <> ALL($2::text[])`,
- [bookId, presentUids.length ? presentUids : ['']],
+export async function applyBookDelta(client, plan) {
+ requirePlanFences(plan);
+ const integration = await client.query(
+ `SELECT id,
+ -- Tolerate a half-migrated database applied out-of-band through 0035, not 0036.
+ EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'carddav_remote_objects'
+ AND column_name = 'legacy_projection'
+ ) AS has_legacy_projection,
+ config->>'connectionGeneration' AS connection_generation,
+ config->>'contactCount' AS contact_count
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ FOR UPDATE`,
+ [plan.userId],
+ );
+ const integrationRow = integration.rows[0];
+ assertConnectionGeneration(
+ integrationRow?.connection_generation ?? null,
+ plan.connectionGeneration,
);
- for (const c of toUpsert) await upsertCardavContact(bookId, userId, c);
- for (const m of toMerge) await mergeIntoExisting(m.id, m.contact);
+ if (!integrationRow) {
+ throw new StaleCarddavPlanError({
+ reason: 'connection-generation-changed',
+ expectedConnectionGeneration: plan.connectionGeneration,
+ actualConnectionGeneration: null,
+ });
+ }
+ const identity = plan.collectionIdentity;
+ const replacingAlias = identity
+ && identity.observedUrl !== identity.canonicalUrl;
+ if (replacingAlias && !plan.replaceAll) {
+ throw new StaleCarddavPlanError({ reason: 'canonical-reconciliation-required' });
+ }
+ const canonicalUrl = plan.replaceAll ? identity?.canonicalUrl : null;
+ const bookUrls = replacingAlias
+ ? [identity.observedUrl, identity.canonicalUrl]
+ : [plan.book.url];
+ const lockedBooks = await lockCarddavBooks(client, plan.userId, bookUrls);
+ let book = lockedBooks.find(row => row.external_url === plan.book.url);
+ if (!book && replacingAlias) {
+ throw new StaleCarddavPlanError({
+ reason: 'observed-alias-missing',
+ observedUrl: identity.observedUrl,
+ });
+ }
+ if (!book) book = await persistDiscoveredBook(client, { userId: plan.userId, ...plan.book });
+ if (String(book.remote_sync_revision ?? '0') !== String(plan.expectedRemoteRevision)) {
+ throw new StaleCarddavPlanError({
+ reason: 'remote-revision-changed',
+ expectedRemoteRevision: plan.expectedRemoteRevision,
+ actualRemoteRevision: String(book.remote_sync_revision ?? '0'),
+ });
+ }
+ const expectedRemoteToken = plan.expectedRemoteToken;
+ if (book.remote_sync_token !== expectedRemoteToken) {
+ throw new StaleCarddavPlanError({
+ reason: 'remote-token-changed',
+ expectedRemoteToken,
+ actualRemoteToken: book.remote_sync_token,
+ });
+ }
- await query(
- "UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1",
- [bookId],
+ if (replacingAlias) {
+ const conflictingBook = lockedBooks.find(row => (
+ row.external_url === canonicalUrl && row.id !== book.id
+ ));
+ if (conflictingBook) {
+ throw new StaleCarddavPlanError({
+ reason: 'canonical-url-conflict',
+ observedUrl: identity.observedUrl,
+ canonicalUrl,
+ conflictingBookId: conflictingBook.id,
+ });
+ }
+ }
+
+ const fingerprintBooks = await lockEligibleTargetBooks(client, plan.userId);
+ await validateTargetBookFootprint(client, plan.userId, fingerprintBooks);
+
+ return applyAutomaticBookProjection(client, {
+ plan,
+ integrationRow,
+ book,
+ fingerprintBooks,
+ replacingAlias,
+ canonicalUrl,
+ identity,
+ });
+}
+
+export async function prepareBookPlan(userId, book, creds) {
+ const current = await query(
+ `SELECT remote_sync_token, remote_sync_capability, remote_sync_revision,
+ connection_generation, book_id
+ FROM (
+ SELECT b.id AS book_id, b.remote_sync_token, b.remote_sync_capability,
+ b.remote_sync_revision::text,
+ ui.config->>'connectionGeneration' AS connection_generation
+ FROM user_integrations ui
+ LEFT JOIN address_books b
+ ON b.user_id = ui.user_id AND b.source = 'carddav' AND b.external_url = $2
+ WHERE ui.user_id = $1 AND ui.provider = 'carddav'
+ ) current_book`,
+ [userId, book.url],
+ );
+ const currentBook = current.rows[0];
+ if (!currentBook) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ const expectedRemoteToken = currentBook.remote_sync_token ?? null;
+ const supportsSyncCollection = Boolean(
+ book.supportsSyncCollection
+ && currentBook.remote_sync_capability !== 'snapshot',
);
- return { bookId, count: presentUids.length };
+ const request = {
+ ...book,
+ ...creds,
+ syncToken: expectedRemoteToken,
+ supportsSyncCollection,
+ };
+ let delta;
+ let fallback = 0;
+ try {
+ delta = await fetchAddressBookDelta(request);
+ } catch (error) {
+ const invalidStoredToken = error?.name === 'CardDavError'
+ && error.status === 403
+ && error.precondition === 'valid-sync-token'
+ && expectedRemoteToken != null
+ && expectedRemoteToken !== '';
+ if (!invalidStoredToken) throw error;
+ delta = await fetchAddressBookDelta({ ...request, syncToken: '' });
+ delta = { ...delta, expectedRemoteToken };
+ fallback = 1;
+ }
+ const identity = delta.collectionIdentity;
+ if (identity && identity.observedUrl !== identity.canonicalUrl && !delta.replaceAll) {
+ const canonicalDelta = await fetchAddressBookDelta({
+ ...request,
+ url: identity.canonicalUrl,
+ syncToken: '',
+ });
+ if (!canonicalDelta.replaceAll
+ || canonicalDelta.collectionIdentity?.canonicalUrl !== identity.canonicalUrl) {
+ throw new StaleCarddavPlanError({ reason: 'canonical-reconciliation-required' });
+ }
+ delta = {
+ ...canonicalDelta,
+ expectedRemoteToken,
+ collectionIdentity: identity,
+ };
+ fallback = 1;
+ }
+ const upserts = delta.upserts.map(card => ({
+ href: card.href,
+ remoteEtag: card.etag ?? null,
+ vcard: card.vcard,
+ contact: contactFromVCard(card.vcard, card.href),
+ }));
+ return {
+ userId,
+ book: { ...book, capabilities: observedCapabilities(book) },
+ expectedRemoteToken: delta.expectedRemoteToken,
+ connectionGeneration: currentBook.connection_generation ?? null,
+ expectedRemoteRevision: currentBook.remote_sync_revision ?? '0',
+ nextRemoteToken: delta.nextRemoteToken,
+ capability: delta.capability,
+ replaceAll: delta.replaceAll,
+ collectionIdentity: delta.collectionIdentity,
+ upserts,
+ removedHrefs: delta.removedHrefs,
+ fetched: upserts.length,
+ fallback: fallback || Number(delta.capability === 'snapshot'),
+ };
+}
+
+export async function reconcileStaleCarddavBooks(client, userId, { seenUrls }) {
+ const { rows: books } = await client.query(
+ `SELECT id, source, external_url, sync_token
+ FROM address_books
+ WHERE user_id = $1
+ ORDER BY id
+ FOR UPDATE`,
+ [userId],
+ );
+
+ const seen = new Set(seenUrls);
+ const staleBookIds = books
+ .filter(book => book.source === 'carddav' && !seen.has(book.external_url))
+ .map(book => book.id)
+ .sort();
+ if (!staleBookIds.length) return [];
+
+ await client.query(
+ `DELETE FROM address_books
+ WHERE user_id = $1 AND id = ANY($2::uuid[])`,
+ [userId, staleBookIds],
+ );
+ return staleBookIds;
+}
+
+export async function finalizeCarddavSyncTransaction(client, userId, {
+ connectionGeneration,
+ seenUrls,
+ status,
+}) {
+ const integration = await lockCarddavIntegration(client, userId);
+ if (!integration) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ const actualGeneration = integration.config?.connectionGeneration ?? null;
+ assertConnectionGeneration(actualGeneration, connectionGeneration);
+ await reconcileStaleCarddavBooks(client, userId, { seenUrls });
+ const contactCount = await countMaterializedCarddavContacts(client, userId);
+ const updated = await client.query(
+ `UPDATE user_integrations
+ SET config = config || $2::jsonb, updated_at = NOW()
+ WHERE id = $1 AND config->>'connectionGeneration' IS NOT DISTINCT FROM $3`,
+ [
+ integration.id,
+ JSON.stringify({ ...status, contactCount }),
+ connectionGeneration,
+ ],
+ );
+ if (updated.rowCount !== 1) {
+ throw new StaleCarddavPlanError({
+ reason: 'connection-generation-changed',
+ expectedConnectionGeneration: connectionGeneration,
+ actualConnectionGeneration: actualGeneration,
+ });
+ }
+ return contactCount;
}
-export async function syncUser(userId) {
- const config = await getCardavConfig(userId);
- if (!config?.serverUrl) return { ok: false, error: 'not connected' };
- if (syncing.has(userId)) return { ok: false, error: 'A sync is already in progress' };
+export async function disconnectCarddavTransaction(client, userId) {
+ const integration = await lockCarddavIntegration(client, userId);
+ if (!integration) return false;
+ const actualGeneration = integration.config?.connectionGeneration ?? null;
+ await reconcileStaleCarddavBooks(client, userId, { seenUrls: [] });
+ const deleted = await client.query(
+ `DELETE FROM user_integrations
+ WHERE id = $1 AND config->>'connectionGeneration' IS NOT DISTINCT FROM $2
+ RETURNING id`,
+ [integration.id, actualGeneration],
+ );
+ if (deleted.rowCount !== 1) {
+ throw new StaleCarddavPlanError({
+ reason: 'connection-generation-changed',
+ expectedConnectionGeneration: actualGeneration,
+ actualConnectionGeneration: null,
+ });
+ }
+ return true;
+}
+
+export async function finalizeCarddavSync(userId, options) {
+ return withTransaction(client => finalizeCarddavSyncTransaction(client, userId, options));
+}
+
+export async function disconnectCarddavAccount(userId) {
+ const disconnected = await withTransaction(client => disconnectCarddavTransaction(client, userId));
+ stopCardavUser(userId);
+ pendingReplacementSyncs.delete(userId);
+ return disconnected;
+}
+
+export async function recordCarddavSyncFailure(userId, expectedGeneration, error) {
+ const patch = {
+ lastError: error.message,
+ lastSyncAt: new Date().toISOString(),
+ ...(error.retryAfterAt ? { retryAfterAt: error.retryAfterAt } : {}),
+ };
+ const result = await query(
+ `UPDATE user_integrations SET config = config || $2::jsonb, updated_at = NOW()
+ WHERE user_id = $1 AND provider = 'carddav'
+ AND config->>'connectionGeneration' IS NOT DISTINCT FROM $3`,
+ [userId, JSON.stringify(patch), expectedGeneration],
+ );
+ return result.rowCount === 1;
+}
+
+async function unmappedExplicitContactIds(userId) {
+ const { rows } = await query(
+ `SELECT c.id
+ FROM contacts c
+ WHERE c.user_id = $1 AND c.is_auto = false
+ AND NOT EXISTS (
+ SELECT 1 FROM carddav_remote_objects mapping
+ WHERE mapping.local_contact_id = c.id
+ )
+ ORDER BY c.id`,
+ [userId],
+ );
+ return rows.map(row => row.id);
+}
+
+export function requestCarddavSync(userId, connectionGeneration) {
+ if (syncing.has(userId)) {
+ if (activeSyncGenerations.get(userId) !== connectionGeneration) {
+ pendingReplacementSyncs.add(userId);
+ }
+ return false;
+ }
+ syncUser(userId, connectionGeneration).catch(() => {});
+ return true;
+}
+
+export async function syncUser(userId, expectedGeneration) {
+ const counters = emptyResultCounters();
+ if (syncing.has(userId)) {
+ if (expectedGeneration !== undefined
+ && activeSyncGenerations.get(userId) !== expectedGeneration) {
+ pendingReplacementSyncs.add(userId);
+ }
+ return { ok: false, error: 'A sync is already in progress', ...counters };
+ }
syncing.add(userId);
- const policy = await getConnectionPolicy();
- const allowPrivate = policy.allowPrivateHosts;
- const creds = { username: config.username, password: decrypt(config.password), allowPrivate };
+ activeSyncGenerations.set(userId, expectedGeneration);
+ let config;
try {
+ config = await getCardavConfig(userId);
+ if (!config?.serverUrl) return { ok: false, error: 'not connected', ...counters };
+ assertConnectionGeneration(config.connectionGeneration, expectedGeneration);
+ const retryAfterAt = activeRetryAfterAt(config);
+ if (retryAfterAt) {
+ throw new CardDavError('CardDAV requests are throttled until Retry-After eligibility', {
+ status: 429,
+ operation: 'sync',
+ retryAfterAt,
+ });
+ }
+ activeSyncGenerations.set(userId, config.connectionGeneration);
+ const creds = await resolveCarddavCredentials(config);
const books = await discoverAddressBooks({ serverUrl: config.serverUrl, ...creds });
- let contactCount = 0;
- const seenUrls = [];
+ await recoverPendingCarddavMutations(userId, {
+ integration: { config },
+ creds,
+ });
+ const plans = [];
for (const book of books) {
- const { count } = await syncBook(userId, book, config.dupMode || 'separate', creds);
- contactCount += count;
- seenUrls.push(book.url);
- }
- // Prune local CardDAV books whose remote collection disappeared (cascades to contacts).
- await query(
- `DELETE FROM address_books
- WHERE user_id = $1 AND source = 'carddav' AND external_url <> ALL($2::text[])`,
- [userId, seenUrls.length ? seenUrls : ['']],
- );
- await saveCardavConfig(userId, {
- lastSyncAt: new Date().toISOString(),
- lastError: null, bookCount: books.length, contactCount,
+ plans.push(await prepareBookPlan(userId, book, creds));
+ }
+
+ for (let index = 0; index < plans.length; index++) {
+ let plan = plans[index];
+ let applied;
+ try {
+ applied = await withTransaction(client => applyBookDelta(client, plan));
+ } catch (error) {
+ if (!(error instanceof StaleCarddavPlanError)) throw error;
+ switch (stalePlanAction(error.reason)) {
+ case 'retry-apply':
+ applied = await withTransaction(client => applyBookDelta(client, plan));
+ break;
+ case 'refetch-once': {
+ const freshConfig = await getCardavConfig(userId);
+ if (freshConfig?.connectionGeneration !== undefined
+ && freshConfig.connectionGeneration !== plan.connectionGeneration) {
+ throw error;
+ }
+ plan = await prepareBookPlan(userId, books[index], creds);
+ applied = await withTransaction(client => applyBookDelta(client, plan));
+ break;
+ }
+ case 'abort':
+ throw error;
+ }
+ }
+ plans[index] = plan;
+ addResultCounters(counters, {
+ fetched: plan.fetched,
+ fallback: plan.fallback,
+ remote: applied.remote,
+ updated: applied.updated,
+ removed: applied.removed,
+ });
+ }
+ const exportFailures = [];
+ for (const contactId of await unmappedExplicitContactIds(userId)) {
+ try {
+ await exportExistingContact(userId, contactId, {
+ books,
+ expectedGeneration: config.connectionGeneration,
+ });
+ } catch (error) {
+ if (error instanceof CardDavError
+ && error.status === 429
+ && activeRetryAfterAt(error)) {
+ throw error;
+ }
+ exportFailures.push({
+ localContactId: contactId,
+ code: error.code || 'ERR_CARDDAV_EXPORT',
+ message: error.message,
+ });
+ }
+ }
+ const seenUrls = plans.map(plan => (
+ plan.collectionIdentity?.canonicalUrl ?? plan.book.url
+ ));
+ const contactCount = await finalizeCarddavSync(userId, {
+ seenUrls,
+ connectionGeneration: config.connectionGeneration,
+ status: {
+ lastSyncAt: new Date().toISOString(),
+ lastError: null,
+ bookCount: books.length,
+ exportFailures,
+ ...(Object.hasOwn(config, 'retryAfterAt') ? { retryAfterAt: null } : {}),
+ },
});
- return { ok: true, bookCount: books.length, contactCount };
+ return {
+ ok: true,
+ bookCount: books.length,
+ contactCount,
+ exportFailures,
+ ...counters,
+ };
} catch (err) {
- await saveCardavConfig(userId, { lastError: err.message, lastSyncAt: new Date().toISOString() });
- return { ok: false, error: err.message };
+ if (config && err.reason !== 'not-connected') {
+ const statusGeneration = expectedGeneration !== undefined
+ ? expectedGeneration
+ : config.connectionGeneration;
+ const gatedRetry = err instanceof CardDavError
+ && err.status === 429
+ && err.requestStatus == null
+ && err.retryAfterAt;
+ if (!gatedRetry) await recordCarddavSyncFailure(userId, statusGeneration, err);
+ }
+ return {
+ ok: false,
+ error: err.message,
+ ...(err.retryAfterAt ? { retryAfterAt: err.retryAfterAt } : {}),
+ ...counters,
+ };
} finally {
syncing.delete(userId);
+ activeSyncGenerations.delete(userId);
+ if (pendingReplacementSyncs.delete(userId)) {
+ queueMicrotask(() => requestCarddavSync(userId));
+ }
}
}
@@ -215,6 +1370,16 @@ export function stopCardavUser(userId) {
}
export async function startCardavScheduler() {
+ if (conflictCleanupTimer) clearInterval(conflictCleanupTimer);
+ conflictCleanupTimer = setInterval(() => withTransaction(client => (
+ deleteResolvedConflictsBefore(
+ client,
+ new Date(Date.now() - CONFLICT_RETENTION_MS),
+ )
+ )).catch(error => {
+ console.warn('CardDAV conflict cleanup failed:', error.message);
+ }), CONFLICT_CLEANUP_INTERVAL_MS);
+ conflictCleanupTimer.unref?.();
try {
const rows = await query("SELECT user_id, config FROM user_integrations WHERE provider = 'carddav'");
for (const row of rows.rows) {
diff --git a/backend/src/services/carddavSync.test.js b/backend/src/services/carddavSync.test.js
new file mode 100644
index 0000000..47558a6
--- /dev/null
+++ b/backend/src/services/carddavSync.test.js
@@ -0,0 +1,2037 @@
+import { createHash } from 'node:crypto';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ localContactHash,
+ parseVCardDocument,
+ semanticVCardHash,
+} from '../utils/vcardProperties.js';
+
+const mocks = vi.hoisted(() => ({
+ decrypt: vi.fn(value => value),
+ deleteResolvedConflictsBefore: vi.fn(),
+ discoverAddressBooks: vi.fn(),
+ exportExistingContact: vi.fn(),
+ fetchAddressBookDelta: vi.fn(),
+ getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })),
+ parseVCard: vi.fn(),
+ query: vi.fn(),
+ recoverPendingCarddavMutations: vi.fn(),
+ withTransaction: vi.fn(),
+}));
+
+vi.mock('./db.js', () => ({
+ query: mocks.query,
+ withTransaction: mocks.withTransaction,
+}));
+vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt }));
+vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy }));
+vi.mock('./carddavClient.js', () => ({
+ discoverAddressBooks: mocks.discoverAddressBooks,
+ fetchAddressBookDelta: mocks.fetchAddressBookDelta,
+}));
+vi.mock('./carddavContactService.js', () => ({
+ exportExistingContact: mocks.exportExistingContact,
+ recoverPendingCarddavMutations: mocks.recoverPendingCarddavMutations,
+}));
+vi.mock('./carddavConflictService.js', () => ({
+ deleteResolvedConflictsBefore: mocks.deleteResolvedConflictsBefore,
+}));
+vi.mock('../utils/vcard.js', async importOriginal => {
+ const original = await importOriginal();
+ mocks.parseVCard.mockImplementation(original.parseVCard);
+ return { ...original, parseVCard: mocks.parseVCard };
+});
+
+const carddavSync = await import('./carddavSync.js');
+const { CardDavError } = await vi.importActual('./carddavTransport.js');
+const { generateVCard } = await vi.importActual('../utils/vcard.js');
+const USER_ID = '00000000-0000-4000-8000-000000000001';
+const BOOK_ID = '00000000-0000-4000-8000-000000000002';
+const TARGET_ID = '00000000-0000-4000-8000-000000000004';
+const LOCAL_BOOK_ID = '00000000-0000-4000-8000-000000000005';
+const BOOK_URL = 'https://dav.example.test/addressbooks/default/';
+const ALIAS_BOOK_URL = 'https://dav.example.test/addressbooks/alias/';
+const CANONICAL_BOOK_URL = 'https://dav.example.test/addressbooks/canonical/';
+const CONNECTION_GENERATION = 'generation-current';
+const EMPTY_COUNTERS = {
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+};
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+const STALE_REASON_POLICIES = [
+ ['invalid-plan-fence', 'abort', 0],
+ ['not-connected', 'abort', 0],
+ ['connection-generation-changed', 'abort', 0],
+ ['projection-footprint-changed', 'retry-apply', 1],
+ ['mapping-revision-changed', 'abort', 0],
+ ['mapping-contact-missing', 'abort', 0],
+ ['canonical-url-conflict', 'abort', 0],
+ ['book-update-missed', 'abort', 0],
+ ['canonical-reconciliation-required', 'abort', 0],
+ ['observed-alias-missing', 'abort', 0],
+ ['remote-revision-changed', 'refetch-once', 1],
+ ['remote-token-changed', 'refetch-once', 1],
+];
+
+function completePlan(overrides = {}) {
+ return {
+ userId: USER_ID,
+ book: { url: BOOK_URL, displayName: 'Remote' },
+ connectionGeneration: CONNECTION_GENERATION,
+ expectedRemoteRevision: '0',
+ expectedRemoteToken: null,
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ ...overrides,
+ };
+}
+
+function applyClient({
+ remoteToken = null,
+ remoteRevision = '0',
+ connectionGeneration = CONNECTION_GENERATION,
+ integrationPresent = true,
+ bookPresent = true,
+ bookUpdateCount = 1,
+ lifecycleBooks = [],
+ contactCount = 0,
+ ledger = new Set(),
+} = {}) {
+ return {
+ query: vi.fn(async (sql, params) => {
+ if (/SELECT id, config\s+FROM user_integrations[\s\S]+FOR UPDATE/.test(sql)) {
+ return integrationPresent ? {
+ rows: [{
+ id: '00000000-0000-4000-8000-000000000006',
+ config: {
+ connectionGeneration,
+ contactCount,
+ },
+ }],
+ } : { rows: [] };
+ }
+ if (/SELECT id, source, external_url, sync_token[\s\S]+FROM address_books/.test(sql)) {
+ return { rows: lifecycleBooks };
+ }
+ if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) {
+ return bookPresent ? {
+ rows: [{
+ id: BOOK_ID,
+ external_url: params[1][0],
+ remote_sync_token: remoteToken,
+ remote_sync_revision: remoteRevision,
+ sync_token: 'local-token-before',
+ }],
+ } : { rows: [] };
+ }
+ if (/FROM user_integrations[\s\S]+FOR (?:SHARE|UPDATE)/.test(sql)) {
+ return integrationPresent
+ ? { rows: [{
+ id: '00000000-0000-4000-8000-000000000006',
+ has_legacy_projection: false,
+ connection_generation: connectionGeneration,
+ contact_count: String(contactCount),
+ }] }
+ : { rows: [] };
+ }
+ // The materialized-mapping recount that derives contactCount: answer it from the
+ // mapping rows this fake ledger has actually taken, so the count is observed rather
+ // than accumulated (as it is in PostgreSQL).
+ if (/count\(\*\)::int AS count[\s\S]+FROM carddav_remote_objects/.test(sql)) {
+ return { rows: [{ count: ledger.size }] };
+ }
+ if (sql.includes('FROM carddav_remote_objects')) return { rows: [] };
+ if (sql.includes('FROM contacts')) return { rows: [] };
+ if (sql.includes('INSERT INTO contacts')) {
+ return { rows: [{ id: '00000000-0000-4000-8000-000000000003' }], rowCount: 1 };
+ }
+ if (sql.includes('INSERT INTO carddav_remote_objects')) {
+ if (params[5]) ledger.add(`${params[0]}|${params[1]}`);
+ return { rows: [{ mapping_revision: '0' }], rowCount: 1 };
+ }
+ if (/UPDATE user_integrations/.test(sql)) {
+ if (/jsonb_build_object\('contactCount'/.test(sql)) contactCount = params[1];
+ else if (/config = config \|\| \$2::jsonb/.test(sql)) {
+ contactCount = JSON.parse(params[1]).contactCount ?? contactCount;
+ }
+ return { rows: [], rowCount: 1 };
+ }
+ if (/UPDATE address_books\s+SET/.test(sql)) return { rows: [], rowCount: bookUpdateCount };
+ return { rows: [], rowCount: 0 };
+ }),
+ };
+}
+
+function projectionClient({
+ objects = [],
+ contacts = [],
+ connectionGeneration = CONNECTION_GENERATION,
+ contactCount = 0,
+} = {}) {
+ return {
+ query: vi.fn(async (sql, params) => {
+ if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) {
+ return {
+ rows: [{
+ id: BOOK_ID,
+ external_url: BOOK_URL,
+ remote_sync_token: null,
+ remote_sync_revision: '0',
+ sync_token: 'local-token-before',
+ }],
+ };
+ }
+ if (/FROM address_books[\s\S]+source <> 'carddav'[\s\S]+FOR UPDATE/.test(sql)) {
+ const books = new Map(contacts
+ .filter(contact => contact.address_book_source !== 'carddav')
+ .map(contact => [contact.address_book_id, {
+ id: contact.address_book_id,
+ source: 'local',
+ sync_token: 'target-token-before',
+ }]));
+ return { rows: [...books.values()] };
+ }
+ if (/SELECT id[\s\S]+source <> 'carddav'/.test(sql)) {
+ const ids = [...new Set(contacts
+ .filter(contact => contact.address_book_source !== 'carddav')
+ .map(contact => contact.address_book_id))];
+ return { rows: ids.sort().map(id => ({
+ id,
+ source: 'local',
+ sync_token: 'target-token-before',
+ })) };
+ }
+ if (/FROM user_integrations[\s\S]+FOR (?:SHARE|UPDATE)/.test(sql)) {
+ return { rows: [{
+ id: '00000000-0000-4000-8000-000000000006',
+ has_legacy_projection: false,
+ connection_generation: connectionGeneration,
+ contact_count: String(contactCount),
+ }] };
+ }
+ if (/FROM carddav_remote_objects[\s\S]+address_book_id <>/.test(sql)) return { rows: [] };
+ if (/^(?:\s*)(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) {
+ return { rows: [{ mapping_revision: '1' }], rowCount: 1 };
+ }
+ if (sql.includes('FROM carddav_remote_objects')) return { rows: objects };
+ if (/SELECT c\.id[\s\S]+FROM contacts/.test(sql) && !sql.includes('c.uid')) {
+ return { rows: contacts.map(contact => ({ id: contact.id })) };
+ }
+ if (/SELECT[\s\S]+FROM contacts/.test(sql)) return { rows: contacts };
+ if (sql.includes('INSERT INTO contacts')) {
+ return { rows: [{ id: '00000000-0000-4000-8000-000000000003' }], rowCount: 1 };
+ }
+ if (/UPDATE address_books[\s\S]+RETURNING id, source, sync_token/.test(sql)) {
+ return {
+ rows: params[1].map(id => ({ id, source: 'local', sync_token: `${id}-after` })),
+ rowCount: params[1].length,
+ };
+ }
+ if (/UPDATE user_integrations/.test(sql)) {
+ if (/jsonb_build_object\('contactCount'/.test(sql)) contactCount = params[1];
+ return { rows: [], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+}
+
+function targetContactRow(overrides = {}) {
+ const vcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:local-target',
+ 'FN:Original Name',
+ 'N:Name;Original;;;',
+ 'EMAIL:duplicate@example.test',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ return {
+ id: TARGET_ID,
+ address_book_id: LOCAL_BOOK_ID,
+ address_book_source: 'local',
+ uid: 'local-target',
+ vcard,
+ etag: createHash('md5').update(vcard).digest('hex'),
+ display_name: 'Original Name',
+ first_name: 'Original',
+ last_name: 'Name',
+ primary_email: 'duplicate@example.test',
+ emails: [{ value: 'duplicate@example.test', type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photo_data: null,
+ additional_fields: [],
+ is_auto: false,
+ ...overrides,
+ };
+}
+
+function remoteCard(name, email) {
+ const href = `${BOOK_URL}${name}.vcf`;
+ const vcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:remote-${name}`,
+ `FN:${name}`,
+ `N:${name};${name};;;`,
+ `EMAIL:${email}`,
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ return {
+ href,
+ remoteEtag: `W/"remote-etag-${name}"`,
+ vcard,
+ contact: {
+ uid: `remote-${name}`,
+ displayName: name,
+ firstName: name,
+ lastName: name,
+ primaryEmail: email,
+ emails: [{ value: email, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ },
+ };
+}
+
+function persistedSeparate(name, email) {
+ const card = remoteCard(name, email);
+ const localUid = createHash('sha256').update(card.href).digest('hex');
+ const vcard = generateVCard({ ...card.contact, uid: localUid });
+ return {
+ card,
+ object: {
+ address_book_id: BOOK_ID,
+ href: card.href,
+ remote_etag: card.remoteEtag,
+ vcard: card.vcard,
+ primary_email: email,
+ disposition: 'separate',
+ local_contact_id: TARGET_ID,
+ merge_before: null,
+ merge_applied: null,
+ },
+ contact: targetContactRow({
+ address_book_id: BOOK_ID,
+ address_book_source: 'carddav',
+ uid: localUid,
+ vcard,
+ etag: createHash('md5').update(vcard).digest('hex'),
+ display_name: name,
+ first_name: name,
+ last_name: name,
+ primary_email: email,
+ emails: [{ value: email, type: 'other', primary: true }],
+ }),
+ };
+}
+
+function configureOneBookSync({ remoteToken = null } = {}) {
+ mocks.query.mockImplementation(async (sql, params) => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return {
+ rows: [{
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ },
+ }],
+ };
+ }
+ if (sql.includes('SELECT remote_sync_token')) {
+ expect(params).toEqual([USER_ID, BOOK_URL]);
+ return { rows: [{
+ remote_sync_token: remoteToken,
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ ]);
+}
+
+function deferred() {
+ let resolve;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+
+describe('disconnectCarddavAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns false without lifecycle mutation when the integration is already absent', async () => {
+ const client = applyClient({ integrationPresent: false });
+ mocks.withTransaction.mockImplementation(callback => callback(client));
+
+ await expect(carddavSync.disconnectCarddavAccount(USER_ID)).resolves.toBe(false);
+
+ expect(client.query).toHaveBeenCalledOnce();
+ expect(client.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/);
+ });
+
+ it('reconciles stale books through one helper that returns changed book IDs', async () => {
+ const client = applyClient({
+ contactCount: 2,
+ lifecycleBooks: [{
+ id: BOOK_ID,
+ source: 'carddav',
+ external_url: BOOK_URL,
+ sync_token: 'local-before',
+ }],
+ });
+
+ await expect(carddavSync.reconcileStaleCarddavBooks(client, USER_ID, {
+ seenUrls: [],
+ })).resolves.toEqual([BOOK_ID]);
+ });
+
+ it('keeps finalization status-only and disconnect integration-delete-only', async () => {
+ const finalizationClient = applyClient();
+ await carddavSync.finalizeCarddavSyncTransaction(finalizationClient, USER_ID, {
+ connectionGeneration: CONNECTION_GENERATION,
+ seenUrls: [],
+ status: { lastError: null },
+ });
+ const finalizationMutations = finalizationClient.query.mock.calls
+ .map(([sql]) => sql)
+ .filter(sql => /(?:UPDATE|DELETE FROM) user_integrations/.test(sql));
+ expect(finalizationMutations).toHaveLength(1);
+ expect(finalizationMutations[0]).toContain('UPDATE user_integrations');
+
+ const disconnectClient = applyClient();
+ const query = disconnectClient.query.getMockImplementation();
+ disconnectClient.query.mockImplementation(async (sql, params) => (
+ sql.includes('DELETE FROM user_integrations')
+ ? { rows: [{ id: USER_ID }], rowCount: 1 }
+ : query(sql, params)
+ ));
+ await carddavSync.disconnectCarddavTransaction(disconnectClient, USER_ID);
+ const disconnectMutations = disconnectClient.query.mock.calls
+ .map(([sql]) => sql)
+ .filter(sql => /(?:UPDATE|DELETE FROM) user_integrations/.test(sql));
+ expect(disconnectMutations).toHaveLength(1);
+ expect(disconnectMutations[0]).toContain('DELETE FROM user_integrations');
+ });
+});
+
+describe('applyBookDelta', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('is exported as the transactional CardDAV apply boundary', () => {
+ expect(carddavSync.applyBookDelta).toBeTypeOf('function');
+ });
+
+ it.each(STALE_REASON_POLICIES)(
+ 'maps stale reason %s to %s with at most %i retry',
+ (reason, action, maximumRetryCount) => {
+ const selected = carddavSync.stalePlanAction(reason);
+ expect(selected).toBe(action);
+ expect({ abort: 0, 'retry-apply': 1, 'refetch-once': 1 }[selected])
+ .toBe(maximumRetryCount);
+ },
+ );
+
+ it('rejects unknown stale reasons and ignores diagnostic fields when selecting policy', () => {
+ expect(() => carddavSync.stalePlanAction('unknown')).toThrow(/unknown stale/i);
+ const stale = Object.assign(
+ new carddavSync.StaleCarddavPlanError({ reason: 'mapping-contact-missing' }),
+ {
+ expectedRemoteToken: 'before',
+ actualRemoteToken: 'after',
+ expectedConnectionGeneration: 'before',
+ actualConnectionGeneration: 'before',
+ },
+ );
+ expect(carddavSync.stalePlanAction(stale.reason)).toBe('abort');
+ });
+
+ it('rejects an unfenced plan before executing SQL', async () => {
+ const client = applyClient();
+ const plan = completePlan();
+ delete plan.connectionGeneration;
+
+ await expect(carddavSync.applyBookDelta(client, plan)).rejects.toMatchObject({
+ reason: 'invalid-plan-fence',
+ });
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it('uses only the passed client and locks the integration before the mirrored book', async () => {
+ mocks.query.mockRejectedValue(new Error('global query must not be used'));
+ const client = applyClient();
+
+ await carddavSync.applyBookDelta(client, completePlan());
+
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(client.query).toHaveBeenCalled();
+ expect(client.query.mock.calls[0][0]).toMatch(
+ /SELECT[\s\S]+FROM user_integrations[\s\S]+FOR UPDATE/,
+ );
+ expect(client.query.mock.calls[1][0]).toMatch(
+ /SELECT[\s\S]+FROM address_books[\s\S]+FOR UPDATE/,
+ );
+ });
+
+ it('locks eligible target books before locking projection contacts', async () => {
+ const client = applyClient();
+
+ await carddavSync.applyBookDelta(client, completePlan());
+
+ const queries = client.query.mock.calls.map(([sql]) => sql);
+ const targetBooks = queries.findIndex(sql => (
+ /FROM address_books/.test(sql) && /source <> 'carddav'/.test(sql) && /FOR UPDATE/.test(sql)
+ ));
+ const contacts = queries.findIndex(sql => /FROM contacts/.test(sql));
+ expect(targetBooks).toBeGreaterThan(0);
+ expect(contacts).toBeGreaterThan(targetBooks);
+ expect(queries[contacts]).toMatch(/ORDER BY c\.id[\s\S]+FOR UPDATE OF c/);
+ });
+
+ it('increments the remote revision in the final guarded book update', async () => {
+ const client = applyClient();
+
+ await carddavSync.applyBookDelta(client, completePlan());
+
+ const update = client.query.mock.calls.find(([sql]) => (
+ /UPDATE address_books SET/.test(sql)
+ ));
+ expect(update?.[0]).toMatch(
+ /remote_sync_revision = remote_sync_revision \+ 1/,
+ );
+ });
+
+ it('rejects a stale connection generation before book lookup or creation', async () => {
+ const client = applyClient({ connectionGeneration: 'generation-new' });
+
+ const error = await carddavSync.applyBookDelta(client, completePlan()).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ expectedConnectionGeneration: CONNECTION_GENERATION,
+ actualConnectionGeneration: 'generation-new',
+ });
+ expect(client.query).toHaveBeenCalledOnce();
+ expect(client.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/);
+ });
+
+ it('treats a missing integration as stale before book lookup or creation', async () => {
+ const client = applyClient({ integrationPresent: false });
+
+ const error = await carddavSync.applyBookDelta(client, completePlan()).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ expectedConnectionGeneration: CONNECTION_GENERATION,
+ actualConnectionGeneration: null,
+ });
+ expect(client.query).toHaveBeenCalledOnce();
+ });
+
+ it('rejects a stale remote revision before reading projection state', async () => {
+ const client = applyClient({ remoteRevision: '8' });
+
+ const error = await carddavSync.applyBookDelta(client, completePlan({
+ expectedRemoteRevision: '7',
+ })).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ expectedRemoteRevision: '7',
+ actualRemoteRevision: '8',
+ });
+ expect(client.query).toHaveBeenCalledTimes(2);
+ expect(client.query.mock.calls.some(([sql]) => /FROM carddav_remote_objects/.test(sql)))
+ .toBe(false);
+ });
+
+ it('rejects a stale opaque remote token before reading or mutating projection state', async () => {
+ const client = applyClient({ remoteToken: 'opaque-token' });
+
+ const error = await carddavSync.applyBookDelta(client, completePlan({
+ expectedRemoteToken: ' opaque-token ',
+ })).catch(caught => caught);
+
+ expect(carddavSync.StaleCarddavPlanError).toBeTypeOf('function');
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ expectedRemoteToken: ' opaque-token ',
+ actualRemoteToken: 'opaque-token',
+ });
+ expect(client.query).toHaveBeenCalledTimes(2);
+ });
+
+ it('rejects an incremental alias replacement before reading projection state', async () => {
+ const client = applyClient();
+
+ const error = await carddavSync.applyBookDelta(client, completePlan({
+ book: { url: ALIAS_BOOK_URL, displayName: 'Remote' },
+ replaceAll: false,
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ })).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({ reason: 'canonical-reconciliation-required' });
+ expect(client.query).toHaveBeenCalledOnce();
+ expect(client.query.mock.calls.some(([sql]) => /FROM carddav_remote_objects/.test(sql)))
+ .toBe(false);
+ });
+
+ it('rejects a missing observed alias instead of recreating it', async () => {
+ const client = applyClient({ bookPresent: false });
+
+ const error = await carddavSync.applyBookDelta(client, completePlan({
+ book: { url: ALIAS_BOOK_URL, displayName: 'Remote' },
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ })).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({
+ reason: 'observed-alias-missing',
+ observedUrl: ALIAS_BOOK_URL,
+ });
+ expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO address_books')))
+ .toBe(false);
+ });
+
+ it('locks observed and canonical books together in stable id order', async () => {
+ const client = applyClient();
+
+ await carddavSync.applyBookDelta(client, completePlan({
+ book: { url: ALIAS_BOOK_URL, displayName: 'Remote' },
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ }));
+
+ const bookLocks = client.query.mock.calls.filter(([sql]) => (
+ /external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)
+ ));
+ expect(bookLocks).toHaveLength(1);
+ expect(bookLocks[0][0]).toMatch(/external_url = ANY\(\$2::text\[\]\)[\s\S]+ORDER BY id[\s\S]+FOR UPDATE/);
+ expect(bookLocks[0][1]).toEqual([
+ USER_ID,
+ [ALIAS_BOOK_URL, CANONICAL_BOOK_URL],
+ ]);
+ });
+
+ it('rejects a zero-row final book update as stale', async () => {
+ const client = applyClient({ bookUpdateCount: 0 });
+
+ const error = await carddavSync.applyBookDelta(client, completePlan())
+ .catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({ reason: 'book-update-missed', bookId: BOOK_ID });
+ });
+
+ it('recovers from an address-book name collision without aborting the transaction', async () => {
+ let insertAttempts = 0;
+ const client = {
+ query: vi.fn(async sql => {
+ if (/FROM address_books[\s\S]+FOR UPDATE/.test(sql)) return { rows: [] };
+ if (sql.includes('INSERT INTO address_books')) {
+ insertAttempts++;
+ if (insertAttempts === 1) {
+ const error = new Error('duplicate name');
+ error.code = '23505';
+ throw error;
+ }
+ return {
+ rows: [{
+ id: BOOK_ID,
+ external_url: BOOK_URL,
+ remote_sync_token: null,
+ remote_sync_revision: '0',
+ sync_token: 'local-token-before',
+ }],
+ };
+ }
+ if (/FROM user_integrations[\s\S]+FOR (?:SHARE|UPDATE)/.test(sql)) {
+ return { rows: [{
+ id: USER_ID,
+ dup_mode: 'separate',
+ connection_generation: CONNECTION_GENERATION,
+ contact_count: '0',
+ }] };
+ }
+ if (sql.includes('FROM carddav_remote_objects')) return { rows: [] };
+ if (sql.includes('FROM contacts')) return { rows: [] };
+ if (/UPDATE address_books SET/.test(sql)) return { rows: [], rowCount: 1 };
+ if (/UPDATE user_integrations/.test(sql)) return { rows: [], rowCount: 1 };
+ return { rows: [], rowCount: 0 };
+ }),
+ };
+
+ await carddavSync.applyBookDelta(client, completePlan());
+
+ expect(insertAttempts).toBe(2);
+ expect(client.query.mock.calls.map(([sql]) => sql)).toContain('ROLLBACK TO SAVEPOINT carddav_book_name');
+ });
+
+ it('preserves the exact remote ETag in the provenance ledger', async () => {
+ const client = applyClient();
+ const card = remoteCard('quoted-etag', 'quoted@example.test');
+
+ await carddavSync.applyBookDelta(client, completePlan({ upserts: [card] }));
+
+ const ledgerInsert = client.query.mock.calls.find(([sql]) => (
+ sql.includes('INSERT INTO carddav_remote_objects')
+ ));
+ expect(ledgerInsert[1][2]).toBe('W/"remote-etag-quoted-etag"');
+ });
+
+ it('performs no ledger write for a no-change incremental plan', async () => {
+ const fixture = persistedSeparate('unchanged', 'unchanged@example.test');
+ const client = projectionClient({
+ objects: [fixture.object],
+ contacts: [fixture.contact],
+ });
+
+ const result = await carddavSync.applyBookDelta(client, completePlan({
+ replaceAll: false,
+ }));
+
+ const ledgerWrites = client.query.mock.calls.filter(([sql]) => (
+ /(?:INSERT INTO|DELETE FROM|UPDATE) carddav_remote_objects/.test(sql)
+ ));
+ expect(ledgerWrites).toEqual([]);
+ expect(result).toMatchObject({ ledgerChanged: false, changedBookIds: [] });
+ });
+
+ it('classifies an unreported local-only edit as pending push', async () => {
+ const fixture = persistedSeparate('local-only', 'local-only@example.test');
+ fixture.object.mapping_status = 'synced';
+ fixture.object.mapping_revision = '4';
+ fixture.object.remote_semantic_hash = semanticVCardHash(parseVCardDocument(fixture.card.vcard));
+ fixture.object.local_contact_hash = localContactHash(fixture.contact);
+ fixture.contact.display_name = 'Local Only Changed';
+ const client = projectionClient({
+ objects: [fixture.object],
+ contacts: [fixture.contact],
+ });
+
+ const result = await carddavSync.applyBookDelta(client, completePlan({
+ replaceAll: false,
+ }));
+
+ const mappingWrite = client.query.mock.calls.find(([sql]) => (
+ /UPDATE carddav_remote_objects/.test(sql)
+ ));
+ expect(mappingWrite[0]).toContain("mapping_status = 'pending_push'");
+ expect(result).toMatchObject({
+ ledgerChanged: true,
+ changedBookIds: [],
+ updated: 0,
+ removed: 0,
+ });
+ });
+
+ it('writes only the changed href for a ledger-only ETag delta', async () => {
+ const fixture = persistedSeparate('etag-only', 'etag-only@example.test');
+ const client = projectionClient({
+ objects: [fixture.object],
+ contacts: [fixture.contact],
+ });
+ const changed = { ...fixture.card, remoteEtag: 'W/"changed-etag"' };
+
+ const result = await carddavSync.applyBookDelta(client, completePlan({
+ replaceAll: false,
+ upserts: [changed],
+ }));
+
+ const ledgerWrites = client.query.mock.calls.filter(([sql]) => (
+ /(?:INSERT INTO|DELETE FROM|UPDATE) carddav_remote_objects/.test(sql)
+ ));
+ expect(ledgerWrites).toHaveLength(1);
+ expect(ledgerWrites[0][0]).toContain('UPDATE carddav_remote_objects');
+ expect(ledgerWrites[0][1]).toEqual(expect.arrayContaining([
+ fixture.card.href,
+ 'W/"changed-etag"',
+ ]));
+ expect(result).toMatchObject({ ledgerChanged: true, changedBookIds: [] });
+ });
+
+ it('regenerates a separate local vCard and ETag from its final contact fields', async () => {
+ const client = applyClient();
+ const card = remoteCard('local-materialization', 'materialized@example.test');
+
+ await carddavSync.applyBookDelta(client, completePlan({ upserts: [card] }));
+
+ const contactInsert = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO contacts'));
+ const localUid = createHash('sha256').update(card.href).digest('hex');
+ expect(contactInsert[1][2]).toBe(localUid);
+ expect(contactInsert[1][3]).toContain(`UID:${localUid}\r\n`);
+ expect(contactInsert[1][3]).toContain('FN:local-materialization\r\n');
+ expect(contactInsert[1][3]).not.toBe(card.vcard);
+ expect(contactInsert[1][4]).toBe(createHash('md5').update(contactInsert[1][3]).digest('hex'));
+ });
+
+ it('reports automatic projection counters from the transactional apply boundary', async () => {
+ const client = projectionClient({ contacts: [targetContactRow()] });
+ const cards = [
+ remoteCard('separate', 'new@example.test'),
+ remoteCard('merge', 'duplicate@example.test'),
+ remoteCard('skip', 'duplicate@example.test'),
+ ];
+
+ const result = await carddavSync.applyBookDelta(client, completePlan({ upserts: cards }));
+
+ expect(result).toMatchObject({
+ ledgerChanged: true,
+ remote: 3,
+ updated: 2,
+ removed: 0,
+ });
+ expect(result).not.toHaveProperty('count');
+ expect(result).not.toHaveProperty('contactCount');
+ expect(result).not.toHaveProperty('skipped');
+ expect(result).not.toHaveProperty('merged');
+ });
+
+ it('counts a deleted separately projected contact at the apply boundary', async () => {
+ const card = remoteCard('gone', 'gone@example.test');
+ const client = projectionClient({
+ objects: [{
+ address_book_id: BOOK_ID,
+ href: card.href,
+ remote_etag: card.remoteEtag,
+ vcard: card.vcard,
+ primary_email: 'gone@example.test',
+ local_contact_id: TARGET_ID,
+ legacy_projection: null,
+ }],
+ contacts: [targetContactRow({
+ id: TARGET_ID,
+ address_book_id: BOOK_ID,
+ uid: 'owned-remote',
+ primary_email: 'gone@example.test',
+ })],
+ });
+
+ const result = await carddavSync.applyBookDelta(client, completePlan({
+ replaceAll: false,
+ removedHrefs: [card.href],
+ }));
+
+ expect(result).toMatchObject({ remote: 0, updated: 0, removed: 1 });
+ });
+});
+
+describe('automatic apply boundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('persists observed capabilities inside the guarded book transaction', async () => {
+ const plan = completePlan({
+ book: {
+ url: BOOK_URL,
+ displayName: 'Remote',
+ discoveryIndex: 0,
+ capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' },
+ },
+ });
+ const client = {
+ query: vi.fn(async (sql, params) => {
+ if (/information_schema\.columns[\s\S]+AS has_legacy_projection/.test(sql)) {
+ return { rows: [{
+ id: USER_ID,
+ has_legacy_projection: false,
+ connection_generation: CONNECTION_GENERATION,
+ contact_count: '0',
+ }] };
+ }
+ if (/external_url = ANY\(\$2::text\[\]\)/.test(sql)) {
+ return { rows: [{
+ id: BOOK_ID,
+ external_url: BOOK_URL,
+ remote_sync_token: null,
+ remote_sync_revision: '0',
+ sync_token: 'local-token',
+ remote_projection_fingerprint: null,
+ }] };
+ }
+ if (/UPDATE address_books SET/.test(sql)) {
+ expect(sql).toMatch(/remote_create_capability = \$7/);
+ expect(sql).toMatch(/remote_update_capability = \$8/);
+ expect(sql).toMatch(/remote_delete_capability = \$9/);
+ expect(params.slice(6)).toEqual(['allowed', 'unknown', 'denied']);
+ return { rows: [], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await expect(carddavSync.applyBookDelta(client, plan)).resolves.toMatchObject({
+ remote: 0,
+ updated: 0,
+ removed: 0,
+ });
+ });
+
+ it('refreshes an imported projection when its remote contact changes', async () => {
+ const card = remoteCard('projected', 'projected@example.test');
+ const updatedVcard = card.vcard.replace(
+ 'FN:projected\r\n',
+ 'FN:Projected After\r\n',
+ );
+ const updated = {
+ ...card,
+ vcard: updatedVcard,
+ contact: { ...card.contact, displayName: 'Projected After' },
+ };
+ const localUid = createHash('sha256').update(card.href).digest('hex');
+ const beforeVcard = generateVCard({
+ ...card.contact,
+ uid: localUid,
+ displayName: 'Projected Before',
+ });
+ const client = projectionClient({
+ objects: [{
+ address_book_id: BOOK_ID,
+ href: card.href,
+ remote_etag: card.remoteEtag,
+ vcard: card.vcard,
+ primary_email: card.contact.primaryEmail,
+ local_contact_id: TARGET_ID,
+ legacy_projection: null,
+ }],
+ contacts: [targetContactRow({
+ id: TARGET_ID,
+ address_book_id: BOOK_ID,
+ address_book_source: 'carddav',
+ uid: localUid,
+ vcard: beforeVcard,
+ etag: createHash('md5').update(beforeVcard).digest('hex'),
+ display_name: 'Projected Before',
+ primary_email: card.contact.primaryEmail,
+ emails: card.contact.emails,
+ })],
+ contactCount: 1,
+ });
+
+ const result = await carddavSync.applyBookDelta(client, completePlan({
+ replaceAll: false,
+ upserts: [updated],
+ }));
+
+ const contactUpdate = client.query.mock.calls.find(([sql]) => (
+ /UPDATE contacts\s+SET/.test(sql)
+ ));
+ expect(contactUpdate).toBeDefined();
+ expect(contactUpdate[1]).toEqual(expect.arrayContaining([
+ USER_ID,
+ TARGET_ID,
+ 'Projected After',
+ ]));
+ expect(result.changedBookIds).toEqual([BOOK_ID]);
+ });
+});
+
+describe('prepareBookPlan fences', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('carries connection generation and opaque remote revision into the plan', async () => {
+ const collectionIdentity = {
+ observedUrl: BOOK_URL,
+ canonicalUrl: BOOK_URL,
+ };
+ mocks.query.mockResolvedValue({
+ rows: [{
+ connection_generation: CONNECTION_GENERATION,
+ remote_sync_token: 'opaque-before',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '9223372036854775806',
+ }],
+ });
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: 'opaque-before',
+ nextRemoteToken: 'opaque-after',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity,
+ upserts: [],
+ removedHrefs: [],
+ });
+
+ const plan = await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ 'separate',
+ { username: 'user', password: 'password' },
+ );
+
+ expect(plan).toMatchObject({
+ connectionGeneration: CONNECTION_GENERATION,
+ expectedRemoteRevision: '9223372036854775806',
+ expectedRemoteToken: 'opaque-before',
+ collectionIdentity,
+ });
+ expect(mocks.query).toHaveBeenCalledOnce();
+ expect(mocks.query.mock.calls[0][0]).toMatch(/connectionGeneration/);
+ expect(mocks.query.mock.calls[0][0]).toMatch(/remote_sync_revision/);
+ expect(mocks.query.mock.calls.some(([sql]) => /UPDATE address_books/.test(sql))).toBe(false);
+ });
+
+ it('carries the complete discovery book without duplicate-mode state', async () => {
+ const book = {
+ url: BOOK_URL,
+ displayName: 'Remote',
+ supportsSyncCollection: true,
+ discoveryIndex: 2,
+ capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' },
+ addressData: [{ contentType: 'text/vcard', version: '4.0' }],
+ };
+ mocks.query.mockResolvedValue({ rows: [{
+ connection_generation: CONNECTION_GENERATION,
+ remote_sync_token: 'before',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '7',
+ }] });
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: 'before',
+ nextRemoteToken: 'after',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL },
+ upserts: [],
+ removedHrefs: [],
+ });
+
+ const plan = await carddavSync.prepareBookPlan(
+ USER_ID,
+ book,
+ { username: 'user', password: 'password' },
+ );
+
+ expect(plan).toMatchObject({
+ book,
+ connectionGeneration: CONNECTION_GENERATION,
+ expectedRemoteRevision: '7',
+ expectedRemoteToken: 'before',
+ });
+ expect(plan).not.toHaveProperty('dupMode');
+ });
+
+ it('carries required generation and revision zero when the remote book is absent', async () => {
+ mocks.query.mockResolvedValue({
+ rows: [{
+ connection_generation: CONNECTION_GENERATION,
+ book_id: null,
+ remote_sync_token: null,
+ remote_sync_capability: null,
+ remote_sync_revision: null,
+ }],
+ });
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL },
+ upserts: [],
+ removedHrefs: [],
+ });
+
+ const plan = await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ 'separate',
+ { username: 'user', password: 'password' },
+ );
+
+ expect(plan).toMatchObject({
+ connectionGeneration: CONNECTION_GENERATION,
+ expectedRemoteRevision: '0',
+ expectedRemoteToken: null,
+ });
+ expect(mocks.query.mock.calls[0][0]).toMatch(
+ /FROM user_integrations ui[\s\S]+LEFT JOIN address_books/,
+ );
+ });
+
+ it('replaces an alias delta with one full canonical reconciliation', async () => {
+ const discarded = remoteCard('discarded', 'discarded@example.test');
+ const retained = remoteCard('retained', 'retained@example.test');
+ mocks.query.mockResolvedValue({
+ rows: [{
+ connection_generation: CONNECTION_GENERATION,
+ remote_sync_token: 'alias-token-before',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '7',
+ }],
+ });
+ mocks.fetchAddressBookDelta
+ .mockResolvedValueOnce({
+ expectedRemoteToken: 'alias-token-before',
+ nextRemoteToken: 'discarded-token',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ upserts: [{
+ href: discarded.href,
+ etag: discarded.remoteEtag,
+ vcard: discarded.vcard,
+ }],
+ removedHrefs: ['discarded.vcf'],
+ })
+ .mockResolvedValueOnce({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'canonical-token-after',
+ capability: 'sync-collection',
+ replaceAll: true,
+ collectionIdentity: {
+ observedUrl: CANONICAL_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ upserts: [{ href: retained.href, etag: retained.remoteEtag, vcard: retained.vcard }],
+ removedHrefs: [],
+ });
+
+ const plan = await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ 'separate',
+ { username: 'user', password: 'password' },
+ );
+
+ expect(mocks.fetchAddressBookDelta.mock.calls).toEqual([
+ [expect.objectContaining({ url: ALIAS_BOOK_URL, syncToken: 'alias-token-before' })],
+ [expect.objectContaining({ url: CANONICAL_BOOK_URL, syncToken: '' })],
+ ]);
+ expect(plan).toMatchObject({
+ connectionGeneration: CONNECTION_GENERATION,
+ expectedRemoteRevision: '7',
+ expectedRemoteToken: 'alias-token-before',
+ nextRemoteToken: 'canonical-token-after',
+ replaceAll: true,
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ removedHrefs: [],
+ });
+ expect(plan.upserts.map(card => card.href)).toEqual([retained.href]);
+ });
+
+ it('does not refetch an alias plan that is already a full reconciliation', async () => {
+ mocks.query.mockResolvedValue({ rows: [{
+ book_id: null,
+ remote_sync_token: null,
+ remote_sync_capability: null,
+ remote_sync_revision: null,
+ connection_generation: CONNECTION_GENERATION,
+ }] });
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'canonical-token-after',
+ capability: 'snapshot',
+ replaceAll: true,
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ upserts: [],
+ removedHrefs: [],
+ });
+
+ await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ 'separate',
+ { username: 'user', password: 'password' },
+ );
+
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
+ });
+
+ it('rejects a canonical reconciliation that finishes at another identity', async () => {
+ const changedUrl = 'https://dav.example.test/addressbooks/changed-again/';
+ mocks.query.mockResolvedValue({ rows: [{
+ remote_sync_token: 'alias-token-before',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '7',
+ connection_generation: CONNECTION_GENERATION,
+ }] });
+ mocks.fetchAddressBookDelta
+ .mockResolvedValueOnce({
+ expectedRemoteToken: 'alias-token-before', nextRemoteToken: 'discarded',
+ capability: 'sync-collection', replaceAll: false,
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ upserts: [], removedHrefs: [],
+ })
+ .mockResolvedValueOnce({
+ expectedRemoteToken: null, nextRemoteToken: 'changed',
+ capability: 'sync-collection', replaceAll: true,
+ collectionIdentity: { observedUrl: CANONICAL_BOOK_URL, canonicalUrl: changedUrl },
+ upserts: [], removedHrefs: [],
+ });
+
+ const error = await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ 'separate',
+ { username: 'user', password: 'password' },
+ ).catch(caught => caught);
+
+ expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError);
+ expect(error).toMatchObject({ reason: 'canonical-reconciliation-required' });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ });
+});
+
+describe('pull-first automatic export orchestration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('fetches and applies every book before exporting with the same snapshot', async () => {
+ const books = [
+ {
+ url: BOOK_URL,
+ displayName: 'Remote',
+ supportsSyncCollection: true,
+ discoveryIndex: 0,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ },
+ {
+ url: `${BOOK_URL}team/`,
+ displayName: 'Team',
+ supportsSyncCollection: true,
+ discoveryIndex: 1,
+ capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' },
+ },
+ ];
+ mocks.query.mockImplementation(async (sql) => {
+ if (/SELECT config FROM user_integrations/.test(sql)) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ if (/SELECT remote_sync_token/.test(sql)) {
+ return { rows: [{
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ if (/mapping.local_contact_id = c.id/.test(sql)) {
+ return { rows: [{ id: 'local-a' }, { id: 'local-b' }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue(books);
+ mocks.fetchAddressBookDelta.mockImplementation(async ({ url }) => ({
+ expectedRemoteToken: null,
+ nextRemoteToken: `${url}token`,
+ capability: 'sync-collection',
+ replaceAll: true,
+ collectionIdentity: { observedUrl: url, canonicalUrl: url },
+ upserts: [],
+ removedHrefs: [],
+ }));
+ mocks.withTransaction
+ .mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 })
+ .mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 })
+ .mockResolvedValueOnce(0);
+ mocks.exportExistingContact
+ .mockRejectedValueOnce(Object.assign(new Error('read only'), {
+ code: 'ERR_CARDDAV_READ_ONLY',
+ }))
+ .mockResolvedValueOnce({ id: 'local-b' });
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
+ expect(mocks.exportExistingContact.mock.calls).toEqual([
+ [USER_ID, 'local-a', { books, expectedGeneration: CONNECTION_GENERATION }],
+ [USER_ID, 'local-b', { books, expectedGeneration: CONNECTION_GENERATION }],
+ ]);
+ expect(mocks.fetchAddressBookDelta.mock.invocationCallOrder[1])
+ .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]);
+ expect(mocks.withTransaction.mock.invocationCallOrder[1])
+ .toBeLessThan(mocks.exportExistingContact.mock.invocationCallOrder[0]);
+ expect(mocks.exportExistingContact.mock.invocationCallOrder[1])
+ .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[2]);
+ expect(result).toMatchObject({
+ ok: true,
+ exportFailures: [{
+ localContactId: 'local-a',
+ code: 'ERR_CARDDAV_READ_ONLY',
+ message: 'read only',
+ }],
+ });
+ });
+});
+
+describe('network planning orchestration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('runs read-only pending-intent recovery before planning the scheduled pull', async () => {
+ configureOneBookSync();
+ const client = applyClient();
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: null,
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction
+ .mockImplementationOnce(callback => callback(client))
+ .mockResolvedValueOnce(0);
+
+ await expect(carddavSync.syncUser(USER_ID)).resolves.toMatchObject({ ok: true });
+
+ expect(mocks.recoverPendingCarddavMutations).toHaveBeenCalledOnce();
+ expect(mocks.recoverPendingCarddavMutations.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.fetchAddressBookDelta.mock.invocationCallOrder[0]);
+ });
+
+ it('settles every book fetch and vCard parse before opening the first transaction', async () => {
+ let discoverySettled = false;
+ const fetchedBooks = [];
+ let insideTransaction = false;
+ const client = applyClient();
+ const secondBookUrl = 'https://dav.example.test/addressbooks/team/';
+ const cards = [
+ remoteCard('alpha', 'alpha@example.test'),
+ remoteCard('beta', 'beta@example.test'),
+ ].map(card => ({ href: card.href, etag: card.remoteEtag, vcard: card.vcard }));
+
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return {
+ rows: [{
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ },
+ }],
+ };
+ }
+ if (sql.includes('SELECT remote_sync_token')) {
+ return { rows: [{
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockImplementation(async () => {
+ expect(insideTransaction).toBe(false);
+ discoverySettled = true;
+ return [
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true },
+ ];
+ });
+ mocks.fetchAddressBookDelta.mockImplementation(async ({ url, syncToken, supportsSyncCollection }) => {
+ expect(insideTransaction).toBe(false);
+ expect(syncToken).toBeNull();
+ expect(supportsSyncCollection).toBe(true);
+ const index = url === BOOK_URL ? 0 : 1;
+ fetchedBooks.push(index);
+ return {
+ expectedRemoteToken: null,
+ nextRemoteToken: `remote-token-${index}`,
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [cards[index]],
+ removedHrefs: [],
+ };
+ });
+ mocks.withTransaction.mockImplementation(async callback => {
+ expect(discoverySettled).toBe(true);
+ expect(fetchedBooks).toEqual([0, 1]);
+ expect(mocks.parseVCard.mock.calls.map(([vcard]) => vcard)).toEqual([
+ cards[0].vcard,
+ cards[1].vcard,
+ ]);
+ insideTransaction = true;
+ try {
+ return await callback(client);
+ } finally {
+ insideTransaction = false;
+ }
+ });
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result.error).toBeUndefined();
+ expect(result).toMatchObject({
+ ok: true,
+ bookCount: 2,
+ contactCount: 2,
+ remote: 2,
+ fetched: 2,
+ updated: 2,
+ removed: 0,
+ fallback: 0,
+ });
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(3);
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
+ expect(mocks.query).toHaveBeenCalledWith(
+ expect.stringContaining('SELECT remote_sync_token'),
+ [USER_ID, BOOK_URL],
+ );
+ expect(mocks.query).toHaveBeenCalledWith(
+ expect.stringContaining('SELECT remote_sync_token'),
+ [USER_ID, secondBookUrl],
+ );
+ expect(mocks.query.mock.calls.some(([sql]) => (
+ /DELETE FROM address_books/.test(sql)
+ ))).toBe(false);
+ });
+
+ it('finalizes a replaced alias under its canonical collection URL', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ if (sql.includes('SELECT remote_sync_token')) return { rows: [{
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([
+ { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ ]);
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'canonical-token-after',
+ capability: 'sync-collection',
+ replaceAll: true,
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ upserts: [],
+ removedHrefs: [],
+ });
+ const apply = applyClient();
+ const finalize = applyClient({
+ lifecycleBooks: [{
+ id: BOOK_ID,
+ source: 'carddav',
+ external_url: CANONICAL_BOOK_URL,
+ sync_token: 'local-token-before',
+ }],
+ });
+ mocks.withTransaction
+ .mockImplementationOnce(callback => callback(apply))
+ .mockImplementationOnce(callback => callback(finalize));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 1 });
+ expect(finalize.query.mock.calls.some(([sql]) => (
+ /DELETE FROM address_books/.test(sql)
+ ))).toBe(false);
+ });
+
+ it('does not prune when discovery fails before returning a complete book list', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted',
+ } }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockRejectedValue(new Error('discovery incomplete'));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({ ok: false, error: 'discovery incomplete', ...EMPTY_COUNTERS });
+ expect(mocks.fetchAddressBookDelta).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ expect(mocks.query.mock.calls.some(([sql]) => sql.includes('DELETE FROM address_books'))).toBe(false);
+ });
+
+ it('prunes stale books after a complete empty discovery through finalization', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted',
+ } }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([]);
+ mocks.withTransaction.mockImplementation(callback => callback(applyClient()));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 0, contactCount: 0 });
+ expect(mocks.fetchAddressBookDelta).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ expect(mocks.query.mock.calls.some(([sql]) => (
+ /DELETE FROM address_books/.test(sql)
+ ))).toBe(false);
+ });
+
+ it('reports not connected when the integration vanishes before finalization', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([]);
+ const finalize = applyClient({ integrationPresent: false });
+ mocks.withTransaction.mockImplementation(callback => callback(finalize));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({ ok: false, error: 'not connected', ...EMPTY_COUNTERS });
+ expect(finalize.query).toHaveBeenCalledOnce();
+ expect(finalize.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/);
+ expect(mocks.query).toHaveBeenCalledTimes(2);
+ expect(mocks.query.mock.calls[1][0]).toMatch(/c\.is_auto = false/);
+ });
+
+ it('does not open a transaction or write when a later book multiget batch fails', async () => {
+ const secondBookUrl = 'https://dav.example.test/addressbooks/team/';
+ const client = applyClient();
+ const raw = remoteCard('alpha', 'alpha@example.test');
+
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return {
+ rows: [{
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ },
+ }],
+ };
+ }
+ if (sql.includes('SELECT remote_sync_token')) return { rows: [{
+ book_id: null,
+ remote_sync_token: null,
+ remote_sync_capability: null,
+ remote_sync_revision: null,
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true },
+ ]);
+ mocks.fetchAddressBookDelta
+ .mockResolvedValueOnce({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'first-token',
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [{ href: raw.href, etag: raw.remoteEtag, vcard: raw.vcard }],
+ removedHrefs: [],
+ })
+ .mockRejectedValueOnce(new Error('multiget batch 2 failed'));
+ mocks.withTransaction.mockImplementation(callback => callback(client));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({ ok: false, error: 'multiget batch 2 failed', ...EMPTY_COUNTERS });
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ expect(client.query).not.toHaveBeenCalled();
+ expect(mocks.query.mock.calls.some(([sql]) => sql.includes('DELETE FROM address_books'))).toBe(false);
+ });
+
+ it('keeps persisted snapshot mode even when discovery later advertises sync support', async () => {
+ const client = applyClient();
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return {
+ rows: [{
+ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ },
+ }],
+ };
+ }
+ if (sql.includes('SELECT remote_sync_token')) {
+ return { rows: [{
+ remote_sync_token: null,
+ remote_sync_capability: 'snapshot',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ ]);
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: null,
+ capability: 'snapshot',
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction.mockImplementation(callback => callback(client));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 1, fallback: 1 });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledWith(expect.objectContaining({
+ url: BOOK_URL,
+ supportsSyncCollection: false,
+ }));
+ });
+
+ it('performs exactly one empty-token reconciliation after a valid-sync-token error', async () => {
+ configureOneBookSync({ remoteToken: 'opaque-before' });
+ const client = applyClient({ remoteToken: 'opaque-before' });
+ mocks.fetchAddressBookDelta
+ .mockRejectedValueOnce(new CardDavError('Stored sync token is invalid', {
+ status: 403,
+ precondition: 'valid-sync-token',
+ }))
+ .mockResolvedValueOnce({
+ expectedRemoteToken: '',
+ nextRemoteToken: 'opaque-after',
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction.mockImplementation(callback => callback(client));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 1, fallback: 1 });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
+ expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => request.syncToken))
+ .toEqual(['opaque-before', '']);
+ const updateBook = client.query.mock.calls.find(([sql]) => sql.includes('UPDATE address_books SET'));
+ expect(updateBook[1][1]).toBe('opaque-after');
+ });
+
+ it('does not recurse when the empty-token reconciliation gets valid-sync-token again', async () => {
+ configureOneBookSync({ remoteToken: 'opaque-before' });
+ const invalidToken = () => new CardDavError('Stored sync token is invalid', {
+ status: 403,
+ precondition: 'valid-sync-token',
+ });
+ mocks.fetchAddressBookDelta
+ .mockRejectedValueOnce(invalidToken())
+ .mockRejectedValueOnce(invalidToken());
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({
+ ok: false,
+ error: 'Stored sync token is invalid',
+ ...EMPTY_COUNTERS,
+ });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
+ expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => request.syncToken))
+ .toEqual(['opaque-before', '']);
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ });
+
+ it('refetches one stale book after rollback without reapplying successful books', async () => {
+ const secondBookUrl = 'https://dav.example.test/addressbooks/team/';
+ const storedTokens = new Map([
+ [BOOK_URL, 'first-before'],
+ [secondBookUrl, 'second-before'],
+ ]);
+ let insideTransaction = false;
+ let secondTokenReads = 0;
+ mocks.query.mockImplementation(async (sql, params) => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return {
+ rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ } }],
+ };
+ }
+ if (sql.includes('SELECT remote_sync_token')) {
+ const url = params[1];
+ if (url === secondBookUrl && secondTokenReads++ > 0) storedTokens.set(url, 'second-concurrent');
+ return { rows: [{
+ remote_sync_token: storedTokens.get(url),
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true },
+ ]);
+ mocks.fetchAddressBookDelta.mockImplementation(async request => {
+ expect(insideTransaction).toBe(false);
+ return {
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: `${request.syncToken}-after`,
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ };
+ });
+ let transactionCount = 0;
+ mocks.withTransaction.mockImplementation(async callback => {
+ transactionCount++;
+ const remoteToken = transactionCount === 2 ? 'second-concurrent'
+ : transactionCount === 1 ? 'first-before' : 'second-concurrent';
+ insideTransaction = true;
+ try {
+ return await callback(applyClient({ remoteToken }));
+ } finally {
+ insideTransaction = false;
+ }
+ });
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 2 });
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(4);
+ expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => [request.url, request.syncToken]))
+ .toEqual([
+ [BOOK_URL, 'first-before'],
+ [secondBookUrl, 'second-before'],
+ [secondBookUrl, 'second-concurrent'],
+ ]);
+ });
+
+ it('does not refetch a generation-stale plan with captured credentials', async () => {
+ configureOneBookSync({ remoteToken: 'opaque-before' });
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: 'opaque-before',
+ nextRemoteToken: 'must-not-apply',
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction.mockImplementation(callback => callback(applyClient({
+ remoteToken: 'opaque-before',
+ connectionGeneration: 'generation-replaced',
+ })));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({ ok: false, error: 'CardDAV sync plan is stale', ...EMPTY_COUNTERS });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+
+ it('bounds a repeated stale token to one fresh refetch', async () => {
+ let tokenReads = 0;
+ configureOneBookSync({ remoteToken: 'unused' });
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted',
+ } }] };
+ }
+ if (sql.includes('SELECT remote_sync_token')) {
+ tokenReads++;
+ return { rows: [{
+ remote_sync_token: tokenReads === 1 ? 'opaque-before' : 'opaque-concurrent',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.fetchAddressBookDelta.mockImplementation(async request => ({
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: `${request.syncToken}-after`,
+ capability: 'sync-collection',
+ replaceAll: false,
+ upserts: [],
+ removedHrefs: [],
+ }));
+ mocks.withTransaction.mockImplementation(callback => callback(applyClient({
+ remoteToken: 'always-newer',
+ })));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({ ok: false, error: 'CardDAV sync plan is stale', ...EMPTY_COUNTERS });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not prune when an apply fails after an earlier book committed', async () => {
+ const secondBookUrl = 'https://dav.example.test/addressbooks/team/';
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted',
+ } }] };
+ }
+ if (sql.includes('SELECT remote_sync_token')) return { rows: [{
+ book_id: null,
+ remote_sync_token: null,
+ remote_sync_capability: null,
+ remote_sync_revision: null,
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true },
+ ]);
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'opaque-after',
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction
+ .mockImplementationOnce(callback => callback(applyClient()))
+ .mockRejectedValueOnce(new Error('second apply failed'));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toEqual({ ok: false, error: 'second apply failed', ...EMPTY_COUNTERS });
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(2);
+ expect(mocks.query.mock.calls.some(([sql]) => sql.includes('DELETE FROM address_books'))).toBe(false);
+ });
+
+ it('retries a changed projection footprint once without refetching the remote plan', async () => {
+ configureOneBookSync();
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'footprint-token',
+ capability: 'sync-collection',
+ replaceAll: true,
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction
+ .mockRejectedValueOnce(new carddavSync.StaleCarddavPlanError({
+ reason: 'projection-footprint-changed',
+ }))
+ .mockImplementationOnce(callback => callback(applyClient()))
+ .mockImplementationOnce(callback => callback(applyClient({ lifecycleBooks: [] })));
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, bookCount: 1 });
+ expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(3);
+ });
+
+ it('runs the latest committed replacement despite delayed queue request order', async () => {
+ let currentGeneration = 'generation-a';
+ const firstStarted = deferred();
+ const releaseFirst = deferred();
+ const latestStarted = deferred();
+ const latestFinished = deferred();
+ let activeDiscoveries = 0;
+ let maxActiveDiscoveries = 0;
+
+ mocks.query.mockImplementation(async (sql, params) => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: currentGeneration,
+ connectionGeneration: currentGeneration,
+ } }] };
+ }
+ if (/UPDATE user_integrations/.test(sql)) {
+ return { rows: [], rowCount: params[2] === currentGeneration ? 1 : 0 };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockImplementation(async ({ password }) => {
+ activeDiscoveries++;
+ maxActiveDiscoveries = Math.max(maxActiveDiscoveries, activeDiscoveries);
+ try {
+ if (password === 'generation-a') {
+ firstStarted.resolve();
+ await releaseFirst.promise;
+ } else if (password === 'generation-c') {
+ latestStarted.resolve();
+ }
+ return [];
+ } finally {
+ activeDiscoveries--;
+ }
+ });
+ mocks.withTransaction.mockImplementation(async callback => {
+ const result = await callback(applyClient({
+ connectionGeneration: currentGeneration,
+ lifecycleBooks: [],
+ }));
+ if (currentGeneration === 'generation-c') latestFinished.resolve();
+ return result;
+ });
+
+ expect(carddavSync.requestCarddavSync(USER_ID, 'generation-a')).toBe(true);
+ await firstStarted.promise;
+ currentGeneration = 'generation-c';
+ expect(carddavSync.requestCarddavSync(USER_ID, 'generation-c')).toBe(false);
+ expect(carddavSync.requestCarddavSync(USER_ID, 'generation-b')).toBe(false);
+ expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce();
+
+ releaseFirst.resolve();
+ const launchedLatest = await Promise.race([
+ latestStarted.promise.then(() => true),
+ new Promise(resolve => setImmediate(() => resolve(false))),
+ ]);
+ expect(launchedLatest).toBe(true);
+ await latestFinished.promise;
+
+ expect(mocks.discoverAddressBooks.mock.calls.map(([request]) => request.password))
+ .toEqual(['generation-a', 'generation-c']);
+ expect(maxActiveDiscoveries).toBe(1);
+ });
+
+ it('does not queue manual or same-generation overlaps', async () => {
+ const started = deferred();
+ const release = deferred();
+ const finished = deferred();
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockImplementation(async () => {
+ started.resolve();
+ await release.promise;
+ return [];
+ });
+ mocks.withTransaction.mockImplementation(async callback => {
+ const result = await callback(applyClient({
+ connectionGeneration: CONNECTION_GENERATION,
+ lifecycleBooks: [],
+ }));
+ finished.resolve();
+ return result;
+ });
+
+ expect(carddavSync.requestCarddavSync(USER_ID, CONNECTION_GENERATION)).toBe(true);
+ await started.promise;
+ await expect(carddavSync.syncUser(USER_ID)).resolves.toMatchObject({
+ ok: false,
+ error: 'A sync is already in progress',
+ });
+ expect(carddavSync.requestCarddavSync(USER_ID, CONNECTION_GENERATION)).toBe(false);
+ release.resolve();
+ await finished.promise;
+ await new Promise(resolve => setImmediate(resolve));
+
+ expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce();
+ });
+});
+
+describe('CardDAV scheduler maintenance', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('cleans resolved conflicts older than a fixed 30 days after fake time advances', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-07-12T12:00:00.000Z'));
+ const client = { query: vi.fn() };
+ mocks.query.mockResolvedValueOnce({ rows: [] });
+ mocks.withTransaction.mockImplementation(callback => callback(client));
+ mocks.deleteResolvedConflictsBefore.mockResolvedValue(2);
+
+ await carddavSync.startCardavScheduler();
+
+ expect(mocks.deleteResolvedConflictsBefore).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
+
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ expect(mocks.deleteResolvedConflictsBefore).toHaveBeenCalledWith(
+ client,
+ new Date('2026-06-13T12:00:00.000Z'),
+ );
+ });
+});
diff --git a/backend/src/services/carddavTransport.js b/backend/src/services/carddavTransport.js
new file mode 100644
index 0000000..92a6dfe
--- /dev/null
+++ b/backend/src/services/carddavTransport.js
@@ -0,0 +1,335 @@
+import { DAV_MAX_RESPONSE_BYTES, parseDavErrorPrecondition } from './carddavXml.js';
+import { getConnectionPolicy } from './connectionPolicy.js';
+import { decrypt } from './encryption.js';
+import { validateHost } from './hostValidation.js';
+import { safeFetch } from './safeFetch.js';
+
+const DAV_REQUEST_TIMEOUT_MS = 30_000;
+const DAV_OPERATION_TIMEOUT_MS = 300_000;
+const DAV_MAX_OPERATION_BYTES = 128 * 1024 * 1024;
+
+const productionLimits = Object.freeze({
+ maxOperationBytes: DAV_MAX_OPERATION_BYTES,
+ maxResponseBytes: DAV_MAX_RESPONSE_BYTES,
+ operationTimeoutMs: DAV_OPERATION_TIMEOUT_MS,
+ requestTimeoutMs: DAV_REQUEST_TIMEOUT_MS,
+});
+const operationStates = new WeakMap();
+
+export class CardDavError extends Error {
+ constructor(message, {
+ status,
+ requestStatus,
+ precondition,
+ operation,
+ retryAfterAt,
+ cause,
+ } = {}) {
+ super(message, { cause });
+ this.name = 'CardDavError';
+ this.status = status ?? null;
+ this.requestStatus = requestStatus ?? null;
+ this.precondition = precondition ?? null;
+ this.operation = operation ?? null;
+ this.retryAfterAt = retryAfterAt ?? null;
+ }
+}
+
+export function activeRetryAfterAt(source) {
+ const retryAfterAt = source?.retryAfterAt;
+ return typeof retryAfterAt === 'string' && Date.parse(retryAfterAt) > Date.now()
+ ? retryAfterAt
+ : null;
+}
+
+export async function resolveCarddavCredentials(config) {
+ const policy = await getConnectionPolicy();
+ return {
+ username: config?.username,
+ password: decrypt(config?.password),
+ allowPrivate: policy.allowPrivateHosts,
+ };
+}
+
+export function createDavOperation(credentialOrigin) {
+ return createDavOperationWithLimits(credentialOrigin, productionLimits);
+}
+
+export function testOnlyCreateDavOperation(credentialOrigin, testLimits) {
+ return createDavOperationWithLimits(credentialOrigin, {
+ ...productionLimits,
+ ...(testLimits || {}),
+ });
+}
+
+function createDavOperationWithLimits(credentialOrigin, limits) {
+ let origin;
+ try { origin = new URL(credentialOrigin).origin; }
+ catch { throw new Error('Invalid server URL'); }
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ controller.abort(new DOMException('CardDAV operation timed out', 'TimeoutError'));
+ }, limits.operationTimeoutMs);
+ timer.unref?.();
+
+ let closed = false;
+ const operation = {
+ credentialOrigin: origin,
+ signal: controller.signal,
+ close() {
+ if (closed) return;
+ closed = true;
+ clearTimeout(timer);
+ },
+ async run(callback) {
+ try {
+ return await callback();
+ } finally {
+ operation.close();
+ }
+ },
+ };
+ operationStates.set(operation, {
+ controller,
+ limits,
+ remainingBytes: limits.maxOperationBytes,
+ });
+ return Object.freeze(operation);
+}
+
+export async function davRequest(operation, method, url, {
+ username,
+ password,
+ depth,
+ body,
+ headers: callerHeaders,
+ acceptedStatuses = [],
+ errorOperation,
+ validateRedirect,
+ allowPrivate = false,
+} = {}) {
+ const state = operationStates.get(operation);
+ if (!state) throw new TypeError('davRequest requires a DAV operation');
+
+ const headers = requestHeaders(callerHeaders, body);
+ headers.Authorization = basicAuth(username, password);
+ if (depth != null) headers.Depth = String(depth);
+
+ const requestController = new AbortController();
+ const requestTimer = setTimeout(() => {
+ requestController.abort(new DOMException('CardDAV request timed out', 'TimeoutError'));
+ }, state.limits.requestTimeoutMs);
+ requestTimer.unref?.();
+ const signal = AbortSignal.any([operation.signal, requestController.signal]);
+
+ let response;
+ let bodyText;
+ let requestUrl;
+ try {
+ const parsedUrl = await abortable(assertHostAllowed(url, allowPrivate), signal);
+ if (parsedUrl.origin !== operation.credentialOrigin) {
+ throw errorWithCode(
+ 'Credentials cannot be sent to a different origin',
+ 'ERR_CROSS_ORIGIN_REDIRECT',
+ );
+ }
+ response = await safeFetch(url, {
+ method,
+ headers,
+ body,
+ signal,
+ }, {
+ allowPrivate,
+ credentialOrigin: operation.credentialOrigin,
+ ...(validateRedirect ? { validateRedirect } : {}),
+ });
+ requestUrl = response.url || parsedUrl.href;
+ if (new URL(requestUrl).origin !== operation.credentialOrigin) {
+ throw errorWithCode(
+ 'Credentialed redirects must stay on the configured origin',
+ 'ERR_CROSS_ORIGIN_REDIRECT',
+ );
+ }
+ bodyText = await readBody(response, state);
+ } catch (error) {
+ if (error instanceof CardDavError) throw error;
+ if (error.name === 'TimeoutError'
+ || (signal.aborted && signal.reason?.name === 'TimeoutError')) {
+ throw new CardDavError('CardDAV server did not respond (timed out)', {
+ operation: errorOperation,
+ cause: error,
+ });
+ }
+ throw new CardDavError(`Could not reach the CardDAV server: ${error.message}`, {
+ operation: errorOperation,
+ cause: error,
+ });
+ } finally {
+ clearTimeout(requestTimer);
+ }
+
+ if (response.status === 401) {
+ throw new CardDavError('Authentication failed — check the username and app password', {
+ status: response.status,
+ requestStatus: response.status,
+ precondition: parseDavErrorPrecondition(bodyText),
+ operation: errorOperation,
+ });
+ }
+ if (!response.ok && response.status !== 207 && !acceptedStatuses.includes(response.status)) {
+ throw new CardDavError(`CardDAV request failed (${response.status} ${response.statusText})`, {
+ status: response.status,
+ requestStatus: response.status,
+ precondition: parseDavErrorPrecondition(bodyText),
+ operation: errorOperation,
+ retryAfterAt: response.status === 429
+ ? parseRetryAfter(response.headers.get('retry-after'))
+ : null,
+ });
+ }
+ return {
+ bodyText,
+ headers: response.headers,
+ requestUrl,
+ status: response.status,
+ };
+}
+
+// Retry-After is remote-controlled: a malicious/compromised server (or a MITM on
+// a plaintext-allowed private target) could otherwise return a huge delay and
+// freeze sync for an unbounded duration. Cap the honored eligibility delay at one
+// hour so throttling stays bounded; past/immediate values pass through unchanged.
+const MAX_RETRY_AFTER_MS = 60 * 60 * 1000;
+
+function parseRetryAfter(value) {
+ if (value == null) return null;
+ const cap = Date.now() + MAX_RETRY_AFTER_MS;
+ if (/^\d+$/.test(value)) {
+ const timestamp = BigInt(Date.now()) + BigInt(value) * 1000n;
+ if (timestamp > BigInt(cap)) return new Date(cap).toISOString();
+ return new Date(Number(timestamp)).toISOString();
+ }
+ if (!/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:0[1-9]|[12]\d|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} (?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d GMT$/.test(value)) {
+ return null;
+ }
+ const timestamp = Date.parse(value);
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toUTCString() !== value) return null;
+ return new Date(Math.min(timestamp, cap)).toISOString();
+}
+
+function requestHeaders(callerHeaders, body) {
+ const requested = new Headers(callerHeaders);
+ const headers = {};
+ for (const [name, value] of requested) {
+ if (name === 'authorization' || name === 'depth') continue;
+ headers[name] = value;
+ }
+ if (body != null && !requested.has('content-type')) {
+ headers['Content-Type'] = 'application/xml; charset=utf-8';
+ }
+ return headers;
+}
+
+function basicAuth(username, password) {
+ return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
+}
+
+async function assertHostAllowed(url, allowPrivate) {
+ let parsed;
+ try { parsed = new URL(url); }
+ catch { throw new Error('Invalid server URL'); }
+ const error = await validateHost(parsed.hostname, { allowPrivate });
+ if (error) throw new Error(error);
+ return parsed;
+}
+
+function abortable(promise, signal) {
+ if (signal.aborted) return Promise.reject(signal.reason);
+ let onAbort;
+ const aborted = new Promise((resolve, reject) => {
+ onAbort = () => reject(signal.reason);
+ signal.addEventListener('abort', onAbort, { once: true });
+ });
+ return Promise.race([promise, aborted])
+ .finally(() => signal.removeEventListener('abort', onAbort));
+}
+
+async function readBody(response, state) {
+ const declaredLengthError = contentLengthError(response, state);
+ if (!response.body) {
+ if (declaredLengthError) throw declaredLengthError;
+ return '';
+ }
+ const reader = response.body.getReader();
+ try {
+ if (declaredLengthError) {
+ await cancelReader(reader);
+ throw declaredLengthError;
+ }
+
+ const decoder = new TextDecoder();
+ let responseBytes = 0;
+ let text = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const chunkBytes = value.byteLength;
+ if (responseBytes + chunkBytes > state.limits.maxResponseBytes) {
+ await cancelReader(reader);
+ throw errorWithCode(
+ 'CardDAV response exceeded the response byte limit',
+ 'ERR_DAV_RESPONSE_TOO_LARGE',
+ );
+ }
+ if (chunkBytes > state.remainingBytes) {
+ await cancelReader(reader);
+ throw errorWithCode(
+ 'CardDAV operation exceeded the cumulative response byte limit',
+ 'ERR_DAV_OPERATION_TOO_LARGE',
+ );
+ }
+ responseBytes += chunkBytes;
+ state.remainingBytes -= chunkBytes;
+ text += decoder.decode(value, { stream: true });
+ }
+ return text + decoder.decode();
+ } finally {
+ reader.releaseLock?.();
+ }
+}
+
+function contentLengthError(response, state) {
+ const declaredLength = parseContentLength(response.headers?.get('content-length'));
+ if (declaredLength == null) return null;
+ if (declaredLength > BigInt(state.limits.maxResponseBytes)) {
+ return errorWithCode(
+ 'CardDAV response exceeded the response byte limit',
+ 'ERR_DAV_RESPONSE_TOO_LARGE',
+ );
+ }
+ if (declaredLength > BigInt(state.remainingBytes)) {
+ return errorWithCode(
+ 'CardDAV operation exceeded the cumulative response byte limit',
+ 'ERR_DAV_OPERATION_TOO_LARGE',
+ );
+ }
+ return null;
+}
+
+function parseContentLength(value) {
+ if (value == null || !/^\d+$/.test(value)) return null;
+ return BigInt(value);
+}
+
+async function cancelReader(reader) {
+ try {
+ await reader.cancel();
+ } catch {
+ // The request is already failing; cancellation is best-effort.
+ }
+}
+
+function errorWithCode(message, code) {
+ return Object.assign(new Error(message), { code });
+}
diff --git a/backend/src/services/carddavTransport.test.js b/backend/src/services/carddavTransport.test.js
new file mode 100644
index 0000000..a59deec
--- /dev/null
+++ b/backend/src/services/carddavTransport.test.js
@@ -0,0 +1,491 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { validateHost } from './hostValidation.js';
+import { safeFetch } from './safeFetch.js';
+import {
+ CardDavError,
+ createDavOperation,
+ davRequest,
+ testOnlyCreateDavOperation,
+} from './carddavTransport.js';
+
+vi.mock('./hostValidation.js', () => ({
+ validateHost: vi.fn().mockResolvedValue(null),
+}));
+
+vi.mock('./safeFetch.js', () => ({
+ safeFetch: vi.fn(),
+}));
+
+const ORIGIN = 'https://dav.example.test';
+const REQUEST_URL = `${ORIGIN}/addressbooks/user/contacts/`;
+
+function limits(overrides = {}) {
+ return {
+ maxOperationBytes: 64,
+ maxResponseBytes: 32,
+ operationTimeoutMs: 1_000,
+ requestTimeoutMs: 500,
+ ...overrides,
+ };
+}
+
+function fakeResponse({
+ chunks = [],
+ contentLength,
+ hasBody = true,
+ headers: responseHeaders,
+ ok = true,
+ status = 207,
+ statusText = 'Multi-Status',
+ url = REQUEST_URL,
+} = {}) {
+ let index = 0;
+ const read = vi.fn(async () => (
+ index < chunks.length
+ ? { done: false, value: chunks[index++] }
+ : { done: true, value: undefined }
+ ));
+ const cancel = vi.fn(async () => {});
+ const releaseLock = vi.fn();
+ const headers = new Headers(responseHeaders);
+ if (contentLength != null) headers.set('Content-Length', contentLength);
+ return {
+ response: {
+ body: hasBody ? { getReader: () => ({ cancel, read, releaseLock }) } : null,
+ headers,
+ ok,
+ status,
+ statusText,
+ url,
+ },
+ cancel,
+ read,
+ releaseLock,
+ };
+}
+
+function bytes(text) {
+ return new TextEncoder().encode(text);
+}
+
+function request(operation, overrides = {}) {
+ return davRequest(operation, 'REPORT', REQUEST_URL, {
+ body: ' ',
+ password: 'test-password',
+ username: 'test-user',
+ ...overrides,
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+describe('DAV response byte limits', () => {
+ it('rejects an oversized Content-Length before reading the body', async () => {
+ const body = fakeResponse({ chunks: [bytes('ignored')], contentLength: '9' });
+ safeFetch.mockResolvedValueOnce(body.response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: 8 }));
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ cause: { code: 'ERR_DAV_RESPONSE_TOO_LARGE' },
+ });
+ expect(body.read).not.toHaveBeenCalled();
+ expect(body.cancel).toHaveBeenCalledOnce();
+ expect(body.releaseLock).toHaveBeenCalledOnce();
+ });
+
+ it('rejects an oversized Content-Length even when the response has no body stream', async () => {
+ const response = fakeResponse({ contentLength: '9', hasBody: false });
+ safeFetch.mockResolvedValueOnce(response.response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: 8 }));
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ cause: { code: 'ERR_DAV_RESPONSE_TOO_LARGE' },
+ });
+ expect(response.read).not.toHaveBeenCalled();
+ });
+
+ it('cancels a chunked UTF-8 body when streamed bytes cross the response cap', async () => {
+ const body = fakeResponse({ chunks: [bytes('abc'), bytes('de')], contentLength: '2' });
+ safeFetch.mockResolvedValueOnce(body.response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: 4 }));
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ cause: { code: 'ERR_DAV_RESPONSE_TOO_LARGE' },
+ });
+ expect(body.read).toHaveBeenCalledTimes(2);
+ expect(body.cancel).toHaveBeenCalledOnce();
+ });
+
+ it('rejects the second individually valid body when cumulative bytes cross the operation cap', async () => {
+ const first = fakeResponse({ chunks: [bytes('abc')] });
+ const second = fakeResponse({ chunks: [bytes('def')] });
+ safeFetch.mockResolvedValueOnce(first.response).mockResolvedValueOnce(second.response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({
+ maxOperationBytes: 5,
+ maxResponseBytes: 4,
+ }));
+
+ await expect(operation.run(async () => {
+ await request(operation);
+ return request(operation);
+ })).rejects.toMatchObject({
+ cause: { code: 'ERR_DAV_OPERATION_TOO_LARGE' },
+ });
+ expect(second.cancel).toHaveBeenCalledOnce();
+ });
+
+ it('accepts exact response and operation boundaries and decodes a split multibyte character', async () => {
+ const encoded = bytes('A€');
+ const body = fakeResponse({
+ chunks: [encoded.slice(0, 2), encoded.slice(2)],
+ contentLength: String(encoded.byteLength),
+ });
+ safeFetch.mockResolvedValueOnce(body.response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({
+ maxOperationBytes: encoded.byteLength,
+ maxResponseBytes: encoded.byteLength,
+ }));
+
+ await expect(operation.run(() => request(operation))).resolves.toMatchObject({
+ bodyText: 'A€',
+ requestUrl: REQUEST_URL,
+ });
+ expect(body.cancel).not.toHaveBeenCalled();
+ });
+});
+
+describe('DAV operation lifetime', () => {
+ it('aborts at the operation deadline after the per-request timer is recreated', async () => {
+ safeFetch
+ .mockImplementationOnce(() => new Promise(resolve => {
+ setTimeout(() => resolve(fakeResponse({ chunks: [bytes('ok')] }).response), 15);
+ }))
+ .mockImplementationOnce((_url, options) => new Promise((resolve, reject) => {
+ options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
+ }));
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({
+ operationTimeoutMs: 20,
+ requestTimeoutMs: 100,
+ }));
+ const pending = operation.run(async () => {
+ await request(operation);
+ return request(operation);
+ });
+ const rejected = expect(pending).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'CardDAV server did not respond (timed out)',
+ cause: { name: 'TimeoutError' },
+ });
+
+ await vi.advanceTimersByTimeAsync(20);
+
+ await rejected;
+ expect(safeFetch).toHaveBeenCalledTimes(2);
+ expect(safeFetch.mock.calls[1][1].signal.aborted).toBe(true);
+ });
+
+ it('aborts never-settling host validation at the operation deadline', async () => {
+ validateHost.mockImplementationOnce(() => new Promise(() => {}));
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({
+ operationTimeoutMs: 20,
+ requestTimeoutMs: 100,
+ }));
+ let rejection;
+ operation.run(() => request(operation)).catch(error => { rejection = error; });
+
+ await vi.advanceTimersByTimeAsync(20);
+
+ expect(rejection).toMatchObject({
+ name: 'CardDavError',
+ message: 'CardDAV server did not respond (timed out)',
+ cause: { name: 'TimeoutError' },
+ });
+ expect(safeFetch).not.toHaveBeenCalled();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('clears operation and request timers after completion', async () => {
+ safeFetch.mockResolvedValueOnce(fakeResponse({ chunks: [bytes('ok')] }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await operation.run(() => request(operation));
+
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('clears operation and request timers after failure', async () => {
+ const cause = new Error('socket closed');
+ safeFetch.mockRejectedValueOnce(cause);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'Could not reach the CardDAV server: socket closed',
+ cause,
+ });
+
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
+describe('DAV request boundary', () => {
+ it('exposes delta-seconds Retry-After as an absolute timestamp on 429', async () => {
+ vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z'));
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ headers: { 'Retry-After': '120' },
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 429,
+ retryAfterAt: '2026-07-12T12:02:00.250Z',
+ });
+ });
+
+ it('exposes an IMF-fixdate Retry-After as its absolute timestamp on 429', async () => {
+ vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z'));
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ headers: { 'Retry-After': 'Sun, 12 Jul 2026 12:02:00 GMT' },
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 429,
+ retryAfterAt: '2026-07-12T12:02:00.000Z',
+ });
+ });
+
+ it('exposes a valid past IMF-fixdate without turning it into a future delay', async () => {
+ vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z'));
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ headers: { 'Retry-After': 'Thu, 01 Jan 1970 00:00:00 GMT' },
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 429,
+ retryAfterAt: '1970-01-01T00:00:00.000Z',
+ });
+ });
+
+ it('caps a huge delta-seconds Retry-After at one hour from now', async () => {
+ vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z'));
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ headers: { 'Retry-After': '9999999999' },
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 429,
+ retryAfterAt: '2026-07-12T13:00:00.250Z',
+ });
+ });
+
+ it('caps a far-future IMF-fixdate Retry-After at one hour from now', async () => {
+ vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z'));
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ headers: { 'Retry-After': 'Fri, 01 Jan 2100 00:00:00 GMT' },
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 429,
+ retryAfterAt: '2026-07-12T13:00:00.250Z',
+ });
+ });
+
+ it.each([
+ ['missing', undefined],
+ ['arbitrary date text', 'tomorrow'],
+ ['non-numeric delay', '120 seconds'],
+ ['invalid IMF-fixdate', 'Sun, 32 Jul 2026 12:02:00 GMT'],
+ ])('does not expose Retry-After eligibility for a 429 with %s header', async (_label, value) => {
+ const headers = value === undefined ? {} : { 'Retry-After': value };
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ headers,
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ status: 429,
+ retryAfterAt: null,
+ });
+ });
+
+ it('merges caller headers and exposes a bounded non-XML success body', async () => {
+ const vcard = 'BEGIN:VCARD\nVERSION:4.0\nEND:VCARD';
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ chunks: [bytes(vcard)],
+ headers: { ETag: 'W/"opaque"', 'Content-Type': 'text/vcard' },
+ status: 200,
+ statusText: 'OK',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({
+ maxOperationBytes: bytes(vcard).byteLength,
+ maxResponseBytes: bytes(vcard).byteLength,
+ }));
+
+ const result = await operation.run(() => davRequest(operation, 'GET', REQUEST_URL, {
+ headers: { Accept: 'text/vcard', Authorization: 'Basic attacker-controlled' },
+ password: 'test-password',
+ username: 'test-user',
+ }));
+
+ expect(result).toMatchObject({
+ bodyText: vcard,
+ requestUrl: REQUEST_URL,
+ status: 200,
+ });
+ expect(result.headers.get('etag')).toBe('W/"opaque"');
+ const requestHeaders = new Headers(safeFetch.mock.calls[0][1].headers);
+ expect(requestHeaders.get('accept')).toBe('text/vcard');
+ expect(requestHeaders.get('authorization'))
+ .toBe(`Basic ${Buffer.from('test-user:test-password').toString('base64')}`);
+ expect(requestHeaders.has('content-type')).toBe(false);
+ });
+
+ it('returns the final trusted response URL for href resolution', async () => {
+ const finalUrl = `${ORIGIN}/canonical/contacts/`;
+ safeFetch.mockResolvedValueOnce(fakeResponse({ url: finalUrl }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).resolves.toMatchObject({
+ bodyText: '',
+ requestUrl: finalUrl,
+ });
+ });
+
+ it('passes the credential origin and Basic authorization through one transport path', async () => {
+ safeFetch.mockResolvedValueOnce(fakeResponse().response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await operation.run(() => request(operation, { allowPrivate: true, depth: 0 }));
+
+ expect(validateHost).toHaveBeenCalledWith('dav.example.test', { allowPrivate: true });
+ expect(safeFetch).toHaveBeenCalledWith(
+ REQUEST_URL,
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: `Basic ${Buffer.from('test-user:test-password').toString('base64')}`,
+ Depth: '0',
+ }),
+ method: 'REPORT',
+ }),
+ { allowPrivate: true, credentialOrigin: ORIGIN },
+ );
+ });
+
+ it('reads an error body through the same bounded path before parsing its DAV precondition', async () => {
+ const xml = ' ';
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ chunks: [bytes(xml)],
+ ok: false,
+ status: 403,
+ statusText: 'Forbidden',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: bytes(xml).byteLength }));
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'CardDAV request failed (403 Forbidden)',
+ precondition: 'valid-sync-token',
+ requestStatus: 403,
+ status: 403,
+ });
+ });
+
+ it('preserves the existing 401 message', async () => {
+ safeFetch.mockResolvedValueOnce(fakeResponse({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ }).response);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toMatchObject({
+ name: 'CardDavError',
+ message: 'Authentication failed — check the username and app password',
+ requestStatus: 401,
+ status: 401,
+ });
+ });
+
+ it('keeps one CardDavError class and wraps cross-origin policy failures with their cause', async () => {
+ const cause = Object.assign(new Error('redirect escaped'), {
+ code: 'ERR_CROSS_ORIGIN_REDIRECT',
+ });
+ safeFetch.mockRejectedValueOnce(cause);
+ const operation = testOnlyCreateDavOperation(ORIGIN, limits());
+
+ await expect(operation.run(() => request(operation))).rejects.toSatisfy(error => (
+ error instanceof CardDavError
+ && error.cause === cause
+ && error.cause.code === 'ERR_CROSS_ORIGIN_REDIRECT'
+ ));
+ });
+});
+
+describe('CardDAV client transport adoption', () => {
+ it('does not let extra arguments alter production operation limits', async () => {
+ safeFetch.mockResolvedValueOnce(fakeResponse({ chunks: [bytes('ok')] }).response);
+ const operation = createDavOperation(ORIGIN, limits({
+ maxOperationBytes: 1,
+ maxResponseBytes: 1,
+ }));
+
+ await expect(operation.run(() => request(operation))).resolves.toMatchObject({
+ bodyText: 'ok',
+ });
+ });
+
+ it('does not re-export transport operations through the client', async () => {
+ const client = await import('./carddavClient.js');
+
+ expect(client).not.toHaveProperty('createDavOperation');
+ expect(client).not.toHaveProperty('davRequest');
+ });
+
+ it('uses the streamed transport path without a response.text fallback', async () => {
+ const xml = ' ';
+ safeFetch.mockResolvedValueOnce(fakeResponse({ chunks: [bytes(xml)] }).response);
+ const { fetchAddressBookCards } = await import('./carddavClient.js');
+
+ await expect(fetchAddressBookCards({
+ url: REQUEST_URL,
+ username: 'test-user',
+ password: 'test-password',
+ })).resolves.toEqual([]);
+ });
+});
diff --git a/backend/src/services/carddavXml.js b/backend/src/services/carddavXml.js
new file mode 100644
index 0000000..8e19351
--- /dev/null
+++ b/backend/src/services/carddavXml.js
@@ -0,0 +1,273 @@
+import { XMLParser, XMLValidator } from 'fast-xml-parser';
+
+export const DAV_NS = 'DAV:';
+export const CARDDAV_NS = 'urn:ietf:params:xml:ns:carddav';
+export const DAV_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
+
+const XML_NS = 'http://www.w3.org/XML/1998/namespace';
+const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
+const DAV_MAX_ENTITY_EXPANSIONS = 1_000_000;
+
+const parser = new XMLParser({
+ preserveOrder: true,
+ removeNSPrefix: false,
+ ignoreAttributes: false,
+ parseTagValue: false,
+ parseAttributeValue: false,
+ htmlEntities: true,
+ trimValues: false,
+ ignoreDeclaration: true,
+ ignorePiTags: true,
+ maxNestedTags: 100,
+ processEntities: {
+ enabled: true,
+ maxEntitySize: 10_000,
+ maxExpansionDepth: 10,
+ maxTotalExpansions: DAV_MAX_ENTITY_EXPANSIONS,
+ maxExpandedLength: DAV_MAX_RESPONSE_BYTES,
+ maxEntityCount: 100,
+ },
+});
+
+export function xmlEscape(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function parseXmlDocument(xmlText, label = 'XML document') {
+ if (typeof xmlText !== 'string' || xmlText.length === 0) {
+ throw new Error(`${label} was not valid XML: document is empty`);
+ }
+ if (/])/i.test(xmlText)) {
+ throw new Error(`${label} must not contain a DOCTYPE`);
+ }
+
+ const validation = XMLValidator.validate(xmlText);
+ if (validation !== true) {
+ throw new Error(`${label} was not valid XML: ${validation.err.msg}`);
+ }
+
+ let orderedDocument;
+ try {
+ orderedDocument = parser.parse(xmlText);
+ } catch (error) {
+ throw new Error(`${label} could not be parsed: ${error.message}`, { cause: error });
+ }
+
+ const roots = [];
+ for (const entry of orderedDocument) {
+ if (!elementQName(entry)) continue;
+ roots.push(resolveElement(entry, new Map([['xml', XML_NS]]), label));
+ }
+ if (roots.length !== 1) {
+ throw new Error(`${label} must contain exactly one top-level element`);
+ }
+ return roots[0];
+}
+
+export function parseDavMultistatus(xmlText, label = 'response') {
+ const root = parseXmlDocument(xmlText, label);
+ if (!isNamed(root, DAV_NS, 'multistatus')) {
+ throw new Error(`CardDAV ${label} was not a valid DAV multistatus`);
+ }
+ return root;
+}
+
+export function childrenNamed(node, namespaceURI, localName) {
+ if (!Array.isArray(node?.children)) return [];
+ return node.children.filter(child => isNamed(child, namespaceURI, localName));
+}
+
+export function onlyChildNamed(node, namespaceURI, localName, label = describeNode(node)) {
+ const matches = childrenNamed(node, namespaceURI, localName);
+ if (matches.length !== 1) {
+ throw new Error(
+ `${label} must contain exactly one ${describeName(namespaceURI, localName)} child; found ${matches.length}`,
+ );
+ }
+ return matches[0];
+}
+
+export function textOfNode(node) {
+ return typeof node?.text === 'string' ? node.text : '';
+}
+
+export function parseDavResponse(responseNode, label = 'DAV response') {
+ if (!isNamed(responseNode, DAV_NS, 'response')) {
+ throw new Error(`${label} was not a DAV response element`);
+ }
+
+ const href = textOfNode(onlyChildNamed(responseNode, DAV_NS, 'href', label));
+ const statusNodes = childrenNamed(responseNode, DAV_NS, 'status');
+ const propstatNodes = childrenNamed(responseNode, DAV_NS, 'propstat');
+ const hasStatus = statusNodes.length > 0;
+ const hasPropstats = propstatNodes.length > 0;
+
+ if (hasStatus === hasPropstats) {
+ throw new Error(
+ `${label} must contain either one DAV status or one or more DAV propstat children, but not both`,
+ );
+ }
+
+ if (hasStatus) {
+ const statusNode = onlyChildNamed(responseNode, DAV_NS, 'status', label);
+ return {
+ href,
+ status: parseStatusCode(statusNode, label),
+ propstats: [],
+ };
+ }
+
+ const propstats = propstatNodes.map((propstatNode, index) => {
+ const propstatLabel = `${label} propstat ${index + 1}`;
+ const propNode = onlyChildNamed(propstatNode, DAV_NS, 'prop', propstatLabel);
+ const statusNode = onlyChildNamed(propstatNode, DAV_NS, 'status', propstatLabel);
+ return {
+ status: parseStatusCode(statusNode, propstatLabel),
+ properties: propNode.children,
+ };
+ });
+
+ return { href, status: null, propstats };
+}
+
+export function successfulProperties(response) {
+ if (!Array.isArray(response?.propstats)) {
+ throw new TypeError('successfulProperties requires a parsed DAV response');
+ }
+ return response.propstats
+ .filter(({ status }) => status >= 200 && status < 300)
+ .flatMap(({ properties }) => properties);
+}
+
+export function parseDavErrorPrecondition(xmlText) {
+ if (!xmlText) return null;
+ try {
+ const root = parseXmlDocument(xmlText, 'DAV error response');
+ if (!isNamed(root, DAV_NS, 'error')) return null;
+ return root.children.find(child => isNamed(child, DAV_NS, 'valid-sync-token'))?.localName ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function resolveElement(entry, inheritedNamespaces, label) {
+ const qName = elementQName(entry);
+ const namespaces = new Map(inheritedNamespaces);
+ const attributes = Object.entries(entry[':@'] || {}).map(([rawName, value]) => ({
+ name: rawName.startsWith('@_') ? rawName.slice(2) : rawName,
+ value,
+ }));
+
+ for (const { name, value } of attributes) {
+ const declaredPrefix = namespaceDeclarationPrefix(name, label);
+ if (declaredPrefix === null) continue;
+ validateNamespaceDeclaration(declaredPrefix, value, label);
+ namespaces.set(declaredPrefix, value);
+ }
+ const resolvedAttributes = [];
+ for (const { name, value } of attributes) {
+ if (namespaceDeclarationPrefix(name, label) !== null) continue;
+ const { prefix, localName: attributeLocalName } = splitQName(name, label);
+ if (prefix && (!namespaces.has(prefix) || namespaces.get(prefix) === '')) {
+ throw new Error(`${label} uses unbound namespace prefix "${prefix}"`);
+ }
+ resolvedAttributes.push({
+ namespaceURI: prefix ? namespaces.get(prefix) : null,
+ localName: attributeLocalName,
+ value,
+ });
+ }
+
+ const { prefix, localName } = splitQName(qName, label);
+ let namespaceURI = null;
+ if (prefix) {
+ if (!namespaces.has(prefix) || namespaces.get(prefix) === '') {
+ throw new Error(`${label} uses unbound namespace prefix "${prefix}"`);
+ }
+ namespaceURI = namespaces.get(prefix);
+ } else if (namespaces.has('') && namespaces.get('') !== '') {
+ namespaceURI = namespaces.get('');
+ }
+
+ const children = [];
+ let text = '';
+ for (const childEntry of entry[qName] || []) {
+ if (Object.hasOwn(childEntry, '#text')) {
+ text += childEntry['#text'];
+ continue;
+ }
+ if (elementQName(childEntry)) {
+ children.push(resolveElement(childEntry, namespaces, label));
+ }
+ }
+
+ return { namespaceURI, localName, attributes: resolvedAttributes, children, text };
+}
+
+function namespaceDeclarationPrefix(attributeName, label) {
+ if (attributeName === 'xmlns') return '';
+ if (attributeName === 'xmlns:') {
+ throw new Error(`${label} contains invalid namespace declaration "xmlns:"`);
+ }
+ if (attributeName.startsWith('xmlns:')) return attributeName.slice('xmlns:'.length);
+ return null;
+}
+
+function validateNamespaceDeclaration(prefix, namespaceURI, label) {
+ if (prefix.includes(':')) {
+ throw new Error(`${label} contains invalid namespace declaration "xmlns:${prefix}"`);
+ }
+ if (prefix === 'xmlns') {
+ throw new Error(`${label} cannot declare the reserved namespace prefix "xmlns"`);
+ }
+ if (prefix === 'xml') {
+ if (namespaceURI !== XML_NS) {
+ throw new Error(`${label} cannot rebind the reserved namespace prefix "xml"`);
+ }
+ return;
+ }
+ if (namespaceURI === XML_NS || namespaceURI === XMLNS_NS) {
+ throw new Error(`${label} cannot bind a reserved namespace URI to "${prefix}"`);
+ }
+ if (prefix && namespaceURI === '') {
+ throw new Error(`${label} cannot undeclare namespace prefix "${prefix}"`);
+ }
+}
+
+function elementQName(entry) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
+ const names = Object.keys(entry).filter(name => name !== ':@' && name !== '#text');
+ return names.length === 1 ? names[0] : null;
+}
+
+function splitQName(qName, label) {
+ const parts = qName.split(':');
+ if (parts.length === 1) return { prefix: '', localName: qName };
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
+ throw new Error(`${label} contains invalid qualified name "${qName}"`);
+ }
+ return { prefix: parts[0], localName: parts[1] };
+}
+
+function parseStatusCode(statusNode, label) {
+ const match = /^HTTP\/\S+\s+(\d{3})(?:\s|$)/i.exec(textOfNode(statusNode));
+ if (!match) throw new Error(`${label} must contain a parseable DAV status`);
+ return Number(match[1]);
+}
+
+function isNamed(node, namespaceURI, localName) {
+ return node?.namespaceURI === namespaceURI && node?.localName === localName;
+}
+
+function describeNode(node) {
+ return node ? describeName(node.namespaceURI, node.localName) : 'XML element';
+}
+
+function describeName(namespaceURI, localName) {
+ return namespaceURI === DAV_NS ? `DAV ${localName}` : `{${namespaceURI ?? ''}}${localName}`;
+}
diff --git a/backend/src/services/carddavXml.test.js b/backend/src/services/carddavXml.test.js
new file mode 100644
index 0000000..68bf632
--- /dev/null
+++ b/backend/src/services/carddavXml.test.js
@@ -0,0 +1,298 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ CARDDAV_NS,
+ DAV_NS,
+ childrenNamed,
+ onlyChildNamed,
+ parseDavErrorPrecondition,
+ parseDavMultistatus,
+ parseDavResponse,
+ parseXmlDocument,
+ successfulProperties,
+ textOfNode,
+ xmlEscape,
+} from './carddavXml.js';
+
+describe('xmlEscape', () => {
+ it('escapes all five predefined XML entities', () => {
+ expect(xmlEscape(`&<>"'`)).toBe('&<>"'');
+ });
+});
+
+const XML_NS = 'http://www.w3.org/XML/1998/namespace';
+const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
+
+describe('namespace-aware DAV XML parsing', () => {
+ it('requires the exact DAV namespace for multistatus', () => {
+ expect(() => parseDavMultistatus(
+ ' ',
+ 'sync response',
+ )).toThrow(/DAV multistatus/);
+ });
+
+ it.each([
+ 'next ',
+ 'next ',
+ ])('accepts DAV elements with any bound prefix spelling', xml => {
+ const root = parseDavMultistatus(xml, 'sync response');
+
+ expect(root).toMatchObject({
+ namespaceURI: DAV_NS,
+ localName: 'multistatus',
+ });
+ expect(textOfNode(onlyChildNamed(root, DAV_NS, 'sync-token'))).toBe('next');
+ });
+
+ it('preserves opaque text while decoding standard XML entities', () => {
+ const root = parseDavMultistatus(
+ ' 00123&x ',
+ 'sync response',
+ );
+
+ expect(textOfNode(onlyChildNamed(root, DAV_NS, 'sync-token'))).toBe(' 00123&x ');
+ });
+
+ it('resolves a nested CardDAV namespace without matching a foreign local name', () => {
+ const root = parseDavMultistatus(`
+ /contacts/a&b.vcf
+ BEGIN:VCARD
+FN:A & B
+END:VCARD
+ poison
+ HTTP/1.1 200 OK
+ `, 'multiget response');
+ const response = parseDavResponse(
+ onlyChildNamed(root, DAV_NS, 'response'),
+ 'multiget response member',
+ );
+ const properties = successfulProperties(response);
+
+ expect(response.href).toBe('/contacts/a&b.vcf');
+ expect(properties.map(({ namespaceURI, localName }) => [namespaceURI, localName])).toEqual([
+ [CARDDAV_NS, 'address-data'],
+ ['urn:not-carddav', 'address-data'],
+ ]);
+ expect(textOfNode(properties[0])).toBe('BEGIN:VCARD\nFN:A & B\nEND:VCARD');
+ expect(properties.filter(node => (
+ node.namespaceURI === CARDDAV_NS && node.localName === 'address-data'
+ ))).toHaveLength(1);
+ });
+
+ it('preserves ordered namespace-aware attributes on CardDAV elements', () => {
+ const root = parseXmlDocument(`
+
+
+ `, 'supported address data');
+
+ expect(root.children.map(node => node.attributes)).toEqual([
+ [
+ { namespaceURI: null, localName: 'content-type', value: 'text/vcard' },
+ { namespaceURI: null, localName: 'version', value: '4.0' },
+ { namespaceURI: 'urn:extension', localName: 'label', value: 'modern' },
+ ],
+ [
+ { namespaceURI: null, localName: 'version', value: '3.0' },
+ { namespaceURI: null, localName: 'content-type', value: 'text/x-vcard' },
+ ],
+ ]);
+ });
+
+ it('rejects malformed XML, multiple roots, unbound prefixes, and a custom-entity DOCTYPE', () => {
+ expect(() => parseXmlDocument(
+ ' ',
+ 'sync response',
+ )).toThrow(/valid XML/);
+ expect(() => parseXmlDocument(
+ ' ',
+ 'sync response',
+ )).toThrow(/valid XML|top-level/);
+ expect(() => parseXmlDocument(' ', 'sync response'))
+ .toThrow(/unbound namespace prefix/);
+ expect(() => parseXmlDocument(
+ ']>&token; ',
+ 'sync response',
+ )).toThrow(/DOCTYPE/);
+ });
+
+ it('rejects an unbound prefixed attribute QName', () => {
+ expect(() => parseXmlDocument(
+ ' ',
+ 'DAV error response',
+ )).toThrow(/unbound namespace prefix "X"/);
+ });
+
+ it('rejects a namespace declaration with an empty prefix QName', () => {
+ expect(() => parseXmlDocument(
+ ' ',
+ 'DAV error response',
+ )).toThrow(/invalid namespace declaration/);
+ });
+
+ it.each([
+ {
+ name: 'xml rebound to DAV',
+ xml: ' ',
+ },
+ {
+ name: 'xmlns declared as a normal prefix',
+ xml: ' ',
+ },
+ {
+ name: 'another prefix bound to the XML namespace',
+ xml: ` `,
+ },
+ {
+ name: 'a prefix bound to the xmlns namespace',
+ xml: ` `,
+ },
+ ])('rejects reserved namespace binding: $name', ({ xml }) => {
+ expect(() => parseXmlDocument(xml, 'DAV error response')).toThrow(/reserved namespace/);
+ });
+
+ it('accepts the implicit xml prefix and its exact reserved binding', () => {
+ const root = parseXmlDocument(
+ ` `,
+ 'DAV error response',
+ );
+
+ expect(root).toMatchObject({ namespaceURI: DAV_NS, localName: 'error' });
+ });
+});
+
+describe('DAV response structure', () => {
+ it('requires exactly one required direct child', () => {
+ const root = parseDavMultistatus(
+ 'one two ',
+ 'sync response',
+ );
+
+ expect(() => onlyChildNamed(root, DAV_NS, 'sync-token'))
+ .toThrow(/exactly one.*sync-token/i);
+ });
+
+ it('requires one href and response status XOR one or more propstats', () => {
+ const duplicateHref = responseNode(`
+ one two HTTP/1.1 200 OK
+ `);
+ const mixedShape = responseNode(`
+ one HTTP/1.1 200 OK
+ HTTP/1.1 200 OK
+ `);
+ const missingShape = responseNode(
+ 'one ',
+ );
+
+ expect(() => parseDavResponse(duplicateHref)).toThrow(/exactly one.*href/i);
+ expect(() => parseDavResponse(mixedShape)).toThrow(/status.*propstat|propstat.*status/i);
+ expect(() => parseDavResponse(missingShape)).toThrow(/status.*propstat|propstat.*status/i);
+ });
+
+ it('requires exactly one prop and one parseable status per propstat', () => {
+ const missingStatus = responseNode(`
+ one etag
+ `);
+ const invalidStatus = responseNode(`
+ one success
+ `);
+
+ expect(() => parseDavResponse(missingStatus)).toThrow(/exactly one.*status/i);
+ expect(() => parseDavResponse(invalidStatus)).toThrow(/parseable.*status/i);
+ });
+
+ it('returns only ordered properties from successful propstats', () => {
+ const response = parseDavResponse(responseNode(`
+ 00123
+ 0007 extension
+ HTTP/1.1 200 OK
+ stale
+ HTTP/1.1 404 Not Found
+ `));
+
+ expect(response.href).toBe(' 00123 ');
+ expect(response.status).toBeNull();
+ expect(response.propstats.map(({ status }) => status)).toEqual([200, 404]);
+ expect(successfulProperties(response).map(node => ({
+ namespaceURI: node.namespaceURI,
+ localName: node.localName,
+ text: textOfNode(node),
+ }))).toEqual([
+ { namespaceURI: DAV_NS, localName: 'getetag', text: '0007' },
+ { namespaceURI: 'urn:extension', localName: 'getetag', text: 'extension' },
+ ]);
+ });
+});
+
+describe('parseDavErrorPrecondition', () => {
+ it('finds an exact DAV valid-sync-token after another DAV child', () => {
+ expect(parseDavErrorPrecondition(
+ ' ',
+ )).toBe('valid-sync-token');
+ });
+
+ it.each([
+ '',
+ 'not XML',
+ 'Forbidden',
+ ' ',
+ ' ',
+ ])('returns null for a non-DAV error precondition', xml => {
+ expect(parseDavErrorPrecondition(xml)).toBeNull();
+ });
+
+ it.each([
+ ' ',
+ ' ',
+ ' ',
+ ])('does not accept a namespace-invalid DAV error document', xml => {
+ expect(parseDavErrorPrecondition(xml)).toBeNull();
+ });
+
+ it('does not accept a malformed default-namespace spoof document', () => {
+ expect(parseDavErrorPrecondition(
+ ' ',
+ )).toBeNull();
+ });
+});
+
+describe('CardDAV client XML boundary', () => {
+ it('does not parse a successful response as a DAV error body', async () => {
+ vi.resetModules();
+ const parseErrorPrecondition = vi.fn();
+ vi.doMock('./carddavXml.js', async importOriginal => ({
+ ...await importOriginal(),
+ parseDavErrorPrecondition: parseErrorPrecondition,
+ }));
+ vi.doMock('./hostValidation.js', () => ({
+ validateHost: vi.fn().mockResolvedValue(null),
+ }));
+ vi.doMock('./safeFetch.js', () => ({
+ safeFetch: vi.fn().mockResolvedValue(new Response(
+ ' ',
+ { status: 207, statusText: 'Multi-Status' },
+ )),
+ }));
+
+ try {
+ const { fetchAddressBookCards } = await import('./carddavClient.js');
+
+ await expect(fetchAddressBookCards({
+ url: 'https://carddav.example.test/addressbooks/user/contacts/',
+ username: 'user',
+ password: 'app-password',
+ })).resolves.toEqual([]);
+ expect(parseErrorPrecondition).not.toHaveBeenCalled();
+ } finally {
+ vi.doUnmock('./carddavXml.js');
+ vi.doUnmock('./hostValidation.js');
+ vi.doUnmock('./safeFetch.js');
+ vi.resetModules();
+ }
+ });
+});
+
+function responseNode(xml) {
+ const root = parseXmlDocument(xml, 'DAV response fixture');
+ expect(childrenNamed({ children: [root] }, DAV_NS, 'response')).toEqual([root]);
+ return root;
+}
diff --git a/backend/src/services/db.js b/backend/src/services/db.js
index 68c6d5d..17078a5 100644
--- a/backend/src/services/db.js
+++ b/backend/src/services/db.js
@@ -5,7 +5,7 @@ const { Pool } = pg;
export const pool = new Pool({
host: process.env.DB_HOST || 'postgres',
- port: 5432,
+ port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'mailflow',
user: process.env.DB_USER || 'mailflow',
password: process.env.DB_PASSWORD,
diff --git a/backend/src/services/hostValidation.js b/backend/src/services/hostValidation.js
index 5eb559e..5a57957 100644
--- a/backend/src/services/hostValidation.js
+++ b/backend/src/services/hostValidation.js
@@ -27,28 +27,59 @@ function isPrivateIPv4(ip) {
);
}
-function isPrivateIPv6(ip) {
- const h = ip.toLowerCase();
- if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80')) return true;
- // IPv4-mapped IPv6 (::ffff:x.x.x.x) — check the embedded IPv4 address.
- // Without this, ::ffff:127.0.0.1 bypasses the IPv4 private-range checks.
- if (h.startsWith('::ffff:')) {
- const embedded = h.slice(7);
- if (isIPv4(embedded)) return isPrivateIPv4(embedded);
+function ipv6ToBigInt(ip) {
+ let normalized = ip.toLowerCase();
+ const lastWord = normalized.slice(normalized.lastIndexOf(':') + 1);
+ if (isIPv4(lastWord)) {
+ const value = ipv4ToLong(lastWord);
+ normalized = `${normalized.slice(0, normalized.lastIndexOf(':') + 1)}`
+ + `${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
}
- // 6to4 (2002::/16) — embeds an IPv4 address in bits 16-47.
- // e.g. 2002:7f00:0001:: wraps 127.0.0.1 and bypasses IPv4 checks without this guard.
- const sixToFour = h.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/);
- if (sixToFour) {
- const hi = parseInt(sixToFour[1].padStart(4, '0'), 16);
- const lo = parseInt(sixToFour[2].padStart(4, '0'), 16);
- const embedded = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
- if (isPrivateIPv4(embedded)) return true;
+
+ const halves = normalized.split('::');
+ if (halves.length > 2) return null;
+ const left = halves[0] ? halves[0].split(':') : [];
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
+ const missing = 8 - left.length - right.length;
+ if (halves.length === 2 && missing < 1) return null;
+ const words = halves.length === 2
+ ? [...left, ...Array(missing).fill('0'), ...right]
+ : left;
+ if (words.length !== 8 || words.some(word => !/^[0-9a-f]{1,4}$/.test(word))) return null;
+
+ return words.reduce((value, word) => (value << 16n) | BigInt(`0x${word}`), 0n);
+}
+
+function inIPv6Cidr(ip, base, bits) {
+ const shift = 128n - BigInt(bits);
+ return (ip >> shift) === (base >> shift);
+}
+
+const ipv4MappedPrefix = ipv6ToBigInt('::ffff:0:0');
+const privateIPv6Cidrs = [
+ ['::', 128],
+ ['::1', 128],
+ ['64:ff9b:1::', 48],
+ ['100::', 64],
+ ['2001::', 32], // Teredo
+ ['2001:db8::', 32], // documentation
+ ['2001:10::', 28], // ORCHID
+ ['2002::', 16], // 6to4
+ ['fc00::', 7], // unique-local
+ ['fe80::', 10], // link-local
+ ['ff00::', 8], // multicast
+].map(([base, bits]) => [ipv6ToBigInt(base), bits]);
+
+function isPrivateIPv6(ip) {
+ const value = ipv6ToBigInt(ip);
+ if (value == null) return false;
+ if (inIPv6Cidr(value, ipv4MappedPrefix, 96)) {
+ const embedded = Number(value & 0xffffffffn);
+ const ipv4 = `${embedded >>> 24}.${(embedded >>> 16) & 0xff}`
+ + `.${(embedded >>> 8) & 0xff}.${embedded & 0xff}`;
+ return isPrivateIPv4(ipv4);
}
- // Teredo (2001:0000::/32) — reject the entire prefix; Teredo tunnels UDP through NAT
- // and can reach private ranges via the embedded server/client address fields.
- if (/^2001:0{1,4}:/.test(h)) return true;
- return false;
+ return privateIPv6Cidrs.some(([base, bits]) => inIPv6Cidr(value, base, bits));
}
// Synchronous check: literal IPs and reserved hostnames.
diff --git a/backend/src/services/hostValidation.test.js b/backend/src/services/hostValidation.test.js
index 3577b96..680c634 100644
--- a/backend/src/services/hostValidation.test.js
+++ b/backend/src/services/hostValidation.test.js
@@ -12,6 +12,23 @@ vi.mock('dns', () => ({
// Pull the mocked fns for per-test control.
const { promises: dns } = await import('dns');
+const publicIPv6 = '2600::1';
+const blockedIPv6 = [
+ ['unspecified', '::'],
+ ['loopback', '::1'],
+ ['IPv4-mapped loopback in hexadecimal form', '::ffff:7f00:1'],
+ ['IPv4-mapped loopback in dotted form', '::ffff:127.0.0.1'],
+ ['local-use translation prefix', '64:ff9b:1::1'],
+ ['discard-only prefix', '100::1'],
+ ['documentation prefix', '2001:db8::1'],
+ ['ORCHID prefix', '2001:10::1'],
+ ['6to4-wrapped private IPv4', '2002:7f00:1::'],
+ ['unique-local', 'fc00::1'],
+ ['link-local', 'fe90::1'],
+ ['multicast', 'ff02::1'],
+ ['Teredo', '2001:0:4136:e378:8000:63bf:3fff:fdd2'],
+];
+
beforeEach(() => {
dns.resolve4.mockClear();
dns.resolve6.mockClear();
@@ -31,7 +48,7 @@ describe('validateHostLiteral', () => {
});
it('passes a valid public IPv6', () => {
- expect(validateHostLiteral('2001:db8::1')).toBeNull();
+ expect(validateHostLiteral(publicIPv6)).toBeNull();
});
it('returns null for null/undefined/empty', () => {
@@ -69,26 +86,24 @@ describe('validateHostLiteral', () => {
expect(validateHostLiteral(ip)).toMatch(/private|reserved/i);
});
- // Private IPv6
- it.each([
- ['::1'], // loopback
- ['fc00::1'], // ULA
- ['fd12:3456::1'], // ULA
- ['fe80::1'], // link-local
- ['::ffff:127.0.0.1'], // IPv4-mapped loopback
- ['::ffff:192.168.1.1'], // IPv4-mapped private
- ['::ffff:10.0.0.1'], // IPv4-mapped private
- ])('blocks private IPv6 %s', ip => {
+ it.each(blockedIPv6)('blocks reserved IPv6 %s (%s)', (_name, ip) => {
expect(validateHostLiteral(ip)).toMatch(/private|reserved/i);
});
+ it.each([
+ ['::ffff:808:808'],
+ ['::ffff:8.8.8.8'],
+ ])('passes public IPv4-mapped IPv6 %s', ip => {
+ expect(validateHostLiteral(ip)).toBeNull();
+ });
+
// Bracket-wrapped IPv6
it('blocks private IPv6 in bracket notation', () => {
expect(validateHostLiteral('[::1]')).toMatch(/private|reserved/i);
});
it('passes public IPv6 in bracket notation', () => {
- expect(validateHostLiteral('[2001:db8::1]')).toBeNull();
+ expect(validateHostLiteral(`[${publicIPv6}]`)).toBeNull();
});
it('trims whitespace before checking', () => {
@@ -115,6 +130,16 @@ describe('validateHost', () => {
expect(await validateHost('evil.attacker.com')).toMatch(/private|reserved/i);
});
+ it.each(blockedIPv6)('blocks a hostname resolving to reserved IPv6 %s (%s)', async (_name, ip) => {
+ dns.resolve6.mockResolvedValue([ip]);
+ expect(await validateHost('evil.attacker.com')).toMatch(/private|reserved/i);
+ });
+
+ it('passes a hostname resolving to a public IPv6 address', async () => {
+ dns.resolve6.mockResolvedValue([publicIPv6]);
+ expect(await validateHost('imap.example.com')).toBeNull();
+ });
+
it('passes when DNS resolution fails (connection will fail naturally)', async () => {
dns.resolve4.mockRejectedValue(new Error('NXDOMAIN'));
dns.resolve6.mockRejectedValue(new Error('NXDOMAIN'));
@@ -148,8 +173,8 @@ describe('resolveForConnection', () => {
});
it('returns the literal IP with no servername for a public IPv6', async () => {
- const result = await resolveForConnection('2001:db8::1');
- expect(result.host).toBe('2001:db8::1');
+ const result = await resolveForConnection(publicIPv6);
+ expect(result.host).toBe(publicIPv6);
expect(result.servername).toBeNull();
});
diff --git a/backend/src/services/migrations.js b/backend/src/services/migrations.js
index 38fc9a2..5dd41dd 100644
--- a/backend/src/services/migrations.js
+++ b/backend/src/services/migrations.js
@@ -5,20 +5,20 @@ import { pool } from './db.js';
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
-async function getMigrationFiles() {
- const files = (await readdir(MIGRATIONS_DIR))
+async function getMigrationFiles(migrationsDirectory) {
+ const files = (await readdir(migrationsDirectory))
.filter(f => /^\d{4}_.+\.sql$/.test(f))
.sort();
return Promise.all(
files.map(async filename => ({
version: filename.replace(/\.sql$/, ''),
- sql: await readFile(join(MIGRATIONS_DIR, filename), 'utf8'),
+ sql: await readFile(join(migrationsDirectory, filename), 'utf8'),
}))
);
}
-export async function runMigrations() {
- const client = await pool.connect();
+export async function runMigrationsWithPool(migrationPool, migrationsDirectory) {
+ const client = await migrationPool.connect();
try {
// Session-level advisory lock: held across individual migration transactions,
// unlike pg_advisory_xact_lock which releases at each COMMIT and would let
@@ -38,7 +38,7 @@ export async function runMigrations() {
const { rows } = await client.query('SELECT version FROM schema_migrations ORDER BY version');
const applied = new Set(rows.map(r => r.version));
- const migrations = await getMigrationFiles();
+ const migrations = await getMigrationFiles(migrationsDirectory);
let ran = 0;
for (const { version, sql } of migrations) {
@@ -94,3 +94,7 @@ export async function runMigrations() {
client.release();
}
}
+
+export async function runMigrations() {
+ return runMigrationsWithPool(pool, MIGRATIONS_DIR);
+}
diff --git a/backend/src/services/postgresTestHelpers.js b/backend/src/services/postgresTestHelpers.js
new file mode 100644
index 0000000..d87a2c2
--- /dev/null
+++ b/backend/src/services/postgresTestHelpers.js
@@ -0,0 +1,162 @@
+import { setTimeout as delay } from 'node:timers/promises';
+import { readdir, readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+export function requireTestDatabaseUrl(description) {
+ const databaseUrl = process.env.TEST_DATABASE_URL;
+ if (!databaseUrl) {
+ throw new Error(`TEST_DATABASE_URL is required for ${description}`);
+ }
+ return databaseUrl;
+}
+
+export function quoteIdentifier(identifier) {
+ return `"${identifier.replaceAll('"', '""')}"`;
+}
+
+export function connectionStringFor(databaseUrl, databaseName) {
+ const url = new URL(databaseUrl);
+ url.pathname = `/${databaseName}`;
+ return url.toString();
+}
+
+export function postgresTestContext(description) {
+ const databaseUrl = requireTestDatabaseUrl(description);
+ return {
+ databaseUrl,
+ connectionStringFor: databaseName => connectionStringFor(databaseUrl, databaseName),
+ };
+}
+
+export async function createTestDatabase(adminClient, databaseName) {
+ await adminClient.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`);
+}
+
+export async function dropTestDatabase(adminClient, databaseName) {
+ await adminClient.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(databaseName)} WITH (FORCE)`);
+}
+
+export async function assertMinimumPostgresVersion(client, minimumVersion = 160000) {
+ const { rows: [server] } = await client.query(
+ "SELECT current_setting('server_version_num')::int AS version_num",
+ );
+ if (server.version_num < minimumVersion) {
+ throw new Error(
+ `PostgreSQL ${minimumVersion} or newer is required; found ${server.version_num}`,
+ );
+ }
+ return server.version_num;
+}
+
+export function productionDatabaseEnvironment(encryptionKey) {
+ const keys = [
+ 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'ENCRYPTION_KEY',
+ ];
+ const snapshot = Object.fromEntries(keys.map(key => [key, process.env[key]]));
+
+ let configuredUrl;
+ return {
+ configure(connectionString) {
+ const url = new URL(connectionString);
+ configuredUrl = url;
+ process.env.DB_HOST = url.hostname;
+ process.env.DB_PORT = url.port || '5432';
+ process.env.DB_NAME = decodeURIComponent(url.pathname.slice(1));
+ process.env.DB_USER = decodeURIComponent(url.username);
+ process.env.DB_PASSWORD = decodeURIComponent(url.password);
+ process.env.ENCRYPTION_KEY = encryptionKey;
+ },
+ configurePool(pool) {
+ if (!configuredUrl) throw new Error('Production database environment is not configured');
+ pool.options.host = configuredUrl.hostname;
+ pool.options.port = Number(configuredUrl.port || 5432);
+ pool.options.database = decodeURIComponent(configuredUrl.pathname.slice(1));
+ pool.options.user = decodeURIComponent(configuredUrl.username);
+ pool.options.password = decodeURIComponent(configuredUrl.password);
+ },
+ restore() {
+ for (const [key, value] of Object.entries(snapshot)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ },
+ };
+}
+
+export async function applyTestMigrations(client, {
+ migrationsDirectory,
+ first = '0001',
+ through = '9999',
+ transactionPerMigration = false,
+} = {}) {
+ const filenames = (await readdir(migrationsDirectory))
+ .filter(filename => /^\d{4}_.+\.sql$/.test(filename))
+ .filter(filename => filename.slice(0, 4) >= first)
+ .filter(filename => filename.slice(0, 4) <= through)
+ .sort();
+
+ for (const filename of filenames) {
+ const sql = await readFile(join(migrationsDirectory, filename), 'utf8');
+ if (/^--\s*no-transaction\b/im.test(sql)) {
+ const statements = sql
+ .replace(/--[^\n]*/g, '')
+ .split(';')
+ .map(statement => statement.trim())
+ .filter(Boolean);
+ for (const statement of statements) await client.query(statement);
+ continue;
+ }
+
+ if (!transactionPerMigration) {
+ await client.query(sql);
+ continue;
+ }
+
+ await client.query('BEGIN');
+ try {
+ await client.query(sql);
+ await client.query('COMMIT');
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ }
+ }
+}
+
+export async function waitForPostgresState({ description, probe, timeoutMs = 10_000 }) {
+ const deadline = performance.now() + timeoutMs;
+ const probeTimedOut = Symbol('probe timed out');
+ let lastState = null;
+
+ while (performance.now() < deadline) {
+ const remainingMs = deadline - performance.now();
+ let timeoutId;
+ let result;
+ try {
+ result = await Promise.race([
+ Promise.resolve().then(probe),
+ new Promise(resolve => {
+ timeoutId = setTimeout(resolve, remainingMs, probeTimedOut);
+ }),
+ ]);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ if (result === probeTimedOut || performance.now() >= deadline) break;
+
+ const { done, state } = result;
+ lastState = state;
+ if (done) return state;
+
+ const delayMs = Math.min(25, deadline - performance.now());
+ if (delayMs > 0) {
+ await delay(delayMs);
+ }
+ }
+
+ throw new Error(
+ `Timed out waiting for ${description} within ${timeoutMs} ms; `
+ + `last observed state: ${JSON.stringify(lastState)}`,
+ );
+}
diff --git a/backend/src/services/postgresTestHelpers.test.js b/backend/src/services/postgresTestHelpers.test.js
new file mode 100644
index 0000000..431c137
--- /dev/null
+++ b/backend/src/services/postgresTestHelpers.test.js
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest';
+import { waitForPostgresState } from './postgresTestHelpers.js';
+
+describe('waitForPostgresState', () => {
+ it('reports the bounded wait and final safe state when the probe never succeeds', async () => {
+ await expect(waitForPostgresState({
+ description: 'mapping transaction to block',
+ timeoutMs: 50,
+ probe: async () => ({ done: false, state: { wait_event_type: null } }),
+ })).rejects.toThrow(/mapping transaction to block.*50 ms.*wait_event_type/);
+ });
+
+ it('rejects within the bound when a probe never resolves', async () => {
+ // Generous wall bound: the 30 ms internal deadline must win the race, but a
+ // heavily loaded host can stall the event loop for a while — keep the margin
+ // wide enough that a stall cannot let the wall timer fire first and flip the
+ // asserted rejection message. This only guards against a genuine hang.
+ const wallBoundMs = 2000;
+ let wallTimer;
+ const startedAt = performance.now();
+
+ try {
+ await expect(Promise.race([
+ waitForPostgresState({
+ description: 'stalled PostgreSQL probe',
+ timeoutMs: 30,
+ probe: () => new Promise(() => {}),
+ }),
+ new Promise((_, reject) => {
+ wallTimer = setTimeout(
+ () => reject(new Error(`Exceeded ${wallBoundMs} ms wall bound`)),
+ wallBoundMs,
+ );
+ }),
+ ])).rejects.toThrow(
+ 'Timed out waiting for stalled PostgreSQL probe within 30 ms; last observed state: null',
+ );
+ expect(performance.now() - startedAt).toBeLessThan(wallBoundMs);
+ } finally {
+ clearTimeout(wallTimer);
+ }
+ });
+
+ it('rejects a successful probe result that completes after the deadline', async () => {
+ let probeTimer;
+ let markProbeSettled;
+ const probeSettled = new Promise(resolve => { markProbeSettled = resolve; });
+ const probeResult = new Promise(resolve => {
+ probeTimer = setTimeout(() => {
+ resolve({ done: true, state: { status: 'ready' } });
+ markProbeSettled();
+ }, 75);
+ });
+
+ try {
+ await expect(waitForPostgresState({
+ description: 'late successful PostgreSQL probe',
+ timeoutMs: 20,
+ probe: () => probeResult,
+ })).rejects.toThrow(
+ 'Timed out waiting for late successful PostgreSQL probe within 20 ms; '
+ + 'last observed state: null',
+ );
+ await probeSettled;
+ } finally {
+ clearTimeout(probeTimer);
+ }
+ });
+});
diff --git a/backend/src/services/safeFetch.js b/backend/src/services/safeFetch.js
index dd0f097..03a0f79 100644
--- a/backend/src/services/safeFetch.js
+++ b/backend/src/services/safeFetch.js
@@ -34,6 +34,12 @@ function insecure() {
{ code: 'ERR_INSECURE_TRANSPORT' },
);
}
+function redirectError(message, code) {
+ return Object.assign(new Error(message), { code });
+}
+
+const MAX_CREDENTIALED_REDIRECTS = 5;
+const FOLLOWED_REDIRECT_STATUSES = new Set([301, 302, 307, 308]);
// A connector that refuses plaintext (when required) and private/reserved addresses,
// pinning the socket to a validated IP (with the original hostname kept for TLS SNI).
@@ -64,15 +70,103 @@ function agentFor(allowPrivate, requireHttps) {
return agents.get(key);
}
-export function safeFetch(url, options = {}, { allowPrivate = false, requireHttps = !allowPrivate } = {}) {
+export function safeFetch(url, options = {}, {
+ allowPrivate = false,
+ requireHttps = !allowPrivate,
+ credentialOrigin,
+ validateRedirect,
+} = {}) {
let parsed;
try { parsed = new URL(url); }
catch { return Promise.reject(new Error('Invalid URL')); }
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
- return Promise.reject(Object.assign(new Error('Only http(s) URLs are allowed'), { code: 'ERR_UNSUPPORTED_SCHEME' }));
+ const validationError = validateUrl(parsed, requireHttps);
+ if (validationError) return Promise.reject(validationError);
+
+ if (credentialOrigin == null) {
+ return fetch(url, { ...options, dispatcher: agentFor(allowPrivate, requireHttps) });
+ }
+
+ let trustedOrigin;
+ try { trustedOrigin = new URL(credentialOrigin).origin; }
+ catch { return Promise.reject(new Error('Invalid credential origin')); }
+ if (parsed.origin !== trustedOrigin) {
+ return Promise.reject(redirectError(
+ 'Credentials cannot be sent to a different origin',
+ 'ERR_CROSS_ORIGIN_REDIRECT',
+ ));
+ }
+
+ return fetchWithCredentialFence(parsed, options, {
+ allowPrivate,
+ requireHttps,
+ trustedOrigin,
+ validateRedirect,
+ });
+}
+
+function validateUrl(url, requireHttps) {
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ return Object.assign(new Error('Only http(s) URLs are allowed'), { code: 'ERR_UNSUPPORTED_SCHEME' });
+ }
+ if (requireHttps && url.protocol !== 'https:') return insecure();
+ return null;
+}
+
+async function fetchWithCredentialFence(initialUrl, options, {
+ allowPrivate,
+ requireHttps,
+ trustedOrigin,
+ validateRedirect,
+}) {
+ let currentUrl = initialUrl;
+ let redirectCount = 0;
+
+ while (true) {
+ const response = await fetch(currentUrl, {
+ ...options,
+ redirect: 'manual',
+ dispatcher: agentFor(allowPrivate, requireHttps),
+ });
+ const location = response.headers.get('location');
+ if (!location || (!FOLLOWED_REDIRECT_STATUSES.has(response.status) && response.status !== 303)) {
+ return response;
+ }
+
+ await cancelResponseBody(response);
+ if (response.status === 303) {
+ throw redirectError(
+ '303 redirects are not supported for credentialed requests',
+ 'ERR_UNSUPPORTED_REDIRECT',
+ );
+ }
+
+ let nextUrl;
+ try { nextUrl = new URL(location, currentUrl); }
+ catch {
+ throw redirectError('Redirect location is not a valid URL', 'ERR_INVALID_REDIRECT');
+ }
+ const validationError = validateUrl(nextUrl, requireHttps);
+ if (validationError) throw validationError;
+ if (nextUrl.origin !== trustedOrigin) {
+ throw redirectError(
+ 'Credentialed redirects must stay on the configured origin',
+ 'ERR_CROSS_ORIGIN_REDIRECT',
+ );
+ }
+ if (redirectCount >= MAX_CREDENTIALED_REDIRECTS) {
+ throw redirectError('Too many credentialed redirects', 'ERR_TOO_MANY_REDIRECTS');
+ }
+ await validateRedirect?.(nextUrl.href);
+
+ redirectCount += 1;
+ currentUrl = nextUrl;
}
- if (requireHttps && parsed.protocol !== 'https:') {
- return Promise.reject(insecure());
+}
+
+async function cancelResponseBody(response) {
+ try {
+ await response.body?.cancel();
+ } catch {
+ // The response is being discarded regardless; cancellation is best-effort.
}
- return fetch(url, { ...options, dispatcher: agentFor(allowPrivate, requireHttps) });
}
diff --git a/backend/src/services/safeFetch.test.js b/backend/src/services/safeFetch.test.js
index b184c6e..d485729 100644
--- a/backend/src/services/safeFetch.test.js
+++ b/backend/src/services/safeFetch.test.js
@@ -1,18 +1,74 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http from 'node:http';
import { safeFetch } from './safeFetch.js';
-let server, port;
+let server, port, redirectServer, redirectPort;
+let serverBAuthorizations;
+let serverBRequests;
+let sameOriginRequests;
+let chainRequests;
beforeAll(async () => {
+ serverBAuthorizations = [];
+ serverBRequests = 0;
+ sameOriginRequests = [];
+ chainRequests = [];
+ redirectServer = http.createServer((req, res) => {
+ serverBRequests += 1;
+ serverBAuthorizations.push(req.headers.authorization);
+ res.writeHead(200);
+ res.end('cross-origin target');
+ });
+ await new Promise(r => redirectServer.listen(0, '127.0.0.1', r));
+ redirectPort = redirectServer.address().port;
+
server = http.createServer((req, res) => {
if (req.url === '/redir') { res.writeHead(302, { Location: '/ok2' }); return res.end(); }
+ if (req.url === '/cross') {
+ res.writeHead(302, { Location: `http://127.0.0.1:${redirectPort}/target` });
+ return res.end();
+ }
+ if (req.url?.startsWith('/same-origin/')) {
+ const [, , statusOrTarget] = req.url.split('/');
+ if (statusOrTarget !== 'target') {
+ res.writeHead(Number(statusOrTarget), { Location: '/same-origin/target' });
+ return res.end();
+ }
+ let body = '';
+ req.setEncoding('utf8');
+ req.on('data', chunk => { body += chunk; });
+ req.on('end', () => {
+ sameOriginRequests.push({
+ authorization: req.headers.authorization,
+ body,
+ method: req.method,
+ });
+ res.writeHead(200);
+ res.end('same-origin target');
+ });
+ return;
+ }
+ if (req.url?.startsWith('/chain/')) {
+ chainRequests.push(req.url);
+ const step = Number(req.url.slice('/chain/'.length));
+ if (step < 6) {
+ res.writeHead(302, { Location: `/chain/${step + 1}` });
+ return res.end();
+ }
+ res.writeHead(200);
+ return res.end('redirect target');
+ }
if (req.url === '/ok' || req.url === '/ok2') { res.writeHead(200); return res.end('hi'); }
res.writeHead(404); res.end();
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
port = server.address().port;
});
-afterAll(() => server && server.close());
+afterAll(async () => {
+ await Promise.all([
+ new Promise(resolve => server?.close(resolve)),
+ new Promise(resolve => redirectServer?.close(resolve)),
+ ]);
+});
describe('safeFetch — SSRF guard', () => {
it('connects to a private IP when allowPrivate=true', async () => {
@@ -39,12 +95,167 @@ describe('safeFetch — SSRF guard', () => {
)).toBe('ERR_BLOCKED_PRIVATE_IP');
});
+ it('blocks hexadecimal IPv4-mapped loopback before connecting', async () => {
+ let requestCount = 0;
+ const loopbackServer = http.createServer((_req, res) => {
+ requestCount += 1;
+ res.end('loopback reached');
+ });
+ await new Promise((resolve, reject) => {
+ loopbackServer.once('error', reject);
+ loopbackServer.listen(0, '127.0.0.1', resolve);
+ });
+ const loopbackPort = loopbackServer.address().port;
+
+ try {
+ expect(await causeCode(
+ safeFetch(`http://[::ffff:7f00:1]:${loopbackPort}`, {}, {
+ allowPrivate: false,
+ requireHttps: false,
+ })
+ )).toBe('ERR_BLOCKED_PRIVATE_IP');
+ expect(requestCount).toBe(0);
+ } finally {
+ await new Promise(resolve => loopbackServer.close(resolve));
+ }
+ });
+
it('follows redirects, validating each hop', async () => {
const r = await safeFetch(`http://127.0.0.1:${port}/redir`, {}, { allowPrivate: true });
expect(r.status).toBe(200);
});
});
+describe('safeFetch — credential origin policy', () => {
+ const credentialedRequest = {
+ method: 'PROPFIND',
+ headers: { Authorization: 'Basic secret' },
+ body: ' ',
+ redirect: 'follow',
+ };
+
+ it('rejects a cross-origin redirect before sending the next request', async () => {
+ const origin = `http://127.0.0.1:${port}`;
+
+ await expect(safeFetch(`${origin}/cross`, credentialedRequest, {
+ allowPrivate: true,
+ credentialOrigin: origin,
+ })).rejects.toMatchObject({ code: 'ERR_CROSS_ORIGIN_REDIRECT' });
+
+ expect(serverBRequests).toBe(0);
+ expect(serverBAuthorizations).toEqual([]);
+ });
+
+ it('rejects an initial URL outside the credential origin before sending a request', async () => {
+ const credentialOrigin = `http://127.0.0.1:${port}`;
+ const requestCount = serverBRequests;
+
+ await expect(safeFetch(`http://127.0.0.1:${redirectPort}/target`, credentialedRequest, {
+ allowPrivate: true,
+ credentialOrigin,
+ })).rejects.toMatchObject({ code: 'ERR_CROSS_ORIGIN_REDIRECT' });
+
+ expect(serverBRequests).toBe(requestCount);
+ });
+
+ it.each([301, 302, 307, 308])(
+ 'preserves DAV method, body, and authorization across a same-origin %i redirect',
+ async status => {
+ const origin = `http://127.0.0.1:${port}`;
+ const response = await safeFetch(`${origin}/same-origin/${status}`, credentialedRequest, {
+ allowPrivate: true,
+ credentialOrigin: origin,
+ });
+
+ expect(await response.text()).toBe('same-origin target');
+ expect(sameOriginRequests.at(-1)).toEqual({
+ authorization: 'Basic secret',
+ body: ' ',
+ method: 'PROPFIND',
+ });
+ },
+ );
+
+ it('validates a same-origin redirect before issuing the next request', async () => {
+ const origin = `http://127.0.0.1:${port}`;
+ const requestCount = sameOriginRequests.length;
+ const redirectError = Object.assign(new Error('redirect escaped its resource scope'), {
+ code: 'ERR_DAV_HREF_SCOPE',
+ });
+ const validateRedirect = vi.fn(() => { throw redirectError; });
+
+ await expect(safeFetch(`${origin}/same-origin/307`, credentialedRequest, {
+ allowPrivate: true,
+ credentialOrigin: origin,
+ validateRedirect,
+ })).rejects.toBe(redirectError);
+
+ expect(validateRedirect).toHaveBeenCalledWith(`${origin}/same-origin/target`);
+ expect(sameOriginRequests).toHaveLength(requestCount);
+ });
+
+ it('cancels a redirect body before issuing the next same-origin request', async () => {
+ const originalFetch = globalThis.fetch;
+ const cancel = vi.fn().mockResolvedValue(undefined);
+ const redirected = {
+ body: { cancel },
+ headers: new Headers({ Location: '/final' }),
+ status: 302,
+ };
+ const finalResponse = {
+ body: null,
+ headers: new Headers(),
+ status: 207,
+ };
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(redirected)
+ .mockImplementationOnce(async () => {
+ expect(cancel).toHaveBeenCalledOnce();
+ return finalResponse;
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ await expect(safeFetch('https://example.com/start', credentialedRequest, {
+ credentialOrigin: 'https://example.com',
+ })).resolves.toBe(finalResponse);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.stubGlobal('fetch', originalFetch);
+ }
+ });
+
+ it('rejects a 303 instead of changing an authenticated DAV request to GET', async () => {
+ const origin = `http://127.0.0.1:${port}`;
+ const requestCount = sameOriginRequests.length;
+
+ await expect(safeFetch(`${origin}/same-origin/303`, credentialedRequest, {
+ allowPrivate: true,
+ credentialOrigin: origin,
+ })).rejects.toMatchObject({ code: 'ERR_UNSUPPORTED_REDIRECT' });
+
+ expect(sameOriginRequests).toHaveLength(requestCount);
+ });
+
+ it('rejects the sixth redirect without issuing a seventh request', async () => {
+ const origin = `http://127.0.0.1:${port}`;
+
+ await expect(safeFetch(`${origin}/chain/0`, credentialedRequest, {
+ allowPrivate: true,
+ credentialOrigin: origin,
+ })).rejects.toMatchObject({ code: 'ERR_TOO_MANY_REDIRECTS' });
+
+ expect(chainRequests).toEqual([
+ '/chain/0',
+ '/chain/1',
+ '/chain/2',
+ '/chain/3',
+ '/chain/4',
+ '/chain/5',
+ ]);
+ });
+});
+
describe('safeFetch — scheme policy', () => {
it('rejects non-http(s) schemes', async () => {
await expect(safeFetch('ftp://example.com/x')).rejects.toThrow(/http\(s\)/i);
diff --git a/backend/src/utils/vcard.js b/backend/src/utils/vcard.js
index 4b8bf41..7686432 100644
--- a/backend/src/utils/vcard.js
+++ b/backend/src/utils/vcard.js
@@ -1,216 +1,50 @@
-// vCard 3.0 parser and generator (RFC 2426).
-// Used by the contacts REST API and the CardDAV server.
-
-// Sanitize a vCard parameter value (e.g. TYPE=...).
-// Strips CR, LF, and other characters that are structural in vCard lines.
-function escapeParam(str) {
- if (!str) return '';
- return str.replace(/[\r\n;:,]/g, '');
-}
-
-// Escape special characters in a vCard property value.
-function escapeValue(str) {
- if (!str) return '';
- return str
- .replace(/\\/g, '\\\\')
- .replace(/;/g, '\\;')
- .replace(/,/g, '\\,')
- .replace(/\n/g, '\\n')
- .replace(/\r/g, '');
-}
-
-// Unescape a vCard property value.
-function unescapeValue(str) {
- if (!str) return '';
- return str
- .replace(/\\n/gi, '\n')
- .replace(/\\,/g, ',')
- .replace(/\\;/g, ';')
- .replace(/\\\\/g, '\\');
-}
-
-// Fold a vCard line at 75 octets per RFC 6350 §3.2.
-function foldLine(line) {
- const bytes = Buffer.from(line, 'utf8');
- if (bytes.length <= 75) return line + '\r\n';
- const parts = [];
- let offset = 0;
- let first = true;
- while (offset < bytes.length) {
- const max = first ? 75 : 74; // continuation lines have a leading space
- // Walk back to a character boundary
- let end = offset + max;
- if (end >= bytes.length) {
- end = bytes.length;
- } else {
- // back off until we're at a UTF-8 character boundary
- while (end > offset && (bytes[end] & 0xC0) === 0x80) end--;
- }
- const chunk = bytes.slice(offset, end).toString('utf8');
- parts.push((first ? '' : ' ') + chunk);
- offset = end;
- first = false;
- }
- return parts.join('\r\n') + '\r\n';
-}
-
-// Unfold a raw vCard string — join lines that start with whitespace.
-function unfold(raw) {
- return raw.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, '');
-}
+import {
+ contactFromVCardDocument,
+ overlayContactOnVCard,
+ parseVCardDocument,
+ serializeVCardDocument,
+} from './vcardProperties.js';
/**
- * Parse a vCard 3.0 string and return a plain object with the fields
- * MailFlow cares about. Unknown properties are silently ignored.
- *
- * Returns: { uid, displayName, firstName, lastName, emails, phones, organization, notes, photoData }
+ * Parse a vCard into the compatibility contact shape used outside CardDAV sync.
*/
export function parseVCard(raw) {
- const text = unfold(raw || '');
- const result = {
- uid: null,
- displayName: null,
- firstName: null,
- lastName: null,
- emails: [],
- phones: [],
- organization: null,
- notes: null,
- photoData: null,
- };
-
- for (const line of text.split(/\r?\n/)) {
- const trimmed = line.trim();
- if (!trimmed || trimmed === 'BEGIN:VCARD' || trimmed === 'END:VCARD') continue;
-
- const colonIdx = trimmed.indexOf(':');
- if (colonIdx < 0) continue;
-
- const rawName = trimmed.slice(0, colonIdx).toUpperCase();
- const value = trimmed.slice(colonIdx + 1);
-
- // Strip parameters (e.g. "EMAIL;TYPE=WORK:..." → name = "EMAIL"), then drop an
- // optional group prefix (e.g. "ITEM1.EMAIL" → "EMAIL") used by Apple/Nextcloud.
- let name = rawName.split(';')[0];
- if (name.includes('.')) name = name.slice(name.lastIndexOf('.') + 1);
- const params = rawName.includes(';') ? rawName.slice(rawName.indexOf(';') + 1) : '';
-
- switch (name) {
- case 'VERSION': break; // ignore
- case 'UID':
- result.uid = unescapeValue(value).trim();
- break;
- case 'FN':
- result.displayName = unescapeValue(value).trim() || null;
- break;
- case 'N': {
- // N:Last;First;Additional;Prefix;Suffix
- const parts = value.split(';').map(p => unescapeValue(p).trim());
- result.lastName = parts[0] || null;
- result.firstName = parts[1] || null;
- break;
- }
- case 'EMAIL': {
- const emailVal = unescapeValue(value).trim().toLowerCase();
- if (emailVal) {
- const typeMatch = params.match(/TYPE=([^;]+)/i);
- const type = typeMatch ? typeMatch[1].toLowerCase().replace(/["']/g, '') : 'other';
- const isPrimary = result.emails.length === 0;
- result.emails.push({ value: emailVal, type, primary: isPrimary });
- }
- break;
- }
- case 'TEL': {
- const phoneVal = unescapeValue(value).trim();
- if (phoneVal) {
- const typeMatch = params.match(/TYPE=([^;]+)/i);
- const type = typeMatch ? typeMatch[1].toLowerCase().replace(/["']/g, '') : 'other';
- result.phones.push({ value: phoneVal, type });
- }
- break;
- }
- case 'ORG':
- result.organization = unescapeValue(value.split(';')[0]).trim() || null;
- break;
- case 'NOTE':
- result.notes = unescapeValue(value).trim() || null;
- break;
- case 'PHOTO': {
- const v = value.trim();
- if (!v) break;
- if (v.startsWith('data:')) {
- // vCard 4.0 inline data URI — store as-is.
- result.photoData = v;
- } else if (/^https?:\/\//i.test(v)) {
- // External URL — skip to avoid privacy leak / fetch complexity.
- result.photoData = null;
- } else {
- // vCard 3.0 ENCODING=b raw base64 — derive MIME from TYPE param.
- const typeMatch = params.match(/TYPE=([^;]+)/i);
- const rawType = typeMatch ? typeMatch[1].replace(/["']/g, '').toUpperCase() : 'JPEG';
- const mimeMap = { JPEG: 'image/jpeg', JPG: 'image/jpeg', PNG: 'image/png', GIF: 'image/gif', WEBP: 'image/webp' };
- const mimeType = mimeMap[rawType] || 'image/jpeg';
- result.photoData = `data:${mimeType};base64,${v}`;
- }
- break;
- }
- }
+ const text = raw == null ? '' : String(raw);
+ const document = parseVCardDocument(text, { defaultVersion: '3.0' });
+ const contact = contactFromVCardDocument(document);
+ if (contact.emails.length && !contact.emails.some(email => email.primary)) {
+ contact.emails[0].primary = true;
}
-
- return result;
+ return contact;
}
/**
- * Generate a vCard 3.0 string from a contact object.
- *
- * contact: { uid, displayName, firstName, lastName, emails, phones, organization, notes }
+ * Generate a deterministic vCard 3.0 for general-purpose contact consumers.
*/
export function generateVCard(contact) {
- const {
- uid,
- displayName,
- firstName,
- lastName,
- emails = [],
- phones = [],
- organization,
- notes,
- } = contact;
-
- const lines = [];
- lines.push('BEGIN:VCARD');
- lines.push('VERSION:3.0');
- lines.push(`UID:${escapeValue(uid || '')}`);
-
- const fn = displayName
- || (firstName || lastName ? [firstName, lastName].filter(Boolean).join(' ') : null)
- || (emails[0]?.value ?? '');
- lines.push(`FN:${escapeValue(fn)}`);
-
- if (firstName || lastName) {
- lines.push(`N:${escapeValue(lastName || '')};${escapeValue(firstName || '')};;;`);
- }
-
- for (const e of emails) {
- const type = escapeParam((e.type || 'other').toUpperCase());
- lines.push(`EMAIL;TYPE=${type}:${escapeValue(e.value || '')}`);
- }
-
- for (const p of phones) {
- const type = escapeParam((p.type || 'voice').toUpperCase());
- lines.push(`TEL;TYPE=${type}:${escapeValue(p.value || '')}`);
- }
-
- if (organization) {
- lines.push(`ORG:${escapeValue(organization)}`);
- }
-
- if (notes) {
- lines.push(`NOTE:${escapeValue(notes)}`);
- }
-
- lines.push('END:VCARD');
-
- // Fold and join
- return lines.map(foldLine).join('');
+ const firstName = contact.firstName ?? contact.first_name;
+ const lastName = contact.lastName ?? contact.last_name;
+ const email = contact.emails?.[0]?.value ?? contact.emails?.[0]?.email ?? '';
+ const displayName = contact.displayName ?? contact.display_name;
+ const emails = (contact.emails || []).map(emailEntry => {
+ const entry = { ...emailEntry };
+ delete entry.primary;
+ delete entry.isPrimary;
+ delete entry.is_primary;
+ return entry;
+ });
+ const compatibilityContact = {
+ ...contact,
+ emails,
+ displayName: displayName
+ || ([firstName, lastName].filter(Boolean).join(' ') || email),
+ };
+ const document = overlayContactOnVCard({
+ version: '3.0',
+ properties: [
+ { group: null, name: 'UID', params: [], rawValue: '' },
+ { group: null, name: 'FN', params: [], rawValue: '' },
+ ],
+ }, compatibilityContact);
+ return serializeVCardDocument(document);
}
diff --git a/backend/src/utils/vcard.test.js b/backend/src/utils/vcard.test.js
new file mode 100644
index 0000000..db26a66
--- /dev/null
+++ b/backend/src/utils/vcard.test.js
@@ -0,0 +1,241 @@
+import { createHash } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import { generateVCard, parseVCard } from './vcard.js';
+import { contactFromVCardDocument, parseVCardDocument } from './vcardProperties.js';
+
+const contact = (photoData) => ({
+ uid: 'photo-test',
+ displayName: 'Photo Test',
+ emails: [{ value: 'photo@example.test', type: 'other' }],
+ phones: [],
+ photoData,
+});
+
+describe('vCard photo serialization', () => {
+ it.each([
+ ['image/jpeg', 'JPEG'],
+ ['image/png', 'PNG'],
+ ['image/gif', 'GIF'],
+ ['image/webp', 'WEBP'],
+ ])('round-trips folded %s data URIs', (mime, type) => {
+ const payload = 'A'.repeat(180);
+ const photoData = `data:${mime};base64,${payload}`;
+ const vcard = generateVCard(contact(photoData));
+
+ expect(vcard).toContain(`PHOTO;ENCODING=b;TYPE=${type}:`);
+ expect(vcard).toMatch(/PHOTO;ENCODING=b;TYPE=.*\r\n /);
+ expect(parseVCard(vcard).photoData).toBe(photoData);
+ });
+
+ it('makes photo-only changes byte-visible and keeps external URLs out', () => {
+ const first = generateVCard(contact('data:image/png;base64,AAA'));
+ const second = generateVCard(contact('data:image/png;base64,BBA'));
+ const external = generateVCard(contact('https://images.example.test/photo.png'));
+ const etag = value => createHash('md5').update(value).digest('hex');
+
+ expect(first).not.toBe(second);
+ expect(etag(first)).not.toBe(etag(second));
+ expect(generateVCard(contact('data:image/png;base64,AAA'))).toBe(first);
+ expect(external).not.toMatch(/^PHOTO/m);
+ expect(parseVCard(external).photoData).toBeNull();
+ });
+});
+
+describe('vCard type parsing', () => {
+ it('strips double quotes from generated email types', () => {
+ expect(generateVCard({
+ uid: 'quoted-email-type',
+ displayName: 'Quoted Email Type',
+ emails: [{ value: 'quoted@example.test', type: 'wo"rk' }],
+ phones: [],
+ })).toContain('EMAIL;TYPE=WORK:quoted@example.test\r\n');
+ });
+
+ it('maps compound TYPE parameters to MailFlow contact labels', () => {
+ const parsed = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nUID:types\r\nFN:Type Test\r\nEMAIL;TYPE=INTERNET,WORK:work@example.test\r\nEMAIL;TYPE="HOME,INTERNET":home@example.test\r\nTEL;TYPE=CELL,VOICE,PREF:+15550000001\r\nTEL;TYPE=WORK,VOICE:+15550000002\r\nTEL;TYPE=VOICE,PREF:+15550000003\r\nEND:VCARD\r\n`);
+
+ expect(parsed.emails.map(({ type }) => type)).toEqual(['work', 'home']);
+ expect(parsed.phones.map(({ type }) => type)).toEqual(['mobile', 'work', 'other']);
+ });
+
+ it('keeps accepting single-quoted compound TYPE values', () => {
+ const parsed = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nUID:quoted\r\nFN:Quoted\r\nEMAIL;TYPE='HOME,INTERNET':home@example.test\r\nEND:VCARD\r\n`);
+
+ expect(parsed.emails[0].type).toBe('home');
+ });
+
+ it.each(['3.0', '4.0'])('keeps first-email fallback for unmarked vCard %s emails', version => {
+ const parsed = parseVCard([
+ 'BEGIN:VCARD',
+ `VERSION:${version}`,
+ 'EMAIL:first@example.test',
+ 'EMAIL:second@example.test',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'));
+
+ expect(parsed.emails.map(email => email.primary)).toEqual([true, false]);
+ });
+});
+
+describe('vCard compatibility facade', () => {
+ it.each([
+ 'status:open',
+ 'sip:alice@example.test',
+ 'webcal:event-id',
+ ])('matches strict parsing for colon-bearing NOTE value %s', value => {
+ const raw = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `NOTE;X-P=trailing\\:${value}`,
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ const strict = contactFromVCardDocument(parseVCardDocument(raw));
+ const compatible = parseVCard(raw);
+
+ expect(strict.notes).toBe(value);
+ expect(compatible.notes).toBe(value);
+ });
+
+ it('keeps defaulting versionless legacy consumer cards to vCard 3.0', () => {
+ expect(parseVCard([
+ 'BEGIN:VCARD',
+ 'UID:legacy-versionless',
+ 'FN:Legacy Versionless',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'))).toMatchObject({
+ uid: 'legacy-versionless',
+ displayName: 'Legacy Versionless',
+ });
+ });
+
+ it('preserves malformed version error surfaces while defaulting legacy cards', () => {
+ expect(() => parseVCard('BEGIN:VCARD\r\nVERSION\r\nEND:VCARD\r\n'))
+ .toThrow('vCard contains an invalid content line');
+ expect(() => parseVCard('BEGIN:VCARD\r\n'))
+ .toThrow('vCard must end with END:VCARD');
+ });
+
+ it.each([
+ ['empty', {}, []],
+ ['phone-only', {
+ phones: [{ value: '+15551234567', type: 'mobile' }],
+ }, ['TEL;TYPE=MOBILE:+15551234567']],
+ ])('keeps legacy UID and mandatory FN placeholders for %s contacts', (
+ _case,
+ contact,
+ extraLines,
+ ) => {
+ expect(generateVCard(contact)).toBe([
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:',
+ 'FN:',
+ ...extraLines,
+ 'END:VCARD',
+ '',
+ ].join('\r\n'));
+ });
+
+ it('keeps deterministic vCard 3.0 bytes for existing contact consumers', () => {
+ const vcard = generateVCard({
+ uid: 'deterministic',
+ displayName: 'Jane Doe',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ emails: [{ value: 'jane@example.test', type: 'work', primary: true }],
+ phones: [{ value: '+15551234567', type: 'mobile' }],
+ organization: 'Example Corp',
+ notes: 'Line one\nLine two',
+ });
+
+ expect(vcard).toBe([
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:deterministic',
+ 'FN:Jane Doe',
+ 'N:Doe;Jane;;;',
+ 'EMAIL;TYPE=WORK:jane@example.test',
+ 'TEL;TYPE=MOBILE:+15551234567',
+ 'ORG:Example Corp',
+ 'NOTE:Line one\\nLine two',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'));
+ expect(generateVCard(parseVCard(vcard))).toBe(vcard);
+ });
+
+ it('keeps deriving FN only for newly generated compatibility cards', () => {
+ const generated = generateVCard({
+ firstName: 'Jane',
+ lastName: 'Doe',
+ emails: [],
+ phones: [],
+ });
+ const generatedFromBlank = generateVCard({
+ displayName: '',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ emails: [],
+ phones: [],
+ });
+
+ expect(generated).toContain('FN:Jane Doe\r\n');
+ expect(generatedFromBlank).toContain('FN:Jane Doe\r\n');
+ });
+
+ it('keeps the last PHOTO value, including a trailing URL clear', () => {
+ const withTwoEmbedded = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ 'PHOTO;ENCODING=b;TYPE=PNG:BAUG',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ const withTrailingUrl = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ 'PHOTO;VALUE=URI:https://images.example.test/photo.png',
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+
+ expect(parseVCard(withTwoEmbedded).photoData).toBe('data:image/png;base64,BAUG');
+ expect(parseVCard(withTrailingUrl).photoData).toBeNull();
+ });
+
+ it('projects supported Additional fields without exposing raw syntax', () => {
+ const parsed = parseVCard([
+ 'BEGIN:VCARD',
+ 'VERSION:4.0',
+ 'UID:additional',
+ 'FN:Additional',
+ 'item1.URL:https://example.test',
+ 'item1.X-ABLabel:Portfolio',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'));
+
+ expect(parsed.additionalFields).toEqual([
+ expect.objectContaining({
+ kind: 'url',
+ label: 'Portfolio',
+ value: 'https://example.test',
+ }),
+ ]);
+ });
+
+ it('rejects unsupported non-base64 data URI photos', () => {
+ expect(() => generateVCard({
+ uid: 'data-uri',
+ displayName: 'Data URI',
+ emails: [],
+ phones: [],
+ photoData: 'data:image/svg+xml, ',
+ })).toThrow(/unsupported PHOTO MIME type/);
+ });
+});
diff --git a/backend/src/utils/vcardDocument.js b/backend/src/utils/vcardDocument.js
new file mode 100644
index 0000000..8668f25
--- /dev/null
+++ b/backend/src/utils/vcardDocument.js
@@ -0,0 +1,657 @@
+const MAX_VCARD_BYTES = 1024 * 1024;
+const MAX_PROPERTIES = 2000;
+const MAX_PARAMETERS = 64;
+const MAX_CONTENT_LINE_BYTES = 64 * 1024;
+const MAX_PHOTO_BYTES = 512 * 1024;
+const VCARD_TOKEN = /^[A-Za-z0-9-]+$/;
+export const VCARD_3_URI_DEFAULTS = new Set(['URL', 'IMPP']);
+export const VCARD_4_URI_DEFAULTS = new Set([
+ 'SOURCE',
+ 'PHOTO',
+ 'TEL',
+ 'IMPP',
+ 'GEO',
+ 'LOGO',
+ 'MEMBER',
+ 'RELATED',
+ 'SOUND',
+ 'UID',
+ 'URL',
+ 'KEY',
+ 'FBURL',
+ 'CALADRURI',
+ 'CALURI',
+]);
+const SUPPORTED_PHOTO_MIME_TYPES = new Map([
+ ['image/jpeg', { mime: 'image/jpeg', type: 'JPEG' }],
+ ['image/jpg', { mime: 'image/jpeg', type: 'JPEG' }],
+ ['image/png', { mime: 'image/png', type: 'PNG' }],
+ ['image/gif', { mime: 'image/gif', type: 'GIF' }],
+ ['image/webp', { mime: 'image/webp', type: 'WEBP' }],
+]);
+export const PHOTO_IMAGE_TYPE_TOKENS = new Set(
+ [...SUPPORTED_PHOTO_MIME_TYPES.keys()].map(mime => mime.slice('image/'.length).toUpperCase()),
+);
+export const ADR_COMPONENTS = [
+ 'poBox',
+ 'extendedAddress',
+ 'street',
+ 'locality',
+ 'region',
+ 'postalCode',
+ 'country',
+];
+
+export const SERVER_OWNED_PROPERTIES = new Set([
+ 'PRODID',
+ 'REV',
+ 'SOURCE',
+ 'CREATED',
+ 'LAST-MODIFIED',
+]);
+
+function byteLength(value) {
+ return Buffer.byteLength(value, 'utf8');
+}
+
+function percentDecodedByteLength(value) {
+ let bytes = 0;
+ let literalStart = 0;
+
+ for (let index = 0; index < value.length; index++) {
+ if (value[index] !== '%') continue;
+ bytes += byteLength(value.slice(literalStart, index));
+ if (!/^[0-9A-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) {
+ throw new Error('invalid percent encoding');
+ }
+ bytes++;
+ index += 2;
+ literalStart = index + 1;
+ }
+
+ return bytes + byteLength(value.slice(literalStart));
+}
+
+function decodePercentEncodedPhoto(value) {
+ const decodedLength = percentDecodedByteLength(value);
+ if (decodedLength > MAX_PHOTO_BYTES) {
+ throw new Error('vCard exceeds the 512 KiB photo limit');
+ }
+
+ const decoded = Buffer.allocUnsafe(decodedLength);
+ let offset = 0;
+ let literalStart = 0;
+ for (let index = 0; index < value.length; index++) {
+ if (value[index] !== '%') continue;
+ if (index > literalStart) {
+ offset += decoded.write(value.slice(literalStart, index), offset, 'utf8');
+ }
+ decoded[offset++] = Number.parseInt(value.slice(index + 1, index + 3), 16);
+ index += 2;
+ literalStart = index + 1;
+ }
+ if (literalStart < value.length) decoded.write(value.slice(literalStart), offset, 'utf8');
+ return decoded;
+}
+
+function splitPhysicalLines(text) {
+ const lines = text.split(/\r\n|\n|\r/);
+ for (const line of lines) {
+ if (byteLength(line) > MAX_CONTENT_LINE_BYTES) {
+ throw new Error('vCard exceeds the 64 KiB physical line limit');
+ }
+ }
+ return lines;
+}
+
+function unfoldLines(lines) {
+ const unfolded = [];
+ let current = null;
+
+ for (const line of lines) {
+ if (/^[ \t]/.test(line) && current !== null) {
+ current += line.slice(1);
+ continue;
+ }
+ if (current !== null) unfolded.push(current);
+ current = line;
+ }
+ if (current !== null) unfolded.push(current);
+
+ return unfolded;
+}
+
+function delimiterIndex(value, delimiter, backslashEscapes = true) {
+ let escaped = false;
+ let quoted = false;
+
+ for (let index = 0; index < value.length; index++) {
+ const character = value[index];
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+ if (backslashEscapes && character === '\\') {
+ escaped = true;
+ continue;
+ }
+ if (character === '"') {
+ quoted = !quoted;
+ continue;
+ }
+ if (character === delimiter && !quoted) return index;
+ }
+
+ return -1;
+}
+
+export function splitDelimited(value, delimiter, backslashEscapes = true) {
+ const parts = [];
+ let rest = value;
+
+ while (true) {
+ const index = delimiterIndex(rest, delimiter, backslashEscapes);
+ if (index < 0) {
+ parts.push(rest);
+ break;
+ }
+ parts.push(rest.slice(0, index));
+ rest = rest.slice(index + 1);
+ }
+
+ return parts;
+}
+
+function decodeParameterValue(value, version) {
+ if (version !== '4.0') return value;
+ let result = '';
+ for (let index = 0; index < value.length; index++) {
+ const character = value[index];
+ if (character !== '^' || index + 1 >= value.length) {
+ result += character;
+ continue;
+ }
+
+ const next = value[++index];
+ if (next === '^') result += '^';
+ else if (next === 'n' || next === 'N') result += '\n';
+ else if (next === "'") result += '"';
+ else result += '^' + next;
+ }
+ return result;
+}
+
+function quotedParameterValue(value, quote = '"') {
+ let start = 0;
+ let end = value.length;
+ while (start < end && /[ \t]/.test(value[start])) start++;
+ while (end > start && /[ \t]/.test(value[end - 1])) end--;
+ if (value[start] !== quote || value[end - 1] !== quote) return null;
+ return value.slice(start + 1, end - 1);
+}
+
+function parseParameterValues(name, encodedValue, version) {
+ if (name === 'TYPE' && version === '3.0') {
+ const legacy = quotedParameterValue(encodedValue, "'");
+ if (legacy !== null) return legacy.split(',');
+ }
+
+ const entries = splitDelimited(encodedValue, ',', false);
+ if (entries.length === 0) return [''];
+ if (entries.length === 1) {
+ const quoted = quotedParameterValue(entries[0]);
+ if (quoted !== null) {
+ const decoded = decodeParameterValue(quoted, version);
+ return name === 'TYPE' ? decoded.split(',') : [decoded];
+ }
+ }
+ return entries.map(entry => {
+ const quoted = quotedParameterValue(entry);
+ return decodeParameterValue(quoted === null ? entry : quoted, version);
+ });
+}
+
+function hasForbiddenParameterControl(value, version) {
+ return [...String(value)].some(character => {
+ const codePoint = character.codePointAt(0);
+ return codePoint === 127 || (
+ codePoint < 32
+ && character !== '\t'
+ && (version !== '4.0' || (character !== '\r' && character !== '\n'))
+ );
+ });
+}
+
+function hasInvalidParameterValue(value, version) {
+ return (version !== '4.0' && String(value).includes('"'))
+ || hasForbiddenParameterControl(value, version);
+}
+
+function parseParameters(parts, version) {
+ if (parts.length > MAX_PARAMETERS) {
+ throw new Error('vCard property exceeds the 64 parameters limit');
+ }
+
+ return parts.map(part => {
+ const equals = delimiterIndex(part, '=', false);
+ if (equals < 0) {
+ const values = [decodeParameterValue(part, version)];
+ if (values.some(value => hasInvalidParameterValue(value, version))) {
+ throw new Error('vCard parameter value contains an invalid character');
+ }
+ return { name: 'TYPE', values };
+ }
+
+ const name = part.slice(0, equals).toUpperCase();
+ if (!VCARD_TOKEN.test(name)) {
+ throw new Error('vCard contains an invalid parameter name');
+ }
+ const encodedValue = part.slice(equals + 1);
+ const values = parseParameterValues(name, encodedValue, version);
+ if (values.some(value => hasInvalidParameterValue(value, version))) {
+ throw new Error('vCard parameter value contains an invalid character');
+ }
+ return {
+ name,
+ values,
+ };
+ });
+}
+
+function parseContentLine(line, version) {
+ const colon = delimiterIndex(line, ':', false);
+ if (colon < 0) throw new Error('vCard contains an invalid content line');
+
+ const header = line.slice(0, colon);
+ const rawValue = line.slice(colon + 1);
+ const parts = splitDelimited(header, ';', false);
+ let name = parts.shift();
+ let group = null;
+ const dot = name.indexOf('.');
+ if (dot >= 0) {
+ group = name.slice(0, dot);
+ name = name.slice(dot + 1);
+ }
+ if (group !== null && !VCARD_TOKEN.test(group)) {
+ throw new Error('vCard contains an invalid property group');
+ }
+ name = name.toUpperCase();
+ if (!VCARD_TOKEN.test(name)) throw new Error('vCard contains an invalid property name');
+
+ return {
+ group,
+ name,
+ params: parseParameters(parts, version),
+ rawValue,
+ };
+}
+
+export function parameterValues(property, name) {
+ return property.params
+ .filter(parameter => parameter.name === name)
+ .flatMap(parameter => parameter.values);
+}
+
+export function decodeBase64Photo(value) {
+ let dataLength = 0;
+ let paddingLength = 0;
+ let paddingStarted = false;
+ for (const character of value) {
+ if (/\s/.test(character)) continue;
+ if (/^[A-Za-z0-9+/]$/.test(character) && !paddingStarted) {
+ dataLength++;
+ continue;
+ }
+ if (character === '=') {
+ paddingStarted = true;
+ paddingLength++;
+ continue;
+ }
+ throw new Error('vCard PHOTO has invalid base64 data');
+ }
+
+ const remainder = dataLength % 4;
+ const validPadding = paddingLength === 0 || (
+ (dataLength + paddingLength) % 4 === 0
+ && ((paddingLength === 1 && remainder === 3)
+ || (paddingLength === 2 && remainder === 2))
+ );
+ if (paddingLength > 2
+ || remainder === 1
+ || !validPadding) {
+ throw new Error('vCard PHOTO has invalid base64 data');
+ }
+ if (Math.floor(dataLength * 6 / 8) > MAX_PHOTO_BYTES) {
+ throw new Error('vCard exceeds the 512 KiB photo limit');
+ }
+
+ const compact = value.replace(/\s/g, '');
+ const withoutPadding = compact.slice(0, dataLength);
+ const decoded = Buffer.from(withoutPadding, 'base64');
+ if (decoded.toString('base64').replace(/=+$/, '') !== withoutPadding) {
+ throw new Error('vCard PHOTO has invalid base64 data');
+ }
+ if (decoded.length > MAX_PHOTO_BYTES) {
+ throw new Error('vCard exceeds the 512 KiB photo limit');
+ }
+ return decoded;
+}
+
+export function photoKind(property, version) {
+ const value = property.rawValue.trim();
+ if (!value) return 'empty';
+ const valueType = parameterValues(property, 'VALUE')[0];
+ if (valueType !== undefined && /^uri$/i.test(valueType)) return 'url';
+ const encodings = parameterValues(property, 'ENCODING');
+ if (encodings.some(encoding => /^(?:b|base64)$/i.test(encoding))) return 'base64';
+ if (valueType !== undefined || encodings.length) return 'legacy-base64';
+ if (/^https?:\/\//i.test(value)) return 'url';
+ if (/^data:/i.test(value)) return 'data-uri';
+ if (version === '4.0') return 'url';
+ return 'legacy-base64';
+}
+
+export function dataUriMimeType(value) {
+ const dataUri = String(value).match(/^data:([^,]*),/is);
+ return dataUri ? dataUri[1].split(';')[0].trim().toLowerCase() : null;
+}
+
+export function supportedPhotoMimeType(value) {
+ return SUPPORTED_PHOTO_MIME_TYPES.get(String(value || '').trim().toLowerCase()) || null;
+}
+
+export function retainedPhotoParameters(params, { retainImageTypeValues = false } = {}) {
+ return params.flatMap(parameter => {
+ const name = String(parameter.name).toUpperCase();
+ if (/^(?:ENCODING|MEDIATYPE|VALUE)$/.test(name)) return [];
+ if (name !== 'TYPE' || retainImageTypeValues) return [structuredClone(parameter)];
+ const values = parameter.values.filter(value => (
+ !PHOTO_IMAGE_TYPE_TOKENS.has(String(value).toUpperCase())
+ ));
+ return values.length ? [{ ...structuredClone(parameter), values }] : [];
+ });
+}
+
+export function canonicalSupportedPhotoDataUri(value) {
+ const dataUri = String(value).match(/^data:([^,]*),(.*)$/is);
+ if (!dataUri) return null;
+ const supported = supportedPhotoMimeType(dataUri[1].split(';')[0]);
+ if (!supported) return null;
+ const bytes = /(?:^|;)base64(?:;|$)/i.test(dataUri[1])
+ ? decodeBase64Photo(dataUri[2])
+ : decodePercentEncodedPhoto(dataUri[2]);
+ return 'data:' + supported.mime + ';base64,' + bytes.toString('base64');
+}
+
+export function embeddedPhotoMimeType(property, version) {
+ const kind = photoKind(property, version);
+ if (kind === 'data-uri') return dataUriMimeType(property.rawValue);
+ if (kind === 'legacy-base64') return 'image/jpeg';
+ if (kind !== 'base64') return null;
+
+ const mediaType = parameterValues(property, 'MEDIATYPE')[0];
+ if (mediaType) return mediaType.toLowerCase();
+ const type = parameterValues(property, 'TYPE')[0];
+ if (!type) return 'image/jpeg';
+ const supported = SUPPORTED_PHOTO_MIME_TYPES.get(`image/${type.toLowerCase()}`);
+ return supported?.mime || type.toLowerCase();
+}
+
+function validatePhoto(property, version) {
+ const value = property.rawValue.trim();
+ const kind = photoKind(property, version);
+ if (kind === 'empty' || kind === 'url') return;
+ if (kind === 'base64' || kind === 'legacy-base64') {
+ decodeBase64Photo(value);
+ return;
+ }
+
+ const dataUri = value.match(/^data:([^,]*),(.*)$/is);
+ if (!dataUri) throw new Error('vCard PHOTO has invalid data URI encoding');
+ if (/(?:^|;)base64(?:;|$)/i.test(dataUri[1])) {
+ decodeBase64Photo(dataUri[2]);
+ return;
+ }
+ let decodedBytes;
+ try {
+ decodedBytes = percentDecodedByteLength(dataUri[2]);
+ } catch {
+ throw new Error('vCard PHOTO has invalid data URI encoding');
+ }
+ if (decodedBytes > MAX_PHOTO_BYTES) {
+ throw new Error('vCard exceeds the 512 KiB photo limit');
+ }
+}
+
+function validatedEmbeddedPhoto(property, version) {
+ const kind = photoKind(property, version);
+ if (kind === 'empty' || kind === 'url') return false;
+ validatePhoto(property, version);
+ return true;
+}
+
+export function parseVCardDocument(raw, { defaultVersion } = {}) {
+ const text = raw == null ? '' : String(raw);
+ const effectiveDefaultVersion = defaultVersion
+ && /^(?:BEGIN:VCARD)(?:\r\n|\n|\r)/i.test(text)
+ && !/(?:^|\r\n|\n|\r)VERSION(?:[;:]|$)/i.test(text)
+ ? defaultVersion
+ : null;
+ const lines = unfoldLines(splitPhysicalLines(text));
+ const canonicalBytes = lines.reduce((bytes, line, index) => (
+ index === lines.length - 1 && line === ''
+ ? bytes
+ : bytes + byteLength(line) + 2
+ ), 0);
+ if (canonicalBytes > MAX_VCARD_BYTES) throw new Error('vCard exceeds the 1 MiB limit');
+
+ let state = 'before';
+ let version = null;
+ let countedProperties = 0;
+ const contentLines = [];
+
+ for (const line of lines) {
+ if (!line) continue;
+ const structural = line.toUpperCase();
+ if (state === 'before') {
+ if (structural !== 'BEGIN:VCARD') {
+ throw new Error('vCard must begin with BEGIN:VCARD');
+ }
+ state = 'version';
+ continue;
+ }
+ if (state === 'ended') {
+ throw new Error('vCard must contain exactly one vCard component');
+ }
+
+ const colon = delimiterIndex(line, ':', false);
+ const header = colon < 0 ? '' : line.slice(0, colon);
+ const structuralName = header.split(';', 1)[0].split('.').at(-1).toUpperCase();
+ if (structuralName === 'VERSION' && header.toUpperCase() !== 'VERSION') {
+ throw new Error('vCard VERSION parameters are not supported');
+ }
+ if (/^(?:BEGIN|END)$/.test(structuralName)
+ && structural !== 'BEGIN:VCARD'
+ && structural !== 'END:VCARD') {
+ throw new Error('vCard document cannot contain structural properties');
+ }
+
+ if (state === 'version') {
+ if (header.toUpperCase() !== 'VERSION') {
+ if (!effectiveDefaultVersion) throw new Error('vCard VERSION must follow BEGIN:VCARD');
+ version = effectiveDefaultVersion;
+ state = 'content';
+ } else {
+ if (byteLength(line) > MAX_CONTENT_LINE_BYTES) {
+ throw new Error('vCard exceeds the 64 KiB unfolded line limit');
+ }
+ version = line.slice(colon + 1).trim();
+ state = 'content';
+ continue;
+ }
+ }
+
+ if (structural === 'END:VCARD') {
+ state = 'ended';
+ continue;
+ }
+ if (structural === 'BEGIN:VCARD') {
+ throw new Error('vCard must contain exactly one vCard component');
+ }
+ countedProperties++;
+ if (countedProperties > MAX_PROPERTIES) {
+ throw new Error('vCard exceeds the 2,000 properties limit');
+ }
+ if (header.toUpperCase() === 'VERSION') {
+ throw new Error('vCard must contain exactly one VERSION property');
+ }
+ contentLines.push(line);
+ }
+
+ if (state === 'before') throw new Error('vCard must begin with BEGIN:VCARD');
+ if (state === 'version' && effectiveDefaultVersion) {
+ version = effectiveDefaultVersion;
+ state = 'content';
+ }
+ if (state === 'version') throw new Error('vCard VERSION must follow BEGIN:VCARD');
+ if (state !== 'ended') throw new Error('vCard must end with END:VCARD');
+
+ if (version !== '3.0' && version !== '4.0') {
+ throw new Error('vCard version must be 3.0 or 4.0');
+ }
+
+ const properties = [];
+ for (const line of contentLines) {
+ const property = parseContentLine(line, version);
+ const embeddedPhoto = property.name === 'PHOTO'
+ && validatedEmbeddedPhoto(property, version);
+ if (!embeddedPhoto && byteLength(line) > MAX_CONTENT_LINE_BYTES) {
+ throw new Error('vCard exceeds the 64 KiB unfolded line limit');
+ }
+ properties.push(property);
+ }
+
+ return { version, properties };
+}
+
+function encodeParameterValue(value, version) {
+ const text = String(value);
+ if (version !== '4.0') {
+ if (hasInvalidParameterValue(text, version)) {
+ throw new Error('vCard parameter value contains an invalid character');
+ }
+ return text;
+ }
+ let encoded = '';
+
+ for (let index = 0; index < text.length; index++) {
+ const character = text[index];
+ if (hasForbiddenParameterControl(character, version)) {
+ throw new Error('vCard parameter value contains an invalid character');
+ }
+ if (character === '^') encoded += '^^';
+ else if (character === '"') encoded += "^'";
+ else if (character === '\r') {
+ if (text[index + 1] === '\n') index++;
+ encoded += '^n';
+ } else if (character === '\n') encoded += '^n';
+ else encoded += character;
+ }
+ return encoded;
+}
+
+function serializeParameter(parameter, version) {
+ const name = String(parameter.name || '').toUpperCase();
+ if (!VCARD_TOKEN.test(name)) throw new Error('vCard contains an invalid parameter name');
+ const values = (parameter.values || []).map(value => {
+ const encoded = encodeParameterValue(value, version);
+ return /[\\,;:"]/.test(encoded) || /^\s|\s$/.test(encoded)
+ ? `"${encoded}"`
+ : encoded;
+ });
+ return `${name}=${values.join(',')}`;
+}
+
+function foldLine(line) {
+ const bytes = Buffer.from(line, 'utf8');
+ if (bytes.length <= 75) return `${line}\r\n`;
+
+ const parts = [];
+ let offset = 0;
+ let first = true;
+ while (offset < bytes.length) {
+ const width = first ? 75 : 74;
+ let end = Math.min(offset + width, bytes.length);
+ while (end > offset && end < bytes.length && (bytes[end] & 0xC0) === 0x80) end--;
+ parts.push(`${first ? '' : ' '}${bytes.subarray(offset, end).toString('utf8')}`);
+ offset = end;
+ first = false;
+ }
+ return `${parts.join('\r\n')}\r\n`;
+}
+
+function serializeProperty(property, version) {
+ const groupName = property.group ? String(property.group) : '';
+ if (groupName && !VCARD_TOKEN.test(groupName)) {
+ throw new Error('vCard contains an invalid property group');
+ }
+ const group = groupName ? `${groupName}.` : '';
+ const name = String(property.name || '').toUpperCase();
+ if (!VCARD_TOKEN.test(name)) throw new Error('vCard contains an invalid property name');
+ if (/^(?:BEGIN|END|VERSION)$/.test(name)) {
+ throw new Error('vCard document cannot contain structural properties');
+ }
+ const params = property.params || [];
+ if (params.length > MAX_PARAMETERS) {
+ throw new Error('vCard property exceeds the 64 parameters limit');
+ }
+
+ const rawValue = String(property.rawValue ?? '');
+ if (/[\r\n]/.test(rawValue)) {
+ throw new Error('vCard property value contains a line break');
+ }
+ const header = [group + name, ...params.map(parameter => (
+ serializeParameter(parameter, version)
+ ))].join(';');
+ const lineBytes = byteLength(header) + 1 + byteLength(rawValue);
+ if (lineBytes + 2 > MAX_VCARD_BYTES) {
+ throw new Error('vCard exceeds the 1 MiB limit');
+ }
+ return { name, params, header, rawValue, lineBytes };
+}
+
+export function serializeVCardDocument(document) {
+ const version = String(document?.version || '3.0');
+ if (version !== '3.0' && version !== '4.0') {
+ throw new Error('vCard version must be 3.0 or 4.0');
+ }
+
+ const properties = document?.properties || [];
+ if (properties.length > MAX_PROPERTIES) {
+ throw new Error('vCard exceeds the 2,000 properties limit');
+ }
+
+ let canonicalBytes = 0;
+ let serialized = '';
+ const appendLine = (line, lineBytes, property = null) => {
+ if (canonicalBytes + lineBytes + 2 > MAX_VCARD_BYTES) {
+ throw new Error('vCard exceeds the 1 MiB limit');
+ }
+ canonicalBytes += lineBytes + 2;
+ const embeddedPhoto = property?.name === 'PHOTO'
+ && validatedEmbeddedPhoto(property, version);
+ if (property && !embeddedPhoto && lineBytes > MAX_CONTENT_LINE_BYTES) {
+ throw new Error('vCard exceeds the 64 KiB unfolded line limit');
+ }
+ serialized += foldLine(line);
+ };
+
+ appendLine('BEGIN:VCARD', byteLength('BEGIN:VCARD'));
+ appendLine(`VERSION:${version}`, byteLength(`VERSION:${version}`));
+ for (const property of properties) {
+ const encoded = serializeProperty(property, version);
+ appendLine(`${encoded.header}:${encoded.rawValue}`, encoded.lineBytes, encoded);
+ }
+ appendLine('END:VCARD', byteLength('END:VCARD'));
+ return serialized;
+}
diff --git a/backend/src/utils/vcardHashes.js b/backend/src/utils/vcardHashes.js
new file mode 100644
index 0000000..ef487ee
--- /dev/null
+++ b/backend/src/utils/vcardHashes.js
@@ -0,0 +1,192 @@
+import { createHash } from 'node:crypto';
+
+import {
+ ADR_COMPONENTS,
+ SERVER_OWNED_PROPERTIES,
+ VCARD_3_URI_DEFAULTS,
+ VCARD_4_URI_DEFAULTS,
+ decodeBase64Photo,
+ parameterValues,
+ photoKind,
+ retainedPhotoParameters,
+} from './vcardDocument.js';
+import {
+ additionalFieldLabel,
+ assertAdditionalIds,
+ canonicalJsonValue,
+ contactValue,
+ groupKey,
+ normalizedBoolean,
+ normalizedPhoto,
+ normalizedScalar,
+ normalizedType,
+ propertyUsesUriCodec,
+ semanticPhotoIdentity,
+} from './vcardProjection.js';
+
+export function localVCardEtag(vcard) {
+ return createHash('md5').update(vcard).digest('hex');
+}
+
+function normalizeSemanticRawValue(property, version) {
+ let value = property.rawValue.replace(/\r\n|\r/g, '\n');
+ if (!propertyUsesUriCodec(property, version)) value = value.replace(/\\N/g, '\\n');
+ if (property.name === 'PHOTO') {
+ const supportedIdentity = semanticPhotoIdentity(property, version);
+ if (supportedIdentity) return supportedIdentity;
+ const dataUri = value.match(/^data:([^,]*),(.*)$/is);
+ if (dataUri) {
+ const metadata = dataUri[1].split(';');
+ const mediaType = metadata.shift().trim().toLowerCase();
+ const parameters = [];
+ let base64 = false;
+ for (const entry of metadata) {
+ const trimmed = entry.trim();
+ if (/^base64$/i.test(trimmed)) {
+ base64 = true;
+ continue;
+ }
+ const equals = trimmed.indexOf('=');
+ parameters.push(equals < 0
+ ? trimmed.toLowerCase()
+ : trimmed.slice(0, equals).toLowerCase() + trimmed.slice(equals));
+ }
+ if (base64) {
+ const prefix = [mediaType, ...parameters, 'base64'].filter(Boolean).join(';');
+ return 'data:' + prefix + ',' + decodeBase64Photo(dataUri[2]).toString('base64');
+ }
+ }
+
+ const kind = photoKind(property, version);
+ if (kind === 'base64' || kind === 'legacy-base64') {
+ value = decodeBase64Photo(value).toString('base64');
+ }
+ }
+ return value;
+}
+
+function canonicalSemanticParameters(params) {
+ const byName = new Map();
+
+ for (const parameter of params) {
+ const name = parameter.name.toUpperCase();
+ const values = byName.get(name) || [];
+ values.push(...parameter.values.map(value => (
+ /^(?:TYPE|ENCODING|VALUE|MEDIATYPE)$/.test(name)
+ ? value.toLowerCase()
+ : value
+ )));
+ byName.set(name, values);
+ }
+
+ return [...byName]
+ .sort(([first], [second]) => {
+ if (first < second) return -1;
+ if (first > second) return 1;
+ return 0;
+ })
+ .map(([name, values]) => ({
+ name,
+ values: name === 'TYPE' ? [...new Set(values)].sort() : values,
+ }));
+}
+
+function semanticParametersFor(property, version, photoIdentity) {
+ if (photoIdentity) {
+ return retainedPhotoParameters(property.params, {
+ retainImageTypeValues: version === '4.0',
+ });
+ }
+
+ const valueTypes = parameterValues(property, 'VALUE');
+ const uriDefaults = version === '4.0' ? VCARD_4_URI_DEFAULTS : VCARD_3_URI_DEFAULTS;
+ if (valueTypes.length === 1
+ && /^uri$/i.test(String(valueTypes[0]).trim())
+ && uriDefaults.has(property.name)) {
+ return property.params.filter(parameter => String(parameter.name).toUpperCase() !== 'VALUE');
+ }
+ return property.params;
+}
+
+export function semanticVCardHash(document) {
+ const groups = new Map();
+ let nextGroup = 1;
+ const canonical = {
+ version: document.version,
+ properties: document.properties
+ .filter(property => !SERVER_OWNED_PROPERTIES.has(property.name))
+ .map(property => {
+ const key = groupKey(property.group);
+ const photoIdentity = property.name === 'PHOTO'
+ ? semanticPhotoIdentity(property, document.version)
+ : null;
+ const semanticParams = semanticParametersFor(
+ property,
+ document.version,
+ photoIdentity,
+ );
+ if (key && !groups.has(key)) groups.set(key, `group-${nextGroup++}`);
+ return {
+ group: key ? groups.get(key) : '',
+ name: property.name.toUpperCase(),
+ params: canonicalSemanticParameters(semanticParams),
+ rawValue: photoIdentity || normalizeSemanticRawValue(property, document.version),
+ };
+ }),
+ };
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
+}
+
+function canonicalAdditionalField(field) {
+ const source = field && typeof field === 'object' ? field : {};
+ const kind = normalizedScalar(source.kind).toLowerCase();
+ const sourceValue = source.value && typeof source.value === 'object'
+ && !Array.isArray(source.value) ? source.value : {};
+ let value = source.value;
+ if (kind === 'postal-address') {
+ value = Object.fromEntries(ADR_COMPONENTS.map(component => [component, sourceValue[component]]));
+ } else if (kind === 'im') {
+ value = {
+ protocol: normalizedScalar(sourceValue.protocol || 'im').toLowerCase(),
+ handle: sourceValue.handle,
+ };
+ }
+ return {
+ id: normalizedScalar(source.id),
+ kind,
+ label: additionalFieldLabel(source, kind),
+ value: canonicalJsonValue(value),
+ };
+}
+
+export function localContactHash(contact) {
+ const emails = (contact.emails || []).map(email => ({
+ value: normalizedScalar(email.value ?? email.email).trim().toLowerCase(),
+ type: normalizedType(email.type || 'other'),
+ primary: normalizedBoolean(email.primary ?? email.isPrimary ?? email.is_primary),
+ }));
+ const phones = (contact.phones || []).map(phone => ({
+ value: normalizedScalar(phone.value ?? phone.number).replace(/\s/g, ''),
+ type: normalizedType(phone.type || 'other'),
+ }));
+ const additionalFields = contactValue(contact, 'additionalFields', 'additional_fields') || [];
+ assertAdditionalIds(
+ additionalFields,
+ 'MailFlow Additional field requires a stable ID',
+ 'MailFlow Additional field IDs must be unique',
+ );
+ const canonical = {
+ uid: normalizedScalar(contact.uid),
+ displayName: normalizedScalar(contactValue(contact, 'displayName', 'display_name')),
+ firstName: normalizedScalar(contactValue(contact, 'firstName', 'first_name')),
+ lastName: normalizedScalar(contactValue(contact, 'lastName', 'last_name')),
+ emails,
+ phones,
+ organization: normalizedScalar(contact.organization),
+ notes: normalizedScalar(contact.notes),
+ photoData: normalizedPhoto(contactValue(contact, 'photoData', 'photo_data')),
+ additionalFields: additionalFields.map(canonicalAdditionalField),
+ };
+
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
+}
diff --git a/backend/src/utils/vcardProjection.js b/backend/src/utils/vcardProjection.js
new file mode 100644
index 0000000..fe7ba2f
--- /dev/null
+++ b/backend/src/utils/vcardProjection.js
@@ -0,0 +1,1299 @@
+import { createHash } from 'node:crypto';
+
+import {
+ ADR_COMPONENTS,
+ SERVER_OWNED_PROPERTIES,
+ VCARD_3_URI_DEFAULTS,
+ VCARD_4_URI_DEFAULTS,
+ canonicalSupportedPhotoDataUri,
+ dataUriMimeType,
+ decodeBase64Photo,
+ embeddedPhotoMimeType,
+ parameterValues,
+ parseVCardDocument,
+ photoKind,
+ retainedPhotoParameters,
+ serializeVCardDocument,
+ splitDelimited,
+ supportedPhotoMimeType,
+} from './vcardDocument.js';
+
+const ADDITIONAL_ID_PARAMETER = 'X-MAILFLOW-ID';
+
+function unescapeText(value) {
+ let result = '';
+ for (let index = 0; index < value.length; index++) {
+ const character = value[index];
+ if (character !== '\\' || index + 1 >= value.length) {
+ result += character;
+ continue;
+ }
+
+ const next = value[++index];
+ if (next === 'n' || next === 'N') result += '\n';
+ else if (next === '\\' || next === ';' || next === ',' || next === ':') result += next;
+ else result += '\\' + next;
+ }
+ return result;
+}
+
+function escapeText(value) {
+ if (!value) return '';
+ return String(value)
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\r\n|\r|\n/g, '\\n');
+}
+
+function escapeParam(value) {
+ return String(value || '').replace(/[\r\n;:,"]/g, '');
+}
+
+function propertiesNamed(document, name) {
+ return document.properties.filter(property => property.name === name);
+}
+
+function typesFor(property) {
+ return parameterValues(property, 'TYPE')
+ .map(value => value.trim())
+ .filter(Boolean);
+}
+
+export function groupKey(group) {
+ return String(group || '').toLowerCase();
+}
+
+export function primaryEmail(contact) {
+ const emails = Array.isArray(contact?.emails) ? contact.emails : [];
+ const primary = emails.find(email => email?.primary) ?? emails[0];
+ const value = primary?.value ?? primary?.email;
+ return value ? String(value).toLowerCase().trim() : null;
+}
+
+function propertyIdentityKey(property) {
+ return groupKey(property?.group) + '\0' + property?.name;
+}
+
+function* propertiesWithOccurrences(document) {
+ const occurrences = new Map();
+ for (const [index, property] of document.properties.entries()) {
+ const identityKey = propertyIdentityKey(property);
+ const occurrence = occurrences.get(identityKey) || 0;
+ occurrences.set(identityKey, occurrence + 1);
+ yield { index, property, occurrence };
+ }
+}
+
+function groupLabel(document, group) {
+ if (!group) return null;
+ const label = document.properties.find(property => (
+ groupKey(property.group) === groupKey(group) && property.name === 'X-ABLABEL'
+ ));
+ if (!label) return null;
+ const value = unescapeText(label.rawValue);
+ return value.trim() ? value : null;
+}
+
+function projectedLabel(document, property, fallback) {
+ return groupLabel(document, property.group)
+ || typesFor(property).find(type => !/^(?:internet|pref)$/i.test(type))
+ || fallback;
+}
+
+function stableAdditionalId(property, occurrence) {
+ const identity = JSON.stringify([groupKey(property.group), property.name, occurrence]);
+ return 'vcard-' + createHash('sha256').update(identity).digest('hex').slice(0, 16);
+}
+
+function additionalId(property, occurrence) {
+ const identityParameters = property.params.filter(parameter => (
+ String(parameter.name).toUpperCase() === ADDITIONAL_ID_PARAMETER
+ ));
+ if (identityParameters.length === 0) return stableAdditionalId(property, occurrence);
+
+ const persisted = identityParameters.flatMap(parameter => parameter.values);
+ if (persisted.length !== 1 || !String(persisted[0]).trim()) {
+ throw new Error('vCard contains an invalid MailFlow Additional field ID');
+ }
+ return String(persisted[0]);
+}
+
+function hasPersistedAdditionalId(property) {
+ return property.params.some(parameter => (
+ String(parameter.name).toUpperCase() === ADDITIONAL_ID_PARAMETER
+ ));
+}
+
+function contactAdditionalId(field) {
+ return String(field?.id ?? '');
+}
+
+export function assertAdditionalIds(fields, missingError, duplicateError) {
+ const ids = new Set();
+ for (const field of fields) {
+ const id = contactAdditionalId(field);
+ if (!id.trim()) throw new Error(missingError);
+ if (ids.has(id)) throw new Error(duplicateError);
+ ids.add(id);
+ }
+}
+
+function withAdditionalId(params, id) {
+ const updated = [];
+ let written = false;
+ for (const parameter of params) {
+ if (String(parameter.name).toUpperCase() !== ADDITIONAL_ID_PARAMETER) {
+ updated.push(parameter);
+ } else if (!written) {
+ updated.push({ name: ADDITIONAL_ID_PARAMETER, values: [String(id ?? '')] });
+ written = true;
+ }
+ }
+ if (!written) updated.push({ name: ADDITIONAL_ID_PARAMETER, values: [String(id ?? '')] });
+ return updated;
+}
+
+function additionalField(property, occurrence, kind, label, value) {
+ return {
+ id: additionalId(property, occurrence),
+ kind,
+ label,
+ value,
+ vcard: {
+ group: property.group,
+ name: property.name,
+ params: structuredClone(property.params),
+ },
+ };
+}
+
+function parseImValue(rawValue, label) {
+ const value = String(rawValue);
+ const colon = value.indexOf(':');
+ if (colon < 0) return { protocol: label.toLowerCase(), handle: value };
+
+ let protocol = value.slice(0, colon).toLowerCase();
+ let handle = value.slice(colon + 1);
+ if (protocol === 'x-apple') {
+ try {
+ handle = decodeURIComponent(handle);
+ } catch {
+ // Keep the original handle; malformed percent encoding is opaque text.
+ }
+ protocol = label.toLowerCase() === 'im' ? 'x-apple' : label.toLowerCase();
+ }
+ return { protocol, handle };
+}
+
+function parseGeoValue(rawValue) {
+ const value = unescapeText(rawValue).replace(/^geo:/i, '');
+ const parts = value.split(/[;,]/);
+ if (parts.length !== 2) return null;
+ const latitude = Number(parts[0]);
+ const longitude = Number(parts[1]);
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
+ return { latitude, longitude };
+}
+
+function additionalFieldsFromDocument(document) {
+ assertUniquePersistedAdditionalIds(document);
+ const fields = [];
+
+ for (const { property, occurrence } of propertiesWithOccurrences(document)) {
+ const raw = unescapeText(property.rawValue);
+
+ switch (property.name) {
+ case 'ADR': {
+ const parts = splitDelimited(property.rawValue, ';').map(unescapeText);
+ while (parts.length < 7) parts.push('');
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'postal-address',
+ projectedLabel(document, property, 'Address'),
+ Object.fromEntries(ADR_COMPONENTS.map((component, index) => [component, parts[index]])),
+ ));
+ break;
+ }
+ case 'URL':
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'url',
+ projectedLabel(document, property, 'URL'),
+ projectedPropertyValue(property, document.version),
+ ));
+ break;
+ case 'IMPP': {
+ const label = projectedLabel(document, property, 'IM');
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'im',
+ label,
+ parseImValue(projectedPropertyValue(property, document.version), label),
+ ));
+ break;
+ }
+ case 'BDAY':
+ fields.push(additionalField(
+ property, occurrence, 'birthday', projectedLabel(document, property, 'Birthday'), raw,
+ ));
+ break;
+ case 'ANNIVERSARY':
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'anniversary',
+ projectedLabel(document, property, 'Anniversary'),
+ raw,
+ ));
+ break;
+ case 'X-ABDATE':
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'date',
+ projectedLabel(document, property, 'Date'),
+ raw,
+ ));
+ break;
+ case 'ROLE':
+ fields.push(additionalField(
+ property, occurrence, 'role', projectedLabel(document, property, 'Role'), raw,
+ ));
+ break;
+ case 'TITLE':
+ fields.push(additionalField(
+ property, occurrence, 'title', projectedLabel(document, property, 'Title'), raw,
+ ));
+ break;
+ case 'NICKNAME':
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'nickname',
+ projectedLabel(document, property, 'Nickname'),
+ raw,
+ ));
+ break;
+ case 'GEO': {
+ const value = parseGeoValue(property.rawValue);
+ if (value) {
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'geo',
+ projectedLabel(document, property, 'Location'),
+ value,
+ ));
+ }
+ break;
+ }
+ case 'X-MAILFLOW-CUSTOM':
+ fields.push(additionalField(
+ property,
+ occurrence,
+ 'custom-text',
+ projectedLabel(document, property, 'Custom'),
+ raw,
+ ));
+ break;
+ }
+ }
+
+ assertAdditionalIds(
+ fields,
+ 'vCard contains an invalid MailFlow Additional field ID',
+ 'vCard contains duplicate MailFlow Additional field IDs',
+ );
+ return fields;
+}
+
+function photoDataFromDocument(document) {
+ let photoData = null;
+ for (const property of propertiesNamed(document, 'PHOTO')) {
+ const value = property.rawValue.trim();
+ const kind = photoKind(property, document.version);
+ if (kind === 'empty') continue;
+ if (kind === 'url') {
+ photoData = null;
+ continue;
+ }
+ if (kind === 'data-uri') {
+ photoData = supportedPhotoMimeType(embeddedPhotoMimeType(property, document.version))
+ ? value
+ : null;
+ continue;
+ }
+
+ if (kind === 'base64') {
+ decodeBase64Photo(value);
+ const supported = supportedPhotoMimeType(
+ embeddedPhotoMimeType(property, document.version),
+ );
+ photoData = supported
+ ? 'data:' + supported.mime + ';base64,' + value.replace(/\s/g, '')
+ : null;
+ continue;
+ }
+
+ decodeBase64Photo(value);
+ photoData = 'data:image/jpeg;base64,' + value.replace(/\s/g, '');
+ }
+ return photoData;
+}
+
+function emailFromProperty(property, primary) {
+ const value = unescapeText(property.rawValue).trim().toLowerCase();
+ if (!value) return null;
+ const types = typesFor(property).map(type => type.toLowerCase());
+ const type = types.find(entry => entry === 'work' || entry === 'home') || 'other';
+ return { value, type, primary };
+}
+
+function v4EmailPreference(property) {
+ const preferences = parameterValues(property, 'PREF')
+ .map(value => String(value).trim())
+ .filter(value => /^\d{1,2}$|^100$/.test(value))
+ .map(Number)
+ .filter(value => value >= 1 && value <= 100);
+ return preferences.length ? Math.min(...preferences) : null;
+}
+
+function emailEntriesFromDocument(document) {
+ const entries = [];
+ document.properties.forEach((property, index) => {
+ if (property.name !== 'EMAIL') return;
+ const projected = emailFromProperty(property, false);
+ if (!projected) return;
+ entries.push({ index, property, projected });
+ });
+ if (entries.length === 0) return entries;
+
+ let preferred;
+ if (document.version === '4.0') {
+ const preferences = entries.map(entry => v4EmailPreference(entry.property));
+ const explicit = preferences.filter(value => value !== null);
+ if (explicit.length) {
+ const lowest = Math.min(...explicit);
+ preferred = preferences.map(value => value === lowest);
+ }
+ } else {
+ const explicit = entries.map(entry => (
+ typesFor(entry.property).some(value => /^pref$/i.test(value))
+ ));
+ if (explicit.some(Boolean)) preferred = explicit;
+ }
+ preferred ||= entries.map(() => false);
+
+ return entries.map((entry, index) => ({
+ ...entry,
+ projected: { ...entry.projected, primary: preferred[index] },
+ }));
+}
+
+export function propertyUsesUriCodec(property, version) {
+ const explicit = parameterValues(property, 'VALUE')[0];
+ if (explicit !== undefined) return /^uri$/i.test(String(explicit).trim());
+ return (version === '4.0' ? VCARD_4_URI_DEFAULTS : VCARD_3_URI_DEFAULTS)
+ .has(property.name);
+}
+
+function projectedPropertyValue(property, version) {
+ return propertyUsesUriCodec(property, version)
+ ? String(property.rawValue)
+ : unescapeText(property.rawValue);
+}
+
+function ownedPropertyRawValue(property, version, value) {
+ return propertyUsesUriCodec(property, version) ? normalizedScalar(value) : escapeText(value);
+}
+
+function phoneFromProperty(property, version) {
+ const value = projectedPropertyValue(property, version).trim();
+ if (!value) return null;
+ const types = typesFor(property)
+ .map(type => type.toLowerCase())
+ .map(type => type === 'cell' ? 'mobile' : type);
+ const type = types.find(entry => (
+ entry === 'mobile' || entry === 'work' || entry === 'home'
+ )) || 'other';
+ return { value, type };
+}
+
+export function contactFromVCardDocument(document) {
+ const contact = {
+ uid: null,
+ displayName: null,
+ firstName: null,
+ lastName: null,
+ emails: emailEntriesFromDocument(document).map(entry => entry.projected),
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: additionalFieldsFromDocument(document),
+ };
+
+ for (const property of document.properties) {
+ switch (property.name) {
+ case 'UID':
+ contact.uid = projectedPropertyValue(property, document.version).trim() || null;
+ break;
+ case 'FN':
+ contact.displayName = unescapeText(property.rawValue).trim() || null;
+ break;
+ case 'N': {
+ const parts = splitDelimited(property.rawValue, ';')
+ .map(value => unescapeText(value).trim());
+ contact.lastName = parts[0] || null;
+ contact.firstName = parts[1] || null;
+ break;
+ }
+ case 'EMAIL':
+ break;
+ case 'TEL': {
+ const phone = phoneFromProperty(property, document.version);
+ if (phone) contact.phones.push(phone);
+ break;
+ }
+ case 'ORG':
+ contact.organization = unescapeText(
+ splitDelimited(property.rawValue, ';')[0] || '',
+ ).trim() || null;
+ break;
+ case 'NOTE':
+ contact.notes = unescapeText(property.rawValue).trim() || null;
+ break;
+ }
+ }
+
+ contact.photoData = photoDataFromDocument(document);
+ return contact;
+}
+
+export const ADDITIONAL_PROPERTIES = new Set([
+ 'ADR',
+ 'URL',
+ 'IMPP',
+ 'BDAY',
+ 'ANNIVERSARY',
+ 'X-ABDATE',
+ 'ROLE',
+ 'TITLE',
+ 'NICKNAME',
+ 'GEO',
+ 'X-MAILFLOW-CUSTOM',
+]);
+
+export function contactValue(contact, camelName, snakeName) {
+ if (Object.hasOwn(contact, camelName)) return contact[camelName];
+ return contact[snakeName];
+}
+
+function lastPropertyIndex(properties, name) {
+ for (let index = properties.length - 1; index >= 0; index--) {
+ if (properties[index].name === name) return index;
+ }
+ return -1;
+}
+
+function structuredRawValue(property, replacements, minimumParts) {
+ const parts = splitDelimited(property.rawValue, ';');
+ while (parts.length < minimumParts) parts.push('');
+ for (const [index, value] of replacements) parts[index] = escapeText(value || '');
+ return parts.join(';');
+}
+
+function updatedTypeParameters(property, nextType, phone) {
+ const ownedType = phone
+ ? /^(?:cell|mobile|work|home|other)$/i
+ : /^(?:work|home|other)$/i;
+ const nextValue = escapeParam(nextType || 'other').toUpperCase();
+ const params = structuredClone(property.params);
+ let replaced = false;
+
+ for (const parameter of params) {
+ if (String(parameter.name).toUpperCase() !== 'TYPE') continue;
+ parameter.values = parameter.values.flatMap(value => {
+ if (!ownedType.test(value)) return [value];
+ if (replaced) return [];
+ replaced = true;
+ return [nextValue];
+ });
+ }
+
+ if (!replaced) {
+ const parameter = params.find(entry => String(entry.name).toUpperCase() === 'TYPE');
+ if (parameter) parameter.values.push(nextValue);
+ else params.push({ name: 'TYPE', values: [nextValue] });
+ }
+ return params.filter(parameter => parameter.values.length > 0);
+}
+
+function emailPrimary(email, fallback = false) {
+ if (Object.hasOwn(email, 'primary')) return normalizedBoolean(email.primary);
+ if (Object.hasOwn(email, 'isPrimary')) return normalizedBoolean(email.isPrimary);
+ if (Object.hasOwn(email, 'is_primary')) return normalizedBoolean(email.is_primary);
+ return fallback;
+}
+
+function updatedEmailPreferenceParameters(property, version, primary) {
+ const params = structuredClone(property.params);
+ if (version === '4.0') {
+ const updated = [];
+ let written = false;
+ for (const parameter of params) {
+ if (String(parameter.name).toUpperCase() !== 'PREF') {
+ updated.push(parameter);
+ } else if (primary && !written) {
+ updated.push({ ...parameter, name: 'PREF', values: ['1'] });
+ written = true;
+ }
+ }
+ if (primary && !written) updated.push({ name: 'PREF', values: ['1'] });
+ return updated;
+ }
+
+ let written = false;
+ const updated = params.map(parameter => {
+ if (String(parameter.name).toUpperCase() !== 'TYPE') return parameter;
+ const values = parameter.values.flatMap(value => {
+ if (!/^pref$/i.test(value)) return [value];
+ if (!primary || written) return [];
+ written = true;
+ return ['PREF'];
+ });
+ return { ...parameter, values };
+ }).filter(parameter => parameter.values.length > 0);
+ if (primary && !written) {
+ const type = updated.find(parameter => String(parameter.name).toUpperCase() === 'TYPE');
+ if (type) type.values.push('PREF');
+ else updated.push({ name: 'TYPE', values: ['PREF'] });
+ }
+ return updated;
+}
+
+function additionalPropertyIndices(document) {
+ const indices = new Map();
+
+ for (const { index, property, occurrence } of propertiesWithOccurrences(document)) {
+ if (ADDITIONAL_PROPERTIES.has(property.name)) {
+ const id = additionalId(property, occurrence);
+ if (indices.has(id)) {
+ throw new Error('vCard contains duplicate MailFlow Additional field IDs');
+ }
+ indices.set(id, index);
+ }
+ }
+ return indices;
+}
+
+function assertUniquePersistedAdditionalIds(document) {
+ additionalPropertyIndices(document);
+}
+
+function sameAdditionalValue(first, second) {
+ return JSON.stringify(canonicalJsonValue(first)) === JSON.stringify(canonicalJsonValue(second));
+}
+
+function retainedPhoto(property, version, hasPhoto, photoData) {
+ if (property.name !== 'PHOTO') return false;
+ if (!hasPhoto) return true;
+ if (photoData) return false;
+ return photoKind(property, version) === 'url';
+}
+
+function makeProperty(name, rawValue, params = [], group = null) {
+ return { group, name, params, rawValue };
+}
+
+function canonicalOwnedPhotoData(photoData) {
+ const value = String(photoData);
+ const mimeType = /^data:/i.test(value) ? dataUriMimeType(value) : null;
+ const supported = mimeType ? supportedPhotoMimeType(mimeType) : null;
+ if (/^data:/i.test(value) && !supported) {
+ throw new Error('vCard has unsupported PHOTO MIME type');
+ }
+ if (/^data:/i.test(value)) return canonicalSupportedPhotoDataUri(value);
+ return 'data:image/jpeg;base64,' + decodeBase64Photo(value).toString('base64');
+}
+
+function photoProperty(version, photoData) {
+ const value = canonicalOwnedPhotoData(photoData);
+ const supported = supportedPhotoMimeType(dataUriMimeType(value));
+ const inline = value.match(/^data:([^;,]+);base64,(.*)$/is);
+ if (version === '4.0') {
+ if (inline) {
+ return makeProperty(
+ 'PHOTO',
+ 'data:' + supported.mime + ';base64,' + inline[2],
+ );
+ }
+ if (/^data:/i.test(value)) {
+ return makeProperty('PHOTO', value, [{ name: 'VALUE', values: ['URI'] }]);
+ }
+ return makeProperty('PHOTO', 'data:image/jpeg;base64,' + value);
+ }
+
+ if (inline) {
+ return makeProperty('PHOTO', inline[2], [
+ { name: 'ENCODING', values: ['b'] },
+ { name: 'TYPE', values: [supported.type] },
+ ]);
+ }
+ if (/^data:/i.test(value)) {
+ return makeProperty('PHOTO', value, [{ name: 'VALUE', values: ['URI'] }]);
+ }
+ return makeProperty('PHOTO', value, [
+ { name: 'ENCODING', values: ['b'] },
+ { name: 'TYPE', values: ['JPEG'] },
+ ]);
+}
+
+function rewrittenPhotoProperty(property, version, photoData) {
+ const replacement = photoProperty(version, photoData);
+ const retainedParams = retainedPhotoParameters(property.params);
+ return {
+ ...replacement,
+ group: property.group,
+ params: [...replacement.params, ...retainedParams],
+ };
+}
+
+export function allocateItemGroup(usedGroups) {
+ let index = 1;
+ while (usedGroups.has(('item' + index).toLowerCase())) index++;
+ const group = 'item' + index;
+ usedGroups.add(group.toLowerCase());
+ return group;
+}
+
+function additionalPropertyName(field) {
+ const names = {
+ 'postal-address': 'ADR',
+ url: 'URL',
+ im: 'IMPP',
+ birthday: 'BDAY',
+ anniversary: 'ANNIVERSARY',
+ date: 'X-ABDATE',
+ role: 'ROLE',
+ title: 'TITLE',
+ nickname: 'NICKNAME',
+ geo: 'GEO',
+ 'custom-text': 'X-MAILFLOW-CUSTOM',
+ };
+ return names[String(field.kind || '').toLowerCase()] || null;
+}
+
+function additionalRawValue(field, version, retainedProperty = null) {
+ const kind = String(field.kind || '').toLowerCase();
+ if (kind === 'postal-address') {
+ const value = field.value || {};
+ return ADR_COMPONENTS.map(component => escapeText(value[component] || '')).join(';');
+ }
+ if (kind === 'im') {
+ const value = field.value || {};
+ const rawValue = normalizedScalar(value.protocol || 'im').toLowerCase()
+ + ':' + normalizedScalar(value.handle);
+ return retainedProperty && !propertyUsesUriCodec(retainedProperty, version)
+ ? escapeText(rawValue)
+ : rawValue;
+ }
+ if (kind === 'url') {
+ const rawValue = normalizedScalar(field.value);
+ return retainedProperty && !propertyUsesUriCodec(retainedProperty, version)
+ ? escapeText(rawValue)
+ : rawValue;
+ }
+ if (kind === 'geo') {
+ const value = field.value || {};
+ if (version === '3.0') return value.latitude + ';' + value.longitude;
+ return 'geo:' + value.latitude + ',' + value.longitude;
+ }
+ return escapeText(field.value || '');
+}
+
+export function additionalFieldLabel(field, kind) {
+ const label = normalizedScalar(field?.label);
+ if (label.trim()) return label;
+ // A canonical/typed field maps to a real vCard property (BDAY, ADR, URL, …) and
+ // only needs a label for optional X-ABLABEL grouping, so a blank label is valid.
+ // A custom-text field carries its meaning solely in the label and still requires one.
+ if (kind === 'custom-text') {
+ throw new Error('MailFlow custom text field requires a label');
+ }
+ return '';
+}
+
+function additionalProperties(fields, usedGroups, version) {
+ const properties = [];
+ const labeledGroups = new Set();
+
+ for (const field of fields) {
+ const kind = String(field.kind || '').toLowerCase();
+ const label = additionalFieldLabel(field, kind);
+ const metadata = field.vcard || {};
+ const name = additionalPropertyName(field);
+ if (!name) continue;
+
+ const compatibleMetadata = String(metadata.name || '').toUpperCase() === name;
+ const group = kind === 'custom-text' || label ? allocateItemGroup(usedGroups) : null;
+ const retainedParams = compatibleMetadata && Array.isArray(metadata.params)
+ ? structuredClone(metadata.params)
+ : [];
+ const params = withAdditionalId(retainedParams, field.id);
+ properties.push(makeProperty(name, additionalRawValue(field, version), params, group));
+
+ const groupKey = String(group).toLowerCase();
+ if (group && label && !labeledGroups.has(groupKey)) {
+ properties.push(makeProperty('X-ABLABEL', escapeText(label), [], group));
+ labeledGroups.add(groupKey);
+ }
+ }
+
+ return properties;
+}
+
+export function overlayContactOnVCard(document, contact, { preserveDocumentUid = false } = {}) {
+ const source = {
+ version: document?.version || '3.0',
+ properties: structuredClone(document?.properties || []),
+ };
+ const sourceContact = contactFromVCardDocument(source);
+ const hasPhoto = Object.hasOwn(contact, 'photoData') || Object.hasOwn(contact, 'photo_data');
+ const photoData = contactValue(contact, 'photoData', 'photo_data');
+ const hasAdditional = Object.hasOwn(contact, 'additionalFields')
+ || Object.hasOwn(contact, 'additional_fields');
+ const additionalFields = contactValue(contact, 'additionalFields', 'additional_fields') || [];
+ if (hasAdditional) {
+ assertAdditionalIds(
+ additionalFields,
+ 'MailFlow Additional field requires a stable ID',
+ 'MailFlow Additional field IDs must be unique',
+ );
+ }
+ const uid = contactValue(contact, 'uid', 'uid');
+ const displayName = contactValue(contact, 'displayName', 'display_name');
+ const firstName = contactValue(contact, 'firstName', 'first_name');
+ const lastName = contactValue(contact, 'lastName', 'last_name');
+ const emails = contact.emails || [];
+ const phones = contact.phones || [];
+ const organization = contact.organization;
+ const notes = contact.notes;
+ const replacements = new Map();
+ const appendedCore = [];
+ const appendedAdditional = [];
+ const groupsToClean = new Set();
+ const labelUpdates = new Map();
+ const usedGroups = new Set(
+ source.properties.filter(property => property.group)
+ .map(property => property.group.toLowerCase()),
+ );
+
+ const removeProperty = index => {
+ replacements.set(index, []);
+ const group = source.properties[index].group;
+ if (group) groupsToClean.add(groupKey(group));
+ };
+ const replaceScalar = (name, sourceValue, nextValue, rawValue) => {
+ const index = lastPropertyIndex(source.properties, name);
+ const property = index >= 0 ? source.properties[index] : makeProperty(name, '');
+ const nextRawValue = typeof rawValue === 'function' ? rawValue(property) : rawValue;
+ if (index >= 0) {
+ if (normalizedScalar(sourceValue) !== normalizedScalar(nextValue)) {
+ replacements.set(index, [{ ...source.properties[index], rawValue: nextRawValue }]);
+ }
+ } else if (normalizedScalar(nextValue)) {
+ appendedCore.push(makeProperty(name, nextRawValue));
+ }
+ };
+
+ // UID is remote-owned identity. On an outgoing edit of an existing remote resource
+ // (preserveDocumentUid) keep the retained document's UID so Mailflow never rewrites
+ // it on the server; only mint from the contact when the document has no UID (create)
+ // or when the caller wants the local key on the document (presented/local vCards).
+ if (!(preserveDocumentUid && sourceContact.uid)) {
+ replaceScalar('UID', sourceContact.uid, uid, property => (
+ ownedPropertyRawValue(property, source.version, uid || '')
+ ));
+ }
+ replaceScalar('FN', sourceContact.displayName, displayName, escapeText(displayName || ''));
+
+ const nameIndex = lastPropertyIndex(source.properties, 'N');
+ if (nameIndex >= 0) {
+ if (normalizedScalar(sourceContact.lastName) !== normalizedScalar(lastName)
+ || normalizedScalar(sourceContact.firstName) !== normalizedScalar(firstName)) {
+ replacements.set(nameIndex, [{
+ ...source.properties[nameIndex],
+ rawValue: structuredRawValue(
+ source.properties[nameIndex],
+ [[0, lastName], [1, firstName]],
+ 5,
+ ),
+ }]);
+ }
+ } else if (firstName || lastName) {
+ appendedCore.push(makeProperty(
+ 'N',
+ escapeText(lastName || '') + ';' + escapeText(firstName || '') + ';;;',
+ ));
+ }
+
+ const sourceEmails = emailEntriesFromDocument(source);
+ const sourcePhones = [];
+ source.properties.forEach((property, index) => {
+ if (property.name === 'TEL') {
+ const projected = phoneFromProperty(property, source.version);
+ if (projected) sourcePhones.push({ index, projected });
+ }
+ });
+ const normalizeEmailPreferences = emails.length !== sourceEmails.length
+ || sourceEmails.some(({ projected }, index) => (
+ !emails[index] || emailPrimary(emails[index], projected.primary) !== projected.primary
+ ));
+
+ sourceEmails.forEach(({ index, projected }, emailIndex) => {
+ const next = emails[emailIndex];
+ if (!next) {
+ removeProperty(index);
+ return;
+ }
+ const property = structuredClone(source.properties[index]);
+ const nextValue = next.value ?? next.email ?? '';
+ const nextType = String(next.type || 'other').toLowerCase();
+ const nextPrimary = emailPrimary(next, projected.primary);
+ let changed = false;
+ if (String(nextValue).trim().toLowerCase() !== projected.value) {
+ property.rawValue = escapeText(nextValue);
+ changed = true;
+ }
+ if (nextType !== projected.type) {
+ property.params = updatedTypeParameters(property, nextType, false);
+ changed = true;
+ }
+ if (normalizeEmailPreferences) {
+ property.params = updatedEmailPreferenceParameters(property, source.version, nextPrimary);
+ changed = true;
+ }
+ if (changed) replacements.set(index, [property]);
+ });
+ for (const email of emails.slice(sourceEmails.length)) {
+ const property = makeProperty(
+ 'EMAIL',
+ escapeText(email.value ?? email.email ?? ''),
+ [{ name: 'TYPE', values: [escapeParam(email.type || 'other').toUpperCase()] }],
+ );
+ property.params = updatedEmailPreferenceParameters(
+ property,
+ source.version,
+ emailPrimary(email),
+ );
+ appendedCore.push(property);
+ }
+
+ sourcePhones.forEach(({ index, projected }, phoneIndex) => {
+ const next = phones[phoneIndex];
+ if (!next) {
+ removeProperty(index);
+ return;
+ }
+ const property = structuredClone(source.properties[index]);
+ const nextValue = next.value ?? next.number ?? '';
+ const nextType = normalizedType(next.type || 'other');
+ let changed = false;
+ if (String(nextValue).trim() !== projected.value) {
+ property.rawValue = ownedPropertyRawValue(property, source.version, nextValue);
+ changed = true;
+ }
+ if (nextType !== projected.type) {
+ property.params = updatedTypeParameters(property, nextType, true);
+ changed = true;
+ }
+ if (changed) replacements.set(index, [property]);
+ });
+ for (const phone of phones.slice(sourcePhones.length)) {
+ const property = makeProperty(
+ 'TEL',
+ '',
+ [{ name: 'TYPE', values: [escapeParam(phone.type || 'voice').toUpperCase()] }],
+ );
+ property.rawValue = ownedPropertyRawValue(
+ property,
+ source.version,
+ phone.value ?? phone.number ?? '',
+ );
+ appendedCore.push(property);
+ }
+
+ const organizationIndex = lastPropertyIndex(source.properties, 'ORG');
+ if (organizationIndex >= 0) {
+ if (normalizedScalar(sourceContact.organization) !== normalizedScalar(organization)) {
+ replacements.set(organizationIndex, [{
+ ...source.properties[organizationIndex],
+ rawValue: structuredRawValue(source.properties[organizationIndex], [[0, organization]], 1),
+ }]);
+ }
+ } else if (organization) {
+ appendedCore.push(makeProperty('ORG', escapeText(organization)));
+ }
+ replaceScalar('NOTE', sourceContact.notes, notes, escapeText(notes || ''));
+
+ if (hasAdditional) {
+ const propertyIndices = additionalPropertyIndices(source);
+ const sourceFields = new Map(sourceContact.additionalFields.map(field => [field.id, field]));
+ const targetById = new Map(additionalFields.map(field => [contactAdditionalId(field), field]));
+ const sourceIndices = sourceContact.additionalFields
+ .map(field => propertyIndices.get(field.id))
+ .sort((first, second) => first - second);
+
+ const sourceOrderByIdentity = new Map();
+ for (const field of sourceContact.additionalFields) {
+ const propertyIndex = propertyIndices.get(field.id);
+ if (hasPersistedAdditionalId(source.properties[propertyIndex])) continue;
+ const key = propertyIdentityKey(field.vcard);
+ const ids = sourceOrderByIdentity.get(key) || [];
+ ids.push(field.id);
+ sourceOrderByIdentity.set(key, ids);
+ }
+ for (const ids of sourceOrderByIdentity.values()) {
+ if (ids.length < 2) continue;
+ const targetIds = new Set(additionalFields.map(contactAdditionalId));
+ let deletedEarlier = false;
+ for (const id of ids) {
+ if (!targetIds.has(id)) {
+ deletedEarlier = true;
+ } else if (deletedEarlier) {
+ throw new Error(
+ 'MailFlow Additional fields cannot delete an earlier ambiguous retained property',
+ );
+ }
+ }
+ const positions = new Map(ids.map((id, index) => [id, index]));
+ const targetOrder = additionalFields
+ .map(field => positions.get(contactAdditionalId(field)))
+ .filter(position => position !== undefined);
+ if (targetOrder.some((position, index) => index > 0 && position < targetOrder[index - 1])) {
+ throw new Error(
+ 'MailFlow Additional fields cannot reorder ambiguous retained properties',
+ );
+ }
+ }
+
+ const canUpdateSharedLabel = (sourceField, nextLabel) => {
+ const key = groupKey(sourceField.vcard?.group);
+ if (!key) return false;
+ const groupedProperties = source.properties.filter(property => (
+ groupKey(property.group) === key && property.name !== 'X-ABLABEL'
+ ));
+ const groupedFields = sourceContact.additionalFields.filter(field => (
+ groupKey(field.vcard?.group) === key
+ ));
+ if (groupedProperties.length !== groupedFields.length) return false;
+ return groupedFields.every(field => {
+ const target = targetById.get(String(field.id || ''));
+ return target
+ && additionalPropertyName(target) === field.vcard.name
+ && normalizedScalar(target.label) === nextLabel;
+ });
+ };
+
+ const derivedIdsToPersist = new Set();
+ const projectedSourceIndices = new Set(sourceIndices);
+ const targetRowIndices = new Map();
+ for (const targetField of additionalFields) {
+ if (additionalPropertyName(targetField)) {
+ targetRowIndices.set(contactAdditionalId(targetField), targetRowIndices.size);
+ }
+ }
+ const sourceFieldsByIdentity = new Map();
+ for (const sourceField of sourceContact.additionalFields) {
+ const key = propertyIdentityKey(sourceField.vcard);
+ const fields = sourceFieldsByIdentity.get(key) || [];
+ fields.push(sourceField);
+ sourceFieldsByIdentity.set(key, fields);
+ }
+ for (const [identityKey, sourceIdentityFields] of sourceFieldsByIdentity) {
+ const retainedTargetFields = additionalFields.flatMap(field => {
+ const sourceField = sourceFields.get(contactAdditionalId(field));
+ if (!sourceField) return [];
+ const sourceKey = propertyIdentityKey(sourceField.vcard);
+ if (sourceKey !== identityKey || additionalPropertyName(field) !== sourceField.vcard.name) {
+ return [];
+ }
+ const nextLabel = additionalFieldLabel(field, String(field.kind || '').toLowerCase());
+ if (normalizedScalar(sourceField.label) !== nextLabel
+ && !canUpdateSharedLabel(sourceField, nextLabel)) {
+ return [];
+ }
+ return [{ sourceField, targetRowIndex: targetRowIndices.get(contactAdditionalId(field)) }];
+ });
+
+ for (const sourceField of sourceIdentityFields) {
+ const propertyIndex = propertyIndices.get(sourceField.id);
+ const retainedProperty = source.properties[propertyIndex];
+ if (hasPersistedAdditionalId(retainedProperty)) continue;
+ const targetOccurrenceIndex = retainedTargetFields.findIndex(
+ retained => retained.sourceField.id === sourceField.id,
+ );
+ if (targetOccurrenceIndex < 0) continue;
+ const targetSlot = sourceIndices[
+ retainedTargetFields[targetOccurrenceIndex].targetRowIndex
+ ] ?? Infinity;
+ const opaquePredecessors = source.properties.filter((property, index) => (
+ index < targetSlot
+ && !projectedSourceIndices.has(index)
+ && propertyIdentityKey(property) === identityKey
+ )).length;
+ const targetOccurrence = targetOccurrenceIndex + opaquePredecessors;
+ if (stableAdditionalId(retainedProperty, targetOccurrence) !== sourceField.id) {
+ derivedIdsToPersist.add(sourceField.id);
+ }
+ }
+ }
+
+ const targetRows = additionalFields.flatMap(field => {
+ const kind = String(field.kind || '').toLowerCase();
+ const nextLabel = additionalFieldLabel(field, kind);
+ const id = contactAdditionalId(field);
+ const sourceField = sourceFields.get(id);
+ const propertyIndex = sourceField ? propertyIndices.get(id) : null;
+ const retainedProperty = propertyIndex == null ? null : source.properties[propertyIndex];
+ const name = additionalPropertyName(field);
+ if (!name) return [];
+ if (!sourceField || retainedProperty.name !== name) {
+ return [additionalProperties([field], usedGroups, source.version)];
+ }
+
+ const property = structuredClone(retainedProperty);
+ if (derivedIdsToPersist.has(id)) {
+ property.params = withAdditionalId(property.params, id);
+ }
+ if (!sameAdditionalValue(field.value, sourceField.value)) {
+ property.rawValue = additionalRawValue(field, source.version, retainedProperty);
+ }
+ const sourceLabel = normalizedScalar(sourceField.label);
+ if (sourceLabel === nextLabel) return [[property]];
+
+ if (canUpdateSharedLabel(sourceField, nextLabel)) {
+ const key = groupKey(property.group);
+ if (!labelUpdates.has(key)) {
+ labelUpdates.set(key, {
+ group: property.group,
+ rawValue: nextLabel ? escapeText(nextLabel) : null,
+ remove: !nextLabel,
+ });
+ }
+ return [[property]];
+ }
+
+ // A cleared label drops the custom X-ABLABEL: keep the property but leave it
+ // ungrouped so it projects back to its default (type/kind) label.
+ if (!nextLabel) {
+ property.group = null;
+ return [[property]];
+ }
+
+ property.group = allocateItemGroup(usedGroups);
+ property.params = withAdditionalId(property.params, id);
+ return [[
+ property,
+ makeProperty('X-ABLABEL', escapeText(nextLabel), [], property.group),
+ ]];
+ });
+
+ sourceIndices.forEach((propertyIndex, index) => {
+ const group = source.properties[propertyIndex].group;
+ if (group) groupsToClean.add(groupKey(group));
+ replacements.set(propertyIndex, targetRows[index] || []);
+ });
+ for (const row of targetRows.slice(sourceIndices.length)) {
+ appendedAdditional.push(...row);
+ }
+ }
+
+ let appendedPhoto = [];
+ if (hasPhoto && normalizedPhoto(photoData) !== normalizedPhoto(sourceContact.photoData)) {
+ let rewrittenPhotoIndex = -1;
+ if (photoData && !/^https?:\/\//i.test(photoData) && sourceContact.photoData) {
+ const sourcePhotoIdentity = normalizedPhoto(sourceContact.photoData);
+ for (let index = source.properties.length - 1; index >= 0; index--) {
+ const property = source.properties[index];
+ if (property.name === 'PHOTO'
+ && semanticPhotoIdentity(property, source.version) === sourcePhotoIdentity) {
+ rewrittenPhotoIndex = index;
+ replacements.set(index, [rewrittenPhotoProperty(property, source.version, photoData)]);
+ break;
+ }
+ }
+ }
+ source.properties.forEach((property, index) => {
+ if (index !== rewrittenPhotoIndex
+ && property.name === 'PHOTO'
+ && !retainedPhoto(property, source.version, hasPhoto, photoData)) {
+ removeProperty(index);
+ }
+ });
+ if (rewrittenPhotoIndex < 0 && photoData && !/^https?:\/\//i.test(photoData)) {
+ appendedPhoto = [photoProperty(source.version, photoData)];
+ }
+ }
+
+ let properties = [];
+ source.properties.forEach((property, index) => {
+ if (SERVER_OWNED_PROPERTIES.has(property.name)) return;
+ properties.push(...(replacements.get(index) || [property]));
+ });
+ properties.push(...appendedCore, ...appendedPhoto, ...appendedAdditional);
+
+ const updatedLabels = new Set();
+ properties = properties.flatMap(property => {
+ if (property.name !== 'X-ABLABEL') return [property];
+ const key = groupKey(property.group);
+ const update = labelUpdates.get(key);
+ if (!update || updatedLabels.has(key)) return [property];
+ updatedLabels.add(key);
+ if (update.remove) return [];
+ return [{ ...property, group: update.group, rawValue: update.rawValue }];
+ });
+ for (const [key, update] of labelUpdates) {
+ if (!updatedLabels.has(key) && !update.remove) {
+ properties.push(makeProperty('X-ABLABEL', update.rawValue, [], update.group));
+ }
+ }
+ for (const key of groupsToClean) {
+ const hasGroupedProperty = properties.some(property => (
+ property.name !== 'X-ABLABEL' && groupKey(property.group) === key
+ ));
+ if (!hasGroupedProperty) {
+ properties = properties.filter(property => !(
+ property.name === 'X-ABLABEL' && groupKey(property.group) === key
+ ));
+ }
+ }
+
+ return {
+ version: source.version,
+ properties,
+ };
+}
+
+// Replace a document's UID property with `uidProperty` (dropping duplicate UID lines),
+// preserving the given UID's exact encoding. A plain array transform that does not throw.
+export function withDocumentUid(document, uidProperty) {
+ if (!uidProperty) return document;
+ let replaced = false;
+ const properties = document.properties.flatMap(property => {
+ if (property.name !== 'UID') return [property];
+ if (replaced) return [];
+ replaced = true;
+ return [structuredClone(uidProperty)];
+ });
+ if (!replaced) properties.unshift(structuredClone(uidProperty));
+ return { ...document, properties };
+}
+
+// A push-destined snapshot must NEVER emit a local UID upstream. When the overlay
+// throws (e.g. assertAdditionalIds on malformed Additional-field IDs), re-key the stored
+// vCard to the retained document's remote UID via transforms that cannot throw; if that is
+// impossible (no retained UID, or the stored vCard is unparseable), fail CLOSED by
+// re-throwing so the conflict/recovery machinery surfaces the error rather than pushing a
+// wrong UID. Never falls back to the raw local-UID document.
+export function pushSafeSnapshot(rawVcard, retainedDocument, cause) {
+ const retainedUidProperty = retainedDocument?.properties?.find(property => property.name === 'UID');
+ if (retainedUidProperty && typeof rawVcard === 'string') {
+ try {
+ const rekeyed = serializeVCardDocument(withDocumentUid(parseVCardDocument(rawVcard), retainedUidProperty));
+ console.warn('[carddav] presented overlay failed; pushed re-keyed fallback snapshot');
+ return rekeyed;
+ } catch {
+ // fall through to fail closed
+ }
+ }
+ throw cause;
+}
+
+// preserveDocumentUid: false (default) keeps the LOCAL UID that keys the resource URL —
+// what the CardDAV server serves; an overlay failure falls back to the stored vCard so a
+// read never fails. true keeps the retained REMOTE UID — for a conflict snapshot that
+// keep-mailflow pushes verbatim, where the remote resource's UID must never be rewritten;
+// an overlay failure re-keys or fails closed via pushSafeSnapshot, never
+// emitting the local-UID document. Do not conflate the two usages.
+//
+// The document MailFlow's own CardDAV server serves to native clients. For a mapped
+// contact it overlays the local modeled columns onto the retained lossless remote
+// vCard so unmodeled server properties (CATEGORIES, KEY, TZ, X-*) are visible to and
+// round-trip through the client. Unmapped/local contacts serve their stored vCard, and
+// any overlay failure falls back to it so a read never fails. `contact` is a raw contacts
+// row (snake_case columns + parsed jsonb emails/phones); overlayContactOnVCard reads both
+// column casings.
+export function presentedVCard(contact, { preserveDocumentUid = false } = {}) {
+ if (!contact.mapping_vcard) return contact.vcard;
+ let retained;
+ try {
+ retained = parseVCardDocument(contact.mapping_vcard);
+ return serializeVCardDocument(overlayContactOnVCard(retained, contact, { preserveDocumentUid }));
+ } catch (error) {
+ if (!preserveDocumentUid) return contact.vcard;
+ return pushSafeSnapshot(contact.vcard, retained, error);
+ }
+}
+
+// The strong ETag MailFlow's CardDAV server serves for a contact, derived from the
+// PRESENTED representation rather than contacts.etag. This advances whenever the
+// served bytes change — including a remote-only edit to unmodeled properties that leaves
+// the modeled columns (and contacts.etag) untouched — so a stale If-Match is rejected and
+// getctag pollers that re-fetch see fresh bytes under a fresh ETag.
+export function presentedEtag(contact) {
+ return createHash('md5').update(presentedVCard(contact)).digest('hex');
+}
+
+export function semanticPhotoIdentity(property, version) {
+ const supported = supportedPhotoMimeType(embeddedPhotoMimeType(property, version));
+ if (!supported) return null;
+ const kind = photoKind(property, version);
+ if (kind === 'data-uri') return canonicalSupportedPhotoDataUri(property.rawValue);
+ if (kind === 'base64' || kind === 'legacy-base64') {
+ const bytes = decodeBase64Photo(property.rawValue);
+ return 'data:' + supported.mime + ';base64,' + bytes.toString('base64');
+ }
+ return null;
+}
+
+export function normalizedScalar(value) {
+ if (value == null || value === '') return '';
+ return String(value).replace(/\r\n|\r/g, '\n');
+}
+
+export function normalizedType(value) {
+ const type = normalizedScalar(value).trim().toLowerCase();
+ return type === 'cell' ? 'mobile' : type;
+}
+
+export function normalizedBoolean(value) {
+ return value === true || value === 1 || String(value).toLowerCase() === 'true';
+}
+
+export function normalizedPhoto(value) {
+ const photo = normalizedScalar(value);
+ const canonical = canonicalSupportedPhotoDataUri(photo);
+ if (canonical || !photo || /^data:|^https?:\/\//i.test(photo)) return canonical || photo;
+ return 'data:image/jpeg;base64,' + decodeBase64Photo(photo).toString('base64');
+}
+
+export function canonicalJsonValue(value, key = '') {
+ if (value == null) return '';
+ if (Array.isArray(value)) return value.map(entry => canonicalJsonValue(entry));
+ if (typeof value === 'object') {
+ return Object.fromEntries(
+ Object.keys(value).sort().map(name => [name, canonicalJsonValue(value[name], name)]),
+ );
+ }
+ if (typeof value === 'string') {
+ const normalized = normalizedScalar(value);
+ return key === 'kind' || key === 'type' ? normalized.toLowerCase() : normalized;
+ }
+ return value;
+}
diff --git a/backend/src/utils/vcardProperties.js b/backend/src/utils/vcardProperties.js
new file mode 100644
index 0000000..2b5a914
--- /dev/null
+++ b/backend/src/utils/vcardProperties.js
@@ -0,0 +1,31 @@
+/**
+ * Public vCard API surface.
+ *
+ * Implementation lives in vcardDocument, vcardProjection, and vcardHashes.
+ */
+// Retained-document syntax and the shared server-owned property boundary.
+export {
+ ADR_COMPONENTS,
+ SERVER_OWNED_PROPERTIES,
+ parseVCardDocument,
+ serializeVCardDocument,
+} from './vcardDocument.js';
+// Contact projection and retained-document overlay.
+export {
+ ADDITIONAL_PROPERTIES,
+ allocateItemGroup,
+ contactFromVCardDocument,
+ groupKey,
+ overlayContactOnVCard,
+ presentedEtag,
+ presentedVCard,
+ primaryEmail,
+ pushSafeSnapshot,
+ withDocumentUid,
+} from './vcardProjection.js';
+// Remote-document semantic and local-contact hashes.
+export {
+ localContactHash,
+ localVCardEtag,
+ semanticVCardHash,
+} from './vcardHashes.js';
diff --git a/backend/src/utils/vcardProperties.test.js b/backend/src/utils/vcardProperties.test.js
new file mode 100644
index 0000000..f61959a
--- /dev/null
+++ b/backend/src/utils/vcardProperties.test.js
@@ -0,0 +1,3194 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ contactFromVCardDocument,
+ localContactHash,
+ overlayContactOnVCard,
+ parseVCardDocument,
+ presentedEtag,
+ presentedVCard,
+ pushSafeSnapshot,
+ semanticVCardHash,
+ serializeVCardDocument,
+} from './vcardProperties.js';
+
+const MAX_VCARD_BYTES = 1024 * 1024;
+const MAX_PROPERTIES = 2000;
+const MAX_PARAMETERS = 64;
+const MAX_CONTENT_LINE_BYTES = 64 * 1024;
+const MAX_PHOTO_BYTES = 512 * 1024;
+
+function vcard(lines, version = '3.0') {
+ return ['BEGIN:VCARD', `VERSION:${version}`, ...lines, 'END:VCARD', ''].join('\r\n');
+}
+
+function parameterValuesForTest(property, name) {
+ return property.params
+ .filter(parameter => parameter.name === name)
+ .flatMap(parameter => parameter.values);
+}
+
+function asciiVCardOfSize(size) {
+ const start = 'BEGIN:VCARD\r\nVERSION:3.0\r\n';
+ const end = 'END:VCARD\r\n';
+ let remaining = size - Buffer.byteLength(start) - Buffer.byteLength(end);
+ const lines = [];
+
+ while (remaining > 0) {
+ let lineBytes = Math.min(MAX_CONTENT_LINE_BYTES + 2, remaining);
+ const tail = remaining - lineBytes;
+ if (tail > 0 && tail < 4) lineBytes -= 4 - tail;
+ lines.push(`X:${'a'.repeat(lineBytes - 4)}\r\n`);
+ remaining -= lineBytes;
+ }
+
+ return start + lines.join('') + end;
+}
+
+function foldAsciiLine(line) {
+ const parts = [];
+ let offset = 0;
+ let first = true;
+ while (offset < line.length) {
+ const width = first ? 75 : 74;
+ parts.push(`${first ? '' : ' '}${line.slice(offset, offset + width)}`);
+ offset += width;
+ first = false;
+ }
+ return parts.join('\r\n');
+}
+
+function additionalIdentityRows(contact) {
+ return contact.additionalFields.map(({ id, kind, label, value }) => ({
+ id,
+ kind,
+ label,
+ value,
+ }));
+}
+
+// Adapted from Nametag's Apple vCard fixture, with MailFlow-specific grouped
+// parameter cases added. Source pinned to:
+// https://github.com/mattogodoy/nametag/blob/bdbe7c129f600b90277a516066f78a731e67e017/tests/vcards/apple-vcard-example.vcf
+const NAMETAG_DERIVED_FIXTURE = vcard([
+ 'PRODID:-//Apple Inc.//iPhone OS 26.2//EN',
+ 'N:Brown;Emmetiano;Lucas;;',
+ 'FN:Emmetiano Lucas Brown',
+ 'item1.TEL;TYPE="CELL,VOICE";X-ABLABEL=Mobile:+15551234567',
+ 'EMAIL;TYPE=INTERNET;TYPE=WORK:doc@bttf.com',
+ 'item2.ADR;TYPE=WORK:;;Rio Segre 2;Valdemorillo;Madrid;28210;Spain',
+ 'item2.X-ABADR:es',
+ 'item3.URL;TYPE=pref:biffsucks.com',
+ 'item3.X-ABLabel:Hobbies',
+ 'X-SOCIALPROFILE;TYPE=twitter:http://twitter.com/doc',
+ 'NOTE:Escaped comma\\, semicolon\\; newline\\nand slash\\\\ retained',
+ 'X-MAILFLOW-UNKNOWN;X-LABEL="one;two":opaque\\:value:tail',
+ 'EXPERTISE;X-LEVEL=9:Flux dynamics',
+ 'IMPP;TYPE=HOME:xmpp:doc@example.test',
+]);
+
+const VCARD_4_BEHAVIOR_FIXTURE = vcard([
+ 'UID:urn:uuid:contact-4',
+ 'FN:Jane Q. Doe',
+ 'N:Doe;Jane;Q.;;',
+ 'EMAIL;TYPE=WORK;PREF=1:Jane.Doe@Example.Test',
+ 'TEL;VALUE=uri;TYPE=CELL:tel:+15551234567;ext=9',
+ 'PHOTO:data:image/png;base64,AQID',
+ 'X-REMOTE;X-ORDER=one,two:opaque',
+], '4.0');
+
+const FOLDED_PHOTO_BEHAVIOR_FIXTURE = vcard([
+ 'UID:folded-photo',
+ 'FN:Folded Photo',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQ\r\n ID',
+]);
+
+function behaviorContract(raw) {
+ const document = parseVCardDocument(raw);
+ const projection = contactFromVCardDocument(document);
+ const serialized = serializeVCardDocument(document);
+
+ return {
+ serialized,
+ serializedBytes: Buffer.from(serialized).toString('base64'),
+ projection,
+ semanticHash: semanticVCardHash(document),
+ localHash: localContactHash(projection),
+ };
+}
+
+describe('vCard parsing, projection, serialization, and hashing behavior contracts', () => {
+ it('captures vCard 3.0 escaping, Additional fields, and custom properties', () => {
+ expect(behaviorContract(NAMETAG_DERIVED_FIXTURE)).toMatchInlineSnapshot(`
+ {
+ "localHash": "55b28825db91fb008c76cc907842fcce1e530ea21d5075ba053ac83f8d15ec56",
+ "projection": {
+ "additionalFields": [
+ {
+ "id": "vcard-ef137e5c921d3ae9",
+ "kind": "postal-address",
+ "label": "WORK",
+ "value": {
+ "country": "Spain",
+ "extendedAddress": "",
+ "locality": "Valdemorillo",
+ "poBox": "",
+ "postalCode": "28210",
+ "region": "Madrid",
+ "street": "Rio Segre 2",
+ },
+ "vcard": {
+ "group": "item2",
+ "name": "ADR",
+ "params": [
+ {
+ "name": "TYPE",
+ "values": [
+ "WORK",
+ ],
+ },
+ ],
+ },
+ },
+ {
+ "id": "vcard-8510414f13db4465",
+ "kind": "url",
+ "label": "Hobbies",
+ "value": "biffsucks.com",
+ "vcard": {
+ "group": "item3",
+ "name": "URL",
+ "params": [
+ {
+ "name": "TYPE",
+ "values": [
+ "pref",
+ ],
+ },
+ ],
+ },
+ },
+ {
+ "id": "vcard-67b665a82e7253f8",
+ "kind": "im",
+ "label": "HOME",
+ "value": {
+ "handle": "doc@example.test",
+ "protocol": "xmpp",
+ },
+ "vcard": {
+ "group": null,
+ "name": "IMPP",
+ "params": [
+ {
+ "name": "TYPE",
+ "values": [
+ "HOME",
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ "displayName": "Emmetiano Lucas Brown",
+ "emails": [
+ {
+ "primary": false,
+ "type": "work",
+ "value": "doc@bttf.com",
+ },
+ ],
+ "firstName": "Emmetiano",
+ "lastName": "Brown",
+ "notes": "Escaped comma, semicolon; newline
+ and slash\\ retained",
+ "organization": null,
+ "phones": [
+ {
+ "type": "mobile",
+ "value": "+15551234567",
+ },
+ ],
+ "photoData": null,
+ "uid": null,
+ },
+ "semanticHash": "0243c83dda67df0fb9b2409c053036e3330e79c2edcbcd98e05b217fe2adc683",
+ "serialized": "BEGIN:VCARD
+ VERSION:3.0
+ PRODID:-//Apple Inc.//iPhone OS 26.2//EN
+ N:Brown;Emmetiano;Lucas;;
+ FN:Emmetiano Lucas Brown
+ item1.TEL;TYPE=CELL,VOICE;X-ABLABEL=Mobile:+15551234567
+ EMAIL;TYPE=INTERNET;TYPE=WORK:doc@bttf.com
+ item2.ADR;TYPE=WORK:;;Rio Segre 2;Valdemorillo;Madrid;28210;Spain
+ item2.X-ABADR:es
+ item3.URL;TYPE=pref:biffsucks.com
+ item3.X-ABLABEL:Hobbies
+ X-SOCIALPROFILE;TYPE=twitter:http://twitter.com/doc
+ NOTE:Escaped comma\\, semicolon\\; newline\\nand slash\\\\ retained
+ X-MAILFLOW-UNKNOWN;X-LABEL="one;two":opaque\\:value:tail
+ EXPERTISE;X-LEVEL=9:Flux dynamics
+ IMPP;TYPE=HOME:xmpp:doc@example.test
+ END:VCARD
+ ",
+ "serializedBytes": "QkVHSU46VkNBUkQNClZFUlNJT046My4wDQpQUk9ESUQ6LS8vQXBwbGUgSW5jLi8vaVBob25lIE9TIDI2LjIvL0VODQpOOkJyb3duO0VtbWV0aWFubztMdWNhczs7DQpGTjpFbW1ldGlhbm8gTHVjYXMgQnJvd24NCml0ZW0xLlRFTDtUWVBFPUNFTEwsVk9JQ0U7WC1BQkxBQkVMPU1vYmlsZTorMTU1NTEyMzQ1NjcNCkVNQUlMO1RZUEU9SU5URVJORVQ7VFlQRT1XT1JLOmRvY0BidHRmLmNvbQ0KaXRlbTIuQURSO1RZUEU9V09SSzo7O1JpbyBTZWdyZSAyO1ZhbGRlbW9yaWxsbztNYWRyaWQ7MjgyMTA7U3BhaW4NCml0ZW0yLlgtQUJBRFI6ZXMNCml0ZW0zLlVSTDtUWVBFPXByZWY6YmlmZnN1Y2tzLmNvbQ0KaXRlbTMuWC1BQkxBQkVMOkhvYmJpZXMNClgtU09DSUFMUFJPRklMRTtUWVBFPXR3aXR0ZXI6aHR0cDovL3R3aXR0ZXIuY29tL2RvYw0KTk9URTpFc2NhcGVkIGNvbW1hXCwgc2VtaWNvbG9uXDsgbmV3bGluZVxuYW5kIHNsYXNoXFwgcmV0YWluZWQNClgtTUFJTEZMT1ctVU5LTk9XTjtYLUxBQkVMPSJvbmU7dHdvIjpvcGFxdWVcOnZhbHVlOnRhaWwNCkVYUEVSVElTRTtYLUxFVkVMPTk6Rmx1eCBkeW5hbWljcw0KSU1QUDtUWVBFPUhPTUU6eG1wcDpkb2NAZXhhbXBsZS50ZXN0DQpFTkQ6VkNBUkQNCg==",
+ }
+ `);
+ });
+
+ it('captures vCard 4.0 syntax, projection, and hashes', () => {
+ expect(behaviorContract(VCARD_4_BEHAVIOR_FIXTURE)).toMatchInlineSnapshot(`
+ {
+ "localHash": "4c59c578e03979fb2ed5c199bff36596ba5ffdfb58e0c93f320601803cff0c24",
+ "projection": {
+ "additionalFields": [],
+ "displayName": "Jane Q. Doe",
+ "emails": [
+ {
+ "primary": true,
+ "type": "work",
+ "value": "jane.doe@example.test",
+ },
+ ],
+ "firstName": "Jane",
+ "lastName": "Doe",
+ "notes": null,
+ "organization": null,
+ "phones": [
+ {
+ "type": "mobile",
+ "value": "tel:+15551234567;ext=9",
+ },
+ ],
+ "photoData": "data:image/png;base64,AQID",
+ "uid": "urn:uuid:contact-4",
+ },
+ "semanticHash": "05fb7f95e67393f76c9b69c0b96de19042d15a5636ef14fb26fb8255bbda835f",
+ "serialized": "BEGIN:VCARD
+ VERSION:4.0
+ UID:urn:uuid:contact-4
+ FN:Jane Q. Doe
+ N:Doe;Jane;Q.;;
+ EMAIL;TYPE=WORK;PREF=1:Jane.Doe@Example.Test
+ TEL;VALUE=uri;TYPE=CELL:tel:+15551234567;ext=9
+ PHOTO:data:image/png;base64,AQID
+ X-REMOTE;X-ORDER=one,two:opaque
+ END:VCARD
+ ",
+ "serializedBytes": "QkVHSU46VkNBUkQNClZFUlNJT046NC4wDQpVSUQ6dXJuOnV1aWQ6Y29udGFjdC00DQpGTjpKYW5lIFEuIERvZQ0KTjpEb2U7SmFuZTtRLjs7DQpFTUFJTDtUWVBFPVdPUks7UFJFRj0xOkphbmUuRG9lQEV4YW1wbGUuVGVzdA0KVEVMO1ZBTFVFPXVyaTtUWVBFPUNFTEw6dGVsOisxNTU1MTIzNDU2NztleHQ9OQ0KUEhPVE86ZGF0YTppbWFnZS9wbmc7YmFzZTY0LEFRSUQNClgtUkVNT1RFO1gtT1JERVI9b25lLHR3bzpvcGFxdWUNCkVORDpWQ0FSRA0K",
+ }
+ `);
+ });
+
+ it('captures folded PHOTO bytes, projection, and hashes', () => {
+ expect(behaviorContract(FOLDED_PHOTO_BEHAVIOR_FIXTURE)).toMatchInlineSnapshot(`
+ {
+ "localHash": "91da1d7ca4d830d5f40e0031eefe205b046431a0d8e12e76f541d0c7e4d5b2ed",
+ "projection": {
+ "additionalFields": [],
+ "displayName": "Folded Photo",
+ "emails": [],
+ "firstName": null,
+ "lastName": null,
+ "notes": null,
+ "organization": null,
+ "phones": [],
+ "photoData": "data:image/png;base64,AQID",
+ "uid": "folded-photo",
+ },
+ "semanticHash": "c6cc7538ba6dc19e5d1ba70058ef864a5e9c440635697895051dd3d7e0df0c42",
+ "serialized": "BEGIN:VCARD
+ VERSION:3.0
+ UID:folded-photo
+ FN:Folded Photo
+ PHOTO;ENCODING=b;TYPE=PNG:AQID
+ END:VCARD
+ ",
+ "serializedBytes": "QkVHSU46VkNBUkQNClZFUlNJT046My4wDQpVSUQ6Zm9sZGVkLXBob3RvDQpGTjpGb2xkZWQgUGhvdG8NClBIT1RPO0VOQ09ESU5HPWI7VFlQRT1QTkc6QVFJRA0KRU5EOlZDQVJEDQo=",
+ }
+ `);
+ });
+});
+
+describe('retained vCard property tree', () => {
+ it('preserves groups, ordered parameters, structured values, escapes, and unknown properties', () => {
+ const document = parseVCardDocument(NAMETAG_DERIVED_FIXTURE);
+
+ expect(document.version).toBe('3.0');
+ expect(document.properties.find(property => property.name === 'TEL')).toEqual({
+ group: 'item1',
+ name: 'TEL',
+ params: [
+ { name: 'TYPE', values: ['CELL', 'VOICE'] },
+ { name: 'X-ABLABEL', values: ['Mobile'] },
+ ],
+ rawValue: '+15551234567',
+ });
+ expect(document.properties.find(property => property.name === 'EMAIL').params).toEqual([
+ { name: 'TYPE', values: ['INTERNET'] },
+ { name: 'TYPE', values: ['WORK'] },
+ ]);
+ expect(document.properties.find(property => property.name === 'N').rawValue)
+ .toBe('Brown;Emmetiano;Lucas;;');
+ expect(document.properties.find(property => property.name === 'NOTE').rawValue)
+ .toBe('Escaped comma\\, semicolon\\; newline\\nand slash\\\\ retained');
+ expect(document.properties.find(property => property.name === 'X-MAILFLOW-UNKNOWN'))
+ .toEqual({
+ group: null,
+ name: 'X-MAILFLOW-UNKNOWN',
+ params: [{ name: 'X-LABEL', values: ['one;two'] }],
+ rawValue: 'opaque\\:value:tail',
+ });
+ expect(document.properties.find(property => property.name === 'X-ABADR')).toEqual({
+ group: 'item2',
+ name: 'X-ABADR',
+ params: [],
+ rawValue: 'es',
+ });
+ expect(document.properties.find(property => property.name === 'EXPERTISE')).toEqual({
+ group: null,
+ name: 'EXPERTISE',
+ params: [{ name: 'X-LEVEL', values: ['9'] }],
+ rawValue: 'Flux dynamics',
+ });
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it.each(['3.0', '4.0'])('accepts vCard %s and normalizes case-insensitive names only', version => {
+ const document = parseVCardDocument([
+ 'begin:vcard',
+ `version:${version}`,
+ 'Item7.email;type="Work,INTERNET":Mixed.Case@Example.Test',
+ 'end:vcard',
+ '',
+ ].join('\n'));
+
+ expect(document).toEqual({
+ version,
+ properties: [{
+ group: 'Item7',
+ name: 'EMAIL',
+ params: [{ name: 'TYPE', values: ['Work', 'INTERNET'] }],
+ rawValue: 'Mixed.Case@Example.Test',
+ }],
+ });
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it('splits at the first colon outside quoted parameters', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-REFERENCE;X-URI="urn:example:label";X-ESCAPED=one\\:two:raw\\:value:tail',
+ ]));
+
+ expect(document.properties[0]).toEqual({
+ group: null,
+ name: 'X-REFERENCE',
+ params: [
+ { name: 'X-URI', values: ['urn:example:label'] },
+ { name: 'X-ESCAPED', values: ['one\\'] },
+ ],
+ rawValue: 'two:raw\\:value:tail',
+ });
+ });
+
+ it('round-trips caret escapes and scans double-quoted parameter delimiters', () => {
+ const quoted = parseVCardDocument(vcard([
+ 'X-REFERENCE;X-LABEL="one;two:three":payload',
+ ]));
+ const document = {
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{
+ name: 'X-VALUES',
+ values: ['^n', '^^', '"quoted"', 'line\nbreak'],
+ }],
+ rawValue: 'payload',
+ }],
+ };
+
+ expect(quoted.properties[0].params).toEqual([
+ { name: 'X-LABEL', values: ['one;two:three'] },
+ ]);
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it('uses the standards content delimiter after a terminal parameter backslash', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-REFERENCE;X-P=foo\\:https://example.test/a',
+ ]));
+
+ expect(document.properties[0]).toEqual({
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{ name: 'X-P', values: ['foo\\'] }],
+ rawValue: 'https://example.test/a',
+ });
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it('preserves unquoted parameter whitespace and decoded vCard 4.0 line breaks', () => {
+ const parsed = parseVCardDocument(vcard([
+ 'X-REFERENCE;X-P= padded :payload',
+ ], '4.0'));
+ const constructed = {
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{ name: 'X-P', values: ['\n'] }],
+ rawValue: 'payload',
+ }],
+ };
+
+ expect(parsed.properties[0].params).toEqual([
+ { name: 'X-P', values: [' padded '] },
+ ]);
+ expect(parseVCardDocument(serializeVCardDocument(parsed))).toEqual(parsed);
+ expect(parseVCardDocument(serializeVCardDocument(constructed))).toEqual(constructed);
+ });
+
+ it.each(['^n', '^N', '^^', "^'"])(
+ 'preserves literal vCard 3.0 parameter sequence %s',
+ value => {
+ const document = parseVCardDocument(vcard([
+ `X-REFERENCE;X-P=literal${value}case:payload`,
+ ]));
+
+ expect(document.properties[0].params).toEqual([
+ { name: 'X-P', values: [`literal${value}case`] },
+ ]);
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ },
+ );
+
+ it.each(['3.0', '4.0'])(
+ 'preserves literal surrounding apostrophes in vCard %s parameters',
+ version => {
+ const document = parseVCardDocument(vcard([
+ "X-REFERENCE;X-P='quoted':payload",
+ ], version));
+
+ expect(document.properties[0].params).toEqual([
+ { name: 'X-P', values: ["'quoted'"] },
+ ]);
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ },
+ );
+
+ it('treats an apostrophe inside an unquoted parameter value as ordinary text', () => {
+ const document = parseVCardDocument(vcard([
+ "X-REFERENCE;X-LABEL=O'Reilly:payload",
+ ]));
+
+ expect(document.properties[0]).toEqual({
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{ name: 'X-LABEL', values: ["O'Reilly"] }],
+ rawValue: 'payload',
+ });
+ });
+
+ it.each([
+ ["ordinary apostrophe", "O'Reilly"],
+ ['literal surrounding single quotes', "'quoted'"],
+ ])('round-trips parameter values containing %s', (_case, value) => {
+ const document = {
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{ name: 'X-LABEL', values: [value] }],
+ rawValue: 'payload',
+ }],
+ };
+
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it('preserves whitespace inside quoted parameter values', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-REFERENCE;X-LABEL= " padded " ;TYPE="CELL,VOICE":payload',
+ ]));
+
+ expect(document.properties[0]).toEqual({
+ group: null,
+ name: 'X-REFERENCE',
+ params: [
+ { name: 'X-LABEL', values: [' padded '] },
+ { name: 'TYPE', values: ['CELL', 'VOICE'] },
+ ],
+ rawValue: 'payload',
+ });
+ const serialized = serializeVCardDocument(document);
+ expect(serialized).toContain('X-LABEL=" padded ";TYPE=CELL,VOICE:payload\r\n');
+ expect(parseVCardDocument(serialized)).toEqual(document);
+ });
+
+ it('parses individually quoted and mixed parameter-list entries', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-REFERENCE;P="one","two";Q=plain,"quoted",tail;TYPE="CELL,VOICE":payload',
+ ]));
+
+ expect(document.properties[0].params).toEqual([
+ { name: 'P', values: ['one', 'two'] },
+ { name: 'Q', values: ['plain', 'quoted', 'tail'] },
+ { name: 'TYPE', values: ['CELL', 'VOICE'] },
+ ]);
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it('keeps literal commas except in a legacy whole-quoted compound TYPE', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-REFERENCE;P="Last, First";TYPE="CELL,VOICE":payload',
+ ]));
+
+ expect(document.properties[0].params).toEqual([
+ { name: 'P', values: ['Last, First'] },
+ { name: 'TYPE', values: ['CELL', 'VOICE'] },
+ ]);
+ });
+
+ it('quotes comma-containing parameter-list entries individually', () => {
+ const document = {
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{
+ name: 'P',
+ values: ['Last, First', 'plain', 'Suffix, Jr.'],
+ }],
+ rawValue: 'payload',
+ }],
+ };
+
+ const serialized = serializeVCardDocument(document);
+
+ expect(serialized).toContain('P="Last, First",plain,"Suffix, Jr.":payload\r\n');
+ expect(parseVCardDocument(serialized)).toEqual(document);
+ });
+
+ it('round-trips empty parameter entries and literal backslashes at every boundary', () => {
+ const document = {
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'X-REFERENCE',
+ params: [
+ { name: 'P-EMPTY', values: ['', 'first', '', ''] },
+ { name: 'P-COMMA', values: ['before-comma\\', 'after'] },
+ { name: 'P-SEMICOLON', values: ['before-semicolon\\'] },
+ { name: 'P-QUOTE', values: ['before-quote\\"after'] },
+ { name: 'P-COLON', values: ['before-colon\\'] },
+ ],
+ rawValue: 'payload',
+ }],
+ };
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it.each([1, 2, 4])('preserves %i external parameter backslashes exactly', count => {
+ const slashes = '\\'.repeat(count);
+ const document = parseVCardDocument(vcard([
+ 'X-REFERENCE'
+ + `;P-UNQUOTED=${slashes}ordinary`
+ + `;P-QUOTED="${slashes}ordinary,${slashes}semi;${slashes}colon:tail"`
+ + `;P-COMMA=${slashes},tail`
+ + `;P-SEMICOLON=${slashes};P-NEXT=next`
+ + `;P-COLON=${slashes}:payload`,
+ ]));
+
+ expect(document.properties[0]).toEqual({
+ group: null,
+ name: 'X-REFERENCE',
+ params: [
+ { name: 'P-UNQUOTED', values: [`${slashes}ordinary`] },
+ {
+ name: 'P-QUOTED',
+ values: [`${slashes}ordinary,${slashes}semi;${slashes}colon:tail`],
+ },
+ { name: 'P-COMMA', values: [slashes, 'tail'] },
+ { name: 'P-SEMICOLON', values: [slashes] },
+ { name: 'P-NEXT', values: ['next'] },
+ { name: 'P-COLON', values: [slashes] },
+ ],
+ rawValue: 'payload',
+ });
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it('folds UTF-8 content at 75 octets without splitting code points', () => {
+ const document = {
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'NOTE',
+ params: [],
+ rawValue: `Résumé ${'🙂'.repeat(40)} fin`,
+ }],
+ };
+
+ const serialized = serializeVCardDocument(document);
+ const physicalLines = serialized.split('\r\n').filter(Boolean);
+
+ expect(physicalLines.some(line => line.startsWith(' '))).toBe(true);
+ expect(physicalLines.every(line => Buffer.byteLength(line, 'utf8') <= 75)).toBe(true);
+ expect(serialized).not.toContain('\ufffd');
+ expect(parseVCardDocument(serialized)).toEqual(document);
+ });
+});
+
+describe('vCard safety boundaries', () => {
+ it('accepts exactly 1 MiB and rejects one decoded vCard byte more', () => {
+ const atLimit = asciiVCardOfSize(MAX_VCARD_BYTES);
+ const overLimit = asciiVCardOfSize(MAX_VCARD_BYTES + 1);
+
+ expect(Buffer.byteLength(atLimit)).toBe(MAX_VCARD_BYTES);
+ expect(parseVCardDocument(atLimit).properties).toHaveLength(16);
+ expect(() => parseVCardDocument(overLimit)).toThrow(/1 MiB/);
+ });
+
+ it('accepts 2,000 properties and rejects property 2,001', () => {
+ const properties = Array.from({ length: MAX_PROPERTIES }, (_, index) => `X-P:${index}`);
+
+ expect(parseVCardDocument(vcard(properties)).properties).toHaveLength(MAX_PROPERTIES);
+ expect(() => parseVCardDocument(vcard([...properties, 'X-P:overflow'])))
+ .toThrow(/2,000 properties/);
+ });
+
+ it('accepts 64 ordered parameters and rejects parameter 65', () => {
+ const parameters = Array.from({ length: MAX_PARAMETERS }, (_, index) => `;P${index}=v`)
+ .join('');
+
+ expect(parseVCardDocument(vcard([`TEL${parameters}:1`])).properties[0].params)
+ .toHaveLength(MAX_PARAMETERS);
+ expect(() => parseVCardDocument(vcard([`TEL${parameters};OVERFLOW=v:1`])))
+ .toThrow(/64 parameters/);
+ });
+
+ it('accepts a 64 KiB physical line and rejects one physical byte more', () => {
+ const atLimit = `NOTE:${'a'.repeat(MAX_CONTENT_LINE_BYTES - 5)}`;
+ const overLimit = `${atLimit}a`;
+
+ expect(Buffer.byteLength(atLimit)).toBe(MAX_CONTENT_LINE_BYTES);
+ expect(parseVCardDocument(vcard([atLimit])).properties[0].rawValue)
+ .toHaveLength(MAX_CONTENT_LINE_BYTES - 5);
+ expect(() => parseVCardDocument(vcard([overLimit]))).toThrow(/64 KiB physical line/);
+ });
+
+ it('accepts a 64 KiB unfolded non-PHOTO line and rejects one unfolded byte more', () => {
+ const firstValue = 'a'.repeat(40_000);
+ const exactTail = 'b'.repeat(MAX_CONTENT_LINE_BYTES - 5 - firstValue.length);
+ const exact = vcard([`NOTE:${firstValue}\r\n ${exactTail}`]);
+ const over = vcard([`NOTE:${firstValue}\r\n ${exactTail}b`]);
+
+ expect(parseVCardDocument(exact).properties[0].rawValue)
+ .toHaveLength(MAX_CONTENT_LINE_BYTES - 5);
+ expect(() => parseVCardDocument(over)).toThrow(/64 KiB unfolded line/);
+ });
+
+ it('applies the unfolded-line limit to VERSION at its exact boundary', () => {
+ const suffix = '3.0';
+ const prefix = 'VERSION:';
+ const exactLine = prefix
+ + ' '.repeat(MAX_CONTENT_LINE_BYTES - Buffer.byteLength(prefix + suffix))
+ + suffix;
+ const overLine = `${exactLine} `;
+
+ expect(Buffer.byteLength(exactLine)).toBe(MAX_CONTENT_LINE_BYTES);
+ const card = versionLine => [
+ 'BEGIN:VCARD',
+ foldAsciiLine(versionLine),
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+
+ expect(parseVCardDocument(card(exactLine)).version).toBe('3.0');
+ expect(() => parseVCardDocument(card(overLine)))
+ .toThrow(/64 KiB unfolded line/);
+ });
+
+ it('allows folded PHOTO past the unfolded-line cap through 512 KiB decoded', () => {
+ const photo = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64');
+ const folded = foldAsciiLine(`PHOTO;ENCODING=b;TYPE=JPEG:${photo}`);
+ const document = parseVCardDocument(vcard([folded]));
+
+ expect(document.properties[0].rawValue).toBe(photo);
+ });
+
+ it('limits folded URI PHOTO by unfolded bytes without shrinking embedded PHOTO capacity', () => {
+ const header = 'PHOTO;VALUE=URI:';
+ const exactUri = foldAsciiLine(header + 'a'.repeat(MAX_CONTENT_LINE_BYTES - header.length));
+ const oversizedUri = foldAsciiLine(
+ header + 'a'.repeat(MAX_CONTENT_LINE_BYTES - header.length + 1),
+ );
+ const embedded = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64');
+
+ expect(parseVCardDocument(vcard([exactUri])).properties[0].rawValue)
+ .toHaveLength(MAX_CONTENT_LINE_BYTES - header.length);
+ expect(() => parseVCardDocument(vcard([oversizedUri])))
+ .toThrow('vCard exceeds the 64 KiB unfolded line limit');
+ expect(() => serializeVCardDocument({
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'VALUE', values: ['URI'] }],
+ rawValue: 'a'.repeat(MAX_CONTENT_LINE_BYTES - header.length + 1),
+ }],
+ })).toThrow('vCard exceeds the 64 KiB unfolded line limit');
+ expect(() => parseVCardDocument(vcard([
+ foldAsciiLine(`PHOTO;ENCODING=b;TYPE=JPEG:${embedded}`),
+ ]))).not.toThrow();
+ });
+
+ it('rejects a decoded PHOTO one byte beyond 512 KiB', () => {
+ const photo = Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64');
+ const folded = foldAsciiLine(`PHOTO;ENCODING=b;TYPE=JPEG:${photo}`);
+
+ expect(() => parseVCardDocument(vcard([folded]))).toThrow(/512 KiB photo/);
+ });
+
+ it.each([
+ ['parsing', photo => parseVCardDocument(vcard([foldAsciiLine(`PHOTO:${photo}`)]))],
+ ['serialization', photo => serializeVCardDocument({
+ version: '3.0',
+ properties: [{ group: null, name: 'PHOTO', params: [], rawValue: photo }],
+ })],
+ ])('accepts exactly 512 KiB and rejects one byte more for legacy PHOTO %s', (_case, run) => {
+ const atLimit = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64');
+ const overLimit = Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64');
+
+ expect(() => run(atLimit)).not.toThrow();
+ expect(() => run(overLimit)).toThrow(/512 KiB photo/);
+ });
+
+ it('rejects oversized base64 PHOTO data before decoding it', () => {
+ const photo = Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64');
+ const bufferFrom = vi.spyOn(Buffer, 'from');
+
+ try {
+ expect(() => serializeVCardDocument({
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'ENCODING', values: ['b'] }],
+ rawValue: photo,
+ }],
+ })).toThrow(/512 KiB photo/);
+ expect(bufferFrom.mock.calls.some(([value]) => (
+ typeof value === 'string' && value.length > MAX_PHOTO_BYTES
+ ))).toBe(false);
+ } finally {
+ bufferFrom.mockRestore();
+ }
+ });
+
+ it('checks the document byte budget before decoding a valid PHOTO', () => {
+ const photo = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64');
+ const encodedPhoto = photo.replace(/=+$/, '');
+ const bufferFrom = vi.spyOn(Buffer, 'from');
+
+ try {
+ expect(() => serializeVCardDocument({
+ version: '3.0',
+ properties: [
+ ...Array.from({ length: 6 }, (_value, index) => ({
+ group: null,
+ name: `X-FILL-${index}`,
+ params: [],
+ rawValue: 'a'.repeat(60 * 1024),
+ })),
+ {
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'ENCODING', values: ['b'] }],
+ rawValue: photo,
+ },
+ ],
+ })).toThrow(/1 MiB/);
+ expect(bufferFrom.mock.calls.some(([value]) => value === encodedPhoto)).toBe(false);
+ } finally {
+ bufferFrom.mockRestore();
+ }
+ });
+
+ it('rejects an oversized URI PHOTO before folding its content line', () => {
+ const rawValue = 'https://example.test/' + 'a'.repeat(MAX_VCARD_BYTES);
+ const bufferFrom = vi.spyOn(Buffer, 'from');
+
+ try {
+ expect(() => serializeVCardDocument({
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'VALUE', values: ['URI'] }],
+ rawValue,
+ }],
+ })).toThrow(/1 MiB/);
+ expect(bufferFrom.mock.calls.some(([value]) => (
+ typeof value === 'string' && value.length > MAX_VCARD_BYTES
+ ))).toBe(false);
+ } finally {
+ bufferFrom.mockRestore();
+ }
+ });
+
+ it.each([
+ ['parsing', photo => parseVCardDocument(vcard([`PHOTO:${photo}`]))],
+ ['serialization', photo => serializeVCardDocument({
+ version: '3.0',
+ properties: [{ group: null, name: 'PHOTO', params: [], rawValue: photo }],
+ })],
+ ])('rejects malformed legacy PHOTO base64 during %s', (_case, run) => {
+ expect(() => run('not-valid-***')).toThrow(/invalid base64/);
+ });
+
+ it.each([
+ ['invalid alphabet', 'AQI*'],
+ ['impossible unpadded sextet length', 'A'],
+ ['incomplete padded quartet', 'A=='],
+ ['padding without payload', '=='],
+ ['padding before the end', 'AA=A'],
+ ['noncanonical discarded bits after one byte', 'AB=='],
+ ['noncanonical discarded bits after two bytes', 'AAB='],
+ ].flatMap(([reason, photo]) => [
+ [reason, 'parsing', () => parseVCardDocument(vcard([
+ `PHOTO;ENCODING=b;TYPE=JPEG:${photo}`,
+ ]))],
+ [reason, 'serialization', () => serializeVCardDocument({
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'ENCODING', values: ['b'] }],
+ rawValue: photo,
+ }],
+ })],
+ ]))('rejects base64 with %s during %s', (_reason, _operation, run) => {
+ expect(run).toThrow(/invalid base64/);
+ });
+
+ it.each([
+ ['padded', 'AQI='],
+ ['unpadded', 'AQI'],
+ ['ASCII whitespace', 'AQ I='],
+ ])('accepts canonical %s base64 PHOTO data', (_case, photo) => {
+ expect(() => parseVCardDocument(vcard([
+ `PHOTO;ENCODING=b;TYPE=JPEG:${photo}`,
+ ]))).not.toThrow();
+ expect(() => serializeVCardDocument({
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'ENCODING', values: ['b'] }],
+ rawValue: photo,
+ }],
+ })).not.toThrow();
+ });
+
+ it('accepts arbitrary percent-encoded octets in non-base64 data URI photos', () => {
+ const rawValue = 'data:image/png,%89PNG%0D%0A';
+ const document = parseVCardDocument(vcard([`PHOTO:${rawValue}`], '4.0'));
+
+ expect(document.properties[0].rawValue).toBe(rawValue);
+ expect(contactFromVCardDocument(document).photoData).toBe(rawValue);
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it.each([
+ ['parsing', rawValue => parseVCardDocument(vcard([`PHOTO:${rawValue}`], '4.0'))],
+ ['serialization', rawValue => serializeVCardDocument({
+ version: '4.0',
+ properties: [{ group: null, name: 'PHOTO', params: [], rawValue }],
+ })],
+ ])('rejects malformed percent escapes in non-base64 data URI photos during %s', (
+ _case,
+ run,
+ ) => {
+ expect(() => run('data:image/png,%8G')).toThrow(/invalid data URI encoding/);
+ expect(() => run('data:image/png,%')).toThrow(/invalid data URI encoding/);
+ });
+
+ it.each([
+ ['parsing', rawValue => parseVCardDocument(vcard([
+ foldAsciiLine(`PHOTO:${rawValue}`),
+ ], '4.0'))],
+ ['serialization', rawValue => serializeVCardDocument({
+ version: '4.0',
+ properties: [{ group: null, name: 'PHOTO', params: [], rawValue }],
+ })],
+ ])('counts decoded bytes for non-base64 data URI photos during %s', (_case, run) => {
+ const prefix = 'data:application/octet-stream,';
+ const atLimit = prefix + 'a'.repeat(MAX_PHOTO_BYTES);
+ const overLimit = `${atLimit}a`;
+
+ expect(() => run(atLimit)).not.toThrow();
+ expect(() => run(overLimit)).toThrow(/512 KiB photo/);
+ });
+
+ it.each([
+ ['group line break', {
+ group: 'item1\r\nX-INJECT', name: 'X-SAFE', params: [], rawValue: 'value',
+ }],
+ ['group delimiter', {
+ group: 'item1;TYPE=WORK', name: 'X-SAFE', params: [], rawValue: 'value',
+ }],
+ ['property-name line break', {
+ group: null, name: 'X-SAFE\r\nX-INJECT', params: [], rawValue: 'value',
+ }],
+ ['property-name delimiter', {
+ group: null, name: 'X-SAFE;TYPE=WORK', params: [], rawValue: 'value',
+ }],
+ ['parameter-name line break', {
+ group: null,
+ name: 'X-SAFE',
+ params: [{ name: 'TYPE\r\nX-INJECT', values: ['WORK'] }],
+ rawValue: 'value',
+ }],
+ ['parameter-name delimiter', {
+ group: null,
+ name: 'X-SAFE',
+ params: [{ name: 'TYPE:INJECT', values: ['WORK'] }],
+ rawValue: 'value',
+ }],
+ ['raw-value carriage return', {
+ group: null, name: 'X-SAFE', params: [], rawValue: 'value\rX-INJECT:payload',
+ }],
+ ['raw-value newline', {
+ group: null, name: 'X-SAFE', params: [], rawValue: 'value\nX-INJECT:payload',
+ }],
+ ])('rejects serializer structural injection through %s', (_case, property) => {
+ expect(() => serializeVCardDocument({
+ version: '4.0',
+ properties: [property],
+ })).toThrow(/invalid (?:property group|property name|parameter name)|line break/);
+ });
+});
+
+describe('MailFlow contact projection', () => {
+ const richDocument = () => parseVCardDocument(vcard([
+ 'UID:contact-1',
+ 'FN:Jane Doe',
+ 'N:Doe;Jane;;;',
+ 'EMAIL;TYPE=INTERNET,WORK:Jane.Doe@Example.Test',
+ 'TEL;TYPE=CELL,VOICE:+1 555 123 4567',
+ 'ORG:Example Corp;Research',
+ 'NOTE:Line one\\nLine two',
+ 'item1.ADR;TYPE=WORK:PO Box 1;Floor 2;123 Main St\\nUnit 4;Vancouver;BC;V1V 1V1;Canada',
+ 'item1.X-ABLabel:Office',
+ 'URL;TYPE=HOME:https://example.test/jane',
+ 'item2.IMPP:x-apple:jane_handle',
+ 'item2.X-ABLabel:Signal',
+ 'BDAY:1990-02-03',
+ 'ANNIVERSARY:2020-04-05',
+ 'item3.X-ABDATE:2012-06-07',
+ 'item3.X-ABLabel:Graduation',
+ 'ROLE:Engineering Lead',
+ 'TITLE:Principal Engineer',
+ 'NICKNAME:JD',
+ 'GEO:geo:49.2827,-123.1207',
+ 'item4.X-MAILFLOW-CUSTOM:Blue',
+ 'item4.X-ABLabel:Favorite color',
+ ]));
+
+ it('projects core fields and every supported Additional-field kind', () => {
+ const first = contactFromVCardDocument(richDocument());
+ const second = contactFromVCardDocument(richDocument());
+
+ expect(first).toMatchObject({
+ uid: 'contact-1',
+ displayName: 'Jane Doe',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ emails: [{ value: 'jane.doe@example.test', type: 'work', primary: false }],
+ phones: [{ value: '+1 555 123 4567', type: 'mobile' }],
+ organization: 'Example Corp',
+ notes: 'Line one\nLine two',
+ photoData: null,
+ });
+ expect(first.additionalFields.map(({ kind, label, value }) => ({ kind, label, value })))
+ .toEqual([
+ {
+ kind: 'postal-address',
+ label: 'Office',
+ value: {
+ poBox: 'PO Box 1',
+ extendedAddress: 'Floor 2',
+ street: '123 Main St\nUnit 4',
+ locality: 'Vancouver',
+ region: 'BC',
+ postalCode: 'V1V 1V1',
+ country: 'Canada',
+ },
+ },
+ { kind: 'url', label: 'HOME', value: 'https://example.test/jane' },
+ {
+ kind: 'im',
+ label: 'Signal',
+ value: { protocol: 'signal', handle: 'jane_handle' },
+ },
+ { kind: 'birthday', label: 'Birthday', value: '1990-02-03' },
+ { kind: 'anniversary', label: 'Anniversary', value: '2020-04-05' },
+ { kind: 'date', label: 'Graduation', value: '2012-06-07' },
+ { kind: 'role', label: 'Role', value: 'Engineering Lead' },
+ { kind: 'title', label: 'Title', value: 'Principal Engineer' },
+ { kind: 'nickname', label: 'Nickname', value: 'JD' },
+ {
+ kind: 'geo',
+ label: 'Location',
+ value: { latitude: 49.2827, longitude: -123.1207 },
+ },
+ { kind: 'custom-text', label: 'Favorite color', value: 'Blue' },
+ ]);
+ expect(new Set(first.additionalFields.map(field => field.id)).size)
+ .toBe(first.additionalFields.length);
+ expect(first.additionalFields.map(field => field.id))
+ .toEqual(second.additionalFields.map(field => field.id));
+ expect(first.additionalFields.every(field => (
+ typeof field.vcard.name === 'string'
+ && Array.isArray(field.vcard.params)
+ && Object.hasOwn(field.vcard, 'group')
+ ))).toBe(true);
+ });
+
+ it('serializes and round-trips a label-less typed Additional field', () => {
+ const contact = {
+ uid: 'labelless-birthday',
+ displayName: 'No Label',
+ firstName: null,
+ lastName: null,
+ emails: [],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [
+ { id: 'field-1', kind: 'birthday', label: '', value: '1985-07-12' },
+ ],
+ };
+
+ // A canonical/typed field maps to a real vCard property (BDAY) and only needs a
+ // label for X-ABLABEL grouping. A blank label must not throw on serialization.
+ expect(() => localContactHash(contact)).not.toThrow();
+ const overlaid = overlayContactOnVCard({ version: '3.0', properties: [] }, contact);
+ const serialized = serializeVCardDocument(overlaid);
+ expect(overlaid.properties.some(property => property.name === 'BDAY')).toBe(true);
+ expect(serialized).not.toMatch(/X-ABLABEL/i);
+
+ const reparsed = contactFromVCardDocument(parseVCardDocument(serialized));
+ expect(reparsed.additionalFields).toEqual([
+ expect.objectContaining({ kind: 'birthday', value: '1985-07-12' }),
+ ]);
+ });
+
+ it('still requires a label for a custom-text Additional field', () => {
+ const contact = {
+ uid: 'labelless-custom',
+ displayName: 'No Label',
+ emails: [],
+ phones: [],
+ additionalFields: [
+ { id: 'field-1', kind: 'custom-text', label: '', value: 'Blue' },
+ ],
+ };
+ expect(() => overlayContactOnVCard({ version: '3.0', properties: [] }, contact))
+ .toThrow(/custom text field requires a label/);
+ });
+
+ it('keeps URL photos opaque and never fetches them', () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCalled = false;
+ globalThis.fetch = () => {
+ fetchCalled = true;
+ throw new Error('unexpected fetch');
+ };
+
+ try {
+ const document = parseVCardDocument(vcard([
+ 'UID:url-photo',
+ 'FN:URL Photo',
+ 'PHOTO;VALUE=URI:https://images.example.test/photo.jpg',
+ ]));
+ const contact = contactFromVCardDocument(document);
+
+ expect(contact.photoData).toBeNull();
+ expect(document.properties.find(property => property.name === 'PHOTO').rawValue)
+ .toBe('https://images.example.test/photo.jpg');
+ expect(fetchCalled).toBe(false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it('retains explicit non-HTTP URI photos through projection and explicit-null overlay', () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCalled = false;
+ globalThis.fetch = () => {
+ fetchCalled = true;
+ throw new Error('unexpected fetch');
+ };
+
+ try {
+ const document = parseVCardDocument(vcard([
+ 'UID:cid-photo',
+ 'FN:CID Photo',
+ 'PHOTO;VALUE=URI:cid:photo1',
+ ]));
+ const photo = document.properties.find(property => property.name === 'PHOTO');
+ const contact = contactFromVCardDocument(document);
+ const overlaid = overlayContactOnVCard(document, { ...contact, photoData: null });
+
+ expect(photo).toEqual({
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'VALUE', values: ['URI'] }],
+ rawValue: 'cid:photo1',
+ });
+ expect(contact.photoData).toBeNull();
+ expect(overlaid.properties.find(property => property.name === 'PHOTO')).toEqual(photo);
+ expect(fetchCalled).toBe(false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it('treats an unparameterized vCard 4.0 PHOTO as an opaque URI', () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCalled = false;
+ globalThis.fetch = () => {
+ fetchCalled = true;
+ throw new Error('unexpected fetch');
+ };
+
+ try {
+ const document = parseVCardDocument([
+ 'BEGIN:VCARD',
+ 'VERSION:4.0',
+ 'PHOTO:cid:photo1',
+ 'UID:cid-photo-default',
+ 'FN:CID Photo Default',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'));
+ const photo = document.properties.find(property => property.name === 'PHOTO');
+ const contact = contactFromVCardDocument(document);
+ const overlaid = overlayContactOnVCard(document, { ...contact, photoData: null });
+ const reparsed = parseVCardDocument(serializeVCardDocument(document));
+
+ expect(photo).toEqual({
+ group: null,
+ name: 'PHOTO',
+ params: [],
+ rawValue: 'cid:photo1',
+ });
+ expect(contact.photoData).toBeNull();
+ expect(overlaid.properties.find(property => property.name === 'PHOTO')).toEqual(photo);
+ expect(reparsed).toEqual(document);
+ expect(semanticVCardHash(reparsed)).toBe(semanticVCardHash(document));
+ expect(fetchCalled).toBe(false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it('distinguishes an absent photo from invalid embedded photo data', () => {
+ const withoutPhoto = parseVCardDocument(vcard(['UID:no-photo', 'FN:No Photo']));
+
+ expect(contactFromVCardDocument(withoutPhoto).photoData).toBeNull();
+ expect(() => parseVCardDocument(vcard([
+ 'UID:bad-photo',
+ 'FN:Bad Photo',
+ 'PHOTO;ENCODING=b;TYPE=JPEG:not-valid-***',
+ ]))).toThrow(/invalid base64/);
+ });
+
+ it.each([
+ ['vCard 3.0 JPEG', 'PHOTO;ENCODING=b;TYPE=JPEG:AQID', '3.0',
+ 'data:image/jpeg;base64,AQID'],
+ ['vCard 3.0 PNG', 'PHOTO;ENCODING=b;TYPE=PNG:AQID', '3.0',
+ 'data:image/png;base64,AQID'],
+ ['vCard 3.0 GIF', 'PHOTO;ENCODING=b;TYPE=GIF:AQID', '3.0',
+ 'data:image/gif;base64,AQID'],
+ ['vCard 3.0 WebP', 'PHOTO;ENCODING=b;TYPE=WEBP:AQID', '3.0',
+ 'data:image/webp;base64,AQID'],
+ ['vCard 4.0 JPEG data URI', 'PHOTO:data:image/jpeg;base64,AQID', '4.0',
+ 'data:image/jpeg;base64,AQID'],
+ ['vCard 4.0 PNG data URI', 'PHOTO:data:image/png;base64,AQID', '4.0',
+ 'data:image/png;base64,AQID'],
+ ['vCard 4.0 GIF data URI', 'PHOTO:data:image/gif;base64,AQID', '4.0',
+ 'data:image/gif;base64,AQID'],
+ ['vCard 4.0 WebP data URI', 'PHOTO:data:image/webp;base64,AQID', '4.0',
+ 'data:image/webp;base64,AQID'],
+ ])('projects supported %s photos', (_case, photo, version, expected) => {
+ const document = parseVCardDocument(vcard([photo], version));
+
+ expect(contactFromVCardDocument(document).photoData).toBe(expected);
+ });
+
+ it.each([
+ ['HTML data URI', 'PHOTO:data:text/html;base64,PGgxPk5vdCBhIHBob3RvPC9oMT4=', '4.0'],
+ ['BMP binary', 'PHOTO;ENCODING=b;TYPE=BMP:AQID', '3.0'],
+ ['unknown binary type', 'PHOTO;ENCODING=b;TYPE=PDF:AQID', '3.0'],
+ ])('keeps unsupported %s PHOTO media opaque and unrendered', (_case, photo, version) => {
+ const document = parseVCardDocument(vcard([photo], version));
+ const contact = contactFromVCardDocument(document);
+
+ expect(contact.photoData).toBeNull();
+ expect(overlayContactOnVCard(document, contact)).toEqual(document);
+ });
+});
+
+describe('MailFlow contact overlay', () => {
+ const losslessDocument = () => parseVCardDocument(vcard([
+ 'UID:lossless-contact',
+ 'FN:Jane Doe',
+ 'N:Doe;Jane;Middle;Dr.;Jr.',
+ 'item1.EMAIL;TYPE=WORK;PREF=1;X-VENDOR=keep:jane@example.test',
+ 'item1.X-ABLabel:Work inbox',
+ 'ORG:Example Corp;Research Division',
+ 'item2.URL;TYPE=HOME;X-VENDOR=keep:https://example.test/jane',
+ 'item2.X-ABLabel:Portfolio',
+ 'GEO:not-a-coordinate',
+ ]));
+
+ it('preserves every retained property on an unchanged overlay', () => {
+ const document = losslessDocument();
+ const contact = contactFromVCardDocument(document);
+
+ expect(overlayContactOnVCard(document, contact)).toEqual(document);
+ });
+
+ it('changes only the matched owned email value', () => {
+ const document = losslessDocument();
+ const contact = contactFromVCardDocument(document);
+ contact.emails[0].value = 'updated@example.test';
+ const expected = structuredClone(document);
+ expected.properties.find(property => property.name === 'EMAIL').rawValue
+ = 'updated@example.test';
+
+ expect(overlayContactOnVCard(document, contact)).toEqual(expected);
+ });
+
+ it.each([
+ ['3.0', 'TYPE=WORK;X-VENDOR=first', 'TYPE=HOME,PREF;X-VENDOR=second'],
+ ['4.0', 'TYPE=WORK;PREF=2;X-VENDOR=first', 'TYPE=HOME;PREF=1;X-VENDOR=second'],
+ ])('round-trips email primary edits using vCard %s preference semantics', (
+ version,
+ firstParams,
+ secondParams,
+ ) => {
+ const document = parseVCardDocument(vcard([
+ `item1.EMAIL;${firstParams}:first@example.test`,
+ 'item1.X-ABLabel:First inbox',
+ `item2.EMAIL;${secondParams}:second@example.test`,
+ 'item2.X-ABLabel:Second inbox',
+ ], version));
+ const fallback = contactFromVCardDocument(parseVCardDocument(vcard([
+ 'EMAIL:first@example.test',
+ 'EMAIL:second@example.test',
+ ], version)));
+ const contact = contactFromVCardDocument(document);
+ const edited = structuredClone(contact);
+ edited.emails[0].primary = true;
+ edited.emails[1].primary = false;
+
+ const overlaid = overlayContactOnVCard(document, edited);
+ const serialized = serializeVCardDocument(overlaid);
+ const projected = contactFromVCardDocument(parseVCardDocument(serialized));
+ const emailProperties = overlaid.properties.filter(property => property.name === 'EMAIL');
+
+ expect(fallback.emails.map(email => email.primary)).toEqual([false, false]);
+ expect(contact.emails.map(email => email.primary)).toEqual([false, true]);
+ expect(localContactHash(edited)).not.toBe(localContactHash(contact));
+ expect(serialized).not.toBe(serializeVCardDocument(document));
+ expect(projected.emails.map(email => email.primary)).toEqual([true, false]);
+ expect(emailProperties.map(property => property.group)).toEqual(['item1', 'item2']);
+ expect(emailProperties.map(property => parameterValuesForTest(property, 'X-VENDOR')))
+ .toEqual([['first'], ['second']]);
+ expect(overlaid.properties.filter(property => property.name === 'X-ABLABEL'))
+ .toEqual(document.properties.filter(property => property.name === 'X-ABLABEL'));
+ if (version === '4.0') {
+ expect(emailProperties.map(property => parameterValuesForTest(property, 'PREF')))
+ .toEqual([['1'], []]);
+ } else {
+ expect(emailProperties.map(property => parameterValuesForTest(property, 'TYPE')))
+ .toEqual([['WORK', 'PREF'], ['HOME']]);
+ }
+ });
+
+ it.each([
+ ['3.0', 'TYPE=WORK,PREF', 'TYPE=HOME'],
+ ['4.0', 'TYPE=WORK;PREF=1', 'TYPE=HOME'],
+ ])('round-trips an all-false email primary edit using vCard %s', (
+ version,
+ firstParams,
+ secondParams,
+ ) => {
+ const document = parseVCardDocument(vcard([
+ 'UID:all-false-primary',
+ 'FN:All False Primary',
+ `EMAIL;${firstParams}:first@example.test`,
+ `EMAIL;${secondParams}:second@example.test`,
+ ], version));
+ const contact = contactFromVCardDocument(document);
+ const edited = structuredClone(contact);
+ edited.emails.forEach(email => { email.primary = false; });
+
+ const overlaid = overlayContactOnVCard(document, edited);
+ const serialized = serializeVCardDocument(overlaid);
+ const reparsed = parseVCardDocument(serialized);
+ const projected = contactFromVCardDocument(reparsed);
+ const emailProperties = overlaid.properties.filter(property => property.name === 'EMAIL');
+
+ expect(contact.emails.map(email => email.primary)).toEqual([true, false]);
+ expect(localContactHash(edited)).not.toBe(localContactHash(contact));
+ expect(projected.emails.map(email => email.primary)).toEqual([false, false]);
+ expect(localContactHash(projected)).toBe(localContactHash(edited));
+ expect(overlayContactOnVCard(reparsed, projected)).toEqual(reparsed);
+ expect(emailProperties.flatMap(property => parameterValuesForTest(property, 'PREF')))
+ .toEqual([]);
+ expect(emailProperties.flatMap(property => parameterValuesForTest(property, 'TYPE')))
+ .not.toContain('PREF');
+ });
+
+ it('clears every ranked vCard 4 preference without rewriting an unchanged card', () => {
+ const document = parseVCardDocument(vcard([
+ 'EMAIL;PREF=1:first@example.test',
+ 'EMAIL;PREF=2:second@example.test',
+ ], '4.0'));
+ const contact = contactFromVCardDocument(document);
+ const originalBytes = serializeVCardDocument(document);
+ const edited = structuredClone(contact);
+ edited.emails.forEach(email => { email.primary = false; });
+
+ const overlaid = overlayContactOnVCard(document, edited);
+ const reparsedDocument = parseVCardDocument(serializeVCardDocument(overlaid));
+ const reparsed = contactFromVCardDocument(reparsedDocument);
+
+ expect(serializeVCardDocument(overlayContactOnVCard(document, contact)))
+ .toBe(originalBytes);
+ expect(overlaid.properties.filter(property => property.name === 'EMAIL')
+ .flatMap(property => parameterValuesForTest(property, 'PREF'))).toEqual([]);
+ expect(reparsed.emails.map(email => email.primary)).toEqual([false, false]);
+ expect(localContactHash(reparsed)).toBe(localContactHash(edited));
+ });
+
+ it('does not append missing UID or FN properties when projected values are empty', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-REMOTE:opaque',
+ ]));
+
+ expect(overlayContactOnVCard(document, contactFromVCardDocument(document)))
+ .toEqual(document);
+ });
+
+ it.each([
+ ['missing', ['N:Doe;Jane;;;']],
+ ['blank', ['FN:', 'N:Doe;Jane;;;']],
+ ])('preserves an unchanged %s retained FN', (_case, lines) => {
+ const document = parseVCardDocument(vcard([
+ 'UID:retained-fn',
+ ...lines,
+ 'EMAIL:jane@example.test',
+ ]));
+ const contact = contactFromVCardDocument(document);
+
+ const overlaid = overlayContactOnVCard(document, contact);
+
+ expect(contact.displayName).toBeNull();
+ expect(overlaid).toEqual(document);
+ expect(localContactHash(contactFromVCardDocument(overlaid)))
+ .toBe(localContactHash(contact));
+ });
+
+ it('updates owned fields, drops protected metadata, and preserves an unknown grouped property', () => {
+ const document = parseVCardDocument(vcard([
+ 'PRODID:-//Remote server//EN',
+ 'REV:20260101T000000Z',
+ 'UID:old-uid',
+ 'FN:Old Name',
+ 'N:Name;Old;;;',
+ 'EMAIL;TYPE=HOME:old@example.test',
+ 'NOTE:Old note',
+ 'item8.X-REMOTE-OPAQUE;X-ORDER="one;two":raw\\,opaque',
+ 'PHOTO;VALUE=URI:https://images.example.test/remote.jpg',
+ ], '4.0'));
+ const originalUnknown = structuredClone(
+ document.properties.find(property => property.name === 'X-REMOTE-OPAQUE'),
+ );
+ const contact = contactFromVCardDocument(document);
+ const overlaid = overlayContactOnVCard(document, {
+ ...contact,
+ uid: 'new-uid',
+ displayName: 'New Name',
+ firstName: 'New',
+ lastName: 'Name',
+ emails: [{ value: 'new@example.test', type: 'work', primary: true }],
+ notes: 'New note',
+ });
+ const projected = contactFromVCardDocument(overlaid);
+
+ expect(overlaid.version).toBe('4.0');
+ expect(overlaid.properties.some(property => property.name === 'PRODID')).toBe(false);
+ expect(overlaid.properties.some(property => property.name === 'REV')).toBe(false);
+ expect(overlaid.properties.find(property => property.name === 'X-REMOTE-OPAQUE'))
+ .toEqual(originalUnknown);
+ expect(overlaid.properties.find(property => property.name === 'PHOTO').rawValue)
+ .toBe('https://images.example.test/remote.jpg');
+ expect(projected).toMatchObject({
+ uid: 'new-uid',
+ displayName: 'New Name',
+ firstName: 'New',
+ lastName: 'Name',
+ emails: [{ value: 'new@example.test', type: 'work', primary: true }],
+ notes: 'New note',
+ photoData: null,
+ });
+ expect(document.properties.some(property => property.name === 'PRODID')).toBe(true);
+ });
+
+ it('treats null embedded photo data as explicit removal', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:photo-remove',
+ 'FN:Photo Remove',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ ]));
+ const contact = contactFromVCardDocument(document);
+
+ expect(contact.photoData).toBe('data:image/png;base64,AQID');
+ expect(overlayContactOnVCard(document, { ...contact, photoData: null }).properties)
+ .not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'PHOTO' })]));
+ });
+
+ it('removes legacy unparameterized base64 photos on explicit null', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:legacy-photo-remove',
+ 'FN:Legacy Photo Remove',
+ 'PHOTO:AQID',
+ ]));
+ const contact = contactFromVCardDocument(document);
+
+ expect(contact.photoData).toBe('data:image/jpeg;base64,AQID');
+ expect(overlayContactOnVCard(document, { ...contact, photoData: null }).properties)
+ .not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'PHOTO' })]));
+ });
+
+ it.each([
+ ['JPEG', 'data:image/jpeg;charset=binary,%FF%D8%FF', '/9j/'],
+ ['PNG', 'data:image/png;charset=binary,%89PNG%0D%0A', 'iVBORw0K'],
+ ])('canonicalizes owned vCard 3.0 percent-encoded %s photos', (
+ type,
+ photoData,
+ payload,
+ ) => {
+ const document = parseVCardDocument(vcard([
+ 'UID:data-uri-photo',
+ 'FN:Data URI Photo',
+ ]));
+ const contact = contactFromVCardDocument(document);
+
+ const overlaid = overlayContactOnVCard(document, { ...contact, photoData });
+ const serialized = serializeVCardDocument(overlaid);
+ const projected = contactFromVCardDocument(parseVCardDocument(serialized));
+ const photo = overlaid.properties.find(property => property.name === 'PHOTO');
+
+ expect(photo).toEqual({
+ group: null,
+ name: 'PHOTO',
+ params: [
+ { name: 'ENCODING', values: ['b'] },
+ { name: 'TYPE', values: [type] },
+ ],
+ rawValue: payload,
+ });
+ expect(serialized).toContain(`PHOTO;ENCODING=b;TYPE=${type}:${payload}\r\n`);
+ expect(localContactHash(projected)).toBe(localContactHash({ ...contact, photoData }));
+ });
+
+ it('rewrites an edited PHOTO in place without dropping retained metadata', () => {
+ const document = parseVCardDocument(vcard([
+ 'item7.PHOTO;ENCODING=b;TYPE=PNG;X-VENDOR=keep:AQID',
+ 'item7.X-ABLabel:Avatar',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ const photoData = 'data:image/png;base64,BAUG';
+
+ const overlaid = overlayContactOnVCard(document, { ...contact, photoData });
+ const photo = overlaid.properties.find(property => property.name === 'PHOTO');
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+
+ expect(photo.group).toBe('item7');
+ expect(parameterValuesForTest(photo, 'X-VENDOR')).toEqual(['keep']);
+ expect(overlaid.properties).toContainEqual({
+ group: 'item7',
+ name: 'X-ABLABEL',
+ params: [],
+ rawValue: 'Avatar',
+ });
+ expect(reparsed.photoData).toBe(photoData);
+ expect(localContactHash(reparsed)).toBe(localContactHash({ ...contact, photoData }));
+ });
+
+ it.each([
+ ['HTML', 'data:text/html;base64,PGgxPk5vdCBhIHBob3RvPC9oMT4='],
+ ['BMP', 'data:image/bmp;base64,AQID'],
+ ['SVG', 'data:image/svg+xml, '],
+ ])('rejects unsupported outbound owned %s photos', (_case, photoData) => {
+ const document = parseVCardDocument(vcard([
+ 'UID:unsupported-outbound-photo',
+ 'FN:Unsupported Outbound Photo',
+ ]));
+ const contact = contactFromVCardDocument(document);
+
+ expect(() => overlayContactOnVCard(document, { ...contact, photoData }))
+ .toThrow(/unsupported PHOTO MIME type/);
+ });
+
+ it('serializes new custom text as a grouped property without item-group collisions', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.X-REMOTE:first',
+ 'item3.X-REMOTE:third',
+ ]));
+ const overlaid = overlayContactOnVCard(document, {
+ uid: 'custom-contact',
+ displayName: 'Custom Contact',
+ emails: [],
+ phones: [],
+ additionalFields: [{
+ id: 'custom-stable-id',
+ kind: 'custom-text',
+ label: 'Favorite color',
+ value: 'Blue',
+ }],
+ });
+ const custom = overlaid.properties.find(property => property.name === 'X-MAILFLOW-CUSTOM');
+ const label = overlaid.properties.find(property => (
+ property.name === 'X-ABLABEL' && property.group === custom.group
+ ));
+
+ expect(custom).toEqual({
+ group: 'item2',
+ name: 'X-MAILFLOW-CUSTOM',
+ params: [{ name: 'X-MAILFLOW-ID', values: ['custom-stable-id'] }],
+ rawValue: 'Blue',
+ });
+ expect(label).toEqual({
+ group: 'item2',
+ name: 'X-ABLABEL',
+ params: [],
+ rawValue: 'Favorite color',
+ });
+ expect(serializeVCardDocument(overlaid)).toContain(
+ 'item2.X-MAILFLOW-CUSTOM;X-MAILFLOW-ID=custom-stable-id:Blue\r\n'
+ + 'item2.X-ABLABEL:Favorite color\r\n',
+ );
+ });
+
+ it('rejects new custom text without an explicit label before hashing or overlay', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:unlabeled-custom',
+ 'FN:Unlabeled Custom',
+ ]));
+ const contact = {
+ ...contactFromVCardDocument(document),
+ additionalFields: [{
+ id: 'unlabeled-custom-field',
+ kind: 'custom-text',
+ label: '',
+ value: 'Blue',
+ }],
+ };
+
+ expect(() => localContactHash(contact))
+ .toThrow('MailFlow custom text field requires a label');
+ expect(() => overlayContactOnVCard(document, contact))
+ .toThrow('MailFlow custom text field requires a label');
+ });
+
+ it('allocates new custom text away from stale occupied metadata groups', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:stale-custom-group',
+ 'FN:Stale Custom Group',
+ 'item1.X-REMOTE:opaque',
+ 'item1.X-ABLABEL:Remote label',
+ ]));
+ const contact = {
+ ...contactFromVCardDocument(document),
+ additionalFields: [{
+ id: 'new-custom-field',
+ kind: 'custom-text',
+ label: 'Favorite color',
+ value: 'Blue',
+ vcard: {
+ group: 'item1',
+ name: 'X-MAILFLOW-CUSTOM',
+ params: [{ name: 'X-VENDOR', values: ['keep'] }],
+ },
+ }],
+ };
+
+ const overlaid = overlayContactOnVCard(document, contact);
+ const serialized = serializeVCardDocument(overlaid);
+ const projected = contactFromVCardDocument(parseVCardDocument(serialized));
+ const custom = overlaid.properties.find(property => property.name === 'X-MAILFLOW-CUSTOM');
+
+ expect(custom).toEqual({
+ group: 'item2',
+ name: 'X-MAILFLOW-CUSTOM',
+ params: [
+ { name: 'X-VENDOR', values: ['keep'] },
+ { name: 'X-MAILFLOW-ID', values: ['new-custom-field'] },
+ ],
+ rawValue: 'Blue',
+ });
+ expect(overlaid.properties).toEqual(expect.arrayContaining([
+ {
+ group: 'item1',
+ name: 'X-ABLABEL',
+ params: [],
+ rawValue: 'Remote label',
+ },
+ {
+ group: 'item2',
+ name: 'X-ABLABEL',
+ params: [],
+ rawValue: 'Favorite color',
+ },
+ ]));
+ expect(projected.additionalFields).toEqual([
+ expect.objectContaining({
+ kind: 'custom-text',
+ label: 'Favorite color',
+ value: 'Blue',
+ }),
+ ]);
+ });
+
+ it.each([
+ ['3.0', '49.1;-123.1'],
+ ['4.0', 'geo:49.1,-123.1'],
+ ])('serializes GEO values for vCard %s', (version, rawValue) => {
+ const document = parseVCardDocument(vcard([], version));
+ const overlaid = overlayContactOnVCard(document, {
+ uid: `geo-${version}`,
+ displayName: 'Geo Contact',
+ emails: [],
+ phones: [],
+ additionalFields: [{
+ id: 'geo-field',
+ kind: 'geo',
+ label: 'Location',
+ value: { latitude: 49.1, longitude: -123.1 },
+ }],
+ });
+ const serialized = serializeVCardDocument(overlaid);
+ const geo = overlaid.properties.find(property => property.name === 'GEO');
+
+ expect(geo.rawValue).toBe(rawValue);
+ expect(serialized).toContain(`GEO;X-MAILFLOW-ID=geo-field:${rawValue}\r\n`);
+ expect(contactFromVCardDocument(parseVCardDocument(serialized)).additionalFields[0].value)
+ .toEqual({ latitude: 49.1, longitude: -123.1 });
+ });
+
+ it('matches Additional groups case-insensitively while preserving retained spelling', () => {
+ const mixedCase = parseVCardDocument(vcard([
+ 'Item1.URL:https://first.example.test',
+ 'item1.URL:https://second.example.test',
+ 'ITEM1.X-ABLabel:Website',
+ ]));
+ const lowerCase = parseVCardDocument(vcard([
+ 'item1.URL:https://first.example.test',
+ 'item1.URL:https://second.example.test',
+ 'item1.X-ABLabel:Website',
+ ]));
+ const contact = contactFromVCardDocument(mixedCase);
+ const lowerContact = contactFromVCardDocument(lowerCase);
+
+ expect(contact.additionalFields.map(field => field.label)).toEqual(['Website', 'Website']);
+ expect(contact.additionalFields.map(field => field.id))
+ .toEqual(lowerContact.additionalFields.map(field => field.id));
+
+ const overlaid = overlayContactOnVCard(mixedCase, {
+ ...contact,
+ additionalFields: contact.additionalFields.map(field => ({
+ ...field,
+ label: 'Portfolio',
+ })),
+ });
+ const urls = overlaid.properties.filter(property => property.name === 'URL');
+ const labels = overlaid.properties.filter(property => property.name === 'X-ABLABEL');
+
+ expect(urls.map(property => property.group)).toEqual(['Item1', 'item1']);
+ expect(labels).toEqual([{
+ group: 'Item1',
+ name: 'X-ABLABEL',
+ params: [],
+ rawValue: 'Portfolio',
+ }]);
+ expect(contactFromVCardDocument(overlaid).additionalFields.map(field => field.label))
+ .toEqual(['Portfolio', 'Portfolio']);
+ });
+
+ it('reorders retained Additional rows in owned target order', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.X-ABLabel:One',
+ 'item2.URL:https://two.example.test',
+ 'item2.X-ABLabel:Two',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ const edited = {
+ ...contact,
+ additionalFields: [...contact.additionalFields].reverse(),
+ };
+
+ const projected = contactFromVCardDocument(
+ parseVCardDocument(serializeVCardDocument(overlayContactOnVCard(document, edited))),
+ );
+
+ expect(projected.additionalFields.map(({ value, label }) => ({ value, label }))).toEqual([
+ { value: 'https://two.example.test', label: 'Two' },
+ { value: 'https://one.example.test', label: 'One' },
+ ]);
+ expect(localContactHash(projected)).toBe(localContactHash(edited));
+ });
+
+ it('splits one retained row before changing a shared-group label', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.URL:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ const edited = structuredClone(contact);
+ edited.additionalFields[1].label = 'Second';
+
+ const overlaid = overlayContactOnVCard(document, edited);
+ const projected = contactFromVCardDocument(
+ parseVCardDocument(serializeVCardDocument(overlaid)),
+ );
+ const urls = overlaid.properties.filter(property => property.name === 'URL');
+
+ expect(projected.additionalFields.map(field => field.label)).toEqual(['Shared', 'Second']);
+ expect(urls[0].group).toBe('item1');
+ expect(urls[1].group).not.toBe('item1');
+ });
+
+ it('accepts an empty standard Additional label and drops the custom label', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://example.test',
+ 'item1.X-ABLabel:Website',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ contact.additionalFields[0].label = '';
+
+ // A typed field maps to a real property (URL) and only needs a label for
+ // X-ABLABEL grouping, so a blank label clears the custom label instead of failing.
+ expect(() => localContactHash(contact)).not.toThrow();
+ const overlaid = overlayContactOnVCard(document, contact);
+ expect(overlaid.properties.some(property => property.name === 'X-ABLABEL')).toBe(false);
+ const projected = contactFromVCardDocument(
+ parseVCardDocument(serializeVCardDocument(overlaid)),
+ );
+ expect(projected.additionalFields).toEqual([
+ expect.objectContaining({ kind: 'url', label: 'URL', value: 'https://example.test' }),
+ ]);
+ });
+
+ it('projects current Additional kinds and labels after overlay', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://existing.example.test',
+ 'item1.X-ABLabel:Website',
+ 'URL:https://change.example.test',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ contact.additionalFields[1] = {
+ ...contact.additionalFields[1],
+ kind: 'role',
+ label: 'Portfolio',
+ value: 'Editor',
+ };
+ contact.additionalFields.push({
+ id: 'new-nickname',
+ kind: 'nickname',
+ label: 'Alias',
+ value: 'JD',
+ });
+
+ const projected = contactFromVCardDocument(overlayContactOnVCard(document, contact));
+
+ expect(projected.additionalFields.map(({ kind, label, value, vcard: metadata }) => ({
+ kind,
+ label,
+ value,
+ name: metadata.name,
+ }))).toEqual([
+ { kind: 'url', label: 'Website', value: 'https://existing.example.test', name: 'URL' },
+ { kind: 'role', label: 'Portfolio', value: 'Editor', name: 'ROLE' },
+ { kind: 'nickname', label: 'Alias', value: 'JD', name: 'NICKNAME' },
+ ]);
+ });
+
+ it('serializes new URL and IMPP Additional values as URI syntax', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:uri-additional',
+ 'FN:URI Additional',
+ ], '4.0'));
+ const contact = {
+ ...contactFromVCardDocument(document),
+ additionalFields: [
+ {
+ id: 'new-url',
+ kind: 'url',
+ label: 'Portfolio',
+ value: 'https://example.test/a,b;c?x=1,2',
+ },
+ {
+ id: 'new-impp',
+ kind: 'im',
+ label: 'Chat',
+ value: { protocol: 'xmpp', handle: 'user,name;tag@example.test' },
+ },
+ ],
+ };
+
+ const serialized = serializeVCardDocument(overlayContactOnVCard(document, contact));
+ const projected = contactFromVCardDocument(parseVCardDocument(serialized));
+
+ expect(serialized).toContain(
+ 'URL;X-MAILFLOW-ID=new-url:https://example.test/a,b;c?x=1,2\r\n',
+ );
+ expect(serialized).toContain(
+ 'IMPP;X-MAILFLOW-ID=new-impp:xmpp:user,name;tag@example.test\r\n',
+ );
+ expect(projected.additionalFields.map(field => field.value)).toEqual([
+ 'https://example.test/a,b;c?x=1,2',
+ { protocol: 'xmpp', handle: 'user,name;tag@example.test' },
+ ]);
+ });
+});
+
+describe('semantic vCard hash', () => {
+ it('canonicalizes parameter order and repeated/list-equivalent forms without mutating syntax', () => {
+ const ordered = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK;PREF=1:jane@example.test',
+ ]));
+ const reordered = parseVCardDocument(vcard([
+ 'EMAIL;PREF=1;TYPE=WORK:jane@example.test',
+ ]));
+ const repeated = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK;TYPE=INTERNET:jane@example.test',
+ ]));
+ const listed = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK,INTERNET:jane@example.test',
+ ]));
+ const changed = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK;PREF=2:jane@example.test',
+ ]));
+ const retainedSerializations = [ordered, reordered, repeated, listed]
+ .map(serializeVCardDocument);
+
+ expect(semanticVCardHash(reordered)).toBe(semanticVCardHash(ordered));
+ expect(semanticVCardHash(listed)).toBe(semanticVCardHash(repeated));
+ expect(semanticVCardHash(changed)).not.toBe(semanticVCardHash(ordered));
+ expect([ordered, reordered, repeated, listed].map(serializeVCardDocument))
+ .toEqual(retainedSerializations);
+ expect(retainedSerializations[0]).toContain('TYPE=WORK;PREF=1');
+ expect(retainedSerializations[1]).toContain('PREF=1;TYPE=WORK');
+ expect(retainedSerializations[2]).toContain('TYPE=WORK;TYPE=INTERNET');
+ expect(retainedSerializations[3]).toContain('TYPE=WORK,INTERNET');
+ });
+
+ it('canonicalizes TYPE value order without sorting ordered parameter values', () => {
+ const first = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK,INTERNET;X-ORDER=one,two:jane@example.test',
+ ]));
+ const typeReordered = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=INTERNET,WORK;X-ORDER=one,two:jane@example.test',
+ ]));
+ const orderedValueChanged = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK,INTERNET;X-ORDER=two,one:jane@example.test',
+ ]));
+
+ expect(semanticVCardHash(typeReordered)).toBe(semanticVCardHash(first));
+ expect(semanticVCardHash(orderedValueChanged)).not.toBe(semanticVCardHash(first));
+ });
+
+ it('canonicalizes complete property-group alpha-renaming', () => {
+ const first = parseVCardDocument(vcard([
+ 'item1.URL:https://example.test',
+ 'item1.X-ABLabel:Website',
+ ]));
+ const renamed = parseVCardDocument(vcard([
+ 'item7.URL:https://example.test',
+ 'item7.X-ABLabel:Website',
+ ]));
+ const relationChanged = parseVCardDocument(vcard([
+ 'item7.URL:https://example.test',
+ 'item8.X-ABLabel:Website',
+ ]));
+
+ expect(semanticVCardHash(renamed)).toBe(semanticVCardHash(first));
+ expect(semanticVCardHash(relationChanged)).not.toBe(semanticVCardHash(first));
+ });
+
+ it('ignores formatting-only rewrites and protected metadata', () => {
+ const first = parseVCardDocument([
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'PRODID:server-one',
+ 'REV:20260101T000000Z',
+ 'FN:Jane Doe',
+ 'EMAIL;TYPE="WORK,INTERNET":jane@example.test',
+ 'NOTE:Line one\\NLine two',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'));
+ const second = parseVCardDocument([
+ 'begin:vcard',
+ 'version:3.0',
+ 'prodid:server-two',
+ 'rev:20261231T235959Z',
+ 'fn:Jane Doe',
+ 'email;type=work,internet:jane@example.test',
+ 'note:Line one\\nLine two',
+ 'end:vcard',
+ '',
+ ].join('\n'));
+
+ expect(semanticVCardHash(first)).toMatch(/^[a-f0-9]{64}$/);
+ expect(semanticVCardHash(second)).toBe(semanticVCardHash(first));
+ });
+
+ it('changes for unknown-property, photo-only, and photo-removal changes', () => {
+ const base = parseVCardDocument(vcard([
+ 'UID:semantic',
+ 'FN:Semantic',
+ 'X-REMOTE:value-a',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ ]));
+ const unknownChanged = parseVCardDocument(vcard([
+ 'UID:semantic',
+ 'FN:Semantic',
+ 'X-REMOTE:value-b',
+ 'PHOTO;ENCODING=b;TYPE=PNG:AQID',
+ ]));
+ const photoChanged = parseVCardDocument(vcard([
+ 'UID:semantic',
+ 'FN:Semantic',
+ 'X-REMOTE:value-a',
+ 'PHOTO;ENCODING=b;TYPE=PNG:BAUG',
+ ]));
+ const photoRemoved = parseVCardDocument(vcard([
+ 'UID:semantic',
+ 'FN:Semantic',
+ 'X-REMOTE:value-a',
+ ]));
+
+ expect(semanticVCardHash(unknownChanged)).not.toBe(semanticVCardHash(base));
+ expect(semanticVCardHash(photoChanged)).not.toBe(semanticVCardHash(base));
+ expect(semanticVCardHash(photoRemoved)).not.toBe(semanticVCardHash(base));
+ });
+
+ it('canonicalizes base64 data-URI metadata and decoded bytes', () => {
+ const first = parseVCardDocument(vcard([
+ 'PHOTO:data:IMAGE/PNG;BASE64,AQID',
+ ], '4.0'));
+ const second = parseVCardDocument(vcard([
+ 'PHOTO:data:image/png;base64,AQ ID',
+ ], '4.0'));
+
+ expect(semanticVCardHash(second)).toBe(semanticVCardHash(first));
+ });
+
+ it('canonicalizes percent-encoded PHOTO bytes independent of hex case', () => {
+ const first = parseVCardDocument(vcard([
+ 'PHOTO:data:image/png,%89PNG%0D%0A',
+ ], '4.0'));
+ const second = parseVCardDocument(vcard([
+ 'PHOTO:data:IMAGE/PNG,%89PNG%0d%0a',
+ ], '4.0'));
+
+ expect(semanticVCardHash(second)).toBe(semanticVCardHash(first));
+ });
+});
+
+describe('local contact hash', () => {
+ const camelContact = () => ({
+ uid: 'stable-uid',
+ displayName: null,
+ firstName: 'Jane\r\nQ.',
+ lastName: 'Doe',
+ emails: [
+ { value: ' Jane.Doe@Example.Test ', type: 'Work', primary: true },
+ { value: 'other@example.test', type: 'OTHER', primary: false },
+ ],
+ phones: [{ value: '+1 555 123 4567', type: 'CELL' }],
+ organization: 'Example Corp',
+ notes: 'Line one\r\nLine two',
+ photoData: 'data:IMAGE/PNG;base64,QUJDRA==',
+ additionalFields: [{
+ id: 'field-stable-1',
+ kind: 'CUSTOM-TEXT',
+ label: 'Favorite\r\ncolor',
+ value: 'Blue',
+ vcard: {
+ name: 'X-MAILFLOW-CUSTOM',
+ group: 'item1',
+ params: [{ values: ['one', 'two'], name: 'TYPE' }],
+ },
+ }],
+ });
+
+ it('matches database/API naming shapes and harmless object-key ordering', () => {
+ const api = camelContact();
+ const database = {
+ id: 'database-id',
+ address_book_id: 'book-id',
+ uid: 'stable-uid',
+ display_name: '',
+ first_name: 'Jane\nQ.',
+ last_name: 'Doe',
+ emails: [
+ { primary: 1, type: 'work', value: 'jane.doe@example.test' },
+ { primary: 0, type: 'other', value: 'other@example.test' },
+ ],
+ phones: [{ type: 'mobile', value: '+15551234567' }],
+ organization: 'Example Corp',
+ notes: 'Line one\nLine two',
+ photo_data: 'data:image/png;base64,QUJD\nRA==',
+ additional_fields: [{
+ value: 'Blue',
+ label: 'Favorite\ncolor',
+ kind: 'custom-text',
+ id: 'field-stable-1',
+ vcard: {
+ params: [{ name: 'type', values: ['ONE', 'TWO'] }],
+ group: 'item1',
+ name: 'x-mailflow-custom',
+ },
+ }],
+ etag: 'ignored',
+ created_at: 'ignored',
+ updated_at: 'ignored',
+ send_count: 999,
+ is_auto: true,
+ };
+
+ expect(localContactHash(api)).toMatch(/^[a-f0-9]{64}$/);
+ expect(localContactHash(database)).toBe(localContactHash(api));
+ });
+
+ it('normalizes null and empty owned scalar representations', () => {
+ const first = camelContact();
+ const second = {
+ ...camelContact(),
+ displayName: '',
+ organization: null,
+ };
+ first.organization = '';
+
+ expect(localContactHash(second)).toBe(localContactHash(first));
+ });
+
+ it('ignores incidental persistence keys inside Additional fields', () => {
+ const original = camelContact();
+ const withPersistence = structuredClone(original);
+ Object.assign(withPersistence.additionalFields[0], {
+ databaseId: 'row-123',
+ createdAt: '2026-07-11T00:00:00Z',
+ is_auto: true,
+ });
+ Object.assign(withPersistence.additionalFields[0].vcard, {
+ databaseId: 'metadata-row-123',
+ etag: 'ignored',
+ });
+ withPersistence.additionalFields[0].vcard.params[0].databaseId = 'parameter-row-123';
+
+ expect(localContactHash(withPersistence)).toBe(localContactHash(original));
+ });
+
+ it('hashes only owned Additional id, kind, label, and value data', () => {
+ const original = camelContact();
+ const syntaxChanged = structuredClone(original);
+ syntaxChanged.additionalFields[0].vcard = {
+ group: 'remote-group',
+ name: 'X-REMOTE-SYNTAX',
+ params: [
+ { name: 'PREF', values: ['1'] },
+ { name: 'X-VENDOR', values: ['opaque'] },
+ ],
+ };
+
+ expect(localContactHash(syntaxChanged)).toBe(localContactHash(original));
+ });
+
+ it('canonicalizes supported local photo MIME identity and decoded bytes', () => {
+ const paddedJpeg = { ...camelContact(), photoData: 'data:image/jpeg;base64,AQI=' };
+ const aliasWithWhitespace = {
+ ...camelContact(),
+ photoData: 'data:IMAGE/JPG;base64,A Q I',
+ };
+ const differentBytes = { ...camelContact(), photoData: 'data:image/jpeg;base64,AQM=' };
+ const sameBytesPng = { ...camelContact(), photoData: 'data:image/png;base64,AQI=' };
+
+ expect(localContactHash(aliasWithWhitespace)).toBe(localContactHash(paddedJpeg));
+ expect(localContactHash(differentBytes)).not.toBe(localContactHash(paddedJpeg));
+ expect(localContactHash(sameBytesPng)).not.toBe(localContactHash(paddedJpeg));
+ });
+
+ it('canonicalizes supported percent-encoded local photo bytes', () => {
+ const upper = { ...camelContact(), photoData: 'data:image/png,%89PNG%0D%0A' };
+ const lower = { ...camelContact(), photoData: 'data:IMAGE/PNG,%89PNG%0d%0a' };
+ const changed = { ...camelContact(), photoData: 'data:image/png,%89PNG%0D%0B' };
+
+ expect(localContactHash(lower)).toBe(localContactHash(upper));
+ expect(localContactHash(changed)).not.toBe(localContactHash(upper));
+ });
+
+ it.each([
+ ['uid', contact => { contact.uid = 'changed'; }],
+ ['display name', contact => { contact.displayName = 'Changed'; }],
+ ['first name', contact => { contact.firstName = 'Changed'; }],
+ ['last name', contact => { contact.lastName = 'Changed'; }],
+ ['email', contact => { contact.emails[0].value = 'changed@example.test'; }],
+ ['email type', contact => { contact.emails[0].type = 'home'; }],
+ ['email primary flag', contact => { contact.emails[0].primary = false; }],
+ ['email order', contact => { contact.emails.reverse(); }],
+ ['phone', contact => { contact.phones[0].value = '+15550000000'; }],
+ ['phone type', contact => { contact.phones[0].type = 'home'; }],
+ ['organization', contact => { contact.organization = 'Changed'; }],
+ ['notes', contact => { contact.notes = 'Changed'; }],
+ ['photo', contact => { contact.photoData = 'data:image/png;base64,AQID'; }],
+ ['Additional value', contact => { contact.additionalFields[0].value = 'Red'; }],
+ ['Additional label', contact => { contact.additionalFields[0].label = 'Other'; }],
+ ['Additional kind', contact => { contact.additionalFields[0].kind = 'url'; }],
+ ['Additional stable ID', contact => { contact.additionalFields[0].id = 'changed-id'; }],
+ ])('changes when the owned %s changes', (_field, mutate) => {
+ const original = camelContact();
+ const changed = structuredClone(original);
+ mutate(changed);
+
+ expect(localContactHash(changed)).not.toBe(localContactHash(original));
+ });
+});
+
+describe('vCard property validation and normalization regressions', () => {
+ it.each([
+ ['parsing', params => parseVCardDocument(vcard([
+ foldAsciiLine(`PHOTO;${params}:${Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64')}`),
+ ]))],
+ ['serialization', params => serializeVCardDocument({
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: params.split(';').map(parameter => {
+ const [name, value] = parameter.split('=');
+ return { name, values: [value] };
+ }),
+ rawValue: Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64'),
+ }],
+ })],
+ ])('uses the first repeated PHOTO VALUE during %s', (_case, run) => {
+ expect(() => run('VALUE=binary;VALUE=uri;ENCODING=b'))
+ .toThrow('vCard exceeds the 512 KiB photo limit');
+ expect(() => run('VALUE=uri;VALUE=binary;ENCODING=b'))
+ .toThrow('vCard exceeds the 64 KiB unfolded line limit');
+ });
+
+ it.each([
+ ['empty', [
+ 'item1.URL;X-MAILFLOW-ID=:https://one.example.test',
+ 'item1.X-ABLabel:One',
+ ], 'vCard contains an invalid MailFlow Additional field ID'],
+ ['multiple values on one property', [
+ 'item1.URL;X-MAILFLOW-ID=one,two:https://one.example.test',
+ 'item1.X-ABLabel:One',
+ ], 'vCard contains an invalid MailFlow Additional field ID'],
+ ['duplicate', [
+ 'item1.URL;X-MAILFLOW-ID=duplicate:https://one.example.test',
+ 'item1.X-ABLabel:One',
+ 'item2.URL;X-MAILFLOW-ID=duplicate:https://two.example.test',
+ 'item2.X-ABLabel:Two',
+ ], 'vCard contains duplicate MailFlow Additional field IDs'],
+ ])('rejects %s persisted Additional IDs during projection', (_case, lines, error) => {
+ const document = parseVCardDocument(vcard(lines));
+ const original = structuredClone(document);
+
+ expect(() => contactFromVCardDocument(document)).toThrow(error);
+ expect(document).toEqual(original);
+ });
+
+ it('keeps a valid unique persisted Additional ID byte-identical on unchanged overlay', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL;X-MAILFLOW-ID=unique:https://one.example.test',
+ 'item1.X-ABLabel:One',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ const originalBytes = serializeVCardDocument(document);
+
+ expect(contact.additionalFields[0].id).toBe('unique');
+ expect(overlayContactOnVCard(document, contact)).toEqual(document);
+ expect(serializeVCardDocument(overlayContactOnVCard(document, contact)))
+ .toBe(originalBytes);
+ });
+
+ it('validates reserved IDs before keeping opaque GEO properties unchanged', () => {
+ const malformed = parseVCardDocument(vcard([
+ 'GEO;X-MAILFLOW-ID=:not-a-coordinate',
+ ]));
+ const valid = parseVCardDocument(vcard([
+ 'GEO;X-MAILFLOW-ID=opaque-geo:not-a-coordinate',
+ ]));
+ const contact = contactFromVCardDocument(valid);
+ const originalBytes = serializeVCardDocument(valid);
+
+ expect(() => contactFromVCardDocument(malformed))
+ .toThrow('vCard contains an invalid MailFlow Additional field ID');
+ expect(contact.additionalFields).toEqual([]);
+ expect(overlayContactOnVCard(valid, contact)).toEqual(valid);
+ expect(serializeVCardDocument(overlayContactOnVCard(valid, contact)))
+ .toBe(originalBytes);
+ });
+
+ it('keeps an opaque GEO occurrence before a derived same-group GEO byte-identical', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.GEO:not-a-coordinate',
+ 'item1.GEO:49.1;-123.1',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ const originalBytes = serializeVCardDocument(document);
+
+ expect(contact.additionalFields).toHaveLength(1);
+ expect(overlayContactOnVCard(document, contact)).toEqual(document);
+ expect(serializeVCardDocument(overlayContactOnVCard(document, contact)))
+ .toBe(originalBytes);
+ });
+
+ it('persists a derived GEO identity after a true occurrence shift past opaque GEO', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.GEO:not-a-coordinate',
+ 'item1.GEO;X-MAILFLOW-ID=saved:49.1;-123.1',
+ 'item1.GEO:50.1;-124.1',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields = target.additionalFields.slice(1);
+ const expectedId = target.additionalFields[0].id;
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const retained = overlaid.properties.find(property => (
+ property.name === 'GEO' && property.rawValue.startsWith('50.1')
+ ));
+
+ expect(parameterValuesForTest(retained, 'X-MAILFLOW-ID')).toEqual([expectedId]);
+ expect(reparsed.additionalFields.map(field => field.id)).toEqual([expectedId]);
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it.each([
+ ['missing', [
+ { kind: 'url', label: 'One', value: 'https://one.example.test' },
+ ], 'MailFlow Additional field requires a stable ID'],
+ ['duplicate', [
+ { id: 'duplicate', kind: 'url', label: 'One', value: 'https://one.example.test' },
+ { id: 'duplicate', kind: 'url', label: 'Two', value: 'https://two.example.test' },
+ ], 'MailFlow Additional field IDs must be unique'],
+ ])('rejects %s caller Additional IDs before overlay or hashing', (
+ _case,
+ additionalFields,
+ error,
+ ) => {
+ const document = parseVCardDocument(vcard([
+ 'UID:caller-id-invariant',
+ 'FN:Caller ID Invariant',
+ ]));
+ const contact = { ...contactFromVCardDocument(document), additionalFields };
+ const original = structuredClone(document);
+
+ expect(() => overlayContactOnVCard(document, contact)).toThrow(error);
+ expect(() => localContactHash(contact)).toThrow(error);
+ expect(document).toEqual(original);
+ });
+
+ it.each([
+ ['postal-address', { street: '123 Main' }],
+ ['im', { handle: 'alice' }],
+ ])('hash-converges accepted partial %s Additional values', (kind, value) => {
+ const document = parseVCardDocument(vcard([]));
+ const target = {
+ ...contactFromVCardDocument(document),
+ additionalFields: [{
+ id: `partial-${kind}`,
+ kind,
+ label: 'Partial',
+ value,
+ }],
+ };
+
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlayContactOnVCard(document, target)),
+ ));
+
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it.each([
+ ['HTTP-looking', 'https://example.test/not-base64'],
+ ['data-looking', 'data:image/jpeg,not-base64'],
+ ].flatMap(([description, rawValue]) => [
+ [description, 'parsing', () => parseVCardDocument(vcard([
+ `PHOTO;ENCODING=b;TYPE=JPEG:${rawValue}`,
+ ]))],
+ [description, 'serialization', () => serializeVCardDocument({
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [
+ { name: 'ENCODING', values: ['b'] },
+ { name: 'TYPE', values: ['JPEG'] },
+ ],
+ rawValue,
+ }],
+ })],
+ ]))('honors explicit base64 for %s PHOTO data during %s', (
+ _description,
+ _operation,
+ run,
+ ) => {
+ expect(run).toThrow('vCard PHOTO has invalid base64 data');
+ });
+
+ it('keeps explicit data-looking URI PHOTO values opaque, retained, and unfetched', () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCalled = false;
+ globalThis.fetch = () => {
+ fetchCalled = true;
+ throw new Error('unexpected fetch');
+ };
+
+ try {
+ const document = parseVCardDocument(vcard([
+ 'PHOTO;VALUE=URI:data:image/png;base64,AQID',
+ ], '4.0'));
+ const contact = contactFromVCardDocument(document);
+ const overlaid = overlayContactOnVCard(document, { ...contact, photoData: null });
+
+ expect(contact.photoData).toBeNull();
+ expect(overlaid).toEqual(document);
+ expect(fetchCalled).toBe(false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it.each([
+ ['3.0', 'line-wrapped PNG data URI', 'data:image/png;base64,AQ \r\nID',
+ 'data:image/png;base64,AQID'],
+ ['4.0', 'line-wrapped PNG data URI', 'data:image/png;base64,AQ \r\nID',
+ 'data:image/png;base64,AQID'],
+ ['3.0', 'line-wrapped raw JPEG base64', 'AQ \r\nID',
+ 'data:image/jpeg;base64,AQID'],
+ ['4.0', 'line-wrapped raw JPEG base64', 'AQ \r\nID',
+ 'data:image/jpeg;base64,AQID'],
+ ['3.0', 'percent-encoded PNG data URI', 'data:image/png,%89PNG%0D%0A',
+ 'data:image/png;base64,iVBORw0K'],
+ ['4.0', 'percent-encoded PNG data URI', 'data:image/png,%89PNG%0D%0A',
+ 'data:image/png;base64,iVBORw0K'],
+ ])('canonicalizes owned photo input for vCard %s (%s)', (
+ version,
+ _case,
+ photoData,
+ canonicalPhotoData,
+ ) => {
+ const document = parseVCardDocument(vcard([], version));
+ const contact = { ...contactFromVCardDocument(document), photoData };
+
+ expect(localContactHash(contact)).toBe(localContactHash({
+ ...contact,
+ photoData: canonicalPhotoData,
+ }));
+
+ const overlaid = overlayContactOnVCard(document, contact);
+ const photo = overlaid.properties.find(property => property.name === 'PHOTO');
+ const serialized = serializeVCardDocument(overlaid);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(serialized));
+ const payload = photo.rawValue.slice(photo.rawValue.lastIndexOf(',') + 1);
+
+ expect(payload).not.toMatch(/\s/);
+ expect(localContactHash(reparsed)).toBe(localContactHash(contact));
+ });
+
+ it('persists and projects the stable ID of a newly added custom-text row', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:new-custom-identity',
+ 'FN:New Custom Identity',
+ ]));
+ const target = {
+ ...contactFromVCardDocument(document),
+ additionalFields: [{
+ id: 'custom-stable-id',
+ kind: 'custom-text',
+ label: 'Favorite color',
+ value: 'Blue',
+ }],
+ };
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const property = overlaid.properties.find(entry => entry.name === 'X-MAILFLOW-CUSTOM');
+
+ expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual(['custom-stable-id']);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('persists and projects the stable ID of a newly added URL row', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:new-url-identity',
+ 'FN:New URL Identity',
+ ]));
+ const target = {
+ ...contactFromVCardDocument(document),
+ additionalFields: [{
+ id: 'url-stable-id',
+ kind: 'url',
+ label: 'Portfolio',
+ value: 'https://example.test/portfolio',
+ }],
+ };
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const property = overlaid.properties.find(entry => entry.name === 'URL');
+
+ expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual(['url-stable-id']);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('keeps a retained Additional ID through a kind change', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL;X-VENDOR=keep:https://example.test/profile',
+ 'item1.X-ABLabel:Profile',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields[0] = {
+ ...target.additionalFields[0],
+ kind: 'role',
+ label: 'Team role',
+ value: 'Editor',
+ };
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const property = overlaid.properties.find(entry => entry.name === 'ROLE');
+
+ expect(parameterValuesForTest(property, 'X-MAILFLOW-ID'))
+ .toEqual([target.additionalFields[0].id]);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('keeps both retained Additional IDs when one shared label is split', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.URL:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields[1].label = 'Second';
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const properties = overlaid.properties.filter(entry => entry.name === 'URL');
+
+ expect(parameterValuesForTest(properties[0], 'X-MAILFLOW-ID')).toEqual([]);
+ expect(parameterValuesForTest(properties[1], 'X-MAILFLOW-ID'))
+ .toEqual([target.additionalFields[1].id]);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('persists derived survivors when the first shared-label row is split', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.URL:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields[0].label = 'First';
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const properties = overlaid.properties.filter(entry => entry.name === 'URL');
+
+ expect(properties.map(property => parameterValuesForTest(property, 'X-MAILFLOW-ID')))
+ .toEqual(target.additionalFields.map(field => [field.id]));
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it.each([
+ ['deletes the first', fields => fields.slice(1), ['two']],
+ ['reorders', fields => [...fields].reverse(), ['two', 'one']],
+ ])('%s retained rows with persisted IDs', (_case, edit, expectedIds) => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL;X-MAILFLOW-ID=one:https://one.example.test',
+ 'item1.URL;X-MAILFLOW-ID=two:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields = edit(target.additionalFields);
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+
+ expect(reparsed.additionalFields.map(field => field.id)).toEqual(expectedIds);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it.each([
+ ['deletes a preceding persisted row', fields => fields.slice(1), ['derived']],
+ ['reorders a preceding persisted row', fields => [...fields].reverse(), ['derived', 'saved']],
+ ])('%s before a derived survivor without identity drift', (_case, edit, expectedValues) => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL;X-MAILFLOW-ID=saved:https://saved.example.test',
+ 'item1.URL:https://derived.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields = edit(target.additionalFields);
+ const expectedIds = target.additionalFields.map(field => field.id);
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const urls = overlaid.properties.filter(property => property.name === 'URL');
+
+ expect(reparsed.additionalFields.map(field => (
+ field.value.includes('derived') ? 'derived' : 'saved'
+ ))).toEqual(expectedValues);
+ expect(reparsed.additionalFields.map(field => field.id)).toEqual(expectedIds);
+ expect(urls.find(property => property.rawValue.includes('derived')).params)
+ .toContainEqual({ name: 'X-MAILFLOW-ID', values: [expectedIds[0]] });
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('prefers and preserves a persisted Additional ID through value and label edits', () => {
+ const document = parseVCardDocument(vcard([
+ 'item4.URL;X-MAILFLOW-ID=ordinary-stable-id:https://old.example.test',
+ 'item4.X-ABLabel:Old label',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields[0].label = 'New label';
+ target.additionalFields[0].value = 'https://new.example.test';
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const property = overlaid.properties.find(entry => entry.name === 'URL');
+
+ expect(target.additionalFields[0].id).toBe('ordinary-stable-id');
+ expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual(['ordinary-stable-id']);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('keeps a derived Additional ID through ordinary edits without adding identity syntax', () => {
+ const document = parseVCardDocument(vcard([
+ 'item5.URL:https://old.example.test',
+ 'item5.X-ABLabel:Old label',
+ ]));
+ const target = contactFromVCardDocument(document);
+ target.additionalFields[0].label = 'New label';
+ target.additionalFields[0].value = 'https://new.example.test';
+
+ const overlaid = overlayContactOnVCard(document, target);
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlaid),
+ ));
+ const property = overlaid.properties.find(entry => entry.name === 'URL');
+
+ expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual([]);
+ expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target));
+ expect(localContactHash(reparsed)).toBe(localContactHash(target));
+ });
+
+ it('keeps unchanged retained remote rows byte-identical without adding identity syntax', () => {
+ const raw = vcard([
+ 'item7.URL;TYPE=HOME;X-VENDOR=keep:https://example.test/profile',
+ 'item7.X-ABLabel:Profile',
+ ]);
+ const document = parseVCardDocument(raw);
+ const contact = contactFromVCardDocument(document);
+ const originalBytes = serializeVCardDocument(document);
+ const overlaid = overlayContactOnVCard(document, contact);
+
+ expect(overlaid).toEqual(document);
+ expect(serializeVCardDocument(overlaid)).toBe(originalBytes);
+ expect(serializeVCardDocument(overlaid)).not.toContain('X-MAILFLOW-ID');
+ });
+
+ it('uses the strict first content delimiter for NOTE and unknown properties', () => {
+ const document = parseVCardDocument(vcard([
+ 'NOTE;X-P=trailing\\:sip:alice@example.test',
+ 'X-REFERENCE;X-P=trailing\\:webcal:event-id',
+ ]));
+
+ expect(document.properties).toEqual([
+ {
+ group: null,
+ name: 'NOTE',
+ params: [{ name: 'X-P', values: ['trailing\\'] }],
+ rawValue: 'sip:alice@example.test',
+ },
+ {
+ group: null,
+ name: 'X-REFERENCE',
+ params: [{ name: 'X-P', values: ['trailing\\'] }],
+ rawValue: 'webcal:event-id',
+ },
+ ]);
+ });
+
+ it('rejects vCard 3.0 parameter values that can inject content lines', () => {
+ const document = {
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'X-SAFE',
+ params: [{ name: 'X-LABEL', values: ['safe\r\nX-INJECT:payload'] }],
+ rawValue: 'value',
+ }],
+ };
+
+ expect(() => serializeVCardDocument(document))
+ .toThrow('vCard parameter value contains an invalid character');
+ });
+
+ it('round-trips legal vCard 3.0 HTAB parameter whitespace', () => {
+ const document = {
+ version: '3.0',
+ properties: [{
+ group: null,
+ name: 'X-SAFE',
+ params: [{ name: 'X-LABEL', values: ['left\tright'] }],
+ rawValue: 'value',
+ }],
+ };
+
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it.each(['\0', '\v', '\f', '\x7f'])(
+ 'rejects forbidden parameter control %j in both versions',
+ control => {
+ for (const version of ['3.0', '4.0']) {
+ expect(() => serializeVCardDocument({
+ version,
+ properties: [{
+ group: null,
+ name: 'X-SAFE',
+ params: [{ name: 'X-LABEL', values: [`left${control}right`] }],
+ rawValue: 'value',
+ }],
+ })).toThrow('vCard parameter value contains an invalid character');
+ }
+ },
+ );
+
+ it.each(['BEGIN', 'END', 'VERSION'])(
+ 'rejects structural %s pseudo-properties before serialization',
+ name => {
+ expect(() => serializeVCardDocument({
+ version: '4.0',
+ properties: [{ group: null, name, params: [], rawValue: 'value' }],
+ })).toThrow('vCard document cannot contain structural properties');
+ },
+ );
+
+ it.each(['\0', '\v', '\f', '\x7f'])(
+ 'rejects forbidden parsed parameter control %j in both versions',
+ control => {
+ for (const version of ['3.0', '4.0']) {
+ expect(() => parseVCardDocument(vcard([
+ `X-SAFE;X-LABEL=left${control}right:value`,
+ ], version))).toThrow('vCard parameter value contains an invalid character');
+ }
+ },
+ );
+
+ it('rejects literal double quotes inside a vCard 3.0 parameter value', () => {
+ expect(() => parseVCardDocument(vcard([
+ 'X-SAFE;X-LABEL=left"middle":value',
+ ]))).toThrow('vCard parameter value contains an invalid character');
+ });
+
+ it('round-trips a valid quoted vCard 3.0 parameter value', () => {
+ const document = parseVCardDocument(vcard([
+ 'X-SAFE;X-LABEL="left,middle":value',
+ ]));
+
+ expect(document.properties[0].params).toEqual([
+ { name: 'X-LABEL', values: ['left,middle'] },
+ ]);
+ expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document);
+ });
+
+ it.each([
+ ['parsing', rawValue => parseVCardDocument(vcard([
+ foldAsciiLine(`PHOTO:${rawValue}`),
+ ], '4.0'))],
+ ['serialization', rawValue => serializeVCardDocument({
+ version: '4.0',
+ properties: [{ group: null, name: 'PHOTO', params: [], rawValue }],
+ })],
+ ])('rejects a PHOTO data carrier without a comma during %s', (_case, run) => {
+ expect(() => run('data:image/png;base64AQID'))
+ .toThrow('vCard PHOTO has invalid data URI encoding');
+ });
+
+ it.each([
+ ['parsing', rawValue => parseVCardDocument(vcard([
+ foldAsciiLine(`PHOTO:${rawValue}`),
+ ], '4.0'))],
+ ['serialization', rawValue => serializeVCardDocument({
+ version: '4.0',
+ properties: [{ group: null, name: 'PHOTO', params: [], rawValue }],
+ })],
+ ])('does not exempt an oversized malformed PHOTO data carrier during %s', (_case, run) => {
+ const malformed = 'data:image/png;base64' + 'a'.repeat(MAX_CONTENT_LINE_BYTES);
+ expect(() => run(malformed))
+ .toThrow(/vCard PHOTO has invalid data URI encoding|64 KiB unfolded line limit/);
+ });
+
+ it.each([
+ 'item1.BEGIN:VCARD',
+ 'BEGIN;X-P=one:VCARD',
+ 'item1.END:VCARD',
+ 'END;X-P=one:VCARD',
+ 'item1.VERSION:4.0',
+ 'VERSION;X-P=one:4.0',
+ ])('rejects parsed structural pseudo-property %s', line => {
+ expect(() => parseVCardDocument(vcard([line], '4.0'))).toThrow();
+ });
+
+ it('rejects parameterized VERSION instead of silently downgrading it', () => {
+ expect(() => parseVCardDocument([
+ 'BEGIN:VCARD',
+ 'VERSION;VALUE=text:4.0',
+ 'X-REFERENCE;X-P=line^nbreak:payload',
+ 'END:VCARD',
+ '',
+ ].join('\r\n'))).toThrow('vCard VERSION parameters are not supported');
+ });
+
+ it('uses the standards delimiter for an unlisted URI scheme after a parameter backslash', () => {
+ const document = parseVCardDocument(vcard([
+ 'IMPP;X-P=trailing\\:sip:alice@example.test',
+ ]));
+
+ expect(document.properties[0]).toEqual({
+ group: null,
+ name: 'IMPP',
+ params: [{ name: 'X-P', values: ['trailing\\'] }],
+ rawValue: 'sip:alice@example.test',
+ });
+ });
+
+ it('serializes owned vCard 4.0 UID and URI TEL edits with URI delimiters', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID:urn:uuid:old,value;part',
+ 'TEL;VALUE=uri:tel:+15551234567,9;ext=1',
+ ], '4.0'));
+ const contact = contactFromVCardDocument(document);
+ contact.uid = 'urn:uuid:new,value;part';
+ contact.phones[0].value = 'tel:+15551234568,9;ext=2';
+
+ const serialized = serializeVCardDocument(overlayContactOnVCard(document, contact));
+ const projected = contactFromVCardDocument(parseVCardDocument(serialized));
+
+ expect(serialized).toContain('UID:urn:uuid:new,value;part\r\n');
+ expect(serialized).toContain('TEL;VALUE=uri:tel:+15551234568,9;ext=2\r\n');
+ expect(projected.uid).toBe(contact.uid);
+ expect(projected.phones[0].value).toBe(contact.phones[0].value);
+ });
+
+ it('requires exactly one ordered vCard envelope and counts structural lines', () => {
+ const card = vcard(['UID:one']);
+ expect(() => parseVCardDocument(card + card)).toThrow(/exactly one vCard component/);
+ expect(() => parseVCardDocument('VERSION:3.0\r\nUID:none\r\n'))
+ .toThrow(/BEGIN:VCARD/);
+ expect(() => parseVCardDocument([
+ 'BEGIN:VCARD',
+ ...Array.from({ length: 2500 }, () => 'VERSION:3.0'),
+ 'END:VCARD',
+ '',
+ ].join('\r\n'))).toThrow(/exactly one VERSION property/);
+ });
+
+ it.each([
+ ['group', 'BAD/GROUP.X-SAFE:value', 'vCard contains an invalid property group'],
+ ['property', 'BAD/NAME:value', 'vCard contains an invalid property name'],
+ ['parameter', 'X-SAFE;BAD@PARAM=v:value', 'vCard contains an invalid parameter name'],
+ ])('rejects an invalid parsed %s token independently', (_token, line, error) => {
+ expect(() => parseVCardDocument(vcard([line]))).toThrow(error);
+ });
+
+ it('rejects deleting an earlier ambiguous retained Additional row before mutation', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.URL:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const projected = contactFromVCardDocument(document);
+ const edited = {
+ ...projected,
+ additionalFields: projected.additionalFields.slice(1),
+ };
+
+ expect(() => overlayContactOnVCard(document, edited)).toThrow(
+ 'MailFlow Additional fields cannot delete an earlier ambiguous retained property',
+ );
+ expect(document.properties.map(property => property.rawValue)).toEqual([
+ 'https://one.example.test',
+ 'https://two.example.test',
+ 'Shared',
+ ]);
+ });
+
+ it('allows deleting the final ambiguous retained Additional row without identity drift', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.URL:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const projected = contactFromVCardDocument(document);
+ const edited = {
+ ...projected,
+ additionalFields: projected.additionalFields.slice(0, 1),
+ };
+
+ const reparsed = contactFromVCardDocument(parseVCardDocument(
+ serializeVCardDocument(overlayContactOnVCard(document, edited)),
+ ));
+
+ expect(reparsed.additionalFields.map(({ id, value }) => ({ id, value }))).toEqual(
+ edited.additionalFields.map(({ id, value }) => ({ id, value })),
+ );
+ expect(localContactHash(reparsed)).toBe(localContactHash(edited));
+ });
+
+ it('uses explicit TEXT instead of vCard 4.0 URI defaults for UID and TEL', () => {
+ const document = parseVCardDocument(vcard([
+ 'UID;VALUE=text:id\\,one',
+ 'TEL;VALUE=text:call\\,me',
+ ], '4.0'));
+ const contact = contactFromVCardDocument(document);
+ const edited = structuredClone(contact);
+ edited.uid = 'id,two';
+ edited.phones[0].value = 'call,you';
+
+ const serialized = serializeVCardDocument(overlayContactOnVCard(document, edited));
+ const reparsed = contactFromVCardDocument(parseVCardDocument(serialized));
+
+ expect(contact.uid).toBe('id,one');
+ expect(contact.phones[0].value).toBe('call,me');
+ expect(serialized).toContain('UID;VALUE=text:id\\,two\r\n');
+ expect(serialized).toContain('TEL;VALUE=text:call\\,you\r\n');
+ expect(reparsed.uid).toBe(edited.uid);
+ expect(reparsed.phones[0].value).toBe(edited.phones[0].value);
+ });
+
+ it('uses vCard 3.0 URL and IMPP URI defaults in projection, overlay, and hashing', () => {
+ const upper = parseVCardDocument(vcard([
+ 'item1.URL:https://example.test/\\N',
+ 'item2.IMPP:xmpp:user\\N@example.test',
+ ]));
+ const lower = parseVCardDocument(vcard([
+ 'item1.URL:https://example.test/\\n',
+ 'item2.IMPP:xmpp:user\\n@example.test',
+ ]));
+ const contact = contactFromVCardDocument(upper);
+ const edited = structuredClone(contact);
+ edited.additionalFields[0].value = 'https://example.test/\\N/next';
+ edited.additionalFields[1].value.handle = 'user\\Nnext@example.test';
+
+ const serialized = serializeVCardDocument(overlayContactOnVCard(upper, edited));
+ const reparsed = contactFromVCardDocument(parseVCardDocument(serialized));
+
+ expect(contact.additionalFields.map(field => field.value)).toEqual([
+ 'https://example.test/\\N',
+ { protocol: 'xmpp', handle: 'user\\N@example.test' },
+ ]);
+ expect(semanticVCardHash(upper)).not.toBe(semanticVCardHash(lower));
+ expect(serialized).toContain('URL:https://example.test/\\N/next\r\n');
+ expect(serialized).toContain('IMPP:xmpp:user\\Nnext@example.test\r\n');
+ expect(localContactHash(reparsed)).toBe(localContactHash(edited));
+ });
+
+ it('lets explicit URI override TEXT defaults and normalizes newlines only for TEXT', () => {
+ const uriUpper = parseVCardDocument(vcard(['TEL;VALUE=uri:tel:123\\N4']));
+ const uriLower = parseVCardDocument(vcard(['TEL;VALUE=uri:tel:123\\n4']));
+ const textUpper = parseVCardDocument(vcard(['TEL;VALUE=text:line\\Nbreak'], '4.0'));
+ const textLower = parseVCardDocument(vcard(['TEL;VALUE=text:line\\nbreak'], '4.0'));
+
+ expect(contactFromVCardDocument(uriUpper).phones[0].value).toBe('tel:123\\N4');
+ expect(contactFromVCardDocument(textUpper).phones[0].value).toBe('line\nbreak');
+ expect(semanticVCardHash(uriUpper)).not.toBe(semanticVCardHash(uriLower));
+ expect(semanticVCardHash(textUpper)).toBe(semanticVCardHash(textLower));
+ });
+
+ it('counts accepted blank logical lines at the 1 MiB boundary', () => {
+ const withBlankLine = size => asciiVCardOfSize(size - 2)
+ .replace('END:VCARD\r\n', '\r\nEND:VCARD\r\n');
+ const atLimit = withBlankLine(MAX_VCARD_BYTES);
+ const overLimit = withBlankLine(MAX_VCARD_BYTES + 1);
+
+ expect(Buffer.byteLength(atLimit)).toBe(MAX_VCARD_BYTES);
+ expect(parseVCardDocument(atLimit).properties).toHaveLength(16);
+ expect(() => parseVCardDocument(overLimit)).toThrow(/1 MiB/);
+ });
+
+ it('serializes and reparses an accepted exact-budget document', () => {
+ const document = parseVCardDocument(asciiVCardOfSize(MAX_VCARD_BYTES));
+ const serialized = serializeVCardDocument(document);
+
+ expect(parseVCardDocument(serialized)).toEqual(document);
+ });
+
+ it('stops serialization before accessing properties after the document budget fails', () => {
+ const later = {
+ group: null,
+ name: 'X-LATER',
+ get params() {
+ throw new Error('later params accessed');
+ },
+ rawValue: 'never',
+ };
+
+ expect(() => serializeVCardDocument({
+ version: '4.0',
+ properties: [{
+ group: null,
+ name: 'PHOTO',
+ params: [{ name: 'VALUE', values: ['URI'] }],
+ rawValue: 'https://example.test/' + 'a'.repeat(MAX_VCARD_BYTES),
+ }, later],
+ })).toThrow('vCard exceeds the 1 MiB limit');
+ });
+
+ it('rejects ambiguous same-group Additional reordering before mutation', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://one.example.test',
+ 'item1.URL:https://two.example.test',
+ 'item1.X-ABLabel:Shared',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ contact.additionalFields.reverse();
+
+ expect(() => overlayContactOnVCard(document, contact))
+ .toThrow('MailFlow Additional fields cannot reorder ambiguous retained properties');
+ expect(document.properties.map(property => property.rawValue)).toEqual([
+ 'https://one.example.test',
+ 'https://two.example.test',
+ 'Shared',
+ ]);
+ });
+
+ it('treats retained whitespace-only group labels as absent without rewriting them', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.URL:https://example.test',
+ 'item1.X-ABLabel: ',
+ ]));
+ const contact = contactFromVCardDocument(document);
+ const originalBytes = serializeVCardDocument(document);
+
+ expect(contact.additionalFields[0].label).toBe('URL');
+ expect(localContactHash(contact)).toMatch(/^[a-f0-9]{64}$/);
+ expect(overlayContactOnVCard(document, contact)).toEqual(document);
+ expect(serializeVCardDocument(overlayContactOnVCard(document, contact))).toBe(originalBytes);
+
+ // Setting a whitespace-only label is treated as clearing it: the retained blank
+ // X-ABLABEL is dropped rather than rewritten to an empty value.
+ contact.additionalFields[0].label = ' ';
+ const overlaid = overlayContactOnVCard(document, contact);
+ expect(overlaid.properties.some(property => property.name === 'X-ABLABEL')).toBe(false);
+ expect(contactFromVCardDocument(
+ parseVCardDocument(serializeVCardDocument(overlaid)),
+ ).additionalFields).toEqual([
+ expect.objectContaining({ kind: 'url', label: 'URL', value: 'https://example.test' }),
+ ]);
+ });
+
+ it('normalizes TEXT newline escapes without collapsing URI escapes', () => {
+ const upperUri = parseVCardDocument(vcard(['URL:https://example.test/\\N'], '4.0'));
+ const lowerUri = parseVCardDocument(vcard(['URL:https://example.test/\\n'], '4.0'));
+ const upperText = parseVCardDocument(vcard(['NOTE:line\\Nbreak'], '4.0'));
+ const lowerText = parseVCardDocument(vcard(['NOTE:line\\nbreak'], '4.0'));
+
+ expect(semanticVCardHash(upperUri)).not.toBe(semanticVCardHash(lowerUri));
+ expect(semanticVCardHash(upperText)).toBe(semanticVCardHash(lowerText));
+ });
+
+ it('canonicalizes supported legacy PHOTO carrier aliases by MIME and decoded bytes', () => {
+ const jpeg = value => parseVCardDocument(vcard([value]));
+ const canonical = jpeg('PHOTO;ENCODING=b;TYPE=JPEG:AQI=');
+ const aliases = jpeg('PHOTO;ENCODING=base64;TYPE=JPG:AQI');
+ const differentMime = jpeg('PHOTO;ENCODING=b;TYPE=PNG:AQI=');
+ const differentBytes = jpeg('PHOTO;ENCODING=b;TYPE=JPEG:AQM=');
+
+ expect(semanticVCardHash(aliases)).toBe(semanticVCardHash(canonical));
+ expect(semanticVCardHash(differentMime)).not.toBe(semanticVCardHash(canonical));
+ expect(semanticVCardHash(differentBytes)).not.toBe(semanticVCardHash(canonical));
+ });
+
+ it('elides explicit URI defaults from semantic hashes', () => {
+ const implicit = parseVCardDocument(vcard(['TEL:tel:+15551234567'], '4.0'));
+ const explicit = parseVCardDocument(vcard([
+ 'TEL;VALUE=uri:tel:+15551234567',
+ ], '4.0'));
+
+ expect(semanticVCardHash(explicit)).toBe(semanticVCardHash(implicit));
+ });
+
+ it('preserves semantic vCard 4 PHOTO TYPE changes in hashes', () => {
+ const home = parseVCardDocument(vcard([
+ 'PHOTO;TYPE=HOME:data:image/png;base64,AQID',
+ ], '4.0'));
+ const work = parseVCardDocument(vcard([
+ 'PHOTO;TYPE=WORK:data:image/png;base64,AQID',
+ ], '4.0'));
+
+ expect(semanticVCardHash(work)).not.toBe(semanticVCardHash(home));
+ });
+
+ it('canonicalizes valid raw JPEG base64 in local hashes and rejects invalid data', () => {
+ const raw = { photoData: 'AQID' };
+ const dataUri = { photoData: 'data:image/jpeg;base64,AQID' };
+ const document = parseVCardDocument(vcard([]));
+ const overlaid = overlayContactOnVCard(document, raw);
+ const projected = contactFromVCardDocument(
+ parseVCardDocument(serializeVCardDocument(overlaid)),
+ );
+
+ expect(localContactHash(raw)).toBe(localContactHash(dataUri));
+ expect(localContactHash(projected)).toBe(localContactHash(raw));
+ expect(() => localContactHash({ photoData: 'not-valid-***' }))
+ .toThrow(/invalid base64/);
+ });
+
+ it('normalizes IMPP protocol casing in local hashes and serialized Additional values', () => {
+ const document = parseVCardDocument(vcard([
+ 'item1.IMPP:xmpp:CaseSensitiveHandle',
+ 'item1.X-ABLabel:Chat',
+ ]));
+ const lower = contactFromVCardDocument(document);
+ const upper = structuredClone(lower);
+ upper.additionalFields[0].value.protocol = 'XMPP';
+ const serialized = serializeVCardDocument(overlayContactOnVCard(document, upper));
+ const projected = contactFromVCardDocument(parseVCardDocument(serialized));
+
+ expect(localContactHash(upper)).toBe(localContactHash(lower));
+ expect(serialized).toContain('IMPP:xmpp:CaseSensitiveHandle\r\n');
+ expect(localContactHash(projected)).toBe(localContactHash(lower));
+ });
+
+ it('set-normalizes duplicate TYPE members in semantic hashes', () => {
+ const single = parseVCardDocument(vcard(['EMAIL;TYPE=WORK:a@example.test']));
+ const repeated = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK;TYPE=WORK:a@example.test',
+ ]));
+ const listed = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK,WORK:a@example.test',
+ ]));
+ const different = parseVCardDocument(vcard([
+ 'EMAIL;TYPE=WORK,HOME:a@example.test',
+ ]));
+
+ expect(semanticVCardHash(repeated)).toBe(semanticVCardHash(single));
+ expect(semanticVCardHash(listed)).toBe(semanticVCardHash(single));
+ expect(semanticVCardHash(different)).not.toBe(semanticVCardHash(single));
+ });
+});
+
+describe('presented ETag tracks the served representation', () => {
+ it('advances the served ETag when a modeled column changes under the same retained document', () => {
+ const mapping_vcard = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Old Name\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n';
+ const base = {
+ uid: 'local-uid', display_name: 'Ada Lovelace', first_name: null, last_name: null,
+ emails: [], phones: [], organization: null, notes: null, photo_data: null,
+ additional_fields: [], vcard: '', mapping_vcard,
+ };
+ const renamed = { ...base, display_name: 'Grace Hopper' };
+ expect(presentedEtag(renamed)).not.toBe(presentedEtag(base));
+ });
+});
+
+describe('push-destined snapshot never emits a local UID on overlay failure', () => {
+ // A contact whose Additional-field IDs are duplicated makes overlayContactOnVCard throw.
+ const malformedContact = () => ({
+ uid: 'local-uid', display_name: 'Dup', first_name: null, last_name: null,
+ emails: [], phones: [], organization: null, notes: null, photo_data: null,
+ additional_fields: [
+ { id: 'dup', kind: 'url', label: '', value: 'https://a.test/' },
+ { id: 'dup', kind: 'url', label: '', value: 'https://b.test/' },
+ ],
+ vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:local-uid\r\nFN:Dup\r\nEND:VCARD\r\n',
+ mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Dup\r\nEND:VCARD\r\n',
+ });
+
+ it('re-keys the fallback to the retained remote UID when preserveDocumentUid is set', () => {
+ const result = presentedVCard(malformedContact(), { preserveDocumentUid: true });
+ expect(result).toContain('UID:remote-uid');
+ expect(result).not.toContain('UID:local-uid');
+ });
+
+ it('serve-only overlay failure still falls back to the stored local-UID vCard', () => {
+ expect(presentedVCard(malformedContact(), { preserveDocumentUid: false }))
+ .toContain('UID:local-uid');
+ });
+
+ it('pushSafeSnapshot re-keys a parseable stored vCard to the retained UID', () => {
+ const retained = parseVCardDocument('BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:X\r\nEND:VCARD\r\n');
+ const stored = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:local-uid\r\nFN:X\r\nEND:VCARD\r\n';
+ const out = pushSafeSnapshot(stored, retained, new Error('overlay failed'));
+ expect(out).toContain('UID:remote-uid');
+ expect(out).not.toContain('UID:local-uid');
+ });
+
+ it('pushSafeSnapshot fails closed (rethrows) when there is no retained UID to re-key to', () => {
+ const cause = new Error('overlay failed');
+ expect(() => pushSafeSnapshot('BEGIN:VCARD\r\nUID:local-uid\r\nEND:VCARD\r\n', { properties: [] }, cause))
+ .toThrow(cause);
+ });
+});
diff --git a/frontend/src/carddavConflictState.js b/frontend/src/carddavConflictState.js
new file mode 100644
index 0000000..02061c5
--- /dev/null
+++ b/frontend/src/carddavConflictState.js
@@ -0,0 +1,137 @@
+export const CARDDAV_RESOLUTIONS = ['keep-mailflow', 'keep-carddav'];
+
+export const CONFLICT_FIELD_ORDER = [
+ 'displayName',
+ 'firstName',
+ 'lastName',
+ 'emails',
+ 'phones',
+ 'organization',
+ 'notes',
+ 'additionalFields',
+ 'photo',
+];
+
+export const CONFLICT_FIELD_LABELS = {
+ displayName: 'contacts.fields.displayName',
+ firstName: 'contacts.fields.firstName',
+ lastName: 'contacts.fields.lastName',
+ emails: 'contacts.fields.email',
+ phones: 'contacts.fields.phone',
+ organization: 'contacts.fields.organization',
+ notes: 'contacts.fields.notes',
+ additionalFields: 'contacts.additional.title',
+ photo: 'contacts.photo.title',
+};
+
+function sortedEntries(entries, fields) {
+ return (entries || [])
+ .map(entry => Object.fromEntries(fields.map(field => [field, entry?.[field] ?? ''])))
+ .sort((first, second) => JSON.stringify(first).localeCompare(JSON.stringify(second)));
+}
+
+function normalizedValue(contact, key) {
+ if (key === 'emails') {
+ return sortedEntries(contact?.emails, ['value', 'type', 'primary']);
+ }
+ if (key === 'phones') {
+ return sortedEntries(contact?.phones, ['value', 'type']);
+ }
+ if (key === 'additionalFields') {
+ return cloneFields(contact?.additionalFields)
+ .map(field => Object.fromEntries(
+ Object.entries(field).map(([fieldKey, value]) => [fieldKey, value ?? '']),
+ ))
+ .sort((first, second) => JSON.stringify(first).localeCompare(JSON.stringify(second)));
+ }
+ return contact?.[key] ?? '';
+}
+
+function comparisonCell(side, key) {
+ if (side?.tombstone) return { kind: 'tombstone' };
+ if (key === 'photo') return { kind: 'photo', present: Boolean(side?.hasPhoto) };
+ return { kind: 'value', value: normalizedValue(side?.contact, key) };
+}
+
+export function conflictComparison(conflict) {
+ return CONFLICT_FIELD_ORDER.map(key => ({
+ key,
+ local: comparisonCell(conflict?.local, key),
+ remote: comparisonCell(conflict?.remote, key),
+ }));
+}
+
+export function initialConflictQueueState(conflicts, selectedId = null) {
+ const countKnown = Array.isArray(conflicts);
+ const unresolved = (conflicts || []).filter(conflict => conflict.status !== 'resolved');
+ const selected = unresolved.some(conflict => conflict.id === selectedId)
+ ? selectedId
+ : unresolved[0]?.id ?? null;
+ return {
+ conflicts: unresolved,
+ selectedId: selected,
+ pendingResolution: null,
+ errorKey: null,
+ countKnown,
+ loadGeneration: 0,
+ loading: false,
+ loadError: false,
+ resolutionApplied: false,
+ };
+}
+
+export function beginConflictLoad(state) {
+ return {
+ ...state,
+ loadGeneration: state.loadGeneration + 1,
+ loading: true,
+ loadError: false,
+ };
+}
+
+export function completeConflictLoad(state, generation, conflicts, selectedId = null) {
+ if (generation !== state.loadGeneration) return state;
+ const loaded = initialConflictQueueState(conflicts, selectedId || state.selectedId);
+ return {
+ ...loaded,
+ pendingResolution: state.resolutionApplied ? null : state.pendingResolution,
+ errorKey: state.resolutionApplied ? null : state.errorKey,
+ loadGeneration: state.loadGeneration,
+ };
+}
+
+export function failConflictLoad(state, generation) {
+ if (generation !== state.loadGeneration) return state;
+ return {
+ ...state,
+ pendingResolution: state.resolutionApplied ? null : state.pendingResolution,
+ errorKey: state.resolutionApplied
+ ? 'contacts.conflicts.resolveFailed'
+ : state.errorKey,
+ loading: false,
+ loadError: true,
+ resolutionApplied: false,
+ };
+}
+
+export function beginConflictResolution(state, resolution) {
+ if (!CARDDAV_RESOLUTIONS.includes(resolution)) throw new TypeError('Unsupported conflict resolution');
+ return { ...state, pendingResolution: resolution, errorKey: null };
+}
+
+export function failConflictResolution(state) {
+ return {
+ ...state,
+ pendingResolution: null,
+ errorKey: 'contacts.conflicts.resolveFailed',
+ resolutionApplied: false,
+ };
+}
+
+export function completeConflictResolution(state) {
+ return {
+ ...state,
+ resolutionApplied: true,
+ };
+}
+import { cloneFields } from './contactCarddavState.js';
diff --git a/frontend/src/carddavConflictState.test.js b/frontend/src/carddavConflictState.test.js
new file mode 100644
index 0000000..5a00999
--- /dev/null
+++ b/frontend/src/carddavConflictState.test.js
@@ -0,0 +1,184 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ CARDDAV_RESOLUTIONS,
+ CONFLICT_FIELD_LABELS,
+ CONFLICT_FIELD_ORDER,
+ beginConflictLoad,
+ beginConflictResolution,
+ completeConflictLoad,
+ completeConflictResolution,
+ conflictComparison,
+ failConflictLoad,
+ failConflictResolution,
+ initialConflictQueueState,
+} from './carddavConflictState.js';
+import { api } from './utils/api.js';
+
+describe('CardDAV conflict state', () => {
+ const conflicts = [{
+ id: 'conflict-1',
+ status: 'unresolved',
+ local: {
+ tombstone: true,
+ hasPhoto: false,
+ contact: null,
+ },
+ remote: {
+ tombstone: false,
+ hasPhoto: true,
+ contact: {
+ displayName: 'Ada Lovelace',
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ emails: [
+ { value: 'z@example.test', type: 'other', primary: false },
+ { value: 'ada@example.test', type: 'work', primary: true },
+ ],
+ phones: [{ value: '+1 555 0100', type: 'mobile' }],
+ organization: 'Analytical Engines',
+ notes: 'Remote note',
+ additionalFields: [{ id: 'site', kind: 'url', label: 'Website', value: 'https://example.test' }],
+ },
+ },
+ }];
+
+ it('orders normalized comparison fields and represents tombstones and photos without payloads', () => {
+ assert.deepEqual(CONFLICT_FIELD_ORDER, [
+ 'displayName', 'firstName', 'lastName', 'emails', 'phones',
+ 'organization', 'notes', 'additionalFields', 'photo',
+ ]);
+
+ const rows = conflictComparison(conflicts[0]);
+ assert.deepEqual(rows.map(row => row.key), CONFLICT_FIELD_ORDER);
+ assert.ok(rows.every(row => row.local.kind === 'tombstone'));
+ assert.deepEqual(rows.find(row => row.key === 'emails').remote, {
+ kind: 'value',
+ value: [
+ { value: 'ada@example.test', type: 'work', primary: true },
+ { value: 'z@example.test', type: 'other', primary: false },
+ ],
+ });
+ assert.deepEqual(rows.at(-1).remote, { kind: 'photo', present: true });
+ assert.equal(JSON.stringify(rows).includes('photoData'), false);
+ assert.deepEqual(CONFLICT_FIELD_LABELS, {
+ displayName: 'contacts.fields.displayName',
+ firstName: 'contacts.fields.firstName',
+ lastName: 'contacts.fields.lastName',
+ emails: 'contacts.fields.email',
+ phones: 'contacts.fields.phone',
+ organization: 'contacts.fields.organization',
+ notes: 'contacts.fields.notes',
+ additionalFields: 'contacts.additional.title',
+ photo: 'contacts.photo.title',
+ });
+ });
+
+ it('exposes exactly the two server-supported resolution actions', () => {
+ assert.deepEqual(CARDDAV_RESOLUTIONS, ['keep-mailflow', 'keep-carddav']);
+ });
+
+ it('keeps the selected comparison open with safe copy when resolution fails', () => {
+ const initial = initialConflictQueueState(conflicts, 'conflict-1');
+ const pending = beginConflictResolution(initial, 'keep-carddav');
+ assert.equal(pending.pendingResolution, 'keep-carddav');
+
+ const failed = failConflictResolution(pending);
+ assert.equal(failed.selectedId, 'conflict-1');
+ assert.deepEqual(failed.conflicts, conflicts);
+ assert.equal(failed.pendingResolution, null);
+ assert.equal(failed.errorKey, 'contacts.conflicts.resolveFailed');
+ assert.equal(JSON.stringify(failed).includes('private remote response'), false);
+ });
+
+ it('does not publish an empty conflict count before the first successful load', () => {
+ assert.equal(initialConflictQueueState(null, 'conflict-1').countKnown, false);
+ assert.equal(initialConflictQueueState([], 'conflict-1').countKnown, true);
+ });
+
+ it('keeps a successful resolution pending until the refreshed queue arrives', () => {
+ const queue = initialConflictQueueState([
+ ...conflicts,
+ { ...conflicts[0], id: 'conflict-2' },
+ ], 'conflict-1');
+ const pending = beginConflictResolution(queue, 'keep-carddav');
+ const applied = completeConflictResolution(pending);
+
+ assert.equal(applied.pendingResolution, 'keep-carddav');
+ assert.equal(applied.resolutionApplied, true);
+ assert.equal(applied.selectedId, 'conflict-1');
+ assert.deepEqual(applied.conflicts, queue.conflicts);
+
+ const loading = beginConflictLoad(applied);
+ const refreshed = completeConflictLoad(loading, loading.loadGeneration, [
+ { ...conflicts[0], id: 'conflict-2' },
+ ], 'conflict-1');
+
+ assert.equal(refreshed.pendingResolution, null);
+ assert.equal(refreshed.resolutionApplied, false);
+ assert.equal(refreshed.loading, false);
+ assert.equal(refreshed.selectedId, 'conflict-2');
+ });
+
+ it('ignores an older conflict load after a newer load has completed', () => {
+ const initial = initialConflictQueueState(conflicts, 'conflict-1');
+ const older = beginConflictLoad(initial);
+ const newer = beginConflictLoad(older);
+ const newestConflict = { ...conflicts[0], id: 'conflict-newest' };
+ const current = completeConflictLoad(
+ newer,
+ newer.loadGeneration,
+ [newestConflict],
+ 'conflict-newest',
+ );
+
+ assert.equal(completeConflictLoad(
+ current,
+ older.loadGeneration,
+ conflicts,
+ 'conflict-1',
+ ), current);
+ assert.deepEqual(current.conflicts, [newestConflict]);
+ });
+
+ it('keeps safe comparison state when the post-resolution refresh fails', () => {
+ const pending = beginConflictResolution(
+ initialConflictQueueState(conflicts, 'conflict-1'),
+ 'keep-mailflow',
+ );
+ const applied = completeConflictResolution(pending);
+ const loading = beginConflictLoad(applied);
+ const failed = failConflictLoad(loading, loading.loadGeneration);
+
+ assert.deepEqual(failed.conflicts, conflicts);
+ assert.equal(failed.selectedId, 'conflict-1');
+ assert.equal(failed.pendingResolution, null);
+ assert.equal(failed.resolutionApplied, false);
+ assert.equal(failed.loading, false);
+ assert.equal(failed.loadError, true);
+ assert.equal(failed.errorKey, 'contacts.conflicts.resolveFailed');
+ });
+
+ it('uses the exact conflict list and resolution API contracts', async () => {
+ const originalFetch = globalThis.fetch;
+ const calls = [];
+ globalThis.fetch = async (url, options) => {
+ calls.push({ url, options });
+ return { ok: true, json: async () => ({}) };
+ };
+
+ try {
+ await api.carddav.getConflicts();
+ await api.carddav.resolveConflict('conflict-1', 'keep-mailflow');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+
+ assert.deepEqual(calls.map(({ url, options }) => [url, options.method]), [
+ ['/api/carddav/conflicts', 'GET'],
+ ['/api/carddav/conflicts/conflict-1/resolve', 'POST'],
+ ]);
+ assert.equal(calls[1].options.body, JSON.stringify({ resolution: 'keep-mailflow' }));
+ });
+});
diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx
index 1663893..32e08f3 100644
--- a/frontend/src/components/AdminPanel.jsx
+++ b/frontend/src/components/AdminPanel.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react';
+import { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useStore } from '../store/index.js';
import { newAiAction, AI_ACTION_LIMITS } from '../aiActions.js';
@@ -11,6 +11,7 @@ import { NOTIFICATION_SOUNDS, playNotificationSound, playCustomSound, warmUpAudi
import { usePushNotifications } from '../hooks/usePushNotifications.js';
import SignatureEditor from './SignatureEditor.jsx';
import GtdZeroPet from './GtdZeroPet.jsx';
+import CardDavConflicts from './CardDavConflicts.jsx';
import { getEffectiveShortcuts, getGroupedActions, ACTION_DEFS, SPECIAL_KEY_LABELS, parseModKey, modLabel } from '../utils/defaultShortcuts.js';
import { DEFAULT_GTD_FOLDERS, GTD_STATES, resolveAccountGtdFolders, diffGtdFolders, findGtdFolderCollisions } from '../utils/gtd.js';
@@ -1856,28 +1857,62 @@ function LayoutsTab() {
}
// ─── Integrations Tab ────────────────────────────────────────────────────────
-// CardDAV contact sync (e.g. Nextcloud). One-way, read-only pull.
+// CardDAV contact sync (e.g. Nextcloud).
function CardDavCard() {
const { t } = useTranslation();
const [status, setStatus] = useState(null); // null while loading
const [expanded, setExpanded] = useState(false);
- const [form, setForm] = useState({ serverUrl: '', username: '', password: '', dupMode: 'separate', intervalMin: 60 });
+ const [form, setForm] = useState({ serverUrl: '', username: '', password: '', intervalMin: 60 });
const [connecting, setConnecting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const [error, setError] = useState('');
+ const [conflictCount, setConflictCount] = useState(0);
+ const [showConflicts, setShowConflicts] = useState(false);
+ const [healthUnavailable, setHealthUnavailable] = useState(false);
+
+ const refreshHealth = useCallback(async () => {
+ setHealthUnavailable(false);
+ const [statusResult, conflictResult] = await Promise.allSettled([
+ api.carddav.status(),
+ api.carddav.getConflicts(),
+ ]);
+ if (statusResult.status === 'fulfilled') setStatus(statusResult.value);
+ else {
+ setStatus(current => current ?? { connected: false });
+ setHealthUnavailable(true);
+ }
+ if (conflictResult.status === 'fulfilled') setConflictCount(conflictResult.value.conflicts.length);
+ else setHealthUnavailable(true);
+ }, []);
+
+ const refreshStatus = useCallback(async () => {
+ try { setStatus(await api.carddav.status()); }
+ catch { setHealthUnavailable(true); }
+ }, []);
- useEffect(() => { api.carddav.status().then(setStatus).catch(() => setStatus({ connected: false })); }, []);
+ useEffect(() => { refreshHealth(); }, [refreshHealth]);
const connected = status?.connected;
const loading = status === null;
+ const needsAttention = Boolean(status?.lastError || conflictCount);
+ const healthLabel = healthUnavailable
+ ? t('admin.integrations.carddav.healthUnavailable')
+ : connected
+ ? t(needsAttention ? 'admin.integrations.carddav.healthNeedsAttention' : 'admin.integrations.carddav.healthHealthy')
+ : t('admin.integrations.carddav.notConnected');
+ const healthColor = connected && !needsAttention && !healthUnavailable
+ ? '#22c55e'
+ : healthUnavailable || needsAttention
+ ? 'var(--red, #f87171)'
+ : 'var(--text-tertiary)';
const handleConnect = async () => {
setConnecting(true); setError('');
try {
const s = await api.carddav.connect({
serverUrl: form.serverUrl.trim(), username: form.username.trim(),
- password: form.password, dupMode: form.dupMode, intervalMin: Number(form.intervalMin),
+ password: form.password, intervalMin: Number(form.intervalMin),
});
setStatus(s); setForm(f => ({ ...f, password: '' }));
} catch (e) { setError(e.message || t('admin.integrations.carddav.connectFailed')); }
@@ -1885,13 +1920,13 @@ function CardDavCard() {
};
const handleSync = async () => {
setSyncing(true); setError('');
- try { const r = await api.carddav.sync(); setStatus(r.status); if (!r.ok && r.error) setError(r.error); }
+ try { const r = await api.carddav.sync(); setStatus(r.status); if (!r.ok && r.error) setError(r.error); await refreshHealth(); }
catch (e) { setError(e.message); }
finally { setSyncing(false); }
};
const handleDisconnect = async () => {
setDisconnecting(true); setError('');
- try { await api.carddav.disconnect(); setStatus({ connected: false }); }
+ try { await api.carddav.disconnect(); setStatus({ connected: false }); setConflictCount(0); setShowConflicts(false); }
catch (e) { setError(e.message); }
finally { setDisconnecting(false); }
};
@@ -1921,8 +1956,8 @@ function CardDavCard() {
{t('admin.integrations.carddav.title')}
{t('todoist.betaLabel')}
-
- {loading ? '...' : (connected ? t('admin.integrations.carddav.connected') : t('admin.integrations.carddav.notConnected'))}
+
+ {loading ? '...' : healthLabel}
{t('admin.integrations.carddav.description')}
@@ -1946,16 +1981,11 @@ function CardDavCard() {
{status.lastError && (
{t('admin.integrations.carddav.syncFailed', { error: status.lastError })}
)}
+
+ {t('contacts.conflicts.count', { count: conflictCount })}
+
-
- {t('admin.integrations.carddav.dupLabel')}
- updateSetting({ dupMode: e.target.value })} style={{ ...inputStyle, cursor: 'pointer' }}>
- {t('admin.integrations.carddav.dupSeparate')}
- {t('admin.integrations.carddav.dupMerge')}
- {t('admin.integrations.carddav.dupSkip')}
-
-
{t('admin.integrations.carddav.intervalLabel')}
{disconnecting ? t('common.loading') : t('admin.integrations.carddav.disconnect')}
+ {conflictCount > 0 && (
+ setShowConflicts(value => !value)} style={{ padding: '6px 14px', borderRadius: 7, cursor: 'pointer', border: '1px solid var(--red-border, rgba(248,113,113,0.3))', background: 'var(--red-dim, rgba(248,113,113,0.1))', color: 'var(--red, #f87171)', fontSize: 13 }}>
+ {t('admin.integrations.carddav.reviewConflicts')}
+
+ )}
+ {showConflicts && (
+ setShowConflicts(false)}
+ onCountChange={setConflictCount}
+ onResolved={refreshStatus}
+ />
+ )}
>
) : (
<>
@@ -1980,12 +2022,6 @@ function CardDavCard() {
setForm(f => ({ ...f, username: e.target.value }))} placeholder={t('admin.integrations.carddav.userPh')} style={inputStyle} />
{t('admin.integrations.carddav.passLabel')}
setForm(f => ({ ...f, password: e.target.value }))} placeholder={t('admin.integrations.carddav.passPh')} style={inputStyle} />
- {t('admin.integrations.carddav.dupLabel')}
- setForm(f => ({ ...f, dupMode: e.target.value }))} style={{ ...inputStyle, cursor: 'pointer' }}>
- {t('admin.integrations.carddav.dupSeparate')}
- {t('admin.integrations.carddav.dupMerge')}
- {t('admin.integrations.carddav.dupSkip')}
-
{errBox}
{t('contacts.conflicts.deleted')};
+ }
+ if (cell.kind === 'photo') {
+ return {t(cell.present ? 'contacts.conflicts.photoPresent' : 'contacts.conflicts.photoAbsent')} ;
+ }
+ const values = formatContactValue(cell.value, t);
+ if (Array.isArray(values)) {
+ return (
+
+ {values.map((value, index) => {value} )}
+
+ );
+ }
+ return {values} ;
+}
+
+export default function CardDavConflicts({
+ initialConflictId = null,
+ onClose,
+ onCountChange,
+ onResolved,
+}) {
+ const { t } = useTranslation();
+ const [queue, setQueue] = useState(() => initialConflictQueueState(null, initialConflictId));
+ const queueRef = useRef(queue);
+ const initialConflictIdRef = useRef(initialConflictId);
+
+ const updateQueue = useCallback(next => {
+ queueRef.current = next;
+ setQueue(next);
+ }, []);
+
+ const load = useCallback(async () => {
+ const loading = beginConflictLoad(queueRef.current);
+ updateQueue(loading);
+ const generation = loading.loadGeneration;
+ try {
+ const result = await api.carddav.getConflicts();
+ updateQueue(completeConflictLoad(
+ queueRef.current,
+ generation,
+ result.conflicts,
+ initialConflictIdRef.current,
+ ));
+ initialConflictIdRef.current = null;
+ } catch {
+ updateQueue(failConflictLoad(queueRef.current, generation));
+ }
+ }, [updateQueue]);
+
+ useEffect(() => { load(); }, [load]);
+ useEffect(() => {
+ if (queue.countKnown) onCountChange?.(queue.conflicts.length);
+ }, [onCountChange, queue.conflicts.length, queue.countKnown]);
+
+ const selected = queue.conflicts.find(conflict => conflict.id === queue.selectedId) || null;
+ const rows = useMemo(() => selected ? conflictComparison(selected) : [], [selected]);
+
+ // Edge fades cue the horizontally-scrollable comparison when a column is off-screen
+ // at narrow widths, and hide once the user reaches that end.
+ const comparisonScrollRef = useRef(null);
+ const [scrollEdges, setScrollEdges] = useState({ start: false, end: false });
+ const syncScrollEdges = useCallback(() => {
+ const element = comparisonScrollRef.current;
+ if (!element) return;
+ const maxScroll = element.scrollWidth - element.clientWidth;
+ setScrollEdges({
+ start: element.scrollLeft > 1,
+ end: maxScroll > 1 && element.scrollLeft < maxScroll - 1,
+ });
+ }, []);
+ useEffect(() => {
+ syncScrollEdges();
+ window.addEventListener('resize', syncScrollEdges);
+ return () => window.removeEventListener('resize', syncScrollEdges);
+ }, [syncScrollEdges, rows]);
+
+ const resolve = async resolution => {
+ const current = queueRef.current;
+ const target = current.conflicts.find(conflict => conflict.id === current.selectedId) || null;
+ if (!target || current.pendingResolution) return;
+ updateQueue(beginConflictResolution(current, resolution));
+ try {
+ await api.carddav.resolveConflict(target.id, resolution);
+ await onResolved?.(target);
+ updateQueue(completeConflictResolution(queueRef.current));
+ await load();
+ } catch {
+ updateQueue(failConflictResolution(queueRef.current));
+ }
+ };
+
+ return (
+
+
+
+
{t('contacts.conflicts.title')}
+
+ {t('contacts.conflicts.count', { count: queue.conflicts.length })}
+
+
+ {onClose && (
+
{t('contacts.conflicts.close')}
+ )}
+
+
+ {queue.loading &&
{t('common.loading')}
}
+ {!queue.loading && queue.loadError &&
{t('contacts.conflicts.loadFailed')}
}
+ {!queue.loading && !queue.loadError && queue.conflicts.length === 0 && (
+
{t('contacts.conflicts.empty')}
+ )}
+
+ {!queue.loading && selected && (
+ <>
+ {queue.conflicts.length > 1 && (
+
+ {queue.conflicts.map((conflict, index) => (
+ updateQueue({
+ ...queueRef.current,
+ selectedId: conflict.id,
+ errorKey: null,
+ })}
+ disabled={Boolean(queue.pendingResolution)}
+ style={{
+ ...secondaryButtonStyle,
+ borderColor: conflict.id === selected.id ? 'var(--accent)' : 'var(--border)',
+ color: conflict.id === selected.id ? 'var(--accent)' : 'var(--text-secondary)',
+ }}
+ >
+ {t('contacts.conflicts.item', { number: index + 1 })}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
{t('contacts.conflicts.mailflowSide')}
+
{t('contacts.conflicts.carddavSide')}
+
+ {rows.map(row => (
+
+
+ {t(CONFLICT_FIELD_LABELS[row.key])}
+
+
+
+
+ ))}
+
+
+ {scrollEdges.start &&
}
+ {scrollEdges.end &&
}
+
+
+ {queue.errorKey &&
{t(queue.errorKey)}
}
+
+ {CARDDAV_RESOLUTIONS.map(resolution => (
+ resolve(resolution)}
+ disabled={Boolean(queue.pendingResolution)}
+ style={resolution === 'keep-mailflow' ? primaryButtonStyle : secondaryButtonStyle}
+ >
+ {queue.pendingResolution === resolution
+ ? t('contacts.conflicts.resolving')
+ : t(resolution === 'keep-mailflow'
+ ? 'contacts.conflicts.keepMailflow'
+ : 'contacts.conflicts.keepCarddav')}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+const primaryButtonStyle = {
+ padding: '8px 14px',
+ border: 'none',
+ borderRadius: 7,
+ background: 'var(--accent)',
+ color: 'white',
+ cursor: 'pointer',
+ fontSize: 13,
+ fontWeight: 500,
+};
+
+const secondaryButtonStyle = {
+ padding: '7px 12px',
+ border: '1px solid var(--border)',
+ borderRadius: 7,
+ background: 'var(--bg-tertiary)',
+ color: 'var(--text-primary)',
+ cursor: 'pointer',
+ fontSize: 13,
+};
+
+const noticeStyle = {
+ padding: 18,
+ borderRadius: 9,
+ background: 'var(--bg-secondary)',
+ color: 'var(--text-tertiary)',
+ fontSize: 13,
+};
+
+const errorStyle = {
+ padding: '10px 12px',
+ borderRadius: 8,
+ background: 'var(--red-dim, rgba(248,113,113,0.1))',
+ border: '1px solid var(--red-border, rgba(248,113,113,0.3))',
+ color: 'var(--red, #f87171)',
+ fontSize: 13,
+};
+
+const comparisonHeaderStyle = {
+ display: 'grid',
+ gridTemplateColumns: '140px minmax(220px, 1fr) minmax(220px, 1fr)',
+ gap: 12,
+ padding: '10px 12px',
+ background: 'var(--bg-tertiary)',
+ borderBottom: '1px solid var(--border)',
+ fontSize: 12,
+};
+
+const comparisonRowStyle = {
+ display: 'grid',
+ gridTemplateColumns: '140px minmax(220px, 1fr) minmax(220px, 1fr)',
+ gap: 12,
+ padding: '10px 12px',
+ borderBottom: '1px solid var(--border-subtle)',
+};
+
+const comparisonCellStyle = {
+ minWidth: 0,
+ overflowWrap: 'anywhere',
+ color: 'var(--text-primary)',
+ fontSize: 13,
+};
+
+function comparisonFadeStyle(side) {
+ return {
+ position: 'absolute',
+ top: 1,
+ bottom: 1,
+ [side]: 1,
+ width: 36,
+ pointerEvents: 'none',
+ borderRadius: side === 'right' ? '0 10px 10px 0' : '10px 0 0 10px',
+ // A scroll shadow reads on any surface (unlike a fade to the panel colour, which
+ // vanishes over same-coloured rows) so the off-screen column is always cued.
+ background: `linear-gradient(to ${side}, rgba(0,0,0,0), rgba(0,0,0,0.38))`,
+ };
+}
diff --git a/frontend/src/components/ContactsPage.jsx b/frontend/src/components/ContactsPage.jsx
index 02493f6..991fc86 100644
--- a/frontend/src/components/ContactsPage.jsx
+++ b/frontend/src/components/ContactsPage.jsx
@@ -3,6 +3,25 @@ import { useTranslation } from 'react-i18next';
import { api } from '../utils/api.js';
import { useStore } from '../store/index.js';
import { useMobile } from '../hooks/useMobile.js';
+import {
+ ADDITIONAL_FIELD_KINDS,
+ additionalFieldInputType,
+ beginPhotoRead,
+ canUploadContactPhoto,
+ completePhotoRead,
+ contactCarddavState,
+ contactToForm,
+ formToContactDraft,
+ initialPhotoReadState,
+ invalidatePhotoRead,
+ newAdditionalField,
+ removeContactPhoto,
+ saveFailureState,
+ shouldRefreshContactAfterResolution,
+ validateContactPhoto,
+} from '../contactCarddavState.js';
+import { formatContactValue, humanizeContactLabel } from '../contactLabels.js';
+import CardDavConflicts from './CardDavConflicts.jsx';
// Deterministic avatar color from a string
function avatarColor(str) {
@@ -32,20 +51,8 @@ function Avatar({ name, email, size = 36 }) {
);
}
-function EmptyEmailForm() {
- return [{ value: '', type: 'other', primary: true }];
-}
-
function emptyContact() {
- return {
- displayName: '',
- firstName: '',
- lastName: '',
- emails: EmptyEmailForm(),
- phones: [],
- organization: '',
- notes: '',
- };
+ return contactToForm();
}
const PAGE_SIZE = 100;
@@ -68,6 +75,10 @@ export default function ContactsPage() {
const [listError, setListError] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [showNew, setShowNew] = useState(false);
+ const [conflictCount, setConflictCount] = useState(0);
+ const [showConflicts, setShowConflicts] = useState(false);
+ const [activeConflictId, setActiveConflictId] = useState(null);
+ const [photoRead, setPhotoRead] = useState(initialPhotoReadState);
// Mobile: 'list' shows the contact list, 'detail' shows contact/form panel
const [mobilePanel, setMobilePanel] = useState('list');
const searchTimer = useRef(null);
@@ -77,10 +88,20 @@ export default function ContactsPage() {
const totalRef = useRef(0);
const loadingMoreRef = useRef(false);
const searchRef = useRef('');
+ const photoReadRef = useRef(photoRead);
useEffect(() => { contactsRef.current = contacts; }, [contacts]);
useEffect(() => { totalRef.current = total; }, [total]);
+ const updatePhotoRead = useCallback(next => {
+ photoReadRef.current = next;
+ setPhotoRead(next);
+ }, []);
+
+ const invalidateCurrentPhotoRead = useCallback(() => {
+ updatePhotoRead(invalidatePhotoRead(photoReadRef.current));
+ }, [updatePhotoRead]);
+
const load = useCallback(async (q = '') => {
setLoading(true);
setListError(null);
@@ -96,7 +117,16 @@ export default function ContactsPage() {
}
}, []);
- useEffect(() => { load(''); }, [load]);
+ const loadConflictCount = useCallback(async () => {
+ try {
+ const result = await api.carddav.getConflicts();
+ setConflictCount(result.conflicts.length);
+ } catch {
+ // Contact loading remains usable when CardDAV health is temporarily unavailable.
+ }
+ }, []);
+
+ useEffect(() => { load(''); loadConflictCount(); }, [load, loadConflictCount]);
const onSearchChange = (e) => {
const val = e.target.value;
@@ -126,6 +156,7 @@ export default function ContactsPage() {
}, []);
const selectContact = async (c) => {
+ invalidateCurrentPhotoRead();
setError(null);
try {
const full = await api.getContact(c.id);
@@ -141,6 +172,7 @@ export default function ContactsPage() {
};
const startNew = () => {
+ invalidateCurrentPhotoRead();
setSelected(null);
setForm(emptyContact());
setEditing(false);
@@ -151,29 +183,27 @@ export default function ContactsPage() {
};
const goBackToList = () => {
+ invalidateCurrentPhotoRead();
setMobilePanel('list');
setSelected(null);
setShowNew(false);
setEditing(false);
+ setShowConflicts(false);
+ setActiveConflictId(null);
setError(null);
};
const startEdit = () => {
if (!selected) return;
- setForm({
- displayName: selected.display_name || '',
- firstName: selected.first_name || '',
- lastName: selected.last_name || '',
- emails: (selected.emails?.length ? selected.emails : EmptyEmailForm()),
- phones: selected.phones || [],
- organization: selected.organization || '',
- notes: selected.notes || '',
- });
+ if (!contactCarddavState(selected).canEdit) return;
+ invalidateCurrentPhotoRead();
+ setForm(contactToForm(selected));
setEditing(true);
setError(null);
};
const cancelEdit = () => {
+ invalidateCurrentPhotoRead();
if (showNew) {
setShowNew(false);
if (isMobile) setMobilePanel('list');
@@ -183,29 +213,14 @@ export default function ContactsPage() {
setError(null);
};
- const saveContact = async () => {
+ const submitContact = async (draft, isNew) => {
setSaving(true);
setError(null);
try {
- const derivedDisplayName =
- form.displayName.trim() ||
- [form.firstName.trim(), form.lastName.trim()].filter(Boolean).join(' ') ||
- null;
- const payload = {
- displayName: derivedDisplayName,
- firstName: form.firstName || null,
- lastName: form.lastName || null,
- emails: form.emails.filter(e => e.value.trim()),
- phones: form.phones.filter(p => p.value.trim()),
- organization: form.organization || null,
- notes: form.notes || null,
- };
- let saved;
- if (showNew) {
- saved = await api.createContact(payload);
- } else {
- saved = await api.updateContact(selected.id, payload);
- }
+ const payload = formToContactDraft(draft);
+ const saved = isNew
+ ? await api.createContact(payload)
+ : await api.updateContact(selected.id, payload);
// Reload list and re-fetch the saved contact before touching UI state,
// so that any error here is still shown inside the open form.
await load(search);
@@ -214,14 +229,36 @@ export default function ContactsPage() {
setEditing(false);
setSelected(updated);
} catch (err) {
- setError(err.message);
+ const failure = saveFailureState(err, draft);
+ if (failure.view === 'conflict') {
+ setActiveConflictId(failure.conflictId);
+ setShowConflicts(true);
+ setConflictCount(count => Math.max(1, count));
+ } else if (failure.view === 'refresh') {
+ // The write may have applied; MailFlow recovered read-only and reconciles on
+ // the next sync. Refresh confirmed state (surfacing the existing pending/sync
+ // marker) and keep the draft so the user can verify and retry if needed.
+ await load(search);
+ if (!isNew && selected) {
+ try { setSelected(await api.getContact(selected.id)); } catch { /* keep prior */ }
+ }
+ setError(t(failure.messageKey));
+ } else {
+ setError(failure.error);
+ }
} finally {
setSaving(false);
}
};
+ const saveContact = () => {
+ if (saving || photoReadRef.current.pending) return;
+ submitContact(form, showNew);
+ };
+
const deleteContact = async () => {
if (!selected) return;
+ if (!contactCarddavState(selected).canDelete) return;
setSaving(true);
try {
await api.deleteContact(selected.id);
@@ -230,7 +267,19 @@ export default function ContactsPage() {
if (isMobile) setMobilePanel('list');
await load(search);
} catch (err) {
- setError(err.message);
+ const failure = saveFailureState(err, null);
+ if (failure.view === 'conflict') {
+ setActiveConflictId(failure.conflictId);
+ setShowConflicts(true);
+ setConflictCount(count => Math.max(1, count));
+ } else if (failure.view === 'refresh') {
+ // The delete may have applied on the server; refresh the list and show honest
+ // copy rather than re-issuing a delete that could race the recovery.
+ await load(search);
+ setError(t('contacts.carddavWriteUnconfirmed'));
+ } else {
+ setError(failure.error);
+ }
} finally {
setSaving(false);
}
@@ -265,6 +314,97 @@ export default function ContactsPage() {
...f, phones: f.phones.filter((_, i) => i !== idx),
}));
+ const setAdditionalField = (id, patch) => setForm(f => ({
+ ...f,
+ additionalFields: f.additionalFields.map(field => (
+ field.id === id ? { ...field, ...patch } : field
+ )),
+ }));
+
+ const setAdditionalKind = (id, kind) => setForm(f => ({
+ ...f,
+ additionalFields: f.additionalFields.map(field => {
+ if (field.id !== id) return field;
+ const replacement = newAdditionalField(kind, () => id);
+ return { ...replacement, label: field.label };
+ }),
+ }));
+
+ const addAdditionalField = () => setForm(f => ({
+ ...f,
+ additionalFields: [...f.additionalFields, newAdditionalField('custom-text')],
+ }));
+
+ const removeAdditionalField = id => setForm(f => ({
+ ...f,
+ additionalFields: f.additionalFields.filter(field => field.id !== id),
+ }));
+
+ const readPhotoFile = (file, onPhotoData) => {
+ const validationKey = validateContactPhoto(file);
+ if (validationKey) {
+ invalidateCurrentPhotoRead();
+ setError(t(validationKey));
+ return;
+ }
+ const reading = beginPhotoRead(photoReadRef.current);
+ updatePhotoRead(reading);
+ const generation = reading.generation;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const completed = completePhotoRead(photoReadRef.current, generation);
+ if (!completed.accepted) return;
+ updatePhotoRead(completed.state);
+ setError(null);
+ onPhotoData(reader.result);
+ };
+ reader.onerror = () => {
+ const completed = completePhotoRead(photoReadRef.current, generation);
+ if (!completed.accepted) return;
+ updatePhotoRead(completed.state);
+ setError(t('contacts.photo.readFailed'));
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const setPhoto = file => readPhotoFile(file, photoData => {
+ setForm(f => ({ ...f, photoData, hasPhoto: true }));
+ });
+
+ // The read view has no draft: the picked photo goes straight through the edit form's
+ // save path, so a CardDAV contact keeps its pending/sync markers and remote push.
+ const uploadDetailPhoto = file => {
+ if (saving || !canUploadContactPhoto(selected)) return;
+ readPhotoFile(file, photoData => submitContact(
+ { ...contactToForm(selected), photoData, hasPhoto: true },
+ false,
+ ));
+ };
+
+ const removePhoto = () => {
+ invalidateCurrentPhotoRead();
+ setForm(removeContactPhoto);
+ };
+
+ const refreshAfterResolution = async resolvedConflict => {
+ await load(search);
+ if (shouldRefreshContactAfterResolution(selected, resolvedConflict)) {
+ try {
+ const updated = await api.getContact(selected.id);
+ invalidateCurrentPhotoRead();
+ setSelected(updated);
+ setEditing(false);
+ } catch (err) {
+ if (err.status === 404) {
+ setSelected(null);
+ setEditing(false);
+ } else {
+ setError(t('contacts.conflicts.refreshFailed'));
+ }
+ }
+ }
+ };
+
const inForm = editing || showNew;
// Shared list panel content (used by both mobile and desktop)
@@ -299,7 +439,7 @@ export default function ContactsPage() {
{c.display_name || c.primary_email}
@@ -344,7 +484,15 @@ export default function ContactsPage() {
// Shared detail / form content
const detailPanel = (
<>
- {!selected && !showNew && !isMobile && (
+ {showConflicts && (
+
{ setShowConflicts(false); setActiveConflictId(null); }}
+ onCountChange={setConflictCount}
+ onResolved={refreshAfterResolution}
+ />
+ )}
+ {!showConflicts && !selected && !showNew && !isMobile && (
{t('contacts.selectHint')}
)}
- {inForm && (
+ {!showConflicts && inForm && (
)}
- {selected && !inForm && (
+ {!showConflicts && selected && !inForm && (
setConfirmDelete(true)}
onDeleteConfirm={deleteContact}
onDeleteCancel={() => setConfirmDelete(false)}
+ onOpenConflict={conflictId => {
+ setActiveConflictId(conflictId);
+ setShowConflicts(true);
+ }}
t={t}
/>
)}
@@ -434,19 +595,35 @@ export default function ContactsPage() {
{mobilePanel === 'list' && (
-
-
-
-
-
+
+ {conflictCount > 0 && (
+ { setActiveConflictId(null); setShowConflicts(true); setMobilePanel('detail'); }}
+ title={t('contacts.conflicts.openCount', { count: conflictCount })}
+ style={{
+ background: 'none', border: 'none', color: 'var(--red, #f87171)',
+ cursor: 'pointer', padding: 0, fontSize: 12, fontWeight: 600,
+ minWidth: 44, minHeight: 44,
+ }}
+ >
+ {conflictCount}
+
+ )}
+
+
+
+
+
+
)}
@@ -495,20 +672,36 @@ export default function ContactsPage() {
}}>
{/* Header */}
-
+
{t('contacts.title')}
-
- + {t('contacts.new')}
-
+
+ {conflictCount > 0 && (
+ { setActiveConflictId(null); setShowConflicts(true); }}
+ title={t('contacts.conflicts.openCount', { count: conflictCount })}
+ style={{
+ background: 'var(--red-dim, rgba(248,113,113,0.1))',
+ border: '1px solid var(--red-border, rgba(248,113,113,0.3))',
+ borderRadius: 6, color: 'var(--red, #f87171)', fontSize: 11,
+ fontWeight: 600, padding: '4px 8px', cursor: 'pointer',
+ }}
+ >
+ {conflictCount}
+
+ )}
+
+ + {t('contacts.new')}
+
+
+ ) :
;
+
return (
-
- {/* Edit/Delete for editable contacts — out of flow, top-right (fixed width). */}
- {!c.read_only && (
-
-
{t('common.edit')}
-
{t('common.delete')}
+
+
+
+ {canUploadPhoto ? (
+ <>
+
{
+ const file = event.target.files?.[0];
+ if (file) onSetPhoto(file);
+ event.target.value = '';
+ }}
+ style={{ display: 'none' }}
+ />
+
photoInputRef.current?.click()}
+ disabled={photoBusy}
+ title={t('contacts.photo.choose')}
+ aria-label={t('contacts.photo.choose')}
+ style={{
+ display: 'flex', padding: 0, borderRadius: '50%',
+ background: 'none', border: 'none', flexShrink: 0,
+ cursor: photoBusy ? 'not-allowed' : 'pointer',
+ opacity: photoBusy ? 0.6 : 1,
+ }}
+ >
+ {avatar}
+
+ >
+ ) : avatar}
+
+
+ {c.display_name || c.primary_email}
+
+ {c.organization && (
+
{c.organization}
+ )}
+ {c.is_auto && (
+
{t('contacts.autoHint')}
+ )}
+
- )}
-
-
-
-
- {c.display_name || c.primary_email}
-
- {c.organization && (
-
{c.organization}
- )}
- {/* CardDAV badge sits in flow below the name so it can never overlap it, whatever
- the badge's translated width. */}
- {c.read_only && (
-
- {t('contacts.carddavBadge')}
-
- )}
- {c.is_auto && (
-
{t('contacts.autoHint')}
+
+ {carddav.labelKey && (
+
onOpenConflict(carddav.conflictId) : undefined}
+ disabled={!carddav.conflictId}
+ style={{
+ fontSize: 11, padding: '4px 10px', borderRadius: 100,
+ background: carddav.conflictId
+ ? 'var(--red-dim, rgba(248,113,113,0.1))'
+ : pendingSync ? 'var(--amber-dim, rgba(245,158,11,0.12))' : 'var(--bg-tertiary)',
+ color: carddav.conflictId
+ ? 'var(--red, #f87171)'
+ : pendingSync ? 'var(--amber, #f59e0b)' : 'var(--text-tertiary)',
+ border: '1px solid var(--border)', whiteSpace: 'nowrap',
+ cursor: carddav.conflictId ? 'pointer' : 'default',
+ }}
+ >
+ {t(carddav.labelKey)}
+
)}
+ {carddav.canEdit &&
{t('common.edit')} }
+ {carddav.canDelete &&
{t('common.delete')} }
@@ -588,7 +836,7 @@ function ContactDetail({ contact: c, confirmDelete, saving, error, onEdit, onDel
)}
- {((c.emails?.length > 0) || (c.phones?.length > 0) || c.notes) && (
+ {((c.emails?.length > 0) || (c.phones?.length > 0) || c.notes || (c.additional_fields?.length > 0) || c.has_photo) && (
{(c.emails || []).map((e, i) => (
@@ -601,6 +849,14 @@ function ContactDetail({ contact: c, confirmDelete, saving, error, onEdit, onDel
))}
{c.notes && {c.notes} }
+ {(c.additional_fields || []).map(field => (
+
+ {formatContactValue(field.value, t)}
+
+ ))}
+ {c.has_photo && !c.photo_data && (
+ {t('contacts.photo.present')}
+ )}
)}
@@ -621,11 +877,16 @@ function ContactDetail({ contact: c, confirmDelete, saving, error, onEdit, onDel
}
function ContactForm({
- form, isNew, saving, error,
+ form, isNew, saving, photoReading, error,
onField, onSetEmail, onAddEmail, onRemoveEmail,
onSetPhone, onAddPhone, onRemovePhone,
+ onSetAdditional, onSetAdditionalKind, onAddAdditional, onRemoveAdditional,
+ onSetPhoto, onRemovePhoto,
onSave, onCancel, t,
}) {
+ const photoInputRef = useRef(null);
+ const [rawLabelIds, setRawLabelIds] = useState(() => new Set());
+ const savePending = saving || photoReading;
const inputStyle = {
width: '100%', boxSizing: 'border-box',
padding: '8px 10px', borderRadius: 7,
@@ -664,6 +925,52 @@ function ContactForm({
onField('organization', e.target.value)} />
+
+
{t('contacts.photo.title')}
+
+ {form.photoData ? (
+
+ ) : (
+
+ {form.hasPhoto ? t('contacts.photo.present') : t('contacts.photo.none')}
+
+ )}
+
{
+ const file = event.target.files?.[0];
+ if (file) onSetPhoto(file);
+ event.target.value = '';
+ }}
+ style={{ display: 'none' }}
+ />
+
photoInputRef.current?.click()}
+ disabled={saving}
+ style={{ ...addFieldBtn, cursor: saving ? 'not-allowed' : 'pointer' }}
+ >
+ {t('contacts.photo.choose')}
+
+ {photoReading && (
+
{t('common.loading')}
+ )}
+ {form.hasPhoto && (
+
+ {t('contacts.photo.remove')}
+
+ )}
+
+
+
{/* Emails */}
{t('contacts.fields.email')}
@@ -679,7 +986,7 @@ function ContactForm({
onSetEmail(i, 'type', ev.target.value)}
- style={{ ...inputStyle, width: 80, padding: '8px 6px' }}
+ style={{ ...inputStyle, width: 80, cursor: 'pointer' }}
>
{t('contacts.emailTypes.other')}
{t('contacts.emailTypes.work')}
@@ -710,7 +1017,7 @@ function ContactForm({
onSetPhone(i, 'type', ev.target.value)}
- style={{ ...inputStyle, width: 90, padding: '8px 6px' }}
+ style={{ ...inputStyle, width: 90, cursor: 'pointer' }}
>
{t('contacts.phoneTypes.mobile')}
{t('contacts.phoneTypes.work')}
@@ -735,15 +1042,53 @@ function ContactForm({
/>
+
+
{t('contacts.additional.title')}
+ {form.additionalFields.map(field => (
+
+
+ onSetAdditionalKind(field.id, event.target.value)}
+ style={{ ...inputStyle, padding: '7px 6px' }}
+ >
+ {ADDITIONAL_FIELD_KINDS.map(kind => (
+ {t(`contacts.additional.types.${kind}`)}
+ ))}
+
+ setRawLabelIds(ids => new Set(ids).add(field.id))}
+ onChange={event => onSetAdditional(field.id, { label: event.target.value })}
+ />
+ onRemoveAdditional(field.id)} style={removeBtn}>
+
+
+
+
onSetAdditional(field.id, { value })}
+ t={t}
+ />
+
+ ))}
+
+ {t('contacts.additional.add')}
+
+
{saving ? t('common.saving') : t('common.save')}
@@ -754,6 +1099,88 @@ function ContactForm({
);
}
+function AdditionalFieldInput({ field, inputStyle, onChange, t }) {
+ const value = field.value;
+ if (field.kind === 'postal-address') {
+ const address = value || {};
+ const addressParts = [
+ ['street', 'contacts.additional.street'],
+ ['extendedAddress', 'contacts.additional.extendedAddress'],
+ ['locality', 'contacts.additional.locality'],
+ ['region', 'contacts.additional.region'],
+ ['postalCode', 'contacts.additional.postalCode'],
+ ['country', 'contacts.additional.country'],
+ ['poBox', 'contacts.additional.poBox'],
+ ];
+ return (
+
+ {addressParts.map(([key, labelKey]) => (
+ onChange({ ...address, [key]: event.target.value })}
+ />
+ ))}
+
+ );
+ }
+ if (field.kind === 'im') {
+ const im = value || {};
+ return (
+
+ onChange({ ...im, protocol: event.target.value })}
+ />
+ onChange({ ...im, handle: event.target.value })}
+ />
+
+ );
+ }
+ if (field.kind === 'geo') {
+ const geo = value || {};
+ const coordinate = raw => raw === '' ? '' : Number(raw);
+ return (
+
+ onChange({ ...geo, latitude: coordinate(event.target.value) })}
+ />
+ onChange({ ...geo, longitude: coordinate(event.target.value) })}
+ />
+
+ );
+ }
+
+ return (
+ onChange(event.target.value)}
+ />
+ );
+}
+
function DetailSection({ children }) {
return (
'pending_materialization', so a
+// pending_materialization mapping reads as sync_state 'local', never pending_materialization.
+const PENDING_SYNC_STATES = new Set(['pending_push']);
+
+export function contactCarddavState(contact) {
+ if (!isCardDavContact(contact)) {
+ return { labelKey: null, canEdit: true, canDelete: true, conflictId: null };
+ }
+
+ const conflictId = contact.conflict_id || null;
+ if (contact.sync_state === 'conflict' || conflictId) {
+ return {
+ labelKey: 'contacts.carddavConflict',
+ canEdit: false,
+ canDelete: false,
+ conflictId,
+ };
+ }
+
+ const canEdit = contact.remote_update_capability !== 'denied';
+ const canDelete = contact.remote_delete_capability !== 'denied';
+ if (PENDING_SYNC_STATES.has(contact.sync_state)) {
+ // A distinct pending marker: the contact stays editable (a re-edit is fenced by the
+ // backend's pending-intent handling), it just is not fully synced yet.
+ return { labelKey: 'contacts.carddavPending', canEdit, canDelete, conflictId: null };
+ }
+ return {
+ labelKey: canEdit || canDelete ? 'contacts.carddavSynced' : 'contacts.carddavReadOnly',
+ canEdit,
+ canDelete,
+ conflictId: null,
+ };
+}
+
+// The detail view's avatar uploads a photo through the same update as the edit form, so it
+// is offered on exactly the contacts that update is allowed on.
+export function canUploadContactPhoto(contact) {
+ return Boolean(contact) && contactCarddavState(contact).canEdit;
+}
+
+export function cloneFields(fields) {
+ return (fields || []).map(field => ({
+ id: field?.id,
+ kind: field?.kind,
+ label: field?.label,
+ value: field?.value && typeof field.value === 'object'
+ ? { ...field.value }
+ : field?.value,
+ }));
+}
+
+export function contactToForm(contact = {}) {
+ return {
+ displayName: contact.display_name || '',
+ firstName: contact.first_name || '',
+ lastName: contact.last_name || '',
+ emails: contact.emails?.length
+ ? contact.emails.map(email => ({ ...email }))
+ : [{ value: '', type: 'other', primary: true }],
+ phones: (contact.phones || []).map(phone => ({ ...phone })),
+ organization: contact.organization || '',
+ notes: contact.notes || '',
+ photoData: contact.photo_data || null,
+ hasPhoto: Boolean(contact.has_photo || contact.photo_data),
+ additionalFields: cloneFields(contact.additional_fields),
+ carddav: {
+ syncState: contact.sync_state || 'local',
+ capabilities: {
+ create: contact.remote_create_capability || null,
+ update: contact.remote_update_capability || null,
+ delete: contact.remote_delete_capability || null,
+ },
+ conflictId: contact.conflict_id || null,
+ },
+ };
+}
+
+export function formToContactDraft(form) {
+ const displayName = form.displayName.trim()
+ || [form.firstName.trim(), form.lastName.trim()].filter(Boolean).join(' ')
+ || null;
+ return {
+ displayName,
+ firstName: form.firstName || null,
+ lastName: form.lastName || null,
+ emails: form.emails.filter(email => email.value.trim()),
+ phones: form.phones.filter(phone => phone.value.trim()),
+ organization: form.organization || null,
+ notes: form.notes || null,
+ photoData: form.photoData,
+ additionalFields: cloneFields(form.additionalFields),
+ };
+}
+
+function emptyAdditionalValue(kind) {
+ if (kind === 'postal-address') {
+ return {
+ poBox: '',
+ extendedAddress: '',
+ street: '',
+ locality: '',
+ region: '',
+ postalCode: '',
+ country: '',
+ };
+ }
+ if (kind === 'im') return { protocol: '', handle: '' };
+ if (kind === 'geo') return { latitude: '', longitude: '' };
+ return '';
+}
+
+export function newAdditionalField(kind, makeId = () => crypto.randomUUID()) {
+ return {
+ id: makeId(),
+ kind,
+ label: '',
+ value: emptyAdditionalValue(kind),
+ };
+}
+
+export function removeContactPhoto(form) {
+ return { ...form, photoData: null, hasPhoto: false };
+}
+
+export function validateContactPhoto(file) {
+ if (!PHOTO_TYPES.has(file?.type)) return 'contacts.photo.invalidType';
+ if (file.size > MAX_PHOTO_BYTES) return 'contacts.photo.tooLarge';
+ return null;
+}
+
+export function initialPhotoReadState() {
+ return { generation: 0, pending: false };
+}
+
+export function beginPhotoRead(state) {
+ return { generation: state.generation + 1, pending: true };
+}
+
+export function invalidatePhotoRead(state) {
+ return { generation: state.generation + 1, pending: false };
+}
+
+export function completePhotoRead(state, generation) {
+ if (!state.pending || state.generation !== generation) {
+ return { state, accepted: false };
+ }
+ return {
+ state: { generation: state.generation, pending: false },
+ accepted: true,
+ };
+}
+
+export function shouldRefreshContactAfterResolution(contact, conflict) {
+ const uid = contact?.uid;
+ return Boolean(uid && [
+ conflict?.local?.contact?.uid,
+ conflict?.remote?.contact?.uid,
+ ].includes(uid));
+}
+
+// A post-write 409 whose body carries an ambiguous-write / pending-intent code: the
+// remote effect may have landed and MailFlow recovered read-only, so the client must
+// refresh state (re-GET) rather than blindly re-issue the mutation.
+const REFRESH_WRITE_CODES = new Set([
+ 'ERR_CARDDAV_AMBIGUOUS_WRITE',
+ 'ERR_CARDDAV_PENDING_INTENT',
+]);
+
+export function isRefreshableWriteError(error) {
+ return error?.status === 409 && REFRESH_WRITE_CODES.has(error?.data?.code);
+}
+
+export function saveFailureState(error, form) {
+ if (error?.status === 409 && error.conflictId) {
+ return {
+ view: 'conflict',
+ conflictId: error.conflictId,
+ form,
+ error: null,
+ };
+ }
+ if (isRefreshableWriteError(error)) {
+ // Keep the draft, signal the caller to re-GET the contact, and show honest copy —
+ // the write may have applied and reconciles on the next sync; don't re-issue it.
+ return {
+ view: 'refresh',
+ conflictId: null,
+ form,
+ error: null,
+ messageKey: 'contacts.carddavWriteUnconfirmed',
+ };
+ }
+ return {
+ view: 'form',
+ conflictId: null,
+ form,
+ error: error?.message || 'Request failed',
+ };
+}
diff --git a/frontend/src/contactCarddavState.test.js b/frontend/src/contactCarddavState.test.js
new file mode 100644
index 0000000..3c4c079
--- /dev/null
+++ b/frontend/src/contactCarddavState.test.js
@@ -0,0 +1,345 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import {
+ ADDITIONAL_FIELD_KINDS,
+ beginPhotoRead,
+ canUploadContactPhoto,
+ completePhotoRead,
+ contactCarddavState,
+ contactToForm,
+ formToContactDraft,
+ initialPhotoReadState,
+ invalidatePhotoRead,
+ isRefreshableWriteError,
+ newAdditionalField,
+ removeContactPhoto,
+ saveFailureState,
+ shouldRefreshContactAfterResolution,
+ validateContactPhoto,
+} from './contactCarddavState.js';
+import * as contactCarddavHelpers from './contactCarddavState.js';
+import { api } from './utils/api.js';
+
+const testDir = dirname(fileURLToPath(import.meta.url));
+
+describe('CardDAV contact state', () => {
+ it('labels writable, operation-read-only, and conflicted contacts from backend capabilities', () => {
+ assert.deepEqual(contactCarddavState({
+ sync_state: 'synced',
+ remote_update_capability: 'allowed',
+ remote_delete_capability: 'denied',
+ }), {
+ labelKey: 'contacts.carddavSynced',
+ canEdit: true,
+ canDelete: false,
+ conflictId: null,
+ });
+ assert.deepEqual(contactCarddavState({
+ sync_state: 'synced',
+ remote_update_capability: 'denied',
+ remote_delete_capability: 'denied',
+ }), {
+ labelKey: 'contacts.carddavReadOnly',
+ canEdit: false,
+ canDelete: false,
+ conflictId: null,
+ });
+ assert.deepEqual(contactCarddavState({
+ sync_state: 'conflict',
+ conflict_id: 'conflict-1',
+ remote_update_capability: 'allowed',
+ remote_delete_capability: 'allowed',
+ }), {
+ labelKey: 'contacts.carddavConflict',
+ canEdit: false,
+ canDelete: false,
+ conflictId: 'conflict-1',
+ });
+ });
+
+ it('marks a write-unconfirmed contact as pending, not fully synced, and keeps it editable', () => {
+ // pending_push is the only pending sync_state the contacts API can surface.
+ assert.deepEqual(contactCarddavState({
+ sync_state: 'pending_push',
+ remote_update_capability: 'allowed',
+ remote_delete_capability: 'allowed',
+ }), {
+ labelKey: 'contacts.carddavPending',
+ canEdit: true,
+ canDelete: true,
+ conflictId: null,
+ });
+ // A conflict still wins over pending, and a denied capability still gates the action.
+ assert.equal(contactCarddavState({ sync_state: 'pending_push', conflict_id: 'c1' }).labelKey,
+ 'contacts.carddavConflict');
+ assert.equal(contactCarddavState({
+ sync_state: 'pending_push',
+ remote_update_capability: 'denied',
+ remote_delete_capability: 'denied',
+ }).canEdit, false);
+ // A genuinely synced contact is unchanged.
+ assert.equal(contactCarddavState({ sync_state: 'synced', remote_update_capability: 'allowed' }).labelKey,
+ 'contacts.carddavSynced');
+ });
+
+ it('initializes an editable form without dropping photos or typed Additional fields', () => {
+ const contact = {
+ display_name: 'Ada Lovelace',
+ first_name: 'Ada',
+ last_name: 'Lovelace',
+ emails: [{ value: 'ada@example.test', type: 'work', primary: true }],
+ phones: [{ value: '+1 555 0100', type: 'mobile' }],
+ organization: 'Analytical Engines',
+ notes: 'First programmer',
+ photo_data: 'data:image/png;base64,AQID',
+ has_photo: true,
+ sync_state: 'synced',
+ remote_create_capability: 'allowed',
+ remote_update_capability: 'unknown',
+ remote_delete_capability: 'denied',
+ conflict_id: 'conflict-1',
+ additional_fields: [{
+ id: 'stable-site',
+ kind: 'url',
+ label: 'Portfolio',
+ value: 'https://example.test/ada',
+ vcard: {
+ group: 'item1',
+ name: 'URL',
+ parameters: { TYPE: ['work'] },
+ },
+ }],
+ };
+ const editableAdditionalFields = [{
+ id: 'stable-site',
+ kind: 'url',
+ label: 'Portfolio',
+ value: 'https://example.test/ada',
+ }];
+
+ const form = contactToForm(contact);
+ assert.equal(form.photoData, contact.photo_data);
+ assert.equal(form.hasPhoto, true);
+ assert.deepEqual(form.carddav, {
+ syncState: 'synced',
+ capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' },
+ conflictId: 'conflict-1',
+ });
+ assert.deepEqual(form.additionalFields, editableAdditionalFields);
+ assert.notEqual(form.additionalFields, contact.additional_fields);
+ assert.equal(JSON.stringify(form.additionalFields).includes('vcard'), false);
+ assert.deepEqual(formToContactDraft(form), {
+ displayName: 'Ada Lovelace',
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ emails: contact.emails,
+ phones: contact.phones,
+ organization: 'Analytical Engines',
+ notes: 'First programmer',
+ photoData: contact.photo_data,
+ additionalFields: editableAdditionalFields,
+ });
+ assert.equal(JSON.stringify(formToContactDraft(form)).includes('parameters'), false);
+ });
+
+ it('supports every projected Additional-field kind and gives new fields stable IDs', () => {
+ assert.deepEqual(ADDITIONAL_FIELD_KINDS, [
+ 'postal-address', 'url', 'im', 'birthday', 'anniversary', 'date',
+ 'role', 'title', 'nickname', 'geo', 'custom-text',
+ ]);
+
+ const field = newAdditionalField('im', () => 'stable-generated-id');
+ assert.deepEqual(field, {
+ id: 'stable-generated-id',
+ kind: 'im',
+ label: '',
+ value: { protocol: '', handle: '' },
+ });
+ });
+
+ it('round-trips valid vCard date shapes through lossless text controls', () => {
+ const additionalFields = [
+ { id: 'basic', kind: 'birthday', label: 'Basic', value: '19960415' },
+ { id: 'partial', kind: 'anniversary', label: 'Partial', value: '--0415' },
+ { id: 'date-time', kind: 'date', label: 'Date-time', value: '19960415T143000Z' },
+ { id: 'text', kind: 'birthday', label: 'Text', value: 'circa 1996' },
+ ];
+
+ const form = contactToForm({ additional_fields: additionalFields });
+
+ assert.deepEqual(form.additionalFields.map(field => field.value), [
+ '19960415', '--0415', '19960415T143000Z', 'circa 1996',
+ ]);
+ assert.equal(typeof contactCarddavHelpers.additionalFieldInputType, 'function');
+ assert.deepEqual(form.additionalFields.map(field => ({
+ type: contactCarddavHelpers.additionalFieldInputType(field.kind),
+ value: field.value,
+ })), additionalFields.map(field => ({ type: 'text', value: field.value })));
+ assert.deepEqual(formToContactDraft(form).additionalFields, additionalFields);
+
+ const contactsPage = readFileSync(join(testDir, 'components', 'ContactsPage.jsx'), 'utf8');
+ assert.match(contactsPage, /type=\{additionalFieldInputType\(field\.kind\)\}/);
+ });
+
+ it('keeps contact status and actions in a wrapping in-flow header', () => {
+ const contactsPage = readFileSync(join(testDir, 'components', 'ContactsPage.jsx'), 'utf8');
+ const detail = contactsPage.slice(
+ contactsPage.indexOf('function ContactDetail'),
+ contactsPage.indexOf('function ContactForm'),
+ );
+
+ assert.match(detail, /flexWrap: 'wrap'/);
+ assert.doesNotMatch(detail, /paddingRight: 128/);
+ });
+
+ it('marks photo removal explicitly and rejects oversized or non-image uploads', () => {
+ assert.deepEqual(removeContactPhoto({
+ photoData: 'data:image/jpeg;base64,AQID',
+ hasPhoto: true,
+ displayName: 'Ada',
+ }), {
+ photoData: null,
+ hasPhoto: false,
+ displayName: 'Ada',
+ });
+ assert.equal(validateContactPhoto({ type: 'image/png', size: 512 * 1024 }), null);
+ assert.equal(validateContactPhoto({ type: 'image/svg+xml', size: 100 }), 'contacts.photo.invalidType');
+ assert.equal(validateContactPhoto({ type: 'image/jpeg', size: 512 * 1024 + 1 }), 'contacts.photo.tooLarge');
+ });
+
+ it('offers the detail-view photo upload only where the remote accepts an update', () => {
+ assert.equal(canUploadContactPhoto({ id: 1 }), true);
+ assert.equal(canUploadContactPhoto({
+ sync_state: 'synced',
+ remote_update_capability: 'allowed',
+ remote_delete_capability: 'denied',
+ }), true);
+ assert.equal(canUploadContactPhoto({
+ sync_state: 'pending_push',
+ remote_update_capability: 'allowed',
+ }), true);
+ assert.equal(canUploadContactPhoto({
+ sync_state: 'synced',
+ remote_update_capability: 'denied',
+ }), false);
+ assert.equal(canUploadContactPhoto({ sync_state: 'conflict', conflict_id: 'conflict-1' }), false);
+ assert.equal(canUploadContactPhoto(null), false);
+ });
+
+ it('uploads the detail-view photo from a focusable control through the edit save path', () => {
+ const contactsPage = readFileSync(join(testDir, 'components', 'ContactsPage.jsx'), 'utf8');
+ const detail = contactsPage.slice(
+ contactsPage.indexOf('function ContactDetail'),
+ contactsPage.indexOf('function ContactForm'),
+ );
+
+ assert.match(detail, /canUploadContactPhoto\(c\)/);
+ assert.match(detail, /
]*onClick/);
+ assert.match(contactsPage, /const uploadDetailPhoto = file =>/);
+ assert.match(contactsPage, /readPhotoFile\(file, photoData => submitContact\(/);
+ });
+
+ it('keeps photo reading pending and ignores a callback after the form changes', () => {
+ const reading = beginPhotoRead(initialPhotoReadState());
+ assert.equal(reading.pending, true);
+
+ const changedForm = invalidatePhotoRead(reading);
+ assert.equal(changedForm.pending, false);
+ assert.equal(changedForm.generation, reading.generation + 1);
+ assert.deepEqual(completePhotoRead(changedForm, reading.generation), {
+ state: changedForm,
+ accepted: false,
+ });
+ });
+
+ it('accepts only the newest photo read callback', () => {
+ const first = beginPhotoRead(initialPhotoReadState());
+ const second = beginPhotoRead(first);
+
+ assert.deepEqual(completePhotoRead(second, first.generation), {
+ state: second,
+ accepted: false,
+ });
+ assert.deepEqual(completePhotoRead(second, second.generation), {
+ state: { generation: second.generation, pending: false },
+ accepted: true,
+ });
+ });
+
+ it('navigates a 409 to its conflict while retaining the exact draft object', () => {
+ const form = { displayName: 'Unsaved edit' };
+ const error = Object.assign(new Error('Request failed'), {
+ status: 409,
+ conflictId: 'conflict-1',
+ });
+
+ assert.deepEqual(saveFailureState(error, form), {
+ view: 'conflict',
+ conflictId: 'conflict-1',
+ form,
+ error: null,
+ });
+ assert.equal(saveFailureState(error, form).form, form);
+ });
+
+ it('routes a 409 ambiguous/pending write to a refresh view that keeps the draft', () => {
+ const form = { displayName: 'Unsaved edit' };
+ for (const code of ['ERR_CARDDAV_AMBIGUOUS_WRITE', 'ERR_CARDDAV_PENDING_INTENT']) {
+ const error = Object.assign(new Error('unconfirmed'), {
+ status: 409,
+ data: { error: 'unconfirmed', code, refresh: true },
+ });
+ assert.equal(isRefreshableWriteError(error), true);
+ assert.deepEqual(saveFailureState(error, form), {
+ view: 'refresh',
+ conflictId: null,
+ form,
+ error: null,
+ messageKey: 'contacts.carddavWriteUnconfirmed',
+ });
+ assert.equal(saveFailureState(error, form).form, form);
+ }
+ // A conflictId 409 still routes to conflict; other errors stay a form error.
+ const conflict = Object.assign(new Error('x'), { status: 409, conflictId: 'c1' });
+ assert.equal(isRefreshableWriteError(conflict), false);
+ assert.equal(saveFailureState(conflict, form).view, 'conflict');
+ assert.equal(saveFailureState(Object.assign(new Error('boom'), { status: 500 }), form).view, 'form');
+ });
+
+ it('refreshes only the selected contact represented by the resolved conflict', () => {
+ const conflict = {
+ local: { tombstone: true, contact: null },
+ remote: { tombstone: false, contact: { uid: 'contact-1' } },
+ };
+
+ assert.equal(shouldRefreshContactAfterResolution({ uid: 'contact-1' }, conflict), true);
+ assert.equal(shouldRefreshContactAfterResolution({ uid: 'contact-2' }, conflict), false);
+ assert.equal(shouldRefreshContactAfterResolution(null, conflict), false);
+ });
+
+ it('preserves HTTP 409 metadata needed for conflict navigation', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => ({
+ ok: false,
+ status: 409,
+ json: async () => ({ conflictId: 'conflict-1' }),
+ });
+
+ try {
+ await assert.rejects(api.updateContact('contact-1', { displayName: 'Draft' }), error => {
+ assert.equal(error.status, 409);
+ assert.equal(error.conflictId, 'conflict-1');
+ assert.deepEqual(error.data, { conflictId: 'conflict-1' });
+ return true;
+ });
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+});
diff --git a/frontend/src/contactLabels.js b/frontend/src/contactLabels.js
new file mode 100644
index 0000000..663fb35
--- /dev/null
+++ b/frontend/src/contactLabels.js
@@ -0,0 +1,57 @@
+// Apple Contacts encodes its well-known X-ABLABEL values in a sentinel wrapper
+// (`_$!!$_`), and a vCard property with no X-ABLABEL falls back to its raw TYPE
+// token (`WORK`, `HOME`). CardDAV servers hand both through verbatim, so they reach the
+// client as the label of an additional field and must be humanized for display only —
+// the stored label is what the retained vCard round-trips.
+const APPLE_LABEL = /^_\$!<(.*)>!\$_$/;
+
+// Well-known label tokens, lowercased so uppercase TYPE tokens resolve like Apple's
+// mixed-case labels, mapped onto localized copy the app already ships.
+const LOCALIZED_LABELS = new Map([
+ ['home', 'contacts.emailTypes.home'],
+ ['work', 'contacts.emailTypes.work'],
+ ['other', 'contacts.emailTypes.other'],
+ ['mobile', 'contacts.phoneTypes.mobile'],
+ ['cell', 'contacts.phoneTypes.mobile'],
+ ['iphone', 'contacts.phoneTypes.mobile'],
+ ['homepage', 'contacts.additional.types.url'],
+ ['url', 'contacts.additional.types.url'],
+ ['birthday', 'contacts.additional.types.birthday'],
+ ['anniversary', 'contacts.additional.types.anniversary'],
+ ['nickname', 'contacts.additional.types.nickname'],
+ ['role', 'contacts.additional.types.role'],
+ ['title', 'contacts.additional.types.title'],
+]);
+
+export const CONTACT_LABEL_KEYS = [...new Set(LOCALIZED_LABELS.values())];
+
+function titleCase(token) {
+ return token
+ .split(/\s+/)
+ .filter(Boolean)
+ .map(word => word[0].toUpperCase() + word.slice(1).toLowerCase())
+ .join(' ');
+}
+
+export function humanizeContactLabel(label, t) {
+ const raw = String(label ?? '').trim();
+ const inner = (APPLE_LABEL.exec(raw)?.[1] ?? raw).trim();
+ if (!inner) return '';
+ const key = LOCALIZED_LABELS.get(inner.toLowerCase());
+ return key ? t(key) : titleCase(inner);
+}
+
+export function formatContactValue(value, t) {
+ if (value === null || value === undefined || value === '') return '—';
+ if (Array.isArray(value)) return value.length ? value.map(item => formatContactValue(item, t)) : '—';
+ if (typeof value !== 'object') return String(value);
+ if (value.kind) {
+ const label = humanizeContactLabel(value.label || value.kind, t)
+ || t(`contacts.additional.types.${value.kind}`);
+ return `${label}: ${formatContactValue(value.value, t)}`;
+ }
+ return Object.values(value)
+ .filter(part => part !== null && part !== undefined && part !== '' && typeof part !== 'boolean')
+ .map(String)
+ .join(' · ') || '—';
+}
diff --git a/frontend/src/contactLabels.test.js b/frontend/src/contactLabels.test.js
new file mode 100644
index 0000000..0c2e98e
--- /dev/null
+++ b/frontend/src/contactLabels.test.js
@@ -0,0 +1,107 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { CONTACT_LABEL_KEYS, formatContactValue, humanizeContactLabel } from './contactLabels.js';
+import { contactToForm, formToContactDraft } from './contactCarddavState.js';
+
+const testDir = dirname(fileURLToPath(import.meta.url));
+
+function valueAt(object, path) {
+ return path.split('.').reduce((value, key) => value?.[key], object);
+}
+
+function localeT(locale) {
+ const messages = JSON.parse(readFileSync(join(testDir, 'locales', `${locale}.json`), 'utf8'));
+ return key => {
+ const value = valueAt(messages, key);
+ assert.equal(typeof value, 'string', `${locale} is missing ${key}`);
+ return value;
+ };
+}
+
+const t = localeT('en');
+
+describe('contact label humanizer', () => {
+ it('humanizes the raw Apple/Nextcloud label artifacts seen in production', () => {
+ // Exactly what a Nextcloud collection of Apple-authored contacts hands the client.
+ assert.equal(humanizeContactLabel('_$!!$_', t), 'Web address');
+ assert.equal(humanizeContactLabel('WORK', t), 'Work');
+ assert.equal(humanizeContactLabel('HOME', t), 'Home');
+ });
+
+ it('resolves well-known labels case-insensitively, wrapped or bare', () => {
+ for (const label of ['work', 'Work', 'WORK', '_$!!$_', '_$!!$_']) {
+ assert.equal(humanizeContactLabel(label, t), 'Work');
+ }
+ assert.equal(humanizeContactLabel('_$!!$_', t), 'Other');
+ assert.equal(humanizeContactLabel('CELL', t), 'Mobile');
+ assert.equal(humanizeContactLabel('_$!!$_', t), 'Mobile');
+ assert.equal(humanizeContactLabel('_$!!$_', t), 'Anniversary');
+ });
+
+ it('reuses the localized copy the app already ships, in every locale', () => {
+ const de = localeT('de');
+ assert.equal(humanizeContactLabel('WORK', de), 'Arbeit');
+ assert.equal(humanizeContactLabel('_$!!$_', de), 'Webadresse');
+ const zhCN = localeT('zhCN');
+ assert.equal(humanizeContactLabel('HOME', zhCN), '家庭');
+
+ // Every mapped key must be an existing key — no parallel copy for labels.
+ const en = JSON.parse(readFileSync(join(testDir, 'locales', 'en.json'), 'utf8'));
+ const missing = CONTACT_LABEL_KEYS.filter(key => typeof valueAt(en, key) !== 'string');
+ assert.deepEqual(missing, []);
+ });
+
+ it('strips the wrapper and title-cases anything it does not know', () => {
+ assert.equal(humanizeContactLabel('_$!!$_', t), 'Fax');
+ assert.equal(humanizeContactLabel('PAGER', t), 'Pager');
+ assert.equal(humanizeContactLabel('MAIN', t), 'Main');
+ assert.equal(humanizeContactLabel('_$!!$_', t), 'Ski Cabin');
+ assert.equal(humanizeContactLabel('', t), '');
+ assert.equal(humanizeContactLabel(null, t), '');
+ assert.equal(humanizeContactLabel(undefined, t), '');
+ });
+
+ it('formats vCard values with humanized labels and omits boolean metadata', () => {
+ assert.equal(formatContactValue({
+ kind: 'url',
+ label: '_$!!$_',
+ value: 'https://example.test',
+ }, t), 'Web address: https://example.test');
+ assert.equal(formatContactValue({
+ value: 'ada@example.test',
+ type: 'work',
+ primary: true,
+ }, t), 'ada@example.test · work');
+ });
+
+ it('is presentation only: the stored label still round-trips byte-for-byte', () => {
+ const label = '_$!!$_';
+ const form = contactToForm({
+ additional_fields: [{ id: 'vcard-1', kind: 'url', label, value: 'https://example.test' }],
+ });
+
+ assert.equal(form.additionalFields[0].label, label);
+ assert.equal(formToContactDraft(form).additionalFields[0].label, label);
+ });
+
+ it('humanizes labels in both the detail and the edit rendering', () => {
+ const contactsPage = readFileSync(join(testDir, 'components', 'ContactsPage.jsx'), 'utf8');
+ const detail = contactsPage.slice(
+ contactsPage.indexOf('function ContactDetail'),
+ contactsPage.indexOf('function ContactForm'),
+ );
+ const form = contactsPage.slice(
+ contactsPage.indexOf('function ContactForm'),
+ contactsPage.indexOf('function AdditionalFieldInput'),
+ );
+
+ assert.match(detail, /humanizeContactLabel\(field\.label, t\)/);
+ assert.match(form, /humanizeContactLabel\(field\.label, t\)/);
+ // The edit control still writes the raw value the user typed, never the humanized one.
+ assert.match(form, /onSetAdditional\(field\.id, \{ label: event\.target\.value \}\)/);
+ });
+});
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json
index c8b7f57..54fd0a1 100644
--- a/frontend/src/locales/de.json
+++ b/frontend/src/locales/de.json
@@ -1192,8 +1192,7 @@
},
"carddav": {
"title": "CardDAV-Kontakte",
- "description": "Kontakte von einem CardDAV-Server wie Nextcloud synchronisieren.",
- "connected": "Verbunden",
+ "description": "Kontakte mit einem CardDAV-Server wie Nextcloud synchronisieren.",
"notConnected": "Nicht verbunden",
"serverLabel": "Server-URL",
"serverPh": "https://cloud.beispiel.de",
@@ -1201,10 +1200,6 @@
"userPh": "Ihr Nextcloud-Benutzername",
"passLabel": "App-Passwort",
"passPh": "App-Passwort (nicht Ihr Login-Passwort)",
- "dupLabel": "Wenn ein Kontakt bereits existiert",
- "dupSeparate": "Beide behalten (eigenes Adressbuch)",
- "dupMerge": "Zusammenführen – CardDAV gewinnt",
- "dupSkip": "CardDAV-Kontakt überspringen",
"intervalLabel": "Sync-Intervall (Minuten)",
"connect": "Verbinden",
"connecting": "Verbinde…",
@@ -1215,7 +1210,11 @@
"lastSync": "Letzte Sync: {{when}}",
"summary": "{{contacts}} Kontakte in {{books}} Adressbüchern",
"syncFailed": "Letzte Sync fehlgeschlagen: {{error}}",
- "help": "Verwenden Sie ein Nextcloud-App-Passwort, nicht Ihr Login-Passwort – SSO-Nutzer erstellen es unter Einstellungen → Sicherheit. Kontakte werden einseitig synchronisiert und sind in MailFlow schreibgeschützt."
+ "healthUnavailable": "Status nicht verfügbar",
+ "healthNeedsAttention": "Überprüfung nötig",
+ "healthHealthy": "Fehlerfrei",
+ "reviewConflicts": "Konflikte prüfen",
+ "help": "Verwenden Sie ein Nextcloud-App-Passwort, nicht Ihr Login-Passwort – SSO-Nutzer erstellen es unter Einstellungen → Sicherheit. Kontakte werden in beide Richtungen synchronisiert, wenn der Server Schreibzugriffe erlaubt."
}
},
"ssoLinked": {
@@ -1331,7 +1330,8 @@
"search": "Kontakte suchen…",
"empty": "Noch keine Kontakte",
"noResults": "Keine Kontakte gefunden",
- "count": "{{count}} Kontakte",
+ "count_one": "{{count}} Kontakt",
+ "count_other": "{{count}} Kontakte",
"auto": "auto",
"autoHint": "Aus empfangenen Mails erkannt",
"selectHint": "Kontakt auswählen",
@@ -1360,7 +1360,74 @@
"home": "Privat",
"other": "Sonstige"
},
- "carddavBadge": "Von CardDAV synchronisiert"
+ "carddavSynced": "Mit CardDAV synchronisiert",
+ "carddavReadOnly": "CardDAV nur lesbar",
+ "carddavConflict": "CardDAV-Konflikt",
+ "carddavPending": "Synchronisierung ausstehend",
+ "carddavWriteUnconfirmed": "Ihre Änderung wurde möglicherweise bereits übernommen – der Kontakt wurde aktualisiert. Prüfen Sie ihn und versuchen Sie es bei Bedarf erneut.",
+ "photo": {
+ "title": "Foto",
+ "contactAlt": "Kontaktfoto",
+ "previewAlt": "Vorschau des Kontaktfotos",
+ "present": "Foto vorhanden",
+ "none": "Kein Foto",
+ "choose": "Foto auswählen",
+ "remove": "Foto entfernen",
+ "invalidType": "Wählen Sie ein JPEG- oder PNG-Bild.",
+ "tooLarge": "Das Foto darf höchstens 512 KiB groß sein.",
+ "readFailed": "Das Foto konnte nicht gelesen werden."
+ },
+ "additional": {
+ "title": "Zusätzliche Felder",
+ "label": "Bezeichnung",
+ "value": "Wert",
+ "add": "Feld hinzufügen",
+ "street": "Straße",
+ "extendedAddress": "Adresszusatz",
+ "locality": "Ort",
+ "region": "Bundesland oder Region",
+ "postalCode": "Postleitzahl",
+ "country": "Land",
+ "poBox": "Postfach",
+ "protocol": "Nachrichtendienst",
+ "handle": "Benutzername oder Kennung",
+ "latitude": "Breitengrad",
+ "longitude": "Längengrad",
+ "types": {
+ "postal-address": "Postanschrift",
+ "url": "Webadresse",
+ "im": "Sofortnachricht",
+ "birthday": "Geburtstag",
+ "anniversary": "Jahrestag",
+ "date": "Datum",
+ "role": "Funktion",
+ "title": "Berufsbezeichnung",
+ "nickname": "Spitzname",
+ "geo": "Koordinaten",
+ "custom-text": "Eigener Text"
+ }
+ },
+ "conflicts": {
+ "title": "CardDAV-Konflikte",
+ "count_one": "{{count}} ungelöster Konflikt",
+ "count_other": "{{count}} ungelöste Konflikte",
+ "deleted": "Gelöscht",
+ "photoPresent": "Foto vorhanden",
+ "photoAbsent": "Kein Foto hinterlegt",
+ "loadFailed": "Die Konflikte konnten nicht geladen werden.",
+ "empty": "Keine ungelösten CardDAV-Konflikte.",
+ "item": "Konflikt {{number}}",
+ "mailflowSide": "MailFlow-Version",
+ "carddavSide": "CardDAV-Version",
+ "resolveFailed": "Der Konflikt konnte nicht gelöst werden. Prüfen Sie beide Versionen und versuchen Sie es erneut.",
+ "resolving": "Wird gelöst…",
+ "keepMailflow": "MailFlow-Version behalten",
+ "keepCarddav": "CardDAV-Version behalten",
+ "openCount_one": "{{count}} CardDAV-Konflikt prüfen",
+ "openCount_other": "{{count}} CardDAV-Konflikte prüfen",
+ "refreshFailed": "Der Kontakt konnte nach der Lösung nicht aktualisiert werden.",
+ "close": "Konflikte schließen"
+ }
},
"todoist": {
"betaLabel": "Beta",
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index e594dd2..1e532c3 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -1259,8 +1259,7 @@
},
"carddav": {
"title": "CardDAV Contacts",
- "description": "Sync contacts from a CardDAV server such as Nextcloud.",
- "connected": "Connected",
+ "description": "Sync contacts with a CardDAV server such as Nextcloud.",
"notConnected": "Not connected",
"serverLabel": "Server URL",
"serverPh": "https://cloud.example.com",
@@ -1268,10 +1267,6 @@
"userPh": "your Nextcloud username",
"passLabel": "App password",
"passPh": "app password (not your login password)",
- "dupLabel": "When a contact already exists",
- "dupSeparate": "Keep both (separate address book)",
- "dupMerge": "Merge — CardDAV wins",
- "dupSkip": "Skip the CardDAV contact",
"intervalLabel": "Sync interval (minutes)",
"connect": "Connect",
"connecting": "Connecting…",
@@ -1282,7 +1277,11 @@
"lastSync": "Last sync: {{when}}",
"summary": "{{contacts}} contacts in {{books}} address books",
"syncFailed": "Last sync failed: {{error}}",
- "help": "Use a Nextcloud app password, not your login password — SSO users must create one under Settings → Security. Contacts are synced one-way and are read-only in MailFlow."
+ "healthUnavailable": "Health unavailable",
+ "healthNeedsAttention": "Needs attention",
+ "healthHealthy": "Healthy",
+ "reviewConflicts": "Review conflicts",
+ "help": "Use a Nextcloud app password, not your login password — SSO users must create one under Settings → Security. Contacts sync in both directions when the server allows writes."
}
},
"ssoLinked": {
@@ -1331,7 +1330,8 @@
"search": "Search contacts…",
"empty": "No contacts yet",
"noResults": "No contacts found",
- "count": "{{count}} contacts",
+ "count_one": "{{count}} contact",
+ "count_other": "{{count}} contacts",
"auto": "auto",
"autoHint": "Discovered from received mail",
"selectHint": "Select a contact to view details",
@@ -1360,7 +1360,74 @@
"home": "Home",
"other": "Other"
},
- "carddavBadge": "Synced from CardDAV"
+ "carddavSynced": "Synced with CardDAV",
+ "carddavReadOnly": "Read-only CardDAV",
+ "carddavConflict": "CardDAV conflict",
+ "carddavPending": "Sync pending",
+ "carddavWriteUnconfirmed": "Your change may already have been applied — the contact was refreshed. Check it and try again if needed.",
+ "photo": {
+ "title": "Photo",
+ "contactAlt": "Contact photo",
+ "previewAlt": "Contact photo preview",
+ "present": "Photo available",
+ "none": "No photo",
+ "choose": "Choose photo",
+ "remove": "Remove photo",
+ "invalidType": "Choose a JPEG or PNG image.",
+ "tooLarge": "The photo must be 512 KiB or smaller.",
+ "readFailed": "The photo could not be read."
+ },
+ "additional": {
+ "title": "Additional fields",
+ "label": "Label",
+ "value": "Value",
+ "add": "Add field",
+ "street": "Street",
+ "extendedAddress": "Address details",
+ "locality": "City",
+ "region": "State or region",
+ "postalCode": "Postal code",
+ "country": "Country",
+ "poBox": "PO box",
+ "protocol": "Messaging service",
+ "handle": "Username or handle",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "types": {
+ "postal-address": "Postal address",
+ "url": "Web address",
+ "im": "Instant messaging",
+ "birthday": "Birthday",
+ "anniversary": "Anniversary",
+ "date": "Date",
+ "role": "Role",
+ "title": "Job title",
+ "nickname": "Nickname",
+ "geo": "Coordinates",
+ "custom-text": "Custom text"
+ }
+ },
+ "conflicts": {
+ "title": "CardDAV conflicts",
+ "count_one": "{{count}} unresolved conflict",
+ "count_other": "{{count}} unresolved conflicts",
+ "deleted": "Deleted",
+ "photoPresent": "Photo present",
+ "photoAbsent": "No photo",
+ "loadFailed": "Conflicts could not be loaded.",
+ "empty": "No unresolved CardDAV conflicts.",
+ "item": "Conflict {{number}}",
+ "mailflowSide": "MailFlow version",
+ "carddavSide": "CardDAV version",
+ "resolveFailed": "The conflict could not be resolved. Review both versions and try again.",
+ "resolving": "Resolving…",
+ "keepMailflow": "Keep MailFlow version",
+ "keepCarddav": "Keep CardDAV version",
+ "openCount_one": "Review {{count}} CardDAV conflict",
+ "openCount_other": "Review {{count}} CardDAV conflicts",
+ "refreshFailed": "The contact could not be refreshed after resolution.",
+ "close": "Close conflicts"
+ }
},
"todoist": {
"betaLabel": "Beta",
diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json
index f05d569..ec5a466 100644
--- a/frontend/src/locales/es.json
+++ b/frontend/src/locales/es.json
@@ -1259,8 +1259,7 @@
},
"carddav": {
"title": "Contactos CardDAV",
- "description": "Sincroniza contactos desde un servidor CardDAV como Nextcloud.",
- "connected": "Conectado",
+ "description": "Sincroniza contactos con un servidor CardDAV como Nextcloud.",
"notConnected": "No conectado",
"serverLabel": "URL del servidor",
"serverPh": "https://cloud.ejemplo.com",
@@ -1268,10 +1267,6 @@
"userPh": "tu usuario de Nextcloud",
"passLabel": "Contraseña de aplicación",
"passPh": "contraseña de aplicación (no la de inicio de sesión)",
- "dupLabel": "Cuando un contacto ya existe",
- "dupSeparate": "Mantener ambos (libreta separada)",
- "dupMerge": "Combinar: gana CardDAV",
- "dupSkip": "Omitir el contacto CardDAV",
"intervalLabel": "Intervalo de sincronización (minutos)",
"connect": "Conectar",
"connecting": "Conectando…",
@@ -1282,7 +1277,11 @@
"lastSync": "Última sincronización: {{when}}",
"summary": "{{contacts}} contactos en {{books}} libretas",
"syncFailed": "La última sincronización falló: {{error}}",
- "help": "Usa una contraseña de aplicación de Nextcloud, no la de inicio de sesión: los usuarios de SSO deben crear una en Ajustes → Seguridad. Los contactos se sincronizan en un solo sentido y son de solo lectura en MailFlow."
+ "healthUnavailable": "Estado no disponible",
+ "healthNeedsAttention": "Requiere atención",
+ "healthHealthy": "Funcionamiento correcto",
+ "reviewConflicts": "Revisar conflictos",
+ "help": "Usa una contraseña de aplicación de Nextcloud, no la de inicio de sesión: los usuarios de SSO deben crear una en Ajustes → Seguridad. Los contactos se sincronizan en ambos sentidos cuando el servidor permite escribir."
}
},
"ssoLinked": {
@@ -1331,7 +1330,8 @@
"search": "Buscar contactos…",
"empty": "Sin contactos aún",
"noResults": "No se encontraron contactos",
- "count": "{{count}} contactos",
+ "count_one": "{{count}} contacto",
+ "count_other": "{{count}} contactos",
"auto": "auto",
"autoHint": "Descubierto del correo recibido",
"selectHint": "Selecciona un contacto",
@@ -1360,7 +1360,74 @@
"home": "Casa",
"other": "Otro"
},
- "carddavBadge": "Sincronizado desde CardDAV"
+ "carddavSynced": "Sincronizado con CardDAV",
+ "carddavReadOnly": "CardDAV de solo lectura",
+ "carddavConflict": "Conflicto de CardDAV",
+ "carddavPending": "Sincronización pendiente",
+ "carddavWriteUnconfirmed": "Es posible que tu cambio ya se haya aplicado: se actualizó el contacto. Revísalo y vuelve a intentarlo si es necesario.",
+ "photo": {
+ "title": "Fotografía",
+ "contactAlt": "Foto del contacto",
+ "previewAlt": "Vista previa de la foto del contacto",
+ "present": "Foto disponible",
+ "none": "Sin foto",
+ "choose": "Elegir foto",
+ "remove": "Quitar foto",
+ "invalidType": "Elige una imagen JPEG o PNG.",
+ "tooLarge": "La foto debe ocupar 512 KiB o menos.",
+ "readFailed": "No se pudo leer la foto."
+ },
+ "additional": {
+ "title": "Campos adicionales",
+ "label": "Etiqueta",
+ "value": "Valor",
+ "add": "Agregar campo",
+ "street": "Calle",
+ "extendedAddress": "Detalles de dirección",
+ "locality": "Ciudad",
+ "region": "Estado o región",
+ "postalCode": "Código postal",
+ "country": "País",
+ "poBox": "Apartado postal",
+ "protocol": "Servicio de mensajería",
+ "handle": "Usuario o identificador",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "types": {
+ "postal-address": "Dirección postal",
+ "url": "Dirección web",
+ "im": "Mensajería instantánea",
+ "birthday": "Cumpleaños",
+ "anniversary": "Aniversario",
+ "date": "Fecha",
+ "role": "Función",
+ "title": "Cargo profesional",
+ "nickname": "Apodo",
+ "geo": "Coordenadas",
+ "custom-text": "Texto personalizado"
+ }
+ },
+ "conflicts": {
+ "title": "Conflictos de CardDAV",
+ "count_one": "{{count}} conflicto sin resolver",
+ "count_other": "{{count}} conflictos sin resolver",
+ "deleted": "Eliminado",
+ "photoPresent": "Hay una foto",
+ "photoAbsent": "No hay foto",
+ "loadFailed": "No se pudieron cargar los conflictos.",
+ "empty": "No hay conflictos de CardDAV sin resolver.",
+ "item": "Conflicto {{number}}",
+ "mailflowSide": "Versión de MailFlow",
+ "carddavSide": "Versión de CardDAV",
+ "resolveFailed": "No se pudo resolver el conflicto. Revisa ambas versiones e inténtalo de nuevo.",
+ "resolving": "Resolviendo…",
+ "keepMailflow": "Conservar la versión de MailFlow",
+ "keepCarddav": "Conservar la versión de CardDAV",
+ "openCount_one": "Revisar {{count}} conflicto de CardDAV",
+ "openCount_other": "Revisar {{count}} conflictos de CardDAV",
+ "refreshFailed": "No se pudo actualizar el contacto después de resolver el conflicto.",
+ "close": "Cerrar conflictos"
+ }
},
"todoist": {
"betaLabel": "Beta",
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 976bc5a..807c7e0 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -1259,8 +1259,7 @@
},
"carddav": {
"title": "Contacts CardDAV",
- "description": "Synchronisez les contacts depuis un serveur CardDAV comme Nextcloud.",
- "connected": "Connecté",
+ "description": "Synchronisez les contacts avec un serveur CardDAV comme Nextcloud.",
"notConnected": "Non connecté",
"serverLabel": "URL du serveur",
"serverPh": "https://cloud.exemple.com",
@@ -1268,10 +1267,6 @@
"userPh": "votre nom d'utilisateur Nextcloud",
"passLabel": "Mot de passe d'application",
"passPh": "mot de passe d'application (pas votre mot de passe de connexion)",
- "dupLabel": "Lorsqu'un contact existe déjà",
- "dupSeparate": "Garder les deux (carnet séparé)",
- "dupMerge": "Fusionner — CardDAV l'emporte",
- "dupSkip": "Ignorer le contact CardDAV",
"intervalLabel": "Intervalle de synchronisation (minutes)",
"connect": "Connecter",
"connecting": "Connexion…",
@@ -1282,7 +1277,11 @@
"lastSync": "Dernière synchro : {{when}}",
"summary": "{{contacts}} contacts dans {{books}} carnets",
"syncFailed": "Échec de la dernière synchro : {{error}}",
- "help": "Utilisez un mot de passe d'application Nextcloud, pas votre mot de passe de connexion — les utilisateurs SSO doivent en créer un dans Paramètres → Sécurité. Les contacts sont synchronisés à sens unique et en lecture seule dans MailFlow."
+ "healthUnavailable": "État indisponible",
+ "healthNeedsAttention": "Intervention nécessaire",
+ "healthHealthy": "Bon fonctionnement",
+ "reviewConflicts": "Examiner les conflits",
+ "help": "Utilisez un mot de passe d’application Nextcloud, pas votre mot de passe de connexion — les utilisateurs SSO doivent en créer un dans Paramètres → Sécurité. Les contacts sont synchronisés dans les deux sens lorsque le serveur autorise l’écriture."
}
},
"ssoLinked": {
@@ -1331,7 +1330,8 @@
"search": "Rechercher des contacts…",
"empty": "Aucun contact pour l'instant",
"noResults": "Aucun contact trouvé",
- "count": "{{count}} contacts",
+ "count_one": "{{count}} contact",
+ "count_other": "{{count}} contacts",
"auto": "auto",
"autoHint": "Découvert depuis les e-mails reçus",
"selectHint": "Sélectionnez un contact",
@@ -1360,7 +1360,74 @@
"home": "Domicile",
"other": "Autre"
},
- "carddavBadge": "Synchronisé depuis CardDAV"
+ "carddavSynced": "Synchronisé avec CardDAV",
+ "carddavReadOnly": "CardDAV en lecture seule",
+ "carddavConflict": "Conflit CardDAV",
+ "carddavPending": "Synchronisation en attente",
+ "carddavWriteUnconfirmed": "Votre modification a peut-être déjà été appliquée : le contact a été actualisé. Vérifiez-le et réessayez si nécessaire.",
+ "photo": {
+ "title": "Photographie",
+ "contactAlt": "Photo du contact",
+ "previewAlt": "Aperçu de la photo du contact",
+ "present": "Photo disponible",
+ "none": "Aucune photo",
+ "choose": "Choisir une photo",
+ "remove": "Retirer la photo",
+ "invalidType": "Choisissez une image JPEG ou PNG.",
+ "tooLarge": "La photo doit faire 512 Kio au maximum.",
+ "readFailed": "La photo n’a pas pu être lue."
+ },
+ "additional": {
+ "title": "Champs supplémentaires",
+ "label": "Libellé",
+ "value": "Valeur",
+ "add": "Ajouter un champ",
+ "street": "Rue",
+ "extendedAddress": "Complément d’adresse",
+ "locality": "Ville",
+ "region": "État ou région",
+ "postalCode": "Code postal",
+ "country": "Pays",
+ "poBox": "Boîte postale",
+ "protocol": "Service de messagerie",
+ "handle": "Nom d’utilisateur ou identifiant",
+ "latitude": "Latitude géographique",
+ "longitude": "Longitude géographique",
+ "types": {
+ "postal-address": "Adresse postale",
+ "url": "Adresse web",
+ "im": "Messagerie instantanée",
+ "birthday": "Date de naissance",
+ "anniversary": "Date anniversaire",
+ "date": "Autre date",
+ "role": "Rôle",
+ "title": "Intitulé du poste",
+ "nickname": "Surnom",
+ "geo": "Coordonnées",
+ "custom-text": "Texte personnalisé"
+ }
+ },
+ "conflicts": {
+ "title": "Conflits CardDAV",
+ "count_one": "{{count}} conflit non résolu",
+ "count_other": "{{count}} conflits non résolus",
+ "deleted": "Supprimé",
+ "photoPresent": "Une photo est présente",
+ "photoAbsent": "Photo absente",
+ "loadFailed": "Impossible de charger les conflits.",
+ "empty": "Aucun conflit CardDAV non résolu.",
+ "item": "Conflit n° {{number}}",
+ "mailflowSide": "Version de MailFlow",
+ "carddavSide": "Version de CardDAV",
+ "resolveFailed": "Impossible de résoudre le conflit. Vérifiez les deux versions et réessayez.",
+ "resolving": "Résolution en cours…",
+ "keepMailflow": "Conserver la version MailFlow",
+ "keepCarddav": "Conserver la version CardDAV",
+ "openCount_one": "Examiner {{count}} conflit CardDAV",
+ "openCount_other": "Examiner {{count}} conflits CardDAV",
+ "refreshFailed": "Impossible d’actualiser le contact après la résolution.",
+ "close": "Fermer les conflits"
+ }
},
"todoist": {
"betaLabel": "Bêta",
diff --git a/frontend/src/locales/i18n.test.js b/frontend/src/locales/i18n.test.js
index e266dc1..d4ffbf1 100644
--- a/frontend/src/locales/i18n.test.js
+++ b/frontend/src/locales/i18n.test.js
@@ -103,6 +103,7 @@ import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import i18next from 'i18next';
const dir = dirname(fileURLToPath(import.meta.url));
@@ -258,7 +259,8 @@ const SAME_VALUE_ALLOWED = {
// "auto" — universal technical loanword, same in all locales
'contacts.auto': 'any',
// "contacts" — same word in English and French
- 'contacts.count': [['en', 'fr']],
+ 'contacts.count_one': [['en', 'fr']],
+ 'contacts.count_other': [['en', 'fr']],
'contacts.title': [['en', 'fr']],
// "Email" — international term used as-is in en, es, it, ru, zhCN
'contacts.fields.email': [['en', 'es', 'it', 'ru', 'zhCN']],
@@ -351,6 +353,12 @@ const DYNAMIC_KEYS = new Set([
// appear as literals; the other three do via the tab pills).
'gtd.state.watch',
'gtd.state.delegated',
+ // t(`contacts.additional.types.${kind}`) — remaining Additional-field kinds.
+ 'contacts.additional.types.custom-text',
+ 'contacts.additional.types.date',
+ 'contacts.additional.types.geo',
+ 'contacts.additional.types.im',
+ 'contacts.additional.types.postal-address',
]);
// JSX attribute names whose values must never be plain strings — always t().
@@ -520,14 +528,39 @@ describe('i18n locale files', () => {
describe('key coverage — every key must appear in every locale', () => {
for (const lang of langs) {
it(`${lang} has no missing keys`, () => {
- const present = new Set(Object.keys(locales[lang]));
- const missing = allKeys.filter(k => !present.has(k));
+ const present = new Set(Object.keys(locales[lang]).map(baseKey));
+ const missing = [...new Set(allKeys.map(baseKey))].filter(k => !present.has(k));
assert.equal(missing.length, 0,
`Missing keys:\n${missing.map(k => ` - ${k}`).join('\n')}`);
});
}
});
+ it('uses locale-appropriate CardDAV and contact count plurals', async () => {
+ const bases = ['contacts.count', 'contacts.conflicts.count', 'contacts.conflicts.openCount'];
+ const suffixes = {
+ en: ['one', 'other'], de: ['one', 'other'], es: ['one', 'other'],
+ fr: ['one', 'other'], it: ['one', 'other'], ru: ['one', 'few', 'many'],
+ zhCN: ['other'],
+ };
+ for (const [lang, expected] of Object.entries(suffixes)) {
+ for (const base of bases) {
+ assert.deepEqual(
+ expected.filter(suffix => locales[lang][`${base}_${suffix}`] === undefined),
+ [],
+ `${lang} is missing plural forms for ${base}`,
+ );
+ }
+ }
+
+ const i18n = i18next.createInstance();
+ await i18n.init({
+ lng: 'en',
+ resources: { en: { translation: JSON.parse(readFileSync(join(dir, 'en.json'), 'utf8')) } },
+ });
+ assert.equal(i18n.t('contacts.conflicts.count', { count: 1 }), '1 unresolved conflict');
+ });
+
describe('value uniqueness — no unlisted locale pair should share a value for the same key', () => {
for (const key of allKeys) {
it(key, () => {
@@ -568,3 +601,77 @@ describe('i18n locale files', () => {
});
});
+
+// Regression guard for the contacts type-label overflow — upstream fix b837b93 / PR #242.
+// Stale pre-normalization DB
+// rows can carry a non-canonical contact type (e.g. a raw compound TYPE param like
+// "cell,voice,pref"). A dynamic `t(`contacts.phoneTypes.${type}`)` lookup with no
+// defaultValue returns the raw i18n key, which — being a long, unbreakable token — overflows
+// the fixed-width DetailRow label. DYNAMIC_KEYS above treats these template-literal keys as
+// "referenced", so Suite 1 never validated their runtime resolution and the class slipped past.
+function dynamicTypeLabelOffenders() {
+ const srcDir = resolve(dir, '..'); // frontend/src (excludes node_modules / build output)
+ const offenders = [];
+ (function walk(d) {
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
+ const full = join(d, entry.name);
+ if (entry.isDirectory()) {
+ if (full !== dir) walk(full); // skip locales/ (this test's own directory)
+ continue;
+ }
+ if (!entry.name.endsWith('.jsx') && !entry.name.endsWith('.js')) continue;
+ readFileSync(full, 'utf8').split('\n').forEach((line, i) => {
+ const text = line.trim();
+ if (/contacts\.(?:phone|email)Types\.\$\{/.test(text) && !text.includes('defaultValue')) {
+ offenders.push(`${full.replace(srcDir + '/', '')}:${i + 1} ${text}`);
+ }
+ });
+ }
+ })(srcDir);
+ return offenders;
+}
+
+describe('contacts type labels — humanized fallback, never a raw i18n key', () => {
+ const nested = JSON.parse(readFileSync(join(dir, 'en.json'), 'utf8'));
+
+ it('resolves a non-canonical stored type to the humanized "other" label, not the key', async () => {
+ const i18n = i18next.createInstance();
+ await i18n.init({
+ lng: 'en',
+ fallbackLng: 'en',
+ resources: { en: { translation: nested } },
+ interpolation: { escapeValue: false },
+ });
+
+ const phoneOther = i18n.t('contacts.phoneTypes.other');
+ const emailOther = i18n.t('contacts.emailTypes.other');
+ assert.equal(phoneOther, 'Other'); // the defaultValue target exists and is humanized
+ assert.equal(emailOther, 'Other');
+
+ // A stale compound TYPE value has no canonical key; the guard's defaultValue resolves it
+ // to the humanized fallback instead of "contacts.phoneTypes.cell,voice,pref".
+ for (const type of ['cell,voice,pref', 'internet,work', 'fax']) {
+ const phoneLabel = i18n.t(`contacts.phoneTypes.${type}`, { defaultValue: phoneOther });
+ const emailLabel = i18n.t(`contacts.emailTypes.${type}`, { defaultValue: emailOther });
+ assert.equal(phoneLabel, 'Other');
+ assert.equal(emailLabel, 'Other');
+ assert.doesNotMatch(phoneLabel, /contacts\.(?:phone|email)Types\./);
+ assert.doesNotMatch(emailLabel, /contacts\.(?:phone|email)Types\./);
+ }
+
+ // The cell/iphone→mobile alias lands on the real canonical label.
+ assert.equal(i18n.t('contacts.phoneTypes.mobile', { defaultValue: phoneOther }), 'Mobile');
+
+ // Characterization of the underlying bug: WITHOUT the defaultValue, i18next returns the key.
+ assert.equal(i18n.t('contacts.phoneTypes.cell,voice,pref'),
+ 'contacts.phoneTypes.cell,voice,pref');
+ });
+
+ it('never lets a dynamic contacts type-label lookup omit its defaultValue guard', () => {
+ const offenders = dynamicTypeLabelOffenders();
+ assert.deepEqual(offenders, [],
+ 'A dynamic contacts type-label lookup with no { defaultValue } renders the raw i18n key '
+ + 'for a non-canonical stored type (see b837b93 / PR #242):\n'
+ + offenders.map(line => ` ${line}`).join('\n'));
+ });
+});
diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json
index 1368084..10daf2e 100644
--- a/frontend/src/locales/it.json
+++ b/frontend/src/locales/it.json
@@ -1259,8 +1259,7 @@
},
"carddav": {
"title": "Contatti CardDAV",
- "description": "Sincronizza i contatti da un server CardDAV come Nextcloud.",
- "connected": "Connesso",
+ "description": "Sincronizza i contatti con un server CardDAV come Nextcloud.",
"notConnected": "Non connesso",
"serverLabel": "URL del server",
"serverPh": "https://cloud.esempio.com",
@@ -1268,10 +1267,6 @@
"userPh": "il tuo nome utente Nextcloud",
"passLabel": "Password dell'app",
"passPh": "password app (non quella di accesso)",
- "dupLabel": "Quando un contatto esiste già",
- "dupSeparate": "Mantieni entrambi (rubrica separata)",
- "dupMerge": "Unisci — vince CardDAV",
- "dupSkip": "Salta il contatto CardDAV",
"intervalLabel": "Intervallo di sincronizzazione (minuti)",
"connect": "Connetti",
"connecting": "Connessione…",
@@ -1282,7 +1277,11 @@
"lastSync": "Ultima sincronizzazione: {{when}}",
"summary": "{{contacts}} contatti in {{books}} rubriche",
"syncFailed": "Ultima sincronizzazione non riuscita: {{error}}",
- "help": "Usa una password app di Nextcloud, non quella di accesso: gli utenti SSO devono crearne una in Impostazioni → Sicurezza. I contatti sono sincronizzati a senso unico e di sola lettura in MailFlow."
+ "healthUnavailable": "Stato non disponibile",
+ "healthNeedsAttention": "Richiede attenzione",
+ "healthHealthy": "Servizio regolare",
+ "reviewConflicts": "Esamina i conflitti",
+ "help": "Usa una password app di Nextcloud, non quella di accesso: gli utenti SSO devono crearne una in Impostazioni → Sicurezza. I contatti vengono sincronizzati in entrambe le direzioni quando il server consente la scrittura."
}
},
"ssoLinked": {
@@ -1331,7 +1330,8 @@
"search": "Cerca contatti…",
"empty": "Nessun contatto ancora",
"noResults": "Nessun contatto trovato",
- "count": "{{count}} contatti",
+ "count_one": "{{count}} contatto",
+ "count_other": "{{count}} contatti",
"auto": "auto",
"autoHint": "Rilevato da email ricevute",
"selectHint": "Seleziona un contatto",
@@ -1360,7 +1360,74 @@
"home": "Casa",
"other": "Altro"
},
- "carddavBadge": "Sincronizzato da CardDAV"
+ "carddavSynced": "Sincronizzato con CardDAV",
+ "carddavReadOnly": "CardDAV in sola lettura",
+ "carddavConflict": "Conflitto CardDAV",
+ "carddavPending": "Sincronizzazione in sospeso",
+ "carddavWriteUnconfirmed": "La tua modifica potrebbe essere già stata applicata: il contatto è stato aggiornato. Verifica e riprova se necessario.",
+ "photo": {
+ "title": "Immagine",
+ "contactAlt": "Foto del contatto",
+ "previewAlt": "Anteprima della foto del contatto",
+ "present": "Foto disponibile",
+ "none": "Nessuna foto",
+ "choose": "Scegli foto",
+ "remove": "Rimuovi foto",
+ "invalidType": "Scegli un’immagine JPEG o PNG.",
+ "tooLarge": "La foto deve essere di 512 KiB o meno.",
+ "readFailed": "Non è stato possibile leggere la foto."
+ },
+ "additional": {
+ "title": "Campi aggiuntivi",
+ "label": "Etichetta",
+ "value": "Contenuto",
+ "add": "Aggiungi campo",
+ "street": "Via",
+ "extendedAddress": "Dettagli indirizzo",
+ "locality": "Città",
+ "region": "Provincia o regione",
+ "postalCode": "Codice postale",
+ "country": "Paese",
+ "poBox": "Casella postale",
+ "protocol": "Servizio di messaggistica",
+ "handle": "Utente o identificativo",
+ "latitude": "Latitudine nord-sud",
+ "longitude": "Longitudine est-ovest",
+ "types": {
+ "postal-address": "Indirizzo postale",
+ "url": "Indirizzo web",
+ "im": "Messaggistica istantanea",
+ "birthday": "Compleanno",
+ "anniversary": "Ricorrenza",
+ "date": "Data personale",
+ "role": "Ruolo",
+ "title": "Titolo professionale",
+ "nickname": "Soprannome",
+ "geo": "Coordinate",
+ "custom-text": "Testo personalizzato"
+ }
+ },
+ "conflicts": {
+ "title": "Conflitti CardDAV",
+ "count_one": "{{count}} conflitto non risolto",
+ "count_other": "{{count}} conflitti non risolti",
+ "deleted": "Cancellato",
+ "photoPresent": "Foto presente",
+ "photoAbsent": "Foto non presente",
+ "loadFailed": "Impossibile caricare i conflitti.",
+ "empty": "Non ci sono conflitti CardDAV irrisolti.",
+ "item": "Conflitto numero {{number}}",
+ "mailflowSide": "Versione MailFlow",
+ "carddavSide": "Versione CardDAV",
+ "resolveFailed": "Impossibile risolvere il conflitto. Controlla entrambe le versioni e riprova.",
+ "resolving": "Risoluzione…",
+ "keepMailflow": "Mantieni la versione MailFlow",
+ "keepCarddav": "Mantieni la versione CardDAV",
+ "openCount_one": "Esamina {{count}} conflitto CardDAV",
+ "openCount_other": "Esamina {{count}} conflitti CardDAV",
+ "refreshFailed": "Impossibile aggiornare il contatto dopo la risoluzione.",
+ "close": "Chiudi i conflitti"
+ }
},
"todoist": {
"betaLabel": "Beta",
diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json
index 3fba0b0..8b49e37 100644
--- a/frontend/src/locales/ru.json
+++ b/frontend/src/locales/ru.json
@@ -1259,8 +1259,7 @@
},
"carddav": {
"title": "Контакты CardDAV",
- "description": "Синхронизация контактов с сервера CardDAV, например Nextcloud.",
- "connected": "Подключено",
+ "description": "Синхронизация контактов с сервером CardDAV, например Nextcloud.",
"notConnected": "Не подключено",
"serverLabel": "URL сервера",
"serverPh": "https://cloud.example.ru",
@@ -1268,10 +1267,6 @@
"userPh": "ваше имя пользователя Nextcloud",
"passLabel": "Пароль приложения",
"passPh": "пароль приложения (не пароль для входа)",
- "dupLabel": "Когда контакт уже существует",
- "dupSeparate": "Оставить оба (отдельная книга)",
- "dupMerge": "Объединить — приоритет CardDAV",
- "dupSkip": "Пропустить контакт CardDAV",
"intervalLabel": "Интервал синхронизации (минуты)",
"connect": "Подключить",
"connecting": "Подключение…",
@@ -1282,7 +1277,11 @@
"lastSync": "Последняя синхронизация: {{when}}",
"summary": "{{contacts}} контактов в {{books}} книгах",
"syncFailed": "Последняя синхронизация не удалась: {{error}}",
- "help": "Используйте пароль приложения Nextcloud, а не пароль для входа — пользователям SSO нужно создать его в Настройки → Безопасность. Контакты синхронизируются в одну сторону и доступны только для чтения в MailFlow."
+ "healthUnavailable": "Состояние недоступно",
+ "healthNeedsAttention": "Требует внимания",
+ "healthHealthy": "Работает исправно",
+ "reviewConflicts": "Проверить конфликты",
+ "help": "Используйте пароль приложения Nextcloud, а не пароль для входа — пользователям SSO нужно создать его в Настройки → Безопасность. Контакты синхронизируются в обоих направлениях, если сервер разрешает запись."
}
},
"ssoLinked": {
@@ -1331,7 +1330,9 @@
"search": "Поиск контактов…",
"empty": "Нет контактов",
"noResults": "Контакты не найдены",
- "count": "{{count}} контактов",
+ "count_one": "{{count}} контакт",
+ "count_few": "{{count}} контакта",
+ "count_many": "{{count}} контактов",
"auto": "auto",
"autoHint": "Обнаружен из входящей почты",
"selectHint": "Выберите контакт",
@@ -1360,7 +1361,76 @@
"home": "Дом",
"other": "Другой"
},
- "carddavBadge": "Синхронизировано из CardDAV"
+ "carddavSynced": "Синхронизировано с CardDAV",
+ "carddavReadOnly": "CardDAV только для чтения",
+ "carddavConflict": "Конфликт CardDAV",
+ "carddavPending": "Ожидает синхронизации",
+ "carddavWriteUnconfirmed": "Возможно, изменение уже применено — контакт обновлён. Проверьте его и при необходимости повторите.",
+ "photo": {
+ "title": "Фотография",
+ "contactAlt": "Фотография контакта",
+ "previewAlt": "Предпросмотр фотографии контакта",
+ "present": "Фотография доступна",
+ "none": "Фотографии нет",
+ "choose": "Выбрать фотографию",
+ "remove": "Удалить фотографию",
+ "invalidType": "Выберите изображение JPEG или PNG.",
+ "tooLarge": "Размер фотографии не должен превышать 512 КиБ.",
+ "readFailed": "Не удалось прочитать фотографию."
+ },
+ "additional": {
+ "title": "Дополнительные поля",
+ "label": "Подпись",
+ "value": "Значение",
+ "add": "Добавить поле",
+ "street": "Улица",
+ "extendedAddress": "Дополнение к адресу",
+ "locality": "Город",
+ "region": "Область или регион",
+ "postalCode": "Почтовый индекс",
+ "country": "Страна",
+ "poBox": "Абонентский ящик",
+ "protocol": "Служба сообщений",
+ "handle": "Имя пользователя или идентификатор",
+ "latitude": "Географическая широта",
+ "longitude": "Географическая долгота",
+ "types": {
+ "postal-address": "Почтовый адрес",
+ "url": "Веб-адрес",
+ "im": "Мгновенные сообщения",
+ "birthday": "День рождения",
+ "anniversary": "Годовщина",
+ "date": "Особая дата",
+ "role": "Роль",
+ "title": "Должность",
+ "nickname": "Псевдоним",
+ "geo": "Координаты",
+ "custom-text": "Произвольный текст"
+ }
+ },
+ "conflicts": {
+ "title": "Конфликты CardDAV",
+ "count_one": "{{count}} неразрешённый конфликт",
+ "count_few": "{{count}} неразрешённых конфликта",
+ "count_many": "{{count}} неразрешённых конфликтов",
+ "deleted": "Удалено",
+ "photoPresent": "Фотография присутствует",
+ "photoAbsent": "Фотография отсутствует",
+ "loadFailed": "Не удалось загрузить конфликты.",
+ "empty": "Неразрешённых конфликтов CardDAV нет.",
+ "item": "Конфликт № {{number}}",
+ "mailflowSide": "Версия MailFlow",
+ "carddavSide": "Версия CardDAV",
+ "resolveFailed": "Не удалось разрешить конфликт. Проверьте обе версии и повторите попытку.",
+ "resolving": "Разрешение…",
+ "keepMailflow": "Оставить версию MailFlow",
+ "keepCarddav": "Оставить версию CardDAV",
+ "openCount_one": "Проверить {{count}} конфликт CardDAV",
+ "openCount_few": "Проверить {{count}} конфликта CardDAV",
+ "openCount_many": "Проверить {{count}} конфликтов CardDAV",
+ "refreshFailed": "Не удалось обновить контакт после разрешения конфликта.",
+ "close": "Закрыть конфликты"
+ }
},
"todoist": {
"betaLabel": "Бета",
diff --git a/frontend/src/locales/zhCN.json b/frontend/src/locales/zhCN.json
index 09edcc9..3dec5a5 100644
--- a/frontend/src/locales/zhCN.json
+++ b/frontend/src/locales/zhCN.json
@@ -1259,8 +1259,7 @@
},
"carddav": {
"title": "CardDAV 联系人",
- "description": "从 Nextcloud 等 CardDAV 服务器同步联系人。",
- "connected": "已连接",
+ "description": "与 Nextcloud 等 CardDAV 服务器同步联系人。",
"notConnected": "未连接",
"serverLabel": "服务器地址",
"serverPh": "https://nextcloud.example.com",
@@ -1268,10 +1267,6 @@
"userPh": "你的 Nextcloud 用户名",
"passLabel": "应用专用密码",
"passPh": "应用专用密码(非登录密码)",
- "dupLabel": "当联系人已存在时",
- "dupSeparate": "保留两者(单独的通讯簿)",
- "dupMerge": "合并 — 以 CardDAV 为准",
- "dupSkip": "跳过该 CardDAV 联系人",
"intervalLabel": "同步间隔(分钟)",
"connect": "连接",
"connecting": "正在连接…",
@@ -1282,7 +1277,11 @@
"lastSync": "上次同步:{{when}}",
"summary": "{{books}} 个通讯簿中的 {{contacts}} 个联系人",
"syncFailed": "上次同步失败:{{error}}",
- "help": "请使用 Nextcloud 应用专用密码,而非登录密码——SSO 用户需在“设置 → 安全”中创建。联系人为单向同步,在 MailFlow 中为只读。"
+ "healthUnavailable": "无法获取运行状态",
+ "healthNeedsAttention": "需要处理",
+ "healthHealthy": "运行正常",
+ "reviewConflicts": "查看冲突",
+ "help": "请使用 Nextcloud 应用专用密码,而非登录密码——SSO 用户需在“设置 → 安全”中创建。当服务器允许写入时,联系人会双向同步。"
}
},
"ssoLinked": {
@@ -1331,7 +1330,7 @@
"search": "搜索联系人…",
"empty": "暂无联系人",
"noResults": "未找到联系人",
- "count": "{{count}} 个联系人",
+ "count_other": "{{count}} 个联系人",
"auto": "auto",
"autoHint": "从收件中自动发现",
"selectHint": "选择联系人",
@@ -1360,7 +1359,72 @@
"home": "家庭",
"other": "其他"
},
- "carddavBadge": "从 CardDAV 同步"
+ "carddavSynced": "已与 CardDAV 同步",
+ "carddavReadOnly": "CardDAV 只读联系人",
+ "carddavConflict": "CardDAV 数据冲突",
+ "carddavPending": "同步待处理",
+ "carddavWriteUnconfirmed": "您的更改可能已经生效——联系人已刷新。请检查后按需重试。",
+ "photo": {
+ "title": "头像",
+ "contactAlt": "联系人头像",
+ "previewAlt": "联系人头像预览",
+ "present": "已有头像",
+ "none": "暂无头像",
+ "choose": "选择头像",
+ "remove": "移除头像",
+ "invalidType": "请选择 JPEG 或 PNG 图片。",
+ "tooLarge": "头像大小不得超过 512 KiB。",
+ "readFailed": "无法读取所选头像。"
+ },
+ "additional": {
+ "title": "其他信息",
+ "label": "字段名称",
+ "value": "字段内容",
+ "add": "添加字段",
+ "street": "街道",
+ "extendedAddress": "详细地址",
+ "locality": "城市",
+ "region": "省份或地区",
+ "postalCode": "邮政编码",
+ "country": "国家或地区",
+ "poBox": "邮政信箱",
+ "protocol": "通讯服务",
+ "handle": "用户名或账号",
+ "latitude": "纬度",
+ "longitude": "经度",
+ "types": {
+ "postal-address": "邮寄地址",
+ "url": "网址",
+ "im": "即时通讯",
+ "birthday": "生日",
+ "anniversary": "纪念日",
+ "date": "其他日期",
+ "role": "职务",
+ "title": "职位名称",
+ "nickname": "昵称",
+ "geo": "地理坐标",
+ "custom-text": "自定义文本"
+ }
+ },
+ "conflicts": {
+ "title": "CardDAV 冲突",
+ "count_other": "{{count}} 个未解决的冲突",
+ "deleted": "已删除",
+ "photoPresent": "包含头像",
+ "photoAbsent": "不含头像",
+ "loadFailed": "无法加载冲突列表。",
+ "empty": "没有未解决的 CardDAV 冲突。",
+ "item": "第 {{number}} 个冲突",
+ "mailflowSide": "MailFlow 中的版本",
+ "carddavSide": "CardDAV 中的版本",
+ "resolveFailed": "无法解决此冲突。请检查两个版本后重试。",
+ "resolving": "正在解决…",
+ "keepMailflow": "保留 MailFlow 版本",
+ "keepCarddav": "保留 CardDAV 版本",
+ "openCount_other": "查看 {{count}} 个 CardDAV 冲突",
+ "refreshFailed": "解决冲突后无法刷新联系人。",
+ "close": "关闭冲突列表"
+ }
},
"todoist": {
"betaLabel": "测试版",
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 64e1346..3697d35 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -23,8 +23,12 @@ async function request(method, path, body, extraHeaders) {
if (res.status === 401 && !path.startsWith('/auth/')) {
window.dispatchEvent(new CustomEvent('mailflow:session_expired'));
}
- const err = await res.json().catch(() => ({ error: 'Request failed' }));
- throw new Error(err.error || 'Request failed');
+ const data = await res.json().catch(() => ({ error: 'Request failed' }));
+ const error = new Error(data.error || 'Request failed');
+ error.status = res.status;
+ error.data = data;
+ if (typeof data.conflictId === 'string') error.conflictId = data.conflictId;
+ throw error;
}
return res.json();
}
@@ -211,6 +215,12 @@ export const api = {
update: (data) => request('PATCH', '/carddav', data),
sync: () => request('POST', '/carddav/sync'),
disconnect: () => request('DELETE', '/carddav'),
+ getConflicts: () => request('GET', '/carddav/conflicts'),
+ resolveConflict: (id, resolution) => request(
+ 'POST',
+ `/carddav/conflicts/${encodeURIComponent(id)}/resolve`,
+ { resolution },
+ ),
},
// Image whitelist
From ee2453ea0cc9b3259bff594e128c5abbd0339a29 Mon Sep 17 00:00:00 2001
From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date: Mon, 13 Jul 2026 22:18:57 -0700
Subject: [PATCH 2/2] feat: per-book roles for CardDAV multi-address-book sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../migrations/0038_carddav_multi_book.sql | 63 +
.../0039_carddav_write_target_alias.sql | 12 +
.../0040_carddav_publish_intent.sql | 20 +
backend/src/routes/carddav.test.js | 17 +
backend/src/routes/carddavAccount.js | 87 +-
backend/src/routes/carddavAccount.test.js | 144 ++
backend/src/routes/carddavConflicts.js | 1 +
backend/src/routes/contacts.js | 34 +-
backend/src/routes/contacts.test.js | 115 +-
.../src/services/carddavConflictService.js | 48 +-
.../services/carddavConflictService.test.js | 39 +
.../carddavContactService.integration.test.js | 334 ++-
backend/src/services/carddavContactService.js | 187 +-
.../services/carddavContactService.test.js | 241 ++-
backend/src/services/carddavLookupService.js | 156 ++
.../src/services/carddavLookupService.test.js | 149 ++
backend/src/services/carddavMappingState.js | 345 ++-
.../src/services/carddavMappingState.test.js | 439 +++-
.../src/services/carddavMigration.db.test.js | 257 ++-
backend/src/services/carddavSync.db.test.js | 686 +++++-
.../services/carddavSync.integration.test.js | 1877 +++++++++++++++--
backend/src/services/carddavSync.js | 945 ++++++++-
backend/src/services/carddavSync.test.js | 420 +++-
backend/src/services/messageService.js | 92 +-
backend/src/services/messageService.test.js | 104 +
frontend/src/carddavBookState.js | 56 +
frontend/src/carddavBookState.test.js | 104 +
frontend/src/components/AdminPanel.jsx | 128 ++
frontend/src/components/ContactsPage.jsx | 66 +-
frontend/src/contactCarddavState.js | 38 +
frontend/src/contactCarddavState.test.js | 78 +
frontend/src/locales/de.json | 27 +-
frontend/src/locales/en.json | 27 +-
frontend/src/locales/es.json | 27 +-
frontend/src/locales/fr.json | 27 +-
frontend/src/locales/i18n.test.js | 9 +
frontend/src/locales/it.json | 27 +-
frontend/src/locales/ru.json | 27 +-
frontend/src/locales/zhCN.json | 27 +-
frontend/src/utils/api.js | 4 +
40 files changed, 7168 insertions(+), 316 deletions(-)
create mode 100644 backend/migrations/0038_carddav_multi_book.sql
create mode 100644 backend/migrations/0039_carddav_write_target_alias.sql
create mode 100644 backend/migrations/0040_carddav_publish_intent.sql
create mode 100644 backend/src/services/carddavLookupService.js
create mode 100644 backend/src/services/carddavLookupService.test.js
create mode 100644 frontend/src/carddavBookState.js
create mode 100644 frontend/src/carddavBookState.test.js
diff --git a/backend/migrations/0038_carddav_multi_book.sql b/backend/migrations/0038_carddav_multi_book.sql
new file mode 100644
index 0000000..bfb1208
--- /dev/null
+++ b/backend/migrations/0038_carddav_multi_book.sql
@@ -0,0 +1,63 @@
+-- Multi-address-book CardDAV: surface per-book roles (write-target /
+-- subscribed / lookup-only) so a connected server exposing several address
+-- books can be treated by role rather than symmetrically. This migration is
+-- schema-only — it does not change sync
+-- behavior. Backfill preserves today's behavior exactly: every existing
+-- carddav book stays subscribed + lookup, and the first create-capable book
+-- per user becomes the write-target (mirroring the current selectedCreateBook
+-- rule), so no existing user's write destination changes.
+
+ALTER TABLE address_books
+ ADD COLUMN IF NOT EXISTS is_write_target BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS is_subscribed BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS is_lookup_source BOOLEAN NOT NULL DEFAULT true;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'address_books_write_target_subscribed'
+ AND conrelid = 'address_books'::regclass
+ ) THEN
+ ALTER TABLE address_books
+ ADD CONSTRAINT address_books_write_target_subscribed
+ CHECK (NOT is_write_target OR is_subscribed);
+ END IF;
+END $$;
+
+-- At most one write-target per user among carddav books.
+CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_write_target_idx
+ ON address_books (user_id)
+ WHERE source = 'carddav' AND is_write_target;
+
+-- Backfill: preserve today's behavior for existing single/multi-book users.
+UPDATE address_books
+ SET is_subscribed = true, is_lookup_source = true
+ WHERE source = 'carddav';
+
+-- First create-capable book per user becomes the write-target (mirrors the
+-- current selectedCreateBook rule; tie-break by insertion order).
+WITH ranked AS (
+ SELECT id, row_number() OVER (
+ PARTITION BY user_id
+ ORDER BY (remote_create_capability = 'allowed') DESC, created_at, id
+ ) AS position
+ FROM address_books
+ WHERE source = 'carddav'
+ AND remote_create_capability IN ('allowed', 'unknown')
+)
+UPDATE address_books SET is_write_target = true
+ WHERE id IN (SELECT id FROM ranked WHERE position = 1);
+
+-- Ledger gains a terminal 'lookup' status and a projected display name.
+ALTER TABLE carddav_remote_objects
+ DROP CONSTRAINT IF EXISTS carddav_remote_objects_mapping_status_check,
+ ADD CONSTRAINT carddav_remote_objects_mapping_status_check
+ CHECK (mapping_status IN
+ ('pending_materialization','synced','pending_push','conflict','lookup')),
+ ADD COLUMN IF NOT EXISTS lookup_display_name TEXT;
+
+-- Fast inbound lookup by email across a user's lookup books.
+CREATE INDEX IF NOT EXISTS carddav_lookup_email_idx
+ ON carddav_remote_objects (primary_email)
+ WHERE mapping_status = 'lookup';
diff --git a/backend/migrations/0039_carddav_write_target_alias.sql b/backend/migrations/0039_carddav_write_target_alias.sql
new file mode 100644
index 0000000..13d56a0
--- /dev/null
+++ b/backend/migrations/0039_carddav_write_target_alias.sql
@@ -0,0 +1,12 @@
+-- Multi-address-book CardDAV follow-up: write-target routing
+-- resolves the stored is_write_target book against a fresh CardDAV discovery
+-- snapshot by URL. Some servers advertise a stable "alias" href in PROPFIND
+-- discovery that 3xx-redirects (REPORT/PUT/DELETE) to a different canonical
+-- collection URL; carddavSync.js already detects and reconciles this by
+-- rewriting address_books.external_url to the canonical URL, but discovery
+-- keeps returning the alias forever afterward. Record the alias alongside the
+-- canonical URL so write-target resolution (and the export sweep) can match
+-- either, instead of a fresh discovery snapshot falsely reporting the
+-- write-target missing. Additive only — no existing column or row changes.
+ALTER TABLE address_books
+ ADD COLUMN IF NOT EXISTS discovery_alias_url TEXT;
diff --git a/backend/migrations/0040_carddav_publish_intent.sql b/backend/migrations/0040_carddav_publish_intent.sql
new file mode 100644
index 0000000..b12beff
--- /dev/null
+++ b/backend/migrations/0040_carddav_publish_intent.sql
@@ -0,0 +1,20 @@
+-- Multi-address-book CardDAV: separate "the user deliberately wants
+-- this contact in their address book" from is_auto.
+--
+-- routes/send.js sets is_auto = false on any contact the user sends mail to, and
+-- routes/search.js ranks autocomplete on exactly that flag ("contacts the user
+-- explicitly sent to or manually created"). is_auto therefore cannot also mean
+-- "publish this to CardDAV" — gating publication on it means replying to a
+-- harvested sender silently pushes them into the user's shared address book.
+-- Publication is gated on this column instead; is_auto keeps its upstream meaning
+-- and send.js is untouched, so autocomplete ranking is unaffected.
+--
+-- Backfill = NOT is_auto: every contact that is explicit on the old schema is
+-- being exported by the sweep today, so it keeps exporting. The false default
+-- applies only to rows created from here on, where a contact that became explicit
+-- merely by being emailed is not published until the user deliberately promotes
+-- it (or turns on the publishEmailedContacts setting).
+ALTER TABLE contacts
+ ADD COLUMN IF NOT EXISTS carddav_publish_intent BOOLEAN NOT NULL DEFAULT false;
+
+UPDATE contacts SET carddav_publish_intent = true WHERE is_auto = false;
diff --git a/backend/src/routes/carddav.test.js b/backend/src/routes/carddav.test.js
index 3484bdf..6af63a2 100644
--- a/backend/src/routes/carddav.test.js
+++ b/backend/src/routes/carddav.test.js
@@ -34,6 +34,9 @@ vi.mock('../services/carddavContactService.js', () => ({
ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
ERR_CARDDAV_PENDING_INTENT: 409,
ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_NO_WRITE_TARGET: 409,
+ ERR_CARDDAV_NOT_CONNECTED: 409,
+ ERR_CARDDAV_ALREADY_MAPPED: 409,
'23505': 409,
},
createContactFromVCard: mocks.createContactFromVCard,
@@ -429,6 +432,20 @@ describe('PUT CardDAV contact resource', () => {
expect(res.end).toHaveBeenCalled();
});
+ it('maps a missing write-target failure to 409 with no fallback', async () => {
+ mockResource(null);
+ mocks.createContactFromVCard.mockRejectedValueOnce(Object.assign(
+ new Error('No CardDAV write-target address book is configured'),
+ { code: 'ERR_CARDDAV_NO_WRITE_TARGET' },
+ ));
+ const res = response();
+
+ await putHandler(request({ headers: { 'if-none-match': '*' } }), res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.end).toHaveBeenCalled();
+ });
+
it('returns 412 without delegation when If-Match does not match confirmed local state', async () => {
mockResource();
const res = response();
diff --git a/backend/src/routes/carddavAccount.js b/backend/src/routes/carddavAccount.js
index d3c0eb8..2d182a3 100644
--- a/backend/src/routes/carddavAccount.js
+++ b/backend/src/routes/carddavAccount.js
@@ -14,9 +14,12 @@ import {
requestCarddavSync,
scheduleCardavUser,
getCardavConfig,
+ getCarddavBookSummaries,
+ patchCarddavBookRoles,
replaceCarddavConnection,
patchCarddavConnection,
disconnectCarddavAccount,
+ StaleCarddavPlanError,
} from '../services/carddavSync.js';
const router = Router();
@@ -24,8 +27,10 @@ router.use(requireAuth);
const clampInterval = (v) => Math.max(15, Math.min(1440, parseInt(v) || 60));
-// Public view of the connection — never leaks the stored password.
-function publicStatus(config) {
+// Public view of the connection — never leaks the stored password. `books` is
+// a per-book read-only summary (roles, capabilities, counts); `bookCount`/
+// `contactCount` remain as aggregate counts for backward compatibility.
+async function publicStatus(config, userId) {
if (!config?.serverUrl) return { connected: false };
return {
connected: true,
@@ -36,11 +41,15 @@ function publicStatus(config) {
lastError: config.lastError || null,
bookCount: config.bookCount ?? null,
contactCount: config.contactCount ?? null,
+ // Absent on every connection made before this setting existed, which must
+ // read as OFF — see the export sweep in carddavSync.js.
+ publishEmailedContacts: config.publishEmailedContacts === true,
+ books: await getCarddavBookSummaries(userId),
};
}
router.get('/', async (req, res) => {
- res.json(publicStatus(await getCardavConfig(req.session.userId)));
+ res.json(await publicStatus(await getCardavConfig(req.session.userId), req.session.userId));
});
router.post('/connect', async (req, res) => {
@@ -89,16 +98,26 @@ router.post('/connect', async (req, res) => {
scheduleCardavUser(req.session.userId, config.intervalMin);
// Kick off the first sync in the background; the client polls GET / for status.
requestCarddavSync(req.session.userId, config.connectionGeneration);
- res.json(publicStatus(config));
+ res.json(await publicStatus(config, req.session.userId));
});
-// Update the interval (and optionally rotate the password).
+// Update the interval and the publish-emailed-contacts setting (and optionally
+// rotate the password).
router.patch('/', async (req, res) => {
const existing = await getCardavConfig(req.session.userId);
if (!existing?.serverUrl) return res.status(409).json({ error: 'CardDAV not connected' });
const patch = {};
if (req.body.intervalMin != null) patch.intervalMin = clampInterval(req.body.intervalMin);
+ // Stored strictly as a boolean: this setting decides whether merely emailing
+ // someone publishes them to a shared address book, so a stray truthy value must
+ // never turn it on.
+ if (req.body.publishEmailedContacts != null) {
+ if (typeof req.body.publishEmailedContacts !== 'boolean') {
+ return res.status(400).json({ error: 'publishEmailedContacts must be a boolean' });
+ }
+ patch.publishEmailedContacts = req.body.publishEmailedContacts;
+ }
if (req.body.password) {
const policy = await getConnectionPolicy();
try {
@@ -119,14 +138,68 @@ router.patch('/', async (req, res) => {
: await patchCarddavConnection(req.session.userId, patch);
if (patch.intervalMin) scheduleCardavUser(req.session.userId, patch.intervalMin);
if (patch.password) requestCarddavSync(req.session.userId, config.connectionGeneration);
- res.json(publicStatus(config));
+ // Turning the setting on widens the export sweep, so publish the contacts it
+ // now admits instead of leaving the user waiting for the next interval tick.
+ // Like a book-role patch, it leaves the connection generation untouched, so a
+ // sync already in flight may have read the old value and be past the sweep:
+ // queue an uncoalesced follow-up rather than trusting it to have seen this.
+ // Turning it off needs no sync — it publishes nothing new, and contacts already
+ // in the address book stay there.
+ if (patch.publishEmailedContacts === true) {
+ requestCarddavSync(req.session.userId, config.connectionGeneration, { coalesce: false });
+ }
+ res.json(await publicStatus(config, req.session.userId));
+});
+
+// Map a book role-change failure to an HTTP status. A generation fence miss or
+// a disconnected connection is a 409 (the connection changed under the client);
+// a missing book is 404; a create-denied write-target attempt is 403; an empty
+// patch is 400. Anything else propagates to the 500 handler.
+function bookPatchError(res, err) {
+ if (err instanceof StaleCarddavPlanError) {
+ return res.status(409).json({ error: err.message, code: err.reason });
+ }
+ const status = {
+ ERR_CARDDAV_BOOK_PATCH_EMPTY: 400,
+ ERR_ADDRESS_BOOK_NOT_FOUND: 404,
+ ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_WRITE_TARGET_SUBSCRIBED: 409,
+ }[err.code];
+ if (status) return res.status(status).json({ error: err.message, code: err.code });
+ throw err;
+}
+
+// Per-book role management: Subscribe / Look-up-senders toggles and the
+// write-target radio (see patchCarddavBookRoles). Fenced against the connection
+// generation the caller just read, then a background sync applies the pull-side
+// effects (a re-subscribed book materializes; an ignored book's ledger drops).
+// The role change leaves the generation untouched, so a sync already in flight
+// for it may be past the patched book: request an uncoalesced sync, which queues
+// a re-run behind that one instead of trusting it to have seen the change.
+router.patch('/books/:id', async (req, res) => {
+ const config = await getCardavConfig(req.session.userId);
+ if (!config?.serverUrl) return res.status(409).json({ error: 'CardDAV not connected' });
+ const { isSubscribed, isLookupSource, makeWriteTarget } = req.body || {};
+ try {
+ await patchCarddavBookRoles(
+ req.session.userId,
+ req.params.id,
+ { isSubscribed, isLookupSource, makeWriteTarget },
+ config.connectionGeneration,
+ );
+ } catch (err) {
+ return bookPatchError(res, err);
+ }
+ requestCarddavSync(req.session.userId, config.connectionGeneration, { coalesce: false });
+ res.json(await publicStatus(config, req.session.userId));
});
router.post('/sync', async (req, res) => {
const config = await getCardavConfig(req.session.userId);
if (!config?.serverUrl) return res.status(409).json({ error: 'CardDAV not connected' });
const result = await syncUser(req.session.userId);
- res.json({ ...result, status: publicStatus(await getCardavConfig(req.session.userId)) });
+ const status = await publicStatus(await getCardavConfig(req.session.userId), req.session.userId);
+ res.json({ ...result, status });
});
router.delete('/', async (req, res) => {
diff --git a/backend/src/routes/carddavAccount.test.js b/backend/src/routes/carddavAccount.test.js
index 9b3b20c..2329dad 100644
--- a/backend/src/routes/carddavAccount.test.js
+++ b/backend/src/routes/carddavAccount.test.js
@@ -1,10 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
+class StaleCarddavPlanError extends Error {
+ constructor(details) {
+ super(details?.reason === 'not-connected' ? 'not connected' : 'CardDAV sync plan is stale');
+ this.name = 'StaleCarddavPlanError';
+ Object.assign(this, details);
+ }
+}
+
const mocks = vi.hoisted(() => ({
query: vi.fn(),
discoverAddressBooks: vi.fn(),
encrypt: vi.fn(value => `encrypted:${value}`),
getCardavConfig: vi.fn(),
+ getCarddavBookSummaries: vi.fn(),
+ patchCarddavBookRoles: vi.fn(),
replaceCarddavConnection: vi.fn(),
patchCarddavConnection: vi.fn(),
requestCarddavSync: vi.fn(),
@@ -25,12 +35,15 @@ vi.mock('../services/carddavClient.js', () => ({
}));
vi.mock('../services/carddavSync.js', () => ({
getCardavConfig: mocks.getCardavConfig,
+ getCarddavBookSummaries: mocks.getCarddavBookSummaries,
+ patchCarddavBookRoles: mocks.patchCarddavBookRoles,
replaceCarddavConnection: mocks.replaceCarddavConnection,
patchCarddavConnection: mocks.patchCarddavConnection,
requestCarddavSync: mocks.requestCarddavSync,
scheduleCardavUser: mocks.scheduleCardavUser,
syncUser: mocks.syncUser,
disconnectCarddavAccount: mocks.disconnectCarddavAccount,
+ StaleCarddavPlanError,
}));
const { default: router } = await import('./carddavAccount.js');
@@ -43,6 +56,9 @@ const patchHandler = router.stack
const syncHandler = router.stack
.find(layer => layer.route?.path === '/sync' && layer.route.methods.post)
.route.stack.at(-1).handle;
+const patchBookHandler = router.stack
+ .find(layer => layer.route?.path === '/books/:id' && layer.route.methods.patch)
+ .route.stack.at(-1).handle;
const deleteHandler = router.stack
.find(layer => layer.route?.path === '/' && layer.route.methods.delete)
.route.stack.at(-1).handle;
@@ -66,6 +82,7 @@ describe('POST /api/carddav/connect', () => {
lastError: null,
});
mocks.requestCarddavSync.mockReturnValue(true);
+ mocks.getCarddavBookSummaries.mockResolvedValue([]);
});
it('delegates replacement atomically and requests its committed generation', async () => {
@@ -93,13 +110,44 @@ describe('POST /api/carddav/connect', () => {
expect(mocks.replaceCarddavConnection.mock.invocationCallOrder[0])
.toBeLessThan(mocks.requestCarddavSync.mock.invocationCallOrder[0]);
expect(mocks.scheduleCardavUser).toHaveBeenCalledWith('user-1', 30);
+ expect(mocks.getCarddavBookSummaries).toHaveBeenCalledWith('user-1');
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
connected: true,
+ books: [],
}));
expect(res.json.mock.calls[0][0]).not.toHaveProperty('dupMode');
expect(res.json.mock.calls[0][0]).not.toHaveProperty('connectionGeneration');
});
+ it('surfaces the per-book summary returned by the sync layer', async () => {
+ const books = [{
+ id: 'book-1',
+ name: 'Personal',
+ externalUrl: 'https://dav.example.test/books/personal',
+ isWriteTarget: true,
+ isSubscribed: true,
+ isLookupSource: true,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ materializedCount: 3,
+ lookupCount: 0,
+ lastSyncAt: '2026-07-12T00:00:00.000Z',
+ }];
+ mocks.getCarddavBookSummaries.mockResolvedValueOnce(books);
+ const res = response();
+
+ await connectHandler({
+ session: { userId: 'user-1' },
+ body: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'secret',
+ intervalMin: 30,
+ },
+ }, res);
+
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ books }));
+ });
+
it('does not request a sync when replacement persistence fails', async () => {
mocks.replaceCarddavConnection.mockRejectedValueOnce(new Error('replace failed'));
const res = response();
@@ -137,6 +185,7 @@ describe('PATCH /api/carddav', () => {
connectionGeneration: 'generation-a',
});
mocks.requestCarddavSync.mockReturnValue(true);
+ mocks.getCarddavBookSummaries.mockResolvedValue([]);
});
it('ignores obsolete duplicate-mode input while delegating the interval', async () => {
@@ -227,6 +276,7 @@ describe('POST /api/carddav/sync', () => {
bookCount: 1,
contactCount: 2,
});
+ mocks.getCarddavBookSummaries.mockResolvedValue([]);
});
it('preserves the result-plus-status envelope while exposing counters', async () => {
@@ -256,6 +306,100 @@ describe('POST /api/carddav/sync', () => {
});
});
+describe('PATCH /api/carddav/books/:id', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getCardavConfig.mockResolvedValue({
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ connectionGeneration: 'generation-a',
+ });
+ mocks.patchCarddavBookRoles.mockResolvedValue('book-1');
+ mocks.requestCarddavSync.mockReturnValue(true);
+ mocks.getCarddavBookSummaries.mockResolvedValue([]);
+ });
+
+ it('delegates the role change fenced by generation, then syncs and returns the summary', async () => {
+ const books = [{ id: 'book-1', name: 'Primary', isWriteTarget: true }];
+ mocks.getCarddavBookSummaries.mockResolvedValueOnce(books);
+ const res = response();
+
+ await patchBookHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'book-1' },
+ body: { makeWriteTarget: true },
+ }, res);
+
+ expect(mocks.patchCarddavBookRoles).toHaveBeenCalledWith(
+ 'user-1',
+ 'book-1',
+ { isSubscribed: undefined, isLookupSource: undefined, makeWriteTarget: true },
+ 'generation-a',
+ );
+ // A role change carries no new generation, so a sync already in flight for that
+ // generation may be past the patched book: request one that cannot be coalesced away.
+ expect(mocks.requestCarddavSync)
+ .toHaveBeenCalledWith('user-1', 'generation-a', { coalesce: false });
+ expect(mocks.patchCarddavBookRoles.mock.invocationCallOrder[0])
+ .toBeLessThan(mocks.requestCarddavSync.mock.invocationCallOrder[0]);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ connected: true, books }));
+ });
+
+ it('rejects with 409 when CardDAV is not connected and never mutates or syncs', async () => {
+ mocks.getCardavConfig.mockResolvedValueOnce(null);
+ const res = response();
+
+ await patchBookHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'book-1' },
+ body: { isSubscribed: true },
+ }, res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(mocks.patchCarddavBookRoles).not.toHaveBeenCalled();
+ expect(mocks.requestCarddavSync).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['ERR_CARDDAV_READ_ONLY', 403],
+ ['ERR_CARDDAV_WRITE_TARGET_SUBSCRIBED', 409],
+ ['ERR_ADDRESS_BOOK_NOT_FOUND', 404],
+ ['ERR_CARDDAV_BOOK_PATCH_EMPTY', 400],
+ ])('maps %s to HTTP %i without triggering a sync', async (code, status) => {
+ mocks.patchCarddavBookRoles.mockRejectedValueOnce(Object.assign(new Error('nope'), { code }));
+ const res = response();
+
+ await patchBookHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'book-1' },
+ body: { makeWriteTarget: true },
+ }, res);
+
+ expect(res.status).toHaveBeenCalledWith(status);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code }));
+ expect(mocks.requestCarddavSync).not.toHaveBeenCalled();
+ });
+
+ it('maps a stale-generation fence to HTTP 409', async () => {
+ mocks.patchCarddavBookRoles.mockRejectedValueOnce(
+ new StaleCarddavPlanError({ reason: 'connection-generation-changed' }),
+ );
+ const res = response();
+
+ await patchBookHandler({
+ session: { userId: 'user-1' },
+ params: { id: 'book-1' },
+ body: { isSubscribed: false },
+ }, res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'connection-generation-changed' }),
+ );
+ expect(mocks.requestCarddavSync).not.toHaveBeenCalled();
+ });
+});
+
describe('DELETE /api/carddav', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/backend/src/routes/carddavConflicts.js b/backend/src/routes/carddavConflicts.js
index ed168c1..c9c3b7d 100644
--- a/backend/src/routes/carddavConflicts.js
+++ b/backend/src/routes/carddavConflicts.js
@@ -15,6 +15,7 @@ function conflictError(res, error) {
ERR_CARDDAV_CONFLICT_RESOLUTION: 400,
ERR_CARDDAV_CONFLICT_NOT_FOUND: 404,
ERR_CARDDAV_CONFLICT_STALE: 409,
+ ERR_CARDDAV_READ_ONLY: 403,
}[error.code];
if (status) return res.status(status).json({ error: error.message });
throw error;
diff --git a/backend/src/routes/contacts.js b/backend/src/routes/contacts.js
index 15dc71c..8056092 100644
--- a/backend/src/routes/contacts.js
+++ b/backend/src/routes/contacts.js
@@ -5,8 +5,10 @@ import {
CARDDAV_CONTACT_ERROR_STATUS,
createContact,
deleteContact,
+ promoteContact,
updateContact,
} from '../services/carddavContactService.js';
+import { resolveLookupPhoto } from '../services/carddavLookupService.js';
const router = Router();
router.use(requireAuth);
@@ -79,13 +81,16 @@ function contactError(res, err, fallback) {
return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code])
.json({ error: err.message, code: err.code, refresh: true });
}
+ // The code rides along with the message so a client can translate the states
+ // it can act on (no write-target, read-only book) instead of echoing a raw
+ // English message into a localized UI.
if (err.code === '23505') {
return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code])
.json({ error: 'A contact with that email already exists' });
}
const status = CARDDAV_CONTACT_ERROR_STATUS[err.code]
?? (Number.isInteger(err.status) ? err.status : null);
- if (status) return res.status(status).json({ error: err.message });
+ if (status) return res.status(status).json({ error: err.message, code: err.code });
console.error(`${fallback}:`, err);
return res.status(500).json({ error: fallback });
}
@@ -170,7 +175,18 @@ router.get('/photo', async (req, res) => {
[userId, email.trim()]
);
- if (!result.rows.length) return res.status(404).end();
+ // Ledger fallback: a sender materialized in no contacts row may still live in
+ // a lookup-only CardDAV book, whose retained vCard PHOTO is decoded lazily.
+ // Contacts-table photos are resolved first, so they always win.
+ if (!result.rows.length) {
+ const lookup = await resolveLookupPhoto(userId, email);
+ if (lookup) {
+ res.set('Cache-Control', 'private, max-age=86400');
+ res.set('Content-Type', lookup.mime);
+ return res.send(lookup.bytes);
+ }
+ return res.status(404).end();
+ }
const photoData = result.rows[0].photo_data;
res.set('Cache-Control', 'private, max-age=86400');
@@ -246,6 +262,20 @@ router.patch('/:id', async (req, res) => {
}
});
+// POST /api/contacts/:id/promote
+// Make an auto-collected contact explicit and export it to the CardDAV
+// write-target book right away. Editing a harvested contact deliberately does
+// not do this (see updateStoredContact) — publishing a sender to a shared
+// address book is a one-way action the user takes on purpose, never a side
+// effect of tidying up a field.
+router.post('/:id/promote', async (req, res) => {
+ try {
+ res.json(await promoteContact(req.session.userId, req.params.id));
+ } catch (err) {
+ contactError(res, err, 'Failed to save contact to the address book');
+ }
+});
+
// DELETE /api/contacts/:id
router.delete('/:id', async (req, res) => {
const userId = req.session.userId;
diff --git a/backend/src/routes/contacts.test.js b/backend/src/routes/contacts.test.js
index e13c183..16008bc 100644
--- a/backend/src/routes/contacts.test.js
+++ b/backend/src/routes/contacts.test.js
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
createContact: vi.fn(),
updateContact: vi.fn(),
deleteContact: vi.fn(),
+ promoteContact: vi.fn(),
+ resolveLookupPhoto: vi.fn(),
}));
vi.mock('../services/db.js', () => ({ query: mocks.query }));
@@ -22,11 +24,18 @@ vi.mock('../services/carddavContactService.js', () => ({
ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
ERR_CARDDAV_PENDING_INTENT: 409,
ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_NO_WRITE_TARGET: 409,
+ ERR_CARDDAV_NOT_CONNECTED: 409,
+ ERR_CARDDAV_ALREADY_MAPPED: 409,
'23505': 409,
},
createContact: mocks.createContact,
updateContact: mocks.updateContact,
deleteContact: mocks.deleteContact,
+ promoteContact: mocks.promoteContact,
+}));
+vi.mock('../services/carddavLookupService.js', () => ({
+ resolveLookupPhoto: mocks.resolveLookupPhoto,
}));
const { default: router } = await import('./contacts.js');
@@ -39,9 +48,11 @@ function handler(method, path) {
const listHandler = handler('get', '/');
const getHandler = handler('get', '/:id');
+const photoHandler = handler('get', '/photo');
const createHandler = handler('post', '/');
const updateHandler = handler('patch', '/:id');
const deleteHandler = handler('delete', '/:id');
+const promoteHandler = handler('post', '/:id/promote');
function response() {
return {
@@ -50,6 +61,18 @@ function response() {
};
}
+function photoResponse() {
+ const res = {
+ headers: {},
+ statusCode: 200,
+ set: vi.fn((key, value) => { res.headers[key] = value; return res; }),
+ status: vi.fn(code => { res.statusCode = code; return res; }),
+ send: vi.fn(() => res),
+ end: vi.fn(() => res),
+ };
+ return res;
+}
+
function draft(overrides = {}) {
return {
displayName: 'Ada Lovelace',
@@ -124,6 +147,45 @@ describe('contact read routes', () => {
});
});
+describe('GET /api/contacts/photo', () => {
+ it('serves a materialized contact photo without consulting the lookup ledger', async () => {
+ mocks.query.mockResolvedValueOnce({ rows: [{ photo_data: 'data:image/png;base64,AQID' }] });
+ const res = photoResponse();
+
+ await photoHandler({ session: { userId: 'user-1' }, query: { email: 'ada@example.test' } }, res);
+
+ expect(mocks.resolveLookupPhoto).not.toHaveBeenCalled();
+ expect(res.headers['Content-Type']).toBe('image/png');
+ expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
+ expect(res.send).toHaveBeenCalledWith(Buffer.from('AQID', 'base64'));
+ });
+
+ it('falls back to a lookup-only book avatar on a contacts miss', async () => {
+ mocks.query.mockResolvedValueOnce({ rows: [] });
+ mocks.resolveLookupPhoto.mockResolvedValueOnce({ mime: 'image/jpeg', bytes: Buffer.from([1, 2, 3]) });
+ const res = photoResponse();
+
+ await photoHandler({ session: { userId: 'user-1' }, query: { email: 'sender@example.test' } }, res);
+
+ expect(mocks.resolveLookupPhoto).toHaveBeenCalledWith('user-1', 'sender@example.test');
+ expect(res.headers['Content-Type']).toBe('image/jpeg');
+ expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
+ expect(res.send).toHaveBeenCalledWith(Buffer.from([1, 2, 3]));
+ expect(res.statusCode).toBe(200);
+ });
+
+ it('returns 404 when neither contacts nor a lookup book resolves the sender', async () => {
+ mocks.query.mockResolvedValueOnce({ rows: [] });
+ mocks.resolveLookupPhoto.mockResolvedValueOnce(null);
+ const res = photoResponse();
+
+ await photoHandler({ session: { userId: 'user-1' }, query: { email: 'nobody@example.test' } }, res);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.send).not.toHaveBeenCalled();
+ });
+});
+
describe('POST /api/contacts', () => {
it('delegates exactly once and preserves local-only response behavior', async () => {
const body = draft();
@@ -140,6 +202,22 @@ describe('POST /api/contacts', () => {
expect(res.json).toHaveBeenCalledWith(created);
});
+ it('returns 409 with an actionable message when no write-target book is configured', async () => {
+ mocks.createContact.mockRejectedValueOnce(Object.assign(
+ new Error('No CardDAV write-target address book is configured'),
+ { code: 'ERR_CARDDAV_NO_WRITE_TARGET' },
+ ));
+ const res = response();
+
+ await createHandler({ session: { userId: 'user-1' }, body: draft() }, res);
+
+ expect(res.status).toHaveBeenCalledWith(409);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'No CardDAV write-target address book is configured',
+ code: 'ERR_CARDDAV_NO_WRITE_TARGET',
+ });
+ });
+
it.each([
['emails', draft({ emails: 'not-an-array' }), 'emails must be an array'],
['phones', draft({ phones: 'not-an-array' }), 'phones must be an array'],
@@ -188,7 +266,10 @@ describe('PATCH /api/contacts/:id', () => {
expect(mocks.updateContact).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(403);
- expect(res.json).toHaveBeenCalledWith({ error: 'This CardDAV address book does not allow update' });
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'This CardDAV address book does not allow update',
+ code: 'ERR_CARDDAV_READ_ONLY',
+ });
});
it('attempts an unknown-capability operation and reports its upstream denial', async () => {
@@ -317,3 +398,35 @@ describe('DELETE /api/contacts/:id', () => {
expect(res.json.mock.calls[0][0]).not.toHaveProperty('retriable', true);
});
});
+
+describe('POST /api/contacts/:id/promote', () => {
+ it('delegates the promotion exactly once and returns the promoted contact', async () => {
+ const promoted = { id: 'contact-1', display_name: 'Ada Lovelace', is_auto: false };
+ mocks.promoteContact.mockResolvedValueOnce(promoted);
+ const res = response();
+
+ await promoteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res);
+
+ expect(mocks.promoteContact).toHaveBeenCalledTimes(1);
+ expect(mocks.promoteContact).toHaveBeenCalledWith('user-1', 'contact-1');
+ expect(mocks.query).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith(promoted);
+ });
+
+ // The codes the promote affordance translates into actionable copy, rather
+ // than echoing a raw English backend message into a localized UI.
+ it.each([
+ ['ERR_CARDDAV_NO_WRITE_TARGET', 409],
+ ['ERR_CARDDAV_READ_ONLY', 403],
+ ['ERR_CARDDAV_NOT_CONNECTED', 409],
+ ['ERR_CARDDAV_ALREADY_MAPPED', 409],
+ ])('surfaces %s as a typed HTTP %i the client can act on', async (code, status) => {
+ mocks.promoteContact.mockRejectedValueOnce(Object.assign(new Error('Promotion failed'), { code }));
+ const res = response();
+
+ await promoteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res);
+
+ expect(res.status).toHaveBeenCalledWith(status);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Promotion failed', code });
+ });
+});
diff --git a/backend/src/services/carddavConflictService.js b/backend/src/services/carddavConflictService.js
index a2957c7..bfca226 100644
--- a/backend/src/services/carddavConflictService.js
+++ b/backend/src/services/carddavConflictService.js
@@ -103,13 +103,14 @@ export async function getConflict(userId, id) {
return row ? publicConflict(row) : null;
}
-function resolutionStateSql(lock = false) {
+function resolutionStateSql(lock = false, includeWriteTarget = false) {
const mappingJoin = lock ? 'JOIN' : 'LEFT JOIN';
return `SELECT conflict.*, mapping.mapping_revision::text, mapping.mapping_status,
contact.id AS contact_id, contact.uid AS contact_uid,
contact.etag AS contact_etag,
contact.address_book_id AS local_address_book_id,
remote_book.external_url AS remote_book_url,
+ ${includeWriteTarget ? 'remote_book.is_write_target AS remote_book_is_write_target,' : ''}
integration.config,
integration.config->>'connectionGeneration' AS connection_generation
FROM carddav_conflicts conflict
@@ -130,12 +131,32 @@ function resolutionStateSql(lock = false) {
${lock ? 'FOR UPDATE OF conflict, mapping, integration' : ''}`;
}
+// The write-target flag is only ever read by the unlocked pre-check
+// (resolveConflict, before any lock or transaction is open) to decide whether
+// a 'keep-mailflow' resolution may push to the remote resource at all — the
+// locked re-reads inside commitResolution/refreshConflictAfter412 persist an
+// already-decided outcome and never need it. Keeping it out of the locked
+// query also means it never needs a not-yet-migrated (transitional) schema
+// fallback: that query's shape doesn't change.
async function readResolutionState(executor, userId, id, lock = false) {
- const { rows: [state] } = await executor.query(
- resolutionStateSql(lock),
- [userId, id],
- );
- return state || null;
+ if (lock) {
+ const { rows: [state] } = await executor.query(resolutionStateSql(true), [userId, id]);
+ return state || null;
+ }
+ try {
+ const { rows: [state] } = await executor.query(
+ resolutionStateSql(false, true),
+ [userId, id],
+ );
+ return state || null;
+ } catch (error) {
+ if (error?.code !== '42703') throw error;
+ const { rows: [state] } = await executor.query(
+ resolutionStateSql(false, false),
+ [userId, id],
+ );
+ return state || null;
+ }
}
function staleConflict(id) {
@@ -347,6 +368,21 @@ export async function resolveConflict(userId, id, resolution) {
return commitResolution(userId, preflight, resolution, remote);
}
+ // 'keep-mailflow' pushes the local side to the remote resource — the one
+ // MailFlow-originated write this function performs. A subscribed or lookup
+ // book is read-only from MailFlow regardless of server write capability
+ // (multi-book-design.md's load-bearing invariant), so refuse before any
+ // network call when this conflict's book isn't the write-target.
+ // `remote_book_is_write_target` is only ever `false` (never absent) when the
+ // schema was actually queried; on a not-yet-migrated (transitional) schema
+ // the column is omitted entirely and this check is skipped.
+ if (preflight.remote_book_is_write_target === false) {
+ throw typedError(
+ 'This CardDAV address book is not the write-target; keep-MailFlow cannot push to it',
+ 'ERR_CARDDAV_READ_ONLY',
+ );
+ }
+
const latest = await fetchLatestRemote(preflight, creds);
try {
if (preflight.local_tombstone) {
diff --git a/backend/src/services/carddavConflictService.test.js b/backend/src/services/carddavConflictService.test.js
index d196450..582efda 100644
--- a/backend/src/services/carddavConflictService.test.js
+++ b/backend/src/services/carddavConflictService.test.js
@@ -91,6 +91,7 @@ function resolutionRow(overrides = {}) {
contact_etag: 'local-etag-before',
local_address_book_id: LOCAL_BOOK_ID,
remote_book_url: 'https://dav.example.test/books/default/',
+ remote_book_is_write_target: true,
connection_generation: 'generation-current',
config: {
serverUrl: 'https://dav.example.test/',
@@ -353,6 +354,44 @@ describe('conflict resolution lifecycle', () => {
expect(sql.some(statement => /UPDATE carddav_conflicts/.test(statement))).toBe(true);
});
+ // multi-book-design.md's load-bearing invariant: a subscribed (or lookup)
+ // secondary is read-only from MailFlow regardless of server write
+ // capability, so 'keep-mailflow' — the one MailFlow-originated write this
+ // service performs — must refuse before any network call when this
+ // conflict's book isn't the write-target.
+ it('refuses keep-mailflow before any network call when the book is not the write-target', async () => {
+ const preflight = resolutionRow({ remote_book_is_write_target: false });
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+
+ await expect(conflictService.resolveConflict(
+ USER_ID,
+ CONFLICT_ID,
+ 'keep-mailflow',
+ )).rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+
+ expect(mocks.fetchCardResource).not.toHaveBeenCalled();
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).not.toHaveBeenCalled();
+ });
+
+ it('allows keep-carddav regardless of write-target status (no MailFlow-originated write)', async () => {
+ const preflight = resolutionRow({ remote_book_is_write_target: false });
+ const client = resolutionClient(preflight);
+ mocks.query.mockResolvedValueOnce({ rows: [preflight] });
+ mocks.fetchCardResource.mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: remoteVCard });
+ mocks.withTransaction.mockImplementationOnce(async callback => callback(client));
+
+ await expect(conflictService.resolveConflict(
+ USER_ID,
+ CONFLICT_ID,
+ 'keep-carddav',
+ )).resolves.toMatchObject({ status: 'resolved', resolution: 'keep-carddav' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ });
+
it('keep-mailflow conditionally deletes a local tombstone before canonical confirmation', async () => {
const preflight = resolutionRow({
local_vcard: null,
diff --git a/backend/src/services/carddavContactService.integration.test.js b/backend/src/services/carddavContactService.integration.test.js
index 24a1715..96285e6 100644
--- a/backend/src/services/carddavContactService.integration.test.js
+++ b/backend/src/services/carddavContactService.integration.test.js
@@ -229,7 +229,32 @@ async function seedConnectedUser(fixture) {
return { userId, connectionGeneration, localBookId: localBook.id };
}
-async function seedMappedContact(fixture, name = 'Mapped Before') {
+// Write-target routing (multi-book Slice 2) resolves the stored is_write_target
+// book against a *fresh* discovery snapshot; nothing yet assigns that flag to a
+// newly discovered book (that bootstrap is out of this slice's scope — see
+// Slice 3/5 of the multi-book design), so a brand-new fixture connection must
+// have its single book pre-designated the write target, exactly as Slice 1's
+// migration backfill does for an already-connected single-book user.
+async function seedWriteTargetBook(fixture, userId) {
+ const externalUrl = new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href;
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed', true, true, true)
+ `, [userId, externalUrl]);
+ return externalUrl;
+}
+
+// `isWriteTarget` defaults to true: these fixtures back tests of pending-intent,
+// conflict, and throttle *mechanics*, not the write-target invariant itself, so
+// they seed a mapped contact whose book is the write target — mirroring what
+// Slice 1's migration backfill (or a real first-sync auto-assignment) already
+// established for an already-connected single-book user. A dedicated test
+// below covers `isWriteTarget: false` (a subscribed secondary) to prove the
+// invariant that a non-write-target book never receives a PUT/DELETE.
+async function seedMappedContact(fixture, name = 'Mapped Before', { isWriteTarget = true } = {}) {
const seeded = await seedConnectedUser(fixture);
const uid = randomUUID();
const rawVCard = vcard(uid, name);
@@ -238,10 +263,15 @@ async function seedMappedContact(fixture, name = 'Mapped Before') {
const { rows: [remoteBook] } = await databaseClient.query(`
INSERT INTO address_books (
user_id, name, source, external_url,
- remote_create_capability, remote_update_capability, remote_delete_capability
- ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed')
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed', $3, true, true)
RETURNING id
- `, [seeded.userId, new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href]);
+ `, [
+ seeded.userId,
+ new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href,
+ isWriteTarget,
+ ]);
const { rows: [contact] } = await databaseClient.query(`
INSERT INTO contacts (
address_book_id, user_id, uid, vcard, etag, display_name,
@@ -276,6 +306,49 @@ async function seedMappedContact(fixture, name = 'Mapped Before') {
return { ...seeded, uid, href, remoteEtag, remoteBookId: remoteBook.id, contact };
}
+// A sender harvested from an inbound message: an unmapped `is_auto` contacts row
+// in the user's local book, exactly as imapManager's collector leaves it. The
+// carddav book is seeded create-capable either way, so only its is_write_target
+// flag decides whether promotion has a destination.
+async function seedAutoContact(fixture, {
+ displayName = 'Harvested Sender',
+ isWriteTarget = true,
+} = {}) {
+ const seeded = await seedConnectedUser(fixture);
+ const { rows: [remoteBook] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed', $3, true, true)
+ RETURNING id
+ `, [
+ seeded.userId,
+ new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href,
+ isWriteTarget,
+ ]);
+ const uid = randomUUID();
+ const email = `${uid}@example.test`;
+ const rawVCard = vcard(uid, displayName, email);
+ const { rows: [contact] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, additional_fields, is_auto
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,'[]'::jsonb,'[]'::jsonb,true)
+ RETURNING id, etag
+ `, [
+ seeded.localBookId,
+ seeded.userId,
+ uid,
+ rawVCard,
+ createHash('md5').update(rawVCard).digest('hex'),
+ displayName,
+ email,
+ JSON.stringify([{ value: email, type: 'other', primary: true }]),
+ ]);
+ return { ...seeded, uid, email, remoteBookId: remoteBook.id, contact };
+}
+
// Reproduce the state a sync pull leaves behind: the local contacts row holds the
// LOSSY re-serialized vCard (generateVCard drops unmodeled properties) while the
// carddav_remote_objects row retains the FULL remote vCard. The local UID is the
@@ -298,8 +371,9 @@ async function seedImportedContact(fixture, remoteVCard, remoteEtag = '"imported
const { rows: [remoteBook] } = await databaseClient.query(`
INSERT INTO address_books (
user_id, name, source, external_url,
- remote_create_capability, remote_update_capability, remote_delete_capability
- ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed')
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed', true, true, true)
RETURNING id
`, [seeded.userId, new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href]);
const { rows: [contact] } = await databaseClient.query(`
@@ -461,6 +535,7 @@ describe('CardDAV contact mutations against PostgreSQL 16 and HTTP', () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
const { userId, localBookId } = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, userId);
const uid = randomUUID();
try {
@@ -570,10 +645,220 @@ describe('CardDAV contact mutations against PostgreSQL 16 and HTTP', () => {
}
}, 120_000);
+ // carddavSync.js's canonical-URL reconciliation (advanceDiscoveredBookState)
+ // rewrites a book's external_url to the canonical URL a redirect resolves
+ // to, but records the alias it replaced in discovery_alias_url — because
+ // the server keeps advertising that alias in PROPFIND discovery forever
+ // afterward. selectedCreateBook matches the write-target by either URL, so
+ // an interactive create discovers and PUTs through the alias; persisting
+ // that discovered book must resolve back to the *existing* write-target
+ // row (by discovery_alias_url), never insert a second, non-write-target
+ // row for the same remote collection.
+ it('routes a create through the discovery alias into the existing write-target row, never a duplicate', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const { userId, localBookId } = await seedConnectedUser(fixture);
+ const canonicalUrl = new URL('/addressbooks/fixture-user/canonical/', fixture.serverUrl).href;
+ const aliasUrl = new URL('/addressbooks/fixture-user/alias/', fixture.serverUrl).href;
+ const { rows: [writeTargetBook] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, discovery_alias_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, $3, 'allowed', 'allowed', 'allowed', true, true, true)
+ RETURNING id
+ `, [userId, canonicalUrl, aliasUrl]);
+ const uid = randomUUID();
+
+ try {
+ resetObservation(fixture);
+ fixture.queueDiscovery({ books: [{ href: '/addressbooks/fixture-user/alias/', displayName: 'Alias Book' }] });
+ const created = await contactService.createContactFromVCard(userId, {
+ localAddressBookId: localBookId,
+ uid,
+ rawVCard: vcard(uid, 'Alias Routed Create'),
+ });
+
+ const { rows: books } = await databaseClient.query(`
+ SELECT id, external_url, discovery_alias_url, is_write_target,
+ remote_update_capability
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ `, [userId]);
+ // Still exactly one carddav book — the pre-existing write-target row,
+ // untouched — never a second, non-write-target row for the alias URL.
+ expect(books).toEqual([{
+ id: writeTargetBook.id,
+ external_url: canonicalUrl,
+ discovery_alias_url: aliasUrl,
+ is_write_target: true,
+ remote_update_capability: 'allowed',
+ }]);
+ const afterCreate = await authoritativeState(userId);
+ // Mapped into that same write-target row: assertWritable's
+ // mapping_is_write_target check (carddavContactService.js) reads this
+ // join, so this contact is not stranded on a stray non-target
+ // duplicate that would reject every future update/delete as read-only.
+ expect(afterCreate.remoteObjects).toEqual([
+ expect.objectContaining({
+ address_book_id: writeTargetBook.id,
+ local_contact_id: created.id,
+ }),
+ ]);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // multi-book-design.md, key decision 3: an incidental cleanup of a harvested
+ // sender must not publish it. The edit lands locally and the contact stays
+ // auto-collected, so the next sync's export sweep (which only claims
+ // is_auto = false contacts) still passes it over.
+ it('keeps an edited harvested contact auto-collected and writes nothing remote', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedAutoContact(fixture);
+
+ try {
+ resetObservation(fixture);
+ const updated = await contactService.updateContact(
+ seeded.userId,
+ seeded.contact.id,
+ { displayName: 'Harvested Renamed', organization: 'Tidied Up' },
+ );
+
+ expect(updated).toMatchObject({
+ id: seeded.contact.id,
+ display_name: 'Harvested Renamed',
+ organization: 'Tidied Up',
+ is_auto: true,
+ });
+ const after = await authoritativeState(seeded.userId);
+ expect(after.contacts).toEqual([
+ expect.objectContaining({ id: seeded.contact.id, is_auto: true }),
+ ]);
+ expect(after.remoteObjects).toEqual([]);
+ expect(fixture.counters.requests).toBe(0);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // Promotion is the one deliberate action that makes a harvested contact
+ // explicit, and it lands in the designated write-target — never in the first
+ // create-capable book discovery happens to return (that was the wrong-book
+ // footgun). The local is_auto flip is remote-first: it commits only after the
+ // PUT is read back and confirmed.
+ it('promotes a harvested contact into the write-target book, never a sibling book', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedAutoContact(fixture, { isWriteTarget: false });
+ const writeTargetPath = '/addressbooks/fixture-user/shared/';
+ const writeTargetUrl = new URL(writeTargetPath, fixture.serverUrl).href;
+ const { rows: [writeTargetBook] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Shared Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed', true, true, true)
+ RETURNING id
+ `, [seeded.userId, writeTargetUrl]);
+
+ try {
+ resetObservation(fixture);
+ // Discovery returns the sibling book FIRST: only the stored write-target
+ // flag stands between this contact and the wrong book.
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' },
+ { href: writeTargetPath, displayName: 'Shared Contacts' },
+ ] });
+ const promoted = await contactService.promoteContact(seeded.userId, seeded.contact.id);
+
+ expect(promoted).toMatchObject({ id: seeded.contact.id, is_auto: false });
+ const put = fixture.requests.filter(request => request.method === 'PUT');
+ expect(put).toHaveLength(1);
+ expect(put[0].path).toBe(`${writeTargetPath}${seeded.uid}.vcf`);
+ expect(fixture.counters.create).toBe(1);
+
+ const after = await authoritativeState(seeded.userId);
+ expect(after.contacts).toEqual([
+ expect.objectContaining({ id: seeded.contact.id, is_auto: false }),
+ ]);
+ expect(after.remoteObjects).toEqual([
+ expect.objectContaining({
+ address_book_id: writeTargetBook.id,
+ href: new URL(`${seeded.uid}.vcf`, writeTargetUrl).href,
+ local_contact_id: seeded.contact.id,
+ mapping_status: 'synced',
+ }),
+ ]);
+ expect(after.conflicts).toEqual([]);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // Promotion is the deliberate act that makes a harvested contact the user's own,
+ // so it records the publish intent the export sweep gates on. Without that the
+ // contact would be relying on the publish-emailed-contacts setting — OFF here,
+ // as it is by default — to stay in the address book it was just added to.
+ it('records publish intent when promoting, independently of the emailed-contacts setting', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedAutoContact(fixture);
+
+ try {
+ resetObservation(fixture);
+ const promoted = await contactService.promoteContact(seeded.userId, seeded.contact.id);
+
+ expect(promoted).toMatchObject({ id: seeded.contact.id, is_auto: false });
+ expect(fixture.requests.filter(request => request.method === 'PUT')).toHaveLength(1);
+
+ const { rows: [row] } = await databaseClient.query(
+ 'SELECT is_auto, carddav_publish_intent FROM contacts WHERE id = $1',
+ [seeded.contact.id],
+ );
+ expect(row).toEqual({ is_auto: false, carddav_publish_intent: true });
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // The book is fully create-capable on the server, so only the missing
+ // write-target designation refuses this promotion — no silent fallback to
+ // "some other writable book", and no local is_auto flip without a remote.
+ it('refuses to promote without a write-target and leaves the contact harvested', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedAutoContact(fixture, { isWriteTarget: false });
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' },
+ ] });
+ await expect(contactService.promoteContact(seeded.userId, seeded.contact.id))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_NO_WRITE_TARGET' });
+
+ expect(await authoritativeState(seeded.userId)).toEqual(before);
+ expect(fixture.counters.create).toBe(0);
+ expect(fixture.requests.some(request => request.method === 'PUT')).toBe(false);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
it('atomically resolves concurrent mapped create-only requests for one book and UID', async () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
const { userId, localBookId } = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, userId);
const uid = randomUUID();
let preflightCommits = 0;
let releasePreflightCommits;
@@ -667,12 +952,35 @@ describe('CardDAV contact mutations against PostgreSQL 16 and HTTP', () => {
}
});
- // The recovery fetch after a 412 must reject a malformed or oversized REMOTE
- // snapshot before it can persist. (A replace now overlays the client body onto the
- // retained remote vCard, so the pending intent is bounded by the retained document
- // rather than the raw client body; the pending-intent 1 MiB and conflict-snapshot
- // 2 MiB size limits are unit-tested at their exact boundaries in
- // carddavMappingState.test.js.)
+ // A subscribed or lookup secondary stays read-only from MailFlow even when
+ // the remote server advertises full mutation capability.
+ it('never PUTs or DELETEs a contact mapped into a book that is not the write-target', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedMappedContact(fixture, 'Secondary Mapped', { isWriteTarget: false });
+
+ try {
+ resetObservation(fixture);
+ const before = await authoritativeState(seeded.userId);
+ await expect(contactService.updateContact(
+ seeded.userId,
+ seeded.contact.id,
+ draft('Rejected Secondary Edit', `${seeded.uid}@example.test`),
+ )).rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+ expect(await authoritativeState(seeded.userId)).toEqual(before);
+ expect(fixture.counters.requests).toBe(0);
+
+ await expect(contactService.deleteContact(seeded.userId, seeded.contact.id))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+ expect(await authoritativeState(seeded.userId)).toEqual(before);
+ expect(fixture.counters.requests).toBe(0);
+ expectNoNetworkInsideTransactions();
+ } finally {
+ await fixture.close();
+ }
+ });
+ // The recovery fetch after a 412 must reject a malformed or oversized remote
+ // snapshot before it can persist.
it.each([
{
label: 'malformed remote snapshot',
@@ -1155,7 +1463,7 @@ describe('CardDAV contact mutations against PostgreSQL 16 and HTTP', () => {
}
}, 120_000);
- it('does not duplicate the modeled main when a mixed Apple item-group survives (W7)', async () => {
+ it('does not duplicate the modeled main when a mixed Apple item-group survives', async () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
try {
diff --git a/backend/src/services/carddavContactService.js b/backend/src/services/carddavContactService.js
index 9c07fcf..7f3c2ed 100644
--- a/backend/src/services/carddavContactService.js
+++ b/backend/src/services/carddavContactService.js
@@ -73,6 +73,9 @@ export const CARDDAV_CONTACT_ERROR_STATUS = Object.freeze({
ERR_CONTACT_EXISTS: 409,
ERR_CARDDAV_CONFLICT: 409,
ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_NO_WRITE_TARGET: 409,
+ ERR_CARDDAV_NOT_CONNECTED: 409,
+ ERR_CARDDAV_ALREADY_MAPPED: 409,
ERR_CARDDAV_FINAL_FENCE: 503,
ERR_CARDDAV_STALE_GENERATION: 503,
ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
@@ -247,7 +250,7 @@ function mergedReplacePayload(clientDocument, retainedDocument) {
continue;
}
// A grouped property survives iff the group has an unmodeled member whose name the client
- // omitted. Then the group survives, but W7: emit ONLY its unmodeled members and its
+ // omitted. Then the group survives, but emit ONLY its unmodeled members and its
// X-ABLABEL — NEVER a modeled main (ADR/URL/TEL/…) the client already owns via its
// full-state modeled fields, which would duplicate that property on the wire (the standard
// Apple item1.ADR + item1.X-ABADR + item1.X-ABLABEL layout). ACCEPTED CONSEQUENCE: a
@@ -303,10 +306,25 @@ function capability(contact, operation) {
return contact[`remote_${operation}_capability`] ?? 'unknown';
}
+// A mapped contact whose remote book is not the user's designated
+// is_write_target is a subscribed (or lookup) secondary: read-only from
+// MailFlow *regardless of server write capability* (multi-book-design.md's
+// load-bearing invariant). Every caller only reaches assertWritable for a
+// mapped contact (contact.href is truthy — see updateContact et al.), so an
+// unmapped contact's absent mapping_is_write_target (undefined, not `false`)
+// never trips this. Same for a not-yet-migrated (transitional) schema, where
+// readContact omits the column entirely — consistent with every book being
+// unrestricted before multi-book roles existed.
function assertWritable(contact, operation) {
if (capability(contact, operation) === 'denied') {
throw typedError(`This CardDAV address book does not allow ${operation}`, 'ERR_CARDDAV_READ_ONLY');
}
+ if (contact.mapping_is_write_target === false) {
+ throw typedError(
+ 'This CardDAV address book is not the write-target and cannot be modified',
+ 'ERR_CARDDAV_READ_ONLY',
+ );
+ }
if (contact.mapping_status === 'conflict' && contact.conflict_id) {
throw new CardDavConflictError(contact.conflict_id);
}
@@ -324,15 +342,8 @@ async function readIntegration(client, userId, lock = false) {
return integration?.config?.serverUrl ? integration : null;
}
-async function readContact(client, userId, { contactId, localAddressBookId, uid }) {
- const conditions = contactId
- ? 'c.id = $2'
- : 'c.address_book_id = $2 AND c.uid = $3';
- const params = contactId
- ? [userId, contactId]
- : [userId, localAddressBookId, uid];
- const { rows: [contact] } = await client.query(
- `SELECT c.*,
+function readContactSql(conditions, includeWriteTarget) {
+ return `SELECT c.*,
c.address_book_id AS local_address_book_id,
mapping.address_book_id AS mapping_address_book_id,
mapping.href, mapping.remote_etag, mapping.mapping_status,
@@ -346,6 +357,7 @@ async function readContact(client, userId, { contactId, localAddressBookId, uid
remote_book.remote_create_capability,
remote_book.remote_update_capability,
remote_book.remote_delete_capability,
+ ${includeWriteTarget ? 'remote_book.is_write_target AS mapping_is_write_target,' : ''}
conflict.id AS conflict_id
FROM contacts c
JOIN address_books local_book ON local_book.id = c.address_book_id
@@ -357,10 +369,36 @@ async function readContact(client, userId, { contactId, localAddressBookId, uid
ON conflict.address_book_id = mapping.address_book_id
AND conflict.href = mapping.href
AND conflict.status = 'unresolved'
- WHERE c.user_id = $1 AND ${conditions}`,
- params,
- );
- return contact || null;
+ WHERE c.user_id = $1 AND ${conditions}`;
+}
+
+// The write-target join is read on every mapped-contact mutation (see
+// assertWritable), so it's guarded the same way selectedCreateBook guards its
+// own is_write_target read: catch PostgreSQL's undefined_column SQLSTATE
+// (a not-yet-migrated, transitional schema) and retry without that column,
+// rather than a separate information_schema pre-check on every read. A
+// SAVEPOINT is required around the first attempt because this always runs
+// inside the caller's transaction — an uncaught undefined_column error would
+// otherwise abort that whole transaction, not just this query.
+async function readContact(client, userId, { contactId, localAddressBookId, uid }) {
+ const conditions = contactId
+ ? 'c.id = $2'
+ : 'c.address_book_id = $2 AND c.uid = $3';
+ const params = contactId
+ ? [userId, contactId]
+ : [userId, localAddressBookId, uid];
+ await client.query('SAVEPOINT carddav_read_contact');
+ try {
+ const { rows: [contact] } = await client.query(readContactSql(conditions, true), params);
+ await client.query('RELEASE SAVEPOINT carddav_read_contact');
+ return contact || null;
+ } catch (error) {
+ if (!isUndefinedColumn(error)) throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_read_contact');
+ await client.query('RELEASE SAVEPOINT carddav_read_contact');
+ const { rows: [contact] } = await client.query(readContactSql(conditions, false), params);
+ return contact || null;
+ }
}
async function readOwnedBook(client, userId, addressBookId) {
@@ -406,11 +444,68 @@ async function discoverCreateContext(userId, integration) {
}
}
-function selectedCreateBook(books) {
- if (!Array.isArray(books)) throw typedError('A fresh CardDAV book snapshot is required', 'ERR_CARDDAV_BOOKS');
- const selected = books.find(book => book.capabilities?.create === 'allowed')
+// PostgreSQL's undefined_column SQLSTATE — a not-yet-migrated (transitional,
+// mid-deploy) database that predates the multi-book columns. Detected by
+// catching the error rather than a separate information_schema pre-check (as
+// supportsPendingIntentSchema does) because this query runs on every single
+// write, not once per sync; a second round trip here is exactly the kind of
+// per-write latency this hot path can't absorb.
+function isUndefinedColumn(error) {
+ return error?.code === '42703';
+}
+
+// The single destination for every MailFlow-originated create/export/promotion:
+// the book the user designated `is_write_target`, resolved against a *fresh*
+// discovery snapshot by `external_url` (discovery may run seconds or days after
+// the book was last persisted). There is deliberately no fallback to a
+// different book — a missing or now-denied write-target is a typed, actionable
+// error, never a silent re-route to "some other writable book" (that was the
+// wrong-book footgun this replaces).
+async function writeTargetBookUrls(userId) {
+ const { rows: [target] } = await query(
+ `SELECT external_url, discovery_alias_url
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true`,
+ [userId],
+ );
+ return target ? { externalUrl: target.external_url, aliasUrl: target.discovery_alias_url } : null;
+}
+
+function firstCapableBook(books) {
+ return books.find(book => book.capabilities?.create === 'allowed')
|| books.find(book => (book.capabilities?.create ?? 'unknown') === 'unknown');
- if (!selected) throw typedError('No writable CardDAV address book was discovered', 'ERR_CARDDAV_READ_ONLY');
+}
+
+async function selectedCreateBook(userId, books) {
+ if (!Array.isArray(books)) throw typedError('A fresh CardDAV book snapshot is required', 'ERR_CARDDAV_BOOKS');
+ let target;
+ try {
+ target = await writeTargetBookUrls(userId);
+ } catch (error) {
+ if (!isUndefinedColumn(error)) throw error;
+ const fallback = firstCapableBook(books);
+ if (!fallback) throw typedError('No writable CardDAV address book was discovered', 'ERR_CARDDAV_READ_ONLY');
+ return fallback;
+ }
+ if (!target) {
+ throw typedError('No CardDAV write-target address book is configured', 'ERR_CARDDAV_NO_WRITE_TARGET');
+ }
+ // Match by the stored canonical URL first, then by the discovery alias a
+ // redirect-reconciling server may still be advertising (see
+ // advanceDiscoveredBookState in carddavMappingState.js) — a fresh discovery
+ // snapshot can otherwise never re-find a book whose external_url sync
+ // already rewrote to a different canonical URL.
+ const selected = books.find(book => book.url === target.externalUrl)
+ || (target.aliasUrl && books.find(book => book.url === target.aliasUrl));
+ if (!selected) {
+ throw typedError(
+ 'The configured CardDAV write-target address book was not found on this server',
+ 'ERR_CARDDAV_NO_WRITE_TARGET',
+ );
+ }
+ if (selected.capabilities?.create === 'denied') {
+ throw typedError('The CardDAV write-target address book does not allow create', 'ERR_CARDDAV_READ_ONLY');
+ }
return selected;
}
@@ -474,6 +569,12 @@ export function contactValues(payload) {
];
}
+// A contact created through this service is one the user deliberately created, so
+// it carries publish intent: unlike a contact that became explicit merely by being
+// emailed, it belongs in the address book whatever publishEmailedContacts says.
+// This matters most for a contact created while CardDAV is disconnected —
+// connecting an account later sweeps it out, and the intent recorded here is what
+// lets it through.
export async function insertContact(client, userId, addressBookId, payload, {
returning = API_CONTACT_COLUMNS,
} = {}) {
@@ -481,8 +582,9 @@ export async function insertContact(client, userId, addressBookId, payload, {
`INSERT INTO contacts (
address_book_id, user_id, uid, vcard, etag,
display_name, first_name, last_name, primary_email,
- emails, phones, organization, notes, photo_data, additional_fields, is_auto
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb,false)
+ emails, phones, organization, notes, photo_data, additional_fields, is_auto,
+ carddav_publish_intent
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb,false,true)
RETURNING ${returning}`,
[addressBookId, userId, ...contactValues(payload)],
);
@@ -494,24 +596,33 @@ function isLocalContactUidConflict(error) {
&& error.constraint === 'contacts_address_book_id_uid_key';
}
+// `markExplicit` clears is_auto and records publish intent — together, the flip
+// that makes a harvested contact a curated one the export sweep will keep in sync.
+// Only a *remote-confirmed* create sets it (see commitRemoteCreate): editing a
+// field of an auto-collected contact deliberately leaves it auto-collected, so an
+// incidental cleanup of a harvested sender never publishes it to the shared
+// write-target book (multi-book-design.md, key decision 3). Promotion is the one
+// deliberate path, and the intent it records is what keeps the contact published
+// no matter what the publishEmailedContacts setting is later set to.
export async function updateStoredContact(
client,
userId,
contactId,
payload,
expectedEtag = null,
- { returning = API_CONTACT_COLUMNS, onMissing = null } = {},
+ { returning = API_CONTACT_COLUMNS, onMissing = null, markExplicit = false } = {},
) {
const etagFence = expectedEtag == null ? '' : 'AND etag = $16';
const params = [...contactValues(payload), contactId, userId];
if (expectedEtag != null) params.push(expectedEtag);
+ const explicit = markExplicit ? ' is_auto = false, carddav_publish_intent = true,' : '';
const { rows: [row] } = await client.query(
`UPDATE contacts SET
uid = $1, vcard = $2, etag = $3,
display_name = $4, first_name = $5, last_name = $6,
primary_email = $7, emails = $8::jsonb, phones = $9::jsonb,
organization = $10, notes = $11, photo_data = $12,
- additional_fields = $13::jsonb, is_auto = false, updated_at = NOW()
+ additional_fields = $13::jsonb,${explicit} updated_at = NOW()
WHERE id = $14 AND user_id = $15 ${etagFence}
RETURNING ${returning}`,
params,
@@ -707,6 +818,10 @@ async function commitRemoteCreate({ userId, preflight, localAddressBookId, book,
}
const localBook = localAddressBookId ?? await ensureLocalBook(client, userId);
const remoteBook = await persistDiscoveredBook(client, { userId, ...book });
+ // The contact now exists in the write-target book, so it is explicit:
+ // an export of an already-explicit contact re-affirms the flag, and a
+ // promotion of a harvested one flips it — here, after the remote write
+ // is confirmed, never before.
const row = preflight.contactId
? await updateStoredContact(
client,
@@ -714,6 +829,7 @@ async function commitRemoteCreate({ userId, preflight, localAddressBookId, book,
preflight.contactId,
payload,
preflight.localEtag,
+ { markExplicit: true },
)
: await insertContact(client, userId, localBook, payload);
assertMappingApplied(await applyConfirmedRemoteContact(client, {
@@ -1277,7 +1393,7 @@ export async function createContact(userId, draft) {
return withTransaction(client => createLocal(client, userId, null, payload));
}
const { books, creds } = await discoverCreateContext(userId, preflight.integration);
- const selected = selectedCreateBook(books);
+ const selected = await selectedCreateBook(userId, books);
const payload = payloadForDraft(uid, validatedDraft, selectedVCardVersion(selected));
return remoteCreate({
userId,
@@ -1342,7 +1458,7 @@ export async function deleteContact(userId, contactId) {
}
export async function exportExistingContact(userId, contactId, { books, expectedGeneration }) {
- const selected = selectedCreateBook(books);
+ const selected = await selectedCreateBook(userId, books);
const prepared = await withTransaction(async client => {
const integration = await readIntegration(client, userId);
if (!integration) throw typedError('CardDAV is not connected', 'ERR_CARDDAV_NOT_CONNECTED');
@@ -1385,6 +1501,27 @@ export async function exportExistingContact(userId, contactId, { books, expected
});
}
+// The deliberate promotion of an auto-collected contact: make it explicit and
+// export it to the write-target book *now*, so the user gets direct feedback
+// instead of a silent next-sync side effect. It is a one-way action, not a
+// per-contact sync toggle — once explicit, the contact simply obeys the normal
+// "explicit contacts synchronize automatically" rule.
+//
+// Deliberately the same write path as the sweep's export (exportExistingContact),
+// so promotion inherits every invariant unchanged: write-target-only routing,
+// the Retry-After gate, the connection-generation fence, and the remote-first
+// commit whose ambiguous-write recovery re-reads the remote before touching
+// local state. The is_auto flip rides along in that confirmed commit.
+export async function promoteContact(userId, contactId) {
+ const integration = await withTransaction(client => readIntegration(client, userId));
+ if (!integration) throw typedError('CardDAV is not connected', 'ERR_CARDDAV_NOT_CONNECTED');
+ const { books } = await discoverCreateContext(userId, integration);
+ return exportExistingContact(userId, contactId, {
+ books,
+ expectedGeneration: integration.config.connectionGeneration ?? null,
+ });
+}
+
export async function createContactFromVCard(userId, {
localAddressBookId,
uid,
@@ -1422,7 +1559,7 @@ export async function createContactFromVCard(userId, {
});
if (prepared.local) return prepared.local;
const { books, creds } = await discoverCreateContext(userId, prepared.integration);
- const selected = selectedCreateBook(books);
+ const selected = await selectedCreateBook(userId, books);
return remoteCreate({
userId,
preflight: prepared,
diff --git a/backend/src/services/carddavContactService.test.js b/backend/src/services/carddavContactService.test.js
index 50bce3a..a71f52c 100644
--- a/backend/src/services/carddavContactService.test.js
+++ b/backend/src/services/carddavContactService.test.js
@@ -68,6 +68,9 @@ it('exports the shared CardDAV contact error status mappings', () => {
ERR_CONTACT_EXISTS: 409,
ERR_CARDDAV_CONFLICT: 409,
ERR_CARDDAV_READ_ONLY: 403,
+ ERR_CARDDAV_NO_WRITE_TARGET: 409,
+ ERR_CARDDAV_NOT_CONNECTED: 409,
+ ERR_CARDDAV_ALREADY_MAPPED: 409,
ERR_CARDDAV_FINAL_FENCE: 503,
ERR_CARDDAV_STALE_GENERATION: 503,
ERR_CARDDAV_AMBIGUOUS_WRITE: 409,
@@ -160,6 +163,24 @@ const book = (overrides = {}) => ({
...overrides,
});
+// Default write-target row matching the single default book most tests
+// discover, so existing single-book scenarios keep resolving the same way
+// selectedCreateBook did before it started reading the stored write-target.
+// Tests exercising multi-book resolution, a missing write-target, an alias,
+// or a denied one override this in their own body.
+function stubWriteTarget(externalUrl = BOOK_URL, aliasUrl = null) {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes('is_write_target')) {
+ return {
+ rows: externalUrl
+ ? [{ external_url: externalUrl, discovery_alias_url: aliasUrl }]
+ : [],
+ };
+ }
+ return { rows: [] };
+ });
+}
+
const mappedLocalHash = (contact = mapped()) => localContactHash({
uid: contact.uid,
displayName: contact.display_name,
@@ -278,6 +299,7 @@ beforeEach(() => {
runtime.inTransaction = false;
runtime.events = [];
mocks.randomUUID.mockReturnValue(UID);
+ stubWriteTarget();
});
describe('local-only contact mutations', () => {
@@ -545,6 +567,60 @@ describe('remote-first mapped lifecycle', () => {
expect(mocks.withTransaction).toHaveBeenCalledOnce();
});
+ // A subscribed (or lookup) secondary is read-only from MailFlow regardless
+ // of server write capability — the load-bearing invariant this slice's
+ // write routing must uphold, not just for creates/exports but for edits and
+ // deletes of a contact already mapped into a non-write-target book.
+ it('refuses to update a contact mapped into a book that is not the write-target', async () => {
+ transactions(preflightHandler({
+ contact: mapped({ mapping_is_write_target: false }),
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toMatchObject({
+ code: 'ERR_CARDDAV_READ_ONLY',
+ });
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+
+ it('refuses to delete a contact mapped into a book that is not the write-target', async () => {
+ transactions(preflightHandler({
+ contact: mapped({ mapping_is_write_target: false }),
+ }));
+
+ await expect(deleteContact(USER_ID, CONTACT_ID)).rejects.toMatchObject({
+ code: 'ERR_CARDDAV_READ_ONLY',
+ });
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ expect(mocks.withTransaction).toHaveBeenCalledOnce();
+ });
+
+ it('reads a mapped contact without the write-target column on a not-yet-migrated (transitional) schema', async () => {
+ const transitionalPreflight = preflightHandler();
+ let savepointRolledBack = false;
+ transactions(
+ async (sql, params) => {
+ if (sql.includes('FROM contacts c') && sql.includes('mapping_is_write_target')) {
+ throw Object.assign(new Error('column "is_write_target" does not exist'), { code: '42703' });
+ }
+ if (sql === 'ROLLBACK TO SAVEPOINT carddav_read_contact') savepointRolledBack = true;
+ return transitionalPreflight(sql, params);
+ },
+ commitHandler(),
+ );
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' });
+ protocolMock(mocks.fetchCardResource, () => ({
+ href: HREF,
+ etag: '"remote-2"',
+ vcard: mocks.putCardResource.mock.calls[0][0].vcard,
+ }));
+
+ await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow());
+
+ expect(savepointRolledBack).toBe(true);
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ });
+
it('rejects a different interactive update while an intent awaits recovery', async () => {
transactions(preflightHandler({ contact: pendingUpdate(CANONICAL_VCARD) }));
@@ -832,16 +908,20 @@ describe('remote creates and export', () => {
expect(mocks.putCardResource).not.toHaveBeenCalled();
});
- it('discovers, selects the first writable book, and uses one UID for the href and vCard', async () => {
- const denied = book({
- url: 'https://dav.example.test/books/denied/',
- capabilities: { create: 'denied', update: 'denied', delete: 'denied' },
+ it('discovers, resolves the stored write-target book by external_url, and uses one UID for the href and vCard', async () => {
+ // The write-target book (matching BOOK_URL via stubWriteTarget()) is discovered
+ // second and would lose to a "first create-capable book" rule if one still
+ // existed; it must still be the one selected, proving resolution is by the
+ // stored external_url, never discovery order.
+ const otherCreateCapable = book({
+ url: 'https://dav.example.test/books/other/',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
});
const versionFour = book({
addressData: [{ contentType: 'text/vcard', version: '4.0' }],
});
transactions(preflightHandler({ contact: null }), commitHandler());
- mocks.discoverAddressBooks.mockResolvedValue([denied, versionFour]);
+ mocks.discoverAddressBooks.mockResolvedValue([otherCreateCapable, versionFour]);
protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
protocolMock(mocks.fetchCardResource, {
href: HREF,
@@ -859,6 +939,81 @@ describe('remote creates and export', () => {
}));
});
+ it('resolves the write-target book by its discovery alias when the DB holds the reconciled canonical URL', async () => {
+ // carddavSync.js reconciles a discovery alias by rewriting external_url to
+ // the canonical URL it redirects to (advanceDiscoveredBookState), but a
+ // fresh discovery snapshot keeps returning the alias forever afterward.
+ const aliasUrl = 'https://dav.example.test/books/alias/';
+ stubWriteTarget(BOOK_URL, aliasUrl);
+ const aliasedBook = book({ url: aliasUrl });
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ mocks.discoverAddressBooks.mockResolvedValue([aliasedBook]);
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, { href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ url: aliasUrl }));
+ });
+
+ it('rejects create with a typed error and no PUT when no write-target book is configured', async () => {
+ stubWriteTarget(null);
+ transactions(preflightHandler({ contact: null }));
+ const otherCreateCapable = book({ url: 'https://dav.example.test/books/other/' });
+ mocks.discoverAddressBooks.mockResolvedValue([otherCreateCapable]);
+
+ await expect(createContact(USER_ID, draft()))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_NO_WRITE_TARGET' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the first capable book on a not-yet-migrated (transitional) schema', async () => {
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes('is_write_target')) {
+ throw Object.assign(new Error('column "is_write_target" does not exist'), { code: '42703' });
+ }
+ throw new Error('unexpected query on the transitional schema');
+ });
+ transactions(preflightHandler({ contact: null }), commitHandler());
+ const denied = book({
+ url: 'https://dav.example.test/books/denied/',
+ capabilities: { create: 'denied', update: 'denied', delete: 'denied' },
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([denied, book()]);
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, { href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD });
+
+ await createContact(USER_ID, draft());
+
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ url: BOOK_URL }));
+ });
+
+ it('rejects create with no fallback when the write-target book is absent from the fresh snapshot', async () => {
+ transactions(preflightHandler({ contact: null }));
+ const otherCreateCapable = book({ url: 'https://dav.example.test/books/other/' });
+ mocks.discoverAddressBooks.mockResolvedValue([otherCreateCapable]);
+
+ await expect(createContact(USER_ID, draft()))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_NO_WRITE_TARGET' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
+ it('rejects create with no fallback when the write-target book is now denied create', async () => {
+ transactions(preflightHandler({ contact: null }));
+ const otherCreateCapable = book({ url: 'https://dav.example.test/books/other/' });
+ const deniedWriteTarget = book({
+ capabilities: { create: 'denied', update: 'allowed', delete: 'allowed' },
+ });
+ mocks.discoverAddressBooks.mockResolvedValue([otherCreateCapable, deniedWriteTarget]);
+
+ await expect(createContact(USER_ID, draft()))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
it('checks the deterministic UID href after an ambiguous create before retrying PUT', async () => {
transactions(preflightHandler({ contact: null }), commitHandler());
mocks.discoverAddressBooks.mockResolvedValue([book()]);
@@ -980,6 +1135,33 @@ describe('remote creates and export', () => {
expect(mocks.putCardResource).toHaveBeenCalledOnce();
});
+ it('exports to the write-target book only, never a server-writable secondary in the same snapshot', async () => {
+ transactions(preflightHandler({ contact: mapped({ href: null, source: 'local' }) }), commitHandler());
+ protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' });
+ protocolMock(mocks.fetchCardResource, {
+ href: HREF,
+ etag: '"remote-1"',
+ vcard: CANONICAL_VCARD,
+ });
+ const writableSecondary = book({ url: 'https://dav.example.test/books/secondary/' });
+
+ await exportExistingContact(USER_ID, CONTACT_ID, { books: [writableSecondary, book()] });
+
+ expect(mocks.putCardResource).toHaveBeenCalledOnce();
+ expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ url: BOOK_URL }));
+ });
+
+ it('rejects export with no fallback when no write-target book is configured', async () => {
+ stubWriteTarget(null);
+ transactions(preflightHandler({ contact: mapped({ href: null, source: 'local' }) }));
+ const otherCreateCapable = book({ url: 'https://dav.example.test/books/other/' });
+
+ await expect(exportExistingContact(USER_ID, CONTACT_ID, { books: [otherCreateCapable] }))
+ .rejects.toMatchObject({ code: 'ERR_CARDDAV_NO_WRITE_TARGET' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
it('rejects an export planned by a stale connection generation before remote I/O', async () => {
const replacement = {
...integration,
@@ -1323,6 +1505,28 @@ describe('MailFlow CardDAV-server seams', () => {
expect(mocks.withTransaction).toHaveBeenCalledOnce();
});
+ it('rejects a MailFlow CardDAV-server create with no fallback when no write-target book is configured', async () => {
+ stubWriteTarget(null);
+ transactions(async sql => {
+ if (sql.includes('FROM address_books')) {
+ return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] };
+ }
+ if (sql.includes('FROM contacts c')) return { rows: [] };
+ if (sql.includes('FROM user_integrations')) return { rows: [integration] };
+ return { rows: [], rowCount: 1 };
+ });
+ const otherCreateCapable = book({ url: 'https://dav.example.test/books/other/' });
+ mocks.discoverAddressBooks.mockResolvedValue([otherCreateCapable]);
+
+ await expect(createContactFromVCard(USER_ID, {
+ localAddressBookId: LOCAL_BOOK_ID,
+ uid: UID,
+ rawVCard: BASE_VCARD,
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_NO_WRITE_TARGET' });
+
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
it('rejects a stale local ETag before an external request', async () => {
transactions(preflightHandler({ contact: mapped({ etag: 'current-local-etag' }) }));
@@ -1335,6 +1539,33 @@ describe('MailFlow CardDAV-server seams', () => {
expect(mocks.putCardResource).not.toHaveBeenCalled();
});
+ it('refuses to replace a contact mapped into a book that is not the write-target', async () => {
+ transactions(preflightHandler({
+ contact: mapped({ mapping_is_write_target: false }),
+ }));
+
+ await expect(replaceContactFromVCard(USER_ID, {
+ localAddressBookId: BOOK_ID,
+ uid: UID,
+ rawVCard: CANONICAL_VCARD,
+ expectedLocalEtag: 'local-etag-before',
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+ expect(mocks.putCardResource).not.toHaveBeenCalled();
+ });
+
+ it('refuses to delete-from-vCard a contact mapped into a book that is not the write-target', async () => {
+ transactions(preflightHandler({
+ contact: mapped({ mapping_is_write_target: false }),
+ }));
+
+ await expect(deleteContactFromVCard(USER_ID, {
+ localAddressBookId: BOOK_ID,
+ uid: UID,
+ expectedLocalEtag: 'local-etag-before',
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+ expect(mocks.deleteCardResource).not.toHaveBeenCalled();
+ });
+
it('resolves delete ownership by local book plus UID and enforces the ETag', async () => {
transactions(preflightHandler(), commitHandler({ row: null }));
protocolMock(mocks.deleteCardResource, { href: HREF, status: 204 });
diff --git a/backend/src/services/carddavLookupService.js b/backend/src/services/carddavLookupService.js
new file mode 100644
index 0000000..dc6b4c6
--- /dev/null
+++ b/backend/src/services/carddavLookupService.js
@@ -0,0 +1,156 @@
+import { query } from './db.js';
+import { parseVCard } from '../utils/vcard.js';
+import { decodeBase64Photo } from '../utils/vcardDocument.js';
+
+// Inbound-sender avatar resolution for lookup-only CardDAV books.
+//
+// A lookup-only book (is_lookup_source && !is_subscribed) retains each remote
+// object as a ledger row (mapping_status='lookup') with no materialized contact
+// and — by design — no photo_data column (see multi-book-design.md, key
+// decision 2). When an inbound sender resolves only against such a book its
+// avatar is produced lazily: parse the retained vCard and pull its PHOTO through
+// the same bounded decode path the rest of CardDAV uses. decodeBase64Photo
+// enforces the 512 KiB limit and rejects malformed data, so an oversized or
+// broken PHOTO yields no avatar rather than an unbounded buffer or a crash.
+//
+// That parse is comparatively expensive and the avatar endpoint is hit once per
+// visible message row, so decoded results are memoized in a small in-process LRU
+// keyed by (userId, lowercased email). The img URL is stable per email, so the
+// browser caches the bytes too (Cache-Control on the route); this cache only
+// spares the server the repeat DB read + vCard parse. It is intentionally
+// best-effort: a sync that rewrites a sender's photo becomes visible once the
+// entry's TTL elapses or it is evicted — acceptable for an avatar, and the
+// escape hatch (a projected photo column) is deliberately deferred.
+
+const PHOTO_CACHE_MAX_ENTRIES = 256;
+const PHOTO_CACHE_TTL_MS = 10 * 60 * 1000;
+
+// Map iteration order is insertion order, so re-inserting on read gives LRU
+// recency and deleting the first key evicts the least-recently-used entry.
+const photoCache = new Map();
+
+function cacheKey(userId, email) {
+ return `${userId} ${email}`;
+}
+
+function cacheGet(key) {
+ const entry = photoCache.get(key);
+ if (!entry) return undefined;
+ if (entry.expiresAt <= Date.now()) {
+ photoCache.delete(key);
+ return undefined;
+ }
+ photoCache.delete(key);
+ photoCache.set(key, entry);
+ return entry.value;
+}
+
+function cacheSet(key, value) {
+ photoCache.delete(key);
+ photoCache.set(key, { value, expiresAt: Date.now() + PHOTO_CACHE_TTL_MS });
+ while (photoCache.size > PHOTO_CACHE_MAX_ENTRIES) {
+ photoCache.delete(photoCache.keys().next().value);
+ }
+}
+
+// Test-only: drop all memoized avatars so LRU/TTL behavior is deterministic.
+export function _clearLookupPhotoCache() {
+ photoCache.clear();
+}
+
+// Extract raw image bytes for a lookup vCard's PHOTO, or null when it has none,
+// is unparseable, or trips the bounded photo decoder (e.g. exceeds 512 KiB).
+function decodeLookupPhoto(vcard) {
+ let photoData;
+ try {
+ photoData = parseVCard(vcard).photoData;
+ } catch {
+ return null;
+ }
+ if (typeof photoData !== 'string' || !photoData.startsWith('data:')) return null;
+ const match = /^data:([^;,]+);base64,(.*)$/is.exec(photoData);
+ if (!match) return null;
+ try {
+ return { mime: match[1], bytes: decodeBase64Photo(match[2]) };
+ } catch {
+ return null;
+ }
+}
+
+// The retained lookup rows for a user's lookup-only carddav books. $1 = userId;
+// callers append the primary_email predicate ($2) and ordering. Shared by the
+// single and batched probes so their scoping (lookup status, ownership, source,
+// lookup-source flag) can never drift apart.
+const LOOKUP_LEDGER_SOURCE = `
+ FROM carddav_remote_objects o
+ JOIN address_books ab ON ab.id = o.address_book_id
+ WHERE o.mapping_status = 'lookup'
+ AND ab.user_id = $1
+ AND ab.source = 'carddav'
+ AND ab.is_lookup_source = true`;
+
+// Resolve an inbound sender's avatar from the user's lookup-only books. Returns
+// { mime, bytes } for the retained vCard's PHOTO, or null when the sender is not
+// in any lookup book (or that vCard carries no usable photo). Callers use this
+// only after a miss in the materialized contacts table, so contacts-table photos
+// always win over the ledger fallback.
+export async function resolveLookupPhoto(userId, email) {
+ const normalized = String(email ?? '').trim().toLowerCase();
+ if (!normalized) return null;
+
+ const key = cacheKey(userId, normalized);
+ const cached = cacheGet(key);
+ if (cached !== undefined) return cached;
+
+ const { rows } = await query(
+ `SELECT o.vcard${LOOKUP_LEDGER_SOURCE}
+ AND o.primary_email = $2
+ ORDER BY o.updated_at DESC
+ LIMIT 1`,
+ [userId, normalized],
+ );
+ const result = rows.length ? decodeLookupPhoto(rows[0].vcard) : null;
+ cacheSet(key, result);
+ return result;
+}
+
+// Batched sibling of resolveLookupPhoto: resolve many senders' avatars in a
+// single DB round-trip. A message page can name hundreds of distinct lookup-only
+// senders; probing one query each (a Promise.all fan-out) floods the ~20-slot
+// connection pool. Cache hits are served from the shared LRU and only the misses
+// reach the DB, in one `primary_email = ANY(...)` probe whose DISTINCT ON keeps
+// the most-recent row per sender — the same choice resolveLookupPhoto's LIMIT 1
+// makes. Per-sender decode + memoization is identical, so both functions share a
+// cache and the photo route keeps reading whatever this primed. Returns a Map of
+// normalized email -> { mime, bytes } | null covering every requested email.
+export async function resolveLookupPhotos(userId, emails) {
+ const results = new Map();
+ const misses = [];
+ for (const email of emails) {
+ const normalized = String(email ?? '').trim().toLowerCase();
+ if (!normalized || results.has(normalized)) continue;
+ const cached = cacheGet(cacheKey(userId, normalized));
+ if (cached !== undefined) {
+ results.set(normalized, cached);
+ continue;
+ }
+ results.set(normalized, null);
+ misses.push(normalized);
+ }
+ if (!misses.length) return results;
+
+ const { rows } = await query(
+ `SELECT DISTINCT ON (o.primary_email) o.primary_email, o.vcard${LOOKUP_LEDGER_SOURCE}
+ AND o.primary_email = ANY($2::text[])
+ ORDER BY o.primary_email, o.updated_at DESC`,
+ [userId, misses],
+ );
+ const vcardByEmail = new Map(rows.map(row => [row.primary_email, row.vcard]));
+ for (const email of misses) {
+ const vcard = vcardByEmail.get(email);
+ const result = vcard ? decodeLookupPhoto(vcard) : null;
+ cacheSet(cacheKey(userId, email), result);
+ results.set(email, result);
+ }
+ return results;
+}
diff --git a/backend/src/services/carddavLookupService.test.js b/backend/src/services/carddavLookupService.test.js
new file mode 100644
index 0000000..0ff468e
--- /dev/null
+++ b/backend/src/services/carddavLookupService.test.js
@@ -0,0 +1,149 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('./db.js', () => ({ query: vi.fn() }));
+
+const { query } = await import('./db.js');
+const { resolveLookupPhoto, resolveLookupPhotos, _clearLookupPhotoCache } = await import('./carddavLookupService.js');
+
+const PHOTO_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
+const PHOTO_B64 = PHOTO_BYTES.toString('base64');
+
+function lookupVCard(photo) {
+ return [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:lookup-1',
+ 'FN:Lookup Sender',
+ 'EMAIL:sender@example.test',
+ ...(photo ? [`PHOTO;ENCODING=b;TYPE=JPEG:${photo}`] : []),
+ 'END:VCARD',
+ ].join('\r\n');
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ _clearLookupPhotoCache();
+});
+
+describe('resolveLookupPhoto', () => {
+ it('decodes the retained vCard PHOTO from a lookup-only book by email', async () => {
+ query.mockResolvedValueOnce({ rows: [{ vcard: lookupVCard(PHOTO_B64) }] });
+
+ const photo = await resolveLookupPhoto('user-1', 'Sender@Example.test');
+
+ expect(photo).toEqual({ mime: 'image/jpeg', bytes: PHOTO_BYTES });
+ const [sql, params] = query.mock.calls[0];
+ expect(sql).toContain("o.mapping_status = 'lookup'");
+ expect(sql).toContain('ab.is_lookup_source = true');
+ expect(sql).toContain("ab.source = 'carddav'");
+ // Email is normalized to lower-case before the ledger probe.
+ expect(params).toEqual(['user-1', 'sender@example.test']);
+ });
+
+ it('memoizes the decoded avatar so a repeat request skips the DB and re-parse', async () => {
+ query.mockResolvedValueOnce({ rows: [{ vcard: lookupVCard(PHOTO_B64) }] });
+
+ const first = await resolveLookupPhoto('user-1', 'sender@example.test');
+ const second = await resolveLookupPhoto('user-1', 'sender@example.test');
+
+ expect(second).toEqual(first);
+ expect(query).toHaveBeenCalledTimes(1);
+ });
+
+ it('memoizes a miss so an absent sender is not re-queried on every message row', async () => {
+ query.mockResolvedValueOnce({ rows: [] });
+
+ expect(await resolveLookupPhoto('user-1', 'nobody@example.test')).toBeNull();
+ expect(await resolveLookupPhoto('user-1', 'nobody@example.test')).toBeNull();
+ expect(query).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns null when the lookup vCard carries no PHOTO', async () => {
+ query.mockResolvedValueOnce({ rows: [{ vcard: lookupVCard(null) }] });
+
+ expect(await resolveLookupPhoto('user-1', 'sender@example.test')).toBeNull();
+ });
+
+ it('refuses to serve a PHOTO that exceeds the bounded decode limit', async () => {
+ // 600 KiB of decoded image (819,200 base64 chars) — over decodeBase64Photo's
+ // 512 KiB ceiling but under the 1 MiB vCard limit. It MUST be line-folded:
+ // as one physical line it would trip the parser's 64 KiB physical-line limit
+ // first and the test would pass even with the size bound deleted. Folded into
+ // <64 KiB physical lines, the parser unfolds it cleanly and the 512 KiB PHOTO
+ // bound is the check that rejects it (delete that bound and this goes red).
+ const oversized = 'A'.repeat(819_200);
+ const CHUNK = 8_000;
+ const photoLines = [`PHOTO;ENCODING=b;TYPE=JPEG:${oversized.slice(0, CHUNK)}`];
+ for (let index = CHUNK; index < oversized.length; index += CHUNK) {
+ photoLines.push(` ${oversized.slice(index, index + CHUNK)}`);
+ }
+ const vcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:lookup-1',
+ 'FN:Lookup Sender',
+ 'EMAIL:sender@example.test',
+ ...photoLines,
+ 'END:VCARD',
+ ].join('\r\n');
+ query.mockResolvedValueOnce({ rows: [{ vcard }] });
+
+ expect(await resolveLookupPhoto('user-1', 'sender@example.test')).toBeNull();
+ });
+
+ it('returns null for a blank email without touching the DB', async () => {
+ expect(await resolveLookupPhoto('user-1', ' ')).toBeNull();
+ expect(query).not.toHaveBeenCalled();
+ });
+});
+
+describe('resolveLookupPhotos (batched)', () => {
+ it('resolves N distinct senders in a single DB round-trip', async () => {
+ query.mockResolvedValueOnce({ rows: [
+ { primary_email: 'alice@example.test', vcard: lookupVCard(PHOTO_B64) },
+ { primary_email: 'carol@example.test', vcard: lookupVCard(PHOTO_B64) },
+ ] });
+
+ const photos = await resolveLookupPhotos('user-1', [
+ 'Alice@Example.test', 'bob@example.test', 'carol@example.test',
+ ]);
+
+ // One probe for the whole page — never one per distinct sender.
+ expect(query).toHaveBeenCalledTimes(1);
+ const [sql, params] = query.mock.calls[0];
+ expect(sql).toContain('primary_email = ANY($2::text[])');
+ expect(sql).toContain('DISTINCT ON (o.primary_email)');
+ // Deduped and lower-cased before the probe.
+ expect(params).toEqual([
+ 'user-1',
+ ['alice@example.test', 'bob@example.test', 'carol@example.test'],
+ ]);
+ expect(photos.get('alice@example.test')).toEqual({ mime: 'image/jpeg', bytes: PHOTO_BYTES });
+ expect(photos.get('carol@example.test')).toEqual({ mime: 'image/jpeg', bytes: PHOTO_BYTES });
+ // A candidate with no ledger row resolves to a memoized null, not undefined.
+ expect(photos.get('bob@example.test')).toBeNull();
+ });
+
+ it('serves cache hits without a probe and shares the cache with resolveLookupPhoto', async () => {
+ query.mockResolvedValueOnce({ rows: [
+ { primary_email: 'alice@example.test', vcard: lookupVCard(PHOTO_B64) },
+ ] });
+
+ await resolveLookupPhotos('user-1', ['alice@example.test']);
+ expect(query).toHaveBeenCalledTimes(1);
+
+ // A second batch for the same sender is fully cached — no new probe.
+ const again = await resolveLookupPhotos('user-1', ['Alice@Example.test']);
+ expect(again.get('alice@example.test')).toEqual({ mime: 'image/jpeg', bytes: PHOTO_BYTES });
+ // The single-email route reads the same primed entry the batch wrote.
+ expect(await resolveLookupPhoto('user-1', 'alice@example.test'))
+ .toEqual({ mime: 'image/jpeg', bytes: PHOTO_BYTES });
+ expect(query).toHaveBeenCalledTimes(1);
+ });
+
+ it('issues no query for an empty or blank-only candidate set', async () => {
+ expect(await resolveLookupPhotos('user-1', [])).toEqual(new Map());
+ expect(await resolveLookupPhotos('user-1', ['', ' '])).toEqual(new Map());
+ expect(query).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/src/services/carddavMappingState.js b/backend/src/services/carddavMappingState.js
index f50e07c..53e4f1e 100644
--- a/backend/src/services/carddavMappingState.js
+++ b/backend/src/services/carddavMappingState.js
@@ -303,6 +303,91 @@ export async function applyRemoteTombstone(client, change) {
return applyRemoteTombstoneState(client, change, true);
}
+// Multi-book lookup projection: retain a remote object for inbound-sender
+// resolution without materializing a local contact. A lookup row keeps the
+// parsed vCard, extracted primary_email, and a projected lookup_display_name
+// but has local_contact_id = NULL and mapping_status = 'lookup', so it can
+// never enter the contacts list, be edited/exported, conflict, or collide with
+// the per-contact active-mapping index (that index excludes NULL contacts).
+// Any pending push intent is cleared: a lookup book is read-only from MailFlow.
+
+// Bind budget: each row contributes LOOKUP_UPSERT_COLUMNS_PER_ROW parameters,
+// and PostgreSQL rejects a single statement carrying more than 65,535 binds. A
+// lookup delta can carry up to DAV_MAX_SYNC_MEMBERS (50,000) members (see
+// carddavClient.js), which at 8 binds/row would need 400,000 binds, so the
+// upsert must be chunked. 4,000 rows is 32,000 binds — comfortably under the
+// limit even if a column is ever added — and keeps each statement small.
+const LOOKUP_UPSERT_COLUMNS_PER_ROW = 8;
+const LOOKUP_UPSERT_CHUNK_ROWS = 4000;
+
+async function upsertLookupObjectChunk(client, chunk) {
+ const tuples = chunk.map((_, index) => {
+ const base = index * LOOKUP_UPSERT_COLUMNS_PER_ROW;
+ return `($${base + 1},$${base + 2},$${base + 3},$${base + 4},$${base + 5},`
+ + `NULL,'lookup',$${base + 6},$${base + 7},NULL,$${base + 8},NOW())`;
+ });
+ const params = chunk.flatMap(change => [
+ change.addressBookId,
+ change.href,
+ change.remoteEtag ?? null,
+ change.vcard,
+ change.primaryEmail ?? null,
+ change.vcardVersion ?? null,
+ change.remoteSemanticHash ?? null,
+ change.lookupDisplayName ?? null,
+ ]);
+ const result = await client.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, vcard_version, remote_semantic_hash,
+ local_contact_hash, lookup_display_name, last_synced_at
+ ) VALUES ${tuples.join(',')}
+ ON CONFLICT (address_book_id, href) DO UPDATE SET
+ remote_etag = EXCLUDED.remote_etag,
+ vcard = EXCLUDED.vcard,
+ primary_email = EXCLUDED.primary_email,
+ local_contact_id = NULL,
+ mapping_status = 'lookup',
+ vcard_version = EXCLUDED.vcard_version,
+ remote_semantic_hash = EXCLUDED.remote_semantic_hash,
+ local_contact_hash = NULL,
+ lookup_display_name = EXCLUDED.lookup_display_name,
+ pending_operation = NULL, pending_vcard = NULL, pending_local_hash = NULL,
+ pending_remote_semantic_hash = NULL, pending_started_at = NULL,
+ mapping_revision = carddav_remote_objects.mapping_revision + 1,
+ last_synced_at = NOW(), last_push_error_code = NULL, last_push_error_at = NULL,
+ updated_at = NOW()`,
+ params,
+ );
+ return result.rowCount;
+}
+
+// Every changed row in a delta is written in multi-row upserts rather than one
+// awaited round trip apiece, chunked to stay under PostgreSQL's per-statement
+// bind limit (see LOOKUP_UPSERT_CHUNK_ROWS). The caller runs this inside its
+// sync transaction, so every chunk shares that transaction and the ledger
+// update stays atomic. Returns the total number of rows written.
+export async function upsertLookupObjects(client, changes) {
+ if (!changes.length) return 0;
+ let written = 0;
+ for (let start = 0; start < changes.length; start += LOOKUP_UPSERT_CHUNK_ROWS) {
+ written += await upsertLookupObjectChunk(
+ client,
+ changes.slice(start, start + LOOKUP_UPSERT_CHUNK_ROWS),
+ );
+ }
+ return written;
+}
+
+export async function removeLookupObjects(client, { addressBookId, hrefs }) {
+ if (!hrefs.length) return { rowCount: 0 };
+ return client.query(
+ `DELETE FROM carddav_remote_objects
+ WHERE address_book_id = $1 AND href = ANY($2::text[])`,
+ [addressBookId, hrefs],
+ );
+}
+
export async function resolveCarddavConflict(client, change) {
requireExpectedRevision(change);
const mapping = change.remoteTombstone
@@ -411,24 +496,192 @@ export async function refreshUnresolvedConflict(client, change) {
return { ok: true, mappingRevision: String(revision), conflict };
}
+function isUndefinedColumn(error) {
+ return error?.code === '42703';
+}
+
+function isNameCollisionViolation(error) {
+ return error.code === '23505' && error.constraint === 'address_books_user_id_name_key';
+}
+
+// Does this schema have the multi-book role columns yet (added by the
+// multi-book migration)? Probed with a SAVEPOINT because this runs inside the
+// caller's sync transaction: an undefined_column error would otherwise abort
+// that whole transaction, not just this one query, on a not-yet-migrated
+// (transitional, mid-deploy) database.
+async function supportsMultiBookRoles(client, userId) {
+ await client.query('SAVEPOINT carddav_multi_book_probe');
+ try {
+ await client.query(
+ `SELECT 1 FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true
+ LIMIT 1`,
+ [userId],
+ );
+ await client.query('RELEASE SAVEPOINT carddav_multi_book_probe');
+ return true;
+ } catch (error) {
+ if (!isUndefinedColumn(error)) throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_multi_book_probe');
+ await client.query('RELEASE SAVEPOINT carddav_multi_book_probe');
+ return false;
+ }
+}
+
+// Atomically claims the write-target (+ subscribed) flags for whichever
+// create-capable carddav book of this user currently ranks best — 'allowed'
+// capability before 'unknown', then earliest created_at/id — mirroring the
+// ranking the 0033 migration backfill applied once for already-connected
+// users.
+//
+// An ignored book (neither subscribed nor a lookup source) is not a candidate at
+// any rank. The claim sets is_subscribed alongside is_write_target, so promoting
+// one would silently re-enable a book the user explicitly turned off and
+// materialize it into the contacts list on the next pull — the very thing the
+// per-book roles exist to prevent. When every create-capable book is ignored the
+// claim takes none and the user is left with no write-target: creates then fail
+// with the typed ERR_CARDDAV_NO_WRITE_TARGET the routes already surface (and the
+// export sweep already skips on), which is the honest outcome once the user has
+// excluded every book that could receive a contact — re-including one through the
+// role controls makes it a candidate again. Deciding *for* them by resurrecting an
+// excluded book would be the one outcome they cannot undo by hand.
+//
+// The `NOT EXISTS` guard makes this a no-op whenever the user already
+// has a write-target, so it is safe to call unconditionally, from two seams:
+// - persistDiscoveredBook below, immediately after inserting a brand-new
+// book — correct for every single-book caller (interactive create/export,
+// which only ever discovers or persists one book per call);
+// - carddavSync.js's syncUser, once after every book in a discovery
+// snapshot has been persisted (see the `deferWriteTargetClaim` book flag
+// below), so a multi-book connect ranks the *whole* snapshot in one shot
+// instead of claiming per book in discovery order — a later 'allowed'
+// book must be able to win over an earlier 'unknown' one that would
+// otherwise keep the flag forever purely by having been discovered first
+// (the bug this replaces) — and once more after a sync's stale-book
+// cleanup (see reconcileStaleCarddavBooks) — a connection replacement
+// (new server or user) can delete the old book that held the flag, which
+// would otherwise leave the replacement connection with none.
+export async function claimBestWriteTargetCandidate(client, userId) {
+ await client.query('SAVEPOINT carddav_write_target_claim');
+ try {
+ await client.query(
+ `UPDATE address_books SET
+ is_write_target = true, is_subscribed = true, updated_at = NOW()
+ WHERE id = (
+ SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND remote_create_capability <> 'denied'
+ AND (is_subscribed OR is_lookup_source)
+ ORDER BY (remote_create_capability = 'allowed') DESC, created_at, id
+ LIMIT 1
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true
+ )`,
+ [userId],
+ );
+ await client.query('RELEASE SAVEPOINT carddav_write_target_claim');
+ } catch (error) {
+ if (!isUndefinedColumn(error) && error.code !== '23505') throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_write_target_claim');
+ await client.query('RELEASE SAVEPOINT carddav_write_target_claim');
+ }
+}
+
+// Resolve a fresh discovery snapshot's book to an already-persisted row, by
+// its canonical external_url or its recorded discovery alias. Some CardDAV
+// servers keep advertising a stable "alias" href in PROPFIND discovery for a
+// collection whose canonical external_url advanceDiscoveredBookState already
+// rewrote after a REPORT/PUT/DELETE redirect reconciliation; selectedCreateBook
+// (carddavContactService.js) deliberately matches such a book by either URL so
+// write routing keeps working, but an INSERT can only ON CONFLICT on one
+// column. Without this lookup, persisting a book resolved via its alias would
+// insert a second, non-write-target row for the same remote collection
+// instead of updating the existing one — silently orphaning every future
+// mutation mapped through it as read-only.
+async function findDiscoveredBookByUrl(client, userId, url) {
+ const { rows: [existing] } = await client.query(
+ `SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ AND (external_url = $2 OR discovery_alias_url = $2)
+ FOR UPDATE`,
+ [userId, url],
+ );
+ return existing || null;
+}
+
export async function persistDiscoveredBook(client, book) {
const capabilities = normalizeCarddavCapabilities(book.capabilities);
const displayName = book.displayName || 'CardDAV';
+ const multiBook = await supportsMultiBookRoles(client, book.userId);
+
+ // Already-persisted book (matched by canonical URL or discovery alias) —
+ // update its capabilities in place. Role flags (is_write_target,
+ // is_subscribed) are never touched here: those are only ever decided once,
+ // at first creation below, or by deliberate role-management elsewhere.
+ const existingBook = multiBook
+ ? await findDiscoveredBookByUrl(client, book.userId, book.url)
+ : null;
+ if (existingBook) {
+ const { rows: [stored] } = await client.query(
+ book.preserveCapabilities === true
+ ? `UPDATE address_books SET updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, external_url, remote_sync_token,
+ remote_sync_revision::text, sync_token,
+ remote_projection_fingerprint`
+ : `UPDATE address_books SET
+ remote_create_capability = $2,
+ remote_update_capability = $3,
+ remote_delete_capability = $4,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, external_url, remote_sync_token,
+ remote_sync_revision::text, sync_token,
+ remote_projection_fingerprint`,
+ book.preserveCapabilities === true
+ ? [existingBook.id]
+ : [existingBook.id, capabilities.create, capabilities.update, capabilities.delete],
+ );
+ return stored;
+ }
+
const capabilityUpdate = book.preserveCapabilities === true
? ''
: `,
remote_create_capability = EXCLUDED.remote_create_capability,
remote_update_capability = EXCLUDED.remote_update_capability,
remote_delete_capability = EXCLUDED.remote_delete_capability`;
- for (let attempt = 0; attempt < 20; attempt++) {
- const name = attempt === 0 ? displayName : `${displayName} (${attempt + 1})`;
+ // A brand-new carddav book is normally inserted lookup-only (is_write_target,
+ // is_subscribed both false, matching the column defaults exactly): a book
+ // never claims the write-target directly, in per-book discovery order, here —
+ // claimBestWriteTargetCandidate re-ranks the *whole* snapshot of the user's
+ // carddav books by capability priority once the row exists, and assigns the
+ // single is_write_target flag. The one exception is subscribeOnCreate: the
+ // sync loop resolves the book that will become the write-target from the
+ // discovery snapshot (see carddavSync.js) and asks for it to be inserted
+ // *subscribed* so it materializes in that very first sync, and durably —
+ // even if a later sibling book in the same batch fails before the batch-wide
+ // claim runs, or a stale write-target from a replaced connection still holds
+ // the flag this pass. is_write_target stays false here (only the claim sets
+ // it, avoiding the one-write-target unique index while a stale one lingers);
+ // subscribing without the write-target flag is a valid subscribed-secondary
+ // state and satisfies the address_books_write_target_subscribed check.
+ const subscribeOnCreate = multiBook && book.subscribeOnCreate === true;
+ const roleColumns = multiBook ? ', is_write_target, is_subscribed' : '';
+ const roleValues = multiBook ? `,false,${subscribeOnCreate}` : '';
+ let stored;
+ let nameAttempt = 0;
+ let unexpectedRetries = 0;
+ for (;;) {
+ const name = nameAttempt === 0 ? displayName : `${displayName} (${nameAttempt + 1})`;
await client.query('SAVEPOINT carddav_book_name');
try {
- const { rows: [stored] } = await client.query(
+ ({ rows: [stored] } = await client.query(
`INSERT INTO address_books (
user_id, name, source, external_url,
- remote_create_capability, remote_update_capability, remote_delete_capability
- ) VALUES ($1,$2,'carddav',$3,$4,$5,$6)
+ remote_create_capability, remote_update_capability, remote_delete_capability${roleColumns}
+ ) VALUES ($1,$2,'carddav',$3,$4,$5,$6${roleValues})
ON CONFLICT (user_id, external_url)
WHERE source = 'carddav' AND external_url IS NOT NULL
DO UPDATE SET
@@ -445,16 +698,38 @@ export async function persistDiscoveredBook(client, book) {
capabilities.update,
capabilities.delete,
],
- );
+ ));
await client.query('RELEASE SAVEPOINT carddav_book_name');
- return stored;
+ break;
} catch (error) {
if (error.code !== '23505') throw error;
await client.query('ROLLBACK TO SAVEPOINT carddav_book_name');
await client.query('RELEASE SAVEPOINT carddav_book_name');
+ // Only a display-name collision (this user already has a same-named
+ // book) should provoke a rename. Anything else — e.g. a concurrent
+ // insert for this same external_url racing this one — retries the
+ // *identical* name a bounded number of times instead of spuriously
+ // renaming this book for an unrelated conflict.
+ if (isNameCollisionViolation(error)) {
+ nameAttempt += 1;
+ if (nameAttempt >= 20) {
+ throw new Error(`Could not create a local address book for "${displayName}"`, { cause: error });
+ }
+ } else {
+ unexpectedRetries += 1;
+ if (unexpectedRetries >= 5) throw error;
+ }
}
}
- throw new Error(`Could not create a local address book for "${displayName}"`);
+ // The sync loop (createCarddavBook in carddavSync.js) persists every book
+ // of a discovery snapshot individually but defers claiming until the whole
+ // snapshot exists, so it can rank all of them at once (see
+ // claimBestWriteTargetCandidate above); every other caller persists a
+ // single book and wants the claim attempted right away.
+ if (multiBook && book.deferWriteTargetClaim !== true) {
+ await claimBestWriteTargetCandidate(client, book.userId);
+ }
+ return stored;
}
const CAPABILITY_COLUMNS = {
@@ -480,11 +755,35 @@ export async function persistDeniedBookCapability(client, {
);
}
-export async function advanceDiscoveredBookState(client, state) {
- const capabilities = normalizeCarddavCapabilities(state.capabilities);
- return client.query(`
+const ADVANCE_BOOK_STATE_SQL = `
+ UPDATE address_books SET
+ external_url = COALESCE($6, external_url),
+ remote_sync_token = $2,
+ remote_sync_capability = $3,
+ remote_sync_revision = remote_sync_revision + 1,
+ remote_projection_fingerprint = $4,
+ remote_create_capability = $7,
+ remote_update_capability = $8,
+ remote_delete_capability = $9,
+ updated_at = NOW()
+ WHERE id = $1 AND remote_sync_revision = $5::bigint
+ `;
+
+// Same statement, but also records the discovery URL a book's canonical
+// external_url gets rewritten from. A CardDAV discovery PROPFIND can keep
+// advertising a stable "alias" href for a collection that 3xx-redirects
+// (REPORT/PUT/DELETE) to a different canonical URL; when that happens
+// canonicalUrl differs from the row's current external_url (the alias),
+// and the alias is worth keeping so write-target resolution can still match
+// a book by whichever URL a fresh discovery snapshot returns (see
+// writeTargetBookUrls / selectedCreateBook in carddavContactService.js).
+const ADVANCE_BOOK_STATE_WITH_ALIAS_SQL = `
UPDATE address_books SET
external_url = COALESCE($6, external_url),
+ discovery_alias_url = CASE
+ WHEN $6 IS NOT NULL AND $6 <> external_url THEN external_url
+ ELSE discovery_alias_url
+ END,
remote_sync_token = $2,
remote_sync_capability = $3,
remote_sync_revision = remote_sync_revision + 1,
@@ -494,7 +793,11 @@ export async function advanceDiscoveredBookState(client, state) {
remote_delete_capability = $9,
updated_at = NOW()
WHERE id = $1 AND remote_sync_revision = $5::bigint
- `, [
+ `;
+
+export async function advanceDiscoveredBookState(client, state) {
+ const capabilities = normalizeCarddavCapabilities(state.capabilities);
+ const params = [
state.addressBookId,
state.remoteSyncToken,
state.remoteSyncCapability,
@@ -504,5 +807,21 @@ export async function advanceDiscoveredBookState(client, state) {
capabilities.create,
capabilities.update,
capabilities.delete,
- ]);
+ ];
+ // Guarded with a SAVEPOINT + undefined_column fallback (rather than an
+ // information_schema pre-check) because this runs inside the caller's sync
+ // transaction, once per book on every sync — an undefined_column error on
+ // a not-yet-migrated (transitional) schema would otherwise abort that whole
+ // transaction, not just this query.
+ await client.query('SAVEPOINT carddav_book_alias_column');
+ try {
+ const result = await client.query(ADVANCE_BOOK_STATE_WITH_ALIAS_SQL, params);
+ await client.query('RELEASE SAVEPOINT carddav_book_alias_column');
+ return result;
+ } catch (error) {
+ if (!isUndefinedColumn(error)) throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_book_alias_column');
+ await client.query('RELEASE SAVEPOINT carddav_book_alias_column');
+ return client.query(ADVANCE_BOOK_STATE_SQL, params);
+ }
}
diff --git a/backend/src/services/carddavMappingState.test.js b/backend/src/services/carddavMappingState.test.js
index 7c67246..7f3b612 100644
--- a/backend/src/services/carddavMappingState.test.js
+++ b/backend/src/services/carddavMappingState.test.js
@@ -4,6 +4,7 @@ import {
advanceDiscoveredBookState,
applyConfirmedRemoteContact,
applyRemoteTombstone,
+ claimBestWriteTargetCandidate,
lockCarddavMapping,
persistDiscoveredBook,
persistPendingMutationIntent,
@@ -468,23 +469,310 @@ describe('refreshUnresolvedConflict', () => {
});
describe('persistDiscoveredBook', () => {
- it('persists capabilities without overwriting sync state', async () => {
+ it('persists capabilities on an already-discovered book without touching its role flags or sync state', async () => {
+ // Matched by URL (findDiscoveredBookByUrl) — never a fresh INSERT, and
+ // never touches is_write_target/is_subscribed/external_url: those are
+ // only ever decided once, at first creation.
const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
- const client = { query: vi.fn(async () => ({ rows: [stored], rowCount: 1 })) };
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [{ id: BOOK_ID }] };
+ return { rows: [stored], rowCount: 1 };
+ }),
+ };
+
await expect(persistDiscoveredBook(client, {
userId: USER_ID,
url: stored.external_url,
displayName: 'Remote',
capabilities: { create: 'allowed', update: 'denied', delete: 'unknown' },
})).resolves.toEqual(stored);
+
+ expect(client.query).not.toHaveBeenCalledWith(
+ expect.stringMatching(/INSERT INTO address_books/),
+ expect.anything(),
+ );
+ const updateCall = client.query.mock.calls.find(([sql]) => /^UPDATE address_books SET/.test(sql));
+ expect(updateCall[0]).toMatch(
+ /remote_create_capability = \$2[\s\S]+remote_update_capability = \$3[\s\S]+remote_delete_capability = \$4[\s\S]+WHERE id = \$1/,
+ );
+ expect(updateCall[0]).not.toMatch(/is_write_target|is_subscribed|external_url\s*=/);
+ expect(updateCall[1]).toEqual([BOOK_ID, 'allowed', 'denied', 'unknown']);
+ });
+
+ it('resolves an existing book by its discovery alias and updates it instead of inserting a duplicate', async () => {
+ // Some CardDAV servers keep advertising a stable "alias" href for a
+ // collection whose canonical external_url advanceDiscoveredBookState
+ // already rewrote (see carddavContactService.js's selectedCreateBook).
+ // Matching only on external_url here would insert a second, non-write-
+ // target row for the same remote collection.
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/canonical/' };
+ const aliasUrl = 'https://dav.example.test/addressbooks/alias/';
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [{ id: BOOK_ID }] };
+ return { rows: [stored], rowCount: 1 };
+ }),
+ };
+
+ await expect(persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: aliasUrl,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ })).resolves.toEqual(stored);
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/SELECT id FROM address_books[\s\S]+external_url = \$2 OR discovery_alias_url = \$2/),
+ [USER_ID, aliasUrl],
+ );
+ expect(client.query).not.toHaveBeenCalledWith(
+ expect.stringMatching(/INSERT INTO address_books/),
+ expect.anything(),
+ );
+ const updateCall = client.query.mock.calls.find(([sql]) => /^UPDATE address_books SET/.test(sql));
+ expect(updateCall[1]).toEqual([BOOK_ID, 'allowed', 'allowed', 'allowed']);
+ });
+
+ it('claims the write-target for the first create-capable book a user discovers', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [] };
+ if (/INSERT INTO address_books/.test(sql)) return { rows: [stored], rowCount: 1 };
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ });
+
+ // Every brand-new book is inserted lookup-only (never claims directly,
+ // in per-book discovery order) ...
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /INSERT INTO address_books[\s\S]+is_write_target, is_subscribed[\s\S]+VALUES \(\$1,\$2,'carddav',\$3,\$4,\$5,\$6,false,false\)/,
+ ),
+ [USER_ID, 'Remote', stored.external_url, 'allowed', 'allowed', 'allowed'],
+ );
+ // ... and claimBestWriteTargetCandidate promotes it afterward, since it's
+ // the only (and therefore best) candidate.
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/is_write_target = true, is_subscribed = true[\s\S]+NOT EXISTS/),
+ [USER_ID],
+ );
+ });
+
+ it('never lets a book claim its own write-target directly, in discovery order', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [] };
+ if (/INSERT INTO address_books/.test(sql)) return { rows: [stored], rowCount: 1 };
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'denied', update: 'denied', delete: 'denied' },
+ });
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /INSERT INTO address_books[\s\S]+VALUES \(\$1,\$2,'carddav',\$3,\$4,\$5,\$6,false,false\)/,
+ ),
+ [USER_ID, 'Remote', stored.external_url, 'denied', 'denied', 'denied'],
+ );
+ // The claim attempt still runs (it ranks across this user's *whole*
+ // snapshot of carddav books, not just this one), but its own ranking
+ // excludes denied books, so a lone denied book is never promoted.
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/remote_create_capability <> 'denied'/),
+ [USER_ID],
+ );
+ });
+
+ it('defers the write-target claim when the caller is batching a discovery snapshot', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [] };
+ if (/INSERT INTO address_books/.test(sql)) return { rows: [stored], rowCount: 1 };
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ deferWriteTargetClaim: true,
+ });
+
+ expect(client.query).not.toHaveBeenCalledWith(
+ expect.stringMatching(/is_write_target = true, is_subscribed = true/),
+ expect.anything(),
+ );
+ });
+
+ it('retries an unrelated 23505 with the identical name instead of spuriously renaming the book', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ let insertAttempts = 0;
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [] };
+ if (/INSERT INTO address_books/.test(sql)) {
+ insertAttempts += 1;
+ if (insertAttempts === 1) {
+ // Not a (user_id, name) collision — e.g. a concurrent insert for
+ // this same external_url racing this one.
+ throw Object.assign(new Error('duplicate key'), {
+ code: '23505',
+ constraint: 'some_unrelated_constraint',
+ });
+ }
+ return { rows: [stored], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await expect(persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ })).resolves.toEqual(stored);
+
+ const insertCalls = client.query.mock.calls.filter(([sql]) => /INSERT INTO address_books/.test(sql));
+ expect(insertCalls).toHaveLength(2);
+ expect(insertCalls[0][1][1]).toBe('Remote');
+ expect(insertCalls[1][1][1]).toBe('Remote');
+ });
+
+ it('renames only on a genuine display-name collision, retrying with a numbered suffix', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ let insertAttempts = 0;
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) return { rows: [] };
+ if (/SELECT id FROM address_books/.test(sql)) return { rows: [] };
+ if (/INSERT INTO address_books/.test(sql)) {
+ insertAttempts += 1;
+ if (insertAttempts === 1) {
+ throw Object.assign(new Error('duplicate key'), {
+ code: '23505',
+ constraint: 'address_books_user_id_name_key',
+ });
+ }
+ return { rows: [stored], rowCount: 1 };
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await expect(persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ })).resolves.toEqual(stored);
+
+ const insertCalls = client.query.mock.calls.filter(([sql]) => /INSERT INTO address_books/.test(sql));
+ expect(insertCalls).toHaveLength(2);
+ expect(insertCalls[0][1][1]).toBe('Remote');
+ expect(insertCalls[1][1][1]).toBe('Remote (2)');
+ });
+
+ it('inserts without multi-book role columns on a not-yet-migrated (transitional) schema', async () => {
+ const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' };
+ const client = {
+ query: vi.fn(async sql => {
+ if (/SELECT 1 FROM address_books/.test(sql)) {
+ throw Object.assign(new Error('column "is_write_target" does not exist'), { code: '42703' });
+ }
+ return { rows: [stored], rowCount: 1 };
+ }),
+ };
+
+ await expect(persistDiscoveredBook(client, {
+ userId: USER_ID,
+ url: stored.external_url,
+ displayName: 'Remote',
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ })).resolves.toEqual(stored);
+
+ const insertCall = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO address_books'));
+ expect(insertCall[0]).not.toMatch(/is_write_target/);
+ expect(insertCall[1]).toEqual([USER_ID, 'Remote', stored.external_url, 'allowed', 'allowed', 'allowed']);
+ expect(client.query.mock.calls.some(([sql]) => sql === 'ROLLBACK TO SAVEPOINT carddav_multi_book_probe')).toBe(true);
+ // No alias lookup on a not-yet-migrated schema (no discovery_alias_url column).
+ expect(client.query).not.toHaveBeenCalledWith(
+ expect.stringMatching(/SELECT id FROM address_books/),
+ expect.anything(),
+ );
+ });
+
+ it('ranks candidates by capability priority before created_at/id and no-ops once a write-target exists', async () => {
+ const client = { query: vi.fn(async () => ({ rows: [], rowCount: 1 })) };
+
+ await claimBestWriteTargetCandidate(client, USER_ID);
+
expect(client.query).toHaveBeenCalledWith(
expect.stringMatching(
- /INSERT INTO address_books[\s\S]+remote_create_capability[\s\S]+ON CONFLICT[\s\S]+remote_create_capability = EXCLUDED.remote_create_capability/,
+ /UPDATE address_books SET[\s\S]+is_write_target = true, is_subscribed = true[\s\S]+remote_create_capability <> 'denied'[\s\S]+ORDER BY \(remote_create_capability = 'allowed'\) DESC, created_at, id[\s\S]+NOT EXISTS[\s\S]+is_write_target = true/,
),
- [USER_ID, 'Remote', stored.external_url, 'allowed', 'denied', 'unknown'],
+ [USER_ID],
);
});
+ it('swallows a lost write-target race instead of throwing', async () => {
+ const client = {
+ query: vi.fn(async sql => {
+ if (/^UPDATE address_books SET/.test(sql)) {
+ throw Object.assign(new Error('duplicate key'), {
+ code: '23505',
+ constraint: 'carddav_one_write_target_idx',
+ });
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await expect(claimBestWriteTargetCandidate(client, USER_ID)).resolves.toBeUndefined();
+
+ expect(client.query).toHaveBeenCalledWith('ROLLBACK TO SAVEPOINT carddav_write_target_claim');
+ });
+
+ it('no-ops on a not-yet-migrated (transitional) schema', async () => {
+ const client = {
+ query: vi.fn(async sql => {
+ if (/^UPDATE address_books SET/.test(sql)) {
+ throw Object.assign(new Error('column "is_write_target" does not exist'), { code: '42703' });
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await expect(claimBestWriteTargetCandidate(client, USER_ID)).resolves.toBeUndefined();
+
+ expect(client.query).toHaveBeenCalledWith('ROLLBACK TO SAVEPOINT carddav_write_target_claim');
+ });
+
it('owns a single denied capability update without changing its siblings', async () => {
const client = { query: vi.fn(async () => ({ rows: [{ id: BOOK_ID }], rowCount: 1 })) };
@@ -533,6 +821,149 @@ describe('persistDiscoveredBook', () => {
],
);
});
+
+ it('records the discovery alias a canonical URL rewrite replaces', async () => {
+ const client = { query: vi.fn(async () => ({ rows: [], rowCount: 1 })) };
+
+ await advanceDiscoveredBookState(client, {
+ addressBookId: BOOK_ID,
+ expectedRemoteRevision: '4',
+ canonicalUrl: 'https://dav.example.test/addressbooks/canonical/',
+ remoteSyncToken: 'opaque-token-2',
+ remoteSyncCapability: 'supported',
+ remoteProjectionFingerprint: 'projection-fingerprint',
+ capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' },
+ });
+
+ expect(client.query).toHaveBeenCalledWith(
+ expect.stringMatching(/discovery_alias_url = CASE[\s\S]+WHEN \$6 IS NOT NULL AND \$6 <> external_url THEN external_url/),
+ [
+ BOOK_ID,
+ 'opaque-token-2',
+ 'supported',
+ 'projection-fingerprint',
+ '4',
+ 'https://dav.example.test/addressbooks/canonical/',
+ 'allowed',
+ 'unknown',
+ 'denied',
+ ],
+ );
+ });
+
+ it('advances book state without the alias column on a not-yet-migrated (transitional) schema', async () => {
+ let attempt = 0;
+ const client = {
+ query: vi.fn(async sql => {
+ if (sql.includes('UPDATE address_books') && sql.includes('discovery_alias_url')) {
+ attempt++;
+ throw Object.assign(new Error('column "discovery_alias_url" does not exist'), { code: '42703' });
+ }
+ return { rows: [], rowCount: 1 };
+ }),
+ };
+
+ await advanceDiscoveredBookState(client, {
+ addressBookId: BOOK_ID,
+ expectedRemoteRevision: '4',
+ canonicalUrl: 'https://dav.example.test/addressbooks/default/',
+ remoteSyncToken: 'opaque-token-2',
+ remoteSyncCapability: 'supported',
+ remoteProjectionFingerprint: 'projection-fingerprint',
+ capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' },
+ });
+
+ expect(attempt).toBe(1);
+ const fallbackCall = client.query.mock.calls.find(([sql]) => (
+ sql.includes('UPDATE address_books') && !sql.includes('discovery_alias_url')
+ ));
+ expect(fallbackCall).toBeDefined();
+ expect(client.query.mock.calls.some(([sql]) => sql === 'ROLLBACK TO SAVEPOINT carddav_book_alias_column')).toBe(true);
+ });
+});
+
+describe('upsertLookupObjects chunking', () => {
+ // Must mirror the constants in carddavMappingState.js: 8 binds per row, and a
+ // chunk small enough that even the largest permitted lookup delta stays under
+ // PostgreSQL's 65,535-bind ceiling.
+ const COLUMNS_PER_ROW = 8;
+ const CHUNK_ROWS = 4000;
+ const PG_BIND_LIMIT = 65535;
+
+ function lookupChange(index) {
+ return {
+ addressBookId: BOOK_ID,
+ href: `https://dav.example.test/addressbooks/lookup/${index}.vcf`,
+ remoteEtag: `"etag-${index}"`,
+ vcard: `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:lookup-${index}\r\nEND:VCARD\r\n`,
+ primaryEmail: `lookup-${index}@example.test`,
+ vcardVersion: '3.0',
+ remoteSemanticHash: `hash-${index}`,
+ lookupDisplayName: `Lookup ${index}`,
+ };
+ }
+
+ function recordingClient() {
+ const inserts = [];
+ const control = [];
+ const client = {
+ query: vi.fn(async (sql, params) => {
+ if (/INSERT INTO carddav_remote_objects/.test(sql)) {
+ inserts.push({ sql, params });
+ return { rowCount: params.length / COLUMNS_PER_ROW };
+ }
+ control.push(sql);
+ return { rows: [], rowCount: 0 };
+ }),
+ };
+ return { client, inserts, control };
+ }
+
+ // Each VALUES tuple ends with ",NOW())"; the ON CONFLICT clause never does, so
+ // this counts exactly the rows a single statement carries — independent of the
+ // parameter array.
+ function rowsInStatement(insert) {
+ return (insert.sql.match(/,NOW\(\)\)/g) || []).length;
+ }
+
+ it('writes nothing and issues no statement for an empty delta', async () => {
+ const { client, inserts } = recordingClient();
+ expect(await mappingState.upsertLookupObjects(client, [])).toBe(0);
+ expect(inserts).toHaveLength(0);
+ });
+
+ it('writes a single-row delta in one statement inside the caller transaction', async () => {
+ const { client, inserts, control } = recordingClient();
+ const written = await mappingState.upsertLookupObjects(client, [lookupChange(0)]);
+ expect(written).toBe(1);
+ expect(inserts).toHaveLength(1);
+ expect(rowsInStatement(inserts[0])).toBe(1);
+ expect(inserts[0].params).toHaveLength(COLUMNS_PER_ROW);
+ // No BEGIN/COMMIT/SAVEPOINT — the chunks share the caller's transaction.
+ expect(control).toEqual([]);
+ });
+
+ it('writes an exactly-full chunk in one statement', async () => {
+ const changes = Array.from({ length: CHUNK_ROWS }, (_, index) => lookupChange(index));
+ const { client, inserts } = recordingClient();
+ const written = await mappingState.upsertLookupObjects(client, changes);
+ expect(written).toBe(CHUNK_ROWS);
+ expect(inserts).toHaveLength(1);
+ expect(rowsInStatement(inserts[0])).toBe(CHUNK_ROWS);
+ expect(inserts[0].params).toHaveLength(CHUNK_ROWS * COLUMNS_PER_ROW);
+ expect(inserts[0].params.length).toBeLessThanOrEqual(PG_BIND_LIMIT);
+ });
+
+ it('splits one row past a full chunk into two statements, each within the bind budget', async () => {
+ const changes = Array.from({ length: CHUNK_ROWS + 1 }, (_, index) => lookupChange(index));
+ const { client, inserts } = recordingClient();
+ const written = await mappingState.upsertLookupObjects(client, changes);
+ expect(written).toBe(CHUNK_ROWS + 1);
+ expect(inserts.map(rowsInStatement)).toEqual([CHUNK_ROWS, 1]);
+ expect(inserts.every(insert => insert.params.length <= PG_BIND_LIMIT)).toBe(true);
+ // Every chunk rebuilds fresh $1-based placeholders.
+ expect(inserts[1].sql).toMatch(/VALUES \(\$1,/);
+ });
});
describe('assertConflictSnapshotsWithinLimit', () => {
diff --git a/backend/src/services/carddavMigration.db.test.js b/backend/src/services/carddavMigration.db.test.js
index a25b6bb..df58a78 100644
--- a/backend/src/services/carddavMigration.db.test.js
+++ b/backend/src/services/carddavMigration.db.test.js
@@ -27,6 +27,8 @@ const databaseNames = {
populatedCollision: `carddav_populated_collision_${databaseSuffix}`,
populatedFirst: `carddav_populated_first_${databaseSuffix}`,
emptyDuplicates: `carddav_empty_duplicates_${databaseSuffix}`,
+ multiBookBackfill: `carddav_multi_book_backfill_${databaseSuffix}`,
+ publishIntentBackfill: `carddav_publish_intent_backfill_${databaseSuffix}`,
};
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -565,6 +567,45 @@ async function expectConflictRetentionSchema(client) {
]);
}
+async function expectMultiBookSchema(client) {
+ const addressBookColumns = await readColumns(client, 'address_books');
+ expect(addressBookColumns).toMatchObject({
+ is_write_target: { data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
+ is_subscribed: { data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
+ is_lookup_source: { data_type: 'boolean', is_nullable: 'NO', column_default: 'true' },
+ });
+
+ const mappingColumns = await readColumns(client, 'carddav_remote_objects');
+ expect(mappingColumns.lookup_display_name).toEqual({
+ data_type: 'text',
+ is_nullable: 'YES',
+ column_default: null,
+ });
+
+ const checks = await readChecks(client, ['address_books', 'carddav_remote_objects']);
+ expect(checks).toEqual(expect.arrayContaining([
+ {
+ table_name: 'address_books',
+ check_clause: '(((NOT is_write_target) OR is_subscribed))',
+ },
+ {
+ table_name: 'carddav_remote_objects',
+ check_clause: "((mapping_status = ANY (ARRAY['pending_materialization'::text, 'synced'::text, 'pending_push'::text, 'conflict'::text, 'lookup'::text])))",
+ },
+ ]));
+
+ const indexDefinitions = await readIndexDefinitions(client, [
+ 'carddav_one_write_target_idx',
+ 'carddav_lookup_email_idx',
+ ]);
+ expect(indexDefinitions.carddav_one_write_target_idx).toBe(
+ "UNIQUE (user_id) WHERE ((source = 'carddav'::text) AND is_write_target)",
+ );
+ expect(indexDefinitions.carddav_lookup_email_idx).toBe(
+ "(primary_email) WHERE (mapping_status = 'lookup'::text)",
+ );
+}
+
it('keeps migration versions unique and orders the CardDAV lifecycle migrations', async () => {
const filenames = (await readdir(migrationsDirectory))
.filter(filename => /^\d{4}_.+\.sql$/.test(filename))
@@ -576,6 +617,9 @@ it('keeps migration versions unique and orders the CardDAV lifecycle migrations'
const expandIndex = filenames.indexOf('0035_carddav_bidirectional_sync.sql');
const contractIndex = filenames.indexOf('0036_carddav_bidirectional_cleanup.sql');
const retentionIndex = filenames.indexOf('0037_carddav_conflict_retention.sql');
+ const multiBookIndex = filenames.indexOf('0038_carddav_multi_book.sql');
+ const writeTargetAliasIndex = filenames.indexOf('0039_carddav_write_target_alias.sql');
+ const publishIntentIndex = filenames.indexOf('0040_carddav_publish_intent.sql');
expect(duplicateVersions).toEqual([]);
expect(upstreamIndex).toBeGreaterThanOrEqual(0);
@@ -583,6 +627,9 @@ it('keeps migration versions unique and orders the CardDAV lifecycle migrations'
expect(expandIndex).toBe(incrementalIndex + 1);
expect(contractIndex).toBe(expandIndex + 1);
expect(retentionIndex).toBe(contractIndex + 1);
+ expect(multiBookIndex).toBe(retentionIndex + 1);
+ expect(writeTargetAliasIndex).toBe(multiBookIndex + 1);
+ expect(publishIntentIndex).toBe(writeTargetAliasIndex + 1);
});
describe('CardDAV schema migrations', () => {
@@ -1086,10 +1133,42 @@ describe('CardDAV schema migrations', () => {
WHERE user_id = $1 AND provider = 'carddav'
`, [userId]);
expect(contractedIntegration.config).not.toHaveProperty('dupMode');
+
+ await applyMigrations(client, '0038', '0038');
+ await expectMultiBookSchema(client);
+
+ // Existing single-book user: backfill preserves today's behavior exactly —
+ // the one book becomes the write-target and stays subscribed + lookup.
+ const { rows: [backfilledBook] } = await client.query(`
+ SELECT is_write_target, is_subscribed, is_lookup_source
+ FROM address_books WHERE id = $1
+ `, [earliestBookId]);
+ expect(backfilledBook).toEqual({
+ is_write_target: true,
+ is_subscribed: true,
+ is_lookup_source: true,
+ });
+
+ // CHECK rejects a write-target that is not subscribed.
+ await expect(client.query(`
+ UPDATE address_books SET is_write_target = true, is_subscribed = false
+ WHERE id = $1
+ `, [earliestBookId])).rejects.toMatchObject({ code: '23514' });
+
+ // Partial unique index rejects a second write-target for the same user.
+ const secondCarddavBookId = randomUUID();
+ await client.query(`
+ INSERT INTO address_books (
+ id, user_id, name, source, external_url, is_subscribed, is_lookup_source
+ ) VALUES ($1, $2, 'Second Book', 'carddav', $3, true, true)
+ `, [secondCarddavBookId, userId, `https://carddav.example.test/second/${userId}`]);
+ await expect(client.query(`
+ UPDATE address_books SET is_write_target = true WHERE id = $1
+ `, [secondCarddavBookId])).rejects.toMatchObject({ code: '23505' });
});
}, 120_000);
- it('creates the retained-conflict schema from migrations 0001 through 0037', async () => {
+ it('creates the multi-book publish-intent schema from migrations 0001 through 0040', async () => {
await withDatabase(databaseNames.fresh, async client => {
await assertMinimumPostgresVersion(client);
const freshUserId = randomUUID();
@@ -1108,7 +1187,7 @@ describe('CardDAV schema migrations', () => {
intervalMin: 45,
nested: { preserve: true },
})]);
- await applyMigrations(client, '0034', '0037');
+ await applyMigrations(client, '0034', '0040');
const { rows: [freshIntegration] } = await client.query(`
SELECT config
FROM user_integrations
@@ -1124,6 +1203,180 @@ describe('CardDAV schema migrations', () => {
});
await expectContractedBidirectionalSchema(client);
await expectConflictRetentionSchema(client);
+ await applyMigrations(client, '0038', '0038');
+ await expectMultiBookSchema(client);
+ await applyMigrations(client, '0039', '0039');
+ const addressBookColumns = await readColumns(client, 'address_books');
+ expect(addressBookColumns.discovery_alias_url).toEqual({
+ data_type: 'text',
+ is_nullable: 'YES',
+ column_default: null,
+ });
+ });
+ }, 120_000);
+
+ it('backfills the correct write-target per user by capability priority, then creation order', async () => {
+ const through0037Directory = await createMigrationDirectory('0001', '0037');
+ const migration0038Directory = await createMigrationDirectory('0038', '0038');
+ const migrationPool = new Pool({
+ connectionString: connectionStringFor(databaseNames.multiBookBackfill),
+ });
+ const priorityUserId = randomUUID();
+ const tieBreakUserId = randomUUID();
+ const allDeniedUserId = randomUUID();
+ // Later creation time, but 'allowed' — must win over an earlier 'unknown' book.
+ const allowedBookId = randomUUID();
+ const earlierUnknownBookId = randomUUID();
+ // Same-priority ('unknown') tie-break resolves by created_at, then id.
+ const earlierTieBookId = randomUUID();
+ const laterTieBookId = randomUUID();
+ // No create-capable book at all -> no write-target is assigned.
+ const deniedBookId = randomUUID();
+ const localBookId = randomUUID();
+
+ try {
+ await runMigrationsWithPool(migrationPool, through0037Directory);
+ await migrationPool.query(`
+ INSERT INTO users (id, username)
+ VALUES ($1, $4), ($2, $5), ($3, $6)
+ `, [
+ priorityUserId, tieBreakUserId, allDeniedUserId,
+ `carddav-priority-${priorityUserId}`,
+ `carddav-tiebreak-${tieBreakUserId}`,
+ `carddav-denied-${allDeniedUserId}`,
+ ]);
+ await migrationPool.query(`
+ INSERT INTO address_books (
+ id, user_id, name, source, external_url, remote_create_capability, created_at
+ ) VALUES
+ ($1, $6, 'Earlier Unknown', 'carddav', $7, 'unknown', '2026-01-01T00:00:00Z'),
+ ($2, $6, 'Later Allowed', 'carddav', $8, 'allowed', '2026-01-02T00:00:00Z'),
+ ($3, $9, 'Earlier Tie', 'carddav', $10, 'unknown', '2026-01-01T00:00:00Z'),
+ ($4, $9, 'Later Tie', 'carddav', $11, 'unknown', '2026-01-02T00:00:00Z'),
+ ($5, $12, 'Denied Only', 'carddav', $13, 'denied', '2026-01-01T00:00:00Z')
+ `, [
+ earlierUnknownBookId, allowedBookId, earlierTieBookId, laterTieBookId, deniedBookId,
+ priorityUserId, `https://dav.example.test/priority/unknown/${priorityUserId}`,
+ `https://dav.example.test/priority/allowed/${priorityUserId}`,
+ tieBreakUserId, `https://dav.example.test/tie/earlier/${tieBreakUserId}`,
+ `https://dav.example.test/tie/later/${tieBreakUserId}`,
+ allDeniedUserId, `https://dav.example.test/denied/${allDeniedUserId}`,
+ ]);
+ // A non-carddav (local) book must never be considered for the write-target.
+ await migrationPool.query(`
+ INSERT INTO address_books (id, user_id, name, source)
+ VALUES ($1, $2, 'Local Only', 'local')
+ `, [localBookId, priorityUserId]);
+
+ await runMigrationsWithPool(migrationPool, migration0038Directory);
+
+ const { rows: booksById } = await migrationPool.query(`
+ SELECT id, is_write_target, is_subscribed, is_lookup_source
+ FROM address_books
+ WHERE id = ANY($1::uuid[])
+ `, [[
+ earlierUnknownBookId, allowedBookId, earlierTieBookId,
+ laterTieBookId, deniedBookId, localBookId,
+ ]]);
+ const byId = Object.fromEntries(booksById.map(row => [row.id, row]));
+
+ // Capability priority: 'allowed' wins even though created later.
+ expect(byId[allowedBookId]).toEqual({
+ id: allowedBookId, is_write_target: true, is_subscribed: true, is_lookup_source: true,
+ });
+ expect(byId[earlierUnknownBookId]).toEqual({
+ id: earlierUnknownBookId, is_write_target: false, is_subscribed: true, is_lookup_source: true,
+ });
+
+ // Tie-break by creation order among equal-priority books.
+ expect(byId[earlierTieBookId]).toEqual({
+ id: earlierTieBookId, is_write_target: true, is_subscribed: true, is_lookup_source: true,
+ });
+ expect(byId[laterTieBookId]).toEqual({
+ id: laterTieBookId, is_write_target: false, is_subscribed: true, is_lookup_source: true,
+ });
+
+ // No create-capable book anywhere -> subscribed + lookup, but no write-target.
+ expect(byId[deniedBookId]).toEqual({
+ id: deniedBookId, is_write_target: false, is_subscribed: true, is_lookup_source: true,
+ });
+
+ // A local (non-carddav) book is untouched by the carddav-only backfill.
+ expect(byId[localBookId]).toEqual({
+ id: localBookId, is_write_target: false, is_subscribed: false, is_lookup_source: true,
+ });
+ } finally {
+ await migrationPool.end();
+ await rm(through0037Directory, { recursive: true, force: true });
+ await rm(migration0038Directory, { recursive: true, force: true });
+ }
+ }, 120_000);
+
+ // The non-regression guarantee of the publish-emailed-contacts setting: it
+ // ships OFF, so it may not un-publish anybody. Every contact that is explicit
+ // on the old schema is exporting today, and the backfill (publish_intent =
+ // NOT is_auto) is what keeps it exporting once publication is gated on intent.
+ // Only *new* explicit-by-email contacts land on the false default.
+ it('backfills publish intent from is_auto so existing explicit contacts keep exporting', async () => {
+ const through0039Directory = await createMigrationDirectory('0001', '0039');
+ const migration0040Directory = await createMigrationDirectory('0040', '0040');
+ const migrationPool = new Pool({
+ connectionString: connectionStringFor(databaseNames.publishIntentBackfill),
});
+ const userId = randomUUID();
+ const bookId = randomUUID();
+ const curatedId = randomUUID();
+ const emailedId = randomUUID();
+ const harvestedId = randomUUID();
+
+ try {
+ await runMigrationsWithPool(migrationPool, through0039Directory);
+ await migrationPool.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-publish-intent-${userId}`],
+ );
+ await migrationPool.query(
+ "INSERT INTO address_books (id, user_id, name, source) VALUES ($1, $2, 'Personal', 'local')",
+ [bookId, userId],
+ );
+ // The two explicit contacts are indistinguishable on the old schema — one
+ // was hand-created, one only ever emailed — and both export today. The
+ // backfill must carry both across, or the setting would silently drop a
+ // contact the user is already syncing.
+ await migrationPool.query(`
+ INSERT INTO contacts (id, address_book_id, user_id, uid, display_name, is_auto)
+ VALUES
+ ($1, $4, $5, 'curated-uid', 'Curated Contact', false),
+ ($2, $4, $5, 'emailed-uid', 'Emailed Sender', false),
+ ($3, $4, $5, 'harvested-uid', 'Harvested Sender', true)
+ `, [curatedId, emailedId, harvestedId, bookId, userId]);
+
+ await runMigrationsWithPool(migrationPool, migration0040Directory);
+
+ const { rows } = await migrationPool.query(`
+ SELECT id, is_auto, carddav_publish_intent
+ FROM contacts
+ WHERE user_id = $1
+ ORDER BY display_name
+ `, [userId]);
+ expect(rows).toEqual([
+ { id: curatedId, is_auto: false, carddav_publish_intent: true },
+ { id: emailedId, is_auto: false, carddav_publish_intent: true },
+ { id: harvestedId, is_auto: true, carddav_publish_intent: false },
+ ]);
+
+ // New rows land on the default — an emailed contact created after the
+ // migration is not published until the user says so.
+ const contactColumns = await readColumns(migrationPool, 'contacts');
+ expect(contactColumns.carddav_publish_intent).toEqual({
+ data_type: 'boolean',
+ is_nullable: 'NO',
+ column_default: 'false',
+ });
+ } finally {
+ await migrationPool.end();
+ await rm(through0039Directory, { recursive: true, force: true });
+ await rm(migration0040Directory, { recursive: true, force: true });
+ }
}, 120_000);
});
diff --git a/backend/src/services/carddavSync.db.test.js b/backend/src/services/carddavSync.db.test.js
index 38158cd..7ea396d 100644
--- a/backend/src/services/carddavSync.db.test.js
+++ b/backend/src/services/carddavSync.db.test.js
@@ -2,13 +2,14 @@ import { createHash, randomUUID } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
-import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyTestMigrations,
assertMinimumPostgresVersion,
createTestDatabase,
dropTestDatabase,
postgresTestContext,
+ quoteIdentifier,
waitForPostgresState,
} from './postgresTestHelpers.js';
@@ -1909,7 +1910,9 @@ describe('CardDAV full snapshot transaction', () => {
await expect(pending).resolves.toMatchObject({ ok: true, bookCount: 1 });
expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
- expect(mocks.withTransaction).toHaveBeenCalledTimes(3);
+ // The initial apply, its footprint-changed retry, the batch-wide
+ // write-target claim, and finalization.
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(4);
}, 120_000);
it('persists projection fingerprint from final eligible target tokens', async () => {
@@ -4089,3 +4092,682 @@ describe('CardDAV full snapshot transaction', () => {
}, 120_000);
});
+
+// Per-book summaries use an isolated database so their role fixtures do not
+// interfere with the shared transaction-test database.
+describe('CardDAV per-book summaries', () => {
+ const summaryDatabaseName = `carddav_book_summary_${process.pid}_${randomUUID()
+ .replaceAll('-', '').slice(0, 10)}`;
+ let summaryAdminClient;
+ let summaryClient;
+
+ beforeAll(async () => {
+ summaryAdminClient = new Client({ connectionString: databaseUrl });
+ await summaryAdminClient.connect();
+ await summaryAdminClient.query(`CREATE DATABASE ${quoteIdentifier(summaryDatabaseName)}`);
+ summaryClient = new Client({ connectionString: connectionStringFor(summaryDatabaseName) });
+ await summaryClient.connect();
+ await applyMigrations(summaryClient, '0038');
+ }, 120_000);
+
+ afterAll(async () => {
+ if (summaryClient) await summaryClient.end();
+ if (summaryAdminClient) {
+ await summaryAdminClient.query(
+ `DROP DATABASE IF EXISTS ${quoteIdentifier(summaryDatabaseName)} WITH (FORCE)`,
+ );
+ await summaryAdminClient.end();
+ }
+ }, 120_000);
+
+ it('reports per-book roles, capabilities, and materialized/lookup counts read-only', async () => {
+ mocks.query.mockImplementation((sql, params) => summaryClient.query(sql, params));
+ const userId = randomUUID();
+ const writeBookId = randomUUID();
+ const lookupBookId = randomUUID();
+ const writeBookUrl = `https://dav.example.test/summary/write/${userId}`;
+ const lookupBookUrl = `https://dav.example.test/summary/lookup/${userId}`;
+
+ await summaryClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-book-summary-${userId}`],
+ );
+ await summaryClient.query(`
+ INSERT INTO address_books (
+ id, user_id, name, source, external_url, created_at,
+ is_write_target, is_subscribed, is_lookup_source,
+ remote_create_capability, remote_update_capability, remote_delete_capability
+ ) VALUES
+ ($1, $3, 'Primary', 'carddav', $4, '2026-01-01T00:00:00Z',
+ true, true, true, 'allowed', 'allowed', 'allowed'),
+ ($2, $3, 'Apple Contacts', 'carddav', $5, '2026-01-02T00:00:00Z',
+ false, false, true, 'denied', 'denied', 'denied')
+ `, [writeBookId, lookupBookId, userId, writeBookUrl, lookupBookUrl]);
+ await summaryClient.query(`
+ INSERT INTO contacts (address_book_id, user_id, uid, display_name)
+ VALUES ($1, $2, 'contact-1', 'Ann Example')
+ `, [writeBookId, userId]);
+ await summaryClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, vcard, primary_email, mapping_status, lookup_display_name
+ ) VALUES ($1, '/ann.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'ann@example.test',
+ 'lookup', 'Ann Example')
+ `, [lookupBookId]);
+
+ const summaries = await carddavSync.getCarddavBookSummaries(userId);
+
+ expect(summaries).toEqual([
+ {
+ id: writeBookId,
+ name: 'Primary',
+ externalUrl: writeBookUrl,
+ isWriteTarget: true,
+ isSubscribed: true,
+ isLookupSource: true,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ materializedCount: 1,
+ lookupCount: 0,
+ lastSyncAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
+ },
+ {
+ id: lookupBookId,
+ name: 'Apple Contacts',
+ externalUrl: lookupBookUrl,
+ isWriteTarget: false,
+ isSubscribed: false,
+ isLookupSource: true,
+ capabilities: { create: 'denied', update: 'denied', delete: 'denied' },
+ materializedCount: 0,
+ lookupCount: 1,
+ lastSyncAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
+ },
+ ]);
+ });
+
+ it('reports an ignored book as serving neither the list nor lookup', async () => {
+ mocks.query.mockImplementation((sql, params) => summaryClient.query(sql, params));
+ const userId = randomUUID();
+ const ignoredBookId = randomUUID();
+ const ignoredBookUrl = `https://dav.example.test/summary/ignored/${userId}`;
+
+ await summaryClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-book-summary-ignored-${userId}`],
+ );
+ await summaryClient.query(`
+ INSERT INTO address_books (
+ id, user_id, name, source, external_url,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, $2, 'Ignored', 'carddav', $3, false, false, false)
+ `, [ignoredBookId, userId, ignoredBookUrl]);
+ // Ledger rows retained from when the book was still a lookup source; the
+ // next sync drops them, and until then they resolve no sender (the lookup
+ // probes filter on is_lookup_source), so the row must not advertise them.
+ await summaryClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, vcard, primary_email, mapping_status, lookup_display_name
+ ) VALUES ($1, '/stale.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'stale@example.test',
+ 'lookup', 'Stale Example')
+ `, [ignoredBookId]);
+
+ await expect(carddavSync.getCarddavBookSummaries(userId)).resolves.toMatchObject([{
+ id: ignoredBookId,
+ isWriteTarget: false,
+ isSubscribed: false,
+ isLookupSource: false,
+ materializedCount: 0,
+ lookupCount: 0,
+ }]);
+ });
+
+ it('returns an empty list for a user with no carddav books', async () => {
+ mocks.query.mockImplementation((sql, params) => summaryClient.query(sql, params));
+ const userId = randomUUID();
+ await summaryClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `carddav-book-summary-empty-${userId}`],
+ );
+
+ await expect(carddavSync.getCarddavBookSummaries(userId)).resolves.toEqual([]);
+ });
+});
+
+describe('CardDAV canonicalized-alias re-sync', () => {
+ const aliasDatabaseName = `carddav_alias_resync_${process.pid}_${randomUUID()
+ .replaceAll('-', '').slice(0, 10)}`;
+ let aliasAdminClient;
+ let aliasClient;
+
+ beforeAll(async () => {
+ aliasAdminClient = new Client({ connectionString: databaseUrl });
+ await aliasAdminClient.connect();
+ await aliasAdminClient.query(`CREATE DATABASE ${quoteIdentifier(aliasDatabaseName)}`);
+ aliasClient = new Client({ connectionString: connectionStringFor(aliasDatabaseName) });
+ await aliasClient.connect();
+ // Full schema, so discovery_alias_url exists and the alias-aware planning
+ // join, lock predicate, and reconciliation comparison run against real PG.
+ await applyMigrations(aliasClient, '0039');
+ }, 120_000);
+
+ afterAll(async () => {
+ if (aliasClient) await aliasClient.end();
+ if (aliasAdminClient) {
+ await aliasAdminClient.query(
+ `DROP DATABASE IF EXISTS ${quoteIdentifier(aliasDatabaseName)} WITH (FORCE)`,
+ );
+ await aliasAdminClient.end();
+ }
+ }, 120_000);
+
+ async function applyInTransaction(plan) {
+ await aliasClient.query('BEGIN');
+ try {
+ const result = await carddavSync.applyBookDelta(aliasClient, plan);
+ await aliasClient.query('COMMIT');
+ return result;
+ } catch (error) {
+ await aliasClient.query('ROLLBACK');
+ throw error;
+ }
+ }
+
+ it('applies a canonicalized book discovery still surfaces under its alias, in place', async () => {
+ const userId = randomUUID();
+ const generation = randomUUID();
+ const aliasUrl = `https://dav.example.test/addressbooks/${userId}/alias/`;
+ const canonicalUrl = `https://dav.example.test/addressbooks/${userId}/canonical/`;
+ const href = `${canonicalUrl}person.vcf`;
+
+ await aliasClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `alias-resync-${userId}`],
+ );
+ await aliasClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'connectionGeneration', $2::text, 'contactCount', 1
+ ))`,
+ [userId, generation],
+ );
+ // Already canonicalized on an earlier sync: external_url holds the canonical
+ // URL, discovery_alias_url retains the alias the server keeps advertising.
+ const { rows: [book] } = await aliasClient.query(
+ `INSERT INTO address_books (
+ user_id, name, source, external_url, discovery_alias_url,
+ remote_sync_token, remote_sync_revision,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Aliased Lookup', 'carddav', $2, $3, 'token-1', 1,
+ false, false, true)
+ RETURNING id`,
+ [userId, canonicalUrl, aliasUrl],
+ );
+ await aliasClient.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ mapping_status, remote_semantic_hash, lookup_display_name
+ ) VALUES ($1, $2, 'old-etag', $3, 'person@example.test',
+ 'lookup', 'old-hash', 'Old Name')`,
+ [book.id, href, 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:person\r\nEND:VCARD\r\n'],
+ );
+
+ // Discovery returns the alias again; the delta reconciles to canonical.
+ const card = { ...remoteCard('person', 'person@example.test'), href };
+ const result = await applyInTransaction({
+ userId,
+ book: { url: aliasUrl, displayName: 'Aliased Lookup' },
+ connectionGeneration: generation,
+ expectedRemoteRevision: '1',
+ expectedRemoteToken: 'token-1',
+ nextRemoteToken: 'token-2',
+ capability: 'sync-collection',
+ replaceAll: true,
+ materialize: false,
+ collectionIdentity: { observedUrl: aliasUrl, canonicalUrl },
+ upserts: [card],
+ removedHrefs: [],
+ });
+
+ expect(result.bookId).toBe(book.id);
+
+ // The book advances in place — canonical URL and alias retained, token and
+ // revision moved — with no duplicate row inserted for the alias.
+ const { rows: books } = await aliasClient.query(
+ `SELECT id, external_url, discovery_alias_url, remote_sync_token,
+ remote_sync_revision::text AS remote_sync_revision
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY id`,
+ [userId],
+ );
+ expect(books).toEqual([{
+ id: book.id,
+ external_url: canonicalUrl,
+ discovery_alias_url: aliasUrl,
+ remote_sync_token: 'token-2',
+ remote_sync_revision: '2',
+ }]);
+
+ const { rows: ledger } = await aliasClient.query(
+ `SELECT href, remote_etag, mapping_status, local_contact_id
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1`,
+ [book.id],
+ );
+ expect(ledger).toEqual([{
+ href,
+ remote_etag: card.remoteEtag,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ }]);
+ }, 120_000);
+
+ it('reconciliation keeps a book seen only under its alias but still prunes a truly stale one', async () => {
+ const userId = randomUUID();
+ const aliasUrl = `https://dav.example.test/addressbooks/${userId}/rec-alias/`;
+ const canonicalUrl = `https://dav.example.test/addressbooks/${userId}/rec-canonical/`;
+ await aliasClient.query(
+ 'INSERT INTO users (id, username) VALUES ($1, $2)',
+ [userId, `alias-reconcile-${userId}`],
+ );
+ const { rows: [book] } = await aliasClient.query(
+ `INSERT INTO address_books (
+ user_id, name, source, external_url, discovery_alias_url, is_lookup_source
+ ) VALUES ($1, 'Reconcile', 'carddav', $2, $3, true)
+ RETURNING id`,
+ [userId, canonicalUrl, aliasUrl],
+ );
+
+ await aliasClient.query('BEGIN');
+ const kept = await carddavSync.reconcileStaleCarddavBooks(aliasClient, userId, {
+ contactCount: 0,
+ seenUrls: [aliasUrl],
+ });
+ await aliasClient.query('COMMIT');
+ expect(kept).toEqual([]);
+ const { rows: survivors } = await aliasClient.query(
+ 'SELECT id FROM address_books WHERE user_id = $1',
+ [userId],
+ );
+ expect(survivors).toEqual([{ id: book.id }]);
+
+ await aliasClient.query('BEGIN');
+ const removed = await carddavSync.reconcileStaleCarddavBooks(aliasClient, userId, {
+ contactCount: 0,
+ seenUrls: [],
+ });
+ await aliasClient.query('COMMIT');
+ expect(removed).toEqual([book.id]);
+ const { rows: afterRemoval } = await aliasClient.query(
+ 'SELECT id FROM address_books WHERE user_id = $1',
+ [userId],
+ );
+ expect(afterRemoval).toEqual([]);
+ }, 120_000);
+});
+
+// Per-book role management: patchCarddavBookRoles applies
+// Subscribe / Look-up-senders / write-target changes in one connection-fenced
+// transaction. Full role schema so the one-write-target index, the
+// write-target/subscribed CHECK, and the lookup ledger columns are all real.
+describe('CardDAV per-book role management', () => {
+ const roleDatabaseName = `carddav_book_roles_${process.pid}_${randomUUID()
+ .replaceAll('-', '').slice(0, 10)}`;
+ let roleAdminClient;
+ let roleClient;
+
+ beforeAll(async () => {
+ roleAdminClient = new Client({ connectionString: databaseUrl });
+ await roleAdminClient.connect();
+ await roleAdminClient.query(`CREATE DATABASE ${quoteIdentifier(roleDatabaseName)}`);
+ roleClient = new Client({ connectionString: connectionStringFor(roleDatabaseName) });
+ await roleClient.connect();
+ await applyMigrations(roleClient, '0039');
+ }, 120_000);
+
+ afterAll(async () => {
+ if (roleClient) await roleClient.end();
+ if (roleAdminClient) {
+ await roleAdminClient.query(
+ `DROP DATABASE IF EXISTS ${quoteIdentifier(roleDatabaseName)} WITH (FORCE)`,
+ );
+ await roleAdminClient.end();
+ }
+ }, 120_000);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.query.mockImplementation((sql, params) => roleClient.query(sql, params));
+ mocks.withTransaction.mockImplementation(async callback => {
+ await roleClient.query('BEGIN');
+ try {
+ const result = await callback(roleClient);
+ await roleClient.query('COMMIT');
+ return result;
+ } catch (error) {
+ await roleClient.query('ROLLBACK');
+ throw error;
+ }
+ });
+ });
+
+ const GENERATION = 'role-generation';
+
+ async function seedRoleUser() {
+ const userId = randomUUID();
+ await roleClient.query('INSERT INTO users (id, username) VALUES ($1, $2)', [
+ userId, `carddav-roles-${userId}`,
+ ]);
+ await roleClient.query(
+ `INSERT INTO user_integrations (user_id, provider, config)
+ VALUES ($1, 'carddav', jsonb_build_object(
+ 'serverUrl', 'https://dav.example.test/',
+ 'username', 'role-user',
+ 'connectionGeneration', $2::text
+ ))`,
+ [userId, GENERATION],
+ );
+ return userId;
+ }
+
+ async function seedBook(userId, {
+ name,
+ write = false,
+ subscribed = false,
+ lookup = true,
+ create = 'allowed',
+ token = null,
+ }) {
+ const url = `https://dav.example.test/books/${randomUUID()}/`;
+ const { rows: [book] } = await roleClient.query(
+ `INSERT INTO address_books (
+ user_id, name, source, external_url, remote_sync_token,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, $2, 'carddav', $3, $4, $5, 'allowed', 'allowed', $6, $7, $8)
+ RETURNING id, remote_sync_revision::text AS remote_sync_revision`,
+ [userId, name, url, token, create, write, subscribed, lookup],
+ );
+ return { id: book.id, url, remoteSyncRevision: book.remote_sync_revision };
+ }
+
+ async function bookRow(bookId) {
+ const { rows: [row] } = await roleClient.query(
+ `SELECT is_write_target, is_subscribed, is_lookup_source,
+ remote_sync_token, remote_sync_revision::text AS remote_sync_revision
+ FROM address_books WHERE id = $1`,
+ [bookId],
+ );
+ return row;
+ }
+
+ it('makeWriteTarget swaps the flag atomically and forces the new book subscribed', async () => {
+ const userId = await seedRoleUser();
+ const primary = await seedBook(userId, {
+ name: 'Primary', write: true, subscribed: true, lookup: true,
+ });
+ const apple = await seedBook(userId, {
+ name: 'Apple', write: false, subscribed: false, lookup: true, token: 'apple-token',
+ });
+
+ await carddavSync.patchCarddavBookRoles(
+ userId, apple.id, { makeWriteTarget: true }, GENERATION,
+ );
+
+ expect(await bookRow(apple.id)).toMatchObject({
+ is_write_target: true, is_subscribed: true, is_lookup_source: true,
+ });
+ // The previous holder keeps its contacts (still subscribed) but yields the flag.
+ expect(await bookRow(primary.id)).toMatchObject({
+ is_write_target: false, is_subscribed: true,
+ });
+ // Exactly one write-target survives the swap.
+ const { rows: targets } = await roleClient.query(
+ `SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true`,
+ [userId],
+ );
+ expect(targets).toEqual([{ id: apple.id }]);
+ // The newly-subscribed book is scheduled for a full materializing re-pull.
+ const appleRow = await bookRow(apple.id);
+ expect(appleRow.remote_sync_token).toBeNull();
+ expect(appleRow.remote_sync_revision).not.toBe(apple.remoteSyncRevision);
+ }, 120_000);
+
+ it('rejects makeWriteTarget for a create-denied book and leaves roles unchanged', async () => {
+ const userId = await seedRoleUser();
+ const primary = await seedBook(userId, {
+ name: 'Primary', write: true, subscribed: true,
+ });
+ const readOnly = await seedBook(userId, {
+ name: 'Directory', write: false, subscribed: false, lookup: true, create: 'denied',
+ });
+
+ await expect(carddavSync.patchCarddavBookRoles(
+ userId, readOnly.id, { makeWriteTarget: true }, GENERATION,
+ )).rejects.toMatchObject({ code: 'ERR_CARDDAV_READ_ONLY' });
+
+ expect(await bookRow(readOnly.id)).toMatchObject({ is_write_target: false });
+ expect(await bookRow(primary.id)).toMatchObject({ is_write_target: true });
+ }, 120_000);
+
+ it('unsubscribe removes materialized contacts and demotes the ledger to lookup', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, {
+ name: 'Secondary', write: false, subscribed: true, lookup: true,
+ });
+ const { rows: [contact] } = await roleClient.query(
+ `INSERT INTO contacts (address_book_id, user_id, uid, display_name, primary_email)
+ VALUES ($1, $2, 'materialized-1', 'Materialized Person', 'mat@example.test')
+ RETURNING id`,
+ [book.id, userId],
+ );
+ await roleClient.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, remote_semantic_hash, local_contact_hash
+ ) VALUES ($1, $2, '"etag-1"', $3, 'mat@example.test', $4, 'synced', 'hash', 'local-hash')`,
+ [book.id, `${book.url}mat.vcf`, 'BEGIN:VCARD\r\nEND:VCARD\r\n', contact.id],
+ );
+
+ await carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isSubscribed: false }, GENERATION,
+ );
+
+ expect(await bookRow(book.id)).toMatchObject({
+ is_write_target: false, is_subscribed: false, is_lookup_source: true,
+ });
+ const { rows: contacts } = await roleClient.query(
+ 'SELECT id FROM contacts WHERE address_book_id = $1', [book.id],
+ );
+ expect(contacts).toEqual([]);
+ const { rows: ledger } = await roleClient.query(
+ `SELECT mapping_status, local_contact_id, local_contact_hash, lookup_display_name
+ FROM carddav_remote_objects WHERE address_book_id = $1`,
+ [book.id],
+ );
+ expect(ledger).toEqual([{
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ local_contact_hash: null,
+ lookup_display_name: 'Materialized Person',
+ }]);
+ }, 120_000);
+
+ it('a materializing pull that lost a race with unsubscribe demotes instead of resurrecting contacts', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, {
+ name: 'Racing', write: false, subscribed: true, lookup: true, token: 'race-token-1',
+ });
+ const href = `${book.url}race.vcf`;
+ const vcard = [
+ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:race', 'FN:Race Contact',
+ 'EMAIL:race@example.test', 'END:VCARD', '',
+ ].join('\r\n');
+ const { rows: [contact] } = await roleClient.query(
+ `INSERT INTO contacts (address_book_id, user_id, uid, vcard, display_name, primary_email)
+ VALUES ($1, $2, 'race', $3, 'Race Contact', 'race@example.test')
+ RETURNING id`,
+ [book.id, userId, vcard],
+ );
+ await roleClient.query(
+ `INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, remote_semantic_hash, local_contact_hash
+ ) VALUES ($1, $2, '"race-1"', $3, 'race@example.test', $4, 'synced', 'sem', 'loc')`,
+ [book.id, href, vcard, contact.id],
+ );
+
+ // The user unsubscribes; the PATCH transaction commits first, removing the
+ // materialized contact and demoting the ledger to lookup.
+ await carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isSubscribed: false }, GENERATION,
+ );
+
+ // A sync already in flight still carries its pre-unsubscribe materialize
+ // decision and its stale (still-valid) token/revision fences. Applying it
+ // must honor the book's live is_subscribed under the shared integration
+ // lock — never re-import the contact the user just removed.
+ await roleClient.query('BEGIN');
+ try {
+ await carddavSync.applyBookDelta(roleClient, {
+ userId,
+ book: { url: book.url, displayName: 'Racing' },
+ connectionGeneration: GENERATION,
+ expectedRemoteRevision: book.remoteSyncRevision,
+ expectedRemoteToken: 'race-token-1',
+ nextRemoteToken: 'race-token-2',
+ capability: 'sync-collection',
+ replaceAll: false,
+ materialize: true,
+ collectionIdentity: null,
+ upserts: [{
+ href,
+ remoteEtag: '"race-1"',
+ vcard,
+ contact: { displayName: 'Race Contact', primaryEmail: 'race@example.test' },
+ }],
+ removedHrefs: [],
+ });
+ await roleClient.query('COMMIT');
+ } catch (error) {
+ await roleClient.query('ROLLBACK');
+ throw error;
+ }
+
+ const { rows: contacts } = await roleClient.query(
+ 'SELECT id FROM contacts WHERE address_book_id = $1', [book.id],
+ );
+ expect(contacts).toEqual([]);
+ const { rows: ledger } = await roleClient.query(
+ `SELECT mapping_status, local_contact_id
+ FROM carddav_remote_objects WHERE address_book_id = $1`,
+ [book.id],
+ );
+ expect(ledger).toEqual([{ mapping_status: 'lookup', local_contact_id: null }]);
+ expect(await bookRow(book.id)).toMatchObject({ is_subscribed: false });
+ }, 120_000);
+
+ it('rejects unsubscribing the write-target book', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, {
+ name: 'Primary', write: true, subscribed: true, lookup: true,
+ });
+
+ await expect(carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isSubscribed: false }, GENERATION,
+ )).rejects.toMatchObject({ code: 'ERR_CARDDAV_WRITE_TARGET_SUBSCRIBED' });
+
+ expect(await bookRow(book.id)).toMatchObject({ is_subscribed: true });
+ }, 120_000);
+
+ it('subscribing a lookup-only book schedules a full materializing reconcile', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, {
+ name: 'Apple', write: false, subscribed: false, lookup: true, token: 'apple-token',
+ });
+
+ await carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isSubscribed: true }, GENERATION,
+ );
+
+ const row = await bookRow(book.id);
+ expect(row).toMatchObject({ is_subscribed: true, is_lookup_source: true });
+ expect(row.remote_sync_token).toBeNull();
+ expect(row.remote_sync_revision).not.toBe(book.remoteSyncRevision);
+ }, 120_000);
+
+ it('turning off look-up-senders while unsubscribed leaves the book ignored', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, {
+ name: 'Apple', write: false, subscribed: false, lookup: true,
+ });
+
+ await carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isLookupSource: false }, GENERATION,
+ );
+
+ expect(await bookRow(book.id)).toMatchObject({
+ is_write_target: false, is_subscribed: false, is_lookup_source: false,
+ });
+ }, 120_000);
+
+ it('re-enabling look-up-senders on an ignored book schedules a full re-pull', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, {
+ name: 'Apple', write: false, subscribed: false, lookup: false, token: 'apple-token',
+ });
+
+ await carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isLookupSource: true }, GENERATION,
+ );
+
+ // An ignored book's ledger is dropped and never maintained, so resuming from
+ // the token it still carries would leave it lookup-on and empty forever.
+ const row = await bookRow(book.id);
+ expect(row).toMatchObject({ is_subscribed: false, is_lookup_source: true });
+ expect(row.remote_sync_token).toBeNull();
+ expect(row.remote_sync_revision).not.toBe(book.remoteSyncRevision);
+ }, 120_000);
+
+ it('fences a role change against a stale connection generation', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, { name: 'Apple', subscribed: false, lookup: true });
+
+ await expect(carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isSubscribed: true }, 'wrong-generation',
+ )).rejects.toMatchObject({
+ name: 'StaleCarddavPlanError', reason: 'connection-generation-changed',
+ });
+
+ expect(await bookRow(book.id)).toMatchObject({ is_subscribed: false });
+ }, 120_000);
+
+ it('rejects a role change with no fields and an unknown book', async () => {
+ const userId = await seedRoleUser();
+ const book = await seedBook(userId, { name: 'Apple', subscribed: false, lookup: true });
+
+ await expect(carddavSync.patchCarddavBookRoles(
+ userId, book.id, {}, GENERATION,
+ )).rejects.toMatchObject({ code: 'ERR_CARDDAV_BOOK_PATCH_EMPTY' });
+
+ await expect(carddavSync.patchCarddavBookRoles(
+ userId, randomUUID(), { isSubscribed: true }, GENERATION,
+ )).rejects.toMatchObject({ code: 'ERR_ADDRESS_BOOK_NOT_FOUND' });
+ }, 120_000);
+
+ it('rejects a role change when the connection is gone', async () => {
+ const userId = randomUUID();
+ await roleClient.query('INSERT INTO users (id, username) VALUES ($1, $2)', [
+ userId, `carddav-roles-none-${userId}`,
+ ]);
+ const { rows: [book] } = await roleClient.query(
+ `INSERT INTO address_books (user_id, name, source, external_url, is_lookup_source)
+ VALUES ($1, 'Orphan', 'carddav', $2, true) RETURNING id`,
+ [userId, `https://dav.example.test/books/${randomUUID()}/`],
+ );
+
+ await expect(carddavSync.patchCarddavBookRoles(
+ userId, book.id, { isSubscribed: true }, GENERATION,
+ )).rejects.toMatchObject({ name: 'StaleCarddavPlanError', reason: 'not-connected' });
+ }, 120_000);
+});
diff --git a/backend/src/services/carddavSync.integration.test.js b/backend/src/services/carddavSync.integration.test.js
index 85fc956..7a3888f 100644
--- a/backend/src/services/carddavSync.integration.test.js
+++ b/backend/src/services/carddavSync.integration.test.js
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import bcrypt from 'bcryptjs';
import express from 'express';
import pg from 'pg';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createCarddavFixtureServer } from './carddavFixtureServer.js';
import {
applyTestMigrations,
@@ -538,15 +538,22 @@ async function failureBoundaryState(userId) {
};
}
-async function seedConnectedUser(fixture, overrides = {}) {
+async function seedUser() {
const userId = randomUUID();
- const connectionGeneration = overrides.connectionGeneration || randomUUID();
- const password = overrides.password || 'fixture-password';
- const encryptedPassword = encrypt(password);
await databaseClient.query(
'INSERT INTO users (id, username) VALUES ($1, $2)',
[userId, `carddav-e2e-${userId}`],
);
+ return userId;
+}
+
+// Attach a CardDAV connection to an existing user. Split out of seedConnectedUser
+// so a test can create contacts *before* the connection exists — the local-only
+// create path — and then connect and let the sweep publish them.
+async function connectSeededUser(fixture, userId, overrides = {}) {
+ const connectionGeneration = overrides.connectionGeneration || randomUUID();
+ const password = overrides.password || 'fixture-password';
+ const encryptedPassword = encrypt(password);
const config = {
serverUrl: fixture.serverUrl,
username: overrides.username || 'fixture-user',
@@ -556,6 +563,11 @@ async function seedConnectedUser(fixture, overrides = {}) {
lastError: 'seeded-error',
bookCount: 0,
contactCount: 0,
+ // Left absent unless a test sets it: a connection stored without the key is
+ // exactly what every existing user has, and absent must mean OFF.
+ ...(overrides.publishEmailedContacts === undefined
+ ? {}
+ : { publishEmailedContacts: overrides.publishEmailedContacts }),
};
await databaseClient.query(`
INSERT INTO user_integrations (user_id, provider, config)
@@ -564,6 +576,27 @@ async function seedConnectedUser(fixture, overrides = {}) {
return { userId, config, connectionGeneration, encryptedPassword, password };
}
+async function seedConnectedUser(fixture, overrides = {}) {
+ return connectSeededUser(fixture, await seedUser(), overrides);
+}
+
+// Pre-designate a fixture connection's single book as the write target so a
+// test can exercise write routing in isolation, without first running the sync
+// whose first-connect bootstrap would assign the flag (that bootstrap is
+// covered end-to-end by the zero-config test below). This mirrors Slice 1's
+// migration backfill for an already-connected single-book user.
+async function seedWriteTargetBook(fixture, userId) {
+ const externalUrl = fixture.href('');
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed', true, true, true)
+ `, [userId, externalUrl]);
+ return externalUrl;
+}
+
function remoteVcard(uid, name, email = `${uid}@example.test`) {
return [
'BEGIN:VCARD',
@@ -681,8 +714,9 @@ async function seedMappedExplicitContact(fixture, userId) {
const { rows: [remoteBook] } = await databaseClient.query(`
INSERT INTO address_books (
user_id, name, source, external_url,
- remote_create_capability, remote_update_capability, remote_delete_capability
- ) VALUES ($1, 'Retry After Remote', 'carddav', $2, 'allowed', 'allowed', 'allowed')
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Retry After Remote', 'carddav', $2, 'allowed', 'allowed', 'allowed', true, true, true)
RETURNING id
`, [userId, fixture.href('')]);
const { rows: [row] } = await databaseClient.query(`
@@ -748,9 +782,60 @@ async function seedUnmappedExplicitContact(userId, {
const { rows: [row] } = await databaseClient.query(`
INSERT INTO contacts (
address_book_id, user_id, uid, vcard, etag, display_name,
- primary_email, emails, phones, additional_fields, is_auto
+ primary_email, emails, phones, additional_fields, is_auto, carddav_publish_intent
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, '[]'::jsonb, false, true
+ )
+ RETURNING id
+ `, [
+ book.id,
+ userId,
+ uid,
+ card,
+ createHash('md5').update(card).digest('hex'),
+ displayName,
+ contact.emails[0].value,
+ JSON.stringify(contact.emails),
+ ]);
+ return { ...row, uid, card };
+}
+
+// An unmapped local contact — explicit by default (the export sweep's subject),
+// or `isAuto` to stand in for a harvested sender the sweep must pass over. Both
+// share one local book, as they do in a real mailbox.
+// `publishIntent` defaults to the migration's backfill rule (NOT is_auto), so an
+// "explicit" seed means a contact the user curated before this setting existed.
+// A contact that became explicit only by being emailed is seeded by passing
+// isAuto: false with publishIntent: false — exactly the row send.js leaves behind.
+async function seedUnmappedContact(userId, {
+ uid = randomUUID(),
+ displayName = 'Unmapped Explicit',
+ isAuto = false,
+ publishIntent = !isAuto,
+} = {}) {
+ const contact = {
+ uid,
+ displayName,
+ emails: [{ value: `${uid}@example.test`, type: 'other', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ };
+ const card = generateVCard(contact);
+ const { rows: [book] } = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Unmapped Explicit Local')
+ ON CONFLICT (user_id, name) DO UPDATE SET updated_at = address_books.updated_at
+ RETURNING id
+ `, [userId]);
+ const { rows: [row] } = await databaseClient.query(`
+ INSERT INTO contacts (
+ address_book_id, user_id, uid, vcard, etag, display_name,
+ primary_email, emails, phones, additional_fields, is_auto, carddav_publish_intent
) VALUES (
- $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, '[]'::jsonb, false
+ $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, '[]'::jsonb, $9, $10
)
RETURNING id
`, [
@@ -762,6 +847,8 @@ async function seedUnmappedExplicitContact(userId, {
displayName,
contact.emails[0].value,
JSON.stringify(contact.emails),
+ isAuto,
+ publishIntent,
]);
return { ...row, uid, card };
}
@@ -842,7 +929,7 @@ async function seedTwoRemoteBooks(fixture, userId) {
nextToken: 'two-book-b-1',
});
expect(await carddavSync.syncUser(userId)).toMatchObject({
- ok: true, bookCount: 2, contactCount: 2,
+ ok: true, bookCount: 2, contactCount: 1,
});
const state = await projectionState(userId);
return {
@@ -975,11 +1062,186 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
}
}, 120_000);
+ // Both halves of the sweep's contract in one pass. An explicit local contact
+ // still exports automatically — the bidirectional spec's "existing explicit
+ // contacts synchronize automatically" invariant, untouched by Slice 6 — while
+ // a harvested contact sitting right beside it is never swept out to the
+ // shared book. Only a deliberate promotion makes it explicit.
+ it('sweeps an explicit unmapped contact to the write-target and never a harvested one', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, seeded.userId);
+ const explicit = await seedUnmappedContact(seeded.userId, {
+ displayName: 'Explicit Sweep',
+ });
+ const harvested = await seedUnmappedContact(seeded.userId, {
+ displayName: 'Harvested Sweep',
+ isAuto: true,
+ });
+ fixture.queueSync('', { events: [], nextToken: 'sweep-token' });
+
+ try {
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true,
+ exportFailures: [],
+ });
+
+ const put = fixture.requests.filter(request => request.method === 'PUT');
+ expect(put).toHaveLength(1);
+ expect(put[0].body).toContain('FN:Explicit Sweep');
+ expect(put[0].path).toBe(`/addressbooks/fixture-user/contacts/${explicit.uid}.vcf`);
+
+ const { rows: autoFlags } = await databaseClient.query(
+ 'SELECT id, is_auto FROM contacts WHERE user_id = $1 ORDER BY display_name',
+ [seeded.userId],
+ );
+ expect(autoFlags).toEqual([
+ { id: explicit.id, is_auto: false },
+ { id: harvested.id, is_auto: true },
+ ]);
+ const projection = await projectionState(seeded.userId);
+ expect(projection.ledger.map(row => row.local_contact_id)).toEqual([explicit.id]);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // send.js flips is_auto to false for anyone the user emails — upstream behavior
+ // the autocomplete ranking in routes/search.js depends on, so it stays. That
+ // makes is_auto the wrong gate for *publishing*: replying to a harvested sender
+ // would silently push them into the user's shared address book. Publication is
+ // gated on carddav_publish_intent instead, which only a deliberate act sets.
+ //
+ // The contact is is_auto = false and unmapped, so the old is_auto-only sweep
+ // would export it. Its false publication intent is the only thing keeping it
+ // local while the setting is off.
+ it('never publishes a contact that became explicit only by being emailed', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, seeded.userId);
+ const emailed = await seedUnmappedContact(seeded.userId, {
+ displayName: 'Emailed Sender',
+ isAuto: false,
+ publishIntent: false,
+ });
+ fixture.queueSync('', { events: [], nextToken: 'publish-off-token' });
+
+ try {
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true,
+ exportFailures: [],
+ });
+
+ const put = fixture.requests.filter(request => request.method === 'PUT');
+ expect(put).toHaveLength(0);
+
+ const projection = await projectionState(seeded.userId);
+ expect(projection.ledger).toHaveLength(0);
+ // Unpublished, but still an explicit contact for autocomplete's purposes.
+ const { rows: [row] } = await databaseClient.query(
+ 'SELECT is_auto, carddav_publish_intent FROM contacts WHERE id = $1',
+ [emailed.id],
+ );
+ expect(row).toEqual({ is_auto: false, carddav_publish_intent: false });
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // The opt-in: the "dedicated Email contacts book" workflow, where emailing
+ // someone is itself the act of adding them.
+ it('publishes an emailed contact when publishEmailedContacts is enabled', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture, { publishEmailedContacts: true });
+ await seedWriteTargetBook(fixture, seeded.userId);
+ const emailed = await seedUnmappedContact(seeded.userId, {
+ displayName: 'Emailed Sender',
+ isAuto: false,
+ publishIntent: false,
+ });
+ const harvested = await seedUnmappedContact(seeded.userId, {
+ displayName: 'Harvested Sender',
+ isAuto: true,
+ });
+ fixture.queueSync('', { events: [], nextToken: 'publish-on-token' });
+
+ try {
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true,
+ exportFailures: [],
+ });
+
+ // The setting widens publication to emailed contacts only — a contact that
+ // was merely harvested from an inbound header is still not the user's.
+ const put = fixture.requests.filter(request => request.method === 'PUT');
+ expect(put).toHaveLength(1);
+ expect(put[0].path).toBe(`/addressbooks/fixture-user/contacts/${emailed.uid}.vcf`);
+ expect(put[0].body).toContain('FN:Emailed Sender');
+
+ const projection = await projectionState(seeded.userId);
+ expect(projection.ledger.map(row => row.local_contact_id)).toEqual([emailed.id]);
+ const { rows: [writeTarget] } = await databaseClient.query(`
+ SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true
+ `, [seeded.userId]);
+ expect(projection.ledger[0].address_book_id).toBe(writeTarget.id);
+ const { rows: [row] } = await databaseClient.query(
+ 'SELECT is_auto FROM contacts WHERE id = $1',
+ [harvested.id],
+ );
+ expect(row).toEqual({ is_auto: true });
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
+ // A contact typed into MailFlow while CardDAV was disconnected is the plainest
+ // possible deliberate act, so connecting an account publishes it — the setting
+ // governs emailed contacts, never ones the user actually created.
+ it('publishes a manually created contact on connect with the setting off', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const userId = await seedUser();
+ const created = await carddavContactService.createContact(userId, {
+ displayName: 'Hand Written',
+ emails: [{ value: 'hand-written@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ });
+ const seeded = await connectSeededUser(fixture, userId);
+ await seedWriteTargetBook(fixture, seeded.userId);
+ fixture.queueSync('', { events: [], nextToken: 'manual-create-token' });
+
+ try {
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true,
+ exportFailures: [],
+ });
+
+ const put = fixture.requests.filter(request => request.method === 'PUT');
+ expect(put).toHaveLength(1);
+ expect(put[0].body).toContain('FN:Hand Written');
+ expect(put[0].path).toBe(`/addressbooks/fixture-user/contacts/${created.uid}.vcf`);
+
+ const projection = await projectionState(seeded.userId);
+ expect(projection.ledger.map(row => row.local_contact_id)).toEqual([created.id]);
+ } finally {
+ await fixture.close();
+ }
+ }, 120_000);
+
it('retains a fresh export Retry-After instead of clearing it from stale sync config', async () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
const seeded = await seedConnectedUser(fixture);
- const local = await seedUnmappedExplicitContact(seeded.userId, {
+ await seedWriteTargetBook(fixture, seeded.userId);
+ const local = await seedUnmappedContact(seeded.userId, {
displayName: 'Fresh Export Throttle',
});
await databaseClient.query(`
@@ -1062,6 +1324,7 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
const seeded = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, seeded.userId);
const before = await failureBoundaryState(seeded.userId);
fixture.queueWrite('PUT', {
status: 429,
@@ -2141,6 +2404,7 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
}, 120_000);
async function createPushOriginContact(fixture, userId, draft) {
+ await seedWriteTargetBook(fixture, userId);
const created = await carddavContactService.createContact(userId, {
firstName: null,
lastName: null,
@@ -3143,9 +3407,13 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
ok: false,
error: 'forced second count book failure',
});
- expect(failedState.contacts).toHaveLength(1);
+ // Book A (the fresh connect's write-target) materialized person-a and its
+ // removal committed, leaving no materialized contacts; book B is the
+ // lookup-only sibling (person-b lives in the ledger only), and its failed
+ // apply rolled back, so its lone lookup row survives.
+ expect(failedState.contacts).toHaveLength(0);
expect(failedState.ledger).toHaveLength(1);
- expect(failedIntegration.config.contactCount).toBe(1);
+ expect(failedIntegration.config.contactCount).toBe(0);
fixture.reset();
fixture.queueDiscovery({ books: [
@@ -3162,10 +3430,12 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
const retriedState = await projectionState(seeded.userId);
const [retriedIntegration] = await integrationState(seeded.userId);
- expect(retried).toMatchObject({ ok: true, contactCount: 1 });
- expect(retriedState.contacts).toHaveLength(1);
+ expect(retried).toMatchObject({ ok: true, contactCount: 0 });
+ // Book A stays empty; book B's retried lookup apply refreshes its single
+ // ledger row (person-b-2) without materializing a contact.
+ expect(retriedState.contacts).toHaveLength(0);
expect(retriedState.ledger).toHaveLength(1);
- expect(retriedIntegration.config.contactCount).toBe(1);
+ expect(retriedIntegration.config.contactCount).toBe(0);
await fixture.close();
}, 120_000);
@@ -3192,119 +3462,1139 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
await fixture.close();
}, 120_000);
- it('snapshot discovery without sync-collection sends exactly one CardDAV filter', async () => {
+ it('claims the write-target for the best create-capable book of a fresh connect, not whichever is discovered first', async () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
const seeded = await seedConnectedUser(fixture);
- const href = fixture.href('snapshot-only.vcf');
- const card = remoteVcard('snapshot-only', 'Snapshot Only');
- fixture.putContact(href, '"snapshot-only-1"', card);
- fixture.queueDiscovery({ books: [{
- href: '/addressbooks/fixture-user/contacts/',
- displayName: 'Snapshot Only',
- reports: false,
- }] });
-
- expect(await carddavSync.syncUser(seeded.userId)).toEqual({
- ok: true,
- bookCount: 1,
- contactCount: 1,
- remote: 1,
- fetched: 1,
- updated: 1,
- removed: 0,
- fallback: 1,
- exportFailures: [],
+ const unknownPath = '/addressbooks/fixture-user/unknown-first/';
+ const allowedPath = '/addressbooks/fixture-user/allowed-second/';
+ const unknownUrl = new URL(unknownPath, fixture.serverUrl).href;
+ const allowedUrl = new URL(allowedPath, fixture.serverUrl).href;
+ const unknownHref = new URL('unknown-first.vcf', unknownUrl).href;
+ const allowedHref = new URL('allowed-second.vcf', allowedUrl).href;
+ fixture.queueDiscovery({ books: [
+ // Discovered *first*, but with unconfirmed ('unknown') create
+ // capability — claiming per book, in discovery order (the bug this
+ // proves fixed) would let this one keep the write-target forever.
+ { href: unknownPath, displayName: 'Unknown First', privileges: false },
+ // Discovered *second*, with confirmed ('allowed') create capability —
+ // the design's capability-priority backfill rule requires this one to
+ // win instead.
+ { href: allowedPath, displayName: 'Allowed Second' },
+ ] });
+ fixture.putContact(
+ unknownHref, '"unknown-first-1"', remoteVcard('unknown-first', 'Unknown First'),
+ );
+ fixture.putContact(
+ allowedHref, '"allowed-second-1"', remoteVcard('allowed-second', 'Allowed Second'),
+ );
+ fixture.queueSync('', {
+ events: [{ href: unknownHref, etag: '"unknown-first-1"' }],
+ nextToken: 'unknown-first-token',
});
- const state = await projectionState(seeded.userId);
- expect(state.books).toHaveLength(1);
- expect(state.books[0]).toMatchObject({
- source: 'carddav',
- remote_sync_token: null,
- remote_sync_capability: 'snapshot',
- remote_sync_revision: '1',
+ fixture.queueSync('', {
+ events: [{ href: allowedHref, etag: '"allowed-second-1"' }],
+ nextToken: 'allowed-second-token',
});
- expect(state.contacts).toHaveLength(1);
- expect(state.contacts[0].primary_email).toBe('snapshot-only@example.test');
- expect(state.ledger).toHaveLength(1);
- expect(fixture.counters).toMatchObject({
- requests: 4,
- propfind: 3,
- sync: 0,
- multiget: 0,
- addressbookQuery: 1,
- snapshotFilters: [1],
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 2, contactCount: 1,
});
+
+ const { rows: roles } = await databaseClient.query(`
+ SELECT external_url, is_write_target, is_subscribed, remote_create_capability
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY external_url
+ `, [seeded.userId]);
+ expect(roles).toEqual([
+ {
+ external_url: allowedUrl,
+ is_write_target: true,
+ is_subscribed: true,
+ remote_create_capability: 'allowed',
+ },
+ {
+ external_url: unknownUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ remote_create_capability: 'unknown',
+ },
+ ]);
await fixture.close();
}, 120_000);
- it('valid empty discovery prunes projection while malformed empty discovery changes only status', async () => {
- const emptyFixture = createCarddavFixtureServer();
- await emptyFixture.listen();
- const emptySeeded = await seedConnectedUser(emptyFixture);
- await seedSingleRemoteContact(emptyFixture, emptySeeded.userId);
- const unrelatedBook = await databaseClient.query(`
- INSERT INTO address_books (user_id, name)
- VALUES ($1, 'Empty Discovery Unrelated') RETURNING id, sync_token
- `, [emptySeeded.userId]);
- emptyFixture.reset();
- emptyFixture.queueDiscovery({ books: [] });
+ // ── Multi-book Slice 3: split pull-from-materialize ─────────────────────────
- expect(await carddavSync.syncUser(emptySeeded.userId)).toEqual({
- ok: true,
- bookCount: 0,
- contactCount: 0,
- remote: 0,
- fetched: 0,
- updated: 0,
- removed: 0,
- fallback: 0,
- exportFailures: [],
- });
- expect(await projectionState(emptySeeded.userId)).toEqual({
- books: [{
- id: unrelatedBook.rows[0].id,
- source: 'local',
- external_url: null,
- sync_token: unrelatedBook.rows[0].sync_token,
- remote_sync_token: null,
- remote_sync_capability: 'unknown',
- remote_sync_revision: '0',
- remote_projection_fingerprint: null,
- }],
- contacts: [],
- ledger: [],
- });
- const [emptyIntegration] = await integrationState(emptySeeded.userId);
- expect(emptyIntegration.config).toEqual({
- ...emptySeeded.config,
- lastError: null,
- lastSyncAt: expect.any(String),
- bookCount: 0,
- contactCount: 0,
- exportFailures: [],
+ async function bookRoles(userId) {
+ const { rows } = await databaseClient.query(`
+ SELECT external_url, is_write_target, is_subscribed, is_lookup_source
+ FROM address_books WHERE user_id = $1 AND source = 'carddav'
+ ORDER BY external_url
+ `, [userId]);
+ return rows;
+ }
+
+ async function ledgerRows(userId) {
+ const { rows } = await databaseClient.query(`
+ SELECT b.external_url, o.href, o.mapping_status, o.local_contact_id,
+ o.primary_email, o.lookup_display_name
+ FROM carddav_remote_objects o
+ JOIN address_books b ON b.id = o.address_book_id
+ WHERE b.user_id = $1
+ ORDER BY b.external_url, o.href
+ `, [userId]);
+ return rows;
+ }
+
+ it('first connect subscribes and materializes the write-target while a sibling defaults to lookup-only', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const primaryPath = '/addressbooks/fixture-user/primary/';
+ const applePath = '/addressbooks/fixture-user/apple/';
+ const primaryUrl = new URL(primaryPath, fixture.serverUrl).href;
+ const appleUrl = new URL(applePath, fixture.serverUrl).href;
+ const primaryHref = new URL('primary.vcf', primaryUrl).href;
+ const appleHref = new URL('apple.vcf', appleUrl).href;
+ fixture.queueDiscovery({ books: [
+ { href: primaryPath, displayName: 'Primary' },
+ { href: applePath, displayName: 'Apple Contacts' },
+ ] });
+ fixture.putContact(primaryHref, '"primary-1"', remoteVcard('primary-person', 'Primary Person'));
+ fixture.putContact(appleHref, '"apple-1"', remoteVcard('apple-person', 'Apple Person'));
+ fixture.queueSync('', {
+ events: [{ href: primaryHref, etag: '"primary-1"' }],
+ nextToken: 'primary-token',
});
- expect(emptyFixture.counters).toMatchObject({
- requests: 3,
- propfind: 3,
- sync: 0,
- multiget: 0,
- addressbookQuery: 0,
+ fixture.queueSync('', {
+ events: [{ href: appleHref, etag: '"apple-1"' }],
+ nextToken: 'apple-token',
});
- await emptyFixture.close();
- const malformedFixture = createCarddavFixtureServer();
- await malformedFixture.listen();
- const malformedSeeded = await seedConnectedUser(malformedFixture);
- await seedSingleRemoteContact(malformedFixture, malformedSeeded.userId);
- const malformedBefore = await projectionState(malformedSeeded.userId, { timestamps: true });
- malformedFixture.reset();
- malformedFixture.queueDiscovery({
- rawBody: ' ',
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 2, contactCount: 1,
});
- const malformed = await carddavSync.syncUser(malformedSeeded.userId);
-
+ // Both books are create-capable, so the first discovered wins the
+ // write-target + subscribe; the sibling defaults to lookup-only.
+ expect(await bookRoles(seeded.userId)).toEqual([
+ { external_url: appleUrl, is_write_target: false, is_subscribed: false, is_lookup_source: true },
+ { external_url: primaryUrl, is_write_target: true, is_subscribed: true, is_lookup_source: true },
+ ]);
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(1);
+ expect(state.contacts[0].primary_email).toBe('primary-person@example.test');
+ expect(state.contacts[0].address_book_id).toBe(
+ state.books.find(book => book.external_url === primaryUrl).id,
+ );
+ // The lookup sibling retains a ledger row with no local contact; the
+ // write-target keeps its ordinary synced, linked mapping.
+ expect(await ledgerRows(seeded.userId)).toEqual([
+ {
+ external_url: appleUrl,
+ href: appleHref,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'apple-person@example.test',
+ lookup_display_name: 'Apple Person',
+ },
+ {
+ external_url: primaryUrl,
+ href: primaryHref,
+ mapping_status: 'synced',
+ local_contact_id: state.contacts[0].id,
+ primary_email: 'primary-person@example.test',
+ lookup_display_name: null,
+ },
+ ]);
+ await fixture.close();
+ }, 120_000);
+
+ it('bootstraps a fresh single-book connection so a create works with no manual role assignment', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ // A brand-new connection with NO write-target designated (no seedWriteTargetBook).
+ const seeded = await seedConnectedUser(fixture);
+ fixture.queueSync('', { events: [], nextToken: 'bootstrap-token' });
+
+ // The first discovery auto-assigns the single create-capable book as
+ // write-target + subscribed — the design's zero-config promise.
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, bookCount: 1 });
+ const { rows: [bootstrapped] } = await databaseClient.query(
+ `SELECT id, is_write_target, is_subscribed, is_lookup_source
+ FROM address_books WHERE user_id = $1 AND source = 'carddav'`,
+ [seeded.userId],
+ );
+ expect(bootstrapped).toMatchObject({
+ is_write_target: true, is_subscribed: true, is_lookup_source: true,
+ });
+
+ // A create now succeeds and lands in the auto-assigned write-target, even
+ // though the user never assigned a role manually.
+ const created = await carddavContactService.createContact(seeded.userId, {
+ displayName: 'Zero Config',
+ firstName: 'Zero',
+ lastName: 'Config',
+ emails: [{ value: 'zero-config@example.test', type: 'work', primary: true }],
+ phones: [],
+ organization: null,
+ notes: null,
+ photoData: null,
+ additionalFields: [],
+ });
+ expect(created.id).toBeTruthy();
+ const { rows: [mapping] } = await databaseClient.query(
+ `SELECT address_book_id, mapping_status
+ FROM carddav_remote_objects WHERE local_contact_id = $1`,
+ [created.id],
+ );
+ expect(mapping).toMatchObject({
+ address_book_id: bootstrapped.id,
+ mapping_status: 'synced',
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('pulls a lookup-only book into the ledger with mapping_status lookup and zero contacts', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const lookupUrl = fixture.href('');
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'denied', 'denied', 'denied', false, false, true)
+ `, [seeded.userId, lookupUrl]);
+ const href = fixture.href('apple-only.vcf');
+ fixture.putContact(href, '"apple-only-1"', remoteVcard('apple-only', 'Apple Only'));
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ fixture.queueSync('', {
+ events: [{ href, etag: '"apple-only-1"' }],
+ nextToken: 'apple-only-token',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(0);
+ expect(await ledgerRows(seeded.userId)).toEqual([{
+ external_url: lookupUrl,
+ href,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'apple-only@example.test',
+ lookup_display_name: 'Apple Only',
+ }]);
+ // A read-only lookup-only book is never promoted to the write-target.
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: lookupUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: true,
+ }]);
+ await fixture.close();
+ }, 120_000);
+
+ it('materializes a lookup-only book after it is subscribed via the role route', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const lookupUrl = fixture.href('');
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'denied', 'denied', 'denied', false, false, true)
+ `, [seeded.userId, lookupUrl]);
+ const href = fixture.href('apple-only.vcf');
+ fixture.putContact(href, '"apple-only-1"', remoteVcard('apple-only', 'Apple Only'));
+ const discovery = { books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] };
+ fixture.queueDiscovery(discovery);
+ fixture.queueSync('', { events: [{ href, etag: '"apple-only-1"' }], nextToken: 'apple-token-1' });
+
+ // First sync leaves the book lookup-only: ledger row, no materialized contact.
+ await carddavSync.syncUser(seeded.userId);
+ expect((await projectionState(seeded.userId)).contacts).toHaveLength(0);
+ const { rows: [book] } = await databaseClient.query(
+ "SELECT id FROM address_books WHERE user_id = $1 AND source = 'carddav'",
+ [seeded.userId],
+ );
+
+ // Subscribing resets the book's pull token so the next sync re-pulls in full.
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, book.id, { isSubscribed: true }, seeded.connectionGeneration,
+ );
+ fixture.queueDiscovery(discovery);
+ fixture.queueSync('', { events: [{ href, etag: '"apple-only-1"' }], nextToken: 'apple-token-2' });
+ await carddavSync.syncUser(seeded.userId);
+
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(1);
+ expect(state.contacts[0].primary_email).toBe('apple-only@example.test');
+ expect(state.contacts[0].address_book_id).toBe(book.id);
+ const { rows: [ledger] } = await databaseClient.query(
+ `SELECT mapping_status, local_contact_id
+ FROM carddav_remote_objects WHERE address_book_id = $1`,
+ [book.id],
+ );
+ expect(ledger).toEqual({ mapping_status: 'synced', local_contact_id: state.contacts[0].id });
+ await fixture.close();
+ }, 120_000);
+
+ it('updates and tombstones lookup ledger rows on a later incremental sync without materializing', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const lookupUrl = fixture.href('');
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'denied', 'denied', 'denied', false, false, true)
+ `, [seeded.userId, lookupUrl]);
+ const hrefA = fixture.href('lookup-a.vcf');
+ const hrefB = fixture.href('lookup-b.vcf');
+ fixture.putContact(hrefA, '"lookup-a-1"', remoteVcard('lookup-a', 'Lookup A'));
+ fixture.putContact(hrefB, '"lookup-b-1"', remoteVcard('lookup-b', 'Lookup B'));
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ fixture.queueSync('', {
+ events: [{ href: hrefA, etag: '"lookup-a-1"' }, { href: hrefB, etag: '"lookup-b-1"' }],
+ nextToken: 'lookup-token-1',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+ expect(await ledgerRows(seeded.userId)).toHaveLength(2);
+ const { rows: [rowBBefore] } = await databaseClient.query(
+ 'SELECT mapping_revision FROM carddav_remote_objects WHERE href = $1',
+ [hrefB],
+ );
+
+ // A later incremental sync: lookup-b's card changed (new name + email + etag)
+ // and lookup-a was deleted server-side. The changed row is re-projected and
+ // the deleted one tombstoned — still ledger-only, still zero contacts.
+ fixture.putContact(hrefB, '"lookup-b-2"', remoteVcard('lookup-b', 'Lookup B Renamed', 'lookup-b2@example.test'));
+ fixture.deleteContact(hrefA);
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ fixture.queueSync('lookup-token-1', {
+ events: [{ href: hrefB, etag: '"lookup-b-2"' }, { href: hrefA, status: 404 }],
+ nextToken: 'lookup-token-2',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(0);
+ expect(await ledgerRows(seeded.userId)).toEqual([{
+ external_url: lookupUrl,
+ href: hrefB,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'lookup-b2@example.test',
+ lookup_display_name: 'Lookup B Renamed',
+ }]);
+ // The changed row was rewritten (revision bumped); the book stays lookup-only.
+ const { rows: [rowB] } = await databaseClient.query(
+ 'SELECT mapping_revision FROM carddav_remote_objects WHERE href = $1',
+ [hrefB],
+ );
+ expect(Number(rowB.mapping_revision)).toBeGreaterThan(Number(rowBBefore.mapping_revision));
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: lookupUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: true,
+ }]);
+ await fixture.close();
+ }, 120_000);
+
+ it('leaves lookup rows outside an incremental delta untouched and correctly counted', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const lookupUrl = fixture.href('');
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'denied', 'denied', 'denied', false, false, true)
+ `, [seeded.userId, lookupUrl]);
+ const hrefA = fixture.href('lookup-a.vcf');
+ const hrefB = fixture.href('lookup-b.vcf');
+ const hrefC = fixture.href('lookup-c.vcf');
+ fixture.putContact(hrefA, '"lookup-a-1"', remoteVcard('lookup-a', 'Lookup A'));
+ fixture.putContact(hrefB, '"lookup-b-1"', remoteVcard('lookup-b', 'Lookup B'));
+ fixture.putContact(hrefC, '"lookup-c-1"', remoteVcard('lookup-c', 'Lookup C'));
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ fixture.queueSync('', {
+ events: [
+ { href: hrefA, etag: '"lookup-a-1"' },
+ { href: hrefB, etag: '"lookup-b-1"' },
+ { href: hrefC, etag: '"lookup-c-1"' },
+ ],
+ nextToken: 'lookup-token-1',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+ const { rows: [rowCBefore] } = await databaseClient.query(
+ 'SELECT mapping_revision FROM carddav_remote_objects WHERE href = $1',
+ [hrefC],
+ );
+
+ // A later incremental sync touches only lookup-b (changed) and lookup-a
+ // (deleted). lookup-c is absent from the delta, so it must be neither
+ // re-projected nor miscounted even though the projection no longer scans
+ // and locks the whole book — the cached count still drops by exactly one.
+ fixture.putContact(hrefB, '"lookup-b-2"', remoteVcard('lookup-b', 'Lookup B Renamed', 'lookup-b2@example.test'));
+ fixture.deleteContact(hrefA);
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ fixture.queueSync('lookup-token-1', {
+ events: [{ href: hrefB, etag: '"lookup-b-2"' }, { href: hrefA, status: 404 }],
+ nextToken: 'lookup-token-2',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(0);
+ expect(await ledgerRows(seeded.userId)).toEqual([
+ {
+ external_url: lookupUrl,
+ href: hrefB,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'lookup-b2@example.test',
+ lookup_display_name: 'Lookup B Renamed',
+ },
+ {
+ external_url: lookupUrl,
+ href: hrefC,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'lookup-c@example.test',
+ lookup_display_name: 'Lookup C',
+ },
+ ]);
+ // The untouched row was not rewritten — its mapping_revision holds steady.
+ const { rows: [rowCAfter] } = await databaseClient.query(
+ 'SELECT mapping_revision FROM carddav_remote_objects WHERE href = $1',
+ [hrefC],
+ );
+ expect(Number(rowCAfter.mapping_revision)).toBe(Number(rowCBefore.mapping_revision));
+ await fixture.close();
+ }, 120_000);
+
+ it('skips an ignored book at the network layer, pulling nothing and retaining its row', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const ignoredUrl = fixture.href('');
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Ignored Book', 'carddav', $2, 'denied', 'denied', 'denied', false, false, false)
+ `, [seeded.userId, ignoredUrl]);
+ fixture.putContact(fixture.href('ignored.vcf'), '"ignored-1"', remoteVcard('ignored', 'Ignored'));
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Ignored Book', privileges: [] },
+ ] });
+ // No queueSync: an ignored book must never issue a sync REPORT.
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+
+ expect(fixture.counters.sync).toBe(0);
+ expect(fixture.counters.multiget).toBe(0);
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(0);
+ expect(await ledgerRows(seeded.userId)).toEqual([]);
+ // The row (and its ignored roles) survives, rather than being reconciled
+ // away and re-discovered as lookup-only next sync.
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: ignoredUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: false,
+ }]);
+ await fixture.close();
+ }, 120_000);
+
+ it('drops a newly ignored book\'s retained ledger rows on the next sync', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ // A lookup-only book with a retained ledger, as the previous sync left it.
+ const ignoredUrl = fixture.href('');
+ const { rows: [ignored] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, remote_sync_token,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'apple-token',
+ 'denied', 'denied', 'denied', false, false, true)
+ RETURNING id
+ `, [seeded.userId, ignoredUrl]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ mapping_status, lookup_display_name
+ ) VALUES ($1, $2, '"lookup-1"', $3, 'lookup-person@example.test',
+ 'lookup', 'Lookup Person')
+ `, [ignored.id, fixture.href('lookup.vcf'), remoteVcard('lookup-person', 'Lookup Person')]);
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = config || jsonb_build_object('contactCount', 1)
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId]);
+
+ // The user switches Look-up-senders off on the unsubscribed book: ignored.
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, ignored.id, { isLookupSource: false }, seeded.connectionGeneration,
+ );
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ // No queueSync: an ignored book must never issue a sync REPORT.
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+
+ // Not pulled, its retained ledger dropped, its row (and roles) kept, and the
+ // cached count no longer carries the rows it no longer serves.
+ expect(fixture.counters.sync).toBe(0);
+ expect(await ledgerRows(seeded.userId)).toEqual([]);
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: ignoredUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: false,
+ }]);
+ const [integration] = await integrationState(seeded.userId);
+ expect(integration.config).toMatchObject({ contactCount: 0 });
+ await fixture.close();
+ }, 120_000);
+
+ it('repopulates the ledger when Look-up-senders is switched back on', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const bookUrl = fixture.href('');
+ const lookupHref = fixture.href('lookup.vcf');
+ const lookupCard = remoteVcard('lookup-person', 'Lookup Person', 'lookup-person@example.test');
+ const { rows: [book] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, remote_sync_token,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'apple-token',
+ 'denied', 'denied', 'denied', false, false, true)
+ RETURNING id
+ `, [seeded.userId, bookUrl]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ mapping_status, lookup_display_name
+ ) VALUES ($1, $2, '"lookup-1"', $3, 'lookup-person@example.test',
+ 'lookup', 'Lookup Person')
+ `, [book.id, lookupHref, lookupCard]);
+
+ // Ignored, then synced: the book is skipped and its retained ledger dropped.
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, book.id, { isLookupSource: false }, seeded.connectionGeneration,
+ );
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+ expect(await ledgerRows(seeded.userId)).toEqual([]);
+
+ // The user switches Look-up-senders back on. The book's ledger is gone, so an
+ // *incremental* delta from the token it still carries would resolve nobody
+ // forever — the pull that repopulates it has to be a full one.
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, book.id, { isLookupSource: true }, seeded.connectionGeneration,
+ );
+ fixture.reset();
+ fixture.putContact(lookupHref, '"lookup-1"', lookupCard);
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ // The stale token still resolves, and reports nothing new: a book that
+ // resumed from it would stay empty.
+ fixture.queueSync('apple-token', { events: [], nextToken: 'apple-token' });
+ fixture.queueSync('', {
+ events: [{ href: lookupHref, etag: '"lookup-1"' }],
+ nextToken: 'apple-2',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // Pulled in full, back into the ledger — lookup-only, so no contact is
+ // materialized and the book stays unsubscribed.
+ expect(await ledgerRows(seeded.userId)).toEqual([{
+ external_url: bookUrl,
+ href: lookupHref,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'lookup-person@example.test',
+ lookup_display_name: 'Lookup Person',
+ }]);
+ expect((await projectionState(seeded.userId)).contacts).toEqual([]);
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: bookUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: true,
+ }]);
+ await fixture.close();
+ }, 120_000);
+
+ it('keeps the ledger of a book re-enabled while the sync that would drop it ran', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const writeTargetUrl = await seedWriteTargetBook(fixture, seeded.userId);
+ // A lookup-only book with a ledger, discovered *after* the write-target so the
+ // sync's book loop reaches it only once the write-target's pull has returned.
+ const applePath = '/addressbooks/fixture-user/apple/';
+ const appleUrl = new URL(applePath, fixture.serverUrl).href;
+ const appleHref = new URL('lookup.vcf', appleUrl).href;
+ const { rows: [apple] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, remote_sync_token,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'apple-token',
+ 'denied', 'denied', 'denied', false, false, true)
+ RETURNING id
+ `, [seeded.userId, appleUrl]);
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ mapping_status, lookup_display_name
+ ) VALUES ($1, $2, '"lookup-1"', $3, 'lookup-person@example.test',
+ 'lookup', 'Lookup Person')
+ `, [apple.id, appleHref, remoteVcard('lookup-person', 'Lookup Person')]);
+ await databaseClient.query(`
+ UPDATE user_integrations
+ SET config = config || jsonb_build_object('contactCount', 1)
+ WHERE user_id = $1 AND provider = 'carddav'
+ `, [seeded.userId]);
+
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, apple.id, { isLookupSource: false }, seeded.connectionGeneration,
+ );
+
+ // A sync starts and snapshots the roles: Apple is ignored, so its ledger is
+ // due to be dropped once the loop gets there. Hold it inside the
+ // write-target's pull, the network call that runs first.
+ const barrier = deferred();
+ const reached = deferred();
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' },
+ { href: applePath, displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ fixture.queueSync('', {
+ events: [],
+ nextToken: 'write-target-1',
+ waitFor: barrier.promise,
+ reached: reached.resolve,
+ });
+ const pending = carddavSync.syncUser(seeded.userId);
+ await reached.promise;
+
+ // Mid-flight, the user switches Look-up-senders back on. The drop that is
+ // about to run carries the pre-patch 'ignored' classification — it must read
+ // the live roles under its lock, not act on that stale decision.
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, apple.id, { isLookupSource: true }, seeded.connectionGeneration,
+ );
+ barrier.resolve();
+ expect(await pending).toMatchObject({ ok: true });
+
+ // The ledger the user just re-enabled survives; lookup-only rows do not
+ // contribute to the materialized contact count.
+ expect(await ledgerRows(seeded.userId)).toEqual([{
+ external_url: appleUrl,
+ href: appleHref,
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ primary_email: 'lookup-person@example.test',
+ lookup_display_name: 'Lookup Person',
+ }]);
+ expect(await bookRoles(seeded.userId)).toEqual([
+ {
+ external_url: appleUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: true,
+ },
+ {
+ external_url: writeTargetUrl,
+ is_write_target: true,
+ is_subscribed: true,
+ is_lookup_source: true,
+ },
+ ]);
+ const [integration] = await integrationState(seeded.userId);
+ expect(integration.config).toMatchObject({ contactCount: 0 });
+ await fixture.close();
+ }, 120_000);
+
+ it('leaves the connection without a write-target rather than claiming an ignored book', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const writeTargetUrl = await seedWriteTargetBook(fixture, seeded.userId);
+ // The only other book is one the user deliberately excluded — create-capable,
+ // so the pre-fix ranking would happily hand it the write-target.
+ const ignoredPath = '/addressbooks/fixture-user/apple/';
+ const ignoredUrl = new URL(ignoredPath, fixture.serverUrl).href;
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed',
+ false, false, false)
+ `, [seeded.userId, ignoredUrl]);
+
+ // The server deletes the write-target book: discovery stops advertising it, so
+ // this sync's stale-book cleanup drops its row and the user has no write-target.
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: ignoredPath, displayName: 'Apple Contacts' },
+ ] });
+ // No queueSync: the ignored book is still skipped at the network layer.
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // The excluded book is left exactly as the user set it — not promoted, not
+ // silently re-subscribed — and the deleted book's row is gone.
+ expect(fixture.counters.sync).toBe(0);
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: ignoredUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: false,
+ }]);
+ const { rows: written } = await databaseClient.query(`
+ SELECT external_url FROM address_books WHERE user_id = $1 AND external_url = $2
+ `, [seeded.userId, writeTargetUrl]);
+ expect(written).toEqual([]);
+
+ // Every remaining book was explicitly excluded, so there is nowhere honest to
+ // put a new contact: creates fail with the typed error the UI already surfaces
+ // rather than landing in a book the user turned off.
+ await expect(carddavContactService.createContact(seeded.userId, {
+ displayName: 'Nowhere To Go',
+ emails: [{ value: 'nowhere@example.test' }],
+ })).rejects.toMatchObject({ code: 'ERR_CARDDAV_NO_WRITE_TARGET' });
+ await fixture.close();
+ }, 120_000);
+
+ it('claims a non-ignored book over a better-ranked ignored one', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, seeded.userId);
+ // The ignored book outranks the newcomer on every axis the claim used to sort
+ // by: create-capable, advertised first, and the older row.
+ const ignoredPath = '/addressbooks/fixture-user/apple/';
+ const ignoredUrl = new URL(ignoredPath, fixture.serverUrl).href;
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, created_at,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, NOW() - interval '1 day',
+ 'allowed', 'allowed', 'allowed', false, false, false)
+ `, [seeded.userId, ignoredUrl]);
+ const freshPath = '/addressbooks/fixture-user/fresh/';
+ const freshUrl = new URL(freshPath, fixture.serverUrl).href;
+ const freshHref = new URL('fresh-person.vcf', freshUrl).href;
+ const freshCard = remoteVcard('fresh-person', 'Fresh Person');
+
+ // The write-target book is deleted server-side; the ignored book and a
+ // brand-new book are all that is left.
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: ignoredPath, displayName: 'Apple Contacts' },
+ { href: freshPath, displayName: 'Fresh Contacts' },
+ ] });
+ fixture.putContact(freshHref, '"fresh-1"', freshCard);
+ fixture.queueSync('', {
+ events: [{ href: freshHref, etag: '"fresh-1"' }],
+ nextToken: 'fresh-1',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // The newcomer takes the write-target and materializes in this same sync, and
+ // the ignored book keeps its roles — the claim and the pull agree on it.
+ expect(await bookRoles(seeded.userId)).toEqual([
+ {
+ external_url: ignoredUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: false,
+ },
+ {
+ external_url: freshUrl,
+ is_write_target: true,
+ is_subscribed: true,
+ is_lookup_source: true,
+ },
+ ]);
+ const { contacts } = await projectionState(seeded.userId);
+ expect(contacts.map(contact => contact.display_name)).toEqual(['Fresh Person']);
+ await fixture.close();
+ }, 120_000);
+
+ it('moves the write-target without ever writing to the outgoing book', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const outgoingUrl = await seedWriteTargetBook(fixture, seeded.userId);
+ const initial = await seedSingleRemoteContact(fixture, seeded.userId, {
+ uid: 'moved-target',
+ name: 'Before The Move',
+ token: 'move-1',
+ });
+ // The book the user is about to promote, discovered alongside the outgoing one.
+ const incomingPath = '/addressbooks/fixture-user/apple/';
+ const incomingUrl = new URL(incomingPath, fixture.serverUrl).href;
+ const { rows: [incoming] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed',
+ false, false, true)
+ RETURNING id
+ `, [seeded.userId, incomingUrl]);
+
+ // An edit was pushed to the outgoing book (still the write-target then) whose
+ // acknowledgement was lost: the PUT landed, the intent is still open.
+ const { rows: [before] } = await databaseClient.query(`
+ SELECT o.address_book_id, o.href, o.local_contact_hash, c.uid
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE c.user_id = $1 AND o.href = $2
+ `, [seeded.userId, initial.href]);
+ const attemptedVCard = remoteVcard(
+ before.uid,
+ 'Edited Before The Move',
+ 'moved-target@example.test',
+ );
+ fixture.putContact(initial.href, '"move-2"', attemptedVCard);
+ await databaseClient.query(`
+ UPDATE carddav_remote_objects SET
+ mapping_status = 'pending_push',
+ pending_operation = 'update', pending_vcard = $1,
+ pending_local_hash = $2, pending_remote_semantic_hash = $3,
+ pending_started_at = NOW(),
+ mapping_revision = mapping_revision + 1
+ WHERE address_book_id = $4 AND href = $5
+ `, [
+ attemptedVCard,
+ before.local_contact_hash,
+ semanticVCardHash(parseVCardDocument(attemptedVCard)),
+ before.address_book_id,
+ before.href,
+ ]);
+
+ await carddavSync.patchCarddavBookRoles(
+ seeded.userId, incoming.id, { makeWriteTarget: true }, seeded.connectionGeneration,
+ );
+
+ fixture.reset();
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' },
+ { href: incomingPath, displayName: 'Apple Contacts' },
+ ] });
+ fixture.queueSync('move-1', {
+ events: [{ href: initial.href, etag: '"move-2"' }],
+ nextToken: 'move-2',
+ });
+ fixture.queueSync('', { events: [], nextToken: 'apple-1' });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true });
+
+ // The outgoing book is a subscribed secondary now: the pending intent left on
+ // it is *observed* (one GET, single-PUT no-replay), never re-pushed, so the
+ // sync writes nothing to a book that is no longer the write-target.
+ expect(fixture.requests.filter(request => request.method === 'PUT')).toHaveLength(0);
+ expect(fixture.requests.filter(request => request.method === 'DELETE')).toHaveLength(0);
+ expect(await bookRoles(seeded.userId)).toEqual([
+ {
+ external_url: incomingUrl,
+ is_write_target: true,
+ is_subscribed: true,
+ is_lookup_source: true,
+ },
+ {
+ external_url: outgoingUrl,
+ is_write_target: false,
+ is_subscribed: true,
+ is_lookup_source: true,
+ },
+ ]);
+ const { rows: [recovered] } = await databaseClient.query(`
+ SELECT o.mapping_status, o.pending_operation, o.pending_vcard,
+ o.pending_local_hash, o.pending_started_at, c.display_name
+ FROM carddav_remote_objects o
+ JOIN contacts c ON c.id = o.local_contact_id
+ WHERE c.user_id = $1 AND o.href = $2
+ `, [seeded.userId, initial.href]);
+ expect(recovered).toMatchObject({
+ mapping_status: 'synced',
+ pending_operation: null,
+ pending_vcard: null,
+ pending_local_hash: null,
+ pending_started_at: null,
+ display_name: 'Edited Before The Move',
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('retains an ignored canonicalized book when discovery advertises only its alias', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const aliasPath = '/addressbooks/fixture-user/apple/';
+ const aliasUrl = new URL(aliasPath, fixture.serverUrl).href;
+ const canonicalUrl = new URL('/addressbooks/fixture-user/apple-canonical/', fixture.serverUrl).href;
+ // The book was canonicalized earlier: it is stored under its canonical URL
+ // while a fresh discovery keeps advertising the alias href.
+ await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, discovery_alias_url,
+ remote_create_capability, remote_update_capability, remote_delete_capability,
+ is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, $3, 'denied', 'denied', 'denied', false, false, false)
+ `, [seeded.userId, canonicalUrl, aliasUrl]);
+ fixture.queueDiscovery({ books: [
+ { href: aliasPath, displayName: 'Apple Contacts', privileges: [] },
+ ] });
+ // No queueSync: an ignored book must never issue a sync REPORT.
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 1, contactCount: 0,
+ });
+ expect(fixture.counters.sync).toBe(0);
+
+ // The ignored row is recorded seen by its external_url, not the fresh alias,
+ // so reconciliation keeps it (roles intact) rather than deleting it and
+ // re-discovering it as a pullable lookup-only book next sync.
+ expect(await bookRoles(seeded.userId)).toEqual([{
+ external_url: canonicalUrl,
+ is_write_target: false,
+ is_subscribed: false,
+ is_lookup_source: false,
+ }]);
+ const { rows: [row] } = await databaseClient.query(
+ `SELECT discovery_alias_url FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'`,
+ [seeded.userId],
+ );
+ expect(row.discovery_alias_url).toBe(aliasUrl);
+ await fixture.close();
+ }, 120_000);
+
+ it('defaults a newly discovered book to lookup-only beside an established write-target', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const writeUrl = await seedWriteTargetBook(fixture, seeded.userId);
+ const writeHref = fixture.href('primary.vcf');
+ const newPath = '/addressbooks/fixture-user/all-company/';
+ const newUrl = new URL(newPath, fixture.serverUrl).href;
+ const newHref = new URL('company.vcf', newUrl).href;
+ fixture.putContact(writeHref, '"primary-1"', remoteVcard('primary-person', 'Primary Person'));
+ fixture.putContact(newHref, '"company-1"', remoteVcard('company-person', 'Company Person'));
+ fixture.queueDiscovery({ books: [
+ { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' },
+ { href: newPath, displayName: 'All Company' },
+ ] });
+ fixture.queueSync('', {
+ events: [{ href: writeHref, etag: '"primary-1"' }],
+ nextToken: 'primary-token',
+ });
+ fixture.queueSync('', {
+ events: [{ href: newHref, etag: '"company-1"' }],
+ nextToken: 'company-token',
+ });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({
+ ok: true, bookCount: 2, contactCount: 1,
+ });
+
+ // The established write-target keeps its role; the newcomer defaults to
+ // lookup-only and never steals the write-target.
+ expect(await bookRoles(seeded.userId)).toEqual([
+ { external_url: newUrl, is_write_target: false, is_subscribed: false, is_lookup_source: true },
+ { external_url: writeUrl, is_write_target: true, is_subscribed: true, is_lookup_source: true },
+ ]);
+ const state = await projectionState(seeded.userId);
+ expect(state.contacts).toHaveLength(1);
+ expect(state.contacts[0].primary_email).toBe('primary-person@example.test');
+ const lookup = await ledgerRows(seeded.userId);
+ expect(lookup.find(row => row.external_url === newUrl)).toMatchObject({
+ mapping_status: 'lookup',
+ local_contact_id: null,
+ lookup_display_name: 'Company Person',
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('snapshot discovery without sync-collection sends exactly one CardDAV filter', async () => {
+ const fixture = createCarddavFixtureServer();
+ await fixture.listen();
+ const seeded = await seedConnectedUser(fixture);
+ const href = fixture.href('snapshot-only.vcf');
+ const card = remoteVcard('snapshot-only', 'Snapshot Only');
+ fixture.putContact(href, '"snapshot-only-1"', card);
+ fixture.queueDiscovery({ books: [{
+ href: '/addressbooks/fixture-user/contacts/',
+ displayName: 'Snapshot Only',
+ reports: false,
+ }] });
+
+ expect(await carddavSync.syncUser(seeded.userId)).toEqual({
+ ok: true,
+ bookCount: 1,
+ contactCount: 1,
+ remote: 1,
+ fetched: 1,
+ updated: 1,
+ removed: 0,
+ fallback: 1,
+ exportFailures: [],
+ });
+ const state = await projectionState(seeded.userId);
+ expect(state.books).toHaveLength(1);
+ expect(state.books[0]).toMatchObject({
+ source: 'carddav',
+ remote_sync_token: null,
+ remote_sync_capability: 'snapshot',
+ remote_sync_revision: '1',
+ });
+ expect(state.contacts).toHaveLength(1);
+ expect(state.contacts[0].primary_email).toBe('snapshot-only@example.test');
+ expect(state.ledger).toHaveLength(1);
+ expect(fixture.counters).toMatchObject({
+ requests: 4,
+ propfind: 3,
+ sync: 0,
+ multiget: 0,
+ addressbookQuery: 1,
+ snapshotFilters: [1],
+ });
+ await fixture.close();
+ }, 120_000);
+
+ it('valid empty discovery prunes projection while malformed empty discovery changes only status', async () => {
+ const emptyFixture = createCarddavFixtureServer();
+ await emptyFixture.listen();
+ const emptySeeded = await seedConnectedUser(emptyFixture);
+ await seedSingleRemoteContact(emptyFixture, emptySeeded.userId);
+ const unrelatedBook = await databaseClient.query(`
+ INSERT INTO address_books (user_id, name)
+ VALUES ($1, 'Empty Discovery Unrelated') RETURNING id, sync_token
+ `, [emptySeeded.userId]);
+ emptyFixture.reset();
+ emptyFixture.queueDiscovery({ books: [] });
+
+ expect(await carddavSync.syncUser(emptySeeded.userId)).toEqual({
+ ok: true,
+ bookCount: 0,
+ contactCount: 0,
+ remote: 0,
+ fetched: 0,
+ updated: 0,
+ removed: 0,
+ fallback: 0,
+ exportFailures: [],
+ });
+ expect(await projectionState(emptySeeded.userId)).toEqual({
+ books: [{
+ id: unrelatedBook.rows[0].id,
+ source: 'local',
+ external_url: null,
+ sync_token: unrelatedBook.rows[0].sync_token,
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ remote_projection_fingerprint: null,
+ }],
+ contacts: [],
+ ledger: [],
+ });
+ const [emptyIntegration] = await integrationState(emptySeeded.userId);
+ expect(emptyIntegration.config).toEqual({
+ ...emptySeeded.config,
+ lastError: null,
+ lastSyncAt: expect.any(String),
+ bookCount: 0,
+ contactCount: 0,
+ exportFailures: [],
+ });
+ expect(emptyFixture.counters).toMatchObject({
+ requests: 3,
+ propfind: 3,
+ sync: 0,
+ multiget: 0,
+ addressbookQuery: 0,
+ });
+ await emptyFixture.close();
+
+ const malformedFixture = createCarddavFixtureServer();
+ await malformedFixture.listen();
+ const malformedSeeded = await seedConnectedUser(malformedFixture);
+ await seedSingleRemoteContact(malformedFixture, malformedSeeded.userId);
+ const malformedBefore = await projectionState(malformedSeeded.userId, { timestamps: true });
+ malformedFixture.reset();
+ malformedFixture.queueDiscovery({
+ rawBody: ' ',
+ });
+
+ const malformed = await carddavSync.syncUser(malformedSeeded.userId);
+
expect(malformed.ok).toBe(false);
expect(malformed.error).toMatch(/home collection|home-set|multistatus/i);
expect(await projectionState(malformedSeeded.userId, { timestamps: true }))
@@ -3781,6 +5071,7 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
const fixture = createCarddavFixtureServer();
await fixture.listen();
const seeded = await seedConnectedUser(fixture);
+ await seedWriteTargetBook(fixture, seeded.userId);
const { rows: [localBook] } = await databaseClient.query(`
INSERT INTO address_books (user_id, name)
VALUES ($1, 'Merged Photo Local') RETURNING id, sync_token
@@ -3801,8 +5092,8 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
const { rows: [localRow] } = await databaseClient.query(`
INSERT INTO contacts (
address_book_id, user_id, uid, vcard, etag, display_name, primary_email,
- emails, phones, photo_data
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, NULL)
+ emails, phones, photo_data, carddav_publish_intent
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, NULL, true)
RETURNING id
`, [
localBook.id,
@@ -3947,7 +5238,8 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
}),
});
expect(replacementResponse.status).toBe(200);
- expect(await replacementResponse.json()).toEqual({
+ const { books: replacementBooks, ...replacementStatus } = await replacementResponse.json();
+ expect(replacementStatus).toEqual({
connected: true,
serverUrl: newFixture.serverUrl,
username: 'replacement-user',
@@ -3956,7 +5248,23 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
lastError: null,
bookCount: null,
contactCount: 1,
- });
+ publishEmailedContacts: false,
+ });
+ // The old connection's book is still present (not yet reconciled away —
+ // that happens when its in-flight sync finishes below). It was this
+ // user's very first carddav book (created by seedSingleRemoteContact's
+ // real sync, above), so it auto-claimed the write-target the same way a
+ // fresh connection's first create-capable book always does; replacing the
+ // *connection* doesn't touch an already-existing book's roles.
+ expect(replacementBooks).toEqual([expect.objectContaining({
+ id: oldBook.id,
+ externalUrl: oldBook.external_url,
+ isWriteTarget: true,
+ isSubscribed: true,
+ isLookupSource: true,
+ materializedCount: 1,
+ lookupCount: 0,
+ })]);
const [replacement] = await integrationState(seeded.userId);
expect(replacement.config).toEqual({
serverUrl: newFixture.serverUrl,
@@ -4013,6 +5321,17 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
remote_projection_fingerprint: expect.any(String),
});
expect(finalProjection.books[0].id).not.toBe(oldBook.id);
+ // The old connection's book (still flagged is_write_target through the
+ // window above) has now been reconciled away by this same sync, so the
+ // replacement connection's lone surviving book must claim the
+ // write-target itself — otherwise the deletion of the old flagged book
+ // would leave this connection with none at all.
+ const { rows: [finalRoles] } = await databaseClient.query(`
+ SELECT is_write_target, is_subscribed
+ FROM address_books
+ WHERE id = $1
+ `, [finalProjection.books[0].id]);
+ expect(finalRoles).toEqual({ is_write_target: true, is_subscribed: true });
const finalContact = finalProjection.contacts[0];
expect(finalProjection.contacts).toEqual([
expectedLocalContact(
@@ -4278,4 +5597,326 @@ describe('production CardDAV HTTP to PostgreSQL 16', () => {
expect(await materializedCarddavContactCount(seeded.userId)).toBe(4);
await fixture.close();
}, 120_000);
+ // A sender present only
+ // in a lookup-only book resolves its name and avatar from the retained ledger
+ // vCard without ever materializing a contact. These reuse the migrated database
+ // and shared pool above; they live here (rather than in an unwired file) so the
+ // in this integration suite so the database test command exercises them.
+ describe('inbound lookup fallback', () => {
+ const PHOTO_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
+ const PHOTO_B64 = PHOTO_BYTES.toString('base64');
+
+ let listMessages;
+ let resolveLookupPhoto;
+ let clearLookupPhotoCache;
+ let photoHandler;
+ let contactsListHandler;
+
+ function routeHandler(router, method, path) {
+ return router.stack
+ .find(layer => layer.route?.path === path && layer.route.methods[method])
+ .route.stack.at(-1).handle;
+ }
+
+ function lookupVCard(email, displayName, photo, photoGroup = null) {
+ const photoLine = photo
+ ? `${photoGroup ? `${photoGroup}.` : ''}PHOTO;ENCODING=b;TYPE=JPEG:${photo}`
+ : null;
+ return [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ `UID:${email}`,
+ `FN:${displayName}`,
+ `EMAIL:${email}`,
+ ...(photoLine ? [photoLine] : []),
+ 'END:VCARD',
+ '',
+ ].join('\r\n');
+ }
+
+ async function seedUserWithInbox() {
+ const userId = randomUUID();
+ await databaseClient.query('INSERT INTO users (id, username) VALUES ($1, $2)', [userId, `lookup-${userId}`]);
+ const { rows: [account] } = await databaseClient.query(`
+ INSERT INTO email_accounts (user_id, name, email_address, enabled)
+ VALUES ($1, 'Inbox', $2, true) RETURNING id
+ `, [userId, `${userId}@mailbox.test`]);
+ const { rows: [localBook] } = await databaseClient.query(
+ "INSERT INTO address_books (user_id, name) VALUES ($1, 'Personal') RETURNING id",
+ [userId],
+ );
+ return { userId, accountId: account.id, localBookId: localBook.id };
+ }
+
+ // A lookup-only book: pulled into the ledger and consulted for sender resolution
+ // but never materialized (is_subscribed=false, is_lookup_source=true).
+ async function seedLookupBook(userId) {
+ const { rows: [book] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Apple Contacts', 'carddav', $2, false, false, true)
+ RETURNING id
+ `, [userId, `https://dav.example.test/${userId}/lookup/`]);
+ return book.id;
+ }
+
+ async function seedLookupRow(bookId, { email, displayName, photo, photoGroup = null }) {
+ await databaseClient.query(`
+ INSERT INTO carddav_remote_objects (
+ address_book_id, href, remote_etag, vcard, primary_email,
+ local_contact_id, mapping_status, lookup_display_name, last_synced_at
+ ) VALUES ($1, $2, '"e1"', $3, $4, NULL, 'lookup', $5, NOW())
+ `, [bookId, `${bookId}-${email}.vcf`, lookupVCard(email, displayName, photo, photoGroup), email, displayName]);
+ }
+
+ async function seedMessage(accountId, { fromEmail, fromName = '' }) {
+ await databaseClient.query(`
+ INSERT INTO messages (account_id, uid, folder, message_id, subject, from_name, from_email, date, is_deleted)
+ VALUES ($1, $2, 'INBOX', $3, 'Hello', $4, $5, NOW(), false)
+ `, [accountId, Math.floor(Math.random() * 1e9), randomUUID(), fromName, fromEmail]);
+ }
+
+ function photoResponse() {
+ const res = {
+ headers: {},
+ statusCode: 200,
+ body: undefined,
+ set: (key, value) => { res.headers[key] = value; return res; },
+ status: code => { res.statusCode = code; return res; },
+ send: body => { res.body = body; return res; },
+ end: () => res,
+ };
+ return res;
+ }
+
+ async function servePhoto(userId, email) {
+ const res = photoResponse();
+ await photoHandler({ session: { userId }, query: { email } }, res);
+ return res;
+ }
+
+ async function listContacts(userId) {
+ let payload;
+ const res = { json: value => { payload = value; }, status: () => res };
+ await contactsListHandler({ session: { userId }, query: {} }, res);
+ return payload;
+ }
+
+ beforeAll(async () => {
+ ({ listMessages } = await import('./messageService.js'));
+ ({ resolveLookupPhoto, _clearLookupPhotoCache: clearLookupPhotoCache } =
+ await import('./carddavLookupService.js'));
+ const { default: contactsRouter } = await import('../routes/contacts.js');
+ photoHandler = routeHandler(contactsRouter, 'get', '/photo');
+ contactsListHandler = routeHandler(contactsRouter, 'get', '/');
+ }, 120_000);
+
+ beforeEach(() => {
+ clearLookupPhotoCache();
+ });
+
+ it('resolves a lookup-only sender name + avatar from the ledger and keeps it out of GET /api/contacts', async () => {
+ const { userId, accountId } = await seedUserWithInbox();
+ const bookId = await seedLookupBook(userId);
+ await seedLookupRow(bookId, { email: 'lookup@example.test', displayName: 'Lookup Sender', photo: PHOTO_B64 });
+ await seedMessage(accountId, { fromEmail: 'lookup@example.test', fromName: '' });
+
+ const { messages } = await listMessages({ userId });
+ expect(messages).toHaveLength(1);
+ // Header carried no display name, so the ledger name fills it in.
+ expect(messages[0].from_name).toBe('Lookup Sender');
+ expect(messages[0].has_contact_photo).toBe(true);
+
+ const photo = await servePhoto(userId, 'lookup@example.test');
+ expect(photo.statusCode).toBe(200);
+ expect(photo.headers['Content-Type']).toBe('image/jpeg');
+ expect(photo.headers['Cache-Control']).toBe('private, max-age=86400');
+ expect(photo.body).toEqual(PHOTO_BYTES);
+
+ // The lookup entry is never materialized, so the contacts list stays empty.
+ const contacts = await listContacts(userId);
+ expect(contacts.total).toBe(0);
+ expect(contacts.contacts).toEqual([]);
+ }, 120_000);
+
+ it('gives every message row from one repeated lookup sender the resolved avatar', async () => {
+ // Several inbox rows share a single lookup-only sender. The photo gate dedupes
+ // by sender before probing the ledger (one shared resolve, not one per row),
+ // but the resolved avatar must still land on every matching row.
+ const { userId, accountId } = await seedUserWithInbox();
+ const bookId = await seedLookupBook(userId);
+ await seedLookupRow(bookId, { email: 'repeat@example.test', displayName: 'Repeat Sender', photo: PHOTO_B64 });
+ for (let i = 0; i < 4; i++) {
+ await seedMessage(accountId, { fromEmail: 'repeat@example.test', fromName: '' });
+ }
+
+ const { messages } = await listMessages({ userId });
+ expect(messages).toHaveLength(4);
+ expect(messages.every(message => message.has_contact_photo === true)).toBe(true);
+ expect(messages.every(message => message.from_name === 'Repeat Sender')).toBe(true);
+ }, 120_000);
+
+ it('resolves the lookup-only sender through the threaded query as well', async () => {
+ const { userId, accountId } = await seedUserWithInbox();
+ const bookId = await seedLookupBook(userId);
+ await seedLookupRow(bookId, { email: 'threaded@example.test', displayName: 'Threaded Sender', photo: PHOTO_B64 });
+ await seedMessage(accountId, { fromEmail: 'threaded@example.test', fromName: '' });
+
+ const { messages } = await listMessages({ userId, accountId, threaded: 'true' });
+ expect(messages).toHaveLength(1);
+ expect(messages[0].from_name).toBe('Threaded Sender');
+ expect(messages[0].has_contact_photo).toBe(true);
+ }, 120_000);
+
+ it('flags a grouped/labeled lookup PHOTO the syntactic gate would miss', async () => {
+ // `item1.PHOTO` is a valid grouped property (RFC 6350 §3.3); the vCard parser
+ // strips the group prefix, so the real decode path extracts and serves it. A
+ // syntactic "PHOTO at line start" check would miss it, so the gate must be
+ // computed through the same decode the photo endpoint runs.
+ const { userId, accountId } = await seedUserWithInbox();
+ const bookId = await seedLookupBook(userId);
+ await seedLookupRow(bookId, {
+ email: 'grouped@example.test', displayName: 'Grouped Sender', photo: PHOTO_B64, photoGroup: 'item1',
+ });
+ await seedMessage(accountId, { fromEmail: 'grouped@example.test', fromName: '' });
+
+ const { messages } = await listMessages({ userId });
+ expect(messages[0].has_contact_photo).toBe(true);
+
+ const threaded = await listMessages({ userId, accountId, threaded: 'true' });
+ expect(threaded.messages[0].has_contact_photo).toBe(true);
+
+ const photo = await servePhoto(userId, 'grouped@example.test');
+ expect(photo.statusCode).toBe(200);
+ expect(photo.headers['Content-Type']).toBe('image/jpeg');
+ expect(photo.body).toEqual(PHOTO_BYTES);
+ }, 120_000);
+
+ it('marks a photo-less lookup sender as no-avatar while still resolving its name (flat and threaded)', async () => {
+ const { userId, accountId } = await seedUserWithInbox();
+ const bookId = await seedLookupBook(userId);
+ // A ledger row with a display name but no PHOTO property in its vCard.
+ await seedLookupRow(bookId, { email: 'nophoto@example.test', displayName: 'No Photo Sender', photo: null });
+ await seedMessage(accountId, { fromEmail: 'nophoto@example.test', fromName: '' });
+
+ const { messages } = await listMessages({ userId });
+ expect(messages).toHaveLength(1);
+ // The ledger name still fills the missing header name...
+ expect(messages[0].from_name).toBe('No Photo Sender');
+ // ...but the avatar gate stays false, so the client never fires a
+ // guaranteed-404 GET /api/contacts/photo for a photo-less lookup sender.
+ expect(messages[0].has_contact_photo).toBe(false);
+
+ const threaded = await listMessages({ userId, accountId, threaded: 'true' });
+ expect(threaded.messages[0].from_name).toBe('No Photo Sender');
+ expect(threaded.messages[0].has_contact_photo).toBe(false);
+
+ expect((await servePhoto(userId, 'nophoto@example.test')).statusCode).toBe(404);
+ }, 120_000);
+
+ it('prefers the materialized contact photo over a competing ledger photo', async () => {
+ const { userId, accountId, localBookId } = await seedUserWithInbox();
+ const contactPhoto = 'data:image/png;base64,AQIDBA==';
+ await databaseClient.query(`
+ INSERT INTO contacts (address_book_id, user_id, uid, display_name, primary_email, photo_data, is_auto)
+ VALUES ($1, $2, $3, 'Real Contact', 'both@example.test', $4, false)
+ `, [localBookId, userId, randomUUID(), contactPhoto]);
+ const bookId = await seedLookupBook(userId);
+ await seedLookupRow(bookId, { email: 'both@example.test', displayName: 'Ledger Copy', photo: PHOTO_B64 });
+ await seedMessage(accountId, { fromEmail: 'both@example.test', fromName: 'Header Name' });
+
+ const photo = await servePhoto(userId, 'both@example.test');
+ expect(photo.statusCode).toBe(200);
+ expect(photo.headers['Content-Type']).toBe('image/png');
+ // The materialized contact's PNG is served, not the ledger's JPEG: contacts
+ // are resolved first, so the ledger fallback is never consulted here.
+ expect(photo.body).toEqual(Buffer.from('AQIDBA==', 'base64'));
+
+ const { messages } = await listMessages({ userId });
+ // co.id is non-null (materialized photo), so the sender is not even a ledger
+ // photo candidate — has_contact_photo comes from the contacts table alone.
+ expect(messages[0].has_contact_photo).toBe(true);
+ // A present header name is preserved (matches materialized-contact behavior).
+ expect(messages[0].from_name).toBe('Header Name');
+ }, 120_000);
+
+ it('falls back to the ledger photo when the materialized contact has none', async () => {
+ // A materialized contact exists but carries no photo_data, so the contacts
+ // query's own `photo_data IS NOT NULL` filter misses it and both the photo
+ // route and the has_contact_photo gate are forced onto the Slice 4 ledger
+ // fallback — the scenario where the two branches actually compete.
+ const { userId, accountId, localBookId } = await seedUserWithInbox();
+ await databaseClient.query(`
+ INSERT INTO contacts (address_book_id, user_id, uid, display_name, primary_email, photo_data, is_auto)
+ VALUES ($1, $2, $3, 'Photoless Contact', 'both@example.test', NULL, false)
+ `, [localBookId, userId, randomUUID()]);
+ const bookId = await seedLookupBook(userId);
+ await seedLookupRow(bookId, { email: 'both@example.test', displayName: 'Ledger Copy', photo: PHOTO_B64 });
+ await seedMessage(accountId, { fromEmail: 'both@example.test', fromName: '' });
+
+ // The photoless contact yields no bytes, so the retained vCard's PHOTO serves.
+ const photo = await servePhoto(userId, 'both@example.test');
+ expect(photo.statusCode).toBe(200);
+ expect(photo.headers['Content-Type']).toBe('image/jpeg');
+ expect(photo.body).toEqual(PHOTO_BYTES);
+
+ const { messages } = await listMessages({ userId });
+ // The gate reflects the servable ledger photo, not the empty contacts row.
+ expect(messages[0].has_contact_photo).toBe(true);
+ // Blank header name, so the ledger display name fills it in.
+ expect(messages[0].from_name).toBe('Ledger Copy');
+
+ const threaded = await listMessages({ userId, accountId, threaded: 'true' });
+ expect(threaded.messages[0].has_contact_photo).toBe(true);
+ expect(threaded.messages[0].from_name).toBe('Ledger Copy');
+ }, 120_000);
+
+ it('resolves nothing for a sender in neither contacts nor any lookup book', async () => {
+ const { userId, accountId } = await seedUserWithInbox();
+ await seedLookupBook(userId);
+ await seedMessage(accountId, { fromEmail: 'stranger@example.test', fromName: 'Stranger' });
+
+ const { messages } = await listMessages({ userId });
+ expect(messages[0].has_contact_photo).toBe(false);
+ expect(messages[0].from_name).toBe('Stranger');
+
+ expect(await resolveLookupPhoto(userId, 'stranger@example.test')).toBeNull();
+ expect((await servePhoto(userId, 'stranger@example.test')).statusCode).toBe(404);
+ }, 120_000);
+
+ it('refuses to serve a lookup PHOTO that exceeds the bounded decode limit', async () => {
+ const { userId, accountId } = await seedUserWithInbox();
+ const bookId = await seedLookupBook(userId);
+ const oversized = 'A'.repeat(513 * 1024);
+ await seedLookupRow(bookId, { email: 'big@example.test', displayName: 'Big Photo', photo: oversized });
+ await seedMessage(accountId, { fromEmail: 'big@example.test', fromName: '' });
+
+ expect(await resolveLookupPhoto(userId, 'big@example.test')).toBeNull();
+ expect((await servePhoto(userId, 'big@example.test')).statusCode).toBe(404);
+
+ // The gate agrees with the decode path: the ledger name still resolves, but
+ // has_contact_photo stays false so the client never fires a doomed GET that
+ // the photo endpoint would 404 on every render.
+ const { messages } = await listMessages({ userId });
+ expect(messages[0].from_name).toBe('Big Photo');
+ expect(messages[0].has_contact_photo).toBe(false);
+
+ const threaded = await listMessages({ userId, accountId, threaded: 'true' });
+ expect(threaded.messages[0].has_contact_photo).toBe(false);
+ }, 120_000);
+
+ it('scopes the fallback to is_lookup_source books only', async () => {
+ const { userId } = await seedUserWithInbox();
+ // A book flagged for lookup off (ignored): its ledger row must not resolve.
+ const { rows: [ignored] } = await databaseClient.query(`
+ INSERT INTO address_books (
+ user_id, name, source, external_url, is_write_target, is_subscribed, is_lookup_source
+ ) VALUES ($1, 'Ignored', 'carddav', $2, false, false, false)
+ RETURNING id
+ `, [userId, `https://dav.example.test/${userId}/ignored/`]);
+ await seedLookupRow(ignored.id, { email: 'ignored@example.test', displayName: 'Ignored', photo: PHOTO_B64 });
+
+ expect(await resolveLookupPhoto(userId, 'ignored@example.test')).toBeNull();
+ }, 120_000);
+ });
});
diff --git a/backend/src/services/carddavSync.js b/backend/src/services/carddavSync.js
index 1b8a064..6360b88 100644
--- a/backend/src/services/carddavSync.js
+++ b/backend/src/services/carddavSync.js
@@ -34,8 +34,11 @@ import {
applyRemoteTombstone,
lockCarddavIntegration,
normalizeCarddavCapabilities,
+ claimBestWriteTargetCandidate,
persistDiscoveredBook,
refreshUnresolvedConflict,
+ removeLookupObjects,
+ upsertLookupObjects,
} from './carddavMappingState.js';
import { normalizeEmail, planAutomaticProjection } from './carddavProjection.js';
@@ -46,6 +49,8 @@ const timers = new Map(); // userId -> interval id
let conflictCleanupTimer;
const syncing = new Set(); // userIds with a sync in flight (prevents overlap)
const activeSyncGenerations = new Map();
+// userIds whose in-flight sync cannot be trusted to reflect a change that landed
+// while it ran, so it must be re-run once it settles (see requestCarddavSync).
const pendingReplacementSyncs = new Set();
const RESULT_COUNTERS = [
'remote', 'fetched', 'updated', 'removed', 'fallback',
@@ -124,6 +129,238 @@ export async function getCardavConfig(userId) {
return r.rows[0]?.config || null;
}
+// Per-book summary for the integrations UI: role flags, observed write
+// capabilities, and how many rows each book contributes (materialized
+// contacts vs. ledger-only lookup rows). Read-only — role changes go through
+// patchCarddavBookRoles (PATCH /api/carddav/books/:id).
+//
+// lookup_count answers "how many senders does this book resolve", so it is
+// scoped exactly like the inbound probes (carddavLookupService's
+// LOOKUP_LEDGER_SOURCE / messageService): ledger rows of an is_lookup_source
+// book. An ignored book's ledger rows resolve nothing — they are dropped on its
+// next sync (see dropIgnoredBookLedger) — so counting them would render the
+// book as "N for lookup" while it looks up nobody.
+export async function getCarddavBookSummaries(userId) {
+ const { rows } = await query(
+ `SELECT
+ b.id, b.name, b.external_url,
+ b.is_write_target, b.is_subscribed, b.is_lookup_source,
+ b.remote_create_capability, b.remote_update_capability, b.remote_delete_capability,
+ b.updated_at,
+ (SELECT count(*)::int FROM contacts c
+ WHERE c.address_book_id = b.id) AS materialized_count,
+ CASE WHEN b.is_lookup_source
+ THEN (SELECT count(*)::int FROM carddav_remote_objects o
+ WHERE o.address_book_id = b.id AND o.mapping_status = 'lookup')
+ ELSE 0
+ END AS lookup_count
+ FROM address_books b
+ WHERE b.user_id = $1 AND b.source = 'carddav'
+ ORDER BY b.created_at, b.id`,
+ [userId],
+ );
+
+ return rows.map(row => ({
+ id: row.id,
+ name: row.name,
+ externalUrl: row.external_url,
+ isWriteTarget: row.is_write_target,
+ isSubscribed: row.is_subscribed,
+ isLookupSource: row.is_lookup_source,
+ capabilities: {
+ create: row.remote_create_capability,
+ update: row.remote_update_capability,
+ delete: row.remote_delete_capability,
+ },
+ materializedCount: row.materialized_count,
+ lookupCount: row.lookup_count,
+ lastSyncAt: row.updated_at ? row.updated_at.toISOString() : null,
+ }));
+}
+
+function bookRoleError(message, code) {
+ return Object.assign(new Error(message), { code });
+}
+
+// When a subscribed book is unsubscribed, its materialized contacts leave the
+// list and its ledger rows are retained for lookup only. Capture each row's
+// display name from the linked contact first (so the ledger keeps a name for
+// inbound resolution), then drop the local link and any pending push intent —
+// a lookup book is read-only from MailFlow — before deleting the now-orphaned
+// contacts and any conflicts (a lookup book produces none). Mirrors the sync's
+// applyLookupBookProjection end state without waiting for the next pull.
+async function demoteSubscribedBookToLookup(client, userId, bookId) {
+ await client.query(
+ `UPDATE carddav_remote_objects o
+ SET lookup_display_name = COALESCE(o.lookup_display_name, c.display_name)
+ FROM contacts c
+ WHERE o.address_book_id = $1 AND o.local_contact_id = c.id`,
+ [bookId],
+ );
+ await client.query(
+ `UPDATE carddav_remote_objects SET
+ mapping_status = 'lookup',
+ local_contact_id = NULL,
+ local_contact_hash = NULL,
+ pending_operation = NULL, pending_vcard = NULL, pending_local_hash = NULL,
+ pending_remote_semantic_hash = NULL, pending_started_at = NULL,
+ mapping_revision = mapping_revision + 1,
+ updated_at = NOW()
+ WHERE address_book_id = $1 AND mapping_status <> 'lookup'`,
+ [bookId],
+ );
+ await client.query(
+ 'DELETE FROM contacts WHERE user_id = $1 AND address_book_id = $2',
+ [userId, bookId],
+ );
+ await client.query(
+ 'DELETE FROM carddav_conflicts WHERE address_book_id = $1',
+ [bookId],
+ );
+}
+
+// Force the next sync to re-pull a book in full, by clearing its incremental
+// sync token (the same reset invalidateCarddavBookIdentity uses on a re-connect).
+// The pull's projection is not this function's business — the sync reads the
+// book's live roles for that — only that an incremental delta cannot get the
+// book's rows to where its new roles need them, so the whole collection must be
+// re-fetched. Both role transitions that owe a book a full pull use it:
+// - into subscribed: a lookup book's retained ledger rows carry local_contact_id
+// = NULL, so an incremental delta would skip them; a full pull re-imports every
+// remote object and converts the lookup rows into materialized contacts.
+// - out of ignored: the book's ledger was dropped (dropIgnoredBookLedger) while
+// the token that would resume from it survived, so an incremental delta would
+// report no changes and leave the book lookup-on with an empty ledger forever.
+// Clearing the token alone would not do: remote_sync_capability is reset with it
+// (the dropped ledger makes the stored capability's incremental path meaningless)
+// and the revision bump fences any pull still in flight against the new roles.
+async function scheduleFullReconcile(client, bookId) {
+ await client.query(
+ `UPDATE address_books SET
+ remote_sync_token = NULL,
+ remote_sync_capability = 'unknown',
+ remote_sync_revision = remote_sync_revision + 1,
+ remote_projection_fingerprint = NULL,
+ updated_at = NOW()
+ WHERE id = $1`,
+ [bookId],
+ );
+}
+
+// Apply a role change to one of a user's carddav books, fenced against the
+// connection generation the caller observed (a concurrent disconnect/reconnect
+// between the caller's config read and this transaction is rejected). The three
+// independent decisions, resolved in one transaction:
+// - makeWriteTarget: validates the book is create-capable, then atomically
+// clears any existing write-target for the user and sets this one, forcing
+// isSubscribed = true (write-target implies subscribed — the row CHECK);
+// - isSubscribed=false: rejected for the write-target; otherwise removes the
+// book's materialized contacts and demotes its ledger rows to lookup;
+// - isSubscribed=true (from a non-subscribed state): schedules a full reconcile
+// on the next sync, which materializes the book;
+// - isLookupSource toggles the lookup axis (both flags false = ignored: the
+// book is skipped at the network layer and its retained ledger rows are
+// dropped on the next sync — see dropIgnoredBookLedger). Re-enabling either
+// role on an ignored book schedules that full reconcile too, to rebuild the
+// ledger the drop took.
+// Returns the affected book's id; the caller re-reads the per-book summaries.
+export async function patchCarddavBookRoles(userId, bookId, patch, expectedGeneration) {
+ const { isSubscribed, isLookupSource, makeWriteTarget } = patch || {};
+ if (makeWriteTarget === undefined && isSubscribed === undefined && isLookupSource === undefined) {
+ throw bookRoleError('No CardDAV book role change was provided', 'ERR_CARDDAV_BOOK_PATCH_EMPTY');
+ }
+ return withTransaction(async client => {
+ const integration = await lockCarddavIntegration(client, userId);
+ if (!integration?.config?.serverUrl) {
+ throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ }
+ const actualGeneration = integration.config?.connectionGeneration ?? null;
+ if (expectedGeneration !== undefined && actualGeneration !== expectedGeneration) {
+ throw new StaleCarddavPlanError({
+ reason: 'connection-generation-changed',
+ expectedConnectionGeneration: expectedGeneration,
+ actualConnectionGeneration: actualGeneration,
+ });
+ }
+ const { rows: [book] } = await client.query(
+ `SELECT id, is_write_target, is_subscribed, is_lookup_source, remote_create_capability
+ FROM address_books
+ WHERE id = $1 AND user_id = $2 AND source = 'carddav'
+ FOR UPDATE`,
+ [bookId, userId],
+ );
+ if (!book) throw bookRoleError('CardDAV address book not found', 'ERR_ADDRESS_BOOK_NOT_FOUND');
+
+ let nextWriteTarget = book.is_write_target;
+ let nextSubscribed = book.is_subscribed;
+ let nextLookup = book.is_lookup_source;
+
+ if (makeWriteTarget === true) {
+ if (book.remote_create_capability === 'denied') {
+ throw bookRoleError(
+ 'This CardDAV address book cannot be a write-target because it does not allow create',
+ 'ERR_CARDDAV_READ_ONLY',
+ );
+ }
+ nextWriteTarget = true;
+ nextSubscribed = true;
+ }
+ if (isSubscribed === true) nextSubscribed = true;
+ if (isSubscribed === false) {
+ if (nextWriteTarget) {
+ throw bookRoleError(
+ 'The CardDAV write-target book must stay subscribed',
+ 'ERR_CARDDAV_WRITE_TARGET_SUBSCRIBED',
+ );
+ }
+ nextSubscribed = false;
+ }
+ if (isLookupSource === true) nextLookup = true;
+ if (isLookupSource === false) nextLookup = false;
+
+ // Atomic write-target swap: clear the current holder before setting this
+ // one, so the one-write-target partial unique index never sees two rows
+ // (mirrors JMAP's onSuccessSetIsDefault — no window with two/zero targets).
+ //
+ // The outgoing holder keeps any open pending push intent, deliberately. An
+ // intent is a record that one PUT/DELETE was *already issued* and its
+ // acknowledgement lost, so it must be observed, never replayed and never
+ // dropped: the next sync's recovery pass reads the resource back (see
+ // recoverPendingCarddavMutations — read-only, recoveryOnly) and either
+ // confirms the write or raises a conflict. Clearing intents here would
+ // discard an un-acknowledged local edit with neither a push nor a conflict.
+ // The book becoming a subscribed secondary does not weaken that: every
+ // write site re-reads is_write_target live (assertWritable, selectedCreateBook,
+ // resolveConflict's keep-mailflow guard), so nothing writes to it again.
+ if (makeWriteTarget === true && !book.is_write_target) {
+ await client.query(
+ `UPDATE address_books SET is_write_target = false, updated_at = NOW()
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true AND id <> $2`,
+ [userId, bookId],
+ );
+ }
+ if (book.is_subscribed && !nextSubscribed) {
+ await demoteSubscribedBookToLookup(client, userId, bookId);
+ }
+ // Both transitions that leave the book's rows short of what its new roles
+ // need: gaining subscribed (its ledger rows are unmaterialized) and leaving
+ // ignored (its ledger was dropped). Either way an incremental delta cannot
+ // fill the gap — see scheduleFullReconcile.
+ const wasIgnored = !book.is_subscribed && !book.is_lookup_source;
+ const nowIgnored = !nextSubscribed && !nextLookup;
+ if ((!book.is_subscribed && nextSubscribed) || (wasIgnored && !nowIgnored)) {
+ await scheduleFullReconcile(client, bookId);
+ }
+ await client.query(
+ `UPDATE address_books SET
+ is_write_target = $2, is_subscribed = $3, is_lookup_source = $4, updated_at = NOW()
+ WHERE id = $1`,
+ [bookId, nextWriteTarget, nextSubscribed, nextLookup],
+ );
+ return bookId;
+ });
+}
+
async function invalidateCarddavBookIdentity(client, userId) {
const { rows: books } = await client.query(
`SELECT id
@@ -195,7 +432,7 @@ export async function patchCarddavConnection(userId, patch, expectedGeneration)
if (identityChanged) await invalidateCarddavBookIdentity(client, userId);
const nextPatch = {};
- for (const field of ['serverUrl', 'username', 'password', 'intervalMin']) {
+ for (const field of ['serverUrl', 'username', 'password', 'intervalMin', 'publishEmailedContacts']) {
if (Object.hasOwn(patch, field)) nextPatch[field] = patch[field];
}
if (['serverUrl', 'username', 'password'].some(field => Object.hasOwn(patch, field))) {
@@ -214,19 +451,81 @@ export async function patchCarddavConnection(userId, patch, expectedGeneration)
});
}
-async function lockCarddavBooks(client, userId, urls) {
+// Match a persisted/locked book row to a URL a fresh discovery returned, by
+// either its canonical external_url or the discovery_alias_url a
+// redirect-reconciling server may still advertise for a collection whose
+// external_url was already rewritten to its canonical form (see
+// advanceDiscoveredBookState in carddavMappingState.js). This is the same
+// external_url-OR-discovery_alias_url resolution the sync-planning join
+// (prepareBookPlan), role loading (loadCarddavBookRoles), and write-target
+// routing (selectedCreateBook) use, so an already-canonicalized book resolves
+// consistently no matter which URL discovery reports. discovery_alias_url is
+// absent on a not-yet-migrated (transitional) schema; a row that lacks it
+// simply falls back to external_url matching.
+function matchBookByDiscoveryUrl(rows, url) {
+ if (url == null) return null;
+ return rows.find(row => (
+ row.external_url === url || row.discovery_alias_url === url
+ )) ?? null;
+}
+
+// discovery_alias_url is added by migration 0034. A sync running on a
+// not-yet-migrated (transitional) schema must still resolve books by
+// external_url alone, so the alias-aware SELECT runs under a SAVEPOINT: an
+// undefined_column (42703) rolls back only this statement, not the caller's
+// whole sync transaction, then the external_url-only variant is retried.
+// Mirrors advanceDiscoveredBookState's guard.
+async function selectWithAliasColumnFallback(client, aliasSql, plainSql, params) {
+ await client.query('SAVEPOINT carddav_alias_column_read');
+ try {
+ const result = await client.query(aliasSql, params);
+ await client.query('RELEASE SAVEPOINT carddav_alias_column_read');
+ return result;
+ } catch (error) {
+ if (error?.code !== '42703') throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_alias_column_read');
+ await client.query('RELEASE SAVEPOINT carddav_alias_column_read');
+ return client.query(plainSql, params);
+ }
+}
+
+// Lock the persisted rows for the URLs a fresh discovery reported. When the
+// schema carries discovery_alias_url (migration 0034 — the caller probes once
+// via the integration query's EXISTS check rather than paying a per-book
+// SAVEPOINT on this hot path), also match a row a redirect-reconciling server
+// still surfaces under its alias, whose external_url was already rewritten to
+// canonical. On a not-yet-migrated (transitional) schema, hasDiscoveryAlias is
+// false and the lock resolves by external_url alone, exactly as before.
+async function lockCarddavBooks(client, userId, urls, hasDiscoveryAlias) {
const result = await client.query(
- `SELECT id, external_url, remote_sync_token, remote_sync_revision::text, sync_token,
- remote_projection_fingerprint
- FROM address_books
- WHERE user_id = $1 AND source = 'carddav' AND external_url = ANY($2::text[])
- ORDER BY id
- FOR UPDATE`,
+ hasDiscoveryAlias
+ ? `SELECT id, external_url, discovery_alias_url, remote_sync_token,
+ remote_sync_revision::text, sync_token, remote_projection_fingerprint
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'
+ AND (external_url = ANY($2::text[]) OR discovery_alias_url = ANY($2::text[]))
+ ORDER BY id
+ FOR UPDATE`
+ : `SELECT id, external_url, remote_sync_token, remote_sync_revision::text, sync_token,
+ remote_projection_fingerprint
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = ANY($2::text[])
+ ORDER BY id
+ FOR UPDATE`,
[userId, urls],
);
return result.rows;
}
+async function createCarddavBook(client, userId, book) {
+ // Defer the write-target claim: syncUser persists every book of a single
+ // discovery snapshot individually, each in its own transaction, so ranking
+ // per book here (in discovery order) could let an earlier, worse-ranked
+ // book keep the flag ahead of a later, better one from the very same
+ // batch. syncUser claims once, after every book has been persisted (see
+ // claimBestWriteTargetCandidate in carddavMappingState.js).
+ return persistDiscoveredBook(client, { userId, ...book, deferWriteTargetClaim: true });
+}
async function lockEligibleTargetBooks(client, userId) {
const result = await client.query(
`SELECT id, source, sync_token
@@ -573,6 +872,53 @@ async function persistCarddavContactCount(client, integration, userId) {
if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' });
}
+// Advance a pulled book's discovery state (sync token, capability, projection
+// fingerprint, canonical URL) after its projection — materializing or
+// lookup-only — has been applied. Shared by both branches so the alias-replace
+// SAVEPOINT dance and the canonical-url-conflict / book-update-missed fences
+// stay identical for a lookup book and a subscribed one.
+async function advanceProjectedBookState(client, {
+ plan, book, fingerprint, replacingAlias, canonicalUrl, identity,
+}) {
+ const capabilities = observedCapabilities(plan.book);
+ const nextRemoteToken = Object.hasOwn(plan, 'nextRemoteToken')
+ ? plan.nextRemoteToken
+ : book.remote_sync_token;
+ let updatedBook;
+ if (replacingAlias) await client.query('SAVEPOINT carddav_alias_replace');
+ try {
+ updatedBook = await advanceDiscoveredBookState(client, {
+ addressBookId: book.id,
+ remoteSyncToken: nextRemoteToken,
+ remoteSyncCapability: plan.capability,
+ remoteProjectionFingerprint: fingerprint,
+ expectedRemoteRevision: plan.expectedRemoteRevision,
+ canonicalUrl,
+ capabilities,
+ });
+ } catch (error) {
+ if (error.code !== '23505' || !replacingAlias) throw error;
+ await client.query('ROLLBACK TO SAVEPOINT carddav_alias_replace');
+ const conflict = await client.query(
+ `SELECT id FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND external_url = $2
+ FOR UPDATE`,
+ [plan.userId, canonicalUrl],
+ );
+ await client.query('RELEASE SAVEPOINT carddav_alias_replace');
+ throw new StaleCarddavPlanError({
+ reason: 'canonical-url-conflict',
+ observedUrl: identity.observedUrl,
+ canonicalUrl,
+ conflictingBookId: conflict.rows[0]?.id,
+ });
+ }
+ if (replacingAlias) await client.query('RELEASE SAVEPOINT carddav_alias_replace');
+ if (updatedBook.rowCount !== 1) {
+ throw new StaleCarddavPlanError({ reason: 'book-update-missed', bookId: book.id });
+ }
+}
+
async function applyAutomaticBookProjection(client, {
plan,
integrationRow,
@@ -860,43 +1206,9 @@ async function applyAutomaticBookProjection(client, {
const rotatedById = new Map(rotatedBooks.map(row => [row.id, row]));
const finalTargetBooks = fingerprintBooks.map(target => rotatedById.get(target.id) || target);
const fingerprint = projectionFingerprint(finalTargetBooks);
- const capabilities = observedCapabilities(plan.book);
- const nextRemoteToken = Object.hasOwn(plan, 'nextRemoteToken')
- ? plan.nextRemoteToken
- : book.remote_sync_token;
- let updatedBook;
- if (replacingAlias) await client.query('SAVEPOINT carddav_alias_replace');
- try {
- updatedBook = await advanceDiscoveredBookState(client, {
- addressBookId: book.id,
- remoteSyncToken: nextRemoteToken,
- remoteSyncCapability: plan.capability,
- remoteProjectionFingerprint: fingerprint,
- expectedRemoteRevision: plan.expectedRemoteRevision,
- canonicalUrl,
- capabilities,
- });
- } catch (error) {
- if (error.code !== '23505' || !replacingAlias) throw error;
- await client.query('ROLLBACK TO SAVEPOINT carddav_alias_replace');
- const conflict = await client.query(
- `SELECT id FROM address_books
- WHERE user_id = $1 AND source = 'carddav' AND external_url = $2
- FOR UPDATE`,
- [plan.userId, canonicalUrl],
- );
- await client.query('RELEASE SAVEPOINT carddav_alias_replace');
- throw new StaleCarddavPlanError({
- reason: 'canonical-url-conflict',
- observedUrl: identity.observedUrl,
- canonicalUrl,
- conflictingBookId: conflict.rows[0]?.id,
- });
- }
- if (replacingAlias) await client.query('RELEASE SAVEPOINT carddav_alias_replace');
- if (updatedBook.rowCount !== 1) {
- throw new StaleCarddavPlanError({ reason: 'book-update-missed', bookId: book.id });
- }
+ await advanceProjectedBookState(client, {
+ plan, book, fingerprint, replacingAlias, canonicalUrl, identity,
+ });
await persistCarddavContactCount(client, integrationRow, plan.userId);
return {
changedBookIds,
@@ -908,6 +1220,124 @@ async function applyAutomaticBookProjection(client, {
};
}
+// Post-delta ledger size of a lookup book, so an incremental sync can report
+// the book's remote count without reading and locking every row (see
+// applyLookupBookProjection). A single lock-free count(); no per-row data.
+async function countLookupObjects(client, addressBookId) {
+ const { rows: [row] } = await client.query(
+ 'SELECT count(*)::int AS count FROM carddav_remote_objects WHERE address_book_id = $1',
+ [addressBookId],
+ );
+ return row?.count ?? 0;
+}
+
+// Lookup-only projection for an is_lookup_source && !is_subscribed book: pull
+// the same incremental delta but retain each remote object as a ledger row
+// (mapping_status='lookup', local_contact_id=NULL) for inbound-sender
+// resolution instead of materializing a local contact. This is the core split
+// of pull-from-materialize — no materializeAutomaticImport, no local
+// export-candidate claiming, no conflict creation, and the materialized
+// footprint (lockEligibleTargetBooks / validateProjectionFootprint) is never
+// touched, so a lookup book contributes zero rows to the contacts list.
+async function applyLookupBookProjection(client, {
+ plan, integrationRow, book, replacingAlias, canonicalUrl, identity,
+}) {
+ const incomingByHref = new Map(plan.upserts.map(remote => [remote.href, remote]));
+ const removedHrefs = new Set(plan.removedHrefs);
+
+ // A full-snapshot (replaceAll) sync must reconcile against every existing
+ // ledger row to tombstone the ones the server dropped, so it reads and locks
+ // them all. An incremental delta only ever names the hrefs it touches, so it
+ // locks just those — never the whole (potentially very large) lookup book —
+ // and derives the post-sync total with a single lock-free count() below.
+ const deltaHrefs = [...incomingByHref.keys(), ...removedHrefs];
+ const { rows: lockedRows } = plan.replaceAll
+ ? await client.query(
+ `SELECT href, remote_etag, remote_semantic_hash, mapping_status
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1
+ ORDER BY href
+ FOR UPDATE`,
+ [book.id],
+ )
+ : await client.query(
+ `SELECT href, remote_etag, remote_semantic_hash, mapping_status
+ FROM carddav_remote_objects
+ WHERE address_book_id = $1 AND href = ANY($2::text[])
+ ORDER BY href
+ FOR UPDATE`,
+ [book.id, deltaHrefs],
+ );
+ const existingByHref = new Map(lockedRows.map(row => [row.href, row]));
+
+ const tombstoneHrefs = lockedRows
+ .filter(row => (
+ removedHrefs.has(row.href) || (plan.replaceAll && !incomingByHref.has(row.href))
+ ))
+ .map(row => row.href);
+ const removedResult = await removeLookupObjects(client, {
+ addressBookId: book.id,
+ hrefs: tombstoneHrefs,
+ });
+ const removed = removedResult.rowCount || 0;
+
+ const changes = [];
+ let added = 0;
+ for (const remote of plan.upserts) {
+ const document = parseVCardDocument(remote.vcard);
+ const remoteSemanticHash = semanticVCardHash(document);
+ const vcardVersion = document.version === '3.0' || document.version === '4.0'
+ ? document.version
+ : null;
+ const existing = existingByHref.get(remote.href);
+ if (!existing) added++;
+ const unchanged = existing
+ && existing.mapping_status === 'lookup'
+ && existing.remote_semantic_hash === remoteSemanticHash
+ && existing.remote_etag === (remote.remoteEtag ?? null);
+ if (unchanged) continue;
+ changes.push({
+ addressBookId: book.id,
+ href: remote.href,
+ remoteEtag: remote.remoteEtag ?? null,
+ vcard: remote.vcard,
+ primaryEmail: remote.contact?.primaryEmail ?? null,
+ vcardVersion,
+ remoteSemanticHash,
+ lookupDisplayName: remote.contact?.displayName ?? null,
+ });
+ }
+ const updated = await upsertLookupObjects(client, changes);
+
+ // An upsert href is always in the incoming set, so it is never tombstoned:
+ // the count moves by (rows added) − (rows tombstoned) regardless of how many
+ // untouched rows the book holds. replaceAll locked every row, so it totals
+ // them in memory; an incremental delta counts once, without a full lock.
+ const contactCountDelta = added - tombstoneHrefs.length;
+ const remote = plan.replaceAll
+ ? lockedRows.length - tombstoneHrefs.length + added
+ : await countLookupObjects(client, book.id);
+
+ await advanceProjectedBookState(client, {
+ plan, book, fingerprint: projectionFingerprint([]), replacingAlias, canonicalUrl, identity,
+ });
+ const totalContactCount = await persistCarddavContactCount(client, integrationRow, plan.userId);
+ return {
+ bookId: book.id,
+ count: remote,
+ changedBookIds: [],
+ ledgerChanged: updated > 0 || removed > 0,
+ updated,
+ removed,
+ contactCount: remote,
+ contactCountDelta,
+ totalContactCount,
+ completeReclassification: true,
+ remote,
+ exports: [],
+ };
+}
+
export async function applyBookDelta(client, plan) {
requirePlanFences(plan);
const integration = await client.query(
@@ -920,6 +1350,20 @@ export async function applyBookDelta(client, plan) {
AND table_name = 'carddav_remote_objects'
AND column_name = 'legacy_projection'
) AS has_legacy_projection,
+ EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'address_books'
+ AND column_name = 'discovery_alias_url'
+ ) AS has_discovery_alias,
+ EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'address_books'
+ AND column_name = 'is_subscribed'
+ ) AS has_book_roles,
config->>'connectionGeneration' AS connection_generation,
config->>'contactCount' AS contact_count
FROM user_integrations
@@ -949,15 +1393,25 @@ export async function applyBookDelta(client, plan) {
const bookUrls = replacingAlias
? [identity.observedUrl, identity.canonicalUrl]
: [plan.book.url];
- const lockedBooks = await lockCarddavBooks(client, plan.userId, bookUrls);
- let book = lockedBooks.find(row => row.external_url === plan.book.url);
+ const lockedBooks = await lockCarddavBooks(
+ client, plan.userId, bookUrls, integrationRow.has_discovery_alias === true,
+ );
+ // Resolve the row by the observed URL first (its external_url on a first
+ // canonicalization, or its discovery_alias_url once already canonicalized),
+ // then by the canonical URL — which is a book's external_url after an earlier
+ // rewrite, so an already-canonicalized book seen again via its alias resolves
+ // even on a transitional schema whose locked rows carry no discovery_alias_url.
+ let book = matchBookByDiscoveryUrl(lockedBooks, plan.book.url);
+ if (!book && replacingAlias) {
+ book = matchBookByDiscoveryUrl(lockedBooks, canonicalUrl);
+ }
if (!book && replacingAlias) {
throw new StaleCarddavPlanError({
reason: 'observed-alias-missing',
observedUrl: identity.observedUrl,
});
}
- if (!book) book = await persistDiscoveredBook(client, { userId: plan.userId, ...plan.book });
+ if (!book) book = await createCarddavBook(client, plan.userId, plan.book);
if (String(book.remote_sync_revision ?? '0') !== String(plan.expectedRemoteRevision)) {
throw new StaleCarddavPlanError({
reason: 'remote-revision-changed',
@@ -988,6 +1442,41 @@ export async function applyBookDelta(client, plan) {
}
}
+ // A lookup-only book never touches the materialized footprint, so it neither
+ // locks the eligible target books nor validates against them. syncUser sets
+ // plan.materialize from the book's stored roles; an unset flag (direct
+ // applyBookDelta callers, and pre-multi-book schemas where roles cannot be
+ // read) defaults to the materializing projection, preserving legacy behavior.
+ //
+ // That role snapshot was taken before the (possibly slow) network pull, so
+ // re-read the book's live is_subscribed under the row lock this transaction
+ // now holds: patchCarddavBookRoles serializes on the same user_integrations
+ // FOR UPDATE lock, so this reflects any role change that committed while the
+ // pull was in flight. Honoring it keeps an unsubscribe that raced this pull
+ // from re-materializing the very contacts it just removed (its demote does
+ // not rotate the token/revision fences), and lets a raced subscribe
+ // materialize. Only when syncUser set plan.materialize on a schema that has
+ // the role columns; direct callers and pre-multi-book schemas keep the plan's
+ // own decision.
+ let materialize = plan.materialize !== false;
+ if (plan.materialize !== undefined && integrationRow.has_book_roles) {
+ const { rows: [role] } = await client.query(
+ 'SELECT is_subscribed FROM address_books WHERE id = $1',
+ [book.id],
+ );
+ if (role) materialize = role.is_subscribed;
+ }
+ if (!materialize) {
+ return applyLookupBookProjection(client, {
+ plan,
+ integrationRow,
+ book,
+ replacingAlias,
+ canonicalUrl,
+ identity,
+ });
+ }
+
const fingerprintBooks = await lockEligibleTargetBooks(client, plan.userId);
await validateTargetBookFootprint(client, plan.userId, fingerprintBooks);
@@ -1002,8 +1491,16 @@ export async function applyBookDelta(client, plan) {
});
}
-export async function prepareBookPlan(userId, book, creds) {
- const current = await query(
+// The sync-planning join. Resolve the discovered book's stored sync state by
+// its canonical external_url OR the discovery_alias_url a redirect-reconciling
+// server may still advertise (same resolution as matchBookByDiscoveryUrl /
+// selectedCreateBook), so an already-canonicalized book keeps its incremental
+// sync token instead of re-pulling in full and then failing the apply step's
+// alias check. Runs on the pool (autocommit), so a plain try/catch — no
+// SAVEPOINT — recovers a not-yet-migrated (transitional) schema that lacks
+// discovery_alias_url by retrying the external_url-only join.
+async function loadCurrentBookState(userId, bookUrl) {
+ const selectClause =
`SELECT remote_sync_token, remote_sync_capability, remote_sync_revision,
connection_generation, book_id
FROM (
@@ -1012,11 +1509,24 @@ export async function prepareBookPlan(userId, book, creds) {
ui.config->>'connectionGeneration' AS connection_generation
FROM user_integrations ui
LEFT JOIN address_books b
- ON b.user_id = ui.user_id AND b.source = 'carddav' AND b.external_url = $2
+ ON b.user_id = ui.user_id AND b.source = 'carddav'`;
+ const tail =
+ `
WHERE ui.user_id = $1 AND ui.provider = 'carddav'
- ) current_book`,
- [userId, book.url],
- );
+ ) current_book`;
+ try {
+ return await query(
+ `${selectClause} AND (b.external_url = $2 OR b.discovery_alias_url = $2)${tail}`,
+ [userId, bookUrl],
+ );
+ } catch (error) {
+ if (error?.code !== '42703') throw error;
+ return query(`${selectClause} AND b.external_url = $2${tail}`, [userId, bookUrl]);
+ }
+}
+
+export async function prepareBookPlan(userId, book, creds) {
+ const current = await loadCurrentBookState(userId, book.url);
const currentBook = current.rows[0];
if (!currentBook) throw new StaleCarddavPlanError({ reason: 'not-connected' });
const expectedRemoteToken = currentBook.remote_sync_token ?? null;
@@ -1087,7 +1597,13 @@ export async function prepareBookPlan(userId, book, creds) {
}
export async function reconcileStaleCarddavBooks(client, userId, { seenUrls }) {
- const { rows: books } = await client.query(
+ const { rows: books } = await selectWithAliasColumnFallback(
+ client,
+ `SELECT id, source, external_url, discovery_alias_url, sync_token
+ FROM address_books
+ WHERE user_id = $1
+ ORDER BY id
+ FOR UPDATE`,
`SELECT id, source, external_url, sync_token
FROM address_books
WHERE user_id = $1
@@ -1096,9 +1612,18 @@ export async function reconcileStaleCarddavBooks(client, userId, { seenUrls }) {
[userId],
);
+ // A carddav book is stale only when neither URL discovery might report for it
+ // — its canonical external_url nor the discovery_alias_url a redirect server
+ // keeps advertising — was seen this sync. Matching external_url alone would
+ // reconcile away an ignored/subscribed book whose row was already rewritten
+ // to canonical but that discovery still surfaces under its alias. A row with
+ // no discovery_alias_url (never canonicalized, or transitional schema) falls
+ // back to external_url matching. seenUrls never contains null/undefined.
const seen = new Set(seenUrls);
const staleBookIds = books
- .filter(book => book.source === 'carddav' && !seen.has(book.external_url))
+ .filter(book => book.source === 'carddav'
+ && !seen.has(book.external_url)
+ && !seen.has(book.discovery_alias_url))
.map(book => book.id)
.sort();
if (!staleBookIds.length) return [];
@@ -1120,7 +1645,8 @@ export async function finalizeCarddavSyncTransaction(client, userId, {
if (!integration) throw new StaleCarddavPlanError({ reason: 'not-connected' });
const actualGeneration = integration.config?.connectionGeneration ?? null;
assertConnectionGeneration(actualGeneration, connectionGeneration);
- await reconcileStaleCarddavBooks(client, userId, { seenUrls });
+ const staleBookIds = await reconcileStaleCarddavBooks(client, userId, { seenUrls });
+ if (staleBookIds.length) await claimBestWriteTargetCandidate(client, userId);
const contactCount = await countMaterializedCarddavContacts(client, userId);
const updated = await client.query(
`UPDATE user_integrations
@@ -1189,24 +1715,213 @@ export async function recordCarddavSyncFailure(userId, expectedGeneration, error
return result.rowCount === 1;
}
-async function unmappedExplicitContactIds(userId) {
- const { rows } = await query(
- `SELECT c.id
- FROM contacts c
- WHERE c.user_id = $1 AND c.is_auto = false
- AND NOT EXISTS (
- SELECT 1 FROM carddav_remote_objects mapping
- WHERE mapping.local_contact_id = c.id
- )
- ORDER BY c.id`,
+// Load each of a user's carddav books' stored roles, keyed by both its
+// canonical external_url and (if present) the discovery alias a fresh
+// PROPFIND may still advertise, so a snapshot book resolves by whichever URL
+// discovery returns. Returns null on a pre-multi-book schema (undefined_column
+// SQLSTATE), signalling syncUser to fall back to the legacy "materialize every
+// discovered book" behavior.
+async function loadCarddavBookRoles(userId) {
+ let rows;
+ try {
+ ({ rows } = await query(
+ `SELECT id, external_url, discovery_alias_url,
+ is_write_target, is_subscribed, is_lookup_source
+ FROM address_books
+ WHERE user_id = $1 AND source = 'carddav'`,
+ [userId],
+ ));
+ } catch (error) {
+ if (error?.code === '42703') return null;
+ throw error;
+ }
+ const roles = new Map();
+ for (const row of rows) {
+ const role = {
+ id: row.id,
+ externalUrl: row.external_url,
+ isWriteTarget: row.is_write_target,
+ isSubscribed: row.is_subscribed,
+ isLookupSource: row.is_lookup_source,
+ };
+ if (row.external_url) roles.set(row.external_url, role);
+ if (row.discovery_alias_url) roles.set(row.discovery_alias_url, role);
+ }
+ return roles;
+}
+
+// How a discovered book should be pulled this sync, from its stored roles:
+// - 'ignored' (both flags false) — skipped at the network layer entirely;
+// - 'materialize' (subscribed) — pulled into the contacts list as today;
+// - 'lookup' (lookup-only, or a freshly discovered book that defaults to
+// lookup-only, or a first-connect book that is not the
+// resolved write-target) — pulled into the ledger only.
+// A null roles map (pre-multi-book schema) always materializes, preserving
+// legacy behavior. A book unknown to the map is being discovered for the first
+// time: it materializes only if it is the resolved write-target-to-be (see
+// resolveWriteTargetUrl), otherwise it defaults to lookup-only.
+function bookProjectionKind(roles, url, writeTargetUrl) {
+ if (roles === null) return 'materialize';
+ const role = roles.get(url);
+ if (role) {
+ if (!role.isSubscribed && !role.isLookupSource) return 'ignored';
+ return role.isSubscribed ? 'materialize' : 'lookup';
+ }
+ return url === writeTargetUrl ? 'materialize' : 'lookup';
+}
+
+// An ignored book is never pulled again, so whatever its last pull left in the
+// ledger is frozen there: rows that resolve no inbound sender (every lookup
+// probe joins on is_lookup_source) yet still count toward the cached
+// contactCount. Drop them on the first sync after the book was ignored — the
+// design's "its ledger rows are dropped on the next sync and it is skipped
+// thereafter" — and hand the cached count back the rows it no longer holds.
+// Idempotent: every later sync of that book deletes nothing.
+//
+// The ledger only. The book's materialized contacts and conflicts are already
+// gone (patchCarddavBookRoles demotes a subscribed book on the way to ignored),
+// and the book row itself survives so its ignored roles are not reconciled away
+// and re-discovered as lookup-only.
+//
+// The caller's 'ignored' classification comes from the role snapshot syncUser
+// took before its (possibly slow) network pulls, so re-read the book's live
+// roles under the user_integrations lock this transaction now holds — exactly as
+// the materializing branch of applyBookDelta does. patchCarddavBookRoles
+// serializes on that same lock, so a Look-up-senders (or Subscribe) toggle that
+// committed while the pull was in flight is visible here, and dropping on the
+// stale decision would wipe the ledger the user just re-enabled. Skipping the
+// drop is all this owes such a book: the patch that re-enabled it scheduled its
+// full re-pull (scheduleFullReconcile) and its route queued the uncoalesced
+// follow-up sync that runs it.
+async function dropIgnoredBookLedger(client, userId, bookId) {
+ const { rows: [integration] } = await client.query(
+ `SELECT id, config->>'contactCount' AS contact_count
+ FROM user_integrations
+ WHERE user_id = $1 AND provider = 'carddav'
+ FOR UPDATE`,
[userId],
);
+ if (!integration) throw new StaleCarddavPlanError({ reason: 'not-connected' });
+ const { rows: [role] } = await client.query(
+ 'SELECT is_subscribed, is_lookup_source FROM address_books WHERE id = $1',
+ [bookId],
+ );
+ if (!role || role.is_subscribed || role.is_lookup_source) return 0;
+ const dropped = await client.query(
+ 'DELETE FROM carddav_remote_objects WHERE address_book_id = $1',
+ [bookId],
+ );
+ if (!dropped.rowCount) return 0;
+ await persistCarddavContactCount(client, integration, userId);
+ return dropped.rowCount;
+}
+
+// When none of the currently discovered books is already the write-target,
+// resolve the one that will become it, so it materializes (and is inserted
+// subscribed) in this very sync rather than one sync late. Covers a fresh first
+// connect (no books yet) and a connection replacement (a new server's books
+// replace an old write-target that this same sync will reconcile away). Ranks
+// the discovery snapshot exactly as the batch-wide claimBestWriteTargetCandidate
+// will once the rows exist — create 'allowed' before 'unknown', then discovery
+// order, and never a book the user ignored — so the book we materialize and the
+// book the claim flags as the write-target are always the same one. Returns null
+// when a discovered book already holds the write-target (its stored role drives
+// materialization) or when no discovered book is an eligible candidate.
+function resolveWriteTargetUrl(roles, books) {
+ if (roles === null) return null;
+ if (books.some(book => roles.get(book.url)?.isWriteTarget)) return null;
+ const ranked = books
+ .filter(book => {
+ const role = roles.get(book.url);
+ if (role && !role.isSubscribed && !role.isLookupSource) return false;
+ return (book.capabilities?.create ?? 'unknown') !== 'denied';
+ })
+ .sort((left, right) => (
+ (right.capabilities?.create === 'allowed' ? 1 : 0)
+ - (left.capabilities?.create === 'allowed' ? 1 : 0)
+ || Number(left.discoveryIndex ?? 0) - Number(right.discoveryIndex ?? 0)
+ ));
+ return ranked[0]?.url ?? null;
+}
+
+// The export sweep only ever has somewhere to send a contact when the user has
+// designated a write-target book; skip the whole sweep (not just each write)
+// when none is configured, rather than recomputing and recording the same
+// ERR_CARDDAV_NO_WRITE_TARGET failure for every unmapped explicit contact. On a
+// not-yet-migrated (transitional) schema — detected via PostgreSQL's
+// undefined_column SQLSTATE, see carddavContactService.js's isUndefinedColumn —
+// run the sweep unconditionally exactly as it did before this gate existed.
+async function hasWriteTargetBook(userId) {
+ let rows;
+ try {
+ ({ rows } = await query(
+ `SELECT 1 FROM address_books
+ WHERE user_id = $1 AND source = 'carddav' AND is_write_target = true
+ LIMIT 1`,
+ [userId],
+ ));
+ } catch (error) {
+ if (error?.code === '42703') return true;
+ throw error;
+ }
+ return rows.length > 0;
+}
+
+// The contacts the sweep publishes to the write-target book.
+//
+// is_auto = false is necessary but not sufficient. send.js flips it for anyone
+// the user emails (and search.js ranks autocomplete on it, so it stays), which
+// would make a reply to a harvested sender enough to publish them to a shared
+// address book. Publication additionally requires carddav_publish_intent — set
+// only by a deliberate act: creating the contact, or promoting it.
+//
+// `publishEmailedContacts` is the user's opt-in to the other reading, where
+// emailing someone *is* the act of adding them (the "Email contacts book"
+// workflow): it re-admits the explicit-but-unintended contacts, and nothing else
+// — a merely harvested contact (is_auto = true) is still never published.
+async function unmappedExplicitContactIds(userId, publishEmailedContacts) {
+ let rows;
+ try {
+ ({ rows } = await query(
+ `SELECT c.id
+ FROM contacts c
+ WHERE c.user_id = $1 AND c.is_auto = false
+ AND (c.carddav_publish_intent = true OR $2 = true)
+ AND NOT EXISTS (
+ SELECT 1 FROM carddav_remote_objects mapping
+ WHERE mapping.local_contact_id = c.id
+ )
+ ORDER BY c.id`,
+ [userId, publishEmailedContacts === true],
+ ));
+ } catch (error) {
+ if (error?.code !== '42703') throw error;
+ ({ rows } = await query(
+ `SELECT c.id
+ FROM contacts c
+ WHERE c.user_id = $1 AND c.is_auto = false
+ AND NOT EXISTS (
+ SELECT 1 FROM carddav_remote_objects mapping
+ WHERE mapping.local_contact_id = c.id
+ )
+ ORDER BY c.id`,
+ [userId],
+ ));
+ }
return rows.map(row => row.id);
}
-export function requestCarddavSync(userId, connectionGeneration) {
+// Start a sync, or — when one is already in flight — decide whether it stands in
+// for the requested one. It does when the caller's change *is* the new connection
+// generation (connect, credential patch): the in-flight sync already reads it, or
+// it is running against the superseded generation and gets queued for replacement.
+// Callers whose change leaves the generation untouched (a book role patch) cannot
+// know whether the in-flight sync read it before or after its own book loop passed
+// that book, so they pass `coalesce: false` to always queue a follow-up rather than
+// risk dropping the pull-side effect until the next scheduled tick.
+export function requestCarddavSync(userId, connectionGeneration, { coalesce = true } = {}) {
if (syncing.has(userId)) {
- if (activeSyncGenerations.get(userId) !== connectionGeneration) {
+ if (!coalesce || activeSyncGenerations.get(userId) !== connectionGeneration) {
pendingReplacementSyncs.add(userId);
}
return false;
@@ -1247,9 +1962,38 @@ export async function syncUser(userId, expectedGeneration) {
integration: { config },
creds,
});
+
+ // Resolve each discovered book's role before pulling. Ignored books (both
+ // flags false) are skipped at the network layer entirely; subscribed books
+ // materialize and lookup-only books populate the ledger only. A freshly
+ // discovered book defaults to lookup-only unless it is the resolved
+ // first-connect write-target, which materializes in this same sync.
+ const roles = await loadCarddavBookRoles(userId);
+ const writeTargetUrl = resolveWriteTargetUrl(roles, books);
const plans = [];
+ const plannedBooks = [];
+ const ignoredUrls = [];
for (const book of books) {
- plans.push(await prepareBookPlan(userId, book, creds));
+ const kind = bookProjectionKind(roles, book.url, writeTargetUrl);
+ if (kind === 'ignored') {
+ // A book only resolves to 'ignored' when it has a stored role, so its
+ // row (id, externalUrl) is always known here.
+ const role = roles.get(book.url);
+ // Record the ignored book as seen by its persisted external_url, not the
+ // discovery URL: a canonicalized book is stored under its canonical URL
+ // while discovery keeps advertising the alias, and reconciliation
+ // matches seen URLs against external_url. Using book.url here would let
+ // reconcileStaleCarddavBooks delete the ignored canonical row and the
+ // next sync rediscover it fresh as lookup-only.
+ ignoredUrls.push(role.externalUrl ?? book.url);
+ await withTransaction(client => dropIgnoredBookLedger(client, userId, role.id));
+ continue;
+ }
+ const plan = await prepareBookPlan(userId, book, creds);
+ plan.materialize = kind === 'materialize';
+ plan.book.subscribeOnCreate = book.url === writeTargetUrl;
+ plans.push(plan);
+ plannedBooks.push(book);
}
for (let index = 0; index < plans.length; index++) {
@@ -1269,7 +2013,11 @@ export async function syncUser(userId, expectedGeneration) {
&& freshConfig.connectionGeneration !== plan.connectionGeneration) {
throw error;
}
- plan = await prepareBookPlan(userId, books[index], creds);
+ plan = await prepareBookPlan(userId, plannedBooks[index], creds);
+ plan.materialize = bookProjectionKind(
+ roles, plannedBooks[index].url, writeTargetUrl,
+ ) === 'materialize';
+ plan.book.subscribeOnCreate = plannedBooks[index].url === writeTargetUrl;
applied = await withTransaction(client => applyBookDelta(client, plan));
break;
}
@@ -1286,29 +2034,46 @@ export async function syncUser(userId, expectedGeneration) {
removed: applied.removed,
});
}
+ if (books.length) {
+ // Rank the *whole* discovery snapshot at once, now that every book in
+ // it has been persisted — not per book, in discovery order — so a
+ // later 'allowed' book can still win the write-target over an earlier
+ // 'unknown' one from the very same connect/sync batch (see
+ // createCarddavBook's deferred claim above). Runs before the export
+ // sweep below so a fresh multi-book connect's write-target is already
+ // available to it in this same sync.
+ await withTransaction(client => claimBestWriteTargetCandidate(client, userId));
+ }
const exportFailures = [];
- for (const contactId of await unmappedExplicitContactIds(userId)) {
- try {
- await exportExistingContact(userId, contactId, {
- books,
- expectedGeneration: config.connectionGeneration,
- });
- } catch (error) {
- if (error instanceof CardDavError
- && error.status === 429
- && activeRetryAfterAt(error)) {
- throw error;
+ if (await hasWriteTargetBook(userId)) {
+ const publishable = await unmappedExplicitContactIds(userId, config.publishEmailedContacts);
+ for (const contactId of publishable) {
+ try {
+ await exportExistingContact(userId, contactId, {
+ books,
+ expectedGeneration: config.connectionGeneration,
+ });
+ } catch (error) {
+ if (error instanceof CardDavError
+ && error.status === 429
+ && activeRetryAfterAt(error)) {
+ throw error;
+ }
+ exportFailures.push({
+ localContactId: contactId,
+ code: error.code || 'ERR_CARDDAV_EXPORT',
+ message: error.message,
+ });
}
- exportFailures.push({
- localContactId: contactId,
- code: error.code || 'ERR_CARDDAV_EXPORT',
- message: error.message,
- });
}
}
- const seenUrls = plans.map(plan => (
- plan.collectionIdentity?.canonicalUrl ?? plan.book.url
- ));
+ // Ignored books are not pulled but must still count as "seen" so the stale
+ // reconciliation keeps their row (and its role flags) rather than deleting
+ // it and re-discovering it fresh as lookup-only on the next sync.
+ const seenUrls = [
+ ...plans.map(plan => plan.collectionIdentity?.canonicalUrl ?? plan.book.url),
+ ...ignoredUrls,
+ ];
const contactCount = await finalizeCarddavSync(userId, {
seenUrls,
connectionGeneration: config.connectionGeneration,
diff --git a/backend/src/services/carddavSync.test.js b/backend/src/services/carddavSync.test.js
index 47558a6..c213426 100644
--- a/backend/src/services/carddavSync.test.js
+++ b/backend/src/services/carddavSync.test.js
@@ -106,6 +106,8 @@ function applyClient({
lifecycleBooks = [],
contactCount = 0,
ledger = new Set(),
+ hasDiscoveryAlias = false,
+ bookRow = null,
} = {}) {
return {
query: vi.fn(async (sql, params) => {
@@ -120,12 +122,12 @@ function applyClient({
}],
} : { rows: [] };
}
- if (/SELECT id, source, external_url, sync_token[\s\S]+FROM address_books/.test(sql)) {
+ if (/SELECT id, source, external_url[\s\S]*FROM address_books[\s\S]+FOR UPDATE/.test(sql)) {
return { rows: lifecycleBooks };
}
if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) {
return bookPresent ? {
- rows: [{
+ rows: [bookRow ?? {
id: BOOK_ID,
external_url: params[1][0],
remote_sync_token: remoteToken,
@@ -139,6 +141,7 @@ function applyClient({
? { rows: [{
id: '00000000-0000-4000-8000-000000000006',
has_legacy_projection: false,
+ has_discovery_alias: hasDiscoveryAlias,
connection_generation: connectionGeneration,
contact_count: String(contactCount),
}] }
@@ -647,6 +650,77 @@ describe('applyBookDelta', () => {
]);
});
+ it('resolves an already-canonicalized book seen again via its alias by discovery_alias_url', async () => {
+ // The book was canonicalized on an earlier sync: external_url now holds the
+ // canonical URL and discovery_alias_url retains the alias the server keeps
+ // advertising. Discovery returns the alias again, so the plan observes the
+ // alias but reconciles to the canonical URL. The locked row (matched by
+ // discovery_alias_url on the alias-aware schema) must resolve normally, not
+ // be rejected as observed-alias-missing.
+ const client = applyClient({
+ hasDiscoveryAlias: true,
+ bookRow: {
+ id: BOOK_ID,
+ external_url: CANONICAL_BOOK_URL,
+ discovery_alias_url: ALIAS_BOOK_URL,
+ remote_sync_token: null,
+ remote_sync_revision: '0',
+ sync_token: 'local-token-before',
+ },
+ });
+
+ await carddavSync.applyBookDelta(client, completePlan({
+ book: { url: ALIAS_BOOK_URL, displayName: 'Remote' },
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ }));
+
+ // The lock uses the alias-aware predicate and the book advances in place —
+ // no new row is inserted for the alias.
+ const bookLock = client.query.mock.calls.find(([sql]) => (
+ /external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)
+ ));
+ expect(bookLock[0]).toMatch(/discovery_alias_url = ANY\(\$2::text\[\]\)/);
+ expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO address_books')))
+ .toBe(false);
+ const bookUpdate = client.query.mock.calls.find(([sql]) => /UPDATE address_books SET/.test(sql));
+ expect(bookUpdate[0]).toMatch(/remote_sync_revision = remote_sync_revision \+ 1/);
+ });
+
+ it('resolves an already-canonicalized book on a transitional schema by canonical URL', async () => {
+ // Same scenario, but the schema predates discovery_alias_url (migration
+ // 0034): the locked row carries no alias column, yet the row was still
+ // rewritten to its canonical external_url. The canonical-URL fallback must
+ // find it rather than inserting a duplicate or rejecting the sync.
+ const client = applyClient({
+ hasDiscoveryAlias: false,
+ bookRow: {
+ id: BOOK_ID,
+ external_url: CANONICAL_BOOK_URL,
+ remote_sync_token: null,
+ remote_sync_revision: '0',
+ sync_token: 'local-token-before',
+ },
+ });
+
+ await carddavSync.applyBookDelta(client, completePlan({
+ book: { url: ALIAS_BOOK_URL, displayName: 'Remote' },
+ collectionIdentity: {
+ observedUrl: ALIAS_BOOK_URL,
+ canonicalUrl: CANONICAL_BOOK_URL,
+ },
+ }));
+
+ const bookLock = client.query.mock.calls.find(([sql]) => (
+ /external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)
+ ));
+ expect(bookLock[0]).not.toMatch(/discovery_alias_url/);
+ expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO address_books')))
+ .toBe(false);
+ });
+
it('rejects a zero-row final book update as stale', async () => {
const client = applyClient({ bookUpdateCount: 0 });
@@ -1220,6 +1294,161 @@ describe('prepareBookPlan fences', () => {
expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
expect(mocks.withTransaction).not.toHaveBeenCalled();
});
+
+ it('resolves the planning join by discovery_alias_url when discovery returns the alias', async () => {
+ // The book was rewritten to its canonical external_url on an earlier sync
+ // and retains the alias in discovery_alias_url. Discovery now returns the
+ // alias, so the join must match on discovery_alias_url to keep the book's
+ // stored incremental token — an external_url-only join would miss the row
+ // and force a full re-pull that then fails the apply step's alias check.
+ mocks.query.mockImplementation(async (sql, params) => {
+ expect(params).toEqual([USER_ID, ALIAS_BOOK_URL]);
+ if (/b\.discovery_alias_url = \$2/.test(sql)) {
+ return { rows: [{
+ book_id: BOOK_ID,
+ connection_generation: CONNECTION_GENERATION,
+ remote_sync_token: 'stored-token',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '5',
+ }] };
+ }
+ return { rows: [{
+ book_id: null,
+ connection_generation: CONNECTION_GENERATION,
+ remote_sync_token: null,
+ remote_sync_capability: null,
+ remote_sync_revision: null,
+ }] };
+ });
+ mocks.fetchAddressBookDelta.mockImplementation(async request => ({
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: 'after',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: ALIAS_BOOK_URL, canonicalUrl: ALIAS_BOOK_URL },
+ upserts: [],
+ removedHrefs: [],
+ }));
+
+ const plan = await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ { username: 'user', password: 'password' },
+ );
+
+ expect(plan).toMatchObject({
+ expectedRemoteToken: 'stored-token',
+ expectedRemoteRevision: '5',
+ });
+ expect(mocks.query.mock.calls[0][0]).toMatch(
+ /b\.external_url = \$2 OR b\.discovery_alias_url = \$2/,
+ );
+ });
+
+ it('falls back to an external_url-only planning join on a not-yet-migrated schema', async () => {
+ let aliasAttempts = 0;
+ mocks.query.mockImplementation(async sql => {
+ if (/discovery_alias_url/.test(sql)) {
+ aliasAttempts++;
+ throw Object.assign(
+ new Error('column "discovery_alias_url" does not exist'),
+ { code: '42703' },
+ );
+ }
+ return { rows: [{
+ book_id: BOOK_ID,
+ connection_generation: CONNECTION_GENERATION,
+ remote_sync_token: 'stored-token',
+ remote_sync_capability: 'sync-collection',
+ remote_sync_revision: '5',
+ }] };
+ });
+ mocks.fetchAddressBookDelta.mockImplementation(async request => ({
+ expectedRemoteToken: request.syncToken,
+ nextRemoteToken: 'after',
+ capability: 'sync-collection',
+ replaceAll: false,
+ collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL },
+ upserts: [],
+ removedHrefs: [],
+ }));
+
+ const plan = await carddavSync.prepareBookPlan(
+ USER_ID,
+ { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true },
+ { username: 'user', password: 'password' },
+ );
+
+ expect(aliasAttempts).toBe(1);
+ expect(plan).toMatchObject({
+ expectedRemoteToken: 'stored-token',
+ expectedRemoteRevision: '5',
+ });
+ const plainCall = mocks.query.mock.calls.find(([sql]) => !/discovery_alias_url/.test(sql));
+ expect(plainCall[0]).toMatch(/b\.external_url = \$2/);
+ });
+});
+
+describe('reconcileStaleCarddavBooks alias handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ function reconcileClient(bookRows) {
+ const deletes = [];
+ const client = {
+ query: vi.fn(async (sql, params) => {
+ if (/SELECT id, source, external_url[\s\S]*FROM address_books[\s\S]+FOR UPDATE/.test(sql)) {
+ return { rows: bookRows };
+ }
+ if (/DELETE FROM address_books/.test(sql)) {
+ deletes.push(params);
+ return { rowCount: params[1].length };
+ }
+ if (/count\(\*\)::int/.test(sql)) return { rows: [{ count: 0 }] };
+ return { rows: [], rowCount: 0 };
+ }),
+ };
+ return { client, deletes };
+ }
+
+ it('keeps a carddav book discovery surfaced only under its alias', async () => {
+ const canonicalUrl = 'https://dav.example.test/addressbooks/canonical/';
+ const aliasUrl = 'https://dav.example.test/addressbooks/alias/';
+ const { client, deletes } = reconcileClient([{
+ id: BOOK_ID,
+ source: 'carddav',
+ external_url: canonicalUrl,
+ discovery_alias_url: aliasUrl,
+ sync_token: null,
+ }]);
+
+ const result = await carddavSync.reconcileStaleCarddavBooks(client, USER_ID, {
+ seenUrls: [aliasUrl],
+ });
+
+ expect(deletes).toEqual([]);
+ expect(result).toEqual([]);
+ });
+
+ it('still deletes a carddav book neither URL was seen for', async () => {
+ const goneUrl = 'https://dav.example.test/addressbooks/gone/';
+ const { client, deletes } = reconcileClient([{
+ id: BOOK_ID,
+ source: 'carddav',
+ external_url: goneUrl,
+ discovery_alias_url: null,
+ sync_token: null,
+ }]);
+
+ const result = await carddavSync.reconcileStaleCarddavBooks(client, USER_ID, {
+ seenUrls: ['https://dav.example.test/addressbooks/other/'],
+ });
+
+ expect(deletes).toHaveLength(1);
+ expect(deletes[0]).toEqual([USER_ID, [BOOK_ID]]);
+ expect(result).toEqual([BOOK_ID]);
+ });
});
describe('pull-first automatic export orchestration', () => {
@@ -1264,6 +1493,7 @@ describe('pull-first automatic export orchestration', () => {
if (/mapping.local_contact_id = c.id/.test(sql)) {
return { rows: [{ id: 'local-a' }, { id: 'local-b' }] };
}
+ if (sql.includes('is_write_target')) return { rows: [{ '?column?': 1 }] };
return { rows: [], rowCount: 0 };
});
mocks.discoverAddressBooks.mockResolvedValue(books);
@@ -1279,6 +1509,9 @@ describe('pull-first automatic export orchestration', () => {
mocks.withTransaction
.mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 })
.mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 })
+ // The batch-wide write-target claim (see claimBestWriteTargetCandidate),
+ // once every book of this snapshot has been persisted.
+ .mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(0);
mocks.exportExistingContact
.mockRejectedValueOnce(Object.assign(new Error('read only'), {
@@ -1297,8 +1530,14 @@ describe('pull-first automatic export orchestration', () => {
.toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]);
expect(mocks.withTransaction.mock.invocationCallOrder[1])
.toBeLessThan(mocks.exportExistingContact.mock.invocationCallOrder[0]);
+ // The write-target claim (3rd withTransaction call) ranks the whole
+ // snapshot once every book has been persisted, before the export sweep
+ // starts, so a fresh multi-book connect's write-target is available to
+ // it within this same sync.
+ expect(mocks.withTransaction.mock.invocationCallOrder[2])
+ .toBeLessThan(mocks.exportExistingContact.mock.invocationCallOrder[0]);
expect(mocks.exportExistingContact.mock.invocationCallOrder[1])
- .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[2]);
+ .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[3]);
expect(result).toMatchObject({
ok: true,
exportFailures: [{
@@ -1308,6 +1547,113 @@ describe('pull-first automatic export orchestration', () => {
}],
});
});
+
+ it('skips the export sweep entirely when no write-target book is configured', async () => {
+ const books = [{
+ url: BOOK_URL,
+ displayName: 'Remote',
+ supportsSyncCollection: true,
+ discoveryIndex: 0,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ }];
+ mocks.query.mockImplementation(async sql => {
+ if (/SELECT config FROM user_integrations/.test(sql)) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ if (/SELECT remote_sync_token/.test(sql)) {
+ return { rows: [{
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ // No is_write_target row for this user: the whole sweep, including the
+ // unmapped-explicit-contact query, must be skipped rather than surfacing
+ // one ERR_CARDDAV_NO_WRITE_TARGET failure per unmapped contact.
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue(books);
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'token',
+ capability: 'sync-collection',
+ replaceAll: true,
+ collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL },
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction
+ .mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 })
+ .mockResolvedValueOnce(undefined) // batch-wide write-target claim
+ .mockResolvedValueOnce(0);
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true, exportFailures: [] });
+ expect(mocks.exportExistingContact).not.toHaveBeenCalled();
+ expect(mocks.query.mock.calls.some(([sql]) => /c\.is_auto = false/.test(sql))).toBe(false);
+ });
+
+ it('runs the export sweep unconditionally on a not-yet-migrated (transitional) schema', async () => {
+ const books = [{
+ url: BOOK_URL,
+ displayName: 'Remote',
+ supportsSyncCollection: true,
+ discoveryIndex: 0,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ }];
+ mocks.query.mockImplementation(async sql => {
+ if (/SELECT config FROM user_integrations/.test(sql)) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ if (/SELECT remote_sync_token/.test(sql)) {
+ return { rows: [{
+ remote_sync_token: null,
+ remote_sync_capability: 'unknown',
+ remote_sync_revision: '0',
+ connection_generation: CONNECTION_GENERATION,
+ }] };
+ }
+ if (/mapping.local_contact_id = c.id/.test(sql)) return { rows: [{ id: 'local-a' }] };
+ if (sql.includes('is_write_target')) {
+ throw Object.assign(new Error('column "is_write_target" does not exist'), { code: '42703' });
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockResolvedValue(books);
+ mocks.fetchAddressBookDelta.mockResolvedValue({
+ expectedRemoteToken: null,
+ nextRemoteToken: 'token',
+ capability: 'sync-collection',
+ replaceAll: true,
+ collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL },
+ upserts: [],
+ removedHrefs: [],
+ });
+ mocks.withTransaction
+ .mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 })
+ .mockResolvedValueOnce(undefined) // batch-wide write-target claim
+ .mockResolvedValueOnce(0);
+ mocks.exportExistingContact.mockResolvedValueOnce({ id: 'local-a' });
+
+ const result = await carddavSync.syncUser(USER_ID);
+
+ expect(result).toMatchObject({ ok: true });
+ expect(mocks.exportExistingContact).toHaveBeenCalledWith(
+ USER_ID, 'local-a', { books, expectedGeneration: CONNECTION_GENERATION },
+ );
+ });
});
describe('network planning orchestration', () => {
@@ -1328,6 +1674,7 @@ describe('network planning orchestration', () => {
});
mocks.withTransaction
.mockImplementationOnce(callback => callback(client))
+ .mockResolvedValueOnce(undefined) // batch-wide write-target claim
.mockResolvedValueOnce(0);
await expect(carddavSync.syncUser(USER_ID)).resolves.toMatchObject({ ok: true });
@@ -1422,7 +1769,8 @@ describe('network planning orchestration', () => {
removed: 0,
fallback: 0,
});
- expect(mocks.withTransaction).toHaveBeenCalledTimes(3);
+ // Two books, plus the batch-wide write-target claim, plus finalization.
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(4);
expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2);
expect(mocks.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT remote_sync_token'),
@@ -1481,6 +1829,7 @@ describe('network planning orchestration', () => {
});
mocks.withTransaction
.mockImplementationOnce(callback => callback(apply))
+ .mockImplementationOnce(callback => callback(apply)) // batch-wide write-target claim
.mockImplementationOnce(callback => callback(finalize));
const result = await carddavSync.syncUser(USER_ID);
@@ -1542,6 +1891,7 @@ describe('network planning orchestration', () => {
connectionGeneration: CONNECTION_GENERATION,
} }] };
}
+ if (sql.includes('is_write_target')) return { rows: [{ '?column?': 1 }] };
return { rows: [], rowCount: 0 };
});
mocks.discoverAddressBooks.mockResolvedValue([]);
@@ -1553,8 +1903,12 @@ describe('network planning orchestration', () => {
expect(result).toEqual({ ok: false, error: 'not connected', ...EMPTY_COUNTERS });
expect(finalize.query).toHaveBeenCalledOnce();
expect(finalize.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/);
- expect(mocks.query).toHaveBeenCalledTimes(2);
- expect(mocks.query.mock.calls[1][0]).toMatch(/c\.is_auto = false/);
+ expect(mocks.query).toHaveBeenCalledTimes(4);
+ // Per-book role load (multi-book Slice 3), then the write-target sweep gate,
+ // then the unmapped-explicit-contact query.
+ expect(mocks.query.mock.calls[1][0]).toMatch(/is_lookup_source/);
+ expect(mocks.query.mock.calls[2][0]).toMatch(/is_write_target = true/);
+ expect(mocks.query.mock.calls[3][0]).toMatch(/c\.is_auto = false/);
});
it('does not open a transaction or write when a later book multiget batch fails', async () => {
@@ -1765,7 +2119,9 @@ describe('network planning orchestration', () => {
const result = await carddavSync.syncUser(USER_ID);
expect(result).toMatchObject({ ok: true, bookCount: 2 });
- expect(mocks.withTransaction).toHaveBeenCalledTimes(4);
+ // bookA + bookB + one refetched retry of bookB, plus the batch-wide
+ // write-target claim, plus finalization.
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(5);
expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => [request.url, request.syncToken]))
.toEqual([
[BOOK_URL, 'first-before'],
@@ -1890,13 +2246,14 @@ describe('network planning orchestration', () => {
reason: 'projection-footprint-changed',
}))
.mockImplementationOnce(callback => callback(applyClient()))
+ .mockImplementationOnce(callback => callback(applyClient())) // batch-wide write-target claim
.mockImplementationOnce(callback => callback(applyClient({ lifecycleBooks: [] })));
const result = await carddavSync.syncUser(USER_ID);
expect(result).toMatchObject({ ok: true, bookCount: 1 });
expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce();
- expect(mocks.withTransaction).toHaveBeenCalledTimes(3);
+ expect(mocks.withTransaction).toHaveBeenCalledTimes(4);
});
it('runs the latest committed replacement despite delayed queue request order', async () => {
@@ -2008,6 +2365,53 @@ describe('network planning orchestration', () => {
expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce();
});
+
+ it('queues a follow-up sync for a same-generation change the in-flight sync may have missed', async () => {
+ const started = deferred();
+ const release = deferred();
+ const followUpStarted = deferred();
+ let discoveries = 0;
+ mocks.query.mockImplementation(async sql => {
+ if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) {
+ return { rows: [{ config: {
+ serverUrl: 'https://dav.example.test/',
+ username: 'user',
+ password: 'encrypted',
+ connectionGeneration: CONNECTION_GENERATION,
+ } }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ mocks.discoverAddressBooks.mockImplementation(async () => {
+ discoveries++;
+ if (discoveries === 1) {
+ started.resolve();
+ await release.promise;
+ } else {
+ followUpStarted.resolve();
+ }
+ return [];
+ });
+ mocks.withTransaction.mockImplementation(callback => callback(applyClient({
+ connectionGeneration: CONNECTION_GENERATION,
+ lifecycleBooks: [],
+ })));
+
+ expect(carddavSync.requestCarddavSync(USER_ID, CONNECTION_GENERATION)).toBe(true);
+ await started.promise;
+ // A book role patch commits without bumping the generation, so the in-flight
+ // sync may already be past the book it changed. Opting out of coalescing
+ // queues a re-run rather than dropping the pull-side effect until the next tick.
+ expect(carddavSync.requestCarddavSync(USER_ID, CONNECTION_GENERATION, { coalesce: false }))
+ .toBe(false);
+ expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce();
+
+ release.resolve();
+ await followUpStarted.promise;
+ await new Promise(resolve => setImmediate(resolve));
+
+ expect(mocks.discoverAddressBooks).toHaveBeenCalledTimes(2);
+ });
});
describe('CardDAV scheduler maintenance', () => {
diff --git a/backend/src/services/messageService.js b/backend/src/services/messageService.js
index b65c863..a0fdcea 100644
--- a/backend/src/services/messageService.js
+++ b/backend/src/services/messageService.js
@@ -1,4 +1,70 @@
import { query } from './db.js';
+import { resolveLookupPhotos } from './carddavLookupService.js';
+
+// Resolve an inbound sender against the user's lookup-only CardDAV books — ledger
+// rows (mapping_status='lookup') that are retained for sender resolution but never
+// materialized as contacts (multi-book-design.md, Slice 4). LATERAL + LIMIT 1
+// yields at most one match, so a sender present in several lookup books never
+// multiplies message rows. It feeds two things below: a fallback display name when
+// the message header carries none, and `matched` — a marker that a retained vCard
+// exists for this sender.
+//
+// `matched` deliberately does NOT try to decide whether that vCard yields a
+// servable avatar: a syntactic "has a PHOTO property" check diverges from the
+// bounded decode the photo endpoint actually runs — it would miss a grouped
+// `item1.PHOTO` (a false no-avatar) and falsely promise a photo for an oversized,
+// URL-only, or malformed one (a guaranteed 404 GET /api/contacts/photo). Instead
+// SQL only flags the sender as a photo *candidate* (co.id IS NULL AND matched);
+// applyLookupPhotoGate below resolves each candidate through the real decode path
+// (resolveLookupPhoto), so has_contact_photo never promises a photo the endpoint
+// would 404 on, nor hides one it would serve.
+const LOOKUP_SENDER_JOIN = `
+ LEFT JOIN LATERAL (
+ SELECT lo.lookup_display_name, true AS matched
+ FROM carddav_remote_objects lo
+ JOIN address_books lab ON lab.id = lo.address_book_id
+ WHERE lo.mapping_status = 'lookup'
+ AND lo.primary_email = lower(m.from_email)
+ AND lab.user_id = a.user_id
+ AND lab.source = 'carddav'
+ AND lab.is_lookup_source = true
+ ORDER BY lo.updated_at DESC
+ LIMIT 1
+ ) lookup ON true`;
+
+// Turn the SQL photo *candidates* (co.id IS NULL AND a lookup vCard matched) into a
+// truthful has_contact_photo by running the same bounded decode the photo endpoint
+// uses. resolveLookupPhoto is memoized, so this also primes the cache that the
+// subsequent GET /api/contacts/photo reads from. Materialized-contact photos are
+// already resolved in SQL (co.id IS NOT NULL) and never re-checked here, so they
+// keep winning over the ledger fallback. Mutates and returns the rows.
+//
+// Candidates are collected as one set of distinct normalized sender emails and
+// resolved in a single batched probe (resolveLookupPhotos), so a page with N
+// distinct lookup senders costs one DB round-trip, not N. Fanning out one query
+// per sender would flood the connection pool on a large page, and — because the
+// in-process LRU cannot dedupe concurrent misses — race every duplicate of a
+// repeated sender to its own DB read + vCard decode (a cache stampede). The
+// batched probe both bounds the pool cost and shares the decode.
+async function applyLookupPhotoGate(userId, rows) {
+ const normalize = row => String(row.from_email ?? '').trim().toLowerCase();
+
+ const candidateEmails = new Set();
+ for (const row of rows) {
+ if (row.lookup_photo_candidate) candidateEmails.add(normalize(row));
+ }
+ const photoByEmail = candidateEmails.size
+ ? await resolveLookupPhotos(userId, [...candidateEmails])
+ : new Map();
+
+ for (const row of rows) {
+ if (row.lookup_photo_candidate && photoByEmail.get(normalize(row))) {
+ row.has_contact_photo = true;
+ }
+ delete row.lookup_photo_candidate;
+ }
+ return rows;
+}
export async function listMessages({ userId, accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, category }) {
const accountsResult = await query(
@@ -91,7 +157,9 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
SELECT DISTINCT ON (m.account_id, m.thread_key, m.message_id)
m.id, m.uid, m.folder, m.message_id,
m.thread_key AS thread_id,
- m.subject, m.from_name, m.from_email,
+ m.subject,
+ COALESCE(NULLIF(m.from_name, ''), lookup.lookup_display_name) AS from_name,
+ m.from_email,
m.to_addresses, m.cc_addresses, m.reply_to, m.in_reply_to,
m.date, m.snippet, m.is_read, m.is_starred,
m.has_attachments, m.account_id, m.category,
@@ -99,12 +167,13 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
a.name AS account_name,
a.email_address AS account_email,
a.color AS account_color,
- (co.id IS NOT NULL) AS has_contact_photo
+ (co.id IS NOT NULL) AS has_contact_photo,
+ (co.id IS NULL AND lookup.matched IS TRUE) AS lookup_photo_candidate
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
LEFT JOIN contacts co ON co.user_id = a.user_id
AND co.primary_email = lower(m.from_email)
- AND co.photo_data IS NOT NULL
+ AND co.photo_data IS NOT NULL${LOOKUP_SENDER_JOIN}
WHERE ${where}
AND m.thread_key IN (SELECT thread_id FROM paged_threads)
ORDER BY m.account_id,
@@ -132,6 +201,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
FIRST_VALUE(d.from_name) OVER (PARTITION BY d.thread_id ORDER BY d.date ASC) AS thread_from_name,
FIRST_VALUE(d.from_email) OVER (PARTITION BY d.thread_id ORDER BY d.date ASC) AS thread_from_email,
FIRST_VALUE(d.has_contact_photo) OVER (PARTITION BY d.thread_id ORDER BY d.date ASC) AS thread_has_contact_photo,
+ FIRST_VALUE(d.lookup_photo_candidate) OVER (PARTITION BY d.thread_id ORDER BY d.date ASC) AS thread_lookup_photo_candidate,
ROW_NUMBER() OVER (PARTITION BY d.thread_id ORDER BY d.date DESC) AS rn
FROM deduped d
LEFT JOIN thread_totals tt ON tt.thread_id = d.thread_id
@@ -143,7 +213,8 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
account_name, account_email, account_color,
category, list_unsubscribe, list_unsubscribe_post,
message_count, unread_count,
- thread_has_contact_photo AS has_contact_photo
+ thread_has_contact_photo AS has_contact_photo,
+ thread_lookup_photo_candidate AS lookup_photo_candidate
FROM ranked
WHERE rn = 1
ORDER BY date DESC
@@ -156,7 +227,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
`, filterValues);
return {
- messages: threadResult.rows,
+ messages: await applyLookupPhotoGate(userId, threadResult.rows),
total: threadCountResult.rows[0]?.total ?? 0,
threaded: true,
resolvedAccountId: isSpecificAccount ? accountId : null,
@@ -168,25 +239,28 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
values.push(safeLimit, safeOffset);
const result = await query(`
- SELECT m.id, m.uid, m.folder, m.message_id, m.subject, m.from_name, m.from_email,
+ SELECT m.id, m.uid, m.folder, m.message_id, m.subject,
+ COALESCE(NULLIF(m.from_name, ''), lookup.lookup_display_name) AS from_name,
+ m.from_email,
m.to_addresses, m.cc_addresses, m.reply_to, m.in_reply_to,
m.date, m.snippet, m.is_read, m.is_starred,
m.has_attachments, m.account_id, m.category,
m.list_unsubscribe, m.list_unsubscribe_post,
a.name as account_name, a.email_address as account_email, a.color as account_color,
- (co.id IS NOT NULL) AS has_contact_photo
+ (co.id IS NOT NULL) AS has_contact_photo,
+ (co.id IS NULL AND lookup.matched IS TRUE) AS lookup_photo_candidate
FROM messages m
JOIN email_accounts a ON m.account_id = a.id
LEFT JOIN contacts co ON co.user_id = a.user_id
AND co.primary_email = lower(m.from_email)
- AND co.photo_data IS NOT NULL
+ AND co.photo_data IS NOT NULL${LOOKUP_SENDER_JOIN}
WHERE ${where}
ORDER BY m.date DESC
LIMIT $${limitParam} OFFSET $${offsetParam}
`, values);
return {
- messages: result.rows,
+ messages: await applyLookupPhotoGate(userId, result.rows),
total,
resolvedAccountId: isSpecificAccount ? accountId : null,
};
diff --git a/backend/src/services/messageService.test.js b/backend/src/services/messageService.test.js
index 34700a5..c7d649a 100644
--- a/backend/src/services/messageService.test.js
+++ b/backend/src/services/messageService.test.js
@@ -3,10 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('./db.js', () => ({ query: vi.fn() }));
const { query } = await import('./db.js');
+const { _clearLookupPhotoCache } = await import('./carddavLookupService.js');
import { listMessages } from './messageService.js';
beforeEach(() => {
query.mockClear();
+ _clearLookupPhotoCache();
});
describe('listMessages — account scope', () => {
@@ -150,3 +152,105 @@ describe('listMessages — threaded mode', () => {
expect(cteSql).toContain("AND folder = 'INBOX'");
});
});
+
+describe('listMessages — lookup-only sender resolution', () => {
+ it('joins lookup-only ledger rows so a headerless sender resolves a name and avatar (flat)', async () => {
+ query
+ .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) // accounts
+ .mockResolvedValueOnce({ rows: [{ n: 1 }] }) // folder count
+ .mockResolvedValueOnce({ rows: [] }); // messages
+
+ await listMessages({ userId: 'user-1' });
+
+ const listSql = query.mock.calls[2][0];
+ expect(listSql).toContain('LEFT JOIN LATERAL');
+ expect(listSql).toContain("lo.mapping_status = 'lookup'");
+ expect(listSql).toContain('lab.is_lookup_source = true');
+ expect(listSql).toContain("lab.source = 'carddav'");
+ expect(listSql).toContain("COALESCE(NULLIF(m.from_name, ''), lookup.lookup_display_name)");
+ // has_contact_photo comes from the materialized contact alone; a lookup match is
+ // only flagged as a photo *candidate*, which listMessages then resolves through
+ // the real bounded decode path (not a syntactic PHOTO check) so the gate cannot
+ // diverge from what GET /api/contacts/photo actually serves.
+ expect(listSql).toContain('(co.id IS NOT NULL) AS has_contact_photo');
+ expect(listSql).toContain('lookup.matched IS TRUE) AS lookup_photo_candidate');
+ // At most one lookup match per row keeps the fallback from multiplying messages.
+ expect(listSql).toContain('LIMIT 1');
+ });
+
+ it('joins lookup-only ledger rows in the threaded CTE as well', async () => {
+ query
+ .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] })
+ .mockResolvedValueOnce({ rows: [{ total_count: 1, unread_count: 0 }] })
+ .mockResolvedValueOnce({ rows: [] })
+ .mockResolvedValueOnce({ rows: [{ total: 0 }] });
+
+ await listMessages({ userId: 'user-1', accountId: 'acc-1', threaded: 'true' });
+
+ const cteSql = query.mock.calls[2][0];
+ expect(cteSql).toContain('LEFT JOIN LATERAL');
+ expect(cteSql).toContain("lo.mapping_status = 'lookup'");
+ expect(cteSql).toContain('lab.is_lookup_source = true');
+ expect(cteSql).toContain("COALESCE(NULLIF(m.from_name, ''), lookup.lookup_display_name)");
+ expect(cteSql).toContain('(co.id IS NOT NULL) AS has_contact_photo');
+ expect(cteSql).toContain('lookup.matched IS TRUE) AS lookup_photo_candidate');
+ });
+
+ it('resolves a repeated lookup-only sender once per page instead of one DB read per row', async () => {
+ // A valid lookup vCard whose PHOTO decodes, so every candidate row gets a photo.
+ const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
+ const vcard = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'UID:lookup-1',
+ 'FN:Lookup Sender',
+ 'EMAIL:sender@example.test',
+ `PHOTO;ENCODING=b;TYPE=JPEG:${photoBytes.toString('base64')}`,
+ 'END:VCARD',
+ ].join('\r\n');
+
+ query
+ .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) // accounts
+ .mockResolvedValueOnce({ rows: [{ n: 3 }] }) // folder count
+ .mockResolvedValueOnce({ rows: [ // three rows, one sender (mixed case)
+ { id: 'm1', from_email: 'Sender@Example.test', lookup_photo_candidate: true },
+ { id: 'm2', from_email: 'sender@example.test', lookup_photo_candidate: true },
+ { id: 'm3', from_email: 'SENDER@EXAMPLE.TEST', lookup_photo_candidate: true },
+ ] })
+ .mockResolvedValue({ rows: [{ primary_email: 'sender@example.test', vcard }] }); // batched probe
+
+ const result = await listMessages({ userId: 'user-1' });
+
+ // 3 base queries (accounts, folder count, messages) + exactly ONE shared
+ // lookup-photo probe for the case-insensitively identical sender — not one
+ // per row, which would be a cache stampede past the in-process LRU.
+ expect(query).toHaveBeenCalledTimes(4);
+ expect(result.messages.map(m => m.has_contact_photo)).toEqual([true, true, true]);
+ // The internal candidate marker never leaks to the API response.
+ expect(result.messages.every(m => !('lookup_photo_candidate' in m))).toBe(true);
+ });
+
+ it('resolves N distinct lookup-only senders in a single batched DB round-trip', async () => {
+ query
+ .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) // accounts
+ .mockResolvedValueOnce({ rows: [{ n: 3 }] }) // folder count
+ .mockResolvedValueOnce({ rows: [ // three distinct senders
+ { id: 'm1', from_email: 'alice@example.test', lookup_photo_candidate: true },
+ { id: 'm2', from_email: 'bob@example.test', lookup_photo_candidate: true },
+ { id: 'm3', from_email: 'carol@example.test', lookup_photo_candidate: true },
+ ] })
+ .mockResolvedValue({ rows: [] }); // one batched probe, no photos
+
+ await listMessages({ userId: 'user-1' });
+
+ // 3 base queries + exactly ONE batched probe for all distinct senders,
+ // never one query per sender (that would flood the connection pool).
+ expect(query).toHaveBeenCalledTimes(4);
+ const probeSql = query.mock.calls[3][0];
+ expect(probeSql).toContain('primary_email = ANY($2::text[])');
+ expect(query.mock.calls[3][1]).toEqual([
+ 'user-1',
+ ['alice@example.test', 'bob@example.test', 'carol@example.test'],
+ ]);
+ });
+});
diff --git a/frontend/src/carddavBookState.js b/frontend/src/carddavBookState.js
new file mode 100644
index 0000000..b8fa81c
--- /dev/null
+++ b/frontend/src/carddavBookState.js
@@ -0,0 +1,56 @@
+// Pure view-model for a CardDAV per-book row in the integrations panel. The
+// three stored role flags (write-target / subscribed / lookup) collapse into one
+// role label, one capability badge, and the enabled/disabled/checked state of
+// the Subscribe and Look-up-senders toggles and the write-target radio. Kept out
+// of the AdminPanel JSX so the control logic is unit-testable without a DOM.
+
+// The effective role, most-privileged first — the label shown on the row.
+export function cardDavBookRole(book) {
+ if (book.isWriteTarget) return 'writeTarget';
+ if (book.isSubscribed) return 'subscribed';
+ if (book.isLookupSource) return 'lookupOnly';
+ return 'ignored';
+}
+
+// The observed write capability badge. Read-only when the server denies both
+// update and delete; unknown while any capability is still unconfirmed;
+// otherwise writable.
+export function cardDavBookCapability(book) {
+ const caps = book.capabilities || {};
+ if (caps.update === 'denied' && caps.delete === 'denied') return 'readOnly';
+ if ([caps.create, caps.update, caps.delete].some(value => value == null || value === 'unknown')) {
+ return 'unknownCapability';
+ }
+ return 'writable';
+}
+
+// Whether emailing someone publishes them to the write-target book, and the patch
+// the toggle sends when clicked.
+//
+// The setting is absent from every connection made before it existed, and absent
+// must read as OFF: publishing stays a deliberate act (create or promote) unless
+// the user opts in. Only an explicit `true` enables it — a missing key is not
+// consent, so this compares rather than coercing.
+export function publishEmailedContactsToggle(status) {
+ const checked = status?.publishEmailedContacts === true;
+ return { checked, patch: { publishEmailedContacts: !checked } };
+}
+
+// Everything a row needs to render its controls and decide which are locked.
+export function cardDavBookControls(book) {
+ const caps = book.capabilities || {};
+ const isWriteTarget = Boolean(book.isWriteTarget);
+ return {
+ role: cardDavBookRole(book),
+ capability: cardDavBookCapability(book),
+ subscribeChecked: Boolean(book.isSubscribed),
+ lookupChecked: Boolean(book.isLookupSource),
+ isWriteTarget,
+ // A book the server won't let MailFlow create in can never be the
+ // write-target, so its radio is disabled.
+ writeTargetDisabled: caps.create === 'denied',
+ // The write-target must stay subscribed (the backend rejects unsubscribing
+ // it), so its Subscribe toggle is locked on.
+ subscribeDisabled: isWriteTarget,
+ };
+}
diff --git a/frontend/src/carddavBookState.test.js b/frontend/src/carddavBookState.test.js
new file mode 100644
index 0000000..39e1f3a
--- /dev/null
+++ b/frontend/src/carddavBookState.test.js
@@ -0,0 +1,104 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ cardDavBookCapability,
+ cardDavBookControls,
+ cardDavBookRole,
+ publishEmailedContactsToggle,
+} from './carddavBookState.js';
+
+describe('CardDAV per-book role view-model', () => {
+ it('labels the role by the most-privileged flag set', () => {
+ assert.equal(cardDavBookRole({ isWriteTarget: true, isSubscribed: true, isLookupSource: true }), 'writeTarget');
+ assert.equal(cardDavBookRole({ isWriteTarget: false, isSubscribed: true, isLookupSource: true }), 'subscribed');
+ assert.equal(cardDavBookRole({ isWriteTarget: false, isSubscribed: false, isLookupSource: true }), 'lookupOnly');
+ assert.equal(cardDavBookRole({ isWriteTarget: false, isSubscribed: false, isLookupSource: false }), 'ignored');
+ });
+
+ it('derives the capability badge from observed write capabilities', () => {
+ assert.equal(cardDavBookCapability({ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' } }), 'writable');
+ assert.equal(cardDavBookCapability({ capabilities: { create: 'denied', update: 'denied', delete: 'denied' } }), 'readOnly');
+ assert.equal(cardDavBookCapability({ capabilities: { create: 'unknown', update: 'allowed', delete: 'allowed' } }), 'unknownCapability');
+ // No capabilities object at all reads as unknown, not a crash.
+ assert.equal(cardDavBookCapability({}), 'unknownCapability');
+ });
+
+ it('renders the write-target row with its Subscribe toggle locked on', () => {
+ const controls = cardDavBookControls({
+ isWriteTarget: true,
+ isSubscribed: true,
+ isLookupSource: true,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ });
+ assert.deepEqual(controls, {
+ role: 'writeTarget',
+ capability: 'writable',
+ subscribeChecked: true,
+ lookupChecked: true,
+ isWriteTarget: true,
+ writeTargetDisabled: false,
+ subscribeDisabled: true,
+ });
+ });
+
+ it('disables the write-target radio for a create-denied lookup-only book', () => {
+ const controls = cardDavBookControls({
+ isWriteTarget: false,
+ isSubscribed: false,
+ isLookupSource: true,
+ capabilities: { create: 'denied', update: 'denied', delete: 'denied' },
+ });
+ assert.deepEqual(controls, {
+ role: 'lookupOnly',
+ capability: 'readOnly',
+ subscribeChecked: false,
+ lookupChecked: true,
+ isWriteTarget: false,
+ writeTargetDisabled: true,
+ subscribeDisabled: false,
+ });
+ });
+
+ it('lets a subscribed secondary be toggled and promoted (radio enabled, subscribe unlocked)', () => {
+ const controls = cardDavBookControls({
+ isWriteTarget: false,
+ isSubscribed: true,
+ isLookupSource: true,
+ capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' },
+ });
+ assert.equal(controls.role, 'subscribed');
+ assert.equal(controls.subscribeDisabled, false);
+ assert.equal(controls.writeTargetDisabled, false);
+ assert.equal(controls.subscribeChecked, true);
+ });
+});
+
+describe('publish-emailed-contacts toggle', () => {
+ // Every connection made before this setting existed has no such key, and a
+ // status the panel has not loaded yet is null. Neither is consent: the toggle
+ // reads OFF, so the panel never shows "publishing everyone you email" to a user
+ // who never asked for it.
+ it('reads OFF when the setting is absent, so it is never enabled by default', () => {
+ for (const status of [undefined, null, {}, { connected: true }]) {
+ assert.deepEqual(publishEmailedContactsToggle(status), {
+ checked: false,
+ patch: { publishEmailedContacts: true },
+ });
+ }
+ });
+
+ it('reads ON only for an explicit true, never a merely truthy value', () => {
+ assert.equal(publishEmailedContactsToggle({ publishEmailedContacts: true }).checked, true);
+ assert.equal(publishEmailedContactsToggle({ publishEmailedContacts: false }).checked, false);
+ assert.equal(publishEmailedContactsToggle({ publishEmailedContacts: 'yes' }).checked, false);
+ assert.equal(publishEmailedContactsToggle({ publishEmailedContacts: 1 }).checked, false);
+ });
+
+ it('sends the inverted value as the patch', () => {
+ assert.deepEqual(publishEmailedContactsToggle({ publishEmailedContacts: true }).patch,
+ { publishEmailedContacts: false });
+ assert.deepEqual(publishEmailedContactsToggle({ publishEmailedContacts: false }).patch,
+ { publishEmailedContacts: true });
+ });
+});
diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx
index 32e08f3..d2fdf74 100644
--- a/frontend/src/components/AdminPanel.jsx
+++ b/frontend/src/components/AdminPanel.jsx
@@ -12,6 +12,7 @@ import { usePushNotifications } from '../hooks/usePushNotifications.js';
import SignatureEditor from './SignatureEditor.jsx';
import GtdZeroPet from './GtdZeroPet.jsx';
import CardDavConflicts from './CardDavConflicts.jsx';
+import { cardDavBookControls, publishEmailedContactsToggle } from '../carddavBookState.js';
import { getEffectiveShortcuts, getGroupedActions, ACTION_DEFS, SPECIAL_KEY_LABELS, parseModKey, modLabel } from '../utils/defaultShortcuts.js';
import { DEFAULT_GTD_FOLDERS, GTD_STATES, resolveAccountGtdFolders, diffGtdFolders, findGtdFolderCollisions } from '../utils/gtd.js';
@@ -1858,6 +1859,77 @@ function LayoutsTab() {
// ─── Integrations Tab ────────────────────────────────────────────────────────
// CardDAV contact sync (e.g. Nextcloud).
+
+// Per-book row: role (write-target/subscribed/lookup-only/ignored) + observed
+// write-capability badges, the Subscribe and Look-up-senders toggles, a
+// write-target radio (disabled where the server denies create), and the row's
+// materialized/lookup counts. onPatch(bookId, roles) sends one role change; the
+// row is disabled (pending) while its own change is in flight. The role/control
+// derivation lives in carddavBookState.js so it is unit-tested without a DOM.
+function CardDavBookRow({ book, t, onPatch, pending }) {
+ const badgeStyle = {
+ fontSize: 11, padding: '2px 8px', borderRadius: 10,
+ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)',
+ border: '1px solid var(--border)',
+ };
+ const controls = cardDavBookControls(book);
+ const controlLabel = disabled => ({
+ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12,
+ color: 'var(--text-secondary)', opacity: disabled ? 0.5 : 1,
+ cursor: disabled ? 'default' : 'pointer',
+ });
+
+ return (
+
+ );
+}
+
function CardDavCard() {
const { t } = useTranslation();
const [status, setStatus] = useState(null); // null while loading
@@ -1870,6 +1942,7 @@ function CardDavCard() {
const [conflictCount, setConflictCount] = useState(0);
const [showConflicts, setShowConflicts] = useState(false);
const [healthUnavailable, setHealthUnavailable] = useState(false);
+ const [bookPatchPending, setBookPatchPending] = useState(null);
const refreshHealth = useCallback(async () => {
setHealthUnavailable(false);
@@ -1906,6 +1979,7 @@ function CardDavCard() {
: healthUnavailable || needsAttention
? 'var(--red, #f87171)'
: 'var(--text-tertiary)';
+ const publishEmailed = publishEmailedContactsToggle(status);
const handleConnect = async () => {
setConnecting(true); setError('');
@@ -1934,6 +2008,21 @@ function CardDavCard() {
setStatus(s => ({ ...s, ...patch }));
try { await api.carddav.update(patch); } catch (e) { setError(e.message); }
};
+ // Change one book's role (Subscribe / Look-up-senders / write-target). The
+ // response is the refreshed per-book summary; a background sync then applies
+ // the pull-side effects (re-subscribe materializes; ignore drops the ledger).
+ const patchBook = async (bookId, roles) => {
+ setBookPatchPending(bookId);
+ setError('');
+ try {
+ setStatus(await api.carddav.patchBook(bookId, roles));
+ await refreshHealth();
+ } catch (e) {
+ setError(e.message || t('admin.integrations.carddav.books.updateFailed'));
+ } finally {
+ setBookPatchPending(null);
+ }
+ };
const inputStyle = { padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: 13, outline: 'none', width: '100%', boxSizing: 'border-box' };
const labelStyle = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', display: 'block', marginBottom: 5 };
@@ -1986,12 +2075,51 @@ function CardDavCard() {
+ {Array.isArray(status.books) && (
+
+
{t('admin.integrations.carddav.books.title')}
+ {status.books.length === 0 ? (
+
+ {t('admin.integrations.carddav.books.empty')}
+
+ ) : (
+
+ {status.books.map(book => (
+
+ ))}
+
+ )}
+
+ )}
+
{t('admin.integrations.carddav.intervalLabel')}
setStatus(s => ({ ...s, intervalMin: e.target.value }))}
onBlur={e => updateSetting({ intervalMin: Number(e.target.value) })} style={inputStyle} />
+
+ updateSetting(publishEmailed.patch)}
+ style={{ marginTop: 2 }}
+ />
+
+
+ {t('admin.integrations.carddav.publishEmailedContacts')}
+
+
+ {t('admin.integrations.carddav.publishEmailedContactsDesc')}
+
+
+
{errBox}
diff --git a/frontend/src/components/ContactsPage.jsx b/frontend/src/components/ContactsPage.jsx
index 991fc86..f83a643 100644
--- a/frontend/src/components/ContactsPage.jsx
+++ b/frontend/src/components/ContactsPage.jsx
@@ -10,11 +10,13 @@ import {
canUploadContactPhoto,
completePhotoRead,
contactCarddavState,
+ contactPromotionState,
contactToForm,
formToContactDraft,
initialPhotoReadState,
invalidatePhotoRead,
newAdditionalField,
+ promoteFailureKey,
removeContactPhoto,
saveFailureState,
shouldRefreshContactAfterResolution,
@@ -76,6 +78,8 @@ export default function ContactsPage() {
const [confirmDelete, setConfirmDelete] = useState(false);
const [showNew, setShowNew] = useState(false);
const [conflictCount, setConflictCount] = useState(0);
+ const [carddav, setCarddav] = useState({ connected: false, books: [] });
+ const [promoting, setPromoting] = useState(false);
const [showConflicts, setShowConflicts] = useState(false);
const [activeConflictId, setActiveConflictId] = useState(null);
const [photoRead, setPhotoRead] = useState(initialPhotoReadState);
@@ -126,7 +130,21 @@ export default function ContactsPage() {
}
}, []);
- useEffect(() => { load(''); loadConflictCount(); }, [load, loadConflictCount]);
+ // The connection's per-book roles decide whether a harvested contact has a
+ // write-target to be promoted into (see contactPromotionState).
+ const loadCarddav = useCallback(async () => {
+ try {
+ setCarddav(await api.carddav.status());
+ } catch {
+ // Without a readable status the promote affordance simply stays hidden.
+ }
+ }, []);
+
+ useEffect(() => {
+ load('');
+ loadConflictCount();
+ loadCarddav();
+ }, [load, loadConflictCount, loadCarddav]);
const onSearchChange = (e) => {
const val = e.target.value;
@@ -285,6 +303,30 @@ export default function ContactsPage() {
}
};
+ // The deliberate one-way promotion: the backend exports the contact to the
+ // write-target book and only then clears is_auto, so re-reading it here shows
+ // the confirmed result rather than an optimistic guess.
+ const promoteContact = async () => {
+ if (!selected || saving) return;
+ if (!contactPromotionState(selected, carddav).enabled) return;
+ setSaving(true);
+ setPromoting(true);
+ setError(null);
+ try {
+ const promoted = await api.promoteContact(selected.id);
+ await load(search);
+ setSelected(await api.getContact(promoted.id));
+ } catch (err) {
+ setError(t(promoteFailureKey(err)));
+ // A rejection means the stored roles differ from what this page read, so
+ // refresh them: the affordance re-renders to match the real state.
+ await loadCarddav();
+ } finally {
+ setPromoting(false);
+ setSaving(false);
+ }
+ };
+
// Form field helpers
const setFormField = (key, val) => setForm(f => ({ ...f, [key]: val }));
@@ -537,12 +579,15 @@ export default function ContactsPage() {
setConfirmDelete(true)}
onDeleteConfirm={deleteContact}
onDeleteCancel={() => setConfirmDelete(false)}
@@ -728,7 +773,7 @@ export default function ContactsPage() {
);
}
-function ContactDetail({ contact: c, confirmDelete, saving, photoReading, error, onEdit, onSetPhoto, onDeleteRequest, onDeleteConfirm, onDeleteCancel, onOpenConflict, t }) {
+function ContactDetail({ contact: c, promotion, confirmDelete, saving, photoReading, promoting, error, onEdit, onSetPhoto, onPromote, onDeleteRequest, onDeleteConfirm, onDeleteCancel, onOpenConflict, t }) {
const carddav = contactCarddavState(c);
const pendingSync = carddav.labelKey === 'contacts.carddavPending';
const photoInputRef = useRef(null);
@@ -788,9 +833,21 @@ function ContactDetail({ contact: c, confirmDelete, saving, photoReading, error,
{c.is_auto && (
{t('contacts.autoHint')}
)}
+ {promotion.reasonKey && (
+ {t(promotion.reasonKey)}
+ )}
+ {promotion.visible && (
+
+ {promoting ? t('common.saving') : t('contacts.promote.action')}
+
+ )}
{carddav.labelKey && (
onOpenConflict(carddav.conflictId) : undefined}
@@ -1206,11 +1263,12 @@ function DetailRow({ label, children }) {
);
}
-function ActionBtn({ children, onClick, danger, disabled }) {
+function ActionBtn({ children, onClick, danger, disabled, title }) {
return (
book.isWriteTarget);
+ if (!target) {
+ return { visible: true, enabled: false, reasonKey: 'contacts.promote.noWriteTarget', bookName: null };
+ }
+ const bookName = target.name || null;
+ if (target.capabilities?.create === 'denied') {
+ return { visible: true, enabled: false, reasonKey: 'contacts.promote.readOnly', bookName };
+ }
+ return { visible: true, enabled: true, reasonKey: null, bookName };
+}
+
+// Book roles can change between the status read and the click, so a rejected
+// promotion is translated from its typed code rather than echoing the backend's
+// English message into a localized UI.
+const PROMOTE_FAILURE_KEYS = {
+ ERR_CARDDAV_NO_WRITE_TARGET: 'contacts.promote.noWriteTarget',
+ ERR_CARDDAV_READ_ONLY: 'contacts.promote.readOnly',
+};
+
+export function promoteFailureKey(error) {
+ return PROMOTE_FAILURE_KEYS[error?.data?.code] || 'contacts.promote.failed';
+}
+
export function cloneFields(fields) {
return (fields || []).map(field => ({
id: field?.id,
diff --git a/frontend/src/contactCarddavState.test.js b/frontend/src/contactCarddavState.test.js
index 3c4c079..3cb01b3 100644
--- a/frontend/src/contactCarddavState.test.js
+++ b/frontend/src/contactCarddavState.test.js
@@ -10,12 +10,14 @@ import {
canUploadContactPhoto,
completePhotoRead,
contactCarddavState,
+ contactPromotionState,
contactToForm,
formToContactDraft,
initialPhotoReadState,
invalidatePhotoRead,
isRefreshableWriteError,
newAdditionalField,
+ promoteFailureKey,
removeContactPhoto,
saveFailureState,
shouldRefreshContactAfterResolution,
@@ -86,6 +88,82 @@ describe('CardDAV contact state', () => {
'contacts.carddavSynced');
});
+ // Editing a harvested contact no longer promotes it (the backend keeps
+ // is_auto), so promotion needs its own visible affordance — offered only when
+ // there is somewhere to send the contact.
+ it('offers promotion for a harvested contact only when a usable write-target exists', () => {
+ const harvested = { is_auto: true, sync_state: 'local' };
+ const books = [
+ { name: 'Personal', isWriteTarget: false, capabilities: { create: 'allowed' } },
+ { name: 'Shared', isWriteTarget: true, capabilities: { create: 'allowed' } },
+ ];
+
+ assert.deepEqual(contactPromotionState(harvested, { connected: true, books }), {
+ visible: true,
+ enabled: true,
+ reasonKey: null,
+ bookName: 'Shared',
+ });
+
+ // Connected but nothing designated to receive new contacts: the button
+ // stays visible and says why, because the fix lives in Settings.
+ assert.deepEqual(contactPromotionState(harvested, {
+ connected: true,
+ books: [{ name: 'Personal', isWriteTarget: false, capabilities: { create: 'allowed' } }],
+ }), {
+ visible: true,
+ enabled: false,
+ reasonKey: 'contacts.promote.noWriteTarget',
+ bookName: null,
+ });
+
+ // A write-target the server won't let MailFlow create in mirrors the
+ // backend's ERR_CARDDAV_READ_ONLY rather than pretending it can be used.
+ assert.deepEqual(contactPromotionState(harvested, {
+ connected: true,
+ books: [{ name: 'Shared', isWriteTarget: true, capabilities: { create: 'denied' } }],
+ }), {
+ visible: true,
+ enabled: false,
+ reasonKey: 'contacts.promote.readOnly',
+ bookName: 'Shared',
+ });
+ });
+
+ it('hides promotion for explicit, already-synced, and non-CardDAV contacts', () => {
+ const books = [{ name: 'Shared', isWriteTarget: true, capabilities: { create: 'allowed' } }];
+
+ // Already explicit: the sweep exports it automatically, nothing to promote.
+ assert.deepEqual(
+ contactPromotionState({ is_auto: false, sync_state: 'local' }, { connected: true, books }),
+ { visible: false, enabled: false, reasonKey: null, bookName: null },
+ );
+ // Already mapped to a remote object.
+ assert.deepEqual(
+ contactPromotionState({ is_auto: true, sync_state: 'synced' }, { connected: true, books }),
+ { visible: false, enabled: false, reasonKey: null, bookName: null },
+ );
+ // No CardDAV account at all: promotion is meaningless, so no affordance.
+ assert.deepEqual(
+ contactPromotionState({ is_auto: true, sync_state: 'local' }, { connected: false }),
+ { visible: false, enabled: false, reasonKey: null, bookName: null },
+ );
+ });
+
+ // The roles can change between the status read and the click, so a rejection
+ // is translated from its typed code rather than echoing the backend's English.
+ it('translates typed promotion failures and falls back for the rest', () => {
+ const failure = code => Object.assign(new Error('Request failed'), {
+ status: 409,
+ data: { error: 'Request failed', code },
+ });
+
+ assert.equal(promoteFailureKey(failure('ERR_CARDDAV_NO_WRITE_TARGET')), 'contacts.promote.noWriteTarget');
+ assert.equal(promoteFailureKey(failure('ERR_CARDDAV_READ_ONLY')), 'contacts.promote.readOnly');
+ assert.equal(promoteFailureKey(failure('ERR_CARDDAV_ALREADY_MAPPED')), 'contacts.promote.failed');
+ assert.equal(promoteFailureKey(new Error('Network down')), 'contacts.promote.failed');
+ });
+
it('initializes an editable form without dropping photos or typed Additional fields', () => {
const contact = {
display_name: 'Ada Lovelace',
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json
index 54fd0a1..79f49b0 100644
--- a/frontend/src/locales/de.json
+++ b/frontend/src/locales/de.json
@@ -1214,7 +1214,25 @@
"healthNeedsAttention": "Überprüfung nötig",
"healthHealthy": "Fehlerfrei",
"reviewConflicts": "Konflikte prüfen",
- "help": "Verwenden Sie ein Nextcloud-App-Passwort, nicht Ihr Login-Passwort – SSO-Nutzer erstellen es unter Einstellungen → Sicherheit. Kontakte werden in beide Richtungen synchronisiert, wenn der Server Schreibzugriffe erlaubt."
+ "help": "Verwenden Sie ein Nextcloud-App-Passwort, nicht Ihr Login-Passwort – SSO-Nutzer erstellen es unter Einstellungen → Sicherheit. Kontakte werden in beide Richtungen synchronisiert, wenn der Server Schreibzugriffe erlaubt.",
+ "publishEmailedContacts": "Kontakte aus gesendeten E-Mails veröffentlichen",
+ "publishEmailedContactsDesc": "Fügt Personen, denen Sie schreiben, dem Zieladressbuch hinzu.",
+ "books": {
+ "title": "Adressbücher",
+ "empty": "Noch keine Adressbücher gefunden.",
+ "writeTarget": "Schreibziel",
+ "subscribed": "Abonniert",
+ "lookupOnly": "Nur Nachschlagen",
+ "ignored": "Ignoriert",
+ "writable": "Beschreibbar",
+ "readOnly": "Schreibgeschützt",
+ "unknownCapability": "Unbekannt",
+ "counts": "{{materialized}} in der Liste · {{lookup}} zum Nachschlagen",
+ "lastSync": "Zuletzt synchronisiert: {{when}}",
+ "subscribeLabel": "Abonnieren",
+ "lookupLabel": "Absender nachschlagen",
+ "updateFailed": "Adressbuch konnte nicht aktualisiert werden"
+ }
}
},
"ssoLinked": {
@@ -1365,6 +1383,13 @@
"carddavConflict": "CardDAV-Konflikt",
"carddavPending": "Synchronisierung ausstehend",
"carddavWriteUnconfirmed": "Ihre Änderung wurde möglicherweise bereits übernommen – der Kontakt wurde aktualisiert. Prüfen Sie ihn und versuchen Sie es bei Bedarf erneut.",
+ "promote": {
+ "action": "Im Adressbuch speichern",
+ "hint": "Speichere diesen Kontakt in deinem CardDAV-Adressbuch, damit er synchron bleibt.",
+ "noWriteTarget": "Kein Adressbuch ist als Schreibziel festgelegt. Wähle unter „Adressbücher“ in Einstellungen → CardDAV-Kontakte eines aus und versuche es erneut.",
+ "readOnly": "Das Schreibziel-Adressbuch nimmt keine neuen Kontakte an. Wähle unter „Adressbücher“ in Einstellungen → CardDAV-Kontakte ein beschreibbares aus.",
+ "failed": "Dieser Kontakt konnte nicht im Adressbuch gespeichert werden."
+ },
"photo": {
"title": "Foto",
"contactAlt": "Kontaktfoto",
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 1e532c3..fdd77d7 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -1281,7 +1281,25 @@
"healthNeedsAttention": "Needs attention",
"healthHealthy": "Healthy",
"reviewConflicts": "Review conflicts",
- "help": "Use a Nextcloud app password, not your login password — SSO users must create one under Settings → Security. Contacts sync in both directions when the server allows writes."
+ "help": "Use a Nextcloud app password, not your login password — SSO users must create one under Settings → Security. Contacts sync in both directions when the server allows writes.",
+ "publishEmailedContacts": "Publish emailed contacts",
+ "publishEmailedContactsDesc": "Add people you email to the write-target address book.",
+ "books": {
+ "title": "Address books",
+ "empty": "No address books discovered yet.",
+ "writeTarget": "Write target",
+ "subscribed": "Subscribed",
+ "lookupOnly": "Lookup only",
+ "ignored": "Ignored",
+ "writable": "Writable",
+ "readOnly": "Read-only",
+ "unknownCapability": "Unknown",
+ "counts": "{{materialized}} in list · {{lookup}} for lookup",
+ "lastSync": "Last synced {{when}}",
+ "subscribeLabel": "Subscribe",
+ "lookupLabel": "Look up senders",
+ "updateFailed": "Failed to update address book"
+ }
}
},
"ssoLinked": {
@@ -1365,6 +1383,13 @@
"carddavConflict": "CardDAV conflict",
"carddavPending": "Sync pending",
"carddavWriteUnconfirmed": "Your change may already have been applied — the contact was refreshed. Check it and try again if needed.",
+ "promote": {
+ "action": "Save to address book",
+ "hint": "Save this contact to your CardDAV address book to keep it in sync.",
+ "noWriteTarget": "No address book is set as the write target. Choose one under Address books in Settings → CardDAV Contacts, then try again.",
+ "readOnly": "The write-target address book does not accept new contacts. Choose a writable one under Address books in Settings → CardDAV Contacts.",
+ "failed": "Could not save this contact to the address book."
+ },
"photo": {
"title": "Photo",
"contactAlt": "Contact photo",
diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json
index ec5a466..0373624 100644
--- a/frontend/src/locales/es.json
+++ b/frontend/src/locales/es.json
@@ -1281,7 +1281,25 @@
"healthNeedsAttention": "Requiere atención",
"healthHealthy": "Funcionamiento correcto",
"reviewConflicts": "Revisar conflictos",
- "help": "Usa una contraseña de aplicación de Nextcloud, no la de inicio de sesión: los usuarios de SSO deben crear una en Ajustes → Seguridad. Los contactos se sincronizan en ambos sentidos cuando el servidor permite escribir."
+ "help": "Usa una contraseña de aplicación de Nextcloud, no la de inicio de sesión: los usuarios de SSO deben crear una en Ajustes → Seguridad. Los contactos se sincronizan en ambos sentidos cuando el servidor permite escribir.",
+ "publishEmailedContacts": "Publicar contactos por correo",
+ "publishEmailedContactsDesc": "Añade las personas a las que escribes a la libreta de direcciones de destino.",
+ "books": {
+ "title": "Libretas de direcciones",
+ "empty": "Aún no se ha detectado ninguna libreta de direcciones.",
+ "writeTarget": "Destino de escritura",
+ "subscribed": "Suscrita",
+ "lookupOnly": "Solo consulta",
+ "ignored": "Ignorada",
+ "writable": "Editable",
+ "readOnly": "Solo lectura",
+ "unknownCapability": "Desconocido",
+ "counts": "{{materialized}} en la lista · {{lookup}} para consulta",
+ "lastSync": "Última sincronización: {{when}}",
+ "subscribeLabel": "Suscribir",
+ "lookupLabel": "Buscar remitentes",
+ "updateFailed": "No se pudo actualizar la libreta de direcciones"
+ }
}
},
"ssoLinked": {
@@ -1365,6 +1383,13 @@
"carddavConflict": "Conflicto de CardDAV",
"carddavPending": "Sincronización pendiente",
"carddavWriteUnconfirmed": "Es posible que tu cambio ya se haya aplicado: se actualizó el contacto. Revísalo y vuelve a intentarlo si es necesario.",
+ "promote": {
+ "action": "Guardar en la libreta de direcciones",
+ "hint": "Guarda este contacto en tu libreta de direcciones CardDAV para mantenerlo sincronizado.",
+ "noWriteTarget": "Ninguna libreta de direcciones es el destino de escritura. Elige una en Libretas de direcciones, dentro de Ajustes → Contactos CardDAV, e inténtalo de nuevo.",
+ "readOnly": "La libreta de destino de escritura no acepta contactos nuevos. Elige una editable en Libretas de direcciones, dentro de Ajustes → Contactos CardDAV.",
+ "failed": "No se pudo guardar este contacto en la libreta de direcciones."
+ },
"photo": {
"title": "Fotografía",
"contactAlt": "Foto del contacto",
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index 807c7e0..2a09011 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -1281,7 +1281,25 @@
"healthNeedsAttention": "Intervention nécessaire",
"healthHealthy": "Bon fonctionnement",
"reviewConflicts": "Examiner les conflits",
- "help": "Utilisez un mot de passe d’application Nextcloud, pas votre mot de passe de connexion — les utilisateurs SSO doivent en créer un dans Paramètres → Sécurité. Les contacts sont synchronisés dans les deux sens lorsque le serveur autorise l’écriture."
+ "help": "Utilisez un mot de passe d’application Nextcloud, pas votre mot de passe de connexion — les utilisateurs SSO doivent en créer un dans Paramètres → Sécurité. Les contacts sont synchronisés dans les deux sens lorsque le serveur autorise l’écriture.",
+ "publishEmailedContacts": "Publier les contacts destinataires",
+ "publishEmailedContactsDesc": "Ajoute les personnes auxquelles vous écrivez au carnet d’adresses cible.",
+ "books": {
+ "title": "Carnets d'adresses",
+ "empty": "Aucun carnet d'adresses détecté pour l'instant.",
+ "writeTarget": "Cible d'écriture",
+ "subscribed": "Abonné",
+ "lookupOnly": "Recherche uniquement",
+ "ignored": "Ignoré",
+ "writable": "Modifiable",
+ "readOnly": "Lecture seule",
+ "unknownCapability": "Inconnu",
+ "counts": "{{materialized}} dans la liste · {{lookup}} pour la recherche",
+ "lastSync": "Dernière synchro : {{when}}",
+ "subscribeLabel": "S'abonner",
+ "lookupLabel": "Rechercher les expéditeurs",
+ "updateFailed": "Échec de la mise à jour du carnet d'adresses"
+ }
}
},
"ssoLinked": {
@@ -1365,6 +1383,13 @@
"carddavConflict": "Conflit CardDAV",
"carddavPending": "Synchronisation en attente",
"carddavWriteUnconfirmed": "Votre modification a peut-être déjà été appliquée : le contact a été actualisé. Vérifiez-le et réessayez si nécessaire.",
+ "promote": {
+ "action": "Enregistrer dans le carnet d'adresses",
+ "hint": "Enregistrez ce contact dans votre carnet d'adresses CardDAV pour le garder synchronisé.",
+ "noWriteTarget": "Aucun carnet d'adresses n'est défini comme cible d'écriture. Choisissez-en un dans Carnets d'adresses, sous Paramètres → Contacts CardDAV, puis réessayez.",
+ "readOnly": "Le carnet d'adresses cible d'écriture n'accepte pas de nouveaux contacts. Choisissez-en un modifiable dans Carnets d'adresses, sous Paramètres → Contacts CardDAV.",
+ "failed": "Impossible d'enregistrer ce contact dans le carnet d'adresses."
+ },
"photo": {
"title": "Photographie",
"contactAlt": "Photo du contact",
diff --git a/frontend/src/locales/i18n.test.js b/frontend/src/locales/i18n.test.js
index d4ffbf1..609e518 100644
--- a/frontend/src/locales/i18n.test.js
+++ b/frontend/src/locales/i18n.test.js
@@ -359,6 +359,15 @@ const DYNAMIC_KEYS = new Set([
'contacts.additional.types.geo',
'contacts.additional.types.im',
'contacts.additional.types.postal-address',
+ // t(`admin.integrations.carddav.books.${role}`) / `.${capability}` — CardDAV
+ // per-book role/capability badges computed at runtime in CardDavBookRow
+ 'admin.integrations.carddav.books.writeTarget',
+ 'admin.integrations.carddav.books.subscribed',
+ 'admin.integrations.carddav.books.lookupOnly',
+ 'admin.integrations.carddav.books.ignored',
+ 'admin.integrations.carddav.books.writable',
+ 'admin.integrations.carddav.books.readOnly',
+ 'admin.integrations.carddav.books.unknownCapability',
]);
// JSX attribute names whose values must never be plain strings — always t().
diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json
index 10daf2e..b7e1464 100644
--- a/frontend/src/locales/it.json
+++ b/frontend/src/locales/it.json
@@ -1281,7 +1281,25 @@
"healthNeedsAttention": "Richiede attenzione",
"healthHealthy": "Servizio regolare",
"reviewConflicts": "Esamina i conflitti",
- "help": "Usa una password app di Nextcloud, non quella di accesso: gli utenti SSO devono crearne una in Impostazioni → Sicurezza. I contatti vengono sincronizzati in entrambe le direzioni quando il server consente la scrittura."
+ "help": "Usa una password app di Nextcloud, non quella di accesso: gli utenti SSO devono crearne una in Impostazioni → Sicurezza. I contatti vengono sincronizzati in entrambe le direzioni quando il server consente la scrittura.",
+ "publishEmailedContacts": "Pubblica i contatti a cui scrivi",
+ "publishEmailedContactsDesc": "Aggiunge le persone a cui scrivi alla rubrica di destinazione.",
+ "books": {
+ "title": "Rubriche",
+ "empty": "Nessuna rubrica ancora rilevata.",
+ "writeTarget": "Destinazione di scrittura",
+ "subscribed": "Sottoscritta",
+ "lookupOnly": "Solo ricerca",
+ "ignored": "Ignorata",
+ "writable": "Scrivibile",
+ "readOnly": "Sola lettura",
+ "unknownCapability": "Sconosciuto",
+ "counts": "{{materialized}} in elenco · {{lookup}} per la ricerca",
+ "lastSync": "Ultima sincronizzazione: {{when}}",
+ "subscribeLabel": "Iscriviti",
+ "lookupLabel": "Cerca mittenti",
+ "updateFailed": "Impossibile aggiornare la rubrica"
+ }
}
},
"ssoLinked": {
@@ -1365,6 +1383,13 @@
"carddavConflict": "Conflitto CardDAV",
"carddavPending": "Sincronizzazione in sospeso",
"carddavWriteUnconfirmed": "La tua modifica potrebbe essere già stata applicata: il contatto è stato aggiornato. Verifica e riprova se necessario.",
+ "promote": {
+ "action": "Salva nella rubrica",
+ "hint": "Salva questo contatto nella tua rubrica CardDAV per mantenerlo sincronizzato.",
+ "noWriteTarget": "Nessuna rubrica è impostata come destinazione di scrittura. Scegline una in Rubriche, sotto Impostazioni → Contatti CardDAV, poi riprova.",
+ "readOnly": "La rubrica di destinazione di scrittura non accetta nuovi contatti. Scegline una scrivibile in Rubriche, sotto Impostazioni → Contatti CardDAV.",
+ "failed": "Impossibile salvare questo contatto nella rubrica."
+ },
"photo": {
"title": "Immagine",
"contactAlt": "Foto del contatto",
diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json
index 8b49e37..2854d93 100644
--- a/frontend/src/locales/ru.json
+++ b/frontend/src/locales/ru.json
@@ -1281,7 +1281,25 @@
"healthNeedsAttention": "Требует внимания",
"healthHealthy": "Работает исправно",
"reviewConflicts": "Проверить конфликты",
- "help": "Используйте пароль приложения Nextcloud, а не пароль для входа — пользователям SSO нужно создать его в Настройки → Безопасность. Контакты синхронизируются в обоих направлениях, если сервер разрешает запись."
+ "help": "Используйте пароль приложения Nextcloud, а не пароль для входа — пользователям SSO нужно создать его в Настройки → Безопасность. Контакты синхронизируются в обоих направлениях, если сервер разрешает запись.",
+ "publishEmailedContacts": "Публиковать контакты из писем",
+ "publishEmailedContactsDesc": "Добавляет адресатов ваших писем в целевую адресную книгу.",
+ "books": {
+ "title": "Адресные книги",
+ "empty": "Адресные книги пока не обнаружены.",
+ "writeTarget": "Книга для записи",
+ "subscribed": "Подписана",
+ "lookupOnly": "Только для поиска",
+ "ignored": "Игнорируется",
+ "writable": "Доступна для записи",
+ "readOnly": "Только чтение",
+ "unknownCapability": "Неизвестно",
+ "counts": "{{materialized}} в списке · {{lookup}} для поиска",
+ "lastSync": "Последняя синхронизация: {{when}}",
+ "subscribeLabel": "Подписаться",
+ "lookupLabel": "Искать отправителей",
+ "updateFailed": "Не удалось обновить адресную книгу"
+ }
}
},
"ssoLinked": {
@@ -1366,6 +1384,13 @@
"carddavConflict": "Конфликт CardDAV",
"carddavPending": "Ожидает синхронизации",
"carddavWriteUnconfirmed": "Возможно, изменение уже применено — контакт обновлён. Проверьте его и при необходимости повторите.",
+ "promote": {
+ "action": "Сохранить в адресную книгу",
+ "hint": "Сохраните этот контакт в адресную книгу CardDAV, чтобы он синхронизировался.",
+ "noWriteTarget": "Ни одна адресная книга не выбрана книгой для записи. Выберите её в разделе «Адресные книги» в Настройки → Контакты CardDAV и повторите попытку.",
+ "readOnly": "Книга для записи не принимает новые контакты. Выберите доступную для записи в разделе «Адресные книги» в Настройки → Контакты CardDAV.",
+ "failed": "Не удалось сохранить этот контакт в адресную книгу."
+ },
"photo": {
"title": "Фотография",
"contactAlt": "Фотография контакта",
diff --git a/frontend/src/locales/zhCN.json b/frontend/src/locales/zhCN.json
index 3dec5a5..ada73d1 100644
--- a/frontend/src/locales/zhCN.json
+++ b/frontend/src/locales/zhCN.json
@@ -1281,7 +1281,25 @@
"healthNeedsAttention": "需要处理",
"healthHealthy": "运行正常",
"reviewConflicts": "查看冲突",
- "help": "请使用 Nextcloud 应用专用密码,而非登录密码——SSO 用户需在“设置 → 安全”中创建。当服务器允许写入时,联系人会双向同步。"
+ "help": "请使用 Nextcloud 应用专用密码,而非登录密码——SSO 用户需在“设置 → 安全”中创建。当服务器允许写入时,联系人会双向同步。",
+ "publishEmailedContacts": "发布邮件联系人",
+ "publishEmailedContactsDesc": "将您发过邮件的人添加到写入目标通讯录。",
+ "books": {
+ "title": "通讯簿",
+ "empty": "尚未发现任何通讯簿。",
+ "writeTarget": "写入目标",
+ "subscribed": "已订阅",
+ "lookupOnly": "仅用于查找",
+ "ignored": "已忽略",
+ "writable": "可写入",
+ "readOnly": "只读",
+ "unknownCapability": "未知",
+ "counts": "列表中 {{materialized}} 个 · 用于查找 {{lookup}} 个",
+ "lastSync": "上次同步:{{when}}",
+ "subscribeLabel": "订阅",
+ "lookupLabel": "查找发件人",
+ "updateFailed": "无法更新通讯簿"
+ }
}
},
"ssoLinked": {
@@ -1364,6 +1382,13 @@
"carddavConflict": "CardDAV 数据冲突",
"carddavPending": "同步待处理",
"carddavWriteUnconfirmed": "您的更改可能已经生效——联系人已刷新。请检查后按需重试。",
+ "promote": {
+ "action": "保存到通讯录",
+ "hint": "将此联系人保存到 CardDAV 通讯录,以保持同步。",
+ "noWriteTarget": "尚未将任何通讯簿设为写入目标。请在「设置 → CardDAV 联系人」的「通讯簿」中选择一个,然后重试。",
+ "readOnly": "写入目标通讯簿不接受新联系人。请在「设置 → CardDAV 联系人」的「通讯簿」中选择一个可写入的通讯簿。",
+ "failed": "无法将此联系人保存到通讯录。"
+ },
"photo": {
"title": "头像",
"contactAlt": "联系人头像",
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 3697d35..d4ca28b 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -207,12 +207,16 @@ export const api = {
createContact: (data) => request('POST', '/contacts', data),
updateContact: (id, data) => request('PATCH', `/contacts/${id}`, data),
deleteContact: (id) => request('DELETE', `/contacts/${id}`),
+ // Make an auto-collected contact explicit and export it to the CardDAV
+ // write-target book. Editing the contact deliberately does not do this.
+ promoteContact: (id) => request('POST', `/contacts/${id}/promote`),
// CardDAV contact sync (Nextcloud etc.)
carddav: {
status: () => request('GET', '/carddav'),
connect: (data) => request('POST', '/carddav/connect', data),
update: (data) => request('PATCH', '/carddav', data),
+ patchBook: (id, roles) => request('PATCH', `/carddav/books/${encodeURIComponent(id)}`, roles),
sync: () => request('POST', '/carddav/sync'),
disconnect: () => request('DELETE', '/carddav'),
getConflicts: () => request('GET', '/carddav/conflicts'),