diff --git a/src/api/__tests__/recent-recipients.test.ts b/src/api/__tests__/recent-recipients.test.ts new file mode 100644 index 00000000..e2bd1518 --- /dev/null +++ b/src/api/__tests__/recent-recipients.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { queryRecentRecipients, searchSentRecipients } from '../recent-recipients'; + +const mockRequest = jmapClient.request as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('queryRecentRecipients', () => { + it('scans Sent To/Cc newest-first and keeps the first name per address', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/query', { ids: ['m1', 'm2'] }, '0'], + ['Email/get', { + list: [ + { id: 'm1', to: [{ name: 'Jane Doe', email: 'jane@x.com' }], cc: [{ email: 'cc@x.com' }] }, + { id: 'm2', to: [{ name: 'Old Jane', email: 'JANE@x.com' }, { name: 'Bob', email: 'bob@x.com' }] }, + ], + }, '1'], + ], + }); + + const result = await queryRecentRecipients('sent-1'); + expect(result).toEqual([ + { name: 'Jane Doe', email: 'jane@x.com' }, + { name: '', email: 'cc@x.com' }, + { name: 'Bob', email: 'bob@x.com' }, + ]); + + const [query, get] = mockRequest.mock.calls[0][0]; + expect(query[1]).toMatchObject({ + accountId: 'acc-1', + filter: { inMailbox: 'sent-1' }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: 300, + }); + expect(get[1].properties).toEqual(['to', 'cc']); + expect(get[1]['#ids']).toEqual({ resultOf: '0', name: 'Email/query', path: '/ids' }); + }); + + it('returns nothing without a Sent mailbox', async () => { + expect(await queryRecentRecipients('')).toEqual([]); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +describe('searchSentRecipients', () => { + it('only keeps the recipients that match the query', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/query', { ids: ['m1'] }, '0'], + ['Email/get', { + list: [{ id: 'm1', to: [{ name: 'Jane', email: 'jane@x.com' }, { name: 'Bob', email: 'bob@x.com' }] }], + }, '1'], + ], + }); + expect(await searchSentRecipients('jan', 'sent-1')).toEqual([{ name: 'Jane', email: 'jane@x.com' }]); + const query = mockRequest.mock.calls[0][0][0]; + expect(query[1].filter).toEqual({ + operator: 'AND', + conditions: [{ inMailbox: 'sent-1' }, { operator: 'OR', conditions: [{ to: 'jan' }, { cc: 'jan' }] }], + }); + }); +}); diff --git a/src/api/recent-recipients.ts b/src/api/recent-recipients.ts new file mode 100644 index 00000000..847ecc68 --- /dev/null +++ b/src/api/recent-recipients.ts @@ -0,0 +1,105 @@ +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import type { EmailAddress } from './types'; + +export interface RecentRecipient { + name: string; + email: string; +} + +const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL]; + +function collect( + emails: Array<{ to?: EmailAddress[] | null; cc?: EmailAddress[] | null }>, + keep: (key: string, name: string) => boolean = () => true, +): RecentRecipient[] { + // Messages arrive newest-first, so the first occurrence per address is the + // most recent one and carries the freshest display name. + const byEmail = new Map(); + for (const email of emails) { + for (const r of [...(email.to || []), ...(email.cc || [])]) { + if (!r?.email) continue; + const key = r.email.toLowerCase().trim(); + if (!key || byEmail.has(key)) continue; + const name = (r.name || '').trim(); + if (!keep(key, name)) continue; + byEmail.set(key, { name, email: r.email }); + } + } + return Array.from(byEmail.values()); +} + +/** + * The people the user has written to: scans the newest `limit` messages of the + * Sent mailbox and returns each To/Cc address once (OWA-style autocomplete + * cache). Only the recipient headers are fetched - no subject, preview or body. + */ +export async function queryRecentRecipients( + sentMailboxId: string, + limit = 300, + accountId?: string, +): Promise { + if (!sentMailboxId) return []; + const account = accountId || jmapClient.accountId; + const res = await jmapClient.request([ + ['Email/query', { + accountId: account, + filter: { inMailbox: sentMailboxId }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + }, '0'], + ['Email/get', { + accountId: account, + '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, + properties: ['to', 'cc'], + }, '1'], + ], USING); + const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get'); + const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{ + to?: EmailAddress[] | null; + cc?: EmailAddress[] | null; + }>; + return collect(list); +} + +/** + * On-demand server search of the Sent mailbox for recipients matching `query` + * (used when the local caches have no hit). Keeps only the addresses that + * actually match, not every co-recipient of a matching message. + */ +export async function searchSentRecipients( + query: string, + sentMailboxId: string, + limit = 60, + accountId?: string, +): Promise { + const q = query.trim(); + if (!q || !sentMailboxId) return []; + const account = accountId || jmapClient.accountId; + const res = await jmapClient.request([ + ['Email/query', { + accountId: account, + filter: { + operator: 'AND', + conditions: [ + { inMailbox: sentMailboxId }, + { operator: 'OR', conditions: [{ to: q }, { cc: q }] }, + ], + }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + }, '0'], + ['Email/get', { + accountId: account, + '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, + properties: ['to', 'cc'], + }, '1'], + ], USING); + const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get'); + const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{ + to?: EmailAddress[] | null; + cc?: EmailAddress[] | null; + }>; + const lower = q.toLowerCase(); + return collect(list, (key, name) => key.includes(lower) || name.toLowerCase().includes(lower)); +} diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx index 8fadf53c..8cdde32a 100644 --- a/src/components/contacts/ContactImportSheet.tsx +++ b/src/components/contacts/ContactImportSheet.tsx @@ -51,7 +51,7 @@ export default function ContactImportSheet({ const [duplicates, setDuplicates] = React.useState>(new Map()); const [error, setError] = React.useState(null); const [importing, setImporting] = React.useState(false); - const [result, setResult] = React.useState(null); + const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null); const slideY = React.useRef(new Animated.Value(900)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; @@ -144,9 +144,9 @@ export default function ContactImportSheet({ if (toImport.length === 0) return; setImporting(true); try { - const count = await importContacts(toImport, targetBookId); - setResult(count); - onImported?.(count); + const outcome = await importContacts(toImport, targetBookId); + setResult(outcome); + onImported?.(outcome.imported); } catch (e) { setError(e instanceof Error ? e.message : 'Import failed.'); } finally { @@ -174,8 +174,13 @@ export default function ContactImportSheet({ - Imported {result} contact{result === 1 ? '' : 's'}. + Imported {result.imported} contact{result.imported === 1 ? '' : 's'}. + {result.failed > 0 && ( + + {result.failed} contact{result.failed === 1 ? '' : 's'} could not be imported. + + )} ) : parsed.length === 0 ? ( diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index 8390926f..5dc7de91 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -2,11 +2,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../api/contacts', () => ({ getAddressBooks: vi.fn(), + getAllAddressBooks: vi.fn(), queryContacts: vi.fn(), getContacts: vi.fn(), + getAllContacts: vi.fn(), createContact: vi.fn(), updateContact: vi.fn(), deleteContacts: vi.fn(), + createAddressBook: vi.fn(), + updateAddressBook: vi.fn(), + deleteAddressBook: vi.fn(), + setDefaultAddressBook: vi.fn(), + getContactsInBook: vi.fn(), + getContactsAccountId: () => 'acc-1', + getContactCapableAccountIds: () => ['acc-1', 'acc-team'], + namespaceId: (accountId: string, id: string) => `${accountId}:${id}`, + stripClientFields: (c: Record) => { + const { originalId: _o, accountId: _a, accountName: _n, isShared: _s, ...rest } = c; + return rest; + }, +})); + +vi.mock('../../api/recent-recipients', () => ({ + queryRecentRecipients: vi.fn(), + searchSentRecipients: vi.fn(), })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -25,14 +44,32 @@ vi.mock('../../api/jmap-client', () => ({ })); import * as contactsApi from '../../api/contacts'; -import { useContactsStore } from '../contacts-store'; +import * as recentApi from '../../api/recent-recipients'; +import { + useContactsStore, + cleanGroupMembers, + normalizeSuggestions, + selectVisibleContacts, + selectGroupMembers, +} from '../contacts-store'; +import type { ContactCard } from '../../api/types'; const mockGetAddressBooks = contactsApi.getAddressBooks as ReturnType; +const mockGetAllAddressBooks = contactsApi.getAllAddressBooks as ReturnType; const mockQueryContacts = contactsApi.queryContacts as ReturnType; const mockGetContacts = contactsApi.getContacts as ReturnType; +const mockGetAllContacts = contactsApi.getAllContacts as ReturnType; const mockCreateContact = contactsApi.createContact as ReturnType; const mockUpdateContact = contactsApi.updateContact as ReturnType; const mockDeleteContacts = contactsApi.deleteContacts as ReturnType; +const mockCreateAddressBook = contactsApi.createAddressBook as ReturnType; +const mockDeleteAddressBook = contactsApi.deleteAddressBook as ReturnType; +const mockSetDefaultAddressBook = contactsApi.setDefaultAddressBook as ReturnType; +const mockGetContactsInBook = contactsApi.getContactsInBook as ReturnType; +const mockQueryRecent = recentApi.queryRecentRecipients as ReturnType; + +const card = (id: string, extra: Partial = {}): ContactCard => + ({ id, addressBookIds: { 'ab-1': true }, ...extra }) as ContactCard; beforeEach(() => { vi.clearAllMocks(); @@ -41,18 +78,29 @@ beforeEach(() => { describe('contacts-store', () => { describe('fetchAddressBooks', () => { - it('should load address books', async () => { - const books = [{ id: 'ab-1', name: 'Personal' }]; - mockGetAddressBooks.mockResolvedValue(books); + it('should load address books from every contacts-capable account', async () => { + const books = [{ id: 'ab-1', name: 'Personal' }, { id: 'acc-team:ab', name: 'Team', isShared: true }]; + mockGetAllAddressBooks.mockResolvedValue(books); await useContactsStore.getState().fetchAddressBooks(); expect(useContactsStore.getState().addressBooks).toEqual(books); }); + + it('shares one in-flight request between concurrent callers', async () => { + let resolve: (v: unknown) => void = () => {}; + mockGetAllAddressBooks.mockReturnValue(new Promise((r) => { resolve = r; })); + const a = useContactsStore.getState().fetchAddressBooks(); + const b = useContactsStore.getState().fetchAddressBooks(); + resolve([{ id: 'ab-1', name: 'P' }]); + await Promise.all([a, b]); + expect(mockGetAllAddressBooks).toHaveBeenCalledTimes(1); + expect(useContactsStore.getState().addressBooks).toHaveLength(1); + }); }); describe('fetchContacts', () => { - it('should query and fetch contacts', async () => { + it('should query and fetch contacts with a filter', async () => { mockQueryContacts.mockResolvedValue(['c1', 'c2']); const contacts = [{ id: 'c1' }, { id: 'c2' }]; mockGetContacts.mockResolvedValue(contacts); @@ -66,50 +114,379 @@ describe('contacts-store', () => { it('should handle empty results', async () => { mockQueryContacts.mockResolvedValue([]); - await useContactsStore.getState().fetchContacts(); + await useContactsStore.getState().fetchContacts({ text: 'nobody' }); expect(useContactsStore.getState().contacts).toEqual([]); expect(mockGetContacts).not.toHaveBeenCalled(); }); + + it('loads every account without a filter', async () => { + mockGetAllContacts.mockResolvedValue([{ id: 'c1' }, { id: 'acc-team:c2', isShared: true }]); + await useContactsStore.getState().fetchContacts(); + expect(useContactsStore.getState().contacts).toHaveLength(2); + }); + + it('waits for an in-flight address book load before querying', async () => { + const order: string[] = []; + let resolveBooks: (v: unknown) => void = () => {}; + mockGetAllAddressBooks.mockReturnValue(new Promise((r) => { resolveBooks = r; })); + mockGetAllContacts.mockImplementation(async () => { order.push('contacts'); return []; }); + + const books = useContactsStore.getState().fetchAddressBooks(); + const contacts = useContactsStore.getState().fetchContacts(); + expect(order).toEqual([]); + order.push('books-resolved'); + resolveBooks([]); + await Promise.all([books, contacts]); + expect(order).toEqual(['books-resolved', 'contacts']); + }); }); describe('createContact', () => { it('should create and append to list', async () => { - useContactsStore.setState({ contacts: [{ id: 'c1' } as any] }); + useContactsStore.setState({ contacts: [card('c1')], addressBooks: [{ id: 'ab-1', name: 'P' }] }); const created = { id: 'c-new', addressBookIds: { 'ab-1': true } }; mockCreateContact.mockResolvedValue(created); const result = await useContactsStore.getState().createContact({}, 'ab-1'); expect(result).toEqual(created); + expect(mockCreateContact).toHaveBeenCalledWith({}, 'ab-1', undefined); expect(useContactsStore.getState().contacts).toHaveLength(2); }); + + it('routes a shared book to its account and namespaces the result', async () => { + useContactsStore.setState({ + addressBooks: [ + { id: 'ab-1', name: 'P' }, + { id: 'acc-team:ab', originalId: 'ab', name: 'Team', accountId: 'acc-team', accountName: 'Team', isShared: true }, + ], + }); + mockCreateContact.mockResolvedValue({ id: 'c9', addressBookIds: { ab: true } }); + + const result = await useContactsStore.getState().createContact({ name: { full: 'X' } }, 'acc-team:ab'); + + expect(mockCreateContact).toHaveBeenCalledWith({ name: { full: 'X' } }, 'ab', 'acc-team'); + expect(result).toMatchObject({ + id: 'acc-team:c9', originalId: 'c9', accountId: 'acc-team', isShared: true, addressBookIds: { 'acc-team:ab': true }, + }); + }); }); describe('updateContact', () => { it('should update and merge changes in state', async () => { - useContactsStore.setState({ - contacts: [{ id: 'c1', kind: 'individual' } as any], - }); + useContactsStore.setState({ contacts: [card('c1', { kind: 'individual' })] }); mockUpdateContact.mockResolvedValue(undefined); - await useContactsStore.getState().updateContact('c1', { kind: 'org' } as any); + await useContactsStore.getState().updateContact('c1', { kind: 'org' }); expect(useContactsStore.getState().contacts[0].kind).toBe('org'); + expect(mockUpdateContact).toHaveBeenCalledWith('c1', { kind: 'org' }, undefined); + }); + + it('drops properties patched to null locally', async () => { + useContactsStore.setState({ contacts: [card('c1', { phones: { p1: { number: '1' } } })] }); + mockUpdateContact.mockResolvedValue(undefined); + await useContactsStore.getState().updateContact('c1', { phones: null } as never); + expect(useContactsStore.getState().contacts[0].phones).toBeUndefined(); + }); + + it('does not apply changes the server refused', async () => { + useContactsStore.setState({ contacts: [card('c1', { kind: 'individual' })] }); + mockUpdateContact.mockRejectedValue(new Error('read-only')); + await expect(useContactsStore.getState().updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only'); + expect(useContactsStore.getState().contacts[0].kind).toBe('individual'); + }); + + it('de-namespaces book ids and routes shared cards to their account', async () => { + useContactsStore.setState({ + contacts: [card('acc-team:c1', { originalId: 'c1', accountId: 'acc-team', isShared: true })], + }); + mockUpdateContact.mockResolvedValue(undefined); + await useContactsStore.getState().updateContact('acc-team:c1', { addressBookIds: { 'acc-team:ab': true } }); + expect(mockUpdateContact).toHaveBeenCalledWith('c1', { addressBookIds: { ab: true } }, 'acc-team'); }); }); describe('deleteContact', () => { - it('should remove from list', async () => { + it('should remove from list and clean group members', async () => { useContactsStore.setState({ - contacts: [{ id: 'c1' } as any, { id: 'c2' } as any], + contacts: [ + card('c1', { uid: 'urn:uuid:u1' }), + card('c2'), + card('g1', { kind: 'group', members: { u1: true, c2: true } }), + ], }); - mockDeleteContacts.mockResolvedValue(undefined); + mockDeleteContacts.mockResolvedValue(['c1']); await useContactsStore.getState().deleteContact('c1'); + const contacts = useContactsStore.getState().contacts; + expect(contacts.map((c) => c.id)).toEqual(['c2', 'g1']); + expect(contacts[1].members).toEqual({ c2: true }); + }); + }); + + describe('bulkDelete', () => { + it('only drops the ids the server destroyed', async () => { + useContactsStore.setState({ contacts: [card('c1'), card('c2'), card('c3')] }); + mockDeleteContacts.mockResolvedValue(['c1', 'c3']); + + await useContactsStore.getState().bulkDelete(['c1', 'c2', 'c3']); + + expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c2']); + expect(useContactsStore.getState().error).toMatch(/1 contact/); + }); + + it('groups cards by account', async () => { + useContactsStore.setState({ + contacts: [card('c1'), card('acc-team:c2', { originalId: 'c2', accountId: 'acc-team', isShared: true })], + }); + mockDeleteContacts.mockImplementation(async (ids: string[]) => ids); + await useContactsStore.getState().bulkDelete(['c1', 'acc-team:c2']); + expect(mockDeleteContacts).toHaveBeenCalledWith(['c1'], undefined); + expect(mockDeleteContacts).toHaveBeenCalledWith(['c2'], 'acc-team'); + expect(useContactsStore.getState().contacts).toEqual([]); + }); + }); + + describe('importContacts', () => { + it('reports imported and failed counts', async () => { + useContactsStore.setState({ addressBooks: [{ id: 'ab-1', name: 'P' }] }); + mockCreateContact + .mockResolvedValueOnce({ id: 'n1', addressBookIds: { 'ab-1': true } }) + .mockRejectedValueOnce(new Error('invalid')); + const result = await useContactsStore.getState().importContacts( + [{ id: 'import-1', name: { full: 'A' } }, { id: 'import-2', name: { full: 'B' } }], + 'ab-1', + ); + expect(result).toEqual({ imported: 1, failed: 1 }); expect(useContactsStore.getState().contacts).toHaveLength(1); - expect(useContactsStore.getState().contacts[0].id).toBe('c2'); + }); + }); + + describe('address books', () => { + it('deleting a book drops its cards from the cache', async () => { + useContactsStore.setState({ + addressBooks: [{ id: 'ab-1', name: 'P' }, { id: 'ab-2', name: 'Q' }], + contacts: [card('c1'), card('c2', { addressBookIds: { 'ab-2': true } })], + }); + mockDeleteAddressBook.mockResolvedValue(undefined); + await useContactsStore.getState().deleteAddressBook('ab-2'); + expect(useContactsStore.getState().addressBooks.map((b) => b.id)).toEqual(['ab-1']); + expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c1']); + }); + + it('setDefaultAddressBook flips the flag on siblings', async () => { + useContactsStore.setState({ + addressBooks: [{ id: 'ab-1', name: 'P', isDefault: true }, { id: 'ab-2', name: 'Q' }], + }); + mockSetDefaultAddressBook.mockResolvedValue(undefined); + await useContactsStore.getState().setDefaultAddressBook('ab-2'); + expect(mockSetDefaultAddressBook).toHaveBeenCalledWith('ab-2', undefined); + expect(useContactsStore.getState().addressBooks.map((b) => !!b.isDefault)).toEqual([false, true]); + }); + + it('getDefaultAddressBookId prefers the default own book', () => { + useContactsStore.setState({ + addressBooks: [ + { id: 'acc-team:ab', name: 'Team', isShared: true, isDefault: true }, + { id: 'ab-1', name: 'P' }, + { id: 'ab-2', name: 'Q', isDefault: true }, + ], + }); + expect(useContactsStore.getState().getDefaultAddressBookId()).toBe('ab-2'); + }); + }); + + describe('groups', () => { + it('addContactsToGroup references members by uid', async () => { + useContactsStore.setState({ + contacts: [ + card('g1', { kind: 'group', members: { existing: true } }), + card('c1', { uid: 'urn:uuid:u1' }), + card('c2'), + ], + }); + mockUpdateContact.mockResolvedValue(undefined); + await useContactsStore.getState().addContactsToGroup('g1', ['c1', 'c2']); + expect(mockUpdateContact).toHaveBeenCalledWith( + 'g1', { members: { existing: true, 'urn:uuid:u1': true, c2: true } }, undefined, + ); + }); + + it('createGroup creates a kind=group card in the default book', async () => { + useContactsStore.setState({ + addressBooks: [{ id: 'ab-1', name: 'P', isDefault: true }], + contacts: [card('c1', { uid: 'u1' })], + }); + mockCreateContact.mockResolvedValue({ id: 'g1', kind: 'group', addressBookIds: { 'ab-1': true } }); + await useContactsStore.getState().createGroup('Sales', ['c1']); + expect(mockCreateContact).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'group', members: { u1: true }, name: expect.objectContaining({ full: 'Sales' }) }), + 'ab-1', + undefined, + ); + }); + + it('getGroupRecipients dedupes addresses case-insensitively', () => { + useContactsStore.setState({ + contacts: [ + card('g1', { kind: 'group', members: { c1: true, c2: true, c3: true } }), + card('c1', { name: { full: 'A' }, emails: { e: { address: 'a@x.com' } } }), + card('c2', { name: { full: 'A2' }, emails: { e: { address: 'A@X.COM' } } }), + card('c3', { name: { full: 'B ' }, emails: { e: { address: 'b@x.com' } } }), + ], + }); + expect(useContactsStore.getState().getGroupRecipients('g1')).toEqual([ + { name: 'A', email: 'a@x.com' }, + { name: 'B', email: 'b@x.com' }, + ]); + }); + }); + + describe('renameKeyword', () => { + it('rewrites the keyword on every affected card', async () => { + useContactsStore.setState({ + contacts: [card('c1', { keywords: { Old: true, Keep: true } }), card('c2', { keywords: { Other: true } })], + selectedCategory: { type: 'keyword', keyword: 'Old' }, + }); + mockUpdateContact.mockResolvedValue(undefined); + await useContactsStore.getState().renameKeyword('Old', 'New'); + expect(mockUpdateContact).toHaveBeenCalledTimes(1); + expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true, New: true } }, undefined); + expect(useContactsStore.getState().selectedCategory).toEqual({ type: 'keyword', keyword: 'New' }); + }); + }); + + describe('autocomplete', () => { + it('merges contacts, groups and recent recipients without duplicates', async () => { + useContactsStore.setState({ + contacts: [ + card('c1', { name: { full: 'Jane Doe' }, emails: { e: { address: 'jane@x.com' } } }), + card('g1', { kind: 'group', name: { full: 'Jane fans' }, members: { c1: true } }), + ], + }); + mockQueryRecent.mockResolvedValue([ + { name: 'Jane Doe', email: 'JANE@x.com' }, + { name: 'Janet', email: 'janet@x.com' }, + ]); + await useContactsStore.getState().loadRecentRecipients('sent-1'); + expect(mockQueryRecent).toHaveBeenCalledWith('sent-1', 300); + + const results = useContactsStore.getState().getAutocomplete('jan'); + expect(results).toEqual([ + { name: 'Jane Doe', email: 'jane@x.com' }, + { name: 'Jane fans', email: '', group: { id: 'g1', memberCount: 1 } }, + { name: 'Janet', email: 'janet@x.com' }, + ]); + }); + + it('loadRecentRecipients is a no-op once loaded', async () => { + mockQueryRecent.mockResolvedValue([]); + await useContactsStore.getState().loadRecentRecipients('sent-1'); + await useContactsStore.getState().loadRecentRecipients('sent-1'); + expect(mockQueryRecent).toHaveBeenCalledTimes(1); + }); + + it('findContactByEmail matches case-insensitively and accepts a mailbox', () => { + useContactsStore.setState({ + contacts: [card('c1', { emails: { e: { address: 'Jane@X.com' } } })], + }); + expect(useContactsStore.getState().findContactByEmail('jane@x.com')?.id).toBe('c1'); + expect(useContactsStore.getState().findContactByEmail('Jane ')?.id).toBe('c1'); + expect(useContactsStore.getState().findContactByEmail('nobody@x.com')).toBeUndefined(); + }); + }); + + describe('trusted senders', () => { + it('add-after-load waits for the in-flight passive load', async () => { + let resolveBooks: (v: unknown) => void = () => {}; + mockGetAddressBooks.mockReturnValue(new Promise((r) => { resolveBooks = r; })); + mockGetContactsInBook.mockResolvedValue([]); + mockCreateContact.mockResolvedValue({ id: 't1' }); + + const passive = useContactsStore.getState().loadTrustedSendersBook(); + const add = useContactsStore.getState().addToTrustedSendersBook('Jane '); + resolveBooks([{ id: 'ab-ts-b', name: 'Trusted Senders' }, { id: 'ab-ts-a', name: 'Trusted Senders' }]); + await Promise.all([passive, add]); + + expect(mockGetAddressBooks).toHaveBeenCalledTimes(1); + // Duplicate books: the lowest id wins on every client (#730). + expect(useContactsStore.getState().trustedSendersBookId).toBe('ab-ts-a'); + expect(mockCreateContact).toHaveBeenCalledWith( + { name: { full: 'Jane' }, emails: { email: { address: 'jane@x.com' } } }, + 'ab-ts-a', + ); + expect(useContactsStore.getState().trustedSenderEmails).toEqual(['jane@x.com']); + }); + + it('creates the book on first add when the passive load found none', async () => { + mockGetAddressBooks.mockResolvedValue([]); + mockCreateAddressBook.mockResolvedValue({ id: 'ab-new', name: 'Trusted Senders' }); + mockGetContactsInBook.mockResolvedValue([]); + mockCreateContact.mockResolvedValue({ id: 't1' }); + + await useContactsStore.getState().loadTrustedSendersBook(); + expect(useContactsStore.getState().trustedSendersBookId).toBeNull(); + expect(mockCreateAddressBook).not.toHaveBeenCalled(); + + await useContactsStore.getState().addToTrustedSendersBook('x@y.com'); + expect(mockCreateAddressBook).toHaveBeenCalledWith('Trusted Senders'); + expect(useContactsStore.getState().trustedSendersBookId).toBe('ab-new'); + }); + + it('removeFromTrustedSendersBook deletes the matching card', async () => { + useContactsStore.setState({ trustedSendersBookId: 'ab-ts', trustedSenderEmails: ['x@y.com', 'z@y.com'] }); + mockGetContactsInBook.mockResolvedValue([ + card('t1', { emails: { e: { address: 'X@y.com' } } }), + card('t2', { emails: { e: { address: 'z@y.com' } } }), + ]); + mockDeleteContacts.mockResolvedValue(['t1']); + await useContactsStore.getState().removeFromTrustedSendersBook('Someone '); + expect(mockDeleteContacts).toHaveBeenCalledWith(['t1']); + expect(useContactsStore.getState().trustedSenderEmails).toEqual(['z@y.com']); }); }); }); + +describe('helpers', () => { + it('cleanGroupMembers strips id, uid and urn:uuid variants', () => { + const contacts = [ + card('c1', { uid: 'urn:uuid:u1' }), + card('g1', { kind: 'group', members: { 'urn:uuid:u1': true, u1: true, c1: true, other: true } }), + ]; + const cleaned = cleanGroupMembers(contacts, new Set(['c1'])); + expect(cleaned[1].members).toEqual({ other: true }); + }); + + it('normalizeSuggestions collapses mailbox-shaped names (#672)', () => { + expect(normalizeSuggestions([ + { name: 'Jane ', email: 'j@x.com' }, + { name: '', email: 'J@x.com' }, + { name: '', email: 'Ann ' }, + { name: 'Grp', email: '', group: { id: 'g', memberCount: 2 } }, + ])).toEqual([ + { name: 'Jane', email: 'j@x.com' }, + { name: 'Ann', email: 'a@x.com' }, + { name: 'Grp', email: '', group: { id: 'g', memberCount: 2 } }, + ]); + }); + + it('uncategorized means "no keywords"', () => { + const state = { + ...useContactsStore.getState(), + contacts: [card('c1', { keywords: { A: true } }), card('c2'), card('g', { kind: 'group' })], + selectedCategory: { type: 'uncategorized' as const }, + }; + expect(selectVisibleContacts(state, '').map((c) => c.id)).toEqual(['c2']); + }); + + it('selectGroupMembers resolves shared cards by originalId', () => { + const contacts = [ + card('g1', { kind: 'group', members: { c9: true } }), + card('acc-team:c9', { originalId: 'c9', isShared: true }), + ]; + expect(selectGroupMembers({ contacts }, 'g1').map((c) => c.id)).toEqual(['acc-team:c9']); + }); +}); diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index cfd89833..1c19590f 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -1,26 +1,36 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { ContactCard, AddressBook, StateChange } from '../api/types'; +import type { ContactCard, AddressBook, StateChange, EmailAddress } from '../api/types'; import { - getAddressBooks as fetchAddressBooks, + getAddressBooks as fetchPrimaryAddressBooks, + getAllAddressBooks as fetchAllAddressBooks, queryContacts, - getContacts as fetchContacts, + getContacts as fetchContactsByIds, + getAllContacts as fetchAllContacts, createContact as apiCreateContact, updateContact as apiUpdateContact, deleteContacts as apiDeleteContacts, createAddressBook as apiCreateAddressBook, updateAddressBook as apiUpdateAddressBook, deleteAddressBook as apiDeleteAddressBook, + setDefaultAddressBook as apiSetDefaultAddressBook, getContactsInBook, + getContactsAccountId, + getContactCapableAccountIds, + namespaceId, + stripClientFields, } from '../api/contacts'; +import { queryRecentRecipients, searchSentRecipients, type RecentRecipient } from '../api/recent-recipients'; import { jmapClient } from '../api/jmap-client'; import { getContactDisplayName, getContactKeywords, + getContactPrimaryEmail, isGroup, matchesContactSearch, } from '../lib/contact-utils'; +import { sanitizeDisplayName, splitMailbox } from '../lib/rfc5322-mailbox'; const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1'; @@ -35,6 +45,13 @@ export type ContactCategory = | { type: 'keyword'; keyword: string } | { type: 'uncategorized' }; +/** One compose-autocomplete suggestion: a person, or a contact group. */ +export interface RecipientSuggestion { + name: string; + email: string; + group?: { id: string; memberCount: number }; +} + export interface ContactsState { addressBooks: AddressBook[]; contacts: ContactCard[]; @@ -50,6 +67,12 @@ export interface ContactsState { trustedSendersLoaded: boolean; trustedSendersLoading: boolean; + // People the user has written to (scanned from the Sent folder) for the + // composer autocomplete. + recentRecipients: RecentRecipient[]; + recentRecipientsLoaded: boolean; + sentMailboxId: string | null; + hydrate: () => Promise; fetchAddressBooks: () => Promise; fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise; @@ -60,17 +83,36 @@ export interface ContactsState { updateContact: (id: string, changes: Partial) => Promise; deleteContact: (id: string) => Promise; bulkDelete: (ids: string[]) => Promise; - importContacts: (contacts: Partial[], addressBookId: string) => Promise; + importContacts: ( + contacts: Partial[], + addressBookId: string, + ) => Promise<{ imported: number; failed: number }>; addContactKeyword: (id: string, keyword: string) => Promise; removeContactKeyword: (id: string, keyword: string) => Promise; addKeywordToContacts: (ids: string[], keyword: string) => Promise; + renameKeyword: (oldKeyword: string, newKeyword: string) => Promise; moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise; + // Groups + addContactsToGroup: (groupId: string, contactIds: string[]) => Promise; + createGroup: (name: string, memberIds: string[], addressBookId?: string) => Promise; + /** Member addresses of a group, one entry per address (case-insensitive). */ + getGroupRecipients: (groupId: string) => EmailAddress[]; + // Address book management createAddressBook: (name: string) => Promise; renameAddressBook: (id: string, name: string) => Promise; deleteAddressBook: (id: string) => Promise; + setDefaultAddressBook: (id: string) => Promise; + /** The book new contacts / imports should land in when none is chosen. */ + getDefaultAddressBookId: () => string | null; + + // Composer autocomplete + loadRecentRecipients: (sentMailboxId?: string | null) => Promise; + searchRecipients: (query: string) => Promise; + getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[]; + findContactByEmail: (email: string) => ContactCard | undefined; // Trusted senders address book. Passive loads (createIfMissing=false) only // read an existing book; the book is created lazily on the first add. @@ -89,269 +131,690 @@ function persistCategory(category: ContactCategory): void { ); } +/** Every e-mail address held by the given contacts, lowercased and de-duplicated. */ +export function collectContactEmails(contacts: ContactCard[]): string[] { + const seen = new Set(); + for (const contact of contacts) { + if (!contact.emails) continue; + for (const entry of Object.values(contact.emails)) { + const address = entry.address?.toLowerCase().trim(); + if (address) seen.add(address); + } + } + return Array.from(seen); +} + +/** Parse "Name " (or a bare address) into its parts. */ +function parseTrustedSenderInput(input: string): { name?: string; email: string } { + const trimmed = input.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const name = angleMatch ? angleMatch[1].trim() : undefined; + const email = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); + return { name: name || undefined, email }; +} + +/** + * Strip a deleted card's id / uid / `urn:uuid:` variants from every group's + * member map so drawer counts and group views stop counting it until the next + * full refresh (webmail cleanGroupMembers). + */ +export function cleanGroupMembers(contacts: ContactCard[], removedIds: Set): ContactCard[] { + const removedKeys = new Set(); + for (const c of contacts) { + if (!removedIds.has(c.id)) continue; + removedKeys.add(c.id); + if (c.uid) { + removedKeys.add(c.uid); + removedKeys.add(c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid); + } + if (c.originalId) removedKeys.add(c.originalId); + } + if (removedKeys.size === 0) return contacts; + return contacts.map((c) => { + if (c.kind !== 'group' || !c.members) return c; + let changed = false; + const newMembers: Record = {}; + for (const [key, val] of Object.entries(c.members)) { + const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key; + if (removedKeys.has(key) || removedKeys.has(bareKey)) { + changed = true; + } else { + newMembers[key] = val; + } + } + return changed ? { ...c, members: newMembers } : c; + }); +} + +/** + * Reduces every suggestion to a clean display name plus a bare address and + * collapses the ones that resolve to the same address (webmail #672). Groups + * carry no address and pass through untouched. + */ +export function normalizeSuggestions(results: RecipientSuggestion[]): RecipientSuggestion[] { + const out: RecipientSuggestion[] = []; + const indexByEmail = new Map(); + for (const r of results) { + if (r.group) { + out.push(r); + continue; + } + const mailbox = splitMailbox(r.email); + if (!mailbox.email) continue; + const name = sanitizeDisplayName(r.name) || mailbox.name || ''; + const suggestion = { name: name === mailbox.email ? '' : name, email: mailbox.email }; + const key = mailbox.email.toLowerCase(); + const seenAt = indexByEmail.get(key); + if (seenAt === undefined) { + indexByEmail.set(key, out.length); + out.push(suggestion); + } else if (!out[seenAt].name && suggestion.name) { + out[seenAt].name = suggestion.name; + } + } + return out; +} + +// In-flight loads shared by every caller so a cold start cannot fire two +// AddressBook/get or ContactCard/query requests at once (Stalwart mints its +// default collections lazily on first touch, #907) and a "trust sender" tap +// never races the passive trusted-senders load. +let addressBooksPromise: Promise | null = null; +let contactsPromise: Promise | null = null; +let trustedSendersPromise: Promise | null = null; + export const useContactsStore = create()( persist( - (set, get) => ({ - addressBooks: [], - contacts: [], - selectedCategory: { type: 'all' }, - loading: false, - error: null, - hydrated: false, + (set, get) => { + /** Server-side id + owning account for a card in state. */ + const contactTarget = (id: string): { originalId: string; accountId?: string } => { + const contact = get().contacts.find((c) => c.id === id); + return { + originalId: contact?.originalId || id, + accountId: contact?.isShared ? contact.accountId : undefined, + }; + }; - trustedSendersBookId: null, - trustedSenderEmails: [], - trustedSendersLoaded: false, - trustedSendersLoading: false, + /** Server-side id + owning account for an address book in state. */ + const bookTarget = (bookId: string): { originalId: string; accountId?: string; book?: AddressBook } => { + const book = get().addressBooks.find((b) => b.id === bookId); + return { + originalId: book?.originalId || bookId, + accountId: book?.isShared ? book.accountId : undefined, + book, + }; + }; - hydrate: async () => { - if (get().hydrated) return; - try { - const raw = await AsyncStorage.getItem(SELECTED_CATEGORY_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as ContactCategory; - if (parsed && typeof parsed === 'object' && 'type' in parsed) { - set({ selectedCategory: parsed }); + /** Tag a freshly created card with the account metadata its book carries. */ + const tagCreated = (created: ContactCard, book: AddressBook | undefined): ContactCard => { + if (!book?.isShared || !book.accountId) return created; + const accountId = book.accountId; + return { + ...created, + id: namespaceId(accountId, created.id), + originalId: created.id, + addressBookIds: Object.fromEntries( + Object.entries(created.addressBookIds || {}).map(([k, v]) => [namespaceId(accountId, k), v]), + ), + accountId, + accountName: book.accountName, + isShared: true, + }; + }; + + /** De-namespace `addressBookIds` of a shared card before sending a patch. */ + const cleanPatch = (contact: ContactCard | undefined, changes: Partial): Partial => { + if (!contact?.isShared || !contact.accountId || !changes.addressBookIds) return changes; + const prefix = `${contact.accountId}:`; + return { + ...changes, + addressBookIds: Object.fromEntries( + Object.entries(changes.addressBookIds).map(([k, v]) => [k.startsWith(prefix) ? k.slice(prefix.length) : k, v]), + ), + }; + }; + + const loadAddressBooks = async (): Promise => { + try { + const addressBooks = (await fetchAllAddressBooks()) ?? []; + set({ addressBooks }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to load address books' }); } - } - } catch (err) { - console.warn('[contacts-store] hydrate failed', err); - } - set({ hydrated: true }); - }, + }; - fetchAddressBooks: async () => { - // No-op until a JMAP session exists. ContactsScreen fires this from a - // mount-time useEffect, which on cold start runs before restoreSession - // has set up the client. - if (!jmapClient.isConnected) return; - try { - const addressBooks = (await fetchAddressBooks()) ?? []; - set({ addressBooks }); - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to load address books' }); - } - }, + return { + addressBooks: [], + contacts: [], + selectedCategory: { type: 'all' }, + loading: false, + error: null, + hydrated: false, - fetchContacts: async (filter) => { - if (!jmapClient.isConnected) return; - set({ loading: true, error: null }); - try { - const ids = (await queryContacts(filter)) ?? []; - const contacts = ids.length > 0 ? ((await fetchContacts(ids)) ?? []) : []; - set({ contacts, loading: false }); - } catch (err) { - set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' }); - } - }, - - refresh: async () => { - await get().fetchContacts(); - }, - - handleStateChange: async (change) => { - if (!jmapClient.isConnected) return; - const accountId = jmapClient.accountId; - const accountChanges = change.changed?.[accountId]; - if (!accountChanges) return; - - const addressBookChanged = 'AddressBook' in accountChanges; - const contactChanged = 'ContactCard' in accountChanges || 'Contact' in accountChanges; - if (!addressBookChanged && !contactChanged) return; - - if (addressBookChanged) { - await get().fetchAddressBooks(); - } - if (contactChanged) { - await get().refresh(); - } - }, - - createContact: async (contact, addressBookId) => { - const created = await apiCreateContact(contact, addressBookId); - set({ contacts: [...get().contacts, created] }); - return created; - }, - - updateContact: async (id, changes) => { - await apiUpdateContact(id, changes); - set({ - contacts: get().contacts.map((c) => - c.id === id ? { ...c, ...changes } : c, - ), - }); - }, - - deleteContact: async (id) => { - await apiDeleteContacts([id]); - set({ contacts: get().contacts.filter((c) => c.id !== id) }); - }, - - bulkDelete: async (ids) => { - if (ids.length === 0) return; - await apiDeleteContacts(ids); - const idSet = new Set(ids); - set({ contacts: get().contacts.filter((c) => !idSet.has(c.id)) }); - }, - - importContacts: async (incoming, addressBookId) => { - let imported = 0; - for (const contact of incoming) { - try { - // Strip the temporary client-side id; the server assigns a real one. - const { id: _id, addressBookIds: _abIds, ...data } = contact; - const created = await apiCreateContact(data, addressBookId); - set({ contacts: [...get().contacts, created] }); - imported++; - } catch (err) { - console.warn('[contacts-store] import failed for one contact', err); - } - } - return imported; - }, - - addContactKeyword: async (id, keyword) => { - const kw = keyword.trim(); - if (!kw) return; - const contact = get().contacts.find((c) => c.id === id); - if (!contact) return; - const keywords = { ...(contact.keywords || {}), [kw]: true }; - await get().updateContact(id, { keywords }); - }, - - removeContactKeyword: async (id, keyword) => { - const contact = get().contacts.find((c) => c.id === id); - if (!contact?.keywords) return; - const { [keyword]: _removed, ...rest } = contact.keywords; - await get().updateContact(id, { keywords: rest }); - }, - - addKeywordToContacts: async (ids, keyword) => { - const kw = keyword.trim(); - if (!kw) return; - for (const id of ids) { - await get().addContactKeyword(id, kw); - } - }, - - moveContactsToAddressBook: async (ids, addressBookId) => { - for (const id of ids) { - await get().updateContact(id, { addressBookIds: { [addressBookId]: true } }); - } - }, - - createAddressBook: async (name) => { - const trimmed = name.trim(); - if (!trimmed) throw new Error('Address book name is required'); - const book = await apiCreateAddressBook(trimmed); - set({ addressBooks: [...get().addressBooks, book] }); - return book; - }, - - renameAddressBook: async (id, name) => { - const trimmed = name.trim(); - if (!trimmed) return; - await apiUpdateAddressBook(id, { name: trimmed }); - set({ - addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)), - }); - }, - - deleteAddressBook: async (id) => { - await apiDeleteAddressBook(id); - set({ - addressBooks: get().addressBooks.filter((b) => b.id !== id), - // Drop the deleted book id from any cached contact so the UI doesn't - // keep filtering against a book that no longer exists. - contacts: get().contacts.map((c) => { - if (!c.addressBookIds?.[id]) return c; - const { [id]: _gone, ...rest } = c.addressBookIds; - return { ...c, addressBookIds: rest }; - }), - }); - const category = get().selectedCategory; - if (category.type === 'addressBook' && category.addressBookId === id) { - get().setSelectedCategory({ type: 'all' }); - } - }, - - loadTrustedSendersBook: async (createIfMissing = false) => { - if (!jmapClient.isConnected || get().trustedSendersLoading) return; - set({ trustedSendersLoading: true }); - try { - const books = (await fetchAddressBooks()) ?? []; - let book = books.find((b) => b.name === TRUSTED_SENDERS_BOOK_NAME); - if (!book) { - if (!createIfMissing) { - set({ trustedSendersLoaded: true, trustedSendersLoading: false }); - return; - } - book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME); - } - const bookId = book.id; - const cards = await getContactsInBook(bookId); - const emails = cards.flatMap((c) => - c.emails ? Object.values(c.emails).map((e) => e.address.toLowerCase().trim()) : [], - ).filter(Boolean); - set({ - trustedSendersBookId: bookId, - trustedSenderEmails: emails, - trustedSendersLoaded: true, + trustedSendersBookId: null, + trustedSenderEmails: [], + trustedSendersLoaded: false, trustedSendersLoading: false, - }); - } catch (err) { - console.warn('[contacts-store] load trusted senders failed', err); - set({ trustedSendersLoaded: true, trustedSendersLoading: false }); - } - }, - addToTrustedSendersBook: async (email) => { - const normalized = email.toLowerCase().trim(); - if (!normalized || get().trustedSenderEmails.includes(normalized)) return; + recentRecipients: [], + recentRecipientsLoaded: false, + sentMailboxId: null, - let bookId = get().trustedSendersBookId; - if (!bookId) { - await get().loadTrustedSendersBook(true); - bookId = get().trustedSendersBookId; - } - if (!bookId) throw new Error('Could not find or create the trusted senders address book'); + hydrate: async () => { + if (get().hydrated) return; + try { + const raw = await AsyncStorage.getItem(SELECTED_CATEGORY_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as ContactCategory; + if (parsed && typeof parsed === 'object' && 'type' in parsed) { + set({ selectedCategory: parsed }); + } + } + } catch (err) { + console.warn('[contacts-store] hydrate failed', err); + } + set({ hydrated: true }); + }, - await apiCreateContact({ emails: { email: { address: normalized } } }, bookId); - set({ trustedSenderEmails: [...get().trustedSenderEmails, normalized] }); - }, + fetchAddressBooks: async () => { + // No-op until a JMAP session exists. ContactsScreen fires this from a + // mount-time useEffect, which on cold start runs before restoreSession + // has set up the client. + if (!jmapClient.isConnected) return; + if (addressBooksPromise) return addressBooksPromise; + addressBooksPromise = loadAddressBooks().finally(() => { + addressBooksPromise = null; + }); + return addressBooksPromise; + }, - removeFromTrustedSendersBook: async (email) => { - const normalized = email.toLowerCase().trim(); - const bookId = get().trustedSendersBookId; - if (!bookId) return; - const cards = await getContactsInBook(bookId); - const match = cards.find((c) => - c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase().trim() === normalized), - ); - if (match) await apiDeleteContacts([match.id]); - set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== normalized) }); - }, + fetchContacts: async (filter) => { + if (!jmapClient.isConnected) return; + if (contactsPromise) return contactsPromise; + contactsPromise = (async () => { + // Books first: AddressBook/get is the call that makes Stalwart mint + // the default book, so ContactCard/query must never run alongside it. + if (addressBooksPromise) await addressBooksPromise; + set({ loading: true, error: null }); + try { + let contacts: ContactCard[]; + if (filter && Object.keys(filter).length > 0) { + const ids = (await queryContacts(filter)) ?? []; + contacts = ids.length > 0 ? ((await fetchContactsByIds(ids)) ?? []) : []; + } else { + contacts = (await fetchAllContacts()) ?? []; + } + set({ contacts, loading: false }); + } catch (err) { + set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' }); + } + })().finally(() => { + contactsPromise = null; + }); + return contactsPromise; + }, - isTrustedAddressBookSender: (email) => - get().trustedSenderEmails.includes(email.toLowerCase().trim()), + refresh: async () => { + await get().fetchContacts(); + }, - setSelectedCategory: (category) => { - set({ selectedCategory: category }); - persistCategory(category); - }, + handleStateChange: async (change) => { + if (!jmapClient.isConnected) return; + let addressBookChanged = false; + let contactChanged = false; + for (const accountId of getContactCapableAccountIds()) { + const accountChanges = change.changed?.[accountId]; + if (!accountChanges) continue; + if ('AddressBook' in accountChanges) addressBookChanged = true; + if ('ContactCard' in accountChanges || 'Contact' in accountChanges) contactChanged = true; + } + if (!addressBookChanged && !contactChanged) return; - reset: () => set({ - addressBooks: [], - contacts: [], - selectedCategory: { type: 'all' }, - loading: false, - error: null, - trustedSendersBookId: null, - trustedSenderEmails: [], - trustedSendersLoaded: false, - trustedSendersLoading: false, - }), - }), + if (addressBookChanged) { + await get().fetchAddressBooks(); + } + if (contactChanged) { + await get().refresh(); + } + }, + + createContact: async (contact, addressBookId) => { + const { originalId, accountId, book } = bookTarget(addressBookId); + const created = tagCreated(await apiCreateContact(contact, originalId, accountId), book); + set({ contacts: [...get().contacts, created] }); + return created; + }, + + updateContact: async (id, changes) => { + const contact = get().contacts.find((c) => c.id === id); + const { originalId, accountId } = contactTarget(id); + await apiUpdateContact(originalId, cleanPatch(contact, changes), accountId); + set({ + contacts: get().contacts.map((c) => { + if (c.id !== id) return c; + const merged: ContactCard = { ...c, ...changes }; + // A `null` patch value clears the property (JMAP semantics); + // mirror that locally so the UI doesn't resurrect the old value. + for (const [key, value] of Object.entries(changes)) { + if (value === null) delete (merged as unknown as Record)[key]; + } + return merged; + }), + }); + }, + + deleteContact: async (id) => { + const { originalId, accountId } = contactTarget(id); + await apiDeleteContacts([originalId], accountId); + set({ contacts: cleanGroupMembers(get().contacts, new Set([id])).filter((c) => c.id !== id) }); + }, + + bulkDelete: async (ids) => { + if (ids.length === 0) return; + // Group by owning account; each account gets one ContactCard/set. + const byAccount = new Map>(); + for (const id of ids) { + const { originalId, accountId } = contactTarget(id); + const list = byAccount.get(accountId) ?? []; + list.push({ id, originalId }); + byAccount.set(accountId, list); + } + const removed = new Set(); + let failed = 0; + for (const [accountId, entries] of byAccount) { + try { + const destroyed = new Set( + await apiDeleteContacts(entries.map((e) => e.originalId), accountId), + ); + for (const e of entries) { + if (destroyed.has(e.originalId)) removed.add(e.id); + else failed++; + } + } catch (err) { + console.warn('[contacts-store] bulk delete failed', err); + failed += entries.length; + } + } + set({ + contacts: cleanGroupMembers(get().contacts, removed).filter((c) => !removed.has(c.id)), + ...(failed > 0 ? { error: `Failed to delete ${failed} contact${failed === 1 ? '' : 's'}` } : {}), + }); + }, + + importContacts: async (incoming, addressBookId) => { + let imported = 0; + let failed = 0; + const { originalId, accountId, book } = bookTarget(addressBookId); + for (const contact of incoming) { + try { + // Strip the temporary client-side id; the server assigns a real one. + const { id: _id, addressBookIds: _abIds, ...data } = contact; + const created = tagCreated(await apiCreateContact(data, originalId, accountId), book); + set({ contacts: [...get().contacts, created] }); + imported++; + } catch (err) { + failed++; + console.warn('[contacts-store] import failed for one contact', err); + } + } + return { imported, failed }; + }, + + addContactKeyword: async (id, keyword) => { + const kw = keyword.trim(); + if (!kw) return; + const contact = get().contacts.find((c) => c.id === id); + if (!contact) return; + const keywords = { ...(contact.keywords || {}), [kw]: true }; + await get().updateContact(id, { keywords }); + }, + + removeContactKeyword: async (id, keyword) => { + const contact = get().contacts.find((c) => c.id === id); + if (!contact?.keywords) return; + const { [keyword]: _removed, ...rest } = contact.keywords; + await get().updateContact(id, { keywords: rest }); + }, + + addKeywordToContacts: async (ids, keyword) => { + const kw = keyword.trim(); + if (!kw) return; + for (const id of ids) { + await get().addContactKeyword(id, kw); + } + }, + + renameKeyword: async (oldKeyword, newKeyword) => { + const oldKw = oldKeyword.trim(); + const newKw = newKeyword.trim(); + if (!oldKw || !newKw || oldKw === newKw) return; + const affected = get().contacts.filter((c) => c.keywords?.[oldKw]); + for (const contact of affected) { + const { [oldKw]: _old, ...rest } = contact.keywords || {}; + await get().updateContact(contact.id, { keywords: { ...rest, [newKw]: true } }); + } + const category = get().selectedCategory; + if (category.type === 'keyword' && category.keyword === oldKw) { + get().setSelectedCategory({ type: 'keyword', keyword: newKw }); + } + }, + + moveContactsToAddressBook: async (ids, addressBookId) => { + const target = bookTarget(addressBookId); + const primaryAccountId = getContactsAccountId(); + const targetAccount = target.accountId || primaryAccountId; + for (const id of ids) { + const contact = get().contacts.find((c) => c.id === id); + if (!contact) continue; + const source = contactTarget(id); + const sourceAccount = source.accountId || primaryAccountId; + if (sourceAccount === targetAccount) { + await apiUpdateContact(source.originalId, { addressBookIds: { [target.originalId]: true } }, source.accountId); + set({ + contacts: get().contacts.map((c) => + c.id === id ? { ...c, addressBookIds: { [addressBookId]: true } } : c, + ), + }); + } else { + // Cross-account: create in the target account, then delete the source. + const { id: _id, addressBookIds: _ab, uid: _uid, created: _c, updated: _u, ...data } = stripClientFields(contact); + const created = tagCreated(await apiCreateContact(data, target.originalId, target.accountId), target.book); + await apiDeleteContacts([source.originalId], source.accountId); + set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) }); + } + } + }, + + addContactsToGroup: async (groupId, contactIds) => { + const group = get().contacts.find((c) => c.id === groupId); + if (!group) throw new Error('Group not found'); + const members = { ...(group.members || {}) }; + for (const id of contactIds) { + const contact = get().contacts.find((c) => c.id === id); + if (!contact || isGroup(contact)) continue; + // Members are referenced by UID (RFC 9553 ยง2.1.6); fall back to the + // id for legacy cards that never got one (#644). + members[contact.uid || contact.originalId || contact.id] = true; + } + await get().updateContact(groupId, { members }); + }, + + createGroup: async (name, memberIds, addressBookId) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error('Group name is required'); + const bookId = addressBookId || get().getDefaultAddressBookId(); + if (!bookId) throw new Error('No address book available'); + const members: Record = {}; + for (const id of memberIds) { + const contact = get().contacts.find((c) => c.id === id); + if (!contact || isGroup(contact)) continue; + members[contact.uid || contact.originalId || contact.id] = true; + } + return get().createContact( + { + kind: 'group', + name: { components: [{ kind: 'given', value: trimmed }], isOrdered: true, full: trimmed }, + members, + }, + bookId, + ); + }, + + getGroupRecipients: (groupId) => { + const members = selectGroupMembers(get(), groupId); + const seen = new Set(); + const out: EmailAddress[] = []; + for (const m of members) { + if (isGroup(m)) continue; + const email = getContactPrimaryEmail(m).trim(); + const key = email.toLowerCase(); + if (!email || seen.has(key)) continue; + seen.add(key); + const name = sanitizeDisplayName(getContactDisplayName(m)); + out.push({ name: name && name !== email ? name : '', email }); + } + return out; + }, + + createAddressBook: async (name) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error('Address book name is required'); + const book = await apiCreateAddressBook(trimmed); + set({ addressBooks: [...get().addressBooks, book] }); + return book; + }, + + renameAddressBook: async (id, name) => { + const trimmed = name.trim(); + if (!trimmed) return; + const { originalId, accountId } = bookTarget(id); + await apiUpdateAddressBook(originalId, { name: trimmed }, accountId); + set({ + addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)), + }); + }, + + deleteAddressBook: async (id) => { + const { originalId, accountId } = bookTarget(id); + await apiDeleteAddressBook(originalId, accountId); + // Stalwart destroys the cards filed in the book along with it, so + // drop them from the cache instead of leaving orphans under "All". + set({ + addressBooks: get().addressBooks.filter((b) => b.id !== id), + contacts: get().contacts.filter((c) => !c.addressBookIds?.[id]), + }); + const category = get().selectedCategory; + if (category.type === 'addressBook' && category.addressBookId === id) { + get().setSelectedCategory({ type: 'all' }); + } + }, + + setDefaultAddressBook: async (id) => { + const { originalId, accountId, book } = bookTarget(id); + await apiSetDefaultAddressBook(originalId, accountId); + set({ + addressBooks: get().addressBooks.map((b) => { + if (b.id === id) return { ...b, isDefault: true }; + // Only one default per account - clear the flag on siblings. + if (b.isDefault && (b.accountId ?? null) === (book?.accountId ?? null)) { + return { ...b, isDefault: false }; + } + return b; + }), + }); + }, + + getDefaultAddressBookId: () => { + const books = get().addressBooks; + const own = books.filter((b) => !b.isShared && b.myRights?.mayWrite !== false); + const candidates = own.length > 0 ? own : books.filter((b) => b.myRights?.mayWrite !== false); + return (candidates.find((b) => b.isDefault) || candidates[0])?.id ?? null; + }, + + loadRecentRecipients: async (sentMailboxId) => { + if (sentMailboxId) set({ sentMailboxId }); + const mailboxId = get().sentMailboxId; + if (get().recentRecipientsLoaded || !mailboxId || !jmapClient.isConnected) return; + try { + const recentRecipients = await queryRecentRecipients(mailboxId, 300); + set({ recentRecipients, recentRecipientsLoaded: true }); + } catch (err) { + console.warn('[contacts-store] load recent recipients failed', err); + set({ recentRecipientsLoaded: true }); + } + }, + + searchRecipients: async (query) => { + const mailboxId = get().sentMailboxId; + const q = query.trim(); + if (!mailboxId || !q || !jmapClient.isConnected) return []; + try { + return normalizeSuggestions(await searchSentRecipients(q, mailboxId)); + } catch (err) { + console.warn('[contacts-store] recipient search failed', err); + return []; + } + }, + + getAutocomplete: (query, limit = 10) => { + const q = query.trim().toLowerCase(); + if (!q) return []; + const { contacts, recentRecipients } = get(); + const results: RecipientSuggestion[] = []; + + for (const contact of contacts) { + if (results.length >= limit) break; + if (isGroup(contact)) { + // Suggest the group itself as a single entry (Outlook-style); + // picking it expands to the member addresses. + const groupName = getContactDisplayName(contact); + if (groupName && groupName.toLowerCase().includes(q)) { + const memberCount = get().getGroupRecipients(contact.id).length; + if (memberCount > 0) { + results.push({ name: groupName, email: '', group: { id: contact.id, memberCount } }); + } + } + continue; + } + const name = getContactDisplayName(contact); + const emails = contact.emails ? Object.values(contact.emails) : []; + for (const entry of emails) { + if (!entry.address) continue; + if (name.toLowerCase().includes(q) || entry.address.toLowerCase().includes(q)) { + results.push({ name, email: entry.address }); + } + } + } + + // Fold in recent recipients from the Sent folder. Contacts take + // precedence, so skip any address already suggested. + if (recentRecipients.length > 0 && results.length < limit) { + const seen = new Set(results.map((r) => r.email.toLowerCase())); + for (const rec of recentRecipients) { + if (results.length >= limit) break; + const addr = rec.email.toLowerCase(); + if (seen.has(addr)) continue; + if (addr.includes(q) || rec.name.toLowerCase().includes(q)) { + results.push({ name: rec.name, email: rec.email }); + seen.add(addr); + } + } + } + + return normalizeSuggestions(results); + }, + + findContactByEmail: (email) => { + const needle = splitMailbox(email).email.toLowerCase().trim(); + if (!needle) return undefined; + return get().contacts.find((c) => + !isGroup(c) && !!c.emails + && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === needle)); + }, + + loadTrustedSendersBook: async (createIfMissing = false) => { + if (!jmapClient.isConnected) return; + // Share the in-flight load so parallel callers (mail view, settings, + // addToTrustedSendersBook) never create the book twice - and so a + // caller that needs the book id can wait for it instead of failing. + if (trustedSendersPromise) await trustedSendersPromise; + if (get().trustedSendersBookId) return; + if (get().trustedSendersLoaded && !createIfMissing) return; + + set({ trustedSendersLoading: true }); + trustedSendersPromise = (async () => { + try { + // Must throw rather than yield [] on failure: treating a failed + // fetch as "no book yet" minted a duplicate on every hiccup (#730). + const books = (await fetchPrimaryAddressBooks()) ?? []; + // Sort so every client picks the same book when duplicates exist. + const matches = books + .filter((b) => b.name === TRUSTED_SENDERS_BOOK_NAME) + .sort((a, b) => a.id.localeCompare(b.id)); + let book = matches[0]; + if (!book) { + if (!createIfMissing) { + set({ trustedSendersLoaded: true, trustedSendersLoading: false }); + return; + } + book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME); + } + const bookId = book.id; + const cards = await getContactsInBook(bookId); + set({ + trustedSendersBookId: bookId, + trustedSenderEmails: collectContactEmails(cards), + trustedSendersLoaded: true, + trustedSendersLoading: false, + }); + } catch (err) { + console.warn('[contacts-store] load trusted senders failed', err); + set({ trustedSendersLoaded: true, trustedSendersLoading: false }); + } + })().finally(() => { + trustedSendersPromise = null; + }); + return trustedSendersPromise; + }, + + addToTrustedSendersBook: async (input) => { + const { name, email } = parseTrustedSenderInput(input); + if (!email || get().trustedSenderEmails.includes(email)) return; + + let bookId = get().trustedSendersBookId; + if (!bookId) { + await get().loadTrustedSendersBook(true); + bookId = get().trustedSendersBookId; + } + if (!bookId) throw new Error('Could not find or create the trusted senders address book'); + // The load may have found the address already filed in the book. + if (get().trustedSenderEmails.includes(email)) return; + + await apiCreateContact( + { + ...(name ? { name: { full: name } } : {}), + emails: { email: { address: email } }, + }, + bookId, + ); + set({ trustedSenderEmails: [...get().trustedSenderEmails, email] }); + }, + + removeFromTrustedSendersBook: async (input) => { + const { email } = parseTrustedSenderInput(input); + const bookId = get().trustedSendersBookId; + if (!bookId || !email) return; + const cards = await getContactsInBook(bookId); + const matches = cards.filter((c) => + c.emails && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === email)); + if (matches.length > 0) await apiDeleteContacts(matches.map((m) => m.id)); + set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== email) }); + }, + + isTrustedAddressBookSender: (email) => + get().trustedSenderEmails.includes(email.toLowerCase().trim()), + + setSelectedCategory: (category) => { + set({ selectedCategory: category }); + persistCategory(category); + }, + + reset: () => set({ + addressBooks: [], + contacts: [], + selectedCategory: { type: 'all' }, + loading: false, + error: null, + trustedSendersBookId: null, + trustedSenderEmails: [], + trustedSendersLoaded: false, + trustedSendersLoading: false, + recentRecipients: [], + recentRecipientsLoaded: false, + sentMailboxId: null, + }), + }; + }, { // Persist address books and contact cards so the list renders instantly // on re-open. A refresh runs in the background once the JMAP session is @@ -360,7 +823,10 @@ export const useContactsStore = create()( storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ addressBooks: state.addressBooks, - contacts: state.contacts, + // Photos are inline base64 blobs; a few hundred of them blow past + // Android AsyncStorage's write cap and the cache silently stops + // updating. They are re-hydrated from the server on refresh. + contacts: state.contacts.map(({ media: _media, ...rest }) => rest), }), }, ), @@ -388,6 +854,11 @@ export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: st .sort((a, b) => a.keyword.localeCompare(b.keyword)); } +/** Contacts without any category (keyword) - the webmail's "No category". */ +export function selectUncategorized(contacts: ContactCard[]): ContactCard[] { + return contacts.filter((c) => !isGroup(c) && getContactKeywords(c).length === 0); +} + export function selectAddressBooksWithCount( addressBooks: AddressBook[], contacts: ContactCard[], @@ -398,7 +869,7 @@ export function selectAddressBooksWithCount( })); } -export function selectGroupMembers(state: ContactsState, groupId: string): ContactCard[] { +export function selectGroupMembers(state: Pick, groupId: string): ContactCard[] { const group = state.contacts.find((c) => c.id === groupId); if (!group?.members) return []; const memberKeys = Object.keys(group.members).filter((k) => group.members![k]); @@ -406,6 +877,7 @@ export function selectGroupMembers(state: ContactsState, groupId: string): Conta const keySet = new Set([...memberKeys, ...normalized]); return state.contacts.filter((c) => { if (keySet.has(c.id)) return true; + if (c.originalId && keySet.has(c.originalId)) return true; if (c.uid) { const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; if (keySet.has(c.uid) || keySet.has(bareUid)) return true; @@ -437,7 +909,7 @@ export function selectVisibleContacts( filtered = individuals.filter((c) => c.keywords?.[category.keyword]); break; case 'uncategorized': - filtered = individuals.filter((c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0); + filtered = selectUncategorized(individuals); break; default: filtered = individuals;