Merge branch 'feat/do-ux-mail-accounts' into feat/do-ux-mail-fixes

# Conflicts:
#	components/email/email-list.tsx
This commit is contained in:
Linus Rath
2026-11-04 07:23:30 +01:00
63 changed files with 923 additions and 177 deletions
@@ -5,6 +5,8 @@ import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { useAppBadge } from '@/hooks/use-app-badge';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Mailbox } from '@/lib/jmap/types';
vi.mock('@/hooks/use-favicon-badge', () => ({
@@ -96,6 +98,51 @@ describe('FaviconBadge', () => {
expect(useAppBadgeMock).toHaveBeenCalledWith(4, true);
});
// Usability test 2026-11-04, mail-6: with two accounts the badge counted
// only the one on screen (11 instead of 31).
describe('with several accounts', () => {
const accounts = (ids: string[], connected = true) =>
ids.map((id) => ({ id, label: id, email: id, username: id, isConnected: connected }));
beforeEach(() => {
useAccountStore.setState({ accounts: accounts(['a', 'b', 'c']) } as never);
useAuthStore.setState({ activeAccountId: 'a' } as never);
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 11 })],
accountMailboxes: {
// The active login's cached copy is older; `mailboxes` is the live one.
a: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 2 })],
b: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 20 }), mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 50 })],
},
});
});
afterEach(() => {
useAccountStore.setState({ accounts: [] } as never);
useEmailStore.setState({ accountMailboxes: {} });
useSettingsStore.setState({ unreadBadgeAllAccounts: true });
});
it('adds up the inbox of every signed-in account', () => {
render(<FaviconBadge />);
// c's folders are not loaded yet and add nothing.
expect(useAppBadgeMock).toHaveBeenLastCalledWith(31, true);
expect(useFaviconBadgeMock).toHaveBeenLastCalledWith(31, true);
});
it('counts only the open account when the setting is off', () => {
useSettingsStore.setState({ unreadBadgeAllAccounts: false });
render(<FaviconBadge />);
expect(useAppBadgeMock).toHaveBeenLastCalledWith(11, true);
});
it('leaves out an account that is signed out', () => {
useAccountStore.setState({ accounts: [...accounts(['a']), ...accounts(['b'], false)] } as never);
render(<FaviconBadge />);
expect(useAppBadgeMock).toHaveBeenLastCalledWith(11, true);
});
});
it('badges zero when there is no inbox yet', () => {
useEmailStore.setState({ mailboxes: [] });
@@ -0,0 +1,71 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { EmailList } from '../email-list';
// Usability test 2026-11-04, mail-14: the Spam folder's banner said "empty the
// Junk folder", the server's name, while the sidebar calls it Spam.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key} ${JSON.stringify(values)}` : key),
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) }));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ stalwartFeaturesEnabled: false, isLoading: false }) }));
const initialEmail = useEmailStore.getState();
const initialAuth = useAuthStore.getState();
function mailbox(id: string, name: string, role: string): Mailbox {
return { id, name, role, accountId: 'c', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0,
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true } } as Mailbox;
}
function show(selected: string) {
const emails = [{ id: 'm1', threadId: 't1', blobId: 'b1', mailboxIds: { [selected]: true }, keywords: {}, subject: 'Hello',
receivedAt: '2026-10-01T00:00:00Z', from: [{ email: 'someone@example.org' }], size: 1, hasAttachment: false } as Email];
useEmailStore.setState({
emails,
mailboxes: [mailbox('junk', 'Junk', 'junk'), mailbox('trash', 'Deleted Items', 'trash')],
selectedMailbox: selected,
totalEmails: 1,
});
return render(<EmailList emails={emails} />);
}
beforeEach(() => {
const client = JMAPClient.withBearer('https://list.example.invalid', 'synthetic', 'someone@example.org');
Object.assign(client, { apiUrl: 'https://list.example.invalid/jmap', accountId: 'c' });
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ methodResponses: [] }))));
useAuthStore.setState({ activeAccountId: 'acc', client, getClientForAccount: () => client } as never);
useEmailStore.setState(initialEmail, true);
});
afterEach(() => {
useAuthStore.setState(initialAuth, true);
vi.unstubAllGlobals();
});
describe('EmailList empty-folder banner', () => {
it('names Spam as the sidebar does', () => {
show('junk');
expect(screen.getByText('empty_hint {"folder":"mailboxes.spam"}')).toBeTruthy();
expect(screen.queryByText(/Junk/)).toBeNull();
});
it('names Trash as the sidebar does', () => {
show('trash');
expect(screen.getByText('empty_hint {"folder":"mailboxes.trash"}')).toBeTruthy();
});
});
@@ -95,6 +95,14 @@ describe('ThreadListItem folder chip in search results (#1172)', () => {
expect(screen.getByTitle('Archive')).toBeTruthy();
});
// Usability test 2026-11-04, mail-14: the chip said the server's "Sent Items".
it('names a folder with a role the way the sidebar does', () => {
useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' });
renderThread(emails().map((e) => ({ ...e, sourceFolder: 'Sent Items', sourceFolderRole: 'sent' })));
expect(screen.getByTitle('mailboxes.sent')).toBeTruthy();
expect(screen.queryByTitle('Sent Items')).toBeNull();
});
it('shows the folder when the search covers all folders', () => {
useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: SEARCH_SCOPE_ALL_FOLDERS });
renderThread(emails());
+11 -3
View File
@@ -33,6 +33,8 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { useShallow } from "zustand/react/shallow";
import { useAuthStore } from "@/stores/auth-store";
import { useOpenEmailInWindow } from "@/hooks/use-popouts-available";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useSettingsStore } from "@/stores/settings-store";
import { useListRowShape } from "@/hooks/use-list-row-shape";
import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
@@ -43,6 +45,7 @@ import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEm
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { toast } from "@/stores/toast-store";
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
@@ -196,7 +199,10 @@ export function EmailList({
const tViewer = useTranslations('email_viewer');
const tSidebar = useTranslations('sidebar');
const tListExtra = useTranslations('mail_list_extra');
const tUnread = useTranslations('account_unread');
const mountainViewList = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
const isDesktopPane = useIsDesktop();
const isEmbeddedList = useIsEmbedded();
const undo = undoLabels(tNotifications);
const tRemove = useTranslations('email_list.confirm_remove');
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
@@ -1074,10 +1080,11 @@ export function EmailList({
);
// Room under the last row for the round Compose button, which would otherwise
// sit on that row's actions (Mountain View has no such button). A spacer and
// sit on that row's actions (Mountain View has no such button, and a desktop
// has Compose in the folder sidebar instead). A spacer and
// not padding on the list: padding is a minimum height the list can't go
// under, and on a short screen it pushed the rows over the tab bar.
const composeButtonRoom = !mountainViewList ? <div aria-hidden="true" className="h-20" /> : null;
const composeButtonRoom = !mountainViewList && (!isDesktopPane || isEmbeddedList) ? <div aria-hidden="true" className="h-20" /> : null;
return (
<TagDisplayContext.Provider value={tagDisplay}>
@@ -1212,7 +1219,8 @@ export function EmailList({
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 text-sm text-muted-foreground">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}</span>
{/* Named as in the sidebar ("Spam"), not by the server ("Junk"). */}
<span>{tUnread('empty_hint', { folder: localizeMailboxName(currentMailbox?.role, currentMailbox?.name ?? '', (k) => tSidebar(`mailboxes.${k}`)) })}</span>
</div>
<Button
variant="outline"
+9 -5
View File
@@ -39,6 +39,7 @@ import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { useSenderWarning } from "@/hooks/use-sender-warning";
import { brandIndicatorDomain } from "@/lib/email-headers";
import { useTranslations } from "next-intl";
import { localizeMailboxName } from "@/lib/mailbox-label";
/**
* Unread bullet in the row gutter.
@@ -310,7 +311,10 @@ function describeAccount(label: string | undefined, account: AccountEntry | unde
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
* from different folders. `email.sourceFolder` is stamped at fetch time.
*/
function SourceFolderTag({ name, tight }: { name: string; tight?: boolean }) {
function SourceFolderTag({ name: serverName, role, tight }: { name: string; role?: string; tight?: boolean }) {
// The sidebar's name for the folder: "Sent", not the server's "Sent Items".
const tSidebar = useTranslations("sidebar");
const name = localizeMailboxName(role, serverName, (key) => tSidebar(`mailboxes.${key}`));
return (
<span
className={cn("inline-flex shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground", tight ? "max-w-[4.5rem]" : "max-w-[8rem]")}
@@ -845,7 +849,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{showPaperclip && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} role={email.sourceFolderRole} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
@@ -923,7 +927,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} role={email.sourceFolderRole} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -1616,7 +1620,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} role={latestEmail.sourceFolderRole} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
@@ -1702,7 +1706,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} role={latestEmail.sourceFolderRole} tight={senderLineTight} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
+23 -2
View File
@@ -4,6 +4,10 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
import { useAppBadge } from "@/hooks/use-app-badge";
import { useShallow } from "zustand/react/shallow";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { totalInboxUnread } from "@/lib/account-inbox-unread";
/**
* Badges the browser-tab favicon, and — where supported — the installed PWA's
@@ -34,9 +38,26 @@ export function FaviconBadge() {
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
);
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
const allAccounts = useSettingsStore((s) => s.unreadBadgeAllAccounts);
// With several accounts the badge counts the inbox of each (on by default):
// new mail in an account that is not on screen still shows on the icon and
// in the desktop tray. The other accounts' folder lists are what the
// account switcher reads (useProMultiAccountMailboxes); one not loaded yet
// adds nothing.
const othersUnread = useOtherAccountsUnread(allAccounts);
const count = inboxUnread + othersUnread;
useFaviconBadge(inboxUnread, enabled);
useAppBadge(inboxUnread, enabled);
useFaviconBadge(count, enabled);
useAppBadge(count, enabled);
return null;
}
function useOtherAccountsUnread(enabled: boolean): number {
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const otherIds = useAccountStore(
useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])),
);
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
return totalInboxUnread(otherIds, accountMailboxes);
}
@@ -119,13 +119,17 @@ describe('AccountSwitcher under Mountain View', () => {
expect(screen.queryByTestId('all-inboxes')).toBeNull();
});
it('leaves the default layout listing every account, unread-free', () => {
// With several accounts the default layout shows the unread of each too
// (usability test 2026-11-04, mail-6): before, it listed them without.
it('leaves the default layout listing every account, each with its unread', () => {
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
render(<AccountSwitcher variant="header" onSelectAllInboxes={vi.fn()} />);
openPopover();
expect(screen.getAllByTestId('account-option')).toHaveLength(2);
const options = screen.getAllByTestId('account-option');
expect(options).toHaveLength(2);
expect(screen.queryByTestId('all-inboxes')).toBeNull();
expect(screen.queryByText('5')).toBeNull();
expect(within(options[0]).getByText('3')).toBeInTheDocument();
expect(within(options[1]).getByText('5')).toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// Usability test 2026-11-04, mail-6: with two accounts the new mail in the
// other one was nowhere to be seen. The switcher now lists the unread of every
// account, and the avatar carries a dot while another account has unread mail.
const { AccountSwitcher } = await import('../account-switcher');
const { useAccountStore } = await import('@/stores/account-store');
const { useAuthStore } = await import('@/stores/auth-store');
const { useEmailStore } = await import('@/stores/email-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const inbox = (unread: number): Mailbox[] =>
[{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 40, unreadEmails: unread }] as unknown as Mailbox[];
function account(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
label: `${id}@example.com`,
email: `${id}@example.com`,
username: `${id}@example.com`,
displayName: 'Robin Kessler',
serverUrl: 'https://mail.example.com',
avatarColor: '#336699',
cookieSlot: 0,
isConnected: true,
isDefault: id === 'work',
...overrides,
};
}
describe('AccountSwitcher unread per account', () => {
beforeEach(() => {
useAccountStore.setState({ accounts: [account('work'), account('home')] } as never);
useAuthStore.setState({ activeAccountId: 'work' } as never);
useEmailStore.setState({ mailboxes: inbox(11), accountMailboxes: { home: inbox(20) } } as never);
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
});
it('lists the unread of each account in the rail menu', () => {
render(<AccountSwitcher variant="rail" />);
fireEvent.click(screen.getByTestId('account-switcher'));
const [work, home] = screen.getAllByTestId('account-option');
expect(work).toHaveAttribute('data-account-id', 'work');
expect(within(work).getByText('11')).toBeInTheDocument();
expect(within(home).getByText('20')).toBeInTheDocument();
});
it('puts a dot on the avatar and says why while another account has unread mail', () => {
render(<AccountSwitcher variant="rail" />);
const button = screen.getByTestId('account-switcher');
expect(within(button).getByTestId('other-accounts-unread')).toBeInTheDocument();
expect(button.getAttribute('aria-label')).toContain('others');
});
it('shows no dot when the other accounts are read', () => {
useEmailStore.setState({ accountMailboxes: { home: inbox(0) } } as never);
render(<AccountSwitcher variant="rail" />);
expect(screen.queryByTestId('other-accounts-unread')).toBeNull();
});
it('shows no count with a single account', () => {
useAccountStore.setState({ accounts: [account('work')] } as never);
render(<AccountSwitcher variant="rail" />);
fireEvent.click(screen.getByTestId('account-switcher'));
expect(within(screen.getByTestId('account-option')).queryByText('11')).toBeNull();
});
it('gives two accounts with the same name different initials', () => {
useAccountStore.setState({
accounts: [account('work', { email: 'dso-ux-mail@example.org' }), account('home', { email: 'dso-ux-mail-b@example.org' })],
} as never);
render(<AccountSwitcher variant="rail" />);
fireEvent.click(screen.getByTestId('account-switcher'));
const [work, home] = screen.getAllByTestId('account-option');
expect(within(work).getByText('DM')).toBeInTheDocument();
expect(within(home).getByText('DB')).toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// Usability test 2026-11-04, mail-14: the Trash folder's menu was headed
// "Deleted Items", the server's name, while the sidebar says Trash.
vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () => null }));
const { MailboxContextMenu } = await import('../mailbox-context-menu');
const box = (id: string, name: string, role: string | null, parentId: string | null = null) =>
({ id, name, role, parentId, sortOrder: 1, totalEmails: 2, unreadEmails: 0, myRights: {} }) as unknown as Mailbox;
function open(mailbox: Mailbox, mailboxes: Mailbox[]) {
render(
<MailboxContextMenu
target={{ kind: 'mailbox', mailbox, hasChildren: false }}
position={{ x: 0, y: 0 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
/>,
);
}
describe('MailboxContextMenu header', () => {
it('names a system folder the way the sidebar does', () => {
const trash = box('trash', 'Deleted Items', 'trash');
open(trash, [trash]);
expect(screen.getByTitle('mailboxes.trash')).toBeTruthy();
expect(screen.queryByText('Deleted Items')).toBeNull();
});
it('keeps the path of a folder of your own', () => {
const parent = box('p', 'Projects', null);
const child = box('c', 'Alpha', null, 'p');
open(child, [parent, child]);
expect(screen.getByTitle('Projects › Alpha')).toBeTruthy();
});
});
@@ -0,0 +1,80 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// Usability test 2026-11-04: on a desktop the round Compose button covered the
// list's rows (mail-13), and the folder sidebar's header held a second account
// switcher beside the rail's avatar (mail-27). The header now holds Compose.
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 }),
}));
const { Sidebar } = await import('../sidebar');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useUIStore } = await import('@/stores/ui-store');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 9, unreadEmails: 4, sortOrder: 0, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const realMatchMedia = window.matchMedia;
function narrowWindow(narrow: boolean) {
window.matchMedia = ((query: string) => ({
matches: narrow && query.includes('64rem') && query.includes('<'),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
describe('folder sidebar header', () => {
beforeEach(() => {
useSettingsStore.setState({ interfaceLayout: 'default', enableUnifiedMailbox: false } as never);
useUIStore.setState({ sidebarCollapsed: false } as never);
narrowWindow(false);
});
afterEach(() => {
window.matchMedia = realMatchMedia;
});
it('starts with Compose on a desktop', () => {
const onCompose = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onCompose={onCompose} />);
fireEvent.click(screen.getByRole('button', { name: 'compose' }));
expect(onCompose).toHaveBeenCalledTimes(1);
});
it('keeps Compose as an icon when the sidebar is collapsed', () => {
useUIStore.setState({ sidebarCollapsed: true } as never);
const onCompose = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onCompose={onCompose} />);
fireEvent.click(screen.getByRole('button', { name: 'compose' }));
expect(onCompose).toHaveBeenCalledTimes(1);
});
it('has no account switcher of its own: the rail has the one', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onCompose={vi.fn()} />);
expect(screen.queryByTestId('account-switcher')).toBeNull();
});
it('leaves Compose to the round button where the sidebar is a drawer', () => {
narrowWindow(true);
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onCompose={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'compose' })).toBeNull();
});
});
@@ -68,19 +68,18 @@ describe('folder list from the keyboard', () => {
it('has no button inside another button', () => {
useSettingsStore.setState({ hideAccountSwitcher: true } as never);
const withUnread = mailboxes.map((m) => ({ ...m, unreadEmails: 3, totalEmails: 5 }) as Mailbox);
const { container } = render(<Sidebar mailboxes={withUnread} selectedMailbox="inbox" onUnreadFilterClick={() => {}} onCreateFolder={() => {}} />);
const { container } = render(<Sidebar mailboxes={withUnread} selectedMailbox="inbox" onCreateFolder={() => {}} />);
expect(container.querySelectorAll('button button, button [role="button"]')).toHaveLength(0);
});
it('keeps a one-digit unread count next to the total in its 24px target', () => {
// The count is text now (mail-8): the row's button is the keyboard target.
it('keeps the unread count out of the tab order', () => {
useSettingsStore.setState({ hideAccountSwitcher: true } as never);
const withUnread = mailboxes.map((m) => ({ ...m, unreadEmails: 4, totalEmails: 7 }) as Mailbox);
render(<Sidebar mailboxes={withUnread} selectedMailbox="inbox" onUnreadFilterClick={() => {}} />);
const unread = row('travel').querySelector<HTMLElement>('[data-testid="folder-counts"] button')!;
expect(unread.textContent).toBe('4');
expect(unread.className).toContain('min-w-6');
expect(unread.className).toContain('justify-end');
expect(unread.className).not.toContain('justify-center');
render(<Sidebar mailboxes={withUnread} selectedMailbox="inbox" />);
const counts = row('travel').querySelector<HTMLElement>('[data-testid="folder-counts"]')!;
expect(counts.textContent).toContain('4');
expect(counts.querySelector('button, [tabindex]')).toBeNull();
});
});
@@ -72,14 +72,17 @@ describe.each([
});
});
describe('folder row click area with the unread filter', () => {
it('filters to unread from the unread count without also opening the folder', () => {
// Usability test 2026-11-04, mail-8: the count was a button of its own that
// turned on an Unread filter and an "Inbox" search scope, both of which stayed
// and broke later searches. It is part of the row now and opens the folder.
describe('folder row click area on the unread count', () => {
it('opens the folder from the unread count', () => {
useSettingsStore.setState({ interfaceLayout: 'default', hideAccountSwitcher: true, enableUnifiedMailbox: false } as never);
const onMailboxSelect = vi.fn();
const onUnreadFilterClick = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} onUnreadFilterClick={onUnreadFilterClick} />);
fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"] button')!);
expect(onUnreadFilterClick).toHaveBeenCalledWith('receipts');
expect(onMailboxSelect).not.toHaveBeenCalled();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} />);
const counts = row('receipts').querySelector('[data-testid="folder-counts"]')!;
expect(counts.querySelector('button')).toBeNull();
fireEvent.click(counts.firstElementChild!);
expect(onMailboxSelect).toHaveBeenCalledWith('receipts');
});
});
+27 -13
View File
@@ -12,7 +12,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { HelpMenuItems } from "@/components/layout/help-links";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText } from "@/lib/account-utils";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds, accountRowText, accountAvatarName } from "@/lib/account-utils";
import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
@@ -40,10 +40,10 @@ interface AccountSwitcherProps {
allInboxesSelected?: boolean;
}
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" | "xl" }) {
function AccountAvatar({ account, accounts, size = "sm" }: { account: AccountEntry; accounts: AccountEntry[]; size?: "sm" | "md" | "xl" }) {
return (
<Avatar
name={account.displayName || account.label}
name={accountAvatarName(account, accounts)}
email={account.email || account.username}
size="sm"
className={cn(
@@ -74,6 +74,7 @@ export function AccountSwitcher({
allInboxesSelected = false,
}: AccountSwitcherProps) {
const t = useTranslations("sidebar");
const tUnread = useTranslations("account_unread");
const router = useRouter();
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -297,12 +298,16 @@ export function AccountSwitcher({
const unreadByAccount: Record<string, Mailbox[] | undefined> = activeAccountId
? { ...accountMailboxes, [activeAccountId]: ownMailboxes }
: accountMailboxes;
// With several accounts every list carries the unread of each, so the new
// mail in another account shows without switching to it.
const severalAccounts = accounts.length > 1;
const unreadFor = (account: AccountEntry) =>
mvPopover ? inboxUnread(unreadByAccount[account.id]) : undefined;
const allInboxesUnread = totalInboxUnread(
accounts.filter((account) => account.isConnected).map((account) => account.id),
unreadByAccount
);
mvPopover || severalAccounts ? inboxUnread(unreadByAccount[account.id]) : undefined;
const connectedIds = accounts.filter((account) => account.isConnected).map((account) => account.id);
const allInboxesUnread = totalInboxUnread(connectedIds, unreadByAccount);
// What waits in the accounts not on screen: a dot on the avatar says so.
const othersUnread = totalInboxUnread(connectedIds.filter((id) => id !== activeAccountId), unreadByAccount);
const othersUnreadText = othersUnread > 0 ? tUnread("others", { count: othersUnread }) : null;
return (
<>
@@ -320,15 +325,24 @@ export function AccountSwitcher({
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
title={variant !== "expanded" ? displayName : undefined}
aria-label={t("switch_account")}
title={variant !== "expanded" ? [displayName, othersUnreadText].filter(Boolean).join("\n") : undefined}
aria-label={othersUnreadText ? `${t("switch_account")}, ${othersUnreadText}` : t("switch_account")}
aria-expanded={open}
aria-haspopup="menu"
aria-controls={open ? menuId : undefined}
>
{activeAccount ? (
<>
<AccountAvatar account={activeAccount} size={variant === "expanded" ? "md" : "sm"} />
<span className="relative flex-shrink-0">
<AccountAvatar account={activeAccount} accounts={accounts} size={variant === "expanded" ? "md" : "sm"} />
{othersUnreadText && (
<span
aria-hidden="true"
data-testid="other-accounts-unread"
className="absolute -top-0.5 -end-0.5 w-2.5 h-2.5 rounded-full bg-primary ring-2 ring-background"
/>
)}
</span>
{variant === "expanded" && (
<>
<div className="min-w-0 flex-1">
@@ -382,7 +396,7 @@ export function AccountSwitcher({
{mvPopover && activeAccount && (
<div className="flex items-center gap-4 rounded-t-3xl bg-background px-4 pt-4 pb-3" data-mv-account-current="">
<AccountAvatar account={activeAccount} size="xl" />
<AccountAvatar account={activeAccount} accounts={accounts} size="xl" />
<div className="min-w-0 flex-1">
<p className="text-lg leading-6 truncate">{displayName}</p>
{displaySubtitle && <p className="text-sm text-muted-foreground truncate">{displaySubtitle}</p>}
@@ -455,7 +469,7 @@ export function AccountSwitcher({
disabled={isActive}
>
<div className="relative flex-shrink-0">
<AccountAvatar account={account} size="md" />
<AccountAvatar account={account} accounts={accounts} size="md" />
{isActive && (
<div className="absolute -bottom-0.5 -end-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-primary-foreground" />
+5 -1
View File
@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import { getMailboxPath } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
ContextMenu,
ContextMenuItem,
@@ -143,6 +144,7 @@ export function MailboxContextMenu({
notifications,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const tSidebar = useTranslations("sidebar");
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
@@ -245,7 +247,9 @@ export function MailboxContextMenu({
// A compact shared mailbox's row is named after its account, so is its menu.
const fullPath = target.sharedAccountId && mailbox.accountName
? mailbox.accountName
: getMailboxPath(mailbox, mailboxes);
: mailbox.role && !mailbox.parentId
? localizeMailboxName(mailbox.role, mailbox.name, (k) => tSidebar(`mailboxes.${k}`))
: getMailboxPath(mailbox, mailboxes);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
+2 -2
View File
@@ -19,7 +19,7 @@ import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getMaxAccounts } from "@/lib/account-utils";
import { accountAvatarName, getMaxAccounts } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
import { quotaLevel, type StorageQuota } from "@/lib/quota";
@@ -925,7 +925,7 @@ export function NavigationRail({
title={`${toUnicodeEmail(account.displayName || account.label)} (${toUnicodeEmail(account.email || account.username)})`}
>
<Avatar
name={account.displayName || account.label}
name={accountAvatarName(account, accounts)}
email={account.email || account.username}
size="sm"
disableFavicon
+37 -47
View File
@@ -35,6 +35,7 @@ import {
AlertTriangle,
NotebookPen,
Pencil,
PenSquare,
Plus,
CalendarClock,
BellOff,
@@ -83,7 +84,6 @@ import { trashAndJunkIds } from "@/lib/unified-mailbox";
import { toast } from "@/stores/toast-store";
import { toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { buildSettingsPath, SCHEDULED_MAILBOX_ID, scopedScheduledMailboxId } from "@/lib/deep-links";
import { useTour } from "@/components/tour/tour-provider";
@@ -116,7 +116,6 @@ interface SidebarProps {
*/
mvShell?: boolean;
onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
@@ -235,16 +234,13 @@ function SidebarRowCounts({
total,
role,
mailboxId,
onUnreadClick,
}: {
unread?: number;
total?: number;
role?: string | null;
mailboxId?: string;
isSelected: boolean;
onUnreadClick?: () => void;
}) {
const t = useTranslations('sidebar');
const tShell = useTranslations('shell');
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view');
@@ -287,26 +283,12 @@ function SidebarRowCounts({
const unreadClass = "text-xs font-semibold tabular-nums text-foreground";
const totalClass = "text-xs tabular-nums text-muted-foreground";
// Text, not a button: a click on the count opens the folder like the rest
// of the row. It used to turn on an Unread filter and an "Inbox" search
// scope that stayed and broke later searches (usability test 2026-11-04).
const unreadNode = unreadCount > 0 ? (
onUnreadClick ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUnreadClick();
}}
// 24px to hit, with a short count at the end of it, next to the
// slash and the total, not in the middle with a gap before them.
className={cn(unreadClass, "inline-flex items-center justify-end min-w-6 min-h-6 -my-1 cursor-pointer hover:underline rounded-sm focus-visible:outline-hidden")}
title={t('unread_filter', { count: unreadCount })}
aria-label={t('unread_filter', { count: unreadCount })}
>
{unreadCountStyle === 'dot' ? unreadDot : unreadCount}
</button>
) : (
// Said with the folder's name already.
<span className={unreadClass} aria-hidden="true">{unreadCountStyle === 'dot' ? unreadDot : unreadCount}</span>
)
// Said with the folder's name already.
<span className={unreadClass} aria-hidden="true">{unreadCountStyle === 'dot' ? unreadDot : unreadCount}</span>
) : null;
return (
@@ -367,7 +349,6 @@ interface SidebarRowProps {
hasChildren?: boolean;
isExpanded?: boolean;
onExpandToggle?: () => void;
onUnreadClick?: () => void;
isCollapsed: boolean;
dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean;
@@ -395,7 +376,6 @@ function SidebarRow({
hasChildren = false,
isExpanded = false,
onExpandToggle,
onUnreadClick,
isCollapsed,
dropHandlers,
isValidDropTarget,
@@ -542,8 +522,7 @@ function SidebarRow({
</>
)}
</button>
{/* Beside the row's button, not in it: the unread count is a button
of its own (it filters the folder to unread). */}
{/* Beside the row's button, not in it; a click on it opens the folder through the row. */}
{!isCollapsed && (
<SidebarRowCounts
unread={unread}
@@ -551,7 +530,6 @@ function SidebarRow({
role={testRole}
mailboxId={testMailboxId}
isSelected={isSelected}
onUnreadClick={onUnreadClick}
/>
)}
{hasMenu && !isCollapsed && coarsePointer && (
@@ -693,7 +671,6 @@ function MailboxTreeItem({
onMailboxSelect,
onToggleExpand,
isCollapsed,
onUnreadFilterClick,
colorful,
onContextMenu,
dragAccountId = null,
@@ -707,7 +684,6 @@ function MailboxTreeItem({
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
colorful: boolean;
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
/** Connected-account id this folder is listed under; null = active account.
@@ -792,7 +768,6 @@ function MailboxTreeItem({
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
onUnreadClick={() => onUnreadFilterClick?.(node.id)}
isCollapsed={isCollapsed}
dropHandlers={
folderDragHandlers || (globalDragging && !pickOnly)
@@ -817,7 +792,6 @@ function MailboxTreeItem({
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful}
onContextMenu={onContextMenu}
dragAccountId={dragAccountId}
@@ -1130,7 +1104,6 @@ function SidebarImpl({
onTagSelect,
onCompose,
onSidebarClose,
onUnreadFilterClick,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
@@ -1238,10 +1211,6 @@ function SidebarImpl({
const selectedKeywordAccountId = useEmailStore(s => s.selectedKeywordAccountId);
const personalSelectedKeyword = selectedKeywordAccountId ? null : selectedKeyword;
const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
// pane.
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
const showSharedMailAccounts = useSettingsStore(s => s.showSharedMailAccounts);
@@ -1516,7 +1485,6 @@ function SidebarImpl({
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? handleMailboxContextMenu : undefined}
dragAccountId={dragAccountId}
@@ -1589,7 +1557,6 @@ function SidebarImpl({
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}
@@ -1635,7 +1602,6 @@ function SidebarImpl({
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? handleMailboxContextMenu : undefined}
pickOnly={!active}
@@ -1953,10 +1919,38 @@ function SidebarImpl({
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
{/* Compose at the head of the folders on a desktop, as in Gmail
and Outlook; the round button over the list stays for phones
and tablets, where this pane is a drawer. The account switcher
that sat here was a second one beside the rail's avatar, which
every app shows (usability test 2026-11-04, mail-13, mail-27). */}
{!isCollapsed && !isDrawer && onCompose && (
<Button
onClick={onCompose}
className="flex-1 min-w-0 h-9 justify-start gap-2 px-3"
data-sidebar-compose=""
data-tour="compose-button"
title={t("compose_hint")}
>
<PenSquare className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("compose")}</span>
</Button>
)}
</div>
{isCollapsed && !isDrawer && onCompose && (
<div className="flex justify-center pb-2">
<IconButton
onClick={onCompose}
variant="default"
data-sidebar-compose=""
data-tour="compose-button"
label={t("compose")}
title={t("compose_hint")}
>
<PenSquare />
</IconButton>
</div>
)}
</div>
)}
@@ -2093,7 +2087,6 @@ function SidebarImpl({
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
dragAccountId={isActive ? null : account.id}
@@ -2160,7 +2153,6 @@ function SidebarImpl({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
@@ -2203,7 +2195,6 @@ function SidebarImpl({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
@@ -2245,7 +2236,6 @@ function SidebarImpl({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
@@ -32,7 +32,10 @@ vi.mock('@/components/email/email-composer', () => ({
</button>
),
}));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
// On a desktop Compose sits at the head of the folder sidebar (mail-13).
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose?: () => void }) => <button type="button" onClick={onCompose}>sidebar.compose</button>,
}));
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 }));
@@ -29,7 +29,10 @@ vi.mock('@/components/email/email-composer', () => ({
return <p>stub composer</p>;
},
}));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
// On a desktop Compose sits at the head of the folder sidebar (mail-13).
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose?: () => void }) => (device.isMobile ? null : <button type="button" onClick={onCompose}>sidebar.compose</button>),
}));
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 }));
@@ -64,9 +67,9 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI
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: () => {} }));
const device = vi.hoisted(() => ({ isMobile: true }));
const device = vi.hoisted(() => ({ isMobile: true, isTablet: false }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: device.isTablet, isDesktop: !device.isMobile && !device.isTablet }),
useMediaQuery: () => false,
useIsDesktop: () => false,
}));
@@ -109,6 +112,7 @@ beforeEach(() => {
});
afterEach(() => {
device.isMobile = true;
device.isTablet = false;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -123,6 +127,29 @@ describe('Mail after the tour on a phone', () => {
expect(listHidden()).toBe(false);
});
// Usability test 2026-11-04, mail-13: on a desktop the round button sat on
// the list's rows; Compose is at the head of the folder sidebar there.
it('draws the round Compose button on a phone, not on a desktop', () => {
const view = render(<MailApp />);
expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1);
device.isMobile = false;
view.rerender(<MailApp />);
expect(document.querySelector('[data-tour="compose-button"]')).toBeNull();
});
// mail-27: a tablet drew the header's ⋮ and the list's ⋮ one above the
// other, with the same entries; the header's one has them all now.
it('draws the list ⋮ on a desktop but not on a tablet, under the header ⋮', () => {
device.isMobile = false;
const view = render(<MailApp />);
expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull();
device.isTablet = true;
view.rerender(<MailApp />);
expect(screen.queryByTestId('mail-folder-menu')).toBeNull();
});
it('keeps an open composer in view when the window narrows to a phone', async () => {
device.isMobile = false;
useUIStore.setState({ activeView: 'list' });
+12 -51
View File
@@ -90,7 +90,7 @@ import {
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog";
import { MailFolderMenu, MailFolderMenuItems } from "@/components/email/mail-list-menus";
import { MailFolderMenu, MailFolderMenuItems, MailSortSubMenu } from "@/components/email/mail-list-menus";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
@@ -532,7 +532,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
isAdvancedSearchOpen,
setSearchFilters,
setSearchMailboxId,
scopeSearchToOpenFolder,
clearSearchFilters,
toggleAdvancedSearch,
advancedSearch,
@@ -621,7 +620,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
isAdvancedSearchOpen: state.isAdvancedSearchOpen,
setSearchFilters: state.setSearchFilters,
setSearchMailboxId: state.setSearchMailboxId,
scopeSearchToOpenFolder: state.scopeSearchToOpenFolder,
clearSearchFilters: state.clearSearchFilters,
toggleAdvancedSearch: state.toggleAdvancedSearch,
advancedSearch: state.advancedSearch,
@@ -3895,47 +3893,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
};
const handleUnreadFilterClick = async (mailboxId: string) => {
const isTogglingOff = selectedMailbox === mailboxId && !selectedKeyword && searchFilters.isUnread === true;
// Open the mailbox unless it is already the one listed. A tag view keeps
// selectedMailbox, so it counts as another view.
if (selectedMailbox !== mailboxId || selectedKeyword) {
if (isUnifiedView) exitUnifiedView();
setScheduledView(false);
selectMailbox(mailboxId);
selectEmail(null);
}
// On mobile, close sidebar and go to list view
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
// On tablet, show the list again
if (isTablet) {
setTabletListVisible(true);
setSidebarOpen(false);
}
if (isTogglingOff) {
// Disable the unread filter and show all emails
clearSearchFilters();
if (client) {
await fetchEmails(client, mailboxId);
}
} else {
// Enable unread filter, in this folder only
clearSearchFilters();
scopeSearchToOpenFolder();
setSearchFilters({ isUnread: true });
if (client) {
await advancedSearch(client);
}
}
};
const tCtxMenu = t;
const handleMarkFolderRead = async (mailboxId: string) => {
@@ -4758,7 +4715,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onOpenStarred: mvShell ? goToStarred : undefined,
onMailboxSelect: handleMailboxSelect,
onTagSelect: handleTagSelect,
onUnreadFilterClick: handleUnreadFilterClick,
onMarkFolderRead: handleMarkFolderRead,
onMarkFolderTreeRead: handleMarkFolderTreeRead,
onMarkAllFoldersRead: handleMarkAllFoldersRead,
@@ -5542,6 +5498,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
disabled={!client || isManualRefreshing}
onClick={() => { close(); void handleManualRefresh(); }}
/>
{/* A tablet shows the list's own ⋮ nowhere else (see below). */}
{isTablet && <MailSortSubMenu folderRole={conversationRole} onPicked={close} />}
<MailFolderMenuItems
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
@@ -5647,8 +5605,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</ToolbarSearchAction>
}
/>
{/* The folder's actions, as the Mountain View list ⋮ has them. */}
<MailFolderMenu
{/* The folder's actions, as the Mountain View list ⋮ has them. A
tablet has them in the header's ⋮ right above, and two ⋮
stacked read as one menu twice (mail-27). */}
{!isTablet && <MailFolderMenu
disabled={isScheduledView}
folderRole={conversationRole}
onRefresh={handleManualRefresh}
@@ -5656,7 +5616,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined}
onMarkAllFoldersRead={folderMenuMarkAllRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined}
/>
/>}
{/* Plugin buttons (top-bar-actions). On phones they sit in
the header's overflow menu instead (MobileHeader). */}
{!isMobile && (
@@ -5863,9 +5823,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</div>
</div>
{/* Floating Compose Button - on desktop Mountain View puts Compose
at the top of the navigation rail instead. */}
{!mvShell && <Button
{/* Floating Compose Button - on desktop the folder sidebar has
Compose at its top instead (and Mountain View its rail), so the
button no longer sits on the list's rows. */}
{!mvShell && (isMobile || isTablet || isEmbedded) && <Button
onClick={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
+4 -12
View File
@@ -188,7 +188,7 @@ export function LayoutSection() {
/** The Appearance page's Sidebar section: what the folder sidebar and rail show. */
export function SidebarSection() {
const t = useTranslations('settings.appearance');
const { hideAccountSwitcher, showRailAccountList, colorfulSidebarIcons, showFolderTotalCount, updateSetting } = useSettingsStore();
const { showRailAccountList, colorfulSidebarIcons, showFolderTotalCount, updateSetting } = useSettingsStore();
const accounts = useAccountStore(s => s.accounts);
const multiAccount = accounts.length > 1;
@@ -208,17 +208,9 @@ export function SidebarSection() {
/>
</SettingItem>
{/* The account switcher and the rail avatars only mean something with
a second account; a single-account user never sees either. */}
{multiAccount && (
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
)}
{/* The rail avatars only mean something with a second account. The
sidebar's own account switcher is gone (the rail's avatar is the
one), and with it the switch that hid it. */}
{multiAccount && (
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { Loader2, RefreshCw, Volume2, XCircle } from '@/components/icons';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { MultiAccountPushSettings } from './multi-account-push';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
@@ -51,6 +52,8 @@ type DevicesState =
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const tAppearance = useTranslations('settings.appearance');
const tUnread = useTranslations('account_unread');
const severalAccounts = useAccountStore((s) => s.accounts.length > 1);
const tThirdParty = useTranslations('third_party');
// The relay list depends on it; re-render once the config is known (#903).
const noThirdParty = useNoThirdPartyTraffic();
@@ -62,6 +65,7 @@ export function NotificationSettings() {
calendarNotificationsEnabled,
calendarNotificationSound,
faviconUnreadBadge,
unreadBadgeAllAccounts,
pushRelayUrl,
updateSetting,
} = useSettingsStore();
@@ -353,6 +357,17 @@ export function NotificationSettings() {
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
/>
</SettingItem>
{/* Only means something with a second account. */}
{severalAccounts && (
<SettingItem label={tUnread('badge_all_accounts.label')} description={tUnread('badge_all_accounts.description')}>
<ToggleSwitch
checked={unreadBadgeAllAccounts}
onChange={(checked) => updateSetting('unreadBadgeAllAccounts', checked)}
disabled={!faviconUnreadBadge}
/>
</SettingItem>
)}
</SettingsSection>
<SettingsSection id="calendar" title={t('calendar.title')} description={t('calendar.description')}>
@@ -8,6 +8,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { invalidateUnifiedMailboxes, useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { emitPushStateChange } from '@/lib/push-events';
/**
* The Pro shell needs every login's folders for its per-account tree. While
@@ -86,11 +87,45 @@ describe('useProMultiAccountMailboxes', () => {
expect(client('b').getAllMailboxes).toHaveBeenCalledTimes(2);
});
it('stays idle outside the Pro shell', () => {
it('stays idle outside the Pro shell with one account', () => {
useSettingsStore.setState({ proInterface: false } as never);
connected(['a', 'b']);
connected(['a']);
renderHook(() => useProMultiAccountMailboxes());
expect(client('a').getAllMailboxes).not.toHaveBeenCalled();
expect(client('a').getMailboxes).not.toHaveBeenCalled();
});
// Usability test 2026-11-04, mail-6: the account switcher and the badge
// count every account's unread in every layout, not only Mountain View.
it("loads each login's own folders outside the Pro shell once a second account is connected", async () => {
useSettingsStore.setState({ proInterface: false, interfaceLayout: 'default' } as never);
connected(['a', 'b']);
renderHook(() => useProMultiAccountMailboxes());
await waitFor(() => expect(useEmailStore.getState().accountMailboxes.b).toBeDefined());
expect(client('b').getMailboxes).toHaveBeenCalledTimes(1);
expect(client('b').getAllMailboxes).not.toHaveBeenCalled();
});
it("reads a background login's folders again after its push, once per burst", async () => {
useSettingsStore.setState({ proInterface: false, interfaceLayout: 'default' } as never);
connected(['a', 'b'], 'a');
renderHook(() => useProMultiAccountMailboxes());
await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(1));
vi.useFakeTimers();
try {
const change = { '@type': 'StateChange', changed: { b: { Mailbox: 's2', Email: 'e2' } } } as never;
// What the mail view's own push handler does first (createMailPushListener).
invalidateUnifiedMailboxes(client('b') as never);
emitPushStateChange(change, client('b') as never, 'background');
emitPushStateChange(change, client('b') as never, 'background');
// The active login's push is the mail view's business, not this hook's.
emitPushStateChange(change, client('a') as never, 'active');
await vi.advanceTimersByTimeAsync(2000);
} finally {
vi.useRealTimers();
}
await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(2));
expect(client('a').getMailboxes).toHaveBeenCalledTimes(1);
});
});
+39 -9
View File
@@ -7,6 +7,7 @@ import { invalidateUnifiedMailboxes, loadAccountMailboxes } from "@/stores/email
import { useSettingsStore } from "@/stores/settings-store";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events";
/**
* Keeps `useEmailStore.accountMailboxes` populated with one entry per
@@ -14,11 +15,14 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
* sidebar reads this cache to render a Thunderbird-style per-account folder
* tree (see [[project_pro_mode]]). Outside Pro the cache stays empty.
*
* Mountain View needs the same cache for a different reason: its account
* popover puts the unread waiting in each account next to that address, and
* the active login's folder list knows nothing about the others. It is only
* worth the extra round trips once a second account is connected - with one
* account the popover has nothing to compare.
* Every layout needs the same cache once a second account is connected: the
* account switcher puts the unread waiting in each account next to that
* address, and the app badge and the desktop tray count every account's
* inbox, while the active login's folder list knows nothing about the others.
* With one account there is nothing to compare, and nothing is fetched.
*
* A background login's push says when its counts moved; its list is then read
* again (one Mailbox/get), a moment later so a burst of changes costs one.
*
* Each login is loaded once per client, not once per connection: while a
* browser restores its logins they connect one after another, and reloading
@@ -31,7 +35,6 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
export function useProMultiAccountMailboxes(): void {
const isEmbedded = useIsEmbedded();
const proInterface = useSettingsStore((s) => s.proInterface);
const mountainView = useSettingsStore((s) => s.interfaceLayout === "mountain-view");
// The ids of the connected logins, as one string: `accounts` itself changes
// for display-name and login-time updates that call for no fetch at all.
const connectedSignature = useAccountStore((s) =>
@@ -43,10 +46,10 @@ export function useProMultiAccountMailboxes(): void {
useEffect(() => {
const connected = connectedSignature ? connectedSignature.split("\n") : [];
if (!proInterface && !isEmbedded && !(mountainView && connected.length > 1)) return;
if (!proInterface && !isEmbedded && connected.length < 2) 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.
// the account switcher and the badge only count the login's own unread.
const withShared = proInterface || isEmbedded;
if (lastActive.current !== null && lastActive.current !== activeAccountId) {
@@ -68,5 +71,32 @@ export function useProMultiAccountMailboxes(): void {
if (!ok && loaded.current.get(id) === client) loaded.current.delete(id);
});
}
}, [proInterface, isEmbedded, mountainView, connectedSignature, activeAccountId]);
}, [proInterface, isEmbedded, connectedSignature, activeAccountId]);
// The active login's counts live in `mailboxes` and follow its push there.
// A background login's change re-reads its list. The mail view's own push
// handler (createMailPushListener) drops that login's cached list on the
// push itself, so this is a fetch, or nothing when the all-accounts views
// have already read and published the list again.
useEffect(() => {
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const stop = subscribePushStateChanges((change, client, role) => {
if (role === "active" || !changesAnyType(change, ["Mailbox", "Email"])) return;
const id = [...loaded.current].find(([, c]) => c === client)?.[0];
if (!id || timers.has(id)) return;
timers.set(id, setTimeout(() => {
timers.delete(id);
if (loaded.current.get(id) !== client) return;
const withShared = useSettingsStore.getState().proInterface || isEmbedded;
void loadAccountMailboxes(client, id, { withShared });
}, BACKGROUND_REFRESH_DELAY_MS));
});
return () => {
stop();
for (const timer of timers.values()) clearTimeout(timer);
};
}, [isEmbedded]);
}
/** How long a background login's changes are gathered before its folders are read again. */
const BACKGROUND_REFRESH_DELAY_MS = 1500;
@@ -70,6 +70,8 @@ describe('searchAcrossAccounts', () => {
expect(byId['own-1']).toMatchObject({
accountId: 'login', sourceClientAccountId: 'login', sourceAccountId: 'me', sourceFolder: 'Inbox',
// The role lets the chip say the sidebar's name (usability test 2026-11-04, mail-14).
sourceFolderRole: 'inbox',
});
expect(byId['grp-1']).toMatchObject({
accountId: 'group', accountLabel: 'Team', sourceClientAccountId: 'login', sourceAccountId: 'group',
+16
View File
@@ -174,6 +174,22 @@ export function accountRowText(
return { title: name, subtitle: address };
}
/**
* The name an account's avatar takes its initials from. Usually the account's
* name. When another account has the same name, both would show the same two
* letters, so the initials come from the address instead: its local part split
* at dots, dashes, underscores and plus signs ("dso-ux-mail-b" gives "DB",
* "dso-ux-mail" gives "DM").
*/
export function accountAvatarName(account: NamedAccount, accounts: NamedAccount[]): string {
const name = account.displayName || account.label;
const { title, subtitle } = accountRowText(account, accounts);
if (subtitle !== null || title !== (account.email || account.username)) return name;
const local = title.split('@')[0] ?? title;
const words = local.split(/[.\-_+]+/).filter(Boolean);
return words.length > 0 ? words.join(' ') : name || title;
}
/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */
export interface OrderableAccount {
id: string;
+2
View File
@@ -97,6 +97,8 @@ export interface Email {
// views (All Mail, unified, cross-account) so the list can show where each
// message lives. Transient/client-only, not part of the JMAP object.
sourceFolder?: string;
// Role of that folder, so the list names it as the sidebar does ("Sent", not "Sent Items").
sourceFolderRole?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
+1 -1
View File
@@ -147,7 +147,7 @@ export const SHARED_SETTINGS = [
'filenameSpaceReplacement', 'filenameLowercase', 'filenameStripDiacritics', 'filenameCollapseSeparators', 'postExportAction',
'recipientMentionsEnabled', 'mentionAddsRecipientTo', 'rtlEditingSupport', 'quickReplyEnabled', 'replyIdentityMatch',
'attachmentReminderEnabled', 'attachmentReminderKeywords', 'emptySubjectWarningEnabled', 'placeholderWarningEnabled',
'notificationSoundChoice', 'pushNotifyInboxOnly', 'faviconUnreadBadge', 'calendarNotificationSound', 'doNotDisturb', 'visualAlerts',
'notificationSoundChoice', 'pushNotifyInboxOnly', 'faviconUnreadBadge', 'unreadBadgeAllAccounts', 'calendarNotificationSound', 'doNotDisturb', 'visualAlerts',
'calendarInvitationParsingEnabled', 'showTimeInMonthView', 'calendarTintedEvents', 'calendarMonthTimedEvents',
'calendarLimitHours', 'calendarDayStartHour', 'calendarDayEndHour', 'calendarHideNonWorkingDays',
'calendarFreeScroll', 'calendarHoverPreview', 'showTasksOnCalendar',
+4 -3
View File
@@ -171,7 +171,6 @@ const ENTRIES: SettingSchemaEntry[] = [
bool('showToolbarLabels', 'appearance', `${AP}toolbar_labels.label`),
bool('colorfulSidebarIcons', 'appearance', `${AP}colorful_sidebar_icons.label`),
bool('showFolderTotalCount', 'appearance', `${AP}show_folder_total_count.label`),
bool('hideAccountSwitcher', 'appearance', `${AP}hide_account_switcher.label`),
bool('showRailAccountList', 'appearance', `${AP}show_rail_account_list.label`),
bool('proInterface', 'appearance', `${AP}pro_interface.label`),
bool('enableUnifiedMailbox', 'appearance', `${AP}unified_mailbox.label`),
@@ -306,6 +305,7 @@ const ENTRIES: SettingSchemaEntry[] = [
choice('notificationSoundChoice', 'notifications', `${NO}sound_selection.choose`, opts(`${NO}sounds.`, NOTIFICATION_SOUND_IDS)),
bool('pushNotifyInboxOnly', 'notifications', `${NO}email.inbox_only`),
bool('faviconUnreadBadge', 'notifications', `${AP}favicon_unread_badge.label`),
bool('unreadBadgeAllAccounts', 'notifications', 'account_unread.badge_all_accounts.label'),
bool('calendarNotificationsEnabled', 'notifications', `${NO}calendar.enabled`),
bool('calendarNotificationSound', 'notifications', `${NO}calendar.sound`),
bool('doNotDisturb', 'accessibility', `${A}do_not_disturb.label`),
@@ -418,8 +418,9 @@ export const DENIED_SETTINGS: readonly string[] = [
'externalContentPolicy', 'trustedSenders', 'trustedSendersAddressBook',
// Free text that ends up in file names or in every sent mail.
'emailDownloadTemplate', 'attachmentDownloadTemplate', 'bundleDownloadTemplate', 'signatureDetails',
// Never read by the app (defaultReplyMode), or on the account page (the delimiter).
'defaultReplyMode', 'subAddressDelimiter',
// Never read by the app (defaultReplyMode, hideAccountSwitcher: the sidebar's
// switcher it hid is gone), or on the account page (the delimiter).
'defaultReplyMode', 'hideAccountSwitcher', 'subAddressDelimiter',
// Sends push metadata to another server.
'pushRelayUrl',
// Turning proofreading on sends what the user writes to a checker plugin.
+1 -1
View File
@@ -86,6 +86,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.notifications.email.enabled' },
{ label: 'settings.notifications.email.sound' },
{ label: 'settings.appearance.favicon_unread_badge.label' },
{ label: 'account_unread.badge_all_accounts.label', when: 'multiAccount' },
{ label: 'settings.notifications.calendar.title' },
{ label: 'settings.notifications.calendar.enabled' },
{ label: 'settings.notifications.sound_selection.title' },
@@ -109,7 +110,6 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'settings.appearance.show_folder_total_count.label' },
{ label: 'settings.appearance.sections.experimental', advanced: true },
{ label: 'settings.appearance.pro_interface.label', advanced: true },
{ label: 'settings.appearance.hide_account_switcher.label', when: 'multiAccount' },
{ label: 'settings.appearance.show_rail_account_list.label', when: 'multiAccount' },
],
accessibility: [
+18 -2
View File
@@ -65,13 +65,25 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
* match, or undefined if none of the account's known folders contain it.
*/
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
return sourceMailboxOf(email, mailboxes)?.name;
}
/**
* The role of that folder, so the list can name it the way the sidebar does
* (localizeMailboxName): "Sent", not the server's "Sent Items".
*/
export function resolveSourceFolderRole(email: Email, mailboxes: Mailbox[]): string | undefined {
return sourceMailboxOf(email, mailboxes)?.role ?? undefined;
}
function sourceMailboxOf(email: Email, mailboxes: Mailbox[]): Mailbox | undefined {
for (const m of mailboxes) {
// All fetch paths now namespace shared emails' mailboxIds to the store id
// (`${ownerId}:${origId}`), so matching `m.id` works for own and shared
// alike. The `originalId` check stays as a defensive fallback for any email
// that still carries a bare owner id. (#281 V3)
if (email.mailboxIds?.[m.id]) return m.name;
if (m.originalId && email.mailboxIds?.[m.originalId]) return m.name;
if (email.mailboxIds?.[m.id]) return m;
if (m.originalId && email.mailboxIds?.[m.originalId]) return m;
}
return undefined;
}
@@ -195,6 +207,7 @@ export async function fetchUnifiedEmails(
sourceClientAccountId: account.clientAccountId,
sourceAccountId: account.jmapAccountId,
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes),
}));
mergedEmails = mergedEmails.concat(decorated);
@@ -321,6 +334,7 @@ async function fanOutUnifiedQuery(
sourceClientAccountId: account.clientAccountId,
sourceAccountId: account.jmapAccountId,
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes),
}));
mergedEmails = mergedEmails.concat(decorated);
totalSum += result.total;
@@ -467,6 +481,7 @@ async function fanOutCrossQuery(
sourceClientAccountId: account.clientAccountId,
sourceAccountId: account.jmapAccountId,
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes),
}));
mergedEmails = mergedEmails.concat(decorated);
totalSum += result.total;
@@ -724,6 +739,7 @@ async function fanOutAccountQuery(
sourceClientAccountId: account.clientAccountId,
sourceAccountId: account.jmapAccountId,
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
sourceFolderRole: resolveSourceFolderRole(email, account.mailboxes),
}));
mergedEmails = mergedEmails.concat(decorated);
totalSum += result.total;
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "تم استخدام {percent}% من {total}",
"accessibility": "إمكانية الوصول"
},
"account_unread": {
"others": "غير مقروءة في الحسابات الأخرى: {count}",
"badge_all_accounts": {
"label": "احتساب البريد غير المقروء في كل الحسابات",
"description": "الرقم على علامة التبويب وأيقونة التطبيق وشريط النظام يجمع صناديق الوارد لكل الحسابات المسجّل دخولها."
},
"empty_hint": "يمكنك إفراغ مجلد {folder} لحذف جميع الرسائل نهائيًا."
},
"protocol_handlers": {
"title": "التطبيقات الافتراضية",
"description": "اختر ما إذا كانت روابط البريد والتقويم تُفتح في {appName}. من الناحية التقنية، يسجّل {appName} نفسه كمعالج بروتوكول لروابط mailto: وwebcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% de {total} utilitzat",
"accessibility": "Accessibilitat"
},
"account_unread": {
"others": "Sense llegir en altres comptes: {count}",
"badge_all_accounts": {
"label": "Compta el correu no llegit de tots els comptes",
"description": "El número de la pestanya, la icona de l'aplicació i la safata suma les safates d'entrada de tots els comptes amb la sessió iniciada."
},
"empty_hint": "Podeu buidar la carpeta {folder} per eliminar definitivament tots els missatges."
},
"protocol_handlers": {
"title": "Aplicacions predeterminades",
"description": "Trieu si els enllaços de correu i calendari s'obren al {appName}. Tècnicament, el {appName} es registra com a gestor de protocol per als enllaços mailto: i webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Využito {percent} % z {total}",
"accessibility": "Přístupnost"
},
"account_unread": {
"others": "Nepřečtené v ostatních účtech: {count}",
"badge_all_accounts": {
"label": "Počítat nepřečtenou poštu ve všech účtech",
"description": "Číslo na kartě, na ikoně aplikace a v oznamovací oblasti sčítá doručené složky všech přihlášených účtů."
},
"empty_hint": "Můžete vyprázdnit složku {folder} a trvale odstranit všechny zprávy."
},
"protocol_handlers": {
"title": "Výchozí aplikace",
"description": "Zvolte, zda se mají e-mailové a kalendářové odkazy otevírat v aplikaci {appName}. Technicky se {appName} registruje jako obslužná aplikace protokolu pro odkazy mailto: a webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% af {total} brugt",
"accessibility": "Tilgængelighed"
},
"account_unread": {
"others": "Ulæste i andre konti: {count}",
"badge_all_accounts": {
"label": "Tæl ulæst post i alle konti",
"description": "Tallet på fanen, appikonet og i proceslinjen lægger indbakkerne for alle loggede-ind konti sammen."
},
"empty_hint": "Du kan tømme mappen {folder} for permanent at fjerne alle beskeder."
},
"protocol_handlers": {
"title": "Standardapps",
"description": "Vælg om e-mail- og kalenderlinks åbnes i {appName}. Teknisk set registrerer {appName} sig som protokolhåndtering for mailto:- og webcal:-links.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent} % von {total} belegt",
"accessibility": "Barrierefreiheit"
},
"account_unread": {
"others": "Ungelesen in anderen Konten: {count}",
"badge_all_accounts": {
"label": "Ungelesene Mails aller Konten zählen",
"description": "Die Zahl am Tab, am App-Symbol und im Infobereich zählt die Posteingänge aller angemeldeten Konten zusammen."
},
"empty_hint": "Sie können den Ordner {folder} leeren, um alle Nachrichten dauerhaft zu entfernen."
},
"protocol_handlers": {
"title": "Standard-Apps",
"description": "Legen Sie fest, ob E-Mail- und Kalender-Links in {appName} geöffnet werden. Technisch registriert sich {appName} dafür als Protokoll-Handler für mailto: und webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% of {total} used",
"accessibility": "Accessibility"
},
"account_unread": {
"others": "Unread in other accounts: {count}",
"badge_all_accounts": {
"label": "Count unread mail in all accounts",
"description": "The number on the tab, the app icon and the tray adds up the inboxes of every signed-in account."
},
"empty_hint": "You can empty the {folder} folder to permanently remove all messages."
},
"protocol_handlers": {
"title": "Default apps",
"description": "Choose whether email and calendar links open in {appName}. Technically, {appName} registers as a protocol handler for mailto: and webcal: links.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% de {total} usado",
"accessibility": "Accesibilidad"
},
"account_unread": {
"others": "Sin leer en otras cuentas: {count}",
"badge_all_accounts": {
"label": "Contar el correo sin leer de todas las cuentas",
"description": "El número de la pestaña, el icono de la aplicación y la bandeja del sistema suma las bandejas de entrada de todas las cuentas con sesión iniciada."
},
"empty_hint": "Puede vaciar la carpeta {folder} para eliminar permanentemente todos los mensajes."
},
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
"description": "Elige si los enlaces de correo y calendario se abren en {appName}. Técnicamente, {appName} se registra como controlador de protocolo para enlaces mailto: y webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% mahust {total} kasutatud",
"accessibility": "Juurdepääsetavus"
},
"account_unread": {
"others": "Lugemata teistes kontodes: {count}",
"badge_all_accounts": {
"label": "Loe kõigi kontode lugemata kirjad kokku",
"description": "Vahekaardi, rakenduse ikooni ja teavitusala arv liidab kokku kõigi sisselogitud kontode postkastid."
},
"empty_hint": "Kausta {folder} tühjendades saad kõik kirjad jäädavalt eemaldada."
},
"protocol_handlers": {
"title": "Vaikerakendused",
"description": "Vali, kas e-posti ja kalendri lingid avanevad rakenduses {appName}. Tehniliselt registreerib {appName} end mailto: ja webcal: linkide protokollikäsitlejana.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}٪ از {total} استفاده شده",
"accessibility": "دسترس‌پذیری"
},
"account_unread": {
"others": "خوانده‌نشده در حساب‌های دیگر: {count}",
"badge_all_accounts": {
"label": "شمارش نامه‌های خوانده‌نشده همه حساب‌ها",
"description": "عدد روی زبانه، نماد برنامه و سینی سیستم، صندوق‌های ورودی همه حساب‌های واردشده را با هم جمع می‌کند."
},
"empty_hint": "می‌توانید پوشه {folder} را خالی کنید تا همه پیام‌ها برای همیشه حذف شوند."
},
"protocol_handlers": {
"title": "برنامه‌های پیش‌فرض",
"description": "انتخاب کنید لینک‌های ایمیل و تقویم در {appName} باز شوند.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent} % de {total} utilisés",
"accessibility": "Accessibilité"
},
"account_unread": {
"others": "Non lus dans les autres comptes : {count}",
"badge_all_accounts": {
"label": "Compter les messages non lus de tous les comptes",
"description": "Le nombre sur l'onglet, l'icône de l'application et la zone de notification additionne les boîtes de réception de tous les comptes connectés."
},
"empty_hint": "Vous pouvez vider le dossier {folder} pour supprimer définitivement tous les messages."
},
"protocol_handlers": {
"title": "Applications par défaut",
"description": "Choisissez si les liens d'e-mail et de calendrier s'ouvrent dans {appName}. Techniquement, {appName} s'enregistre comme gestionnaire de protocole pour les liens mailto: et webcal:.",
+8
View File
@@ -214,6 +214,14 @@
"mv_storage_used": "נוצלו {percent}% מתוך {total}",
"accessibility": "נגישות"
},
"account_unread": {
"others": "לא נקראו בחשבונות האחרים: {count}",
"badge_all_accounts": {
"label": "ספירת דואר שלא נקרא בכל החשבונות",
"description": "המספר בכרטיסייה, בסמל האפליקציה ובמגש המערכת מחבר את תיבות הדואר הנכנס של כל החשבונות המחוברים."
},
"empty_hint": "אפשר לרוקן את התיקייה {folder} כדי להסיר לצמיתות את כל ההודעות."
},
"sidebar_apps": {
"modal_title": "אפליקציות בסרגל הצד",
"add_new": "הוסף אפליקציה",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{total} {percent}%-a foglalt",
"accessibility": "Kisegítő lehetőségek"
},
"account_unread": {
"others": "Olvasatlan a többi fiókban: {count}",
"badge_all_accounts": {
"label": "Az összes fiók olvasatlan leveleinek számlálása",
"description": "A lapon, az alkalmazásikonon és a tálcán látható szám az összes bejelentkezett fiók beérkezett leveleit adja össze."
},
"empty_hint": "A(z) {folder} mappa ürítésével véglegesen eltávolíthatod az összes üzenetet."
},
"protocol_handlers": {
"title": "Alapértelmezett alkalmazások",
"description": "Válaszd ki, hogy az e-mail és naptár hivatkozások a(z) {appName} alkalmazásban nyíljanak meg. Technikailag a(z) {appName} protokollkezelőként regisztrálja magát a mailto: és webcal: hivatkozásokhoz.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% di {total} utilizzato",
"accessibility": "Accessibilità"
},
"account_unread": {
"others": "Non letti negli altri account: {count}",
"badge_all_accounts": {
"label": "Conta la posta non letta di tutti gli account",
"description": "Il numero sulla scheda, sull'icona dell'app e nell'area di notifica somma le caselle di posta in arrivo di tutti gli account collegati."
},
"empty_hint": "Puoi svuotare la cartella {folder} per rimuovere definitivamente tutti i messaggi."
},
"protocol_handlers": {
"title": "App predefinite",
"description": "Scegli se i link e-mail e calendario devono aprirsi in {appName}. Tecnicamente, {appName} si registra come gestore di protocollo per i link mailto: e webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{total} のうち {percent}% を使用中",
"accessibility": "アクセシビリティ"
},
"account_unread": {
"others": "他のアカウントの未読: {count}",
"badge_all_accounts": {
"label": "すべてのアカウントの未読を数える",
"description": "タブ、アプリのアイコン、通知領域の数字に、サインイン中のすべてのアカウントの受信トレイを合計して表示します。"
},
"empty_hint": "{folder}フォルダを空にして、すべてのメールを完全に削除できます。"
},
"protocol_handlers": {
"title": "既定のアプリ",
"description": "メールとカレンダーのリンクを {appName} で開くかどうかを選択します。技術的には、{appName} は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{total} 중 {percent}% 사용",
"accessibility": "접근성"
},
"account_unread": {
"others": "다른 계정의 읽지 않은 메일: {count}",
"badge_all_accounts": {
"label": "모든 계정의 읽지 않은 메일 세기",
"description": "탭, 앱 아이콘, 알림 영역의 숫자에 로그인한 모든 계정의 받은편지함을 합산해 표시해요."
},
"empty_hint": "{folder} 폴더를 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
},
"protocol_handlers": {
"title": "기본 앱",
"description": "이메일 및 캘린더 링크를 {appName}에서 열지 선택하세요. 기술적으로 {appName}은(는) mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Izmantoti {percent}% no {total}",
"accessibility": "Pieejamība"
},
"account_unread": {
"others": "Nelasītas citos kontos: {count}",
"badge_all_accounts": {
"label": "Skaitīt nelasītās vēstules visos kontos",
"description": "Skaitlis cilnē, lietotnes ikonā un paziņojumu apgabalā saskaita visu pierakstīto kontu iesūtnes."
},
"empty_hint": "Jūs varat iztīrīt mapi {folder}, lai neatgriezeniski dzēstu visus ziņojumus."
},
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
"description": "Izvēlieties, vai e-pasta un kalendāra saites atvērt {appName}. Tehniski {appName} reģistrējas kā protokola apstrādātājs mailto: un webcal: saitēm.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{total}-с {percent}% ашигласан",
"accessibility": "Хүртээмж"
},
"account_unread": {
"others": "Бусад бүртгэлд уншаагүй: {count}",
"badge_all_accounts": {
"label": "Бүх бүртгэлийн уншаагүй захидлыг тоолох",
"description": "Таб, аппын дүрс болон мэдэгдлийн хэсэг дээрх тоо нь нэвтэрсэн бүх бүртгэлийн ирсэн захидлыг нэгтгэнэ."
},
"empty_hint": "Та бүх мессежийг бүрмөсөн устгахын тулд {folder} хавтасыг хоослох боломжтой."
},
"protocol_handlers": {
"title": "Өгөгдмөл програмууд",
"description": "И-мэйл болон хуанлийн холбоосууд {appName} дээр нээгдэх эсэхийг сонгоно уу. Техникийн хувьд {appName} нь mailto: болон webcal: холбоосуудад протокол зохицуулагчаар бүртгүүлдэг.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent} % av {total} brukt",
"accessibility": "Tilgjengelighet"
},
"account_unread": {
"others": "Uleste i andre kontoer: {count}",
"badge_all_accounts": {
"label": "Tell ulest e-post i alle kontoer",
"description": "Tallet på fanen, appikonet og i systemstatusfeltet legger sammen innboksene til alle påloggede kontoer."
},
"empty_hint": "Du kan tømme mappen {folder} for å fjerne alle meldinger permanent."
},
"protocol_handlers": {
"title": "Standardapper",
"description": "Velg om e-post- og kalenderlenker skal åpnes i {appName}. Teknisk sett registrerer {appName} seg som protokollbehandler for mailto:- og webcal:-lenker.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% van {total} gebruikt",
"accessibility": "Toegankelijkheid"
},
"account_unread": {
"others": "Ongelezen in andere accounts: {count}",
"badge_all_accounts": {
"label": "Ongelezen mail van alle accounts tellen",
"description": "Het getal op het tabblad, het app-pictogram en in het systeemvak telt de inboxen van alle aangemelde accounts bij elkaar op."
},
"empty_hint": "U kunt de map {folder} legen om alle berichten permanent te verwijderen."
},
"protocol_handlers": {
"title": "Standaardapps",
"description": "Kies of e-mail- en kalenderlinks in {appName} worden geopend. Technisch registreert {appName} zich als protocolhandler voor mailto:- en webcal:-links.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Wykorzystano {percent}% z {total}",
"accessibility": "Dostępność"
},
"account_unread": {
"others": "Nieprzeczytane na innych kontach: {count}",
"badge_all_accounts": {
"label": "Licz nieprzeczytaną pocztę ze wszystkich kont",
"description": "Liczba na karcie, ikonie aplikacji i w zasobniku sumuje skrzynki odbiorcze wszystkich zalogowanych kont."
},
"empty_hint": "Możesz opróżnić folder {folder}, aby trwale usunąć wszystkie wiadomości."
},
"protocol_handlers": {
"title": "Aplikacje domyślne",
"description": "Wybierz, czy linki e-mail i kalendarza mają otwierać się w {appName}. Technicznie {appName} rejestruje się jako obsługa protokołu dla linków mailto: i webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% de {total} utilizado",
"accessibility": "Acessibilidade"
},
"account_unread": {
"others": "Não lidas em outras contas: {count}",
"badge_all_accounts": {
"label": "Contar os e-mails não lidos de todas as contas",
"description": "O número na aba, no ícone do app e na bandeja do sistema soma as caixas de entrada de todas as contas conectadas."
},
"empty_hint": "Você pode esvaziar a pasta {folder} para remover permanentemente todas as mensagens."
},
"protocol_handlers": {
"title": "Aplicativos padrão",
"description": "Escolha se links de e-mail e calendário devem abrir no {appName}. Tecnicamente, o {appName} se registra como manipulador de protocolo para links mailto: e webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{percent}% din {total} folosit",
"accessibility": "Accesibilitate"
},
"account_unread": {
"others": "Necitite în alte conturi: {count}",
"badge_all_accounts": {
"label": "Numără mesajele necitite din toate conturile",
"description": "Numărul de pe filă, de pe pictograma aplicației și din zona de notificare adună căsuțele de primire ale tuturor conturilor conectate."
},
"empty_hint": "Puteți goli folderul {folder} pentru a șterge definitiv toate mesajele."
},
"protocol_handlers": {
"title": "Aplicații implicite",
"description": "Alegeți dacă linkurile către e-mail și calendar se deschid în {appName}. Din punct de vedere tehnic, {appName} se înregistrează ca gestionar de protocol pentru linkurile de tip mailto: și webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Использовано {percent}% из {total}",
"accessibility": "Специальные возможности"
},
"account_unread": {
"others": "Непрочитанные в других аккаунтах: {count}",
"badge_all_accounts": {
"label": "Считать непрочитанные письма во всех аккаунтах",
"description": "Число на вкладке, на значке приложения и в трее складывает входящие всех аккаунтов, в которые выполнен вход."
},
"empty_hint": "Вы можете очистить папку «{folder}», чтобы навсегда удалить все сообщения."
},
"protocol_handlers": {
"title": "Приложения по умолчанию",
"description": "Выберите, должны ли ссылки электронной почты и календаря открываться в {appName}. Технически {appName} регистрируется как обработчик протокола для ссылок mailto: и webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Využité {percent} % z {total}",
"accessibility": "Prístupnosť"
},
"account_unread": {
"others": "Neprečítané v ostatných účtoch: {count}",
"badge_all_accounts": {
"label": "Počítať neprečítanú poštu vo všetkých účtoch",
"description": "Číslo na karte, na ikone aplikácie a v oblasti oznámení sčítava doručenú poštu všetkých prihlásených účtov."
},
"empty_hint": "Môžete vyprázdniť priečinok {folder} a natrvalo odstrániť všetky správy."
},
"protocol_handlers": {
"title": "Predvolené aplikácie",
"description": "Vyberte, či sa e-mailové a kalendárové odkazy otvárajú v aplikácii {appName}. {appName} sa registruje ako obslužná aplikácia protokolu pre odkazy mailto: a webcal:.",
+8
View File
@@ -345,6 +345,14 @@
"mv_storage_used": "ใช้ไป {percent}% จาก {total}",
"accessibility": "การเข้าถึง"
},
"account_unread": {
"others": "ยังไม่ได้อ่านในบัญชีอื่น: {count}",
"badge_all_accounts": {
"label": "นับอีเมลที่ยังไม่ได้อ่านของทุกบัญชี",
"description": "ตัวเลขบนแท็บ ไอคอนแอป และถาดระบบ จะรวมกล่องจดหมายเข้าของทุกบัญชีที่ลงชื่อเข้าใช้"
},
"empty_hint": "คุณสามารถล้างโฟลเดอร์{folder}เพื่อลบข้อความทั้งหมดอย่างถาวร"
},
"language": {
"title": "ภาษา",
"english": "English",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "{total} alanın %{percent} kadarı kullanıldı",
"accessibility": "Erişilebilirlik"
},
"account_unread": {
"others": "Diğer hesaplarda okunmamış: {count}",
"badge_all_accounts": {
"label": "Tüm hesaplardaki okunmamış postaları say",
"description": "Sekmedeki, uygulama simgesindeki ve sistem tepsisindeki sayı, oturum açılmış tüm hesapların gelen kutularını toplar."
},
"empty_hint": "Tüm iletileri kalıcı olarak kaldırmak için {folder} klasörünü boşaltabilirsiniz."
},
"protocol_handlers": {
"title": "Varsayılan uygulamalar",
"description": "E-posta ve takvim bağlantılarının {appName} uygulamasında açılıp açılmayacağını seçin. Teknik olarak {appName}, mailto: ve webcal: bağlantıları için protokol işleyicisi olarak kaydolur.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Використано {percent}% з {total}",
"accessibility": "Доступність"
},
"account_unread": {
"others": "Непрочитані в інших облікових записах: {count}",
"badge_all_accounts": {
"label": "Рахувати непрочитані листи в усіх облікових записах",
"description": "Число на вкладці, на значку застосунку та в треї підсумовує вхідні всіх облікових записів, у які виконано вхід."
},
"empty_hint": "Ви можете очистити папку «{folder}», щоб остаточно видалити всі повідомлення."
},
"protocol_handlers": {
"title": "Програми за замовчуванням",
"description": "Виберіть, чи відкривати посилання електронної пошти та календаря в {appName}. Технічно {appName} реєструється як обробник протоколу для посилань mailto: і webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "Đã dùng {percent}% dung lượng {total}",
"accessibility": "Hỗ trợ tiếp cận"
},
"account_unread": {
"others": "Chưa đọc trong các tài khoản khác: {count}",
"badge_all_accounts": {
"label": "Đếm thư chưa đọc của mọi tài khoản",
"description": "Con số trên tab, biểu tượng ứng dụng và khay hệ thống cộng hộp thư đến của mọi tài khoản đã đăng nhập."
},
"empty_hint": "Bạn có thể làm trống thư mục {folder} để xóa vĩnh viễn tất cả thư."
},
"protocol_handlers": {
"title": "Ứng dụng mặc định",
"description": "Chọn xem các liên kết email và lịch có mở trong {appName} hay không. Về mặt kỹ thuật, {appName} đăng ký làm trình xử lý giao thức cho các liên kết mailto: và webcal:.",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "已使用 {total} 中的 {percent}%",
"accessibility": "無障礙"
},
"account_unread": {
"others": "其他帳號中的未讀郵件:{count}",
"badge_all_accounts": {
"label": "計算所有帳號的未讀郵件",
"description": "分頁、應用程式圖示和系統匣上的數字會合計所有已登入帳號的收件匣。"
},
"empty_hint": "您可以清空「{folder}」信件匣,永久刪除其中的所有郵件。"
},
"protocol_handlers": {
"title": "預設應用程式",
"description": "選擇電子郵件與行事曆連結是否要在 {appName} 開啟。{appName} 會註冊為 mailto: 與 webcal: 連結的通訊協定處理常式。",
+8
View File
@@ -215,6 +215,14 @@
"mv_storage_used": "已使用 {total} 中的 {percent}%",
"accessibility": "无障碍"
},
"account_unread": {
"others": "其他账户中的未读邮件:{count}",
"badge_all_accounts": {
"label": "统计所有账户的未读邮件",
"description": "标签页、应用图标和托盘上的数字会合计所有已登录账户的收件箱。"
},
"empty_hint": "您可以清空“{folder}”文件夹以永久删除所有邮件。"
},
"protocol_handlers": {
"title": "默认应用",
"description": "选择是否在 {appName} 中打开电子邮件和日历链接。从技术上讲,{appName} 会注册为 mailto: 和 webcal: 链接的协议处理程序。",
@@ -348,7 +348,9 @@ describe('unread badge scope', () => {
});
});
// What mail-app's handleUnreadFilterClick does with the store.
// What a click on a folder's unread count used to do with the store. The
// count is plain text now (usability test 2026-11-04, mail-8); the store
// keeps the folder-following scope.
async function clickUnreadBadge(mailboxId: string) {
const store = useEmailStore.getState();
store.selectMailbox(mailboxId);
@@ -401,6 +403,24 @@ describe('unread badge scope', () => {
expect(useEmailStore.getState().searchMailboxId).toBe('owner-x:x-inbox');
});
// mail-8: the scope stayed on Inbox and a later search for mail in
// Archive found nothing.
it('drops the following scope when a new search is typed', async () => {
await clickUnreadBadge('inbox');
useEmailStore.getState().setSearchQuery('O');
useEmailStore.getState().setSearchQuery('Old conversation');
expect(useEmailStore.getState().searchMailboxId).toBe('');
expect(useEmailStore.getState().searchScopeFollowsFolder).toBe(false);
});
it('keeps a scope picked in the filter panel when a new search is typed', () => {
useEmailStore.getState().setSearchMailboxId('inbox');
useEmailStore.getState().setSearchQuery('Old conversation');
expect(useEmailStore.getState().searchMailboxId).toBe('inbox');
});
it('stops following once the filters are cleared', async () => {
await clickUnreadBadge('inbox');
useEmailStore.getState().clearSearchFilters();
+6
View File
@@ -3625,6 +3625,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setSearchQuery: (query) => set((state) => ({
searchQuery: query,
...(query === state.searchQuery ? {} : NO_SELECTION(state)),
// A new search looks where searches look by default. A scope that only
// followed the open folder (scopeSearchToOpenFolder) is dropped; one the
// person picked in the filter panel stays.
...(state.searchScopeFollowsFolder && !state.searchQuery.trim() && query.trim()
? { searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox), searchScopeFollowsFolder: false }
: {}),
})),
setQuota: (quota) => set({ quota }),
+3
View File
@@ -763,6 +763,7 @@ interface SettingsState extends AccessibilitySettings {
senderFavicons: boolean;
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
faviconUnreadBadge: boolean; // Badge the browser-tab icon with the inbox unread count
unreadBadgeAllAccounts: boolean; // That badge (and the desktop tray) counts every signed-in account's inbox, not only the open one
// Sidebar
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
@@ -1068,6 +1069,7 @@ const DEFAULT_SETTINGS = {
senderFavicons: true,
showAvatarsInJunk: false,
faviconUnreadBadge: true,
unreadBadgeAllAccounts: true,
// Sidebar
colorfulSidebarIcons: true,
@@ -1483,6 +1485,7 @@ export const useSettingsStore = create<SettingsState>()(
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
faviconUnreadBadge: state.faviconUnreadBadge,
unreadBadgeAllAccounts: state.unreadBadgeAllAccounts,
colorfulSidebarIcons: state.colorfulSidebarIcons,
tintListRowsByTag: state.tintListRowsByTag,
tintListRowsByAccount: state.tintListRowsByAccount,