fix: show shared mailboxes under each account when several are signed in #1486

This commit is contained in:
Linus Rath
2026-11-01 00:24:57 +01:00
parent a03f76ec85
commit 4842e08cde
6 changed files with 412 additions and 129 deletions
@@ -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(
<Sidebar
mailboxes={activeMailboxes}
selectedMailbox="a-inbox"
multiAccountMode
accountMailboxes={otherOwn}
accountSharedMailboxes={otherShared}
/>,
);
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(
<Sidebar
mailboxes={[own('b-inbox', 'Inbox', 'inbox'), shared('team', 'Team mailbox')]}
selectedMailbox="b-inbox"
multiAccountMode
accountMailboxes={{ a: activeMailboxes }}
/>,
);
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(
<Sidebar
mailboxes={activeMailboxes}
selectedMailbox="a-inbox"
multiAccountMode
accountMailboxes={otherOwn}
accountSharedMailboxes={otherShared}
onAccountMailboxSelect={onAccountMailboxSelect}
onLoginSharedMailboxSelect={onLoginSharedMailboxSelect}
/>,
);
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(
<Sidebar
mailboxes={[own('b-inbox', 'Inbox', 'inbox'), shared('team', 'Team mailbox')]}
selectedMailbox="b-inbox"
multiAccountMode
accountMailboxes={{ a: activeMailboxes }}
onAccountMailboxSelect={onAccountMailboxSelect}
/>,
);
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(
<Sidebar
mailboxes={activeMailboxes}
selectedMailbox="a-inbox"
multiAccountMode
accountMailboxes={otherOwn}
accountSharedMailboxes={otherShared}
/>,
);
expect(screen.queryByTestId('section-shared-in-account')).toBeNull();
});
});
+190 -122
View File
@@ -160,6 +160,12 @@ interface SidebarProps {
accountMailboxes?: Record<string, Mailbox[]>;
viewingAccountId?: string | null;
onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void;
/** Shared mailboxes of each non-active login, keyed by login (#1486). */
accountSharedMailboxes?: Record<string, Mailbox[]>;
/** 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<string, unknown> | 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<string, unknown>) : {}),
...(globalDragging && !pickOnly ? (dropHandlers as Record<string, unknown>) : {}),
}
: 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)}
</Fragment>
@@ -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 (
<div data-testid={sub ? "section-shared-in-account" : undefined}>
<SidebarSectionHeader
label={t("shared")}
expanded={expanded}
onToggle={onToggle}
isCollapsed={isCollapsed}
sub={sub}
testId="section-shared"
/>
{((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 (
<div key={account.id} data-testid="compact-shared-mailbox">
<MailboxTreeItem
node={compact}
sharedAccountName={account.name}
selectedMailbox={selected}
expandedFolders={expandedFolders}
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? (e, node) => handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders) : undefined}
pickOnly={!active}
renderAfterChild={(child) => child.role === 'drafts' && scheduledRow()}
/>
{compactExpanded && !hasDrafts && scheduledRow()}
{active && compactExpanded && menuAccountId && activeAccountId && (
<SharedAccountTags
loginId={activeAccountId}
accountId={menuAccountId}
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
expandedTags={expandedTags}
onTagSelect={onTagSelect}
onToggleExpand={handleToggleTagExpand}
colorful={colorfulSidebarIcons}
/>
)}
</div>
);
}
return (
<div key={account.id}>
<SidebarSectionHeader
label={account.name}
expanded={accountExpanded}
onToggle={() => toggleSharedAccount(account.id)}
isCollapsed={isCollapsed}
sub
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
testId="section-shared-account"
onContextMenu={active && menuAccountId
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
: undefined}
/>
{accountExpanded && !isCollapsed && (
<>
{account.children.map((child) => (
<Fragment key={child.id}>
<MailboxTreeItem
node={child}
selectedMailbox={selected}
expandedFolders={expandedFolders}
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? handleMailboxContextMenu : undefined}
pickOnly={!active}
/>
{active && child.role === 'drafts' && menuAccountId
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
</Fragment>
))}
{active && menuAccountId && !account.children.some((child) => child.role === 'drafts')
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
{active && menuAccountId && activeAccountId && (
<SharedAccountTags
loginId={activeAccountId}
accountId={menuAccountId}
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
expandedTags={expandedTags}
onTagSelect={onTagSelect}
onToggleExpand={handleToggleTagExpand}
colorful={colorfulSidebarIcons}
/>
)}
</>
)}
</div>
);
})}
</>
)}
</div>
);
};
// 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 && (
<div>
<SidebarSectionHeader
label={t("shared")}
expanded={sharedExpanded}
onToggle={toggleShared}
isCollapsed={isCollapsed}
testId="section-shared"
/>
{((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 (
<div key={account.id} data-testid="compact-shared-mailbox">
<MailboxTreeItem
node={compact}
sharedAccountName={account.name}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={(e, node) => handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders)}
renderAfterChild={(child) => child.role === 'drafts' && scheduledRow()}
/>
{compactExpanded && !hasDrafts && scheduledRow()}
{compactExpanded && menuAccountId && activeAccountId && (
<SharedAccountTags
loginId={activeAccountId}
accountId={menuAccountId}
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
expandedTags={expandedTags}
onTagSelect={onTagSelect}
onToggleExpand={handleToggleTagExpand}
colorful={colorfulSidebarIcons}
/>
)}
</div>
);
}
return (
<div key={account.id}>
<SidebarSectionHeader
label={account.name}
expanded={accountExpanded}
onToggle={() => toggleSharedAccount(account.id)}
isCollapsed={isCollapsed}
sub
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
testId="section-shared-account"
onContextMenu={menuAccountId
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
: undefined}
/>
{accountExpanded && !isCollapsed && (
<>
{account.children.map((child) => (
<Fragment key={child.id}>
<MailboxTreeItem
node={child}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
{child.role === 'drafts' && menuAccountId
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
</Fragment>
))}
{menuAccountId && !account.children.some((child) => child.role === 'drafts')
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
{menuAccountId && activeAccountId && (
<SharedAccountTags
loginId={activeAccountId}
accountId={menuAccountId}
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
expandedTags={expandedTags}
onTagSelect={onTagSelect}
onToggleExpand={handleToggleTagExpand}
colorful={colorfulSidebarIcons}
/>
)}
</>
)}
</div>
);
})}
</>
)}
</div>
)}
{!useMultiAccount && showSecondary && renderSharedSection(sharedAccounts, {
expanded: sharedExpanded,
onToggle: toggleShared,
selected: selectedKeyword ? "" : selectedMailbox,
onSelect: onMailboxSelect,
})}
{!tagsAboveShared && renderTags()}
+26 -2
View File
@@ -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__:<accountId>";
// 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}
/>
</ErrorBoundary>
+4 -1
View File
@@ -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);
});
@@ -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'));
+24 -4
View File
@@ -212,6 +212,13 @@ interface EmailStore {
* `mailboxes` for back-compat with the single-account view.
*/
accountMailboxes: Record<string, Mailbox[]>;
/**
* 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<string, Mailbox[]>;
/**
* 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<boolean> {
export async function loadAccountMailboxes(
client: IJMAPClient,
accountId: string,
opts: { withShared?: boolean } = {},
): Promise<boolean> {
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<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
accountMailboxes: {},
accountSharedMailboxes: {},
viewingAccountId: null,
selectedEmail: null,
selectedMailbox: "",