Move completed implementation plans (2026-04-20), design specs, and audit documents to docs/archive/ for historical reference while keeping the main docs/ directory focused on active documentation. Archived: - 1 implementation plan (superpowers/plans/) - 2 design specs (superpowers/specs/) - 3 design documents (designs/) - 5 audit/proposal documents (root level) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
17 KiB
CardDAV Contacts Sync Design
Issue: #10 – CardDAV provider for Contacts
Date: 2026-05-04
Status: Approved
Overview
Enable multi-account CardDAV synchronization for the Contacts module, allowing family members to sync their phone contacts into Oikos. This implements inbound-only sync (CardDAV → Oikos) with smart merging, multiple values per contact (phones, emails, addresses), and per-account addressbook selection.
Requirements Summary
Based on Issue #10 and design discussion:
- Multi-Account Support – Connect multiple CardDAV servers simultaneously (iCloud, Nextcloud, company servers)
- Addressbook Selection – Checkbox-based enable/disable per addressbook (like CalDAV calendar selection)
- Inbound-Only Sync – CardDAV → Oikos; no outbound sync (read-only from server perspective)
- Smart Merge – Match by email/phone; update existing contacts instead of creating duplicates
- Editable with Merge – Synced contacts are editable in Oikos; manual changes preserved (only NULL fields filled on sync)
- Hybrid Sync – Auto-sync via cron + manual "Sync Now" button
- Visual Source Marking – Icon/badge shows which account synced each contact
- Keep on Delete – When account/addressbook deleted, contacts remain (lose CardDAV link, become manual contacts)
- Settings Integration – New "Contacts Sync" section in Settings → Calendar tab
- Full Field Support – Extended schema for all iOS/Android contact fields (organization, job title, birthday, website, photo, nickname)
- Multiple Values – Separate tables for phones/emails/addresses with labels (mobile, work, home)
Architecture
Components
- Service:
server/services/cardav-sync.js– Account management, addressbook discovery, contact sync - API Routes:
server/routes/contacts.jsextended + new/cardav/*endpoints - DB Tables: 6 new/extended tables (Migration 30)
- UI: Settings → Calendar tab extended with "Contacts Sync" section
- Library:
tsdav(already present as optionalDependency)
Data Flow
CardDAV Server → tsdav → cardav-sync.js → Smart Merge → contacts + contact_phones/emails/addresses
↓
UI (Settings, Contacts List)
Database Schema (Migration 30)
New Table: cardav_accounts
CREATE TABLE cardav_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- User-defined label ("iCloud", "Nextcloud")
cardav_url TEXT NOT NULL, -- CardDAV server base URL
username TEXT NOT NULL, -- CardDAV username
password TEXT NOT NULL, -- Encrypted if DB_ENCRYPTION_KEY set
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_sync TEXT, -- ISO 8601, nullable
UNIQUE(cardav_url, username)
);
New Table: cardav_addressbook_selection
CREATE TABLE cardav_addressbook_selection (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL, -- FK → cardav_accounts
addressbook_url TEXT NOT NULL, -- CardDAV addressbook URL
addressbook_name TEXT NOT NULL, -- Display name from provider
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = disabled, 1 = enabled
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(account_id, addressbook_url),
FOREIGN KEY(account_id) REFERENCES cardav_accounts(id) ON DELETE CASCADE
);
Extended Table: contacts
ALTER TABLE contacts ADD COLUMN organization TEXT; -- Company/Organization
ALTER TABLE contacts ADD COLUMN job_title TEXT; -- Job title
ALTER TABLE contacts ADD COLUMN birthday TEXT; -- ISO 8601 date (YYYY-MM-DD)
ALTER TABLE contacts ADD COLUMN website TEXT; -- URL
ALTER TABLE contacts ADD COLUMN photo TEXT; -- Base64 data URL
ALTER TABLE contacts ADD COLUMN nickname TEXT;
ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER; -- FK → cardav_accounts, nullable
ALTER TABLE contacts ADD COLUMN cardav_uid TEXT; -- vCard UID from server, nullable
ALTER TABLE contacts ADD COLUMN cardav_addressbook_url TEXT; -- Source addressbook, nullable
-- Indices for Smart Merge
CREATE INDEX idx_contacts_cardav_uid ON contacts(cardav_uid);
CREATE INDEX idx_contacts_email ON contacts(email);
Note: Existing phone, email, address columns remain for backward compatibility and as fallback for primary values.
New Table: contact_phones
CREATE TABLE contact_phones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT, -- 'mobile', 'work', 'home', 'other', 'iphone', 'main', 'fax'
value TEXT NOT NULL, -- Phone number
is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = primary number
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id);
CREATE INDEX idx_contact_phones_value ON contact_phones(value);
New Table: contact_emails
CREATE TABLE contact_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT, -- 'work', 'home', 'other', 'icloud'
value TEXT NOT NULL, -- Email address
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id);
CREATE INDEX idx_contact_emails_value ON contact_emails(value);
New Table: contact_addresses
CREATE TABLE contact_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT, -- 'home', 'work', 'other'
street TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
country TEXT,
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
Design Decisions
cardav_uidstores vCard UID from server for re-sync identificationcardav_account_idis NULL for manual contacts, set for synced contacts- Account deletion: Sets
cardav_account_id = NULL(contacts remain as manual contacts) is_primaryflag marks primary phone/email/address for UI display and tel:/mailto: links- Backward compatibility: Existing
phone,email,addresscolumns remain; synced contacts also populate these with primary values
Sync Service (server/services/cardav-sync.js)
Structure
// Account Management
addAccount(name, cardavUrl, username, password)
→ Test connection via tsdav
→ Store encrypted password
→ Insert into cardav_accounts
→ Discover and insert addressbooks
→ Return { account, addressbooks }
deleteAccount(accountId)
→ SET cardav_account_id = NULL for all contacts (keep contacts)
→ DELETE from cardav_accounts (CASCADE deletes addressbook_selection)
testConnection(cardavUrl, username, password)
→ Use tsdav.createDAVClient() to connect
→ Fetch addressbooks to verify
→ Return { ok: true, addressbooks } or throw error
getAllAccounts()
→ SELECT * FROM cardav_accounts
// Addressbook Discovery
discoverAddressbooks(accountId)
→ Fetch addressbooks from server via tsdav
→ UPSERT into cardav_addressbook_selection
→ Return list with enabled status
// Contact Sync
syncAccount(accountId)
→ Get all enabled addressbooks for account
→ For each: syncAddressbook(accountId, addressbookUrl)
→ Update last_sync timestamp
→ Return { synced: count, errors: count }
syncAddressbook(accountId, addressbookUrl)
→ Fetch all vCards from addressbook via tsdav
→ For each vCard: parseAndMergeContact(vCardText, accountId, addressbookUrl)
parseAndMergeContact(vCardText, accountId, addressbookUrl)
→ Parse vCard fields (see Field Mapping below)
→ Apply Smart Merge Logic
→ Insert/Update contacts + contact_phones/emails/addresses
Smart Merge Logic
1. Extract UID from vCard
2. Check: EXISTS contact WHERE cardav_uid = UID?
→ YES:
- UPDATE existing contact (only NULL fields are filled)
- Preserve manual changes in non-NULL fields
- UPDATE cardav_account_id, cardav_addressbook_url
→ NO:
Check: EXISTS contact WHERE email IN vCard.emails OR phone IN vCard.phones?
→ YES:
- UPDATE existing contact (fill NULL fields)
- SET cardav_uid, cardav_account_id (establish link)
→ NO:
- INSERT new contact with all vCard fields
- SET cardav_uid, cardav_account_id
3. Update contact_phones/emails/addresses:
- DELETE existing entries for this contact WHERE is_primary = 0
- INSERT new entries from vCard
- Keep entries WHERE is_primary = 1 (manually marked)
- If no primary exists, mark first entry as primary
Field Mapping (vCard → Oikos)
| vCard Property | Oikos Field(s) | Notes |
|---|---|---|
FN |
name |
Formatted name |
N |
name |
Fallback if FN missing |
TEL |
contact_phones |
Multiple entries with labels |
EMAIL |
contact_emails |
Multiple entries with labels |
ADR |
contact_addresses |
Multiple entries with labels |
ORG |
organization |
Company/organization |
TITLE |
job_title |
Job title |
URL |
website |
First URL (if multiple, take first) |
BDAY |
birthday |
ISO 8601 date (YYYY-MM-DD) |
PHOTO |
photo |
Base64 data URL |
NICKNAME |
nickname |
Nickname |
NOTE |
notes |
Notes |
CATEGORIES |
category |
Map to Oikos categories or use 'Sonstiges' |
Error Handling
- Connection failures: Log error, skip sync, return error to UI
- Invalid vCards: Log warning, skip contact, continue with next
- Database errors: Rollback transaction, return error
- Auth failures: Log error, mark account as "needs re-auth" (future enhancement)
API Routes
New CardDAV Management Routes
POST /api/v1/contacts/cardav/accounts
Body: { name, cardavUrl, username, password }
Response: { data: { account, addressbooks: [...] } }
GET /api/v1/contacts/cardav/accounts
Response: { data: [{ id, name, cardavUrl, username, lastSync }] }
DELETE /api/v1/contacts/cardav/accounts/:id
Response: { data: { deleted: true } }
POST /api/v1/contacts/cardav/accounts/:id/test
Response: { data: { ok: true } }
GET /api/v1/contacts/cardav/accounts/:id/addressbooks
Response: { data: [{ id, url, name, enabled }] }
POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
Response: { data: [{ id, url, name, enabled }] }
PUT /api/v1/contacts/cardav/addressbooks/:id
Body: { enabled: true/false }
Response: { data: { id, enabled } }
POST /api/v1/contacts/cardav/accounts/:id/sync
Response: { data: { synced: 15, errors: 0 } }
Extended Contacts Routes
GET /api/v1/contacts/:id
Response: {
data: {
id, name, category, notes, organization, jobTitle, birthday,
website, photo, nickname, cardavAccountId, cardavUid,
phones: [{ id, label, value, isPrimary }],
emails: [{ id, label, value, isPrimary }],
addresses: [{ id, label, street, city, state, postalCode, country, isPrimary }]
}
}
POST /api/v1/contacts
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
Response: { data: Contact }
PUT /api/v1/contacts/:id
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
Response: { data: Contact }
UI Integration
Settings → Calendar Tab (Extended)
Restructure with two sections:
Settings → Calendar
[Section 1: Calendar Sync]
- Google Calendar OAuth
- CalDAV Accounts
- ICS Subscriptions
[Section 2: Contacts Sync] ← NEW
- CardDAV Accounts
CardDAV Account Card:
<div class="sync-account-card">
<div class="account-header">
<strong>iCloud</strong>
<span class="last-sync">Last sync: 2 minutes ago</span>
</div>
<div class="account-actions">
<button class="refresh-addressbooks">Refresh Addressbooks</button>
<button class="sync-now">Sync Now</button>
<button class="delete-account">Delete</button>
</div>
<!-- Addressbook Selection (expandable) -->
<div class="addressbook-list">
<label>
<input type="checkbox" checked data-id="1">
📇 Personal (enabled)
</label>
<label>
<input type="checkbox" data-id="2">
💼 Work (disabled)
</label>
</div>
</div>
Add Account Modal:
- Fields: Name, CardDAV URL, Username, Password
- Test connection on save
- On success: Show addressbook list immediately
Contact List (public/pages/contacts.js)
Source Badge:
<div class="contact-card">
<div class="contact-header">
<strong>Max Mustermann</strong>
<span class="contact-source-badge" v-if="contact.cardavAccountId">
<i data-lucide="cloud"></i> iCloud
</span>
</div>
<div class="contact-phones">
📱 +49 123 456 (mobile) · 🏢 +49 789 (work)
</div>
<div class="contact-emails">
✉️ max@example.com (home) · 💼 max@work.com (work)
</div>
</div>
Contact Modal (Extended)
New Fields:
- Organization (text input)
- Job Title (text input)
- Birthday (date picker)
- Website (URL input)
- Nickname (text input)
- Photo (upload button, like Birthdays module)
Multiple Values UI:
<div class="form-group">
<label>Phone Numbers</label>
<div id="phones-list">
<div class="multi-value-row">
<select class="phone-label">
<option value="mobile">Mobile</option>
<option value="work">Work</option>
<option value="home">Home</option>
<option value="other">Other</option>
</select>
<input type="tel" class="phone-value" value="+49 123">
<label class="checkbox-inline">
<input type="checkbox" class="is-primary"> Primary
</label>
<button class="btn-remove">✕</button>
</div>
</div>
<button id="add-phone" class="btn btn--secondary">+ Add Phone</button>
</div>
<!-- Same pattern for Emails and Addresses -->
Testing (test-cardav.js)
Uses Node's built-in test runner with in-memory SQLite (like test-caldav.js).
Test Coverage
// DB Schema
- should create cardav_accounts table
- should create cardav_addressbook_selection table with FK CASCADE
- should add new columns to contacts table
- should create contact_phones/emails/addresses tables
- should enforce UNIQUE constraint on (cardav_url, username)
// Account Management
- should add account and store encrypted password
- should reject duplicate accounts (same URL + username)
- should delete account and set contacts' cardav_account_id = NULL
- should keep contacts when account is deleted
// Addressbook Selection
- should insert addressbook selection
- should CASCADE delete when account deleted
- should toggle enabled/disabled status
// Smart Merge Logic
- should create new contact when cardav_uid not found
- should update existing contact when cardav_uid matches
- should match by email and link to CardDAV
- should match by phone and link to CardDAV
- should fill only NULL fields on merge (preserve manual changes)
// Multiple Values
- should insert multiple phones/emails/addresses
- should mark is_primary correctly
- should CASCADE delete when contact deleted
// vCard Parsing
- should parse FN, N, TEL, EMAIL, ADR, ORG, TITLE, URL, BDAY, PHOTO, NOTE
- should handle missing optional fields
- should handle multiple TEL/EMAIL/ADR entries with labels
Mock Strategy
- In-memory SQLite (no persistent DB)
- Mock
tsdavimports with fixture vCard data - No real CardDAV server calls in tests
Implementation Notes
Phase 1: Database & Core Service
- Migration 30 (all tables)
server/services/cardav-sync.js(account management, sync logic)- Tests for DB schema and sync logic
Phase 2: API Routes
- New
/api/v1/contacts/cardav/*routes - Extended
/api/v1/contactsroutes for multiple values - Tests for API routes
Phase 3: UI Integration
- Settings → Calendar tab extended
- Contact list with source badges
- Contact modal extended (new fields, multiple values)
- Tests for UI interactions
Phase 4: Cron Integration
- Add CardDAV sync to existing cron job (like CalDAV)
- Use same
SYNC_INTERVAL_MINUTESenv var
Security Considerations
- Password Encryption: Use same encryption as CalDAV (DB_ENCRYPTION_KEY)
- CSRF Protection: All POST/PUT/DELETE routes use existing CSRF middleware
- Session Auth: All routes require authenticated session
- Input Validation: Validate all fields (max lengths, URL format, email format)
- SQL Injection: Use parameterized queries (better-sqlite3)
- XSS Prevention: Use
esc()for all user-generated content in UI
Future Enhancements
- Conflict Resolution UI: Show conflicts when manual changes differ from server
- Selective Field Sync: Choose which fields to sync per addressbook
- Sync Statistics: Show detailed sync logs (added, updated, skipped)
- vCard Export (Multi): Export all contacts as single .vcf file
- CardDAV Server Mode: Oikos as CardDAV server (Issue #10 mentioned this as possible future)
Design Status: ✅ Approved
Next Step: Create implementation plan via writing-plans skill