feat: add per-account mailbox cache for Pro shell data layer
This commit is contained in:
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<typeof useEmailStore.getState>['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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,21 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
interface EmailStore {
|
interface EmailStore {
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
mailboxes: Mailbox[];
|
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<string, Mailbox[]>;
|
||||||
|
/**
|
||||||
|
* 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;
|
selectedEmail: Email | null;
|
||||||
selectedMailbox: string;
|
selectedMailbox: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -54,6 +69,23 @@ interface EmailStore {
|
|||||||
|
|
||||||
setEmails: (emails: Email[]) => void;
|
setEmails: (emails: Email[]) => void;
|
||||||
setMailboxes: (mailboxes: Mailbox[]) => 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<void>;
|
||||||
selectEmail: (email: Email | null) => void;
|
selectEmail: (email: Email | null) => void;
|
||||||
selectMailbox: (mailboxId: string) => void;
|
selectMailbox: (mailboxId: string) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
@@ -193,6 +225,8 @@ function findTrashMailbox(
|
|||||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||||
emails: [],
|
emails: [],
|
||||||
mailboxes: [],
|
mailboxes: [],
|
||||||
|
accountMailboxes: {},
|
||||||
|
viewingAccountId: null,
|
||||||
selectedEmail: null,
|
selectedEmail: null,
|
||||||
selectedMailbox: "",
|
selectedMailbox: "",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -236,6 +270,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
setEmails: (emails) => set({ emails }),
|
setEmails: (emails) => set({ emails }),
|
||||||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
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) => {
|
selectEmail: (email) => {
|
||||||
const prev = get().selectedEmail;
|
const prev = get().selectedEmail;
|
||||||
set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
|
set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
|
||||||
|
|||||||
Reference in New Issue
Block a user