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] 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 c590b96e..e72544d2 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 00000000..5e4e07e7
--- /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 00000000..38258c2f
--- /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 00000000..6d253e07
--- /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 00000000..7714b952
--- /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 fc54c5cf..53bcb5cd 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 03691887..48751412 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 231383d5..c03f44b0 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 00000000..3484bdf0
--- /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 8495cdaf..d3c0eb8f 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 00000000..9b3b20cf
--- /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 00000000..ed168c18
--- /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 00000000..7794d39c
--- /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 8c096f1b..15dc71cf 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 00000000..e13c183d
--- /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 af1ed103..fa55a24c 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 00000000..b0b3bfa6
--- /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 1fd18141..e02261fa 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 00000000..a2957c71
--- /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 00000000..d196450a
--- /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 00000000..24a17150
--- /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 00000000..9c07fcf0
--- /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 00000000..50bce3a1
--- /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 00000000..86e4afc8
--- /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 00000000..f50e07c6
--- /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 00000000..7c67246e
--- /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 00000000..a25b6bbb
--- /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 00000000..e5ccbcdc
--- /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 00000000..98231c78
--- /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 00000000..38158cd4
--- /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 00000000..85fc9564
--- /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 ddbe589c..1b8a0645 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 00000000..47558a6f
--- /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 00000000..92a6dfe0
--- /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 00000000..a59deec8
--- /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 00000000..8e193510
--- /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 00000000..68bf632e
--- /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 68c6d5db..17078a50 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 5eb559e6..5a57957f 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 3577b963..680c6340 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 38fc9a20..5dd41dd5 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 00000000..d87a2c22
--- /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 00000000..431c137f
--- /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 dd0f0973..03a0f792 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 b184c6ec..d485729c 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 4b8bf418..76864328 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 00000000..db26a664
--- /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 00000000..8668f25d
--- /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 00000000..ef487ee8
--- /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 00000000..fe7ba2f9
--- /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 00000000..2b5a9141
--- /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 00000000..f61959a5
--- /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 00000000..02061c5c
--- /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 00000000..5a009991
--- /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 16638936..32e08f30 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 02493f6f..991fc869 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 00000000..3c4c0797
--- /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 00000000..663fb359
--- /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 00000000..0c2e98e4
--- /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 c8b7f574..54fd0a15 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 e594dd23..1e532c33 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 f05d5694..ec5a4660 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 976bc5a6..807c7e06 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 e266dc18..d4ffbf1c 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 1368084b..10daf2e1 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 3fba0b04..8b49e37d 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 09edcc98..3dec5a5c 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 64e1346d..3697d359 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