From acac3128bc28a07fb4c5214bf2cdfb1b9f3afc5e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:46:44 +0100 Subject: [PATCH] fix: archive and delete a message from its own account's folders in tag and unified views --- .../mail-app-source-routing.test.tsx | 135 ++++++++++++++++++ components/mail/mail-app.tsx | 60 ++++---- .../__tests__/email-store-batch-spam.test.ts | 8 +- .../email-store-source-folders.test.ts | 73 ++++++++++ stores/email-store.ts | 60 ++++---- 5 files changed, 275 insertions(+), 61 deletions(-) create mode 100644 components/mail/__tests__/mail-app-source-routing.test.tsx create mode 100644 stores/__tests__/email-store-source-folders.test.ts diff --git a/components/mail/__tests__/mail-app-source-routing.test.tsx b/components/mail/__tests__/mail-app-source-routing.test.tsx new file mode 100644 index 000000000..838d07b00 --- /dev/null +++ b/components/mail/__tests__/mail-app-source-routing.test.tsx @@ -0,0 +1,135 @@ +import React, { Suspense, lazy } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; + +// Actions on the open message act in its own login and account. Stalwart +// numbers every account's folders and messages alike (Inbox `a`, Deleted +// Items `b`, first message `eaaaaab`), so a folder or message picked from +// another account's list names something else there. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onArchive, onDelete }: { onArchive?: () => void; onDelete?: () => void }) => ( +
+ + +
+ ), +})); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string | undefined, accountId: string, shared = false): Mailbox { + return { + id: shared ? `${accountId}:${id}` : id, originalId: id, name: role ?? id, role, accountId, isShared: shared, + sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} + +// The active login's folders: its own account `cf` and the group `ce`. +const folders = [ + mailbox('a', 'inbox', 'cf'), mailbox('b', 'trash', 'cf'), mailbox('i', 'archive', 'cf'), + mailbox('a', 'inbox', 'ce', true), mailbox('b', 'trash', 'ce', true), mailbox('k', 'archive', 'ce', true), +]; + +// The group's message, opened in the Red tag view, which lists own and group +// messages together and stamps each with its login and account. +const groupMessage = { + id: 'eaaaaab', threadId: 'c', subject: 'GRP one', receivedAt: '2026-10-26T10:00:00Z', + keywords: { $seen: true, '$label:red': true }, mailboxIds: { 'ce:a': true }, + sourceClientAccountId: 'login-a', sourceAccountId: 'ce', +} as unknown as Email; + +let client: Record; +const moveToMailbox = vi.fn(); +const moveThreadToMailbox = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + clearAllHooks(); + client = { + getAccountId: () => 'cf', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', archiveMode: 'single', deleteAction: 'trash' } as never); + moveToMailbox.mockReset().mockResolvedValue(true); + moveThreadToMailbox.mockReset().mockResolvedValue(true); + useEmailStore.setState({ + emails: [groupMessage], selectedEmail: groupMessage, selectedMailbox: '', selectedKeyword: 'red', mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + moveToMailbox, moveThreadToMailbox, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('the open message of a group account in a tag view', () => { + it('is archived into the group\'s Archive, not the own one', async () => { + render(); + fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); + await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled()); + expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:k', 'login-a/ce:eaaaaab'); + }); + + it('is deleted into the group\'s Deleted Items, not the own one', async () => { + render(); + fireEvent.click(await screen.findByText('stub delete', {}, { timeout: 5000 })); + await waitFor(() => expect(moveToMailbox).toHaveBeenCalled()); + expect(moveToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:b', 'login-a/ce:eaaaaab'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d987d9b05..0b43a8046 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; +import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; @@ -2813,20 +2813,25 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; - // In unified view the trash destination and current-folder check must come - // from the email's own account, not the active one. The owning account's - // mailbox list is cached under its JMAP id (`sourceAccountId`). (#281) - const actionMailboxes = - isUnifiedView && emailToDelete.sourceAccountId - ? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes) - : mailboxes; + // The trash destination and current-folder check come from the email's own + // login and account, as the delete itself is routed: its source in a list + // that mixes accounts, else the folder being browsed. Never the list cached + // under the bare account id, which can be another server's account. (#281) + let action: ReturnType; + try { + action = resolveEmailActionContext(emailToDelete, client); + } catch (error) { + toast.error(t('notifications.error_deleting'), error instanceof Error ? error.message : undefined); + return; + } + const actionMailboxes = action.mailboxes; // Check if we're currently in the trash or junk folder. In unified view the // "current folder" is the unified role within the email's account. const currentMailbox = isUnifiedView ? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id]) ?? actionMailboxes.find(m => m.role === unifiedRole)) - : mailboxes.find(m => m.id === selectedMailbox); + : actionMailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; const isInJunk = currentMailbox?.role === 'junk'; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; @@ -2853,9 +2858,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // the trash lookup to the email's account: for a shared/group source every // mailbox in the list is `isShared`, so we match by accountId instead of // excluding shared (otherwise no trash is found and the delete fails). (#281) - const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined; const matchesScope = (m: Mailbox) => - sourceAccountId ? m.accountId === sourceAccountId : !m.isShared; + action.accountId ? m.accountId === action.accountId : !m.isShared; const trashMailbox = actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ?? actionMailboxes.find(m => { @@ -2879,27 +2883,27 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { if (!client || !emailToArchive || !isArchiveActionEnabled()) return; - // In unified view the archive folder (and any year/month subfolders we - // create) must live in the email's own account, reached through the login it - // is reachable via (`sourceClientAccountId`) and routed to its owning JMAP - // account (`sourceAccountId`). For personal sources these resolve to the - // account itself, so behavior is unchanged. (#281) - const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined; - const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined; - const archiveClient = archiveClientId - ? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client) - : client; + // In a list that mixes accounts (the unified views, a tag view) the archive + // folder (and any year/month subfolders we create) must live in the email's + // own account, reached through the login it is reachable via + // (`sourceClientAccountId`) and routed to its owning JMAP account + // (`sourceAccountId`), with that source's own folder list. For personal + // sources these resolve to the account itself, so behavior is unchanged. (#281) + const archiveClientId = emailToArchive.sourceAccountId ? emailToArchive.sourceClientAccountId : undefined; + const archiveAccountId = archiveClientId ? emailToArchive.sourceAccountId : undefined; + const archiveClient = archiveClientId ? useAuthStore.getState().getClientForAccount(archiveClientId) : client; + if (!archiveClient) { + toast.error(t('notifications.error_archiving'), 'Not connected'); + return; + } // Read fresh mailboxes from the store – batch archive calls this in a loop, // and each iteration needs to see folders created by prior iterations. - const readMailboxes = () => { - const s = useEmailStore.getState(); - return archiveAccountId - ? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes) - : s.mailboxes; - }; + const readMailboxes = () => archiveAccountId + ? resolveEmailActionContext(emailToArchive, client).mailboxes + : useEmailStore.getState().mailboxes; const refreshMailboxes = () => archiveAccountId - ? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId) + ? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId, archiveClientId) : fetchMailboxes(client); const currentMailboxes = readMailboxes(); diff --git a/stores/__tests__/email-store-batch-spam.test.ts b/stores/__tests__/email-store-batch-spam.test.ts index 0096addd5..54d0f8d4d 100644 --- a/stores/__tests__/email-store-batch-spam.test.ts +++ b/stores/__tests__/email-store-batch-spam.test.ts @@ -77,8 +77,8 @@ describe('batch spam actions route per account', () => { useSettingsStore.setState({ deleteAction: 'trash' } as never); const aMailboxes = [ - makeMailbox({ id: 'a-inbox', role: 'inbox' }), - makeMailbox({ id: 'a-junk', name: 'Junk', role: 'junk', totalEmails: 1 }), + makeMailbox({ id: 'a-inbox', role: 'inbox', accountId: 'account-a' }), + makeMailbox({ id: 'a-junk', name: 'Junk', role: 'junk', totalEmails: 1, accountId: 'account-a' }), ]; useEmailStore.setState({ isUnifiedView: true, @@ -90,8 +90,8 @@ describe('batch spam actions route per account', () => { accountMailboxes: { 'account-a': aMailboxes, 'account-b': [ - makeMailbox({ id: 'b-inbox', role: 'inbox' }), - makeMailbox({ id: 'b-junk', name: 'Junk', role: 'junk', totalEmails: 2, unreadEmails: 1 }), + makeMailbox({ id: 'b-inbox', role: 'inbox', accountId: 'account-b' }), + makeMailbox({ id: 'b-junk', name: 'Junk', role: 'junk', totalEmails: 2, unreadEmails: 1, accountId: 'account-b' }), ], }, emails: [emailA, emailB, emailB2], diff --git a/stores/__tests__/email-store-source-folders.test.ts b/stores/__tests__/email-store-source-folders.test.ts new file mode 100644 index 000000000..75336f79f --- /dev/null +++ b/stores/__tests__/email-store-source-folders.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore, resolveEmailActionContext } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '../settings-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// A single-message action on a stamped row takes role folders (Trash, Junk, +// Inbox) from that row's login and account. The list cached under the bare +// account id holds whichever login's list was fetched last, and another +// server numbers its accounts the same way: its "cf" is another account, +// whose Deleted Items has another id. + +function mailbox(id: string, role: string | undefined, accountId: string, extra: Partial = {}): Mailbox { + return { id, originalId: id, name: id, role, accountId, isShared: false, sortOrder: 0, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, isSubscribed: true, ...extra } as Mailbox; +} + +function makeClient(accountId: string) { + return { + getAccountId: () => accountId, + moveToTrash: vi.fn().mockResolvedValue(undefined), + markAsSpam: vi.fn().mockResolvedValue(undefined), + } as unknown as IJMAPClient; +} + +// Login B's message in its own account "cf", listed in a tag view of login A. +const row = { + id: 'eaaaaab', threadId: 't', subject: 'B', receivedAt: '2026-10-26T10:00:00Z', + keywords: { $seen: true }, mailboxIds: { a: true }, + sourceClientAccountId: 'login-b', sourceAccountId: 'cf', +} as unknown as Email; + +describe('role folders of a stamped row', () => { + let a: IJMAPClient; + let b: IJMAPClient; + + beforeEach(() => { + a = makeClient('cf'); + b = makeClient('cf'); + useAuthStore.setState({ + activeAccountId: 'login-a', client: a as never, + getClientForAccount: (id: string) => (id === 'login-a' ? a : id === 'login-b' ? b : undefined) as never, + } as never); + useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false } as never); + useEmailStore.setState({ + isUnifiedView: false, viewingAccountId: null, selectedMailbox: 'tag-red', + mailboxes: [mailbox('a', 'inbox', 'cf'), mailbox('x9', 'trash', 'cf')], + accountMailboxes: { + // Login B's folders, as the sidebar loaded them. + 'login-b': [mailbox('a', 'inbox', 'cf'), mailbox('b', 'trash', 'cf'), mailbox('c', 'junk', 'cf')], + // Under the bare id: another login's "cf", fetched last. + cf: [mailbox('a', 'inbox', 'cf'), mailbox('x9', 'trash', 'cf'), mailbox('x8', 'junk', 'cf')], + }, + emails: [row], selectedEmail: null, selectedEmailKeys: new Set(), + threadEmailsCache: new Map(), expandedThreadIds: new Set(), + } as never); + }); + + it('come from its own login, not from the list cached under the bare account id', () => { + expect(resolveEmailActionContext(row, a).mailboxes.map(m => m.id)).toEqual(['a', 'b', 'c']); + }); + + it('come from the list cached for its login and account when there is one', () => { + useEmailStore.setState(s => ({ accountMailboxes: { ...s.accountMailboxes, 'login-b|cf': [mailbox('b', 'trash', 'cf')] } })); + expect(resolveEmailActionContext(row, a).mailboxes.map(m => m.id)).toEqual(['b']); + }); + + it('delete moves it to its own Deleted Items', async () => { + await useEmailStore.getState().deleteEmail(a, 'eaaaaab', false, 'login-b/cf:eaaaaab'); + expect(b.moveToTrash).toHaveBeenCalledWith('eaaaaab', 'b', 'cf', false); + expect(a.moveToTrash).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 8786f56b7..423076d1d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -274,9 +274,11 @@ interface EmailStore { /** * 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. + * sidebar's per-account groups for every connected account. With + * `clientAccountId`, `accountId` is a JMAP account reached through that + * login, and the list is also stored as that source's (sourceMailboxes). */ - fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise; + fetchAccountMailboxes: (client: IJMAPClient, accountId: string, clientAccountId?: string) => Promise; selectEmail: (email: Email | null) => void; /** * Shows a list row in the viewer while its body is on its way: selects it @@ -1448,7 +1450,7 @@ export function resolveEmailActionContext( if (email.sourceClientAccountId && email.sourceAccountId) { return { client: stampedClient(email.sourceClientAccountId), - mailboxes: sourceMailboxes(state, email.sourceClientAccountId, email.sourceAccountId) ?? state.mailboxes, + mailboxes: sourceMailboxes(state, email.sourceClientAccountId, email.sourceAccountId), accountId: email.sourceAccountId, }; } @@ -1920,36 +1922,32 @@ function sourceMailboxesKey(clientAccountId: string, jmapAccountId: string): str return `${clientAccountId}|${jmapAccountId}`; } -/** The cached folder list of a source of an aggregate list, if any (see sourceMailboxesKey). */ +/** + * The folders a source's role-based destinations (Archive, Junk, Inbox, + * Deleted Items) come from: the list cached for its login and account, else + * that login's folder list narrowed to the account: a tag view and an "All + * folders" search stamp their rows too, but only the unified views fill that + * cache. Never the list cached under the bare account id, which holds + * whichever login's list was fetched last when two logins share account ids. + */ function sourceMailboxes( - state: { accountMailboxes: Record }, - clientAccountId: string | undefined, + state: Pick, + clientAccountId: string, jmapAccountId: string, -): Mailbox[] | undefined { - return (clientAccountId ? state.accountMailboxes[sourceMailboxesKey(clientAccountId, jmapAccountId)] : undefined) - ?? state.accountMailboxes[jmapAccountId]; +): Mailbox[] { + const cached = state.accountMailboxes[sourceMailboxesKey(clientAccountId, jmapAccountId)]; + if (cached) return cached; + const loginMailboxes = clientAccountId === useAuthStore.getState().activeAccountId + ? state.mailboxes + : state.accountMailboxes[clientAccountId]; + return (loginMailboxes ?? []).filter((mailbox) => mailbox.accountId === jmapAccountId); } -/** - * The folders a SourceBatch's role-based destinations (Archive, Junk, Inbox, - * Deleted Items) come from: its source's own list, or the viewed account's - * for undecorated messages. A source's list is the one cached for its login - * and account, else that login's folder list narrowed to the account: a tag - * view and an "All folders" search stamp their rows too, but only the - * unified views fill that cache. Never the list cached under the bare - * account id, which holds whichever login's list was fetched last when two - * logins share account ids. - */ +/** sourceMailboxes of a SourceBatch, or the viewed account's for undecorated messages. */ function sourceBatchMailboxes(batch: SourceBatch): Mailbox[] { if (!batch.accountId) return resolveActionMailboxes(); if (!batch.clientAccountId) return []; - const state = useEmailStore.getState(); - const cached = state.accountMailboxes[sourceMailboxesKey(batch.clientAccountId, batch.accountId)]; - if (cached) return cached; - const loginMailboxes = batch.clientAccountId === useAuthStore.getState().activeAccountId - ? state.mailboxes - : state.accountMailboxes[batch.clientAccountId]; - return (loginMailboxes ?? []).filter((mailbox) => mailbox.accountId === batch.accountId); + return sourceMailboxes(useEmailStore.getState(), batch.clientAccountId, batch.accountId); } /** A folder that messages of an aggregate list are moved to (resolveMoveDestination). */ @@ -2913,7 +2911,7 @@ export const useEmailStore = create((set, get) => ({ unifiedRole: null, crossView: null, })), - fetchAccountMailboxes: async (client, accountId) => { + fetchAccountMailboxes: async (client, accountId, clientAccountId) => { try { // `accountId` is overloaded across callers: // - a real login (AccountEntry.id, e.g. per-account sidebar / cross-account @@ -2931,7 +2929,11 @@ export const useEmailStore = create((set, get) => ({ // 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 }, + accountMailboxes: { + ...state.accountMailboxes, + [accountId]: mailboxes, + ...(clientAccountId ? { [sourceMailboxesKey(clientAccountId, accountId)]: mailboxes } : {}), + }, })); } catch (error) { console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); @@ -5906,7 +5908,7 @@ export const useEmailStore = create((set, get) => ({ if (listEmail?.sourceClientAccountId && listEmail?.sourceAccountId) { undoClient = stampedClient(listEmail.sourceClientAccountId); accountId = listEmail.sourceAccountId; - inboxMailboxes = sourceMailboxes(get(), listEmail.sourceClientAccountId, listEmail.sourceAccountId) ?? mailboxes; + inboxMailboxes = sourceMailboxes(get(), listEmail.sourceClientAccountId, listEmail.sourceAccountId); } else { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); accountId = currentMailbox?.accountId;