fix(settings): keep the merged mailbox when switching to another account

Switching account loads that account's synced settings, and the views
that span every login - the unified mailbox, its cross-account scope and
the All mail / Unread / Starred entries - came along with them. An
account whose stored settings predated the merged mailbox turned it off
as soon as it became active, so the folders of every account were only
visible from the other one.

Once a device holds several logins those views are its own choice and
an account switch leaves them alone. A device's first login still takes
them from the server, and they are still saved with the settings.
This commit is contained in:
lucamzanon
2026-10-01 10:27:06 +02:00
parent 2cbf73ed08
commit 7739f1aaaa
2 changed files with 81 additions and 2 deletions
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiFetch = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
import { useSettingsStore } from '../settings-store';
import { useAccountStore } from '../account-store';
// An account whose stored settings predate the merged mailbox.
const STORED = { enableUnifiedMailbox: false, unifiedCrossAccount: false, enableCrossAllView: false, sendDelaySeconds: 30 };
function serverAnswers(settings: Record<string, unknown>) {
apiFetch.mockResolvedValue(new Response(JSON.stringify({ settings }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
}
describe('views spanning every account across an account switch', () => {
beforeEach(() => {
apiFetch.mockReset();
useSettingsStore.getState().disableSync();
useSettingsStore.setState({
enableUnifiedMailbox: true,
unifiedCrossAccount: true,
enableCrossAllView: true,
sendDelaySeconds: 0,
});
});
it('keeps the device\'s merged-mailbox choice when another of its logins loads its settings', async () => {
useAccountStore.setState({ accounts: [{ id: 'a' }, { id: 'b' }] as never });
serverAnswers(STORED);
await useSettingsStore.getState().loadFromServer('b@example.com', 'https://mail-b.example.com');
const s = useSettingsStore.getState();
expect(s.enableUnifiedMailbox).toBe(true);
expect(s.unifiedCrossAccount).toBe(true);
expect(s.enableCrossAllView).toBe(true);
// Everything else still follows the account.
expect(s.sendDelaySeconds).toBe(30);
});
it('takes them from the server on a device\'s first login', async () => {
useAccountStore.setState({ accounts: [{ id: 'a' }] as never });
serverAnswers(STORED);
await useSettingsStore.getState().loadFromServer('a@example.com', 'https://mail-a.example.com');
expect(useSettingsStore.getState().enableUnifiedMailbox).toBe(false);
expect(useSettingsStore.getState().unifiedCrossAccount).toBe(false);
});
});
+27 -2
View File
@@ -147,6 +147,23 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab';
*/
const DEVICE_LOCAL_SETTING_KEYS = new Set<string>(['proInterface']);
/**
* Settings that shape views spanning every logged-in account. They are synced
* with the active account's settings like everything else, but once a device
* holds several logins they are that device's choice: switching to another
* account must not turn the merged mailbox off just because that account's
* stored settings predate it. A first login on a device still takes them from
* the server.
*/
const CROSS_ACCOUNT_VIEW_KEYS = new Set<string>([
'enableUnifiedMailbox',
'includeGroupInUnified',
'unifiedCrossAccount',
'enableCrossUnreadView',
'enableCrossStarredView',
'enableCrossAllView',
]);
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
/** Action fired by a mobile list-row swipe. 'none' disables that direction. */
export type SwipeAction = 'none' | 'archive' | 'delete' | 'markRead' | 'star' | 'spam';
@@ -513,7 +530,7 @@ interface SettingsState {
) => void;
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (json: string, opts?: { serverAccountId?: string }) => boolean;
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => boolean;
// Folder icons
setFolderIcon: (mailboxId: string, icon: string) => void;
@@ -905,7 +922,7 @@ export const useSettingsStore = create<SettingsState>()(
return JSON.stringify(settings, null, 2);
},
importSettings: (json: string, opts?: { serverAccountId?: string }) => {
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => {
try {
const settings = JSON.parse(json);
@@ -957,6 +974,9 @@ export const useSettingsStore = create<SettingsState>()(
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return;
}
if (opts?.keepCrossAccountViews && CROSS_ACCOUNT_VIEW_KEYS.has(key)) {
return;
}
// Per-account maps (accountId -> value) live in every account's
// synced settings blob, so a per-account server load must NOT
// replace the whole map - each account's server is authoritative
@@ -1168,11 +1188,16 @@ export const useSettingsStore = create<SettingsState>()(
return false;
}
if (settings && typeof settings === 'object') {
// With several logins on this device, the views spanning them stay
// as the device has them (see CROSS_ACCOUNT_VIEW_KEYS).
const { useAccountStore } = await import('./account-store');
const keepCrossAccountViews = useAccountStore.getState().accounts.length > 1;
isLoadingFromServer = true;
// Merge (not replace) per-account maps for the account being loaded,
// so multi-account logins don't clobber each other by login order.
get().importSettings(JSON.stringify(settings), {
serverAccountId: generateAccountId(username, serverUrl),
keepCrossAccountViews,
});
isLoadingFromServer = false;
syncLog('Settings loaded from server successfully');