diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 31253d06..0b974798 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -51,6 +51,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIdentitySync } from "@/hooks/use-identity-sync"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useProTabStore } from "@/stores/pro-tab-store"; +import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes"; import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -281,8 +282,16 @@ export default function Home() { batchMarkAsRead, batchMarkAsSpam, batchUndoSpam, + accountMailboxes, + viewingAccountId, + selectAccountMailbox, + setViewingAccount, } = useEmailStore(); + // Pro shell: populate per-account mailbox cache so the sidebar can render + // every connected account Thunderbird-style. + useProMultiAccountMailboxes(); + const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( @@ -1418,6 +1427,35 @@ export default function Home() { } }; + // Whenever the global active account changes, drop any non-active viewing + // override so we don't leave the email list pointed at a now-stale id. + useEffect(() => { + if (viewingAccountId && viewingAccountId === activeAccountId) { + setViewingAccount(null); + } + }, [activeAccountId, viewingAccountId, setViewingAccount]); + + // Pro sidebar: user clicked a folder under a specific account group. + // accountId === null means the active account; non-null means a viewing + // override that fetches via that account's JMAP client. + const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => { + const viewingClient = accountId + ? useAuthStore.getState().getClientForAccount(accountId) ?? client + : client; + selectAccountMailbox(accountId, mailboxId); + selectEmail(null); + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + if (isTablet) { + setTabletListVisible(true); + } + if (viewingClient) { + await fetchEmails(viewingClient, mailboxId); + } + }; + const handleMailboxSelect = async (mailboxId: string) => { if (isUnifiedMailboxId(mailboxId)) { const role = UNIFIED_ROLE_BY_ID[mailboxId]; @@ -2190,6 +2228,10 @@ export default function Home() { } }} onSidebarClose={() => setSidebarOpen(false)} + multiAccountMode={isEmbedded} + accountMailboxes={accountMailboxes} + viewingAccountId={viewingAccountId} + onAccountMailboxSelect={handleAccountMailboxSelect} /> diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 0fc3133d..e7a51701 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -75,6 +75,20 @@ interface SidebarProps { onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; className?: string; + /** + * Multi-account (Pro) mode props. When `multiAccountMode` is true, the + * sidebar renders a per-connected-account group instead of a single + * folders section — Thunderbird-style. `accountMailboxes` provides the + * mailbox list for non-active accounts (the active account still flows + * through the `mailboxes` prop). `viewingAccountId` highlights which + * account's folder is currently selected (null = active account). + * `onAccountMailboxSelect` fires with the owning accountId when the user + * picks a folder; callers translate that into `selectAccountMailbox`. + */ + multiAccountMode?: boolean; + accountMailboxes?: Record; + viewingAccountId?: string | null; + onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void; } const ROW_PX_BASE = 8; @@ -661,10 +675,14 @@ export function Sidebar({ onImportEmail, onRefreshMailboxes, className, + multiAccountMode = false, + accountMailboxes, + viewingAccountId = null, + onAccountMailboxSelect, }: SidebarProps) { const router = useRouter(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); - const { primaryIdentity: _primaryIdentity } = useAuthStore(); + const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [foldersExpanded, setFoldersExpanded] = useState(() => { try { @@ -696,6 +714,17 @@ export function Sidebar({ return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set(); } catch { return new Set(); } }); + // Per-connected-account collapse state for Pro / Thunderbird-style mode. + // Stored as the set of accountIds the user has explicitly collapsed — + // anything not in the set is treated as expanded. Inverting the storage + // model lets new accounts default to expanded automatically. + const [collapsedAccountGroups, setCollapsedAccountGroups] = useState>(() => { + try { + const stored = localStorage.getItem('sidebarCollapsedAccountGroups'); + if (stored !== null) return new Set(JSON.parse(stored) as string[]); + } catch { /* fall through */ } + return new Set(); + }); const emailKeywords = useSettingsStore(s => s.emailKeywords); const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's @@ -755,6 +784,24 @@ export function Sidebar({ const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-')); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); + // Multi-account mode (Pro shell): render every connected account as its + // own collapsible group. The active account's tree comes from the + // `mailboxes` prop (which is the live email-store value); other accounts + // come from the per-account cache populated by useProMultiAccountMailboxes. + const useMultiAccount = multiAccountMode && connectedAccounts.length > 1; + const accountGroups = useMultiAccount + ? connectedAccounts.map((account) => { + const isActive = account.id === activeAccountId; + const accountMailboxList = isActive + ? mailboxes + : (accountMailboxes?.[account.id] ?? []); + const tree = buildMailboxTree(accountMailboxList).filter( + (n) => !n.id.startsWith('shared-account-') + ); + return { account, isActive, tree }; + }) + : []; + const getUnifiedIcon = (role: UnifiedMailboxRole) => { switch (role) { case 'inbox': return Inbox; @@ -834,6 +881,14 @@ export function Sidebar({ return next; }); }; + const toggleAccountGroup = (id: string) => { + setCollapsedAccountGroups((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + try { localStorage.setItem('sidebarCollapsedAccountGroups', JSON.stringify(Array.from(next))); } catch { /* */ } + return next; + }); + }; const openFolderSettings = () => { try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ } @@ -937,43 +992,90 @@ export function Sidebar({ )} -
- - {((foldersExpanded && !isCollapsed) || isCollapsed) && ( - <> - {mailboxes.length === 0 ? ( -
- {!isCollapsed && t("loading_mailboxes")} -
- ) : ( - ownTree.map((node) => ( - - )) - )} - - )} -
+ {useMultiAccount ? ( + accountGroups.map(({ account, isActive, tree }) => { + const expanded = !collapsedAccountGroups.has(account.id); + const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id; + return ( +
+ toggleAccountGroup(account.id)} + onSettings={isActive ? openFolderSettings : undefined} + settingsTitle={isActive ? t('settings') : undefined} + isCollapsed={isCollapsed} + first={!showUnified && account.id === connectedAccounts[0]?.id} + icon={} + /> + {((expanded && !isCollapsed) || isCollapsed) && ( + <> + {tree.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + tree.map((node) => ( + + onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) + } + onToggleExpand={handleToggleExpand} + isCollapsed={isCollapsed} + onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} + colorful={colorfulSidebarIcons} + onContextMenu={isActive ? handleMailboxContextMenu : undefined} + /> + )) + )} + + )} +
+ ); + }) + ) : ( +
+ + {((foldersExpanded && !isCollapsed) || isCollapsed) && ( + <> + {mailboxes.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + ownTree.map((node) => ( + + )) + )} + + )} +
+ )} - {sharedAccounts.length > 0 && ( + {!useMultiAccount && sharedAccounts.length > 0 && (
{ + const viewingId = useEmailStore.getState().viewingAccountId; + const client = resolveActionClient(fallbackClient); + try { + const mailboxes = await client.getMailboxes(); + if (viewingId) { + useEmailStore.setState((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes }, + })); + } else { + useEmailStore.setState({ mailboxes }); + } + } catch (error) { + console.error('Failed to refresh mailboxes after mutation:', error); + } +} + // Find the trash mailbox for a given account scope. Prefers JMAP role, but // falls back to name matching ("trash" / "deleted") so users with custom or // pre-existing folders (e.g. "Deleted Items") aren't silently destroyed. @@ -322,7 +370,7 @@ export const useEmailStore = create((set, get) => ({ return; } const tagIds = keywords.map(k => k.id); - const counts = await client.getTagCounts(tagIds); + const counts = await resolveActionClient(client).getTagCounts(tagIds); set({ tagCounts: counts }); } catch (error) { console.error('Failed to fetch tag counts:', error); @@ -446,9 +494,10 @@ export const useEmailStore = create((set, get) => ({ set({ isLoading: true, error: null }); // Keep previous emails visible during transition try { const targetMailboxId = mailboxId || get().selectedMailbox; + const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === targetMailboxId); // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; @@ -464,7 +513,7 @@ export const useEmailStore = create((set, get) => ({ // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. - const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); + const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); set({ emails: result.emails, hasMoreEmails: result.hasMore, @@ -532,6 +581,7 @@ export const useEmailStore = create((set, get) => ({ set({ isLoadingMore: true, error: null }); try { + const effectiveClient = resolveActionClient(client); // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; @@ -544,21 +594,21 @@ export const useEmailStore = create((set, get) => ({ const hasFilters = !isFilterEmpty(searchFilters); if (searchQuery || hasFilters) { - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; const accountId = mailbox?.isShared ? mailbox.accountId : undefined; if (hasFilters) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position); } else { - result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); + result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); } } else { // Load more from mailbox // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; @@ -566,7 +616,7 @@ export const useEmailStore = create((set, get) => ({ const jmapMailboxId = mailbox?.originalId || selectedMailbox; // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails). - result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); + result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); } // Use fresh state when merging to avoid overwriting concurrent updates @@ -597,13 +647,13 @@ export const useEmailStore = create((set, get) => ({ try { // Find the selected mailbox to determine accountId (for shared folders) const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); // Only pass accountId for shared mailboxes const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - const email = await client.getEmail(emailId, accountId); + const email = await resolveActionClient(client).getEmail(emailId, accountId); if (email) { set({ selectedEmail: email }); @@ -619,7 +669,7 @@ export const useEmailStore = create((set, get) => ({ fetchQuota: async (client) => { try { - const quota = await client.getQuota(); + const quota = await resolveActionClient(client).getQuota(); set({ quota }); } catch { // Don't set error state as quota is optional @@ -666,6 +716,7 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isUnread = !email.keywords?.$seen; + const effectiveClient = resolveActionClient(client); // Get delete action preference from settings const deleteAction = useSettingsStore.getState().deleteAction; @@ -673,7 +724,7 @@ export const useEmailStore = create((set, get) => ({ // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; @@ -690,7 +741,7 @@ export const useEmailStore = create((set, get) => ({ if (trashMailbox) { // Use originalId for shared mailboxes if available const trashId = trashMailbox.originalId || trashMailbox.id; - await client.moveToTrash(emailId, trashId, accountId); + await effectiveClient.moveToTrash(emailId, trashId, accountId); // Remove from local state (email moved to trash, not in current view) set((state) => { @@ -737,7 +788,7 @@ export const useEmailStore = create((set, get) => ({ } // Permanent delete - await client.deleteEmail(emailId); + await effectiveClient.deleteEmail(emailId); // Remove from local state and update mailbox counters if needed set((state) => { @@ -811,11 +862,11 @@ export const useEmailStore = create((set, get) => ({ // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - await client.markAsRead(emailId, read, accountId); + await resolveActionClient(client).markAsRead(emailId, read, accountId); // Update local state including mailbox counters set((state) => { @@ -879,14 +930,15 @@ export const useEmailStore = create((set, get) => ({ const isUnread = !email.keywords?.$seen; const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : []; - const { selectedMailbox, mailboxes } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - await client.moveEmail(emailId, jmapDestId, accountId); + await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId); set((state) => { const updatedMailboxes = state.mailboxes.map(mailbox => { @@ -933,7 +985,8 @@ export const useEmailStore = create((set, get) => ({ } try { - const { emails, mailboxes, selectedMailbox, isUnifiedView } = get(); + const { emails, selectedMailbox, isUnifiedView } = get(); + const mailboxes = resolveActionMailboxes(); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; const idSet = new Set(emailIds); @@ -955,7 +1008,7 @@ export const useEmailStore = create((set, get) => ({ } else { const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; - await client.batchMoveEmails(emailIds, jmapDestId, accountId); + await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId); } // Adjust counters and drop moved emails from the current view. @@ -1010,12 +1063,14 @@ export const useEmailStore = create((set, get) => ({ return; } - const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); + const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; - const destMailbox = state.mailboxes.find(mb => mb.id === destinationMailboxId); + const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - const thread = await client.getThread(email.threadId, accountId); + const thread = await effectiveClient.getThread(email.threadId, accountId); const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId]; if (threadEmailIds.length <= 1) { @@ -1023,7 +1078,7 @@ export const useEmailStore = create((set, get) => ({ return; } - await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId); + await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId); const removedEmailIds = new Set(threadEmailIds); set((currentState) => { @@ -1057,7 +1112,7 @@ export const useEmailStore = create((set, get) => ({ try { // Get the current mailbox to scope the search const selectedMailbox = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Use originalId for shared mailboxes const jmapMailboxId = mailbox?.originalId || selectedMailbox; @@ -1066,7 +1121,7 @@ export const useEmailStore = create((set, get) => ({ // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); + const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); set({ emails: result.emails, @@ -1088,7 +1143,8 @@ export const useEmailStore = create((set, get) => ({ }, advancedSearch: async (client) => { - const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get(); + const { searchQuery, searchFilters, selectedMailbox, searchAbortController } = get(); + const mailboxes = resolveActionMailboxes(); if (searchAbortController) { searchAbortController.abort(); @@ -1111,7 +1167,7 @@ export const useEmailStore = create((set, get) => ({ const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0); + const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); if (controller.signal.aborted) return; @@ -1159,7 +1215,7 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isFlagged = email.keywords.$flagged || false; - await client.toggleStar(emailId, !isFlagged); + await resolveActionClient(client).toggleStar(emailId, !isFlagged); // Update local state set((state) => ({ @@ -1191,7 +1247,8 @@ export const useEmailStore = create((set, get) => ({ // Batch operations batchMarkAsRead: async (client, read) => { - const { selectedEmailIds, emails, mailboxes } = get(); + const { selectedEmailIds, emails } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1215,7 +1272,7 @@ export const useEmailStore = create((set, get) => ({ }); await Promise.allSettled(promises); } else { - await client.batchMarkAsRead(emailIdsArray, read); + await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read); } // Update local state @@ -1260,7 +1317,8 @@ export const useEmailStore = create((set, get) => ({ }, batchDelete: async (client, permanent = false) => { - const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get(); + const { selectedEmailIds, emails, selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1423,7 +1481,7 @@ export const useEmailStore = create((set, get) => ({ }); await Promise.allSettled(promises); } else { - await client.batchMoveEmails(emailIdsArray, toMailboxId); + await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId); } // Update local state - remove from current view since they moved @@ -1448,7 +1506,8 @@ export const useEmailStore = create((set, get) => ({ }, batchArchive: async (client) => { - const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get(); + const { selectedEmailIds, emails } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive'); @@ -1462,7 +1521,7 @@ export const useEmailStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { - await client.batchArchiveEmails( + await resolveActionClient(client).batchArchiveEmails( selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })), archiveId, mode, @@ -1473,8 +1532,9 @@ export const useEmailStore = create((set, get) => ({ const remaining = emails.filter(e => !selectedEmailIds.has(e.id)); set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false }); - await fetchMailboxes(client); - // Refresh the current mailbox view (honors active search/filters) + // Refresh the active or viewed account's mailbox cache after the + // archive (a year/month archive can create new sub-folders). + await refreshMailboxesForViewingAccount(client); await get().refreshCurrentMailbox(client); } catch (error) { set({ @@ -1487,7 +1547,8 @@ export const useEmailStore = create((set, get) => ({ // Spam operations markAsSpam: async (client, emailId) => { - const { selectedMailbox, mailboxes, emails } = get(); + const { selectedMailbox, emails } = get(); + const mailboxes = resolveActionMailboxes(); const email = emails.find(e => e.id === emailId); if (!email) return; @@ -1501,7 +1562,7 @@ export const useEmailStore = create((set, get) => ({ }); try { - await client.markAsSpam(emailId, currentMailbox.accountId); + await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId); set(state => ({ emails: state.emails.filter(e => e.id !== emailId), @@ -1514,7 +1575,8 @@ export const useEmailStore = create((set, get) => ({ }, undoSpam: async (client, emailId) => { - const { mailboxes, selectedMailbox } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); // Try cache first (preserves exact original mailbox for toast undo) const cachedData = get().spamUndoCache.get(emailId); @@ -1546,7 +1608,7 @@ export const useEmailStore = create((set, get) => ({ } try { - await client.undoSpam(emailId, targetMailboxId, accountId); + await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId); await get().fetchEmails(client, selectedMailbox); } catch (error) { console.error('Failed to restore email:', error); @@ -1555,14 +1617,16 @@ export const useEmailStore = create((set, get) => ({ }, batchMarkAsSpam: async (client, emailIds) => { - const { selectedMailbox, mailboxes } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (!currentMailbox) return; try { for (const emailId of emailIds) { - await client.markAsSpam(emailId, currentMailbox.accountId); + await effectiveClient.markAsSpam(emailId, currentMailbox.accountId); } set(state => ({ @@ -1577,7 +1641,9 @@ export const useEmailStore = create((set, get) => ({ }, batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => { - const { mailboxes, selectedMailbox } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); // Find inbox (batch operations don't preserve original mailboxes) const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); @@ -1594,7 +1660,7 @@ export const useEmailStore = create((set, get) => ({ try { for (const emailId of emailIds) { - await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); + await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); } set(state => ({ @@ -1681,7 +1747,8 @@ export const useEmailStore = create((set, get) => ({ try { // Fetch emails for the current mailbox without clearing the list first // This provides a smoother update experience - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || selectedMailbox; @@ -1697,9 +1764,9 @@ export const useEmailStore = create((set, get) => ({ let result; if (hasFilters || searchQuery) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0); } else { - result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); + result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); } const currentEmails = get().emails; @@ -1789,7 +1856,8 @@ export const useEmailStore = create((set, get) => ({ }, fetchThreadEmails: async (client, threadId) => { - const { threadEmailsCache, selectedMailbox, mailboxes } = get(); + const { threadEmailsCache, selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); // Check if we already have this thread cached const cachedEmails = threadEmailsCache.get(threadId); @@ -1806,7 +1874,7 @@ export const useEmailStore = create((set, get) => ({ const accountId = mailbox?.isShared ? mailbox.accountId : undefined; // Fetch all emails in the thread - const emails = await client.getThreadEmails(threadId, accountId); + const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId); // Update cache const newCache = new Map(get().threadEmailsCache); @@ -1841,8 +1909,12 @@ export const useEmailStore = create((set, get) => ({ // Mailbox management createMailbox: async (client, name, parentId) => { try { - await client.createMailbox(name, parentId); - await get().fetchMailboxes(client); + await resolveActionClient(client).createMailbox(name, parentId); + if (get().viewingAccountId) { + await refreshMailboxesForViewingAccount(client); + } else { + await get().fetchMailboxes(client); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to create folder' }); throw error; @@ -1851,12 +1923,24 @@ export const useEmailStore = create((set, get) => ({ renameMailbox: async (client, mailboxId, name) => { try { - await client.updateMailbox(mailboxId, { name }); - set({ - mailboxes: get().mailboxes.map(mb => - mb.id === mailboxId ? { ...mb, name } : mb - ), - }); + await resolveActionClient(client).updateMailbox(mailboxId, { name }); + const viewingId = get().viewingAccountId; + if (viewingId) { + set((state) => ({ + accountMailboxes: { + ...state.accountMailboxes, + [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => + mb.id === mailboxId ? { ...mb, name } : mb + ), + }, + })); + } else { + set({ + mailboxes: get().mailboxes.map(mb => + mb.id === mailboxId ? { ...mb, name } : mb + ), + }); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to rename folder' }); throw error; @@ -1865,18 +1949,27 @@ export const useEmailStore = create((set, get) => ({ deleteMailbox: async (client, mailboxId) => { try { - await client.deleteMailbox(mailboxId); - const { mailboxes, selectedMailbox } = get(); - const newMailboxes = mailboxes.filter(mb => mb.id !== mailboxId); - const updates: Partial = { mailboxes: newMailboxes }; - // If the deleted mailbox was selected, switch to inbox - if (selectedMailbox === mailboxId) { - const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared); - if (inbox) { - updates.selectedMailbox = inbox.id; + await resolveActionClient(client).deleteMailbox(mailboxId); + const { selectedMailbox, viewingAccountId: viewingId } = get(); + if (viewingId) { + const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId); + const patch: Partial = { + accountMailboxes: { ...get().accountMailboxes, [viewingId]: updatedList }, + }; + if (selectedMailbox === mailboxId) { + const inbox = updatedList.find(mb => mb.role === 'inbox' && !mb.isShared); + if (inbox) patch.selectedMailbox = inbox.id; } + set(patch); + } else { + const newMailboxes = get().mailboxes.filter(mb => mb.id !== mailboxId); + const updates: Partial = { mailboxes: newMailboxes }; + if (selectedMailbox === mailboxId) { + const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared); + if (inbox) updates.selectedMailbox = inbox.id; + } + set(updates); } - set(updates as EmailStore); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to delete folder' }); throw error; @@ -1885,15 +1978,20 @@ export const useEmailStore = create((set, get) => ({ setMailboxRole: async (client, mailboxId, role) => { try { + const effectiveClient = resolveActionClient(client); // If assigning a role, first clear that role from ALL other mailboxes that have it if (role) { - const existingMailboxes = get().mailboxes.filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId); + const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId); for (const existing of existingMailboxes) { - await client.updateMailbox(existing.id, { role: null }); + await effectiveClient.updateMailbox(existing.id, { role: null }); } } - await client.updateMailbox(mailboxId, { role }); - await get().fetchMailboxes(client); + await effectiveClient.updateMailbox(mailboxId, { role }); + if (get().viewingAccountId) { + await refreshMailboxesForViewingAccount(client); + } else { + await get().fetchMailboxes(client); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to update folder role' }); throw error; @@ -1903,7 +2001,7 @@ export const useEmailStore = create((set, get) => ({ emptyMailbox: async (client, mailboxId) => { try { set({ isLoading: true, error: null }); - await client.emptyMailbox(mailboxId); + await resolveActionClient(client).emptyMailbox(mailboxId); // Clear emails from local state if we're viewing this mailbox const currentMailbox = get().selectedMailbox; @@ -1911,15 +2009,28 @@ export const useEmailStore = create((set, get) => ({ set({ emails: [], selectedEmail: null }); } - // Update mailbox counters - set({ - mailboxes: get().mailboxes.map(mb => - mb.id === mailboxId - ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } - : mb - ), - isLoading: false, - }); + const viewingId = get().viewingAccountId; + if (viewingId) { + set((state) => ({ + accountMailboxes: { + ...state.accountMailboxes, + [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => + mb.id === mailboxId + ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } + : mb + ), + }, + })); + } else { + set({ + mailboxes: get().mailboxes.map(mb => + mb.id === mailboxId + ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } + : mb + ), + }); + } + set({ isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to empty folder', @@ -1931,11 +2042,11 @@ export const useEmailStore = create((set, get) => ({ markMailboxAsRead: async (client, mailboxId) => { try { - const mailbox = get().mailboxes.find(mb => mb.id === mailboxId); + const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || mailboxId; - const count = await client.markMailboxAsRead(jmapMailboxId, accountId); + const count = await resolveActionClient(client).markMailboxAsRead(jmapMailboxId, accountId); // Update local state: mark all emails currently visible in this mailbox as read, // and zero-out the mailbox unread counter.