diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts new file mode 100644 index 00000000..ea44916f --- /dev/null +++ b/hooks/use-pro-multi-account-mailboxes.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; + +/** + * Keeps `useEmailStore.accountMailboxes` populated with one entry per + * connected account while the Pro shell is the active interface. The Pro + * sidebar reads this cache to render a Thunderbird-style per-account folder + * tree (see [[project_pro_mode]]). Outside Pro the cache stays empty. + * + * Refetches whenever the set of connected accounts changes, so adding or + * removing an account in another tab is reflected without a reload. + */ +export function useProMultiAccountMailboxes(): void { + const isEmbedded = useIsEmbedded(); + const proInterface = useSettingsStore((s) => s.proInterface); + const accounts = useAccountStore((s) => s.accounts); + + useEffect(() => { + if (!proInterface && !isEmbedded) return; + + const connected = accounts.filter((a) => a.isConnected); + if (connected.length === 0) return; + + const fetchAccountMailboxes = useEmailStore.getState().fetchAccountMailboxes; + const getClientForAccount = useAuthStore.getState().getClientForAccount; + + for (const account of connected) { + const client = getClientForAccount(account.id); + if (!client) continue; + void fetchAccountMailboxes(client, account.id); + } + }, [proInterface, isEmbedded, accounts]); +} diff --git a/stores/__tests__/email-store-multi-account.test.ts b/stores/__tests__/email-store-multi-account.test.ts new file mode 100644 index 00000000..02a5cacb --- /dev/null +++ b/stores/__tests__/email-store-multi-account.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +function makeMailbox(overrides: Partial = {}): Mailbox { + return { + id: overrides.id ?? 'inbox', + name: overrides.name ?? 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + myRights: { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + }, + isSubscribed: true, + isShared: false, + ...overrides, + }; +} + +describe('useEmailStore multi-account state', () => { + beforeEach(() => { + useEmailStore.setState({ + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: '', + selectedEmail: null, + selectedEmailIds: new Set(), + selectedKeyword: null, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + }); + }); + + it('caches mailboxes per account via setAccountMailboxes', () => { + const accountA = [makeMailbox({ id: 'a-inbox', name: 'A Inbox' })]; + const accountB = [makeMailbox({ id: 'b-inbox', name: 'B Inbox' })]; + + useEmailStore.getState().setAccountMailboxes('account-a', accountA); + useEmailStore.getState().setAccountMailboxes('account-b', accountB); + + expect(useEmailStore.getState().accountMailboxes).toEqual({ + 'account-a': accountA, + 'account-b': accountB, + }); + }); + + it('replaces the cached entry when setAccountMailboxes is called again', () => { + const initial = [makeMailbox({ id: 'a-inbox' })]; + const updated = [makeMailbox({ id: 'a-inbox' }), makeMailbox({ id: 'a-sent', name: 'Sent' })]; + + useEmailStore.getState().setAccountMailboxes('account-a', initial); + useEmailStore.getState().setAccountMailboxes('account-a', updated); + + expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual(updated); + }); + + it('clearAccountMailboxes wipes the entire cache', () => { + useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox()]); + useEmailStore.getState().setAccountMailboxes('account-b', [makeMailbox()]); + + useEmailStore.getState().clearAccountMailboxes(); + + expect(useEmailStore.getState().accountMailboxes).toEqual({}); + }); + + it('setViewingAccount updates viewingAccountId without touching the mailbox cache', () => { + useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox()]); + useEmailStore.getState().setViewingAccount('account-a'); + expect(useEmailStore.getState().viewingAccountId).toBe('account-a'); + expect(useEmailStore.getState().accountMailboxes['account-a']).toBeDefined(); + + useEmailStore.getState().setViewingAccount(null); + expect(useEmailStore.getState().viewingAccountId).toBeNull(); + }); + + it('selectAccountMailbox sets viewing and selected together, and clears email selection state', () => { + useEmailStore.setState({ + selectedEmail: { id: 'e1' } as unknown as ReturnType['selectedEmail'], + selectedEmailIds: new Set(['e1', 'e2']), + selectedKeyword: 'work', + expandedThreadIds: new Set(['thread-1']), + }); + + useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox'); + + const state = useEmailStore.getState(); + expect(state.viewingAccountId).toBe('account-b'); + expect(state.selectedMailbox).toBe('b-inbox'); + expect(state.selectedEmail).toBeNull(); + expect(state.selectedEmailIds.size).toBe(0); + expect(state.selectedKeyword).toBeNull(); + expect(state.expandedThreadIds.size).toBe(0); + }); + + it('selectAccountMailbox with null accountId switches back to the active account', () => { + useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox'); + expect(useEmailStore.getState().viewingAccountId).toBe('account-b'); + + useEmailStore.getState().selectAccountMailbox(null, 'a-inbox'); + expect(useEmailStore.getState().viewingAccountId).toBeNull(); + expect(useEmailStore.getState().selectedMailbox).toBe('a-inbox'); + }); + + it('fetchAccountMailboxes caches the result keyed by accountId', async () => { + const mailboxes = [makeMailbox({ id: 'a-inbox' }), makeMailbox({ id: 'a-sent', name: 'Sent' })]; + const client = { + getMailboxes: vi.fn().mockResolvedValue(mailboxes), + } as unknown as IJMAPClient; + + await useEmailStore.getState().fetchAccountMailboxes(client, 'account-a'); + + expect(client.getMailboxes).toHaveBeenCalledTimes(1); + expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual(mailboxes); + }); + + it('fetchAccountMailboxes leaves the cache untouched when the client throws', async () => { + useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox({ id: 'a-inbox' })]); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = { + getMailboxes: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as IJMAPClient; + + await useEmailStore.getState().fetchAccountMailboxes(client, 'account-a'); + + expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual([ + makeMailbox({ id: 'a-inbox' }), + ]); + consoleError.mockRestore(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index ea9d7d0f..0282e5f3 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -14,6 +14,21 @@ import { useAccountStore } from "@/stores/account-store"; interface EmailStore { emails: Email[]; mailboxes: Mailbox[]; + /** + * Mailbox caches keyed by accountId. Populated for every connected account + * when the Pro shell is active so the sidebar can render per-account groups + * Thunderbird-style. The active account's mailboxes still live in + * `mailboxes` for back-compat with the single-account view. + */ + accountMailboxes: Record; + /** + * When set, the mail view is reading from this account instead of the + * global active one. `null` means "use the global active account" — i.e. + * the standard single-account behavior. Selecting a folder under a + * non-active account in the Pro sidebar updates this without changing + * `useAuthStore.activeAccountId`. + */ + viewingAccountId: string | null; selectedEmail: Email | null; selectedMailbox: string; isLoading: boolean; @@ -54,6 +69,23 @@ interface EmailStore { setEmails: (emails: Email[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void; + /** Cache or update the mailbox list for a specific account. */ + setAccountMailboxes: (accountId: string, mailboxes: Mailbox[]) => void; + /** Wipe the per-account mailbox cache (e.g. on logout). */ + clearAccountMailboxes: () => void; + setViewingAccount: (accountId: string | null) => void; + /** + * Atomic version of (setViewingAccount + selectMailbox). Pass `null` for + * the active account; pass an accountId to view a non-active account's + * folder without changing the global active account. + */ + selectAccountMailbox: (accountId: string | null, mailboxId: string) => void; + /** + * Fetch mailboxes via the supplied client and store them under + * `accountMailboxes[accountId]`. Used by the Pro shell to populate the + * sidebar's per-account groups for every connected account. + */ + fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise; selectEmail: (email: Email | null) => void; selectMailbox: (mailboxId: string) => void; setLoading: (loading: boolean) => void; @@ -193,6 +225,8 @@ function findTrashMailbox( export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], + accountMailboxes: {}, + viewingAccountId: null, selectedEmail: null, selectedMailbox: "", isLoading: false, @@ -236,6 +270,33 @@ export const useEmailStore = create((set, get) => ({ setEmails: (emails) => set({ emails }), setMailboxes: (mailboxes) => set({ mailboxes }), + setAccountMailboxes: (accountId, mailboxes) => set((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes }, + })), + clearAccountMailboxes: () => set({ accountMailboxes: {} }), + setViewingAccount: (accountId) => set({ viewingAccountId: accountId }), + selectAccountMailbox: (accountId, mailboxId) => set({ + viewingAccountId: accountId, + selectedMailbox: mailboxId, + selectedEmail: null, + selectedEmailIds: new Set(), + selectedKeyword: null, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + }), + fetchAccountMailboxes: async (client, accountId) => { + try { + const mailboxes = await client.getMailboxes(); + // Re-check the cache after the await to avoid stomping a more recent + // fetch that finished while this one was in flight. + set((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes }, + })); + } catch (error) { + console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); + } + }, selectEmail: (email) => { const prev = get().selectedEmail; set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });