feat: per-account All Mail folder selection

Replaces the global allMailFolderIds (string[] | null) with a per-account
Record<accountId, string[]>, so each account chooses which of its own folders
the "All Mail" view merges. A missing entry = "not configured" (defaults to
every no-role folder); an explicit [] = "no folders".

- settings-store: type/default -> Record (default {}); persist version 4 -> 5,
  migration drops the legacy global list (the active account isn't known at
  migrate time); onRehydrate + importSettings coerce/ignore any non-record
  (legacy global string[] | null) shape. isPlainRecord() guard.
- email-store.resolveAllMailJmapIds: reads the entry for the account the view is
  scoped to (viewingAccountId ?? activeAccountId); undefined -> all no-role,
  [] -> none.
- layout-settings: read/write the active account's entry; when more than one
  account is logged in, an italic hint names the account the selection applies
  to (settings.appearance.all_mail.account_hint, 19 locales; de/ro translated).
- Test: stores/__tests__/settings-store-all-mail.test.ts (per-account
  independence, explicit-empty vs not-configured, importSettings legacy guard).
This commit is contained in:
Stefan Hildebrandt
2026-06-24 15:52:09 +02:00
committed by Linus Rath
parent 5c2f206c74
commit 751f3c1685
23 changed files with 133 additions and 12 deletions
+21 -5
View File
@@ -121,24 +121,37 @@ export function LayoutSettings() {
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
// Own (non-shared) folders and the current All Mail selection. `null` =
// never configured, which defaults to all non-special (no-role) folders.
// Own (non-shared) folders and the active account's All Mail selection. The
// selection is per account: a missing entry = never configured, which
// defaults to all no-role folders; an explicit [] = no folders.
const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]);
const currentAllMailEntry = activeAccountId ? allMailFolderIds[activeAccountId] : undefined;
const allMailSelected = new Set(
allMailFolderIds === null
currentAllMailEntry === undefined
? ownMailboxes.filter(m => !m.role).map(m => m.id)
: allMailFolderIds
: currentAllMailEntry
);
const toggleAllMailFolder = (id: string) => {
if (!activeAccountId) return;
const next = new Set(allMailSelected);
if (next.has(id)) next.delete(id);
else next.add(id);
updateSetting('allMailFolderIds', ownMailboxes.filter(m => next.has(m.id)).map(m => m.id));
updateSetting('allMailFolderIds', {
...allMailFolderIds,
[activeAccountId]: ownMailboxes.filter(m => next.has(m.id)).map(m => m.id),
});
};
// Name the account the selection applies to, but only when more than one is
// logged in (otherwise it's unambiguous).
const activeAccount = accounts.find(a => a.id === activeAccountId);
const allMailAccountHint = accounts.length > 1 && activeAccount
? t('all_mail.account_hint', { account: activeAccount.displayName || activeAccount.email })
: null;
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -244,6 +257,9 @@ export function LayoutSettings() {
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
{allMailAccountHint && (
<div className="text-xs italic text-muted-foreground mt-0.5">{allMailAccountHint}</div>
)}
</div>
{ownMailboxes.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p>
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -922,6 +922,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Gilt für {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -922,6 +922,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -922,6 +922,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -922,6 +922,7 @@
"description": "Afișați o intrare „Toate mesajele” deasupra folderelor, care reunește mesajele din toate folderele acestui cont într-o singură listă.",
"folders_label": "Dosare în „Toate mesajele”",
"folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.",
"account_hint": "Se aplică pentru {account}.",
"no_folders": "Nu sunt disponibile foldere."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
+1
View File
@@ -919,6 +919,7 @@
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore } from '../settings-store';
describe('settings-store per-account allMailFolderIds', () => {
beforeEach(() => {
useSettingsStore.setState({ allMailFolderIds: {} });
});
it('defaults to an empty record (every account "not configured")', () => {
expect(useSettingsStore.getState().allMailFolderIds).toEqual({});
});
it('keeps each account selection independent', () => {
useSettingsStore.setState({
allMailFolderIds: { 'acct-1': ['inbox', 'archive'], 'acct-2': ['sent'] },
});
const map = useSettingsStore.getState().allMailFolderIds;
expect(map['acct-1']).toEqual(['inbox', 'archive']);
expect(map['acct-2']).toEqual(['sent']);
// A third account remains unconfigured (no entry).
expect(map['acct-3']).toBeUndefined();
});
it('distinguishes explicit-empty ([] = no folders) from not-configured (undefined)', () => {
useSettingsStore.setState({ allMailFolderIds: { 'acct-1': [] } });
const map = useSettingsStore.getState().allMailFolderIds;
expect(map['acct-1']).toEqual([]); // explicit "no folders"
expect(map['acct-2']).toBeUndefined(); // never configured
});
describe('importSettings legacy-shape guard', () => {
it('ignores a legacy global array shape', () => {
useSettingsStore.setState({ allMailFolderIds: { 'acct-1': ['inbox'] } });
const ok = useSettingsStore.getState().importSettings(
JSON.stringify({ allMailFolderIds: ['inbox', 'sent'] }),
);
expect(ok).toBe(true);
// unchanged - the array shape was rejected
expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-1': ['inbox'] });
});
it('ignores a null legacy value', () => {
useSettingsStore.setState({ allMailFolderIds: { 'acct-1': ['inbox'] } });
useSettingsStore.getState().importSettings(JSON.stringify({ allMailFolderIds: null }));
expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-1': ['inbox'] });
});
it('accepts a proper per-account record', () => {
useSettingsStore.getState().importSettings(
JSON.stringify({ allMailFolderIds: { 'acct-9': ['inbox', 'spam'] } }),
);
expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-9': ['inbox', 'spam'] });
});
});
});
+8 -4
View File
@@ -321,15 +321,19 @@ function resolveActionMailboxes(): Mailbox[] {
/**
* Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the
* active/viewing account. Honors the per-user `allMailFolderIds` setting; when
* unset (null) it defaults to every non-special (no-role) folder. Shared
* active/viewing account. Honors that account's `allMailFolderIds` entry; when
* not configured it defaults to every non-special (no-role) folder. Shared
* folders are excluded - All Mail is scoped to a single account. Returns
* JMAP-side ids (originalId for namespaced mailboxes).
*/
function resolveAllMailJmapIds(): string[] {
const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared);
const configured = useSettingsStore.getState().allMailFolderIds;
const selected = configured === null
// Per-account selection: read the entry for the account the view is scoped to
// (the Pro viewing override, else the global active account). A missing entry
// = "not configured" -> all no-role folders; an explicit [] = no folders.
const accountId = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId;
const configured = accountId ? useSettingsStore.getState().allMailFolderIds[accountId] : undefined;
const selected = configured === undefined
? mailboxes.filter((mb) => !mb.role)
: mailboxes.filter((mb) => configured.includes(mb.id));
return selected.map((mb) => mb.originalId || mb.id);
+30 -3
View File
@@ -15,6 +15,11 @@ const syncLog = (...args: unknown[]) => console.log('[SETTINGS_SYNC]', ...args);
const syncWarn = (...args: unknown[]) => console.warn('[SETTINGS_SYNC]', ...args);
const syncError = (...args: unknown[]) => console.error('[SETTINGS_SYNC]', ...args);
/** True for a non-null, non-array plain object (the allMailFolderIds map shape). */
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// Settings sync state (module-level, not persisted)
let syncEnabled = false;
let syncUsername: string | null = null;
@@ -221,7 +226,10 @@ interface SettingsState {
// configured, in which case the view defaults to all non-special (no-role)
// folders of the active account.
enableAllMailView: boolean;
allMailFolderIds: string[] | null;
// Per-account "All Mail" folder selection, keyed by AccountEntry.id. A
// missing entry = "not configured" -> defaults to every no-role folder; an
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
allMailFolderIds: Record<string, string[]>;
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
@@ -409,7 +417,7 @@ const DEFAULT_SETTINGS = {
// All Mail view (gated)
enableAllMailView: false,
allMailFolderIds: null as string[] | null,
allMailFolderIds: {} as Record<string, string[]>,
// Email Display
disableThreading: false,
@@ -633,6 +641,11 @@ export const useSettingsStore = create<SettingsState>()(
set({ sendDelaySeconds: 0 });
return;
}
// Ignore a legacy global allMailFolderIds (string[] | null) or any
// non-record value - this build keys it per account.
if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) {
return;
}
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return;
}
@@ -823,7 +836,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: 'settings-storage',
version: 4,
version: 5,
migrate: (persisted, version) => {
const state = persisted as Record<string, unknown>;
if (version < 2 && state.listDensity) {
@@ -843,11 +856,25 @@ export const useSettingsStore = create<SettingsState>()(
if (version < 4) {
state.dateFormat = 'smart';
}
// v5: allMailFolderIds went from a global `string[] | null` to a
// per-account `Record<accountId, string[]>`. The legacy global list
// can't be attributed to a specific account here (the active account
// isn't known at migrate time), so it's dropped - each account starts
// "not configured" (defaults to all no-role folders).
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
state.allMailFolderIds = {};
}
return state as unknown as SettingsState;
},
onRehydrateStorage: () => {
return (state) => {
if (state) {
// Defensive: a legacy global array or any non-record value (e.g.
// synced from an older client) is coerced to an empty map so
// per-account consumers never see a non-record.
if (!isPlainRecord(state.allMailFolderIds)) {
state.allMailFolderIds = {};
}
applyFontSize(state.fontSize);
applyDensity(state.density);
applyAnimations(state.animationsEnabled);