diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index dd9dbec7..cd469e06 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -31,6 +31,8 @@ const defaultState = { supportsSync: false, selectedContactIds: new Set(), activeTab: 'all' as const, + directoryPrincipals: [], + directoryLoaded: false, }; describe('contact-store', () => { @@ -273,6 +275,28 @@ describe('contact-store', () => { const results = useContactStore.getState().getAutocomplete('Multi'); expect(results).toHaveLength(2); }); + + it('should augment results with directory principals', () => { + useContactStore.setState({ + contacts: [], + directoryPrincipals: [{ name: 'Dana Director', email: 'dana@example.com' }], + }); + const results = useContactStore.getState().getAutocomplete('dana'); + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ name: 'Dana Director', email: 'dana@example.com' }); + }); + + it('should not duplicate a directory principal already matched as a contact', () => { + useContactStore.setState({ + contacts: [ + makeContact({ id: 'c1', name: { components: [{ kind: 'given', value: 'Jane' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }), + ], + directoryPrincipals: [{ name: 'Jane From Directory', email: 'JANE@example.com' }], + }); + const results = useContactStore.getState().getAutocomplete('jane'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Jane'); + }); }); describe('getGroups', () => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 5b7fcd9c..4aa67f60 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -239,6 +239,13 @@ function initializeFeatureStores(client: IJMAPClient): void { useContactStore.getState().setSupportsSync(false); } + // Directory (RFC 9670 principals) is independent of contacts support and only + // works when the server allows directory queries; populates recipient + // autocomplete with other users on the server. + if (client.supportsPrincipals()) { + useContactStore.getState().fetchDirectory(client).catch((err) => debug.error('Failed to fetch directory:', err)); + } + const vacationStore = useVacationStore.getState(); if (client.supportsVacationResponse()) { vacationStore.setSupported(true); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index a6159235..27ee111a 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -159,7 +159,13 @@ interface ContactStore { lastSelectedContactId: string | null; activeTab: 'all' | 'groups'; + // Directory (RFC 9670 principals) — other users/groups on the server, used to + // augment recipient autocomplete. Runtime only, not persisted. + directoryPrincipals: Array<{ name: string; email: string; description?: string }>; + directoryLoaded: boolean; + fetchContacts: (client: IJMAPClient) => Promise; + fetchDirectory: (client: IJMAPClient) => Promise; fetchAddressBooks: (client: IJMAPClient) => Promise; fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise; fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise; @@ -258,6 +264,8 @@ export const useContactStore = create()( selectedContactIds: new Set(), lastSelectedContactId: null, activeTab: 'all' as const, + directoryPrincipals: [], + directoryLoaded: false, fetchContacts: async (client) => { set({ isLoading: true, error: null }); @@ -270,6 +278,28 @@ export const useContactStore = create()( } }, + fetchDirectory: async (client) => { + if (!client.supportsPrincipals()) return; + try { + const principals = await client.getPrincipals(); + const entries: Array<{ name: string; email: string; description?: string }> = []; + for (const p of principals) { + // Stalwart reports a principal's account name in `email`; only those + // with an address are usable as recipients. + const email = p.email?.trim(); + if (!email) continue; + entries.push({ + name: p.name || email, + email, + description: p.description ?? undefined, + }); + } + set({ directoryPrincipals: entries, directoryLoaded: true }); + } catch (error) { + debug.error('Failed to fetch directory principals:', error); + } + }, + fetchAddressBooks: async (client) => { try { const addressBooks = await client.getAllAddressBooks(); @@ -474,6 +504,8 @@ export const useContactStore = create()( error: null, selectedContactIds: new Set(), activeTab: 'all', + directoryPrincipals: [], + directoryLoaded: false, }), getAutocomplete: (query) => { @@ -516,6 +548,22 @@ export const useContactStore = create()( if (results.length >= 10) break; } + // Augment with directory principals (other users on the server, RFC 9670). + // Contacts take precedence, so skip any address already suggested. + const { directoryPrincipals } = get(); + if (directoryPrincipals.length > 0) { + const seen = new Set(results.map(r => r.email.toLowerCase())); + for (const p of directoryPrincipals) { + if (results.length >= 10) break; + const addr = p.email.toLowerCase(); + if (seen.has(addr)) continue; + if (p.name.toLowerCase().includes(lower) || addr.includes(lower)) { + results.push({ name: p.name, email: p.email }); + seen.add(addr); + } + } + } + return results; },