fix: archive and delete a message from its own account's folders in tag and unified views

This commit is contained in:
Linus Rath
2026-10-26 16:11:51 +01:00
parent d72bff95f9
commit acac3128bc
5 changed files with 275 additions and 61 deletions
@@ -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<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ onArchive, onDelete }: { onArchive?: () => void; onDelete?: () => void }) => (
<div>
<button onClick={() => onArchive?.()}>stub archive</button>
<button onClick={() => onDelete?.()}>stub delete</button>
</div>
),
}));
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: () => <div>message list</div> }));
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<string, unknown>;
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(<MailApp />);
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(<MailApp />);
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');
});
});
+32 -28
View File
@@ -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<typeof resolveEmailActionContext>;
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();
@@ -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],
@@ -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> = {}): 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();
});
});
+31 -29
View File
@@ -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<void>;
fetchAccountMailboxes: (client: IJMAPClient, accountId: string, clientAccountId?: string) => Promise<void>;
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<string, Mailbox[]> },
clientAccountId: string | undefined,
state: Pick<EmailStore, 'accountMailboxes' | 'mailboxes'>,
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<EmailStore>((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<EmailStore>((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<EmailStore>((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;