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
+44
View File
@@ -174,6 +174,41 @@ describe('contacts operations', () => {
const result = await getContacts(['c1']);
expect(result).toEqual(contacts);
});
it('reads the calendar and scheduling links into the form fields', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/get', { list: [{
id: 'c1',
calendars: { cal: { kind: 'calendar', uri: 'https://cal' } },
schedulingAddresses: { s: { uri: 'mailto:s@x' } },
}] }, '0']],
});
const [card] = await getContacts(['c1']);
expect(card).toMatchObject({ calendarUri: 'https://cal', schedulingUri: 'mailto:s@x' });
});
});
// Stalwart refuses the whole card over calendarUri ("Invalid property.").
describe('calendar, scheduling and free/busy links', () => {
it('creates them as RFC 9553 calendars and scheduling addresses', async () => {
mockRequest
.mockResolvedValueOnce({ methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] });
await createContact({ name: { full: 'A' }, calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:a@x' }, 'ab-1');
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
expect(create).not.toHaveProperty('calendarUri');
expect(create).not.toHaveProperty('freeBusyUri');
expect(create).not.toHaveProperty('schedulingUri');
expect(Object.keys(create.calendars)).toEqual(['cal', 'fb']);
expect(create.schedulingAddresses.sched.uri).toBe('mailto:a@x');
});
it('clears them on an update', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: { c1: null } }, '0']] });
await updateContact('c1', { calendarUri: null, schedulingUri: null } as never);
const patch = mockRequest.mock.calls[0][0][0][1].update.c1;
expect(patch).toEqual({ calendars: null, schedulingAddresses: null });
});
});
describe('getAllContacts', () => {
@@ -445,6 +480,15 @@ describe('contacts operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['ab-1']);
expect(call[1]).not.toHaveProperty('onDestroyRemoveContents');
});
it('takes the cards along when asked (RFC 9610 addressBookHasContents)', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
});
await deleteAddressBook('ab-1', undefined, { removeContents: true });
expect(mockRequest.mock.calls[0][0][0][1].onDestroyRemoveContents).toBe(true);
});
it('throws when destroy fails', async () => {
+19 -16
View File
@@ -3,6 +3,7 @@ import { JMAPSetError } from './jmap-result';
import { CAPABILITIES } from './types';
import type { ContactCard, ContactMedia, AddressBook } from './types';
import { generateUUID } from '../lib/uuid';
import { contactFromWire, contactToWire } from '../lib/contact-wire';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
@@ -42,14 +43,6 @@ function setError(err: SetError | undefined, fallback: string): JMAPSetError {
return new JMAPSetError(err?.type ?? 'setError', setErrorMessage(err, fallback), err?.properties);
}
/**
* Properties an update never clears (webmail contactToWire). A null `id` fails
* the whole update ("The id property is immutable.") and so does a null
* `addressBookIds`; a null `uid` is refused by Stalwart 1.0 and on 0.16
* silently deletes the card's UID.
*/
const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const;
/**
* Primary account for the contacts capability (RFC 8620 `primaryAccounts`),
* falling back to the mail primary. Contacts can live in a different account
@@ -180,7 +173,7 @@ export async function getContacts(ids: string[], accountId?: string): Promise<Co
USING,
);
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
all.push(...list);
all.push(...list.map(contactFromWire));
}
return all;
}
@@ -238,12 +231,15 @@ export async function createContact(
): Promise<ContactCard> {
const account = accountId || getContactsAccountId();
const data = stripClientFields(contact);
// The calendar, scheduling and SOURCE links and a vCard's flat address
// fields as RFC 9553 has them: Stalwart refuses the whole card otherwise.
const wire = contactToWire(data, 'create');
const res = await jmapClient.request(
[['ContactCard/set', {
accountId: account,
create: {
'new-contact': {
...data,
...wire,
// Stalwart stores the card without one if omitted (#644), which
// breaks group membership and CardDAV round-trips.
uid: data.uid || `urn:uuid:${generateUUID()}`,
@@ -285,10 +281,7 @@ export async function updateContact(
accountId?: string,
): Promise<void> {
const account = accountId || getContactsAccountId();
const patch = { ...stripClientFields(changes) } as Record<string, unknown>;
for (const key of NEVER_CLEARED_KEYS) {
if (patch[key] == null) delete patch[key];
}
const patch = contactToWire(stripClientFields(changes), 'update');
const res = await jmapClient.request(
[['ContactCard/set', { accountId: account, update: { [id]: patch } }, '0']],
USING,
@@ -398,10 +391,20 @@ export async function setDefaultAddressBook(id: string, accountId?: string): Pro
methodResult(res);
}
export async function deleteAddressBook(id: string, accountId?: string): Promise<void> {
export async function deleteAddressBook(
id: string,
accountId?: string,
options?: { removeContents?: boolean },
): Promise<void> {
const account = accountId || getContactsAccountId();
// Without onDestroyRemoveContents a book that still holds cards is
// refused with `addressBookHasContents` (RFC 9610 §2.3; Stalwart 1.0).
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, destroy: [id] }, '0']],
[['AddressBook/set', {
accountId: account,
destroy: [id],
...(options?.removeContents ? { onDestroyRemoveContents: true } : {}),
}, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);