mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: save contacts with calendar or scheduling links and re-import exported vCards by UID
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user