import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; import { getClientByLocalAccountId } from './client-registry'; import { enqueueOperation, isNetworkError } from '@/lib/offline-write-queue'; import { useAccountStore } from '@/stores/account-store'; /** One connected JMAP account for contact multi-account aggregation. */ export interface ContactAccountClient { localAccountId: string; client: IJMAPClient; } /** * Prefix used to namespace contact/address-book IDs that belong to a * non-active JMAP account when the Pro shell aggregates across accounts. * The active account's IDs are left untouched so existing single-account * code paths keep working unchanged. */ const CROSS_ACCOUNT_ID_DELIMITER = '::'; function buildCrossAccountIdPrefix(localAccountId: string): string { return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`; } function prefixAddressBooksWithLocalAccount( books: AddressBook[], localAccountId: string, isActiveAccount: boolean, ): AddressBook[] { if (isActiveAccount) { return books.map((b) => ({ ...b, localAccountId })); } const prefix = buildCrossAccountIdPrefix(localAccountId); return books.map((b) => ({ ...b, id: `${prefix}${b.id}`, localAccountId, })); } function prefixContactsWithLocalAccount( contacts: ContactCard[], localAccountId: string, isActiveAccount: boolean, ): ContactCard[] { if (isActiveAccount) { return contacts.map((c) => ({ ...c, localAccountId })); } const prefix = buildCrossAccountIdPrefix(localAccountId); return contacts.map((c) => ({ ...c, id: `${prefix}${c.id}`, localAccountId, addressBookIds: c.addressBookIds ? Object.fromEntries( Object.entries(c.addressBookIds).map(([bookId, v]) => [`${prefix}${bookId}`, v]), ) : c.addressBookIds, })); } /** * Route mutations back through the client that owns the target entity * when in multi-account Pro mode. See [[useProMultiAccountContacts]]. * * Lookup goes through `client-registry` (not a direct auth-store import) * to avoid a top-level cycle: auth-store already imports this module to * bootstrap feature stores after login. */ function resolveAccountClient(active: T, localAccountId?: string): T { if (!localAccountId) return active; const lookup = getClientByLocalAccountId(localAccountId) as T | undefined; return lookup ?? active; } function stripLocalAccountPrefix(id: string, localAccountId?: string): string { if (!localAccountId) return id; const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`; return id.startsWith(prefix) ? id.slice(prefix.length) : id; } export function getContactDisplayName(contact: ContactCard): string { if (contact.name) { // Try given + surname from components first 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 full = [given, surname].filter(Boolean).join(' '); if (full) return full; } // Fall back to name.full (RFC 9553 - used by Stalwart and other JMAP servers) if (contact.name.full) return contact.name.full; } if (contact.nicknames) { const nick = Object.values(contact.nicknames)[0]; if (nick?.name) return nick.name; } if (contact.organizations) { const org = Object.values(contact.organizations)[0]; if (org?.name) return org.name; } if (contact.emails) { const email = Object.values(contact.emails)[0]; if (email?.address) return email.address; } return ''; } export function getContactPrimaryEmail(contact: ContactCard): string { if (!contact.emails) return ''; return Object.values(contact.emails)[0]?.address || ''; } // Some JMAP servers (notably Stalwart, see issue #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 browsers // won't render the bytes as an image. 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 normalizeContactPhotoUri(media.uri, media.mediaType); } } return undefined; } export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders'; interface ContactStore { contacts: ContactCard[]; addressBooks: AddressBook[]; selectedContactId: string | null; searchQuery: string; isLoading: boolean; error: string | null; supportsSync: boolean; // Trusted senders address book cache (runtime only, not persisted) trustedSenderEmails: string[]; trustedSendersBookId: string | null; trustedSendersLoaded: boolean; trustedSendersLoading: boolean; // Recent recipients (from the Sent folder) for compose autocomplete - runtime only recentRecipients: Array<{ name: string; email: string }>; recentRecipientsLoaded: boolean; sentMailboxId: string | null; selectedContactIds: Set; 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; createContact: (client: IJMAPClient, contact: Partial) => Promise; updateContact: (client: IJMAPClient, id: string, updates: Partial) => Promise; deleteContact: (client: IJMAPClient, id: string) => Promise; addLocalContact: (contact: ContactCard) => void; updateLocalContact: (id: string, updates: Partial) => void; deleteLocalContact: (id: string) => void; setSelectedContact: (id: string | null) => void; setSearchQuery: (query: string) => void; setSupportsSync: (supports: boolean) => void; setActiveTab: (tab: 'all' | 'groups') => void; clearContacts: () => void; getAutocomplete: (query: string) => Array<{ name: string; email: string; group?: { id: string; memberCount: number } }>; getGroups: () => ContactCard[]; getIndividuals: () => ContactCard[]; getGroupMembers: (groupId: string) => ContactCard[]; createGroup: (client: IJMAPClient | null, name: string, memberIds: string[]) => Promise; updateGroup: (client: IJMAPClient | null, groupId: string, name: string) => Promise; addMembersToGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise; removeMembersFromGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise; deleteGroup: (client: IJMAPClient | null, groupId: string) => Promise; toggleContactSelection: (id: string) => void; selectRangeContacts: (targetId: string, sortedIds: string[]) => void; selectAllContacts: (ids: string[]) => void; clearSelection: () => void; bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise; bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise; moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise; createAddressBook: (client: IJMAPClient, name: string) => Promise; renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise; removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise; shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise; renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise; importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise; // Trusted senders address book loadTrustedSendersBook: (client: IJMAPClient) => Promise; addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; isTrustedAddressBookSender: (email: string) => boolean; // Recent recipients (compose autocomplete, derived from the Sent folder) loadRecentRecipients: (client: IJMAPClient, sentMailboxId: string) => Promise; // On-demand "search the server" for recipients not in the recent cache searchRecipients: (client: IJMAPClient, query: string) => Promise>; } export const useContactStore = create()( persist( (set, get) => { // Clean group member references when contacts are removed function cleanGroupMembers(contacts: ContactCard[], removedIds: Set): ContactCard[] { // Collect uid/id variants of removed contacts for matching 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); const bare = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; removedKeys.add(bare); } if (c.originalId) removedKeys.add(c.originalId); } 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; }); } return ({ contacts: [], addressBooks: [], selectedContactId: null, searchQuery: '', isLoading: false, error: null, supportsSync: false, trustedSenderEmails: [], trustedSendersBookId: null, trustedSendersLoaded: false, trustedSendersLoading: false, recentRecipients: [], recentRecipientsLoaded: false, sentMailboxId: null, selectedContactIds: new Set(), lastSelectedContactId: null, activeTab: 'all' as const, directoryPrincipals: [], directoryLoaded: false, fetchContacts: async (client) => { set({ isLoading: true, error: null }); try { const contacts = await client.getAllContacts(); set({ contacts, isLoading: false }); } catch (error) { console.error('Failed to fetch contacts:', error); set({ error: 'Failed to fetch contacts', isLoading: false }); } }, 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.description || 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(); set({ addressBooks }); } catch (error) { console.error('Failed to fetch address books:', error); set({ error: 'Failed to fetch address books' }); } }, fetchAllAccountsContacts: async (accounts, activeLocalAccountId) => { set({ isLoading: true, error: null }); try { const results = await Promise.all( accounts.map(async ({ client, localAccountId }) => { try { const list = await client.getAllContacts(); return prefixContactsWithLocalAccount( list, localAccountId, localAccountId === activeLocalAccountId, ); } catch (error) { debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error); return [] as ContactCard[]; } }), ); set({ contacts: results.flat(), isLoading: false }); } catch (error) { console.error('Failed to fetch all-account contacts:', error); set({ error: 'Failed to fetch contacts', isLoading: false }); } }, fetchAllAccountsAddressBooks: async (accounts, activeLocalAccountId) => { try { const results = await Promise.all( accounts.map(async ({ client, localAccountId }) => { try { const list = await client.getAllAddressBooks(); return prefixAddressBooksWithLocalAccount( list, localAccountId, localAccountId === activeLocalAccountId, ); } catch (error) { debug.error(`Failed to fetch address books for account ${localAccountId}:`, error); return [] as AddressBook[]; } }), ); set({ addressBooks: results.flat() }); } catch (error) { console.error('Failed to fetch all-account address books:', error); set({ error: 'Failed to fetch address books' }); } }, createContact: async (client, contact) => { set({ isLoading: true, error: null }); let accountId: string | undefined = contact.isShared ? contact.accountId : undefined; let cleanedContact = contact; try { // Determine target account from the selected address book. Also // pin the local account so we route through the right server's // client in multi-account Pro mode. let localAccountId = contact.localAccountId; // De-namespace addressBookIds if they reference a shared address book if (contact.addressBookIds) { const books = get().addressBooks; const deNamespaced: Record = {}; let sharedAccountId: string | undefined; for (const [bookId, value] of Object.entries(contact.addressBookIds)) { const book = books.find(b => b.id === bookId); if (book?.localAccountId) localAccountId = book.localAccountId; if (book?.isShared && book.originalId) { deNamespaced[book.originalId] = value; sharedAccountId = book.accountId; } else if (book?.originalId) { deNamespaced[book.originalId] = value; } else { deNamespaced[bookId] = value; } } if (sharedAccountId) { accountId = sharedAccountId; cleanedContact = { ...contact, addressBookIds: deNamespaced, isShared: true, accountId: sharedAccountId }; } else { cleanedContact = { ...contact, addressBookIds: deNamespaced }; } } client = resolveAccountClient(client, localAccountId); const created = await client.createContact(cleanedContact, accountId); // Preserve shared account metadata if (contact.isShared && contact.accountId) { created.accountId = contact.accountId; created.accountName = contact.accountName; created.isShared = true; created.id = `${contact.accountId}:${created.id}`; created.originalId = created.id.includes(':') ? created.id.split(':').slice(1).join(':') : created.id; } set((state) => ({ contacts: [...state.contacts, created], isLoading: false, })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to create contact'; if (isNetworkError(error)) { const queueAccountId = accountId || useAccountStore.getState().activeAccountId; if (queueAccountId) { enqueueOperation({ type: 'createContact', accountId: queueAccountId, payload: cleanedContact }); } } set({ error: msg, isLoading: false }); throw error; } }, updateContact: async (client, id, updates) => { set({ error: null }); const contact = get().contacts.find(c => c.id === id); const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); const accountId = contact?.isShared ? contact.accountId : undefined; let cleanedUpdates = updates; try { client = resolveAccountClient(client, contact?.localAccountId); // De-namespace addressBookIds for shared contacts before sending to JMAP server if (contact?.isShared && contact?.accountId && updates.addressBookIds) { const prefix = `${contact.accountId}:`; const deNamespaced = Object.fromEntries( Object.entries(updates.addressBookIds).map(([k, v]) => [ k.startsWith(prefix) ? k.slice(prefix.length) : k, v ]) ); cleanedUpdates = { ...updates, addressBookIds: deNamespaced }; } await client.updateContact(originalId, cleanedUpdates, accountId); set((state) => ({ contacts: state.contacts.map(c => c.id === id ? { ...c, ...updates } : c ), })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to update contact'; if (isNetworkError(error)) { const queueAccountId = accountId || useAccountStore.getState().activeAccountId; if (queueAccountId) { enqueueOperation({ type: 'updateContact', accountId: queueAccountId, payload: { id: originalId, updates: cleanedUpdates } }); } } set({ error: msg }); throw error; } }, deleteContact: async (client, id) => { set({ error: null }); const contact = get().contacts.find(c => c.id === id); const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); const accountId = contact?.isShared ? contact.accountId : undefined; try { client = resolveAccountClient(client, contact?.localAccountId); await client.deleteContact(originalId, accountId); set((state) => { const removedIds = new Set([id]); const cleaned = cleanGroupMembers(state.contacts, removedIds); return { contacts: cleaned.filter(c => c.id !== id), selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, }; }); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete contact'; if (isNetworkError(error)) { const queueAccountId = accountId || useAccountStore.getState().activeAccountId; if (queueAccountId) { enqueueOperation({ type: 'deleteContact', accountId: queueAccountId, payload: { id: originalId, targetAccountId: accountId } }); } } set({ error: msg }); throw error; } }, addLocalContact: (contact) => set((state) => ({ contacts: [...state.contacts, contact], })), updateLocalContact: (id, updates) => set((state) => ({ contacts: state.contacts.map(c => c.id === id ? { ...c, ...updates } : c ), })), deleteLocalContact: (id) => set((state) => { const removedIds = new Set([id]); const cleaned = cleanGroupMembers(state.contacts, removedIds); return { contacts: cleaned.filter(c => c.id !== id), selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, }; }), setSelectedContact: (id) => set({ selectedContactId: id }), setSearchQuery: (query) => set({ searchQuery: query }), setSupportsSync: (supports) => set({ supportsSync: supports }), setActiveTab: (tab) => set({ activeTab: tab }), clearContacts: () => set({ contacts: [], addressBooks: [], selectedContactId: null, searchQuery: '', error: null, selectedContactIds: new Set(), activeTab: 'all', directoryPrincipals: [], directoryLoaded: false, }), getAutocomplete: (query) => { const { contacts } = get(); if (!query || query.length < 1) return []; const lower = query.toLowerCase(); const results: Array<{ name: string; email: string; group?: { id: string; memberCount: number } }> = []; for (const contact of contacts) { if (contact.kind === 'group') { // Suggest the group itself as a single entry (Outlook-style); // the composer turns it into one chip carrying the members. const groupName = getContactDisplayName(contact); if (groupName && groupName.toLowerCase().includes(lower)) { const memberCount = get().getGroupMembers(contact.id) .filter(m => getContactPrimaryEmail(m).trim()).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 emailEntry of emails) { if (!emailEntry.address) continue; if ( name.toLowerCase().includes(lower) || emailEntry.address.toLowerCase().includes(lower) ) { results.push({ name, email: emailEntry.address }); } } 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)) { const betterName = p.description || p.name; if (betterName && betterName !== p.email) { const existing = results.find(r => r.email.toLowerCase() === addr); if (existing && (!existing.name || existing.name === existing.email)) { existing.name = betterName; } } continue; } if (p.name.toLowerCase().includes(lower) || addr.includes(lower) || (p.description && p.description.toLowerCase().includes(lower))) { const displayName = p.description || p.name; const name = displayName !== p.email ? displayName : ''; results.push({ name, email: p.email }); seen.add(addr); } } } // Finally fold in recent recipients from the Sent folder (people you've // written to - the OWA-style autocomplete cache). Contacts and directory // principals take precedence, so skip any address already suggested (no // duplicates). These carry the display name from the message, so match // on name or address. const { recentRecipients } = get(); if (recentRecipients.length > 0 && results.length < 10) { const seenRecent = new Set(results.map(r => r.email.toLowerCase())); for (const rec of recentRecipients) { if (results.length >= 10) break; const addr = rec.email.toLowerCase(); if (seenRecent.has(addr)) continue; if (addr.includes(lower) || (rec.name && rec.name.toLowerCase().includes(lower))) { results.push({ name: rec.name, email: rec.email }); seenRecent.add(addr); } } } return results; }, getGroups: () => { return get().contacts.filter(c => c.kind === 'group'); }, getIndividuals: () => { return get().contacts.filter(c => c.kind !== 'group'); }, getGroupMembers: (groupId) => { const { contacts } = get(); const group = contacts.find(c => c.id === groupId); if (!group?.members) return []; const memberKeys = Object.keys(group.members).filter(k => group.members![k]); // Normalize: strip urn:uuid: prefix for matching const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k); return contacts.filter(c => { if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true; if (c.uid) { const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid; return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid); } return false; }); }, createGroup: async (client, name, memberIds) => { const { contacts } = get(); const members: Record = {}; memberIds.forEach(id => { const contact = contacts.find(c => c.id === id); const key = contact?.uid || id; members[key] = true; }); const groupData: Partial = { kind: 'group', name: { components: [{ kind: 'given', value: name }], isOrdered: true }, members, }; if (client && get().supportsSync) { const created = await client.createContact(groupData); set((state) => ({ contacts: [...state.contacts, created] })); } else { const localGroup: ContactCard = { id: `local-${generateUUID()}`, addressBookIds: {}, ...groupData, } as ContactCard; set((state) => ({ contacts: [...state.contacts, localGroup] })); } }, updateGroup: async (client, groupId, name) => { const updates: Partial = { name: { components: [{ kind: 'given', value: name }], isOrdered: true }, }; if (client && get().supportsSync) { const group = get().contacts.find(c => c.id === groupId); const originalId = group?.originalId || groupId; const accountId = group?.isShared ? group.accountId : undefined; await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => c.id === groupId ? { ...c, ...updates } : c ), })); }, addMembersToGroup: async (client, groupId, memberIds) => { const { contacts } = get(); const group = contacts.find(c => c.id === groupId); if (!group) return; const newMembers = { ...group.members }; memberIds.forEach(id => { const contact = contacts.find(c => c.id === id); const key = contact?.uid || contact?.originalId || id; newMembers[key] = true; }); const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { const originalId = group.originalId || groupId; const accountId = group.isShared ? group.accountId : undefined; await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => c.id === groupId ? { ...c, members: newMembers } : c ), })); }, removeMembersFromGroup: async (client, groupId, memberIds) => { const { contacts } = get(); const group = contacts.find(c => c.id === groupId); if (!group?.members) return; const newMembers = { ...group.members }; memberIds.forEach(id => { // Try direct id match first if (newMembers[id] !== undefined) { delete newMembers[id]; return; } // Try uid-based match const contact = contacts.find(c => c.id === id); if (contact?.uid && newMembers[contact.uid] !== undefined) { delete newMembers[contact.uid]; } else { // Try stripping urn:uuid: prefix matching for (const key of Object.keys(newMembers)) { const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key; const bareUid = contact?.uid?.startsWith('urn:uuid:') ? contact.uid.slice(9) : contact?.uid; if (bareKey === id || bareKey === bareUid) { delete newMembers[key]; break; } } } }); const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { const originalId = group.originalId || groupId; const accountId = group.isShared ? group.accountId : undefined; await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => c.id === groupId ? { ...c, members: newMembers } : c ), })); }, deleteGroup: async (client, groupId) => { if (client && get().supportsSync) { const group = get().contacts.find(c => c.id === groupId); const originalId = group?.originalId || groupId; const accountId = group?.isShared ? group.accountId : undefined; await client.deleteContact(originalId, accountId); } set((state) => ({ contacts: state.contacts.filter(c => c.id !== groupId), selectedContactId: state.selectedContactId === groupId ? null : state.selectedContactId, })); }, toggleContactSelection: (id) => set((state) => { const next = new Set(state.selectedContactIds); if (next.has(id)) { next.delete(id); } else { next.add(id); } return { selectedContactIds: next, lastSelectedContactId: id }; }), selectRangeContacts: (targetId, sortedIds) => { const { lastSelectedContactId, selectedContactIds } = get(); const anchorId = lastSelectedContactId || sortedIds[0]; if (!anchorId) return; const anchorIndex = sortedIds.indexOf(anchorId); const targetIndex = sortedIds.indexOf(targetId); if (anchorIndex === -1 || targetIndex === -1) return; const start = Math.min(anchorIndex, targetIndex); const end = Math.max(anchorIndex, targetIndex); const newSelection = new Set(selectedContactIds); for (let i = start; i <= end; i++) { newSelection.add(sortedIds[i]); } set({ selectedContactIds: newSelection }); }, selectAllContacts: (ids) => set({ selectedContactIds: new Set(ids) }), clearSelection: () => set({ selectedContactIds: new Set(), lastSelectedContactId: null }), bulkDeleteContacts: async (client, ids) => { set({ error: null }); const { supportsSync, contacts } = get(); const deletedIds = new Set(ids); if (client && supportsSync) { for (const id of ids) { try { const contact = contacts.find(c => c.id === id); const originalId = contact?.originalId || id; const accountId = contact?.isShared ? contact.accountId : undefined; await client.deleteContact(originalId, accountId); } catch (error) { console.error(`Failed to delete contact ${id}:`, error); deletedIds.delete(id); } } if (deletedIds.size < ids.length) { set({ error: `Failed to delete ${ids.length - deletedIds.size} contact(s)` }); } } set((state) => { const cleaned = cleanGroupMembers(state.contacts, deletedIds); return { contacts: cleaned.filter(c => !deletedIds.has(c.id)), selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId, selectedContactIds: new Set(), }; }); }, bulkAddToGroup: async (client, groupId, contactIds) => { await get().addMembersToGroup(client, groupId, contactIds); set({ selectedContactIds: new Set() }); }, moveContactToAddressBook: async (client, contactIds, addressBook) => { set({ error: null }); const { contacts } = get(); const targetBookOriginalId = addressBook.originalId || addressBook.id; const targetAccountId = addressBook.accountId; const primaryAccountId = client.getContactsAccountId(); for (const id of contactIds) { const contact = contacts.find(c => c.id === id); if (!contact) continue; const originalId = contact.originalId || id; const sourceAccountId = contact.isShared ? contact.accountId : undefined; // Same account: just update the addressBookIds if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) { await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId); const isTargetPrimary = !targetAccountId || targetAccountId === primaryAccountId; const localBookId = isTargetPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`; set((state) => ({ contacts: state.contacts.map(c => c.id === id ? { ...c, addressBookIds: { [localBookId]: true } } : c ), })); } else { // Cross-account: create in target, delete from source const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, id: _id, ...contactData } = contact; const newContact = await client.createContact( { ...contactData, addressBookIds: { [targetBookOriginalId]: true } }, targetAccountId ); await client.deleteContact(originalId, sourceAccountId); // Update local state const isPrimary = !targetAccountId || targetAccountId === primaryAccountId; const localBookId = isPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`; set((state) => ({ contacts: state.contacts.map(c => { if (c.id !== id) return c; return { ...newContact, id: isPrimary ? newContact.id : `${targetAccountId}:${newContact.id}`, originalId: newContact.id, accountId: targetAccountId, accountName: addressBook.accountName || targetAccountId, isShared: !isPrimary, addressBookIds: { [localBookId]: true }, }; }), })); } } }, createAddressBook: async (client, name) => { set({ error: null }); const trimmed = name.trim(); if (!trimmed) throw new Error('Address book name is required'); try { // New books always belong to the active account; the caller refreshes // the list afterwards (single- or multi-account aware) so the freshly // created book lands in state with its full server-set properties. return await client.createAddressBook(trimmed); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to create address book'; set({ error: msg }); throw error; } }, renameAddressBook: async (client, addressBook, newName) => { set({ error: null }); const trimmed = newName.trim(); if (!trimmed) return; try { const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId); const accountId = addressBook.isShared ? addressBook.accountId : undefined; client = resolveAccountClient(client, addressBook.localAccountId); await client.updateAddressBook(originalId, { name: trimmed }, accountId); set((state) => ({ addressBooks: state.addressBooks.map(b => b.id === addressBook.id ? { ...b, name: trimmed } : b ), })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to rename address book'; set({ error: msg }); throw error; } }, removeAddressBook: async (client, addressBook) => { set({ error: null }); try { const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId); const accountId = addressBook.isShared ? addressBook.accountId : undefined; client = resolveAccountClient(client, addressBook.localAccountId); await client.deleteAddressBook(originalId, accountId); set((state) => ({ addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id), contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]), })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to delete address book'; set({ error: msg }); throw error; } }, shareAddressBook: async (client, addressBook, principalId, rights) => { set({ error: null }); try { const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId); const accountId = addressBook.isShared ? addressBook.accountId : undefined; client = resolveAccountClient(client, addressBook.localAccountId); await client.setAddressBookShare(originalId, principalId, rights, accountId); set((state) => ({ addressBooks: state.addressBooks.map(b => { if (b.id !== addressBook.id) return b; const next = { ...(b.shareWith ?? {}) }; if (rights === null) delete next[principalId]; else next[principalId] = rights; return { ...b, shareWith: next }; }), })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to share address book'; set({ error: msg }); throw error; } }, renameKeyword: async (client, oldKeyword, newKeyword) => { set({ error: null }); const oldKw = oldKeyword.trim(); const newKw = newKeyword.trim(); if (!oldKw || !newKw || oldKw === newKw) return; const { contacts, supportsSync } = get(); const affected = contacts.filter(c => c.keywords?.[oldKw]); for (const contact of affected) { const { [oldKw]: _old, ...rest } = contact.keywords || {}; const updatedKeywords: Record = { ...rest, [newKw]: true }; try { if (supportsSync && client) { const originalId = contact.originalId || contact.id; const accountId = contact.isShared ? contact.accountId : undefined; await client.updateContact(originalId, { keywords: updatedKeywords }, accountId); } set((state) => ({ contacts: state.contacts.map(c => c.id === contact.id ? { ...c, keywords: updatedKeywords } : c ), })); } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to rename category'; set({ error: msg }); throw error; } } }, loadRecentRecipients: async (client, sentMailboxId) => { if (sentMailboxId) set({ sentMailboxId }); if (get().recentRecipientsLoaded || !sentMailboxId) return; try { // Read the Sent folder and collect the people we've written to, so // compose autocomplete can suggest them (OWA-style). getEmails sorts // receivedAt desc, so keeping the first occurrence per address yields // the most recent one plus its display name. const { emails } = await client.getEmails(sentMailboxId, undefined, 300, 0); 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; byEmail.set(key, { name: (r.name || '').trim(), email: r.email }); } } set({ recentRecipients: Array.from(byEmail.values()), recentRecipientsLoaded: true }); debug.log('contacts', 'Loaded', byEmail.size, 'recent recipients from Sent'); } catch (error) { debug.error('Failed to load recent recipients:', error); set({ recentRecipientsLoaded: true }); } }, searchRecipients: async (client, query) => { const { sentMailboxId } = get(); const q = query.trim(); if (!sentMailboxId || q.length < 1) return []; try { return await client.searchSentRecipients(q, sentMailboxId); } catch (error) { debug.error('Recipient server search failed:', error); return []; } }, loadTrustedSendersBook: async (client) => { if (get().trustedSendersLoading) return; set({ trustedSendersLoading: true }); try { debug.log('contacts', 'Loading trusted senders address book'); const books = await client.getAddressBooks(); let book = books.find(b => b.name === TRUSTED_SENDERS_BOOK_NAME); if (!book) { debug.log('contacts', 'Creating new trusted senders address book'); book = await client.createAddressBook(TRUSTED_SENDERS_BOOK_NAME); } const bookId = book.id; debug.log('contacts', 'Trusted senders book id:', bookId); const contacts = await client.getContacts(bookId); debug.log('contacts', 'Loaded', contacts.length, 'trusted sender contacts'); const emails = contacts.flatMap(c => c.emails ? Object.values(c.emails).map(e => e.address.toLowerCase().trim()) : [] ).filter(Boolean); set({ trustedSendersBookId: bookId, trustedSenderEmails: emails, trustedSendersLoaded: true, trustedSendersLoading: false }); } catch (error) { debug.error('Failed to load trusted senders address book:', error); set({ trustedSendersLoaded: true, trustedSendersLoading: false }); } }, addToTrustedSendersBook: async (client, email) => { // Parse "Name " format to extract display name and email const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const displayName = angleMatch ? angleMatch[1].trim() : undefined; const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const { trustedSenderEmails } = get(); if (trustedSenderEmails.includes(emailAddress)) return; let bookId = get().trustedSendersBookId; if (!bookId) { await get().loadTrustedSendersBook(client); bookId = get().trustedSendersBookId; } if (!bookId) throw new Error('Could not find or create trusted senders address book'); debug.log('contacts', 'Adding trusted sender:', emailAddress, 'to book:', bookId); await client.createContact({ addressBookIds: { [bookId]: true }, ...(displayName ? { name: { full: displayName } } : {}), emails: { email: { address: emailAddress } }, }); set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, emailAddress] })); debug.log('contacts', 'Trusted sender added successfully'); }, removeFromTrustedSendersBook: async (client, email) => { // Parse "Name " format to extract just the email address const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const { trustedSendersBookId } = get(); if (!trustedSendersBookId) return; debug.log('contacts', 'Removing trusted sender:', normalizedEmail); const contacts = await client.getContacts(trustedSendersBookId); const match = contacts.find(c => c.emails && Object.values(c.emails).some(e => e.address.toLowerCase().trim() === normalizedEmail) ); if (match) { await client.deleteContact(match.id); debug.log('contacts', 'Trusted sender removed'); } set((state) => ({ trustedSenderEmails: state.trustedSenderEmails.filter(e => e !== normalizedEmail) })); }, isTrustedAddressBookSender: (email) => { // Parse "Name " format to extract just the email address const trimmed = email.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); return get().trustedSenderEmails.includes(normalizedEmail); }, importContacts: async (client, contacts) => { const { supportsSync } = get(); let imported = 0; for (const contact of contacts) { try { if (client && supportsSync) { const { id: _id, ...data } = contact; const created = await client.createContact(data); set((state) => ({ contacts: [...state.contacts, created] })); } else { const localContact: ContactCard = { ...contact, id: `local-${generateUUID()}`, }; set((state) => ({ contacts: [...state.contacts, localContact] })); } imported++; } catch (error) { console.error('Failed to import contact:', error); } } return imported; }, }); }, { name: 'contact-storage', partialize: (state) => ({ contacts: state.supportsSync ? [] : state.contacts, supportsSync: state.supportsSync, }), } ) ); import { registerPushHandler } from '@/lib/push-event-bus'; registerPushHandler('ContactCard', async (client) => { const store = useContactStore.getState(); store.fetchContacts(client).catch((err) => { console.error('Failed to refresh contacts on push:', err); }); }); export type { ContactName };