From 4842e08cde90e9ad52f787ec5be68c33be5161e5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:24:57 +0100 Subject: [PATCH] fix: show shared mailboxes under each account when several are signed in #1486 --- .../__tests__/sidebar-pro-shared.test.tsx | 152 +++++++++ components/layout/sidebar.tsx | 312 +++++++++++------- components/mail/mail-app.tsx | 28 +- hooks/use-pro-multi-account-mailboxes.ts | 5 +- .../email-store-unified-mailbox-cache.test.ts | 16 + stores/email-store.ts | 28 +- 6 files changed, 412 insertions(+), 129 deletions(-) create mode 100644 components/layout/__tests__/sidebar-pro-shared.test.tsx diff --git a/components/layout/__tests__/sidebar-pro-shared.test.tsx b/components/layout/__tests__/sidebar-pro-shared.test.tsx new file mode 100644 index 000000000..c1480a58e --- /dev/null +++ b/components/layout/__tests__/sidebar-pro-shared.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// #1486: with two logins the Pro sidebar shows one folder group per login, +// and the shared mailboxes were left out of it altogether. Each group now has +// its own Shared section with the mailboxes that login can reach. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { useUIStore } = await import('@/stores/ui-store'); +const { useAccountStore } = await import('@/stores/account-store'); +const { useAuthStore } = await import('@/stores/auth-store'); + +const own = (id: string, name: string, role: string | null) => + ({ id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: { mayAddItems: true } }) as unknown as Mailbox; +const shared = (accountId: string, accountName: string) => + ({ + id: `${accountId}-inbox`, name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, + sortOrder: 0, myRights: { mayAddItems: true }, accountId, accountName, isShared: true, + }) as unknown as Mailbox; + +const account = (id: string) => ({ + id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`, + cookieSlot: id === 'a' ? 0 : 1, isConnected: true, +}); + +// Login a is active and has no shared mailbox; login b has one. +const activeMailboxes = [own('a-inbox', 'Inbox', 'inbox')]; +const otherOwn = { b: [own('b-inbox', 'Inbox', 'inbox')] }; +const otherShared = { b: [shared('team', 'Team mailbox')] }; + +const sharedHeaders = () => screen.queryAllByTestId('section-shared-account').map((el) => el.textContent); + +describe('Pro sidebar shared mailboxes with several logins (#1486)', () => { + beforeEach(() => { + localStorage.clear(); + useUIStore.setState({ sidebarCollapsed: false } as never); + useAccountStore.setState({ accounts: [account('a'), account('b')] } as never); + useAuthStore.setState({ activeAccountId: 'a' } as never); + useSettingsStore.setState({ + emailKeywords: [], + enableUnifiedMailbox: false, + hideAccountSwitcher: true, + showSharedMailAccounts: true, + hiddenSharedMailAccounts: {}, + compactSharedMailboxes: false, + } as never); + }); + + it('lists the shared mailboxes of a login that is not the active one', () => { + render( + , + ); + + expect(screen.getAllByTestId('section-shared-in-account')).toHaveLength(1); + expect(sharedHeaders()).toEqual([expect.stringContaining('Team mailbox')]); + }); + + it('lists the active login\'s shared mailboxes inside its group', () => { + useAuthStore.setState({ activeAccountId: 'b' } as never); + render( + , + ); + + expect(sharedHeaders()).toEqual([expect.stringContaining('Team mailbox')]); + }); + + it('opens another login\'s shared mailbox through that login', () => { + const onLoginSharedMailboxSelect = vi.fn(); + const onAccountMailboxSelect = vi.fn(); + render( + , + ); + + const section = screen.getByTestId('section-shared-in-account'); + fireEvent.click(within(section).getByText('Team mailbox')); + fireEvent.click(section.querySelector('[data-mailbox-id="team-inbox"] > button')!); + + expect(onLoginSharedMailboxSelect).toHaveBeenCalledWith('b', 'team-inbox'); + expect(onAccountMailboxSelect).not.toHaveBeenCalled(); + }); + + it('opens the active login\'s shared mailbox like its other folders', () => { + useAuthStore.setState({ activeAccountId: 'b' } as never); + const onAccountMailboxSelect = vi.fn(); + render( + , + ); + + const section = screen.getByTestId('section-shared-in-account'); + fireEvent.click(within(section).getByText('Team mailbox')); + fireEvent.click(section.querySelector('[data-mailbox-id="team-inbox"] > button')!); + + expect(onAccountMailboxSelect).toHaveBeenCalledWith(null, 'team-inbox'); + }); + + it('hides a shared account hidden for that login only', () => { + useSettingsStore.setState({ hiddenSharedMailAccounts: { b: ['team'] } } as never); + render( + , + ); + + expect(screen.queryByTestId('section-shared-in-account')).toBeNull(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 111a8e8ed..a76ce0940 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -160,6 +160,12 @@ interface SidebarProps { accountMailboxes?: Record; viewingAccountId?: string | null; onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void; + /** Shared mailboxes of each non-active login, keyed by login (#1486). */ + accountSharedMailboxes?: Record; + /** A shared mailbox of a non-active login was picked: the caller switches + * to that login and opens it, since shared mail is reached through the + * active login. */ + onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void; } const ROW_PX_BASE = 8; @@ -557,6 +563,8 @@ function SidebarRow({ const isSystemFolder = (node: MailboxNode) => !!node.role; /** Key of the single-account "Your folders" section in the collapsed set. */ const OWN_FOLDERS_KEY = '__own__'; +/** Prefix of a Pro account group's "Shared" section in the same set. */ +const SHARED_KEY_PREFIX = '__shared__:'; function SidebarSectionHeader({ label, @@ -677,6 +685,7 @@ function MailboxTreeItem({ colorful, onContextMenu, dragAccountId = null, + pickOnly = false, sharedAccountName, renderAfterChild, }: { @@ -693,6 +702,9 @@ function MailboxTreeItem({ * Travels in the folder-drag payload so a folder tab fetches through the * right client, and tells a message drop which login the folder is in. */ dragAccountId?: string | null; + /** Listed for opening only: no drag, no message drop, no mute state. For + * another login's shared mailboxes, which the active login can't reach. */ + pickOnly?: boolean; /** Set when this node is a shared account's inbox standing for the whole * account (compact shared mailboxes, #1342): the row is named after the * account and carries its icon. */ @@ -711,14 +723,14 @@ function MailboxTreeItem({ const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); // Only for the active login's folders: its client tells a missing // accountId (own mailbox) apart. - const muted = useMutedMailbox(isVirtualNode || dragAccountId ? null : node); + const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node); const isEmbedded = useIsEmbedded(); // Pro shell only: folder rows are drag sources so a folder can be dropped // onto a tab strip / pane and open as its own tab. Native HTML5 DnD - the // Pro shell's drop targets live outside any dnd-kit context. const folderDragHandlers: Record | undefined = - isEmbedded && !isVirtualNode + isEmbedded && !isVirtualNode && !pickOnly ? { draggable: true, onDragStart: (e: React.DragEvent) => { @@ -768,15 +780,15 @@ function MailboxTreeItem({ onUnreadClick={() => onUnreadFilterClick?.(node.id)} isCollapsed={isCollapsed} dropHandlers={ - folderDragHandlers || globalDragging + folderDragHandlers || (globalDragging && !pickOnly) ? { ...(folderDragHandlers ?? {}), - ...(globalDragging ? (dropHandlers as Record) : {}), + ...(globalDragging && !pickOnly ? (dropHandlers as Record) : {}), } : undefined } - isValidDropTarget={isValidDropTarget} - isInvalidDropTarget={isInvalidDropTarget} + isValidDropTarget={isValidDropTarget && !pickOnly} + isInvalidDropTarget={isInvalidDropTarget && !pickOnly} onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined} muted={muted} /> @@ -794,6 +806,7 @@ function MailboxTreeItem({ colorful={colorful} onContextMenu={onContextMenu} dragAccountId={dragAccountId} + pickOnly={pickOnly} /> {renderAfterChild?.(child)} @@ -1129,6 +1142,8 @@ function SidebarImpl({ accountMailboxes, viewingAccountId = null, onAccountMailboxSelect, + accountSharedMailboxes, + onLoginSharedMailboxSelect, mvShell = false, }: SidebarProps) { const router = useRouter(); @@ -1395,7 +1410,15 @@ function SidebarImpl({ const tree = buildMailboxTree(accountMailboxList).filter( (n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n)) ); - return { account, isActive, tree }; + // The active login's shared mailboxes are in `mailboxes`; the others' + // are kept beside their own folders (#1486). + const hiddenIds = hiddenSharedMailIds(hiddenSharedMailAccounts, account.id); + const shared = isActive + ? sharedAccounts + : buildMailboxTree(accountSharedMailboxes?.[account.id] ?? []).filter((n) => + n.id.startsWith('shared-account-') + && isSharedMailAccountVisible(n.accountId, showSharedMailAccounts, hiddenIds)); + return { account, isActive, tree, shared }; }) : []; @@ -1483,6 +1506,148 @@ function SidebarImpl({ ); }; + // The Shared section: the group and delegated mailboxes a login can reach. + // With several logins in the Pro sidebar each one gets its own, inside its + // group (#1486). Only the active login's has menus, its Scheduled rows + // and its shared tags, as with the folders of the other logins. + const renderSharedSection = ( + accounts: MailboxNode[], + { + sub = false, + expanded, + onToggle, + selected, + onSelect, + active = true, + }: { + sub?: boolean; + expanded: boolean; + onToggle: () => void; + selected: string; + onSelect?: (mailboxId: string) => void; + active?: boolean; + }, + ) => { + if (accounts.length === 0) return null; + return ( +
+ + {((expanded && !isCollapsed) || isCollapsed) && ( + <> + {accounts.map((account) => { + const accountExpanded = expandedSharedAccounts.has(account.id); + const menuAccountId = sharedAccountMenuId(account.accountId); + const compact = compactSharedMailboxes ? compactSharedMailbox(account) : null; + if (compact) { + // A row without folders below it has no chevron, so what + // else hangs below it (Scheduled, tags) stays shown. + const compactExpanded = !isCollapsed + && (expandedFolders.has(compact.id) || compact.children.length === 0); + const scheduledCount = active && menuAccountId ? (scheduledTotalByAccount?.[menuAccountId] ?? 0) : 0; + const scheduledRow = () => scheduledCount > 0 + && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId, depth: 1 }); + // Scheduled goes after Drafts, as under a header row; with + // Drafts left out (empty), after the other folders. + const hasDrafts = compact.children.some((child) => child.role === 'drafts'); + // "Mark subfolders read" covers the inbox's own subfolders + // only, not the account's folders listed below the row. + const inboxHasSubfolders = (account.children.find((child) => child.id === compact.id)?.children.length ?? 0) > 0; + return ( +
+ handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders) : undefined} + pickOnly={!active} + renderAfterChild={(child) => child.role === 'drafts' && scheduledRow()} + /> + {compactExpanded && !hasDrafts && scheduledRow()} + {active && compactExpanded && menuAccountId && activeAccountId && ( + + )} +
+ ); + } + return ( +
+ toggleSharedAccount(account.id)} + isCollapsed={isCollapsed} + sub + icon={} + testId="section-shared-account" + onContextMenu={active && menuAccountId + ? (e) => handleSharedAccountContextMenu(e, menuAccountId) + : undefined} + /> + {accountExpanded && !isCollapsed && ( + <> + {account.children.map((child) => ( + + + {active && child.role === 'drafts' && menuAccountId + && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} + + ))} + {active && menuAccountId && !account.children.some((child) => child.role === 'drafts') + && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} + {active && menuAccountId && activeAccountId && ( + + )} + + )} +
+ ); + })} + + )} +
+ ); + }; + // The Tags section. It follows the shared mailboxes unless the admin policy // lists it above them, so a long list of shared mailboxes does not push the // tags out of sight. @@ -1871,7 +2036,7 @@ function SidebarImpl({ )} {useMultiAccount ? ( - accountGroups.map(({ account, isActive, tree }) => { + accountGroups.map(({ account, isActive, tree, shared }) => { const expanded = !collapsedAccountGroups.has(account.id); const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id; return ( @@ -1921,6 +2086,17 @@ function SidebarImpl({ active: isActive, dragAccountId: isActive ? null : account.id, })} + {renderSharedSection(shared, { + sub: true, + expanded: !collapsedCustomFolders.has(`${SHARED_KEY_PREFIX}${account.id}`), + onToggle: () => toggleCustomFolders(`${SHARED_KEY_PREFIX}${account.id}`), + // Another login's shared mailbox is opened by switching to that login. + selected: selectedKeyword || !isViewing || !isActive ? "" : selectedMailbox, + onSelect: isActive + ? (mailboxId) => onAccountMailboxSelect?.(null, mailboxId) + : (mailboxId) => onLoginSharedMailboxSelect?.(account.id, mailboxId), + active: isActive, + })} )} @@ -2053,120 +2229,12 @@ function SidebarImpl({ {tagsAboveShared && renderTags()} - {!useMultiAccount && showSecondary && sharedAccounts.length > 0 && ( -
- - {((sharedExpanded && !isCollapsed) || isCollapsed) && ( - <> - {sharedAccounts.map((account) => { - const accountExpanded = expandedSharedAccounts.has(account.id); - const menuAccountId = sharedAccountMenuId(account.accountId); - const compact = compactSharedMailboxes ? compactSharedMailbox(account) : null; - if (compact) { - // A row without folders below it has no chevron, so what - // else hangs below it (Scheduled, tags) stays shown. - const compactExpanded = !isCollapsed - && (expandedFolders.has(compact.id) || compact.children.length === 0); - const scheduledCount = menuAccountId ? (scheduledTotalByAccount?.[menuAccountId] ?? 0) : 0; - const scheduledRow = () => scheduledCount > 0 - && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId, depth: 1 }); - // Scheduled goes after Drafts, as under a header row; with - // Drafts left out (empty), after the other folders. - const hasDrafts = compact.children.some((child) => child.role === 'drafts'); - // "Mark subfolders read" covers the inbox's own subfolders - // only, not the account's folders listed below the row. - const inboxHasSubfolders = (account.children.find((child) => child.id === compact.id)?.children.length ?? 0) > 0; - return ( -
- handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders)} - renderAfterChild={(child) => child.role === 'drafts' && scheduledRow()} - /> - {compactExpanded && !hasDrafts && scheduledRow()} - {compactExpanded && menuAccountId && activeAccountId && ( - - )} -
- ); - } - return ( -
- toggleSharedAccount(account.id)} - isCollapsed={isCollapsed} - sub - icon={} - testId="section-shared-account" - onContextMenu={menuAccountId - ? (e) => handleSharedAccountContextMenu(e, menuAccountId) - : undefined} - /> - {accountExpanded && !isCollapsed && ( - <> - {account.children.map((child) => ( - - - {child.role === 'drafts' && menuAccountId - && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} - - ))} - {menuAccountId && !account.children.some((child) => child.role === 'drafts') - && renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })} - {menuAccountId && activeAccountId && ( - - )} - - )} -
- ); - })} - - )} -
- )} + {!useMultiAccount && showSecondary && renderSharedSection(sharedAccounts, { + expanded: sharedExpanded, + onToggle: toggleShared, + selected: selectedKeyword ? "" : selectedMailbox, + onSelect: onMailboxSelect, + })} {!tagsAboveShared && renderTags()} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 9b37c8029..3718674e6 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -96,6 +96,7 @@ import { FilterPanelHost } from "@/components/search/filter-panel-host"; import { SearchFilterPanel, SELECT_CLASS } from "@/components/search/search-filter-panel"; import type { ContactSuggestion } from "@/lib/search-suggestions"; import type { Attachment, SendEmailResult } from "@/lib/jmap/types"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments"; import { useSearchHistoryStore } from "@/stores/search-history-store"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; @@ -503,6 +504,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { batchMarkAsSpam, batchUndoSpam, accountMailboxes, + accountSharedMailboxes, viewingAccountId, selectAccountMailbox, setViewingAccount, @@ -591,6 +593,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { batchMarkAsSpam: state.batchMarkAsSpam, batchUndoSpam: state.batchUndoSpam, accountMailboxes: state.accountMailboxes, + accountSharedMailboxes: state.accountSharedMailboxes, viewingAccountId: state.viewingAccountId, selectAccountMailbox: state.selectAccountMailbox, setViewingAccount: state.setViewingAccount, @@ -3309,10 +3312,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return true; }; - const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => { + // `activeClient` stands in for `client` when the caller has just switched + // logins and this render's `client` is still the previous one. + const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string, activeClient?: IJMAPClient) => { const viewingClient = accountId ? useAuthStore.getState().getClientForAccount(accountId) ?? client - : client; + : activeClient ?? client; selectAccountMailbox(accountId, mailboxId); selectEmail(null); if (isMobile) { @@ -3337,6 +3342,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }; + // Pro sidebar: a shared mailbox listed under another login (#1486). Shared + // mail is read through the active login - its folder list, actions and + // tags all assume it - so switch to that login first, wait for its folders + // and open the mailbox there, as a folder link to another login does. + const handleLoginSharedMailboxSelect = async (loginId: string, mailboxId: string) => { + if (useAuthStore.getState().activeAccountId !== loginId) { + const before = useEmailStore.getState().mailboxes; + await switchAccount(loginId); + // Overtaken or failed: the folders on screen are not that login's. + if (useAuthStore.getState().activeAccountId !== loginId) return; + const fresh = await freshMailboxes(before); + if (useAuthStore.getState().activeAccountId !== loginId) return; + if (!fresh.some((mb) => mb.id === mailboxId)) return; + } + await handleAccountMailboxSelect(null, mailboxId, useAuthStore.getState().client ?? undefined); + }; + const handleMailboxSelect = async (mailboxId: string) => { // A shared account's Scheduled row arrives as "__scheduled__:"; // the store only ever sees the plain virtual id plus the account scope. @@ -5011,6 +5033,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { mvShell={mvShell} multiAccountMode={isEmbedded} accountMailboxes={accountMailboxes} + accountSharedMailboxes={accountSharedMailboxes} + onLoginSharedMailboxSelect={handleLoginSharedMailboxSelect} viewingAccountId={viewingAccountId} /> diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts index 854c2dba3..60c8cf455 100644 --- a/hooks/use-pro-multi-account-mailboxes.ts +++ b/hooks/use-pro-multi-account-mailboxes.ts @@ -45,6 +45,9 @@ export function useProMultiAccountMailboxes(): void { const connected = connectedSignature ? connectedSignature.split("\n") : []; if (!proInterface && !isEmbedded && !(mountainView && connected.length > 1)) return; if (connected.length === 0) return; + // The Pro sidebar lists each login's shared mailboxes under it (#1486); + // Mountain View's account popover only counts the login's own unread. + const withShared = proInterface || isEmbedded; if (lastActive.current !== null && lastActive.current !== activeAccountId) { invalidateUnifiedMailboxes(); @@ -60,7 +63,7 @@ export function useProMultiAccountMailboxes(): void { const client = getClientForAccount(id); if (!client || loaded.current.get(id) === client) continue; loaded.current.set(id, client); - void loadAccountMailboxes(client, id).then((ok) => { + void loadAccountMailboxes(client, id, { withShared }).then((ok) => { // Try a failed login again on the next run, as the identities hook does. if (!ok && loaded.current.get(id) === client) loaded.current.delete(id); }); diff --git a/stores/__tests__/email-store-unified-mailbox-cache.test.ts b/stores/__tests__/email-store-unified-mailbox-cache.test.ts index 0b7959bff..ae9fef4cb 100644 --- a/stores/__tests__/email-store-unified-mailbox-cache.test.ts +++ b/stores/__tests__/email-store-unified-mailbox-cache.test.ts @@ -119,6 +119,22 @@ describe('unified scope folder lists', () => { expect(useEmailStore.getState().accountMailboxes['jmap-b']?.[0].id).toBe('b-inbox'); }); + it('keeps a login\'s shared mailboxes apart from its own folders (#1486)', async () => { + const b = fakeClient('b'); + const shared = { ...inbox('g1-inbox', 2), isShared: true, accountId: 'g1', accountName: 'Support' } as Mailbox; + b.getAllMailboxes.mockResolvedValue([inbox('b-inbox', 1), shared]); + connect({ b }); + useEmailStore.setState({ accountSharedMailboxes: {} }); + + await loadAccountMailboxes(b as unknown as IJMAPClient, 'b', { withShared: true }); + + const state = useEmailStore.getState(); + expect(b.getAllMailboxes).toHaveBeenCalledTimes(1); + // Role lookups in b's own list must never land on the shared inbox. + expect(state.accountMailboxes.b.map((m) => m.id)).toEqual(['b-inbox']); + expect(state.accountSharedMailboxes.b.map((m) => m.id)).toEqual(['g1-inbox']); + }); + it('retries a login whose fetch failed', async () => { const a = fakeClient('a'); a.getMailboxes.mockRejectedValueOnce(new Error('offline')); diff --git a/stores/email-store.ts b/stores/email-store.ts index 8f4583738..902cb5428 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -212,6 +212,13 @@ interface EmailStore { * `mailboxes` for back-compat with the single-account view. */ accountMailboxes: Record; + /** + * The shared (group/delegated) mailboxes each login can reach, keyed by + * login. Kept apart from `accountMailboxes`, which holds a login's own + * folders only, so nothing that looks up a role there picks a shared one. + * Only the Pro sidebar reads it, to list them under each login (#1486). + */ + accountSharedMailboxes: Record; /** * When set, the mail view is reading from this account instead of the * global active one. `null` means "use the global active account" - i.e. @@ -1853,12 +1860,24 @@ async function unifiedMailboxesFor( * the unified scope uses, so the two never fetch the same list twice and a * list already there is not fetched again until that login reports a change. */ -export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise { +export async function loadAccountMailboxes( + client: IJMAPClient, + accountId: string, + opts: { withShared?: boolean } = {}, +): Promise { + const withShared = opts.withShared ?? false; try { - const { mailboxes, fresh } = await unifiedMailboxesFor(client, false); - if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return true; + const { mailboxes, fresh } = await unifiedMailboxesFor(client, withShared); + const state = useEmailStore.getState(); + if (!fresh && state.accountMailboxes[accountId] && (!withShared || state.accountSharedMailboxes[accountId])) return true; + // With the shared ones the list is getAllMailboxes: own folders go where + // they always went, the shared ones beside them. + const own = withShared ? mailboxes.filter((m) => !m.isShared) : mailboxes.slice(); useEmailStore.setState((state) => ({ - accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes.slice() }, + accountMailboxes: { ...state.accountMailboxes, [accountId]: own }, + ...(withShared + ? { accountSharedMailboxes: { ...state.accountSharedMailboxes, [accountId]: mailboxes.filter((m) => m.isShared) } } + : {}), })); return true; } catch (error) { @@ -3238,6 +3257,7 @@ export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], accountMailboxes: {}, + accountSharedMailboxes: {}, viewingAccountId: null, selectedEmail: null, selectedMailbox: "",