fix: save contacts with calendar or scheduling links and re-import exported vCards by UID

This commit is contained in:
Linus Rath
2026-10-29 03:28:09 +01:00
parent d57bdfaace
commit 32f70c103d
8 changed files with 453 additions and 37 deletions
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { contactFromWire, contactToWire } from '../contact-wire';
import { parseVCard } from '../vcard';
import type { ContactCard } from '../../api/types';
describe('contactToWire', () => {
// Stalwart refuses a card with calendarUri / schedulingUri / freeBusyUri
// as properties ("Invalid property.").
it('writes the calendar, scheduling and free/busy links as RFC 9553 entries', () => {
const wire = contactToWire({
name: { full: 'A' },
calendarUri: 'https://cal.example/a',
freeBusyUri: 'https://cal.example/a/fb',
schedulingUri: 'mailto:a@example.com',
}, 'create');
expect(wire).not.toHaveProperty('calendarUri');
expect(wire).not.toHaveProperty('freeBusyUri');
expect(wire).not.toHaveProperty('schedulingUri');
expect(wire.calendars).toEqual({
cal: { '@type': 'Calendar', kind: 'calendar', uri: 'https://cal.example/a' },
fb: { '@type': 'Calendar', kind: 'freeBusy', uri: 'https://cal.example/a/fb' },
});
expect(wire.schedulingAddresses).toEqual({ sched: { '@type': 'SchedulingAddress', uri: 'mailto:a@example.com' } });
});
it('clears the links on an update that empties them', () => {
const wire = contactToWire({ calendarUri: null as never, schedulingUri: null as never }, 'update');
expect(wire.calendars).toBeNull();
expect(wire.schedulingAddresses).toBeNull();
});
it('leaves the links alone on an update that does not name them', () => {
const wire = contactToWire({ name: { full: 'B' } }, 'update');
expect(wire).not.toHaveProperty('calendars');
expect(wire).not.toHaveProperty('schedulingAddresses');
});
it('never clears id, uid or the address books', () => {
const wire = contactToWire({ uid: undefined, addressBookIds: null as never, notes: undefined }, 'update');
expect(wire).toEqual({ notes: null });
});
// vCard import: flat ADR fields, LABEL, SOURCE and BIRTHPLACE.
it('writes what a vCard import holds as RFC 9553 has it', () => {
const [card] = parseVCard([
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Max Muster',
'ADR;TYPE=HOME;LABEL="Hauptstr. 1, Berlin":;;Hauptstr. 1;Berlin;;10115;Germany',
'SOURCE:https://dir.example/max.vcf',
'BDAY:19800102',
'BIRTHPLACE:Hamburg',
'END:VCARD',
].join('\r\n'));
const { id: _id, addressBookIds: _b, ...data } = card;
const wire = contactToWire(data, 'create');
const address = Object.values(wire.addresses as Record<string, Record<string, unknown>>)[0];
for (const flat of ['street', 'locality', 'region', 'postcode', 'country', 'fullAddress']) {
expect(address).not.toHaveProperty(flat);
}
expect(address.components).toEqual([
{ kind: 'name', value: 'Hauptstr. 1' },
{ kind: 'locality', value: 'Berlin' },
{ kind: 'postcode', value: '10115' },
{ kind: 'country', value: 'Germany' },
]);
expect(address.full).toBe('Hauptstr. 1, Berlin');
expect(wire).not.toHaveProperty('source');
expect(wire.directories).toEqual({ source: { '@type': 'Directory', kind: 'entry', uri: 'https://dir.example/max.vcf' } });
const birth = Object.values(wire.anniversaries as Record<string, { place?: Record<string, unknown> }>)[0];
expect(birth.place).toEqual({ full: 'Hamburg' });
});
it('drops the client-only account fields', () => {
const wire = contactToWire({ originalId: 'x', accountId: 'a', accountName: 'n', isShared: true, name: { full: 'C' } }, 'create');
expect(wire).toEqual({ name: { full: 'C' } });
});
});
describe('contactFromWire', () => {
it('fills the flat links from what the server stores', () => {
const card = contactFromWire({
id: 'c',
addressBookIds: {},
calendars: { x: { kind: 'calendar', uri: 'https://cal' }, y: { kind: 'freeBusy', uri: 'https://fb' } },
schedulingAddresses: { s: { uri: 'mailto:s@x' } },
directories: { d: { kind: 'entry', uri: 'https://src' } },
} as ContactCard);
expect(card).toMatchObject({
calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:s@x', source: 'https://src',
});
});
it('round-trips through the wire form', () => {
const app = { calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:s@x' };
const back = contactFromWire({ id: 'c', addressBookIds: {}, ...contactToWire(app, 'create') } as ContactCard);
expect(back).toMatchObject(app);
});
});
+137
View File
@@ -0,0 +1,137 @@
import type { AddressComponent, ContactAddress, ContactAnniversary, ContactCard } from '../api/types';
/**
* Translation between the ContactCard shape the app works with and the RFC
* 9553 card the server stores (webmail lib/jmap/contact-wire.ts).
*
* ContactCard/set rejects a whole card over a single property it does not
* know ("Invalid property."), so client-side fields must never reach it, and
* the app's flat conveniences are written as their JSContact equivalents:
* the vCard CALURI / FBURL / CALADRURI / SOURCE links, and the flat street,
* locality... of a vCard ADR.
*/
/** Fields the client adds to cards it lists; not part of RFC 9553. */
const CLIENT_ONLY_KEYS = ['originalId', 'accountId', 'accountName', 'isShared'] as const;
/** vCard CALURI / FBURL / CALADRURI / SOURCE, kept flat in the app. */
const URI_KEYS = ['calendarUri', 'freeBusyUri', 'schedulingUri', 'source'] as const;
/**
* Properties an update never clears. A null `id` fails the whole update ("The
* id property is immutable.") and so does a null `addressBookIds` ("Invalid
* patch operation for addressBookIds."). A null `uid` is refused by Stalwart
* 1.0 ("You cannot change the UID of a contact.") and on 0.16 silently
* deletes the card's UID.
*/
const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const;
const LEGACY_ADDRESS_FIELDS: Array<[keyof ContactAddress, AddressComponent['kind']]> = [
['street', 'name'],
['locality', 'locality'],
['region', 'region'],
['postcode', 'postcode'],
['country', 'country'],
];
/** The RFC 9553 calendar, scheduling and directory entries the app reads. */
interface WireLinks {
calendars?: Record<string, { kind?: string; uri?: string } | null>;
schedulingAddresses?: Record<string, { uri?: string } | null>;
}
/** vCard imports store flat fields; RFC 9553 wants `components` and `full`. */
function addressToWire(address: ContactAddress): Record<string, unknown> {
const { street: _s, locality: _l, region: _r, postcode: _p, country: _c, fullAddress, ...rest } = address;
const out: Record<string, unknown> = { ...rest };
if (!address.components?.length) {
const components = LEGACY_ADDRESS_FIELDS
.map(([field, kind]) => ({ kind, value: (address[field] as string | undefined)?.trim() ?? '' }))
.filter((c) => c.value);
if (components.length) {
out.components = components;
out.isOrdered = true;
out.defaultSeparator = ', ';
}
}
if (fullAddress && !address.full) out.full = fullAddress;
return out;
}
/** A BIRTHPLACE / DEATHPLACE read from a vCard is an address too. */
function anniversaryToWire(anniversary: ContactAnniversary): Record<string, unknown> {
if (!anniversary.place) return { ...anniversary };
return { ...anniversary, place: addressToWire(anniversary.place) };
}
type Mode = 'create' | 'update';
/**
* The card as ContactCard/set expects it. On `update`, a key that is present
* with the value `undefined` means "the user cleared it" and is sent as
* `null`: JSON drops undefined, and an omitted property keeps its old value.
* `id`, `uid` and `addressBookIds` without a value are left out instead.
*/
export function contactToWire(card: Partial<ContactCard>, mode: Mode): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(card)) {
if ((CLIENT_ONLY_KEYS as readonly string[]).includes(key)) continue;
if ((URI_KEYS as readonly string[]).includes(key)) continue;
if (mode === 'update' && value == null && (NEVER_CLEARED_KEYS as readonly string[]).includes(key)) continue;
if (value === undefined) {
if (mode === 'update') out[key] = null;
continue;
}
out[key] = value;
}
if (card.addresses) {
out.addresses = Object.fromEntries(
Object.entries(card.addresses).map(([id, a]) => [id, addressToWire(a)]),
);
}
if (card.anniversaries) {
out.anniversaries = Object.fromEntries(
Object.entries(card.anniversaries).map(([id, a]) => [id, anniversaryToWire(a)]),
);
}
// Rebuild the calendar links only when the caller manages them, so an
// update that does not mention them leaves the server's entries alone.
if ('calendarUri' in card || 'freeBusyUri' in card) {
const calendars: Record<string, unknown> = {};
if (card.calendarUri) calendars.cal = { '@type': 'Calendar', kind: 'calendar', uri: card.calendarUri };
if (card.freeBusyUri) calendars.fb = { '@type': 'Calendar', kind: 'freeBusy', uri: card.freeBusyUri };
if (Object.keys(calendars).length) out.calendars = calendars;
else if (mode === 'update') out.calendars = null;
}
if ('schedulingUri' in card) {
if (card.schedulingUri) out.schedulingAddresses = { sched: { '@type': 'SchedulingAddress', uri: card.schedulingUri } };
else if (mode === 'update') out.schedulingAddresses = null;
}
if (card.source) {
// vCard SOURCE is a JSContact directory entry (RFC 9553 §2.6.2).
const directories = { ...(card.directories ?? {}) } as Record<string, unknown>;
directories.source = { '@type': 'Directory', kind: 'entry', uri: card.source };
out.directories = directories;
}
return out;
}
/** Fill the app's flat URI fields from the RFC 9553 properties. */
export function contactFromWire<T extends ContactCard>(card: T): T {
const links = card as T & WireLinks;
const calendars = Object.values(links.calendars ?? {});
const calendarUri = card.calendarUri ?? calendars.find((c) => c?.kind === 'calendar')?.uri;
const freeBusyUri = card.freeBusyUri ?? calendars.find((c) => c?.kind === 'freeBusy')?.uri;
const schedulingUri = card.schedulingUri ?? Object.values(links.schedulingAddresses ?? {})[0]?.uri;
const source = card.source ?? Object.values(card.directories ?? {}).find((d) => d?.kind === 'entry')?.uri;
if (!calendarUri && !freeBusyUri && !schedulingUri && !source) return card;
return {
...card,
...(calendarUri ? { calendarUri } : {}),
...(freeBusyUri ? { freeBusyUri } : {}),
...(schedulingUri ? { schedulingUri } : {}),
...(source ? { source } : {}),
};
}