fix: check ContactCard/set results, re-fetch created cards, assign UIDs and paginate contact queries (#644)

This commit is contained in:
Linus Rath
2026-08-29 17:50:44 +02:00
parent ccbe67c1bc
commit 580e010b33
4 changed files with 679 additions and 81 deletions
+225 -18
View File
@@ -1,8 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
const session = {
primaryAccounts: {} as Record<string, string>,
accounts: {} as Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>,
};
vi.mock('../jmap-client', () => ({ vi.mock('../jmap-client', () => ({
jmapClient: { jmapClient: {
accountId: 'acc-1', accountId: 'acc-1',
username: 'me@example.com',
get currentSession() { return session; },
request: vi.fn(), request: vi.fn(),
getMaxObjectsInGet: () => 500, getMaxObjectsInGet: () => 500,
}, },
@@ -11,13 +18,18 @@ vi.mock('../jmap-client', () => ({
import { jmapClient } from '../jmap-client'; import { jmapClient } from '../jmap-client';
import { import {
getAddressBooks, getAddressBooks,
getAllAddressBooks,
getContactsAccountId,
getContactCapableAccountIds,
queryContacts, queryContacts,
getContacts, getContacts,
getAllContacts,
createContact, createContact,
updateContact, updateContact,
deleteContacts, deleteContacts,
createAddressBook, createAddressBook,
updateAddressBook, updateAddressBook,
setDefaultAddressBook,
deleteAddressBook, deleteAddressBook,
} from '../contacts'; } from '../contacts';
@@ -25,11 +37,31 @@ const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
session.primaryAccounts = {};
session.accounts = {};
}); });
describe('contacts operations', () => { describe('contacts operations', () => {
describe('account resolution', () => {
it('prefers the contacts primary account over the mail account', () => {
expect(getContactsAccountId()).toBe('acc-1');
session.primaryAccounts = { 'urn:ietf:params:jmap:contacts': 'acc-contacts' };
expect(getContactsAccountId()).toBe('acc-contacts');
});
it('lists contacts-capable and non-personal accounts after the primary', () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
'acc-cal': { name: 'Cal', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
'acc-ct': { name: 'Ct', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:contacts': {} } },
};
expect(getContactCapableAccountIds()).toEqual(['acc-1', 'acc-team', 'acc-ct']);
});
});
describe('getAddressBooks', () => { describe('getAddressBooks', () => {
it('should fetch address books', async () => { it('should fetch address books with an explicit property list', async () => {
const books = [{ id: 'ab-1', name: 'Personal' }]; const books = [{ id: 'ab-1', name: 'Personal' }];
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/get', { list: books }, '0']], methodResponses: [['AddressBook/get', { list: books }, '0']],
@@ -37,21 +69,64 @@ describe('contacts operations', () => {
const result = await getAddressBooks(); const result = await getAddressBooks();
expect(result).toEqual(books); expect(result).toEqual(books);
expect(mockRequest).toHaveBeenCalledWith( const call = mockRequest.mock.calls[0][0][0];
[['AddressBook/get', { accountId: 'acc-1' }, '0']], expect(call[0]).toBe('AddressBook/get');
expect.arrayContaining(['urn:ietf:params:jmap:contacts']), expect(call[1].accountId).toBe('acc-1');
); expect(call[1].properties).toEqual(expect.arrayContaining(['shareWith', 'myRights', 'isDefault']));
expect(mockRequest.mock.calls[0][1]).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:contacts']));
});
it('throws on a method-level error', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['error', { type: 'serverFail', description: 'boom' }, '0']],
});
await expect(getAddressBooks()).rejects.toThrow('boom');
});
});
describe('getAllAddressBooks', () => {
it('namespaces books from shared accounts and tags them', async () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
};
mockRequest
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Shared' }] }, '0']] });
const books = await getAllAddressBooks();
expect(books).toHaveLength(2);
expect(books[0]).toMatchObject({ id: 'default', originalId: 'default', isShared: false });
expect(books[1]).toMatchObject({
id: 'acc-team:default', originalId: 'default', accountId: 'acc-team', accountName: 'Team', isShared: true,
});
});
it('keeps own books when a shared account fails', async () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
};
mockRequest
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
.mockRejectedValueOnce(new Error('forbidden'));
const books = await getAllAddressBooks();
expect(books.map((b) => b.id)).toEqual(['default']);
}); });
}); });
describe('queryContacts', () => { describe('queryContacts', () => {
it('should query contacts with text filter', async () => { it('should query contacts with text filter', async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'] }, '0']], methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'], total: 2 }, '0']],
}); });
const result = await queryContacts({ text: 'john' }); const result = await queryContacts({ text: 'john' });
expect(result).toEqual(['c1', 'c2']); expect(result).toEqual(['c1', 'c2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'john' });
expect(call[1].position).toBe(0);
}); });
it('should omit filter when none provided', async () => { it('should omit filter when none provided', async () => {
@@ -63,6 +138,28 @@ describe('contacts operations', () => {
const call = mockRequest.mock.calls[0][0][0]; const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toBeUndefined(); expect(call[1].filter).toBeUndefined();
}); });
it('walks position until every id is collected', async () => {
mockRequest
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['a', 'b'], total: 5 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c', 'd'], total: 5 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['e'], total: 5 }, '0']] });
const result = await queryContacts(undefined, 2);
expect(result).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(mockRequest).toHaveBeenCalledTimes(3);
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(2);
expect(mockRequest.mock.calls[2][0][0][1].position).toBe(4);
});
it('stops when the server returns a short page without a total', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: ['a'] }, '0']],
});
const result = await queryContacts(undefined, 2);
expect(result).toEqual(['a']);
expect(mockRequest).toHaveBeenCalledTimes(1);
});
}); });
describe('getContacts', () => { describe('getContacts', () => {
@@ -77,21 +174,85 @@ describe('contacts operations', () => {
}); });
}); });
describe('createContact', () => { describe('getAllContacts', () => {
it('should create a contact in the specified address book', async () => { it('namespaces cards and their book ids for shared accounts', async () => {
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } }; session.accounts = {
mockRequest.mockResolvedValue({ 'acc-1': { name: 'me', isPersonal: true },
methodResponses: [['ContactCard/set', { created: { 'new-contact': created } }, '0']], 'acc-team': { name: 'Team', isPersonal: false },
};
mockRequest
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c1'], total: 1 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { default: true } }] }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c9'], total: 1 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c9', addressBookIds: { default: true } }] }, '0']] });
const cards = await getAllContacts();
expect(cards[0]).toEqual({ id: 'c1', addressBookIds: { default: true } });
expect(cards[1]).toMatchObject({
id: 'acc-team:c9',
originalId: 'c9',
addressBookIds: { 'acc-team:default': true },
accountId: 'acc-team',
isShared: true,
}); });
});
});
describe('createContact', () => {
it('assigns a urn:uuid UID, then re-fetches the created card', async () => {
const full = {
id: 'c-new',
uid: 'urn:uuid:whatever',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given', value: 'Jane' }] },
};
mockRequest
.mockResolvedValueOnce({
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
})
.mockResolvedValueOnce({
methodResponses: [['ContactCard/get', { list: [full] }, '0']],
});
const result = await createContact( const result = await createContact(
{ name: { components: [{ kind: 'given', value: 'Jane' }] } }, { name: { components: [{ kind: 'given', value: 'Jane' }] } },
'ab-1', 'ab-1',
); );
expect(result).toEqual(created); expect(result).toEqual(full);
const call = mockRequest.mock.calls[0][0][0]; const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
expect(call[1].create['new-contact'].addressBookIds).toEqual({ 'ab-1': true }); expect(create.addressBookIds).toEqual({ 'ab-1': true });
expect(create.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/);
expect(mockRequest.mock.calls[1][0][0][0]).toBe('ContactCard/get');
expect(mockRequest.mock.calls[1][0][0][1].ids).toEqual(['c-new']);
});
it('keeps an existing uid and strips client-only fields', async () => {
mockRequest
.mockResolvedValueOnce({
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
})
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] });
const result = await createContact(
{ uid: 'urn:uuid:keep', isShared: true, accountName: 'x', name: { full: 'A' } },
'ab-1',
);
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
expect(create.uid).toBe('urn:uuid:keep');
expect(create.isShared).toBeUndefined();
expect(create.accountName).toBeUndefined();
// Falls back to a client-side merge when the re-fetch comes back empty.
expect(result).toMatchObject({ id: 'c-new', uid: 'urn:uuid:keep', name: { full: 'A' }, addressBookIds: { 'ab-1': true } });
});
it('throws the server description when the card is rejected', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'bad date', properties: ['anniversaries'] } },
}, '0']],
});
await expect(createContact({ name: { full: 'A' } }, 'ab-1')).rejects.toThrow('bad date (anniversaries)');
}); });
}); });
@@ -106,18 +267,46 @@ describe('contacts operations', () => {
const call = mockRequest.mock.calls[0][0][0]; const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ c1: { kind: 'individual' } }); expect(call[1].update).toEqual({ c1: { kind: 'individual' } });
}); });
it('throws when the server refuses the update', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { notUpdated: { c1: { type: 'forbidden', description: 'read-only book' } } }, '0']],
});
await expect(updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only book');
});
it('routes to the given account', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: {} }, '0']] });
await updateContact('c1', { kind: 'org' }, 'acc-team');
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-team');
});
}); });
describe('deleteContacts', () => { describe('deleteContacts', () => {
it('should destroy contacts by ids', async () => { it('should destroy contacts by ids and return the destroyed ones', async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']], methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']],
}); });
await deleteContacts(['c1', 'c2']); const destroyed = await deleteContacts(['c1', 'c2']);
const call = mockRequest.mock.calls[0][0][0]; const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['c1', 'c2']); expect(call[1].destroy).toEqual(['c1', 'c2']);
expect(destroyed).toEqual(['c1', 'c2']);
});
it('returns only the destroyed subset on partial failure', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { destroyed: ['c1'], notDestroyed: { c2: { type: 'forbidden' } } }, '0']],
});
expect(await deleteContacts(['c1', 'c2'])).toEqual(['c1']);
});
it('throws when nothing was destroyed', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { notDestroyed: { c1: { type: 'forbidden', description: 'nope' } } }, '0']],
});
await expect(deleteContacts(['c1'])).rejects.toThrow('nope');
}); });
}); });
@@ -143,18 +332,36 @@ describe('contacts operations', () => {
}); });
describe('updateAddressBook', () => { describe('updateAddressBook', () => {
it('forwards only settable properties', async () => { it('forwards only settable properties and never isDefault', async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']], methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
}); });
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never); await updateAddressBook('ab-1', { name: 'Renamed', isDefault: true, id: 'should-be-ignored' } as never);
const call = mockRequest.mock.calls[0][0][0]; const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } }); expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
}); });
}); });
describe('setDefaultAddressBook', () => {
it('uses onSuccessSetIsDefault', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: {} }, '0']],
});
await setDefaultAddressBook('ab-2');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1]).toEqual({ accountId: 'acc-1', onSuccessSetIsDefault: 'ab-2' });
});
it('throws on a method error', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['error', { type: 'invalidArguments', description: 'no such book' }, '0']],
});
await expect(setDefaultAddressBook('ab-x')).rejects.toThrow('no such book');
});
});
describe('deleteAddressBook', () => { describe('deleteAddressBook', () => {
it('destroys the book by id', async () => { it('destroys the book by id', async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
+289 -60
View File
@@ -1,9 +1,25 @@
import { jmapClient } from './jmap-client'; import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { ContactCard, AddressBook } from './types'; import type { ContactCard, AddressBook } from './types';
import { generateUUID } from '../lib/uuid';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]; 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 { function methodResult<T = any>(res: any, index = 0): T {
const entry = res?.methodResponses?.[index]; const entry = res?.methodResponses?.[index];
if (!entry) throw new Error('JMAP: empty method response'); if (!entry) throw new Error('JMAP: empty method response');
@@ -14,40 +30,139 @@ function methodResult<T = any>(res: any, index = 0): T {
return entry[1] as T; return entry[1] as T;
} }
export async function getAddressBooks(): Promise<AddressBook[]> { function setErrorMessage(err: SetError | undefined, fallback: string): string {
const accountId = jmapClient.accountId; if (!err) return fallback;
const detail = err.description || err.type || fallback;
return err.properties?.length ? `${detail} (${err.properties.join(', ')})` : detail;
}
/**
* 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
* accounts that advertise the contacts capability or are non-personal (shared
* / group) accounts - Stalwart doesn't always advertise capabilities on group
* accounts even when they have contact resources. Mirrors the webmail's
* getContactCapableAccountIds().
*/
export function getContactCapableAccountIds(): string[] {
const primaryId = getContactsAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const out: string[] = [];
for (const [id, account] of Object.entries(accounts)) {
if (id === primaryId) continue;
if (account.accountCapabilities?.[CAPABILITIES.CONTACTS] || account.isPersonal === false) {
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( const res = await jmapClient.request(
[['AddressBook/get', { accountId }, '0']], [['AddressBook/get', { accountId: account, properties: ADDRESS_BOOK_PROPERTIES }, '0']],
USING, USING,
); );
return methodResult<{ list: AddressBook[] }>(res).list ?? []; return methodResult<{ list: AddressBook[] }>(res).list ?? [];
} }
export async function queryContacts( /**
filter?: { text?: string; inAddressBook?: string }, * Address books across every contacts-capable account. Books from non-primary
limit = 1000, * (shared / group) accounts get a namespaced id plus `accountId`, `accountName`
): Promise<string[]> { * and `isShared`; a failing shared account never hides the user's own books.
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query). */
// Client sorts by display name after fetch. export async function getAllAddressBooks(): Promise<AddressBook[]> {
const accountId = jmapClient.accountId; const primaryId = getContactsAccountId();
const args: Record<string, unknown> = { accountId, limit }; const all: AddressBook[] = [];
if (filter && Object.keys(filter).length > 0) args.filter = filter; for (const accountId of getContactCapableAccountIds()) {
const res = await jmapClient.request( const isPrimary = accountId === primaryId;
[['ContactCard/query', args, '0']], try {
USING, const books = await getAddressBooks(accountId);
); all.push(...books.map((book) => ({
return methodResult<{ ids: string[] }>(res).ids ?? []; ...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 getContacts(ids: string[]): Promise<ContactCard[]> { 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 []; if (ids.length === 0) return [];
const accountId = jmapClient.accountId; const account = accountId || getContactsAccountId();
const batchSize = jmapClient.getMaxObjectsInGet(); const batchSize = jmapClient.getMaxObjectsInGet();
const all: ContactCard[] = []; const all: ContactCard[] = [];
for (let i = 0; i < ids.length; i += batchSize) { for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize); const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request( const res = await jmapClient.request(
[['ContactCard/get', { accountId, ids: batch }, '0']], [['ContactCard/get', { accountId: account, ids: batch }, '0']],
USING, USING,
); );
const list = methodResult<{ list: ContactCard[] }>(res).list ?? []; const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
@@ -56,93 +171,207 @@ export async function getContacts(ids: string[]): Promise<ContactCard[]> {
return all; 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( export async function createContact(
contact: Partial<ContactCard>, contact: Partial<ContactCard>,
addressBookId: string, addressBookId: string,
accountId?: string,
): Promise<ContactCard> { ): Promise<ContactCard> {
const accountId = jmapClient.accountId; const account = accountId || getContactsAccountId();
const data = stripClientFields(contact);
const res = await jmapClient.request( const res = await jmapClient.request(
[['ContactCard/set', { [['ContactCard/set', {
accountId, accountId: account,
create: { create: {
'new-contact': { ...contact, addressBookIds: { [addressBookId]: true } }, 'new-contact': {
...data,
// 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']], }, '0']],
USING, USING,
); );
return res.methodResponses[0][1].created['new-contact']; const result = methodResult<{
created?: Record<string, Partial<ContactCard>>;
notCreated?: Record<string, SetError>;
}>(res);
const created = result.created?.['new-contact'];
if (!created?.id) {
throw new Error(setErrorMessage(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( export async function updateContact(
id: string, id: string,
changes: Partial<ContactCard>, changes: Partial<ContactCard>,
accountId?: string,
): Promise<void> { ): Promise<void> {
const accountId = jmapClient.accountId; const account = accountId || getContactsAccountId();
await jmapClient.request(
[['ContactCard/set', { accountId, update: { [id]: changes } }, '0']],
USING,
);
}
export async function deleteContacts(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['ContactCard/set', { accountId, destroy: ids }, '0']],
USING,
);
}
export async function createAddressBook(name: string): Promise<AddressBook> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request( const res = await jmapClient.request(
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']], [['ContactCard/set', { accountId: account, update: { [id]: stripClientFields(changes) } }, '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 contact'));
}
/**
* 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, USING,
); );
const result = methodResult<{ const result = methodResult<{
created?: Record<string, AddressBook>; created?: Record<string, AddressBook>;
notCreated?: Record<string, { description?: string; type?: string }>; notCreated?: Record<string, SetError>;
}>(res); }>(res);
const created = result.created?.['new-book']; const created = result.created?.['new-book'];
if (created) return { ...created, name }; if (created) return { ...created, name };
const err = result.notCreated?.['new-book']; throw new Error(setErrorMessage(result.notCreated?.['new-book'], 'Failed to create address book'));
throw new Error(err?.description || err?.type || 'Failed to create address book');
} }
export async function updateAddressBook( export async function updateAddressBook(
id: string, id: string,
updates: Partial<AddressBook>, updates: Partial<AddressBook>,
accountId?: string,
): Promise<void> { ): Promise<void> {
const accountId = jmapClient.accountId; const account = accountId || getContactsAccountId();
// Only forward server-settable properties. // Only forward server-settable properties. `isDefault` is deliberately not
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>; // 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> = {}; const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name; if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description; if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder; if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
const res = await jmapClient.request( const res = await jmapClient.request(
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']], [['AddressBook/set', { accountId: account, update: { [id]: patch } }, '0']],
USING, USING,
); );
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res); const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
const err = result.notUpdated?.[id]; const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update address book'); if (err) throw new Error(setErrorMessage(err, 'Failed to update address book'));
} }
export async function deleteAddressBook(id: string): Promise<void> { /**
const accountId = jmapClient.accountId; * 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( const res = await jmapClient.request(
[['AddressBook/set', { accountId, destroy: [id] }, '0']], [['AddressBook/set', { accountId: account, onSuccessSetIsDefault: id }, '0']],
USING, USING,
); );
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res); methodResult(res);
}
export async function deleteAddressBook(id: string, accountId?: string): Promise<void> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, destroy: [id] }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);
const err = result.notDestroyed?.[id]; const err = result.notDestroyed?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to delete address book'); if (err) throw new Error(setErrorMessage(err, 'Failed to delete address book'));
} }
/** Fetch all contact cards that live in a specific address book. */ /** Fetch all contact cards that live in a specific address book. */
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> { export async function getContactsInBook(addressBookId: string, accountId?: string): Promise<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId }); const ids = await queryContacts({ inAddressBook: addressBookId }, undefined, accountId);
return getContacts(ids); return getContacts(ids, accountId);
} }
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import {
getContactDisplayName,
getContactPhotoUri,
normalizeContactPhotoUri,
partialDateToString,
stringToPartialDate,
} from '../contact-utils';
import type { ContactCard } from '../../api/types';
describe('getContactDisplayName', () => {
it('joins given + surname only, like the webmail', () => {
const c: ContactCard = {
id: 'c',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'middle', value: 'Q' }, { kind: 'surname', value: 'Doe' }] },
};
expect(getContactDisplayName(c)).toBe('Jane Doe');
});
});
describe('normalizeContactPhotoUri (#307)', () => {
it('adds a media type to data URIs that lack one', () => {
expect(normalizeContactPhotoUri('data:base64,AAAA')).toBe('data:image/jpeg;base64,AAAA');
expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/png')).toBe('data:image/png;base64,AAAA');
});
it('leaves well-formed URIs alone', () => {
expect(normalizeContactPhotoUri('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA');
expect(normalizeContactPhotoUri('https://x/y.jpg')).toBe('https://x/y.jpg');
});
it('is applied by getContactPhotoUri', () => {
const c: ContactCard = { id: 'c', addressBookIds: {}, media: { m: { kind: 'photo', uri: 'data:base64,AAAA' } } };
expect(getContactPhotoUri(c)).toBe('data:image/jpeg;base64,AAAA');
});
});
describe('partialDateToString', () => {
it.each([
[{ year: 1990, month: 5, day: 4 }, '1990-05-04'],
[{ year: 1990, month: 5 }, '1990-05'],
[{ year: 1990 }, '1990'],
[{ month: 5, day: 4 }, '--05-04'],
[{ month: 5 }, '--05'],
[{ day: 4 }, '---04'],
])('is lossless for %j', (pd, expected) => {
expect(partialDateToString(pd)).toBe(expected);
});
it('handles timestamps and strings', () => {
expect(partialDateToString({ '@type': 'Timestamp', utc: '2020-01-02T03:04:05Z' })).toBe('2020-01-02');
expect(partialDateToString('1999-12-31')).toBe('1999-12-31');
expect(partialDateToString(undefined)).toBe('');
});
});
describe('stringToPartialDate', () => {
it.each([
['1990-05-04', { year: 1990, month: 5, day: 4 }],
['19900504', { year: 1990, month: 5, day: 4 }],
['1990-05-04T00:00:00Z', { year: 1990, month: 5, day: 4 }],
['1990-05', { year: 1990, month: 5 }],
['1990', { year: 1990 }],
['--05-04', { month: 5, day: 4 }],
['--0504', { month: 5, day: 4 }],
['--05', { month: 5 }],
['---04', { day: 4 }],
])('parses %s', (input, expected) => {
expect(stringToPartialDate(input)).toEqual(expected);
});
it.each(['May 5', '1990-13-01', 'circa 1800', '', ' '])('rejects %s', (input) => {
expect(stringToPartialDate(input)).toBeNull();
});
it('round-trips through partialDateToString', () => {
for (const s of ['1990-05-04', '1990-05', '1990', '--05-04', '--05', '---04']) {
expect(partialDateToString(stringToPartialDate(s)!)).toBe(s);
}
});
});
+83 -3
View File
@@ -7,11 +7,13 @@ import type {
export function getContactDisplayName(contact: ContactCard): string { export function getContactDisplayName(contact: ContactCard): string {
if (contact.name) { if (contact.name) {
// given + surname only, like the webmail: keeps sort order, dedupe keys
// and initials identical across clients (the middle name is shown by the
// detail screen, not by the list).
if (contact.name.components && contact.name.components.length > 0) { if (contact.name.components && contact.name.components.length > 0) {
const given = contact.name.components.find((c) => c.kind === 'given')?.value || ''; const given = contact.name.components.find((c) => c.kind === 'given')?.value || '';
const surname = contact.name.components.find((c) => c.kind === 'surname')?.value || ''; const surname = contact.name.components.find((c) => c.kind === 'surname')?.value || '';
const middle = contact.name.components.find((c) => c.kind === 'middle')?.value || ''; const full = [given, surname].filter(Boolean).join(' ');
const full = [given, middle, surname].filter(Boolean).join(' ');
if (full) return full; if (full) return full;
} }
if (contact.name.full) return contact.name.full; if (contact.name.full) return contact.name.full;
@@ -68,14 +70,92 @@ export function getContactInitials(contact: ContactCard): string {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
} }
// Some JMAP servers (notably Stalwart, see webmail #307) emit photo data URIs
// without a mediatype, like `data:base64,...` or `data:;base64,...`. Per
// RFC 2397 the missing/empty mediatype defaults to `text/plain`, so RN's
// Image cannot decode the bytes. Rewrite to include a mediatype.
export function normalizeContactPhotoUri(uri: string, mediaType?: string): string {
const mime = mediaType && mediaType.includes('/') ? mediaType : 'image/jpeg';
if (uri.startsWith('data:base64,')) {
return `data:${mime};base64,${uri.slice('data:base64,'.length)}`;
}
if (uri.startsWith('data:;base64,')) {
return `data:${mime};base64,${uri.slice('data:;base64,'.length)}`;
}
return uri;
}
export function getContactPhotoUri(contact: ContactCard): string | undefined { export function getContactPhotoUri(contact: ContactCard): string | undefined {
if (!contact.media) return undefined; if (!contact.media) return undefined;
for (const media of Object.values(contact.media)) { for (const media of Object.values(contact.media)) {
if (media.kind === 'photo' && media.uri) return media.uri; if (media.kind === 'photo' && media.uri) {
return normalizeContactPhotoUri(media.uri, media.mediaType);
}
} }
return undefined; return undefined;
} }
/**
* Lossless PartialDate → input string: `1990-05-04`, `1990-05`, `1990`,
* `--05-04`, `--05`, `---04`. Never pads a missing month/day with `01` (that
* used to rewrite a year-only birthday to Jan 1 on the next save).
*/
export function partialDateToString(d: AnniversaryDate | undefined): string {
if (!d) return '';
if (typeof d === 'string') return d;
if (typeof d === 'object') {
if ('@type' in d && d['@type'] === 'Timestamp') {
return typeof d.utc === 'string' ? d.utc.split('T')[0] : '';
}
const pd = d as PartialDate;
const yr = pd.year ? String(pd.year).padStart(4, '0') : '';
const mo = pd.month ? String(pd.month).padStart(2, '0') : '';
const da = pd.day ? String(pd.day).padStart(2, '0') : '';
if (yr && mo && da) return `${yr}-${mo}-${da}`;
if (yr && mo) return `${yr}-${mo}`;
if (yr) return yr;
if (mo && da) return `--${mo}-${da}`;
if (mo) return `--${mo}`;
if (da) return `---${da}`;
}
return '';
}
/**
* Parse a typed date into an RFC 9553 PartialDate. Accepts the extended and
* basic vCard forms (`YYYY-MM-DD`, `YYYYMMDD`, `YYYY-MM`, `YYYY`, `--MM-DD`,
* `--MMDD`, `--MM`, `---DD`) and an optional trailing time part, which is
* dropped. Returns null for anything else - Stalwart rejects string dates, so
* free text must never reach the server.
*/
export function stringToPartialDate(s: string): PartialDate | null {
const datePart = s.trim().split(/[T ]/)[0];
if (!datePart) return null;
const mk = (year?: number, month?: number, day?: number): PartialDate | null => {
if (month !== undefined && (month < 1 || month > 12)) return null;
if (day !== undefined && (day < 1 || day > 31)) return null;
const pd: PartialDate = {};
if (year !== undefined) pd.year = year;
if (month !== undefined) pd.month = month;
if (day !== undefined) pd.day = day;
return Object.keys(pd).length > 0 ? pd : null;
};
const n = (v: string | undefined) => (v === undefined ? undefined : parseInt(v, 10));
let m = datePart.match(/^---(\d{2})$/);
if (m) return mk(undefined, undefined, n(m[1]));
m = datePart.match(/^--(\d{2})-?(\d{2})$/);
if (m) return mk(undefined, n(m[1]), n(m[2]));
m = datePart.match(/^--(\d{2})$/);
if (m) return mk(undefined, n(m[1]));
m = datePart.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
if (m) return mk(n(m[1]), n(m[2]), n(m[3]));
m = datePart.match(/^(\d{4})-(\d{2})$/);
if (m) return mk(n(m[1]), n(m[2]));
m = datePart.match(/^(\d{4})$/);
if (m) return mk(n(m[1]));
return null;
}
const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function formatPartialDate(dateInput: AnniversaryDate | string | undefined): string { export function formatPartialDate(dateInput: AnniversaryDate | string | undefined): string {