= 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 ? (
<>
-
+
+
+ {othersUnreadText && (
+
+ )}
+
{variant === "expanded" && (
<>
@@ -382,7 +396,7 @@ export function AccountSwitcher({
{mvPopover && activeAccount && (
-
+
{displayName}
{displaySubtitle &&
{displaySubtitle}
}
@@ -455,7 +469,7 @@ export function AccountSwitcher({
disabled={isActive}
>
-
+
{isActive && (
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index bd7dbcefb..dda7d0ad4 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -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)})`}
>
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)}
/>
+
+ {/* Only means something with a second account. */}
+ {severalAccounts && (
+
+ updateSetting('unreadBadgeAllAccounts', checked)}
+ disabled={!faviconUnreadBadge}
+ />
+
+ )}
diff --git a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx
index 223420172..7fc74e156 100644
--- a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx
+++ b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx
@@ -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);
+ });
});
diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts
index 60c8cf455..5487a5633 100644
--- a/hooks/use-pro-multi-account-mailboxes.ts
+++ b/hooks/use-pro-multi-account-mailboxes.ts
@@ -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>();
+ 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;
diff --git a/lib/account-utils.ts b/lib/account-utils.ts
index 301333af0..2081326cf 100644
--- a/lib/account-utils.ts
+++ b/lib/account-utils.ts
@@ -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;
diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts
index 67323171c..99544462c 100644
--- a/lib/plugin-sandbox/host-api-app.ts
+++ b/lib/plugin-sandbox/host-api-app.ts
@@ -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',
diff --git a/lib/settings-schema.ts b/lib/settings-schema.ts
index 995bb4e41..b5ac5b036 100644
--- a/lib/settings-schema.ts
+++ b/lib/settings-schema.ts
@@ -306,6 +306,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`),
diff --git a/lib/settings-search.ts b/lib/settings-search.ts
index 982b15062..383a25826 100644
--- a/lib/settings-search.ts
+++ b/lib/settings-search.ts
@@ -86,6 +86,7 @@ export const tabSearchEntries: Record = {
{ label: 'settings.notifications.email.enabled' },
{ label: 'settings.notifications.email.sound' },
{ label: 'settings.appearance.favicon_unread_badge.label' },
+ { label: 'account_unread.badge_all_accounts.label' },
{ label: 'settings.notifications.calendar.title' },
{ label: 'settings.notifications.calendar.enabled' },
{ label: 'settings.notifications.sound_selection.title' },
diff --git a/locales/ar/common.json b/locales/ar/common.json
index ed9cbecb0..0537b16ed 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "تم استخدام {percent}% من {total}",
"accessibility": "إمكانية الوصول"
},
+ "account_unread": {
+ "others": "غير مقروءة في الحسابات الأخرى: {count}",
+ "badge_all_accounts": {
+ "label": "احتساب البريد غير المقروء في كل الحسابات",
+ "description": "الرقم على علامة التبويب وأيقونة التطبيق وشريط النظام يجمع صناديق الوارد لكل الحسابات المسجّل دخولها."
+ }
+ },
"protocol_handlers": {
"title": "التطبيقات الافتراضية",
"description": "اختر ما إذا كانت روابط البريد والتقويم تُفتح في {appName}. من الناحية التقنية، يسجّل {appName} نفسه كمعالج بروتوكول لروابط mailto: وwebcal:.",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 5e3d4916d..0b3ee80b6 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index ace3e4105..5929cbfd9 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -215,6 +215,13 @@
"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ů."
+ }
+ },
"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:.",
diff --git a/locales/da/common.json b/locales/da/common.json
index b473c8379..6f3733da6 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/de/common.json b/locales/de/common.json
index 9cb2097f7..67ff209b4 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/en/common.json b/locales/en/common.json
index 84f9389a7..d32a34bc9 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/es/common.json b/locales/es/common.json
index a44fce868..f7f50067d 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/et/common.json b/locales/et/common.json
index ace51f3b5..a40644ac4 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 9961aab01..157117c51 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "{percent}٪ از {total} استفاده شده",
"accessibility": "دسترسپذیری"
},
+ "account_unread": {
+ "others": "خواندهنشده در حسابهای دیگر: {count}",
+ "badge_all_accounts": {
+ "label": "شمارش نامههای خواندهنشده همه حسابها",
+ "description": "عدد روی زبانه، نماد برنامه و سینی سیستم، صندوقهای ورودی همه حسابهای واردشده را با هم جمع میکند."
+ }
+ },
"protocol_handlers": {
"title": "برنامههای پیشفرض",
"description": "انتخاب کنید لینکهای ایمیل و تقویم در {appName} باز شوند.",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 750e84417..c3829f87e 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/he/common.json b/locales/he/common.json
index 23cb11fa8..7ad373151 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -214,6 +214,13 @@
"mv_storage_used": "נוצלו {percent}% מתוך {total}",
"accessibility": "נגישות"
},
+ "account_unread": {
+ "others": "לא נקראו בחשבונות האחרים: {count}",
+ "badge_all_accounts": {
+ "label": "ספירת דואר שלא נקרא בכל החשבונות",
+ "description": "המספר בכרטיסייה, בסמל האפליקציה ובמגש המערכת מחבר את תיבות הדואר הנכנס של כל החשבונות המחוברים."
+ }
+ },
"sidebar_apps": {
"modal_title": "אפליקציות בסרגל הצד",
"add_new": "הוסף אפליקציה",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 172fa6df6..67fc90fa7 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/it/common.json b/locales/it/common.json
index 71f13cca0..29326a99f 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 7d382e843..98020f31e 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "{total} のうち {percent}% を使用中",
"accessibility": "アクセシビリティ"
},
+ "account_unread": {
+ "others": "他のアカウントの未読: {count}",
+ "badge_all_accounts": {
+ "label": "すべてのアカウントの未読を数える",
+ "description": "タブ、アプリのアイコン、通知領域の数字に、サインイン中のすべてのアカウントの受信トレイを合計して表示します。"
+ }
+ },
"protocol_handlers": {
"title": "既定のアプリ",
"description": "メールとカレンダーのリンクを {appName} で開くかどうかを選択します。技術的には、{appName} は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 9f9643ad0..897e453ef 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "{total} 중 {percent}% 사용",
"accessibility": "접근성"
},
+ "account_unread": {
+ "others": "다른 계정의 읽지 않은 메일: {count}",
+ "badge_all_accounts": {
+ "label": "모든 계정의 읽지 않은 메일 세기",
+ "description": "탭, 앱 아이콘, 알림 영역의 숫자에 로그인한 모든 계정의 받은편지함을 합산해 표시해요."
+ }
+ },
"protocol_handlers": {
"title": "기본 앱",
"description": "이메일 및 캘린더 링크를 {appName}에서 열지 선택하세요. 기술적으로 {appName}은(는) mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index a4bc8ef83..2599cc500 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 152574c4e..68967ba2e 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "{total}-с {percent}% ашигласан",
"accessibility": "Хүртээмж"
},
+ "account_unread": {
+ "others": "Бусад бүртгэлд уншаагүй: {count}",
+ "badge_all_accounts": {
+ "label": "Бүх бүртгэлийн уншаагүй захидлыг тоолох",
+ "description": "Таб, аппын дүрс болон мэдэгдлийн хэсэг дээрх тоо нь нэвтэрсэн бүх бүртгэлийн ирсэн захидлыг нэгтгэнэ."
+ }
+ },
"protocol_handlers": {
"title": "Өгөгдмөл програмууд",
"description": "И-мэйл болон хуанлийн холбоосууд {appName} дээр нээгдэх эсэхийг сонгоно уу. Техникийн хувьд {appName} нь mailto: болон webcal: холбоосуудад протокол зохицуулагчаар бүртгүүлдэг.",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 9a6c14f76..bcda16c1d 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 3c57a4007..9eb958686 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 20bd079ae..6712c74bc 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 2452edf4a..dfc82a03e 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 03bfef66a..5f007a176 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index ebb25a96f..be20722d5 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "Использовано {percent}% из {total}",
"accessibility": "Специальные возможности"
},
+ "account_unread": {
+ "others": "Непрочитанные в других аккаунтах: {count}",
+ "badge_all_accounts": {
+ "label": "Считать непрочитанные письма во всех аккаунтах",
+ "description": "Число на вкладке, на значке приложения и в трее складывает входящие всех аккаунтов, в которые выполнен вход."
+ }
+ },
"protocol_handlers": {
"title": "Приложения по умолчанию",
"description": "Выберите, должны ли ссылки электронной почты и календаря открываться в {appName}. Технически {appName} регистрируется как обработчик протокола для ссылок mailto: и webcal:.",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 43343c99e..8e05da256 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/th/common.json b/locales/th/common.json
index d4aef2af2..a65253edd 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -345,6 +345,13 @@
"mv_storage_used": "ใช้ไป {percent}% จาก {total}",
"accessibility": "การเข้าถึง"
},
+ "account_unread": {
+ "others": "ยังไม่ได้อ่านในบัญชีอื่น: {count}",
+ "badge_all_accounts": {
+ "label": "นับอีเมลที่ยังไม่ได้อ่านของทุกบัญชี",
+ "description": "ตัวเลขบนแท็บ ไอคอนแอป และถาดระบบ จะรวมกล่องจดหมายเข้าของทุกบัญชีที่ลงชื่อเข้าใช้"
+ }
+ },
"language": {
"title": "ภาษา",
"english": "English",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 03393f7f0..478699e25 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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.",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index f033cd1ee..a968e8ad0 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "Використано {percent}% з {total}",
"accessibility": "Доступність"
},
+ "account_unread": {
+ "others": "Непрочитані в інших облікових записах: {count}",
+ "badge_all_accounts": {
+ "label": "Рахувати непрочитані листи в усіх облікових записах",
+ "description": "Число на вкладці, на значку застосунку та в треї підсумовує вхідні всіх облікових записів, у які виконано вхід."
+ }
+ },
"protocol_handlers": {
"title": "Програми за замовчуванням",
"description": "Виберіть, чи відкривати посилання електронної пошти та календаря в {appName}. Технічно {appName} реєструється як обробник протоколу для посилань mailto: і webcal:.",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 9faa09bde..664a14d64 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -215,6 +215,13 @@
"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."
+ }
+ },
"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:.",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index eed67bf21..c613c620a 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "已使用 {total} 中的 {percent}%",
"accessibility": "無障礙"
},
+ "account_unread": {
+ "others": "其他帳號中的未讀郵件:{count}",
+ "badge_all_accounts": {
+ "label": "計算所有帳號的未讀郵件",
+ "description": "分頁、應用程式圖示和系統匣上的數字會合計所有已登入帳號的收件匣。"
+ }
+ },
"protocol_handlers": {
"title": "預設應用程式",
"description": "選擇電子郵件與行事曆連結是否要在 {appName} 開啟。{appName} 會註冊為 mailto: 與 webcal: 連結的通訊協定處理常式。",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 0edeb8c81..938b5c782 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -215,6 +215,13 @@
"mv_storage_used": "已使用 {total} 中的 {percent}%",
"accessibility": "无障碍"
},
+ "account_unread": {
+ "others": "其他账户中的未读邮件:{count}",
+ "badge_all_accounts": {
+ "label": "统计所有账户的未读邮件",
+ "description": "标签页、应用图标和托盘上的数字会合计所有已登录账户的收件箱。"
+ }
+ },
"protocol_handlers": {
"title": "默认应用",
"description": "选择是否在 {appName} 中打开电子邮件和日历链接。从技术上讲,{appName} 会注册为 mailto: 和 webcal: 链接的协议处理程序。",
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 1d771cb5f..9cb1d4e07 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -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()(
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
faviconUnreadBadge: state.faviconUnreadBadge,
+ unreadBadgeAllAccounts: state.unreadBadgeAllAccounts,
colorfulSidebarIcons: state.colorfulSidebarIcons,
tintListRowsByTag: state.tintListRowsByTag,
tintListRowsByAccount: state.tintListRowsByAccount,
From 60a97303c2975718ffa7844a662b7ae0eaf62166 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:17:05 +0100
Subject: [PATCH 2/5] fix: open the folder when its unread count is clicked,
and search all folders again on a new search
Two sidebar tests asserted the old unread-count button (it filtered the
folder to unread without opening it, in a 24px target); they now check
that the count is plain text and opens the folder.
---
.../sidebar-folder-touch-menu.test.tsx | 15 +++---
.../__tests__/sidebar-row-click-area.test.tsx | 17 ++++---
components/layout/sidebar.tsx | 46 +++----------------
components/mail/mail-app.tsx | 44 ------------------
.../email-store-search-scope.test.ts | 22 ++++++++-
stores/email-store.ts | 6 +++
6 files changed, 50 insertions(+), 100 deletions(-)
diff --git a/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx b/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx
index 349511372..6580f0352 100644
--- a/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx
+++ b/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx
@@ -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( {}} onCreateFolder={() => {}} />);
+ const { container } = render( {}} />);
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( {}} />);
- const unread = row('travel').querySelector('[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();
+ const counts = row('travel').querySelector('[data-testid="folder-counts"]')!;
+ expect(counts.textContent).toContain('4');
+ expect(counts.querySelector('button, [tabindex]')).toBeNull();
});
});
diff --git a/components/layout/__tests__/sidebar-row-click-area.test.tsx b/components/layout/__tests__/sidebar-row-click-area.test.tsx
index b09e4363b..e6e215ce9 100644
--- a/components/layout/__tests__/sidebar-row-click-area.test.tsx
+++ b/components/layout/__tests__/sidebar-row-click-area.test.tsx
@@ -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();
- fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"] button')!);
- expect(onUnreadFilterClick).toHaveBeenCalledWith('receipts');
- expect(onMailboxSelect).not.toHaveBeenCalled();
+ render();
+ const counts = row('receipts').querySelector('[data-testid="folder-counts"]')!;
+ expect(counts.querySelector('button')).toBeNull();
+ fireEvent.click(counts.firstElementChild!);
+ expect(onMailboxSelect).toHaveBeenCalledWith('receipts');
});
});
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index e525f3737..eab2e30a2 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -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 ? (
-
- ) : (
- // Said with the folder's name already.
- {unreadCountStyle === 'dot' ? unreadDot : unreadCount}
- )
+ // Said with the folder's name already.
+ {unreadCountStyle === 'dot' ? unreadDot : unreadCount}
) : null;
return (
@@ -367,7 +349,6 @@ interface SidebarRowProps {
hasChildren?: boolean;
isExpanded?: boolean;
onExpandToggle?: () => void;
- onUnreadClick?: () => void;
isCollapsed: boolean;
dropHandlers?: Record;
isValidDropTarget?: boolean;
@@ -395,7 +376,6 @@ function SidebarRow({
hasChildren = false,
isExpanded = false,
onExpandToggle,
- onUnreadClick,
isCollapsed,
dropHandlers,
isValidDropTarget,
@@ -542,8 +522,7 @@ function SidebarRow({
>
)}
- {/* 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 && (
)}
{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.
@@ -789,7 +765,6 @@ function MailboxTreeItem({
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
- onUnreadClick={() => onUnreadFilterClick?.(node.id)}
isCollapsed={isCollapsed}
dropHandlers={
folderDragHandlers || (globalDragging && !pickOnly)
@@ -814,7 +789,6 @@ function MailboxTreeItem({
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful}
onContextMenu={onContextMenu}
dragAccountId={dragAccountId}
@@ -1127,7 +1101,6 @@ function SidebarImpl({
onTagSelect,
onCompose,
onSidebarClose,
- onUnreadFilterClick,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
@@ -1513,7 +1486,6 @@ function SidebarImpl({
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? handleMailboxContextMenu : undefined}
dragAccountId={dragAccountId}
@@ -1586,7 +1558,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}
@@ -1632,7 +1603,6 @@ function SidebarImpl({
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? handleMailboxContextMenu : undefined}
pickOnly={!active}
@@ -2090,7 +2060,6 @@ function SidebarImpl({
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
dragAccountId={isActive ? null : account.id}
@@ -2157,7 +2126,6 @@ function SidebarImpl({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
@@ -2200,7 +2168,6 @@ function SidebarImpl({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
@@ -2242,7 +2209,6 @@ function SidebarImpl({
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
- onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 11d4246c0..8e082b4d8 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -529,7 +529,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
isAdvancedSearchOpen,
setSearchFilters,
setSearchMailboxId,
- scopeSearchToOpenFolder,
clearSearchFilters,
toggleAdvancedSearch,
advancedSearch,
@@ -618,7 +617,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,
@@ -3892,47 +3890,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) => {
@@ -4755,7 +4712,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onOpenStarred: mvShell ? goToStarred : undefined,
onMailboxSelect: handleMailboxSelect,
onTagSelect: handleTagSelect,
- onUnreadFilterClick: handleUnreadFilterClick,
onMarkFolderRead: handleMarkFolderRead,
onMarkFolderTreeRead: handleMarkFolderTreeRead,
onMarkAllFoldersRead: handleMarkAllFoldersRead,
diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts
index 3ef44c02b..ef3d9ba14 100644
--- a/stores/__tests__/email-store-search-scope.test.ts
+++ b/stores/__tests__/email-store-search-scope.test.ts
@@ -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();
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 96d37325e..dde5fa8c6 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -3625,6 +3625,12 @@ export const useEmailStore = create((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 }),
From c721c737b731f90191ddffe3dce089dd3bcb531e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:38:53 +0100
Subject: [PATCH 3/5] fix: list the all-accounts badge setting in settings
search only with several accounts
---
lib/settings-search.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/settings-search.ts b/lib/settings-search.ts
index 383a25826..f130aba5a 100644
--- a/lib/settings-search.ts
+++ b/lib/settings-search.ts
@@ -86,7 +86,7 @@ export const tabSearchEntries: Record = {
{ label: 'settings.notifications.email.enabled' },
{ label: 'settings.notifications.email.sound' },
{ label: 'settings.appearance.favicon_unread_badge.label' },
- { label: 'account_unread.badge_all_accounts.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' },
From 4224149ab50af6b8b12333fbb67452da32ad504c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:39:16 +0100
Subject: [PATCH 4/5] feat: move Compose to the top of the folder sidebar on
desktop and keep one account switcher and one list menu
Two mail-app tests clicked the round Compose button on a desktop; their
mocked sidebar now draws the Compose button the real one has there.
---
components/email/email-list.tsx | 9 ++-
.../layout/__tests__/sidebar-compose.test.tsx | 80 +++++++++++++++++++
components/layout/sidebar.tsx | 38 +++++++--
.../mail/__tests__/after-send-hook.test.tsx | 5 +-
.../mail/__tests__/mobile-tour-end.test.tsx | 33 +++++++-
components/mail/mail-app.tsx | 19 +++--
components/settings/layout-settings.tsx | 16 +---
lib/settings-schema.ts | 6 +-
lib/settings-search.ts | 1 -
9 files changed, 171 insertions(+), 36 deletions(-)
create mode 100644 components/layout/__tests__/sidebar-compose.test.tsx
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index a744becf7..2f3b142f7 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -28,6 +28,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";
@@ -185,6 +187,8 @@ export function EmailList({
const tViewer = useTranslations('email_viewer');
const tSidebar = useTranslations('sidebar');
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 })));
@@ -986,10 +990,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 ? : null;
+ const composeButtonRoom = !mountainViewList && (!isDesktopPane || isEmbeddedList) ? : null;
return (
diff --git a/components/layout/__tests__/sidebar-compose.test.tsx b/components/layout/__tests__/sidebar-compose.test.tsx
new file mode 100644
index 000000000..f45c60f41
--- /dev/null
+++ b/components/layout/__tests__/sidebar-compose.test.tsx
@@ -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();
+ 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();
+ 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();
+ expect(screen.queryByTestId('account-switcher')).toBeNull();
+ });
+
+ it('leaves Compose to the round button where the sidebar is a drawer', () => {
+ narrowWindow(true);
+ render();
+ expect(screen.queryByRole('button', { name: 'compose' })).toBeNull();
+ });
+});
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index eab2e30a2..e0903a2e0 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -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";
@@ -1208,10 +1208,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);
@@ -1920,10 +1916,38 @@ function SidebarImpl({
{isCollapsed ? : }
- {!isCollapsed && !hideAccountSwitcher && (
-
+ {/* 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 && (
+
)}
+ {isCollapsed && !isDrawer && onCompose && (
+
+ )}
)}
diff --git a/components/mail/__tests__/after-send-hook.test.tsx b/components/mail/__tests__/after-send-hook.test.tsx
index e71b1006a..40fa0a406 100644
--- a/components/mail/__tests__/after-send-hook.test.tsx
+++ b/components/mail/__tests__/after-send-hook.test.tsx
@@ -32,7 +32,10 @@ vi.mock('@/components/email/email-composer', () => ({
),
}));
-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 }) =>
,
+}));
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 }));
diff --git a/components/mail/__tests__/mobile-tour-end.test.tsx b/components/mail/__tests__/mobile-tour-end.test.tsx
index 37fabc597..c059d5839 100644
--- a/components/mail/__tests__/mobile-tour-end.test.tsx
+++ b/components/mail/__tests__/mobile-tour-end.test.tsx
@@ -29,7 +29,10 @@ vi.mock('@/components/email/email-composer', () => ({
return
stub composer
;
},
}));
-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 :
),
+}));
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(
);
+ expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1);
+
+ device.isMobile = false;
+ view.rerender(
);
+ 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(
);
+ expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull();
+
+ device.isTablet = true;
+ view.rerender(
);
+ 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' });
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 8e082b4d8..35edbd576 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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";
@@ -5492,6 +5492,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 &&
}
handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
@@ -5597,8 +5599,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
/>
- {/* The folder's actions, as the Mountain View list ⋮ has them. */}
-
+ />}
{/* Plugin buttons (top-bar-actions). On phones they sit in
the header's overflow menu instead (MobileHeader). */}
{!isMobile && (
@@ -5814,9 +5818,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
- {/* Floating Compose Button - on desktop Mountain View puts Compose
- at the top of the navigation rail instead. */}
- {!mvShell &&
- {showSourceFolder && }
+ {showSourceFolder && }
{scheduledSendLabel ? (
)}
{hasAttachment && }
- {showSourceFolder && }
+ {showSourceFolder && }
{scheduledSendLabel ? (
- {showSourceFolder && }
+ {showSourceFolder && }
{scheduledSendLabel ? (
({ 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(
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx
index 827d1d592..c4cf19add 100644
--- a/components/layout/mailbox-context-menu.tsx
+++ b/components/layout/mailbox-context-menu.tsx
@@ -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 (
diff --git a/lib/__tests__/unified-mailbox-search-accounts.test.ts b/lib/__tests__/unified-mailbox-search-accounts.test.ts
index cda7d2ec1..469bf6ce2 100644
--- a/lib/__tests__/unified-mailbox-search-accounts.test.ts
+++ b/lib/__tests__/unified-mailbox-search-accounts.test.ts
@@ -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',
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts
index 6a19f659f..29e0ea8af 100644
--- a/lib/jmap/types.ts
+++ b/lib/jmap/types.ts
@@ -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;
diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts
index de3c74509..fba5aa042 100644
--- a/lib/unified-mailbox.ts
+++ b/lib/unified-mailbox.ts
@@ -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;
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 0537b16ed..756a18a7b 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "احتساب البريد غير المقروء في كل الحسابات",
"description": "الرقم على علامة التبويب وأيقونة التطبيق وشريط النظام يجمع صناديق الوارد لكل الحسابات المسجّل دخولها."
- }
+ },
+ "empty_hint": "يمكنك إفراغ مجلد {folder} لحذف جميع الرسائل نهائيًا."
},
"protocol_handlers": {
"title": "التطبيقات الافتراضية",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 0b3ee80b6..24f758b50 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 5929cbfd9..b880a8f63 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/da/common.json b/locales/da/common.json
index 6f3733da6..c82d1d889 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/de/common.json b/locales/de/common.json
index 67ff209b4..2ef9913d6 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/en/common.json b/locales/en/common.json
index d32a34bc9..2d9077ef4 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/es/common.json b/locales/es/common.json
index f7f50067d..5a2e71541 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/et/common.json b/locales/et/common.json
index a40644ac4..0c9ba74e5 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 157117c51..60a4cd545 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "شمارش نامههای خواندهنشده همه حسابها",
"description": "عدد روی زبانه، نماد برنامه و سینی سیستم، صندوقهای ورودی همه حسابهای واردشده را با هم جمع میکند."
- }
+ },
+ "empty_hint": "میتوانید پوشه {folder} را خالی کنید تا همه پیامها برای همیشه حذف شوند."
},
"protocol_handlers": {
"title": "برنامههای پیشفرض",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index c3829f87e..d633c0666 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/he/common.json b/locales/he/common.json
index 7ad373151..e9b4ae79f 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -219,7 +219,8 @@
"badge_all_accounts": {
"label": "ספירת דואר שלא נקרא בכל החשבונות",
"description": "המספר בכרטיסייה, בסמל האפליקציה ובמגש המערכת מחבר את תיבות הדואר הנכנס של כל החשבונות המחוברים."
- }
+ },
+ "empty_hint": "אפשר לרוקן את התיקייה {folder} כדי להסיר לצמיתות את כל ההודעות."
},
"sidebar_apps": {
"modal_title": "אפליקציות בסרגל הצד",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 67fc90fa7..e70075d52 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/it/common.json b/locales/it/common.json
index 29326a99f..6f3e59484 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 98020f31e..7477c80fa 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "すべてのアカウントの未読を数える",
"description": "タブ、アプリのアイコン、通知領域の数字に、サインイン中のすべてのアカウントの受信トレイを合計して表示します。"
- }
+ },
+ "empty_hint": "{folder}フォルダを空にして、すべてのメールを完全に削除できます。"
},
"protocol_handlers": {
"title": "既定のアプリ",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 897e453ef..0b10f8f86 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "모든 계정의 읽지 않은 메일 세기",
"description": "탭, 앱 아이콘, 알림 영역의 숫자에 로그인한 모든 계정의 받은편지함을 합산해 표시해요."
- }
+ },
+ "empty_hint": "{folder} 폴더를 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
},
"protocol_handlers": {
"title": "기본 앱",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 2599cc500..2a6ed6181 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 68967ba2e..d6f2e38c4 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "Бүх бүртгэлийн уншаагүй захидлыг тоолох",
"description": "Таб, аппын дүрс болон мэдэгдлийн хэсэг дээрх тоо нь нэвтэрсэн бүх бүртгэлийн ирсэн захидлыг нэгтгэнэ."
- }
+ },
+ "empty_hint": "Та бүх мессежийг бүрмөсөн устгахын тулд {folder} хавтасыг хоослох боломжтой."
},
"protocol_handlers": {
"title": "Өгөгдмөл програмууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index bcda16c1d..9929438ad 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 9eb958686..7ffcf88d9 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 6712c74bc..26d66cff4 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index dfc82a03e..2338788a0 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 5f007a176..e0e1898c3 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index be20722d5..0c61cb1f0 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "Считать непрочитанные письма во всех аккаунтах",
"description": "Число на вкладке, на значке приложения и в трее складывает входящие всех аккаунтов, в которые выполнен вход."
- }
+ },
+ "empty_hint": "Вы можете очистить папку «{folder}», чтобы навсегда удалить все сообщения."
},
"protocol_handlers": {
"title": "Приложения по умолчанию",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 8e05da256..b9fe1746b 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/th/common.json b/locales/th/common.json
index a65253edd..99f744624 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -350,7 +350,8 @@
"badge_all_accounts": {
"label": "นับอีเมลที่ยังไม่ได้อ่านของทุกบัญชี",
"description": "ตัวเลขบนแท็บ ไอคอนแอป และถาดระบบ จะรวมกล่องจดหมายเข้าของทุกบัญชีที่ลงชื่อเข้าใช้"
- }
+ },
+ "empty_hint": "คุณสามารถล้างโฟลเดอร์{folder}เพื่อลบข้อความทั้งหมดอย่างถาวร"
},
"language": {
"title": "ภาษา",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 478699e25..9a6778286 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index a968e8ad0..f1e6e0156 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "Рахувати непрочитані листи в усіх облікових записах",
"description": "Число на вкладці, на значку застосунку та в треї підсумовує вхідні всіх облікових записів, у які виконано вхід."
- }
+ },
+ "empty_hint": "Ви можете очистити папку «{folder}», щоб остаточно видалити всі повідомлення."
},
"protocol_handlers": {
"title": "Програми за замовчуванням",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 664a14d64..85b0c9e78 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -220,7 +220,8 @@
"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",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index c613c620a..037a42eb3 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "計算所有帳號的未讀郵件",
"description": "分頁、應用程式圖示和系統匣上的數字會合計所有已登入帳號的收件匣。"
- }
+ },
+ "empty_hint": "您可以清空「{folder}」信件匣,永久刪除其中的所有郵件。"
},
"protocol_handlers": {
"title": "預設應用程式",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 938b5c782..b2db22953 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -220,7 +220,8 @@
"badge_all_accounts": {
"label": "统计所有账户的未读邮件",
"description": "标签页、应用图标和托盘上的数字会合计所有已登录账户的收件箱。"
- }
+ },
+ "empty_hint": "您可以清空“{folder}”文件夹以永久删除所有邮件。"
},
"protocol_handlers": {
"title": "默认应用",