Files
native/src/api/contacts.ts
T

422 lines
16 KiB
TypeScript

import { jmapClient } from './jmap-client';
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';
import { accountSupports } from '../lib/account-capability';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
// Stalwart's default property list for AddressBook/get omits shareWith, so
// existing shares would be invisible after a fresh login (webmail #257).
const ADDRESS_BOOK_PROPERTIES = [
'id',
'name',
'description',
'sortOrder',
'isDefault',
'isSubscribed',
'shareWith',
'myRights',
] as const;
type SetError = { description?: string; type?: string; properties?: string[] };
function methodResult<T = any>(res: any, index = 0): T {
const entry = res?.methodResponses?.[index];
if (!entry) throw new Error('JMAP: empty method response');
if (entry[0] === 'error') {
const err = entry[1] || {};
throw new Error(err.description || err.type || 'JMAP method error');
}
return entry[1] as T;
}
function setErrorMessage(err: SetError | undefined, fallback: string): string {
if (!err) return fallback;
const detail = err.description || err.type || fallback;
return err.properties?.length ? `${detail} (${err.properties.join(', ')})` : detail;
}
/** The SetError as a thrown error that keeps its type and properties. */
function setError(err: SetError | undefined, fallback: string): JMAPSetError {
return new JMAPSetError(err?.type ?? 'setError', setErrorMessage(err, fallback), err?.properties);
}
/**
* Primary account for the contacts capability (RFC 8620 `primaryAccounts`),
* falling back to the mail primary. Contacts can live in a different account
* than mail on some servers.
*/
export function getContactsAccountId(): string {
const id = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS];
return id || jmapClient.accountId;
}
/**
* Every account that may hold address books: the contacts primary first, then
* the accounts the user may use contacts with (lib/account-capability: a
* shared account that lists its capabilities without contacts is left out,
* one that lists none falls back to the server-wide capability, webmail
* #1239). Mirrors the webmail's getContactCapableAccountIds().
*/
export function getContactCapableAccountIds(): string[] {
const primaryId = getContactsAccountId();
const session = jmapClient.currentSession;
const accounts = session?.accounts ?? {};
const out: string[] = [];
for (const [id, account] of Object.entries(accounts)) {
if (id === primaryId) continue;
if (accountSupports(account, session?.capabilities, CAPABILITIES.CONTACTS)) {
out.push(id);
}
}
return [primaryId, ...out];
}
function accountName(accountId: string): string {
return jmapClient.currentSession?.accounts?.[accountId]?.name || accountId;
}
/** Namespace an id from a non-primary account as `<accountId>:<id>`. */
export function namespaceId(accountId: string, id: string): string {
return `${accountId}:${id}`;
}
/**
* Strip client-only metadata before a card goes to the server. Shared-account
* cards carry namespaced ids and account tags that JMAP would reject as
* unknown properties.
*/
export function stripClientFields<T extends Partial<ContactCard>>(contact: T): Partial<ContactCard> {
const {
originalId: _oid, accountId: _aid, accountName: _an, isShared: _is,
...rest
} = contact as ContactCard;
return rest;
}
export async function getAddressBooks(accountId?: string): Promise<AddressBook[]> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/get', { accountId: account, properties: ADDRESS_BOOK_PROPERTIES }, '0']],
USING,
);
return methodResult<{ list: AddressBook[] }>(res).list ?? [];
}
/**
* Address books across every contacts-capable account. Books from non-primary
* (shared / group) accounts get a namespaced id plus `accountId`, `accountName`
* and `isShared`; a failing shared account never hides the user's own books.
*/
export async function getAllAddressBooks(): Promise<AddressBook[]> {
const primaryId = getContactsAccountId();
const all: AddressBook[] = [];
for (const accountId of getContactCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const books = await getAddressBooks(accountId);
all.push(...books.map((book) => ({
...book,
id: isPrimary ? book.id : namespaceId(accountId, book.id),
originalId: book.id,
accountId,
accountName: isPrimary ? (jmapClient.username || accountName(accountId)) : accountName(accountId),
isShared: !isPrimary,
})));
} catch (err) {
if (isPrimary) throw err;
console.warn(`[contacts] address books for shared account ${accountId} failed`, err);
}
}
return all;
}
export async function queryContacts(
filter?: { text?: string; inAddressBook?: string },
limit?: number,
accountId?: string,
): Promise<string[]> {
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
// Client sorts by display name after fetch. The server may clamp `limit`
// below what we ask for, so walk `position` until the total is reached.
const account = accountId || getContactsAccountId();
const batchSize = limit ?? jmapClient.getMaxObjectsInGet();
const allIds: string[] = [];
let position = 0;
for (;;) {
const args: Record<string, unknown> = { accountId: account, position, limit: batchSize };
if (filter && Object.keys(filter).length > 0) args.filter = filter;
const res = await jmapClient.request(
[['ContactCard/query', args, '0']],
USING,
);
const result = methodResult<{ ids?: string[]; total?: number }>(res);
const ids = result.ids ?? [];
allIds.push(...ids);
const total = typeof result.total === 'number' ? result.total : -1;
if (ids.length === 0 || ids.length < batchSize || (total >= 0 && allIds.length >= total)) break;
position += ids.length;
}
return allIds;
}
export async function getContacts(ids: string[], accountId?: string): Promise<ContactCard[]> {
if (ids.length === 0) return [];
const account = accountId || getContactsAccountId();
const batchSize = jmapClient.getMaxObjectsInGet();
const all: ContactCard[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request(
[['ContactCard/get', { accountId: account, ids: batch }, '0']],
USING,
);
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
all.push(...list.map(contactFromWire));
}
return all;
}
/** Tag a card fetched from `accountId` with the client-side account metadata. */
function tagContact(contact: ContactCard, accountId: string, isPrimary: boolean): ContactCard {
if (isPrimary) return contact;
const addressBookIds = contact.addressBookIds
? Object.fromEntries(
Object.entries(contact.addressBookIds).map(([bookId, v]) => [namespaceId(accountId, bookId), v]),
)
: contact.addressBookIds;
return {
...contact,
id: namespaceId(accountId, contact.id),
originalId: contact.id,
addressBookIds,
accountId,
accountName: accountName(accountId),
isShared: true,
};
}
/**
* Contact cards across every contacts-capable account, namespaced like
* getAllAddressBooks. A failing shared account is skipped; a failing primary
* account throws so the store can surface the error.
*/
export async function getAllContacts(): Promise<ContactCard[]> {
const primaryId = getContactsAccountId();
const all: ContactCard[] = [];
for (const accountId of getContactCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const ids = await queryContacts(undefined, undefined, accountId);
const cards = await getContacts(ids, accountId);
all.push(...cards.map((card) => tagContact(card, accountId, isPrimary)));
} catch (err) {
if (isPrimary) throw err;
console.warn(`[contacts] contacts for shared account ${accountId} failed`, err);
}
}
return all;
}
export async function getContact(id: string, accountId?: string): Promise<ContactCard | null> {
const list = await getContacts([id], accountId);
return list[0] ?? null;
}
export async function createContact(
contact: Partial<ContactCard>,
addressBookId: string,
accountId?: string,
): 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': {
...wire,
// Stalwart stores the card without one if omitted (#644), which
// breaks group membership and CardDAV round-trips.
uid: data.uid || `urn:uuid:${generateUUID()}`,
addressBookIds: { [addressBookId]: true },
},
},
}, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, Partial<ContactCard>>;
notCreated?: Record<string, SetError>;
}>(res);
const created = result.created?.['new-contact'];
if (!created?.id) {
throw setError(result.notCreated?.['new-contact'], 'Failed to create contact');
}
// The set response only carries server-set properties (id, created,
// updated...). Re-fetch the full card so the store appends a complete row
// instead of an "Unnamed" stub; fall back to a client-side merge.
try {
const full = await getContact(created.id, account);
if (full) return full;
} catch {
// fall through to the merge below
}
return {
...data,
uid: data.uid,
...created,
id: created.id,
addressBookIds: { [addressBookId]: true },
} as ContactCard;
}
export async function updateContact(
id: string,
changes: Partial<ContactCard>,
accountId?: string,
): Promise<void> {
const account = accountId || getContactsAccountId();
const patch = contactToWire(stripClientFields(changes), 'update');
const res = await jmapClient.request(
[['ContactCard/set', { accountId: account, update: { [id]: patch } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
const err = result.notUpdated?.[id];
if (err) throw setError(err, 'Failed to update contact');
}
/**
* The card's `media` as the server has it now: null when it has none,
* undefined when the card is gone. Built on before a photo edit, since a
* blobId from an older copy of the card makes Stalwart 1.0 refuse the whole
* update ("The following blobIds do not exist or are not accessible").
*/
export async function getContactMedia(
id: string,
accountId?: string,
): Promise<Record<string, ContactMedia> | null | undefined> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['ContactCard/get', { accountId: account, ids: [id], properties: ['id', 'media'] }, '0']],
USING,
);
const card = (methodResult<{ list?: Array<Pick<ContactCard, 'id' | 'media'>> }>(res).list ?? [])
.find((c) => c.id === id);
if (!card) return undefined;
return card.media ?? null;
}
/**
* Destroy cards. Returns the ids the server actually destroyed; throws when
* nothing could be destroyed (with the first server description) so a
* single-card delete surfaces the reason.
*/
export async function deleteContacts(ids: string[], accountId?: string): Promise<string[]> {
if (ids.length === 0) return [];
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['ContactCard/set', { accountId: account, destroy: ids }, '0']],
USING,
);
const result = methodResult<{
destroyed?: string[];
notDestroyed?: Record<string, SetError>;
}>(res);
const failed = result.notDestroyed ?? {};
// Servers that omit `destroyed` still honoured everything not listed as failed.
const destroyed = result.destroyed ?? ids.filter((id) => !(id in failed));
if (destroyed.length === 0) {
const firstFailure = Object.values(failed)[0];
throw new Error(setErrorMessage(firstFailure, 'Failed to delete contact'));
}
return destroyed;
}
export async function createAddressBook(name: string, accountId?: string): Promise<AddressBook> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, create: { 'new-book': { name } } }, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, AddressBook>;
notCreated?: Record<string, SetError>;
}>(res);
const created = result.created?.['new-book'];
if (created) return { ...created, name };
throw new Error(setErrorMessage(result.notCreated?.['new-book'], 'Failed to create address book'));
}
export async function updateAddressBook(
id: string,
updates: Partial<AddressBook>,
accountId?: string,
): Promise<void> {
const account = accountId || getContactsAccountId();
// Only forward server-settable properties. `isDefault` is deliberately not
// among them: it is read-only in AddressBook/set and including it fails the
// whole update with invalidProperties - use setDefaultAddressBook instead.
const { name, description, sortOrder } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, update: { [id]: patch } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err, 'Failed to update address book'));
}
/**
* Mark an address book as the account default. `isDefault` is read-only in
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
* request argument instead (same shape as Calendar/set).
*/
export async function setDefaultAddressBook(id: string, accountId?: string): Promise<void> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, onSuccessSetIsDefault: id }, '0']],
USING,
);
methodResult(res);
}
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],
...(options?.removeContents ? { onDestroyRemoveContents: true } : {}),
}, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err, 'Failed to delete address book'));
}
/** Fetch all contact cards that live in a specific address book. */
export async function getContactsInBook(addressBookId: string, accountId?: string): Promise<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId }, undefined, accountId);
return getContacts(ids, accountId);
}