diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index c617756e..92e50d35 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +const session = { + primaryAccounts: {} as Record, + accounts: {} as Record }>, +}; + vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', + username: 'me@example.com', + get currentSession() { return session; }, request: vi.fn(), getMaxObjectsInGet: () => 500, }, @@ -11,13 +18,18 @@ vi.mock('../jmap-client', () => ({ import { jmapClient } from '../jmap-client'; import { getAddressBooks, + getAllAddressBooks, + getContactsAccountId, + getContactCapableAccountIds, queryContacts, getContacts, + getAllContacts, createContact, updateContact, deleteContacts, createAddressBook, updateAddressBook, + setDefaultAddressBook, deleteAddressBook, } from '../contacts'; @@ -25,11 +37,31 @@ const mockRequest = jmapClient.request as ReturnType; beforeEach(() => { vi.clearAllMocks(); + session.primaryAccounts = {}; + session.accounts = {}; }); 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', () => { - it('should fetch address books', async () => { + it('should fetch address books with an explicit property list', async () => { const books = [{ id: 'ab-1', name: 'Personal' }]; mockRequest.mockResolvedValue({ methodResponses: [['AddressBook/get', { list: books }, '0']], @@ -37,21 +69,64 @@ describe('contacts operations', () => { const result = await getAddressBooks(); expect(result).toEqual(books); - expect(mockRequest).toHaveBeenCalledWith( - [['AddressBook/get', { accountId: 'acc-1' }, '0']], - expect.arrayContaining(['urn:ietf:params:jmap:contacts']), - ); + const call = mockRequest.mock.calls[0][0][0]; + expect(call[0]).toBe('AddressBook/get'); + 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', () => { it('should query contacts with text filter', async () => { mockRequest.mockResolvedValue({ - methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'] }, '0']], + methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'], total: 2 }, '0']], }); const result = await queryContacts({ text: 'john' }); 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 () => { @@ -63,6 +138,28 @@ describe('contacts operations', () => { const call = mockRequest.mock.calls[0][0][0]; 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', () => { @@ -77,21 +174,85 @@ describe('contacts operations', () => { }); }); - describe('createContact', () => { - it('should create a contact in the specified address book', async () => { - const created = { id: 'c-new', addressBookIds: { 'ab-1': true } }; - mockRequest.mockResolvedValue({ - methodResponses: [['ContactCard/set', { created: { 'new-contact': created } }, '0']], + describe('getAllContacts', () => { + it('namespaces cards and their book ids for shared accounts', async () => { + session.accounts = { + 'acc-1': { name: 'me', isPersonal: true }, + '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( { name: { components: [{ kind: 'given', value: 'Jane' }] } }, 'ab-1', ); - expect(result).toEqual(created); - const call = mockRequest.mock.calls[0][0][0]; - expect(call[1].create['new-contact'].addressBookIds).toEqual({ 'ab-1': true }); + expect(result).toEqual(full); + const create = mockRequest.mock.calls[0][0][0][1].create['new-contact']; + 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]; 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', () => { - it('should destroy contacts by ids', async () => { + it('should destroy contacts by ids and return the destroyed ones', async () => { mockRequest.mockResolvedValue({ 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]; 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', () => { - it('forwards only settable properties', async () => { + it('forwards only settable properties and never isDefault', async () => { mockRequest.mockResolvedValue({ 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]; 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', () => { it('destroys the book by id', async () => { mockRequest.mockResolvedValue({ diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 0c5d95eb..82e6ba96 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -1,9 +1,25 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { ContactCard, AddressBook } from './types'; +import { generateUUID } from '../lib/uuid'; 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(res: any, index = 0): T { const entry = res?.methodResponses?.[index]; if (!entry) throw new Error('JMAP: empty method response'); @@ -14,40 +30,139 @@ function methodResult(res: any, index = 0): T { return entry[1] as T; } -export async function getAddressBooks(): Promise { - const accountId = jmapClient.accountId; +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; +} + +/** + * 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 `:`. */ +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>(contact: T): Partial { + const { + originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, + ...rest + } = contact as ContactCard; + return rest; +} + +export async function getAddressBooks(accountId?: string): Promise { + const account = accountId || getContactsAccountId(); const res = await jmapClient.request( - [['AddressBook/get', { accountId }, '0']], + [['AddressBook/get', { accountId: account, properties: ADDRESS_BOOK_PROPERTIES }, '0']], USING, ); return methodResult<{ list: AddressBook[] }>(res).list ?? []; } -export async function queryContacts( - filter?: { text?: string; inAddressBook?: string }, - limit = 1000, -): Promise { - // Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query). - // Client sorts by display name after fetch. - const accountId = jmapClient.accountId; - const args: Record = { accountId, limit }; - if (filter && Object.keys(filter).length > 0) args.filter = filter; - const res = await jmapClient.request( - [['ContactCard/query', args, '0']], - USING, - ); - return methodResult<{ ids: string[] }>(res).ids ?? []; +/** + * 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 { + 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 getContacts(ids: string[]): Promise { +export async function queryContacts( + filter?: { text?: string; inAddressBook?: string }, + limit?: number, + accountId?: string, +): Promise { + // 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 = { 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 { if (ids.length === 0) return []; - const accountId = jmapClient.accountId; + 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, ids: batch }, '0']], + [['ContactCard/get', { accountId: account, ids: batch }, '0']], USING, ); const list = methodResult<{ list: ContactCard[] }>(res).list ?? []; @@ -56,93 +171,207 @@ export async function getContacts(ids: string[]): Promise { 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 { + 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 { + const list = await getContacts([id], accountId); + return list[0] ?? null; +} + export async function createContact( contact: Partial, addressBookId: string, + accountId?: string, ): Promise { - const accountId = jmapClient.accountId; + const account = accountId || getContactsAccountId(); + const data = stripClientFields(contact); const res = await jmapClient.request( [['ContactCard/set', { - accountId, + accountId: account, 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']], USING, ); - return res.methodResponses[0][1].created['new-contact']; + const result = methodResult<{ + created?: Record>; + notCreated?: Record; + }>(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( id: string, changes: Partial, + accountId?: string, ): Promise { - const accountId = jmapClient.accountId; - await jmapClient.request( - [['ContactCard/set', { accountId, update: { [id]: changes } }, '0']], - USING, - ); -} - -export async function deleteContacts(ids: string[]): Promise { - const accountId = jmapClient.accountId; - await jmapClient.request( - [['ContactCard/set', { accountId, destroy: ids }, '0']], - USING, - ); -} - -export async function createAddressBook(name: string): Promise { - const accountId = jmapClient.accountId; + const account = accountId || getContactsAccountId(); 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 }>(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 { + 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; + }>(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 { + const account = accountId || getContactsAccountId(); + const res = await jmapClient.request( + [['AddressBook/set', { accountId: account, create: { 'new-book': { name } } }, '0']], USING, ); const result = methodResult<{ created?: Record; - notCreated?: Record; + notCreated?: Record; }>(res); const created = result.created?.['new-book']; if (created) return { ...created, name }; - const err = result.notCreated?.['new-book']; - throw new Error(err?.description || err?.type || 'Failed to create address book'); + throw new Error(setErrorMessage(result.notCreated?.['new-book'], 'Failed to create address book')); } export async function updateAddressBook( id: string, updates: Partial, + accountId?: string, ): Promise { - const accountId = jmapClient.accountId; - // Only forward server-settable properties. - const { name, description, sortOrder, isDefault } = updates as Record; + 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; const patch: Record = {}; if (name !== undefined) patch.name = name; if (description !== undefined) patch.description = description; if (sortOrder !== undefined) patch.sortOrder = sortOrder; - if (isDefault !== undefined) patch.isDefault = isDefault; const res = await jmapClient.request( - [['AddressBook/set', { accountId, update: { [id]: patch } }, '0']], + [['AddressBook/set', { accountId: account, update: { [id]: patch } }, '0']], USING, ); - const result = methodResult<{ notUpdated?: Record }>(res); + const result = methodResult<{ notUpdated?: Record }>(res); 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 { - 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 { + const account = accountId || getContactsAccountId(); const res = await jmapClient.request( - [['AddressBook/set', { accountId, destroy: [id] }, '0']], + [['AddressBook/set', { accountId: account, onSuccessSetIsDefault: id }, '0']], USING, ); - const result = methodResult<{ notDestroyed?: Record }>(res); + methodResult(res); +} + +export async function deleteAddressBook(id: string, accountId?: string): Promise { + const account = accountId || getContactsAccountId(); + const res = await jmapClient.request( + [['AddressBook/set', { accountId: account, destroy: [id] }, '0']], + USING, + ); + const result = methodResult<{ notDestroyed?: Record }>(res); 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. */ -export async function getContactsInBook(addressBookId: string): Promise { - const ids = await queryContacts({ inAddressBook: addressBookId }); - return getContacts(ids); +export async function getContactsInBook(addressBookId: string, accountId?: string): Promise { + const ids = await queryContacts({ inAddressBook: addressBookId }, undefined, accountId); + return getContacts(ids, accountId); } diff --git a/src/lib/__tests__/contact-utils.test.ts b/src/lib/__tests__/contact-utils.test.ts new file mode 100644 index 00000000..0996391b --- /dev/null +++ b/src/lib/__tests__/contact-utils.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/contact-utils.ts b/src/lib/contact-utils.ts index 5752583f..989e6416 100644 --- a/src/lib/contact-utils.ts +++ b/src/lib/contact-utils.ts @@ -7,11 +7,13 @@ import type { export function getContactDisplayName(contact: ContactCard): string { 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) { const given = contact.name.components.find((c) => c.kind === 'given')?.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, middle, surname].filter(Boolean).join(' '); + const full = [given, surname].filter(Boolean).join(' '); if (full) return 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(); } +// 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 { if (!contact.media) return undefined; 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; } +/** + * 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']; export function formatPartialDate(dateInput: AnniversaryDate | string | undefined): string {