mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: archive and delete a message from its own account's folders in tag and unified views
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user