fix: keep contact UIDs on update and export MEMBER URIs unchanged

`contactToWire` sends a key that is present but undefined as null, so a
caller passing a card back without `uid`, `id` or `addressBookIds` (the
plugin API does) asked the server to clear them. Stalwart 1.0 refuses a
null UID and 0.16 silently deletes it; a null `id` or `addressBookIds`
fails the update on both. Update mode now leaves those keys out.

vCard export prefixed every group member with `urn:uuid:`, but keys are
already URIs for groups made in Bulwark (members are keyed by uid) and
for groups from CardDAV clients, which gave
`MEMBER:urn:uuid:urn:uuid:…`. Keys with a URI scheme are written as they
are.
This commit is contained in:
Linus Rath
2026-09-25 18:40:22 +02:00
parent e712454ec2
commit 606771fee6
5 changed files with 99 additions and 1 deletions
+11
View File
@@ -17,6 +17,15 @@ const CLIENT_ONLY_KEYS = [
/** vCard CALURI / FBURL / CALADRURI, kept flat in the UI. */
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'],
@@ -49,12 +58,14 @@ 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;