From 688e8bef3b98ed9d96cb2687ce5fdd7fc166be0b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:21:01 +0100 Subject: [PATCH] fix: keep two accounts on the same host apart by port --- App.tsx | 7 +- locales/rn/en.json | 5 +- src/components/SidebarDrawer.tsx | 13 +-- src/components/settings/AccountSettings.tsx | 4 +- src/lib/__tests__/account-id-port.test.ts | 95 ++++++++++++++++++++ src/lib/__tests__/push-notifications.test.ts | 40 +++++++++ src/lib/account-utils.ts | 70 ++++++++++++++- src/lib/push-background-task.ts | 5 ++ src/lib/push-notifications.ts | 43 +++++++-- src/stores/account-store.ts | 10 ++- src/stores/auth-store.ts | 16 ++++ 11 files changed, 287 insertions(+), 21 deletions(-) create mode 100644 src/lib/__tests__/account-id-port.test.ts diff --git a/App.tsx b/App.tsx index 4f459a38..e0729c85 100644 --- a/App.tsx +++ b/App.tsx @@ -654,7 +654,10 @@ export default function App() { ); const activeAccountId = useAuthStore((s) => s.activeAccountId); React.useEffect(() => { - if (!isAuthenticated || !client) return; + // The setup asks the server through the client: it waits for the + // session, and runs again once one comes up (an account added or + // switched to, a cold start that began offline). + if (!isAuthenticated || !client || !haveLiveSession) return; let cancelled = false; const doSetup = async () => { @@ -700,7 +703,7 @@ export default function App() { unsubscribe(); unsubscribeUp(); }; - }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); + }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId, haveLiveSession]); // Muting a folder or account changes the server-side push filter (#78), // which the setup above would only send at the next launch. Send it now, diff --git a/locales/rn/en.json b/locales/rn/en.json index fb90bce8..b89ab690 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -560,10 +560,10 @@ "export_failed": "Export failed", "forwarded": "Forwarded", "load_failed": "Failed to load email", - "next": "Next", - "not_spam_short": "Not spam", "new_tag": "New tag", "new_tag_placeholder": "Tag name", + "next": "Next", + "not_spam_short": "Not spam", "pause_animation": "Pause animation", "play_animation": "Play animation", "preview": { @@ -1341,6 +1341,7 @@ "accounts_desc_mobile": "Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.", "confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.", "description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", + "err_account_changed": "The account changed while push was being set up. Try again.", "err_android_only": "Push notifications are only available on Android.", "err_apns_empty": "iOS returned an empty push token.", "err_apns_token": "iOS could not issue a push token ({detail}).", diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 66dea0bd..937b4d95 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -21,7 +21,7 @@ import { useAccountStore } from '../stores/account-store'; import { useSettingsStore } from '../stores/settings-store'; import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; -import { MAX_ACCOUNTS, accountRowText } from '../lib/account-utils'; +import { MAX_ACCOUNTS, accountRowText, serverHostLabel } from '../lib/account-utils'; import { toUnicodeEmail } from '../lib/idn'; import { buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode, @@ -788,6 +788,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side if (!serverUrl) return ''; try { return new URL(serverUrl).hostname; } catch { return serverUrl; } }, [serverUrl]); + // The header names the server with its port when that isn't the default: + // two servers on one host are two accounts with the same address. + const hostLabel = React.useMemo(() => (serverUrl ? serverHostLabel(serverUrl) : ''), [serverUrl]); const accountEmailFull = React.useMemo(() => { if (!username) return hostname; if (username.includes('@')) return username; @@ -836,8 +839,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side {username || t('settings.account.title', 'Account')} - {hostname ? ( - {hostname} + {hostLabel ? ( + {hostLabel} ) : null} {busy ? ( @@ -857,8 +860,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side {accounts.map((acc) => { const isActive = acc.id === activeAccountId; - let accHost = ''; - try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; } + // With the port when it isn't the default (see hostLabel). + const accHost = serverHostLabel(acc.serverUrl); // The address leads when another account has the same name // (webmail 3bb9f7df8). const rowText = accountRowText(acc, accounts); diff --git a/src/components/settings/AccountSettings.tsx b/src/components/settings/AccountSettings.tsx index e2c4a9ba..0be8994e 100644 --- a/src/components/settings/AccountSettings.tsx +++ b/src/components/settings/AccountSettings.tsx @@ -15,7 +15,7 @@ import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; import { sharedAccountSettingsTabs } from '../../lib/capabilities'; import { jmapClient } from '../../api/jmap-client'; import { fetchMailQuota, type MailQuota } from '../../api/quota'; -import { MAX_ACCOUNTS, accountRowText } from '../../lib/account-utils'; +import { MAX_ACCOUNTS, accountRowText, nonDefaultPort, serverHostLabel } from '../../lib/account-utils'; import { toUnicodeEmail } from '../../lib/idn'; import type { RootStackParamList } from '../../navigation/types'; @@ -189,6 +189,8 @@ export function AccountSettings() { {isActive && } {rowText.subtitle ? {toUnicodeEmail(rowText.subtitle)} : null} + {/* Two servers on one host are two accounts with the same address. */} + {nonDefaultPort(a.serverUrl) ? {serverHostLabel(a.serverUrl)} : null} {isDefault && ( {t('settings.account.accounts.default_badge', 'Default account')} diff --git a/src/lib/__tests__/account-id-port.test.ts b/src/lib/__tests__/account-id-port.test.ts new file mode 100644 index 00000000..558f8ce0 --- /dev/null +++ b/src/lib/__tests__/account-id-port.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { generateAccountId, nonDefaultPort, serverHostLabel, setKnownAccounts } from '../account-utils'; +import { useAccountStore } from '../../stores/account-store'; + +// The same address on two servers on one host was one account: ids were +// username@host, so signing in on http://10.10.10.62:8016 replaced the +// account of http://10.10.10.62:8120. The port is part of the id now, while +// accounts already signed in keep the id they have. + +const entry = (username: string, serverUrl: string) => ({ + serverUrl, + username, + displayName: username, + email: username, + lastLoginAt: 1, + isConnected: true, + hasError: false, +}); + +beforeEach(() => { + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); +}); + +afterEach(() => { + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + setKnownAccounts([]); +}); + +describe('generateAccountId', () => { + it('keeps the ids of accounts on the default ports', () => { + expect(generateAccountId('u@example.org', 'https://mail.example.org')).toBe('u@example.org@mail.example.org'); + expect(generateAccountId('u@example.org', 'https://mail.example.org:443/')).toBe('u@example.org@mail.example.org'); + expect(generateAccountId('u@example.org', 'http://mail.example.org:80/jmap')).toBe('u@example.org@mail.example.org'); + }); + + it('tells two servers on one host apart by port', () => { + const a = generateAccountId('usera@example.org', 'http://10.10.10.62:8120'); + const b = generateAccountId('usera@example.org', 'http://10.10.10.62:8016'); + expect(a).toBe('usera@example.org@10.10.10.62:8120'); + expect(b).toBe('usera@example.org@10.10.10.62:8016'); + }); + + it('keeps the host-only id of an account signed in before ports counted', () => { + setKnownAccounts([{ id: 'usera@example.org@10.10.10.62', username: 'usera@example.org', serverUrl: 'http://10.10.10.62:8120' }]); + expect(generateAccountId('usera@example.org', 'http://10.10.10.62:8120')).toBe('usera@example.org@10.10.10.62'); + expect(generateAccountId('usera@example.org', 'http://10.10.10.62:8120/')).toBe('usera@example.org@10.10.10.62'); + // Another server on the same host is another account. + expect(generateAccountId('usera@example.org', 'http://10.10.10.62:8016')).toBe('usera@example.org@10.10.10.62:8016'); + }); +}); + +describe('the account registry', () => { + it('adds the same address on another port of the same host as a second account', () => { + const store = useAccountStore.getState(); + const a = store.addAccount(entry('usera@example.org', 'http://10.10.10.62:8120')); + const b = useAccountStore.getState().addAccount(entry('usera@example.org', 'http://10.10.10.62:8016')); + expect(a).not.toBe(b); + const { accounts } = useAccountStore.getState(); + expect(accounts.map((x) => x.serverUrl)).toEqual(['http://10.10.10.62:8120', 'http://10.10.10.62:8016']); + }); + + it('keeps an existing account under its old id when another port is added', () => { + // As persisted by an earlier version. + useAccountStore.setState({ + accounts: [{ + ...entry('usera@example.org', 'http://10.10.10.62:8120'), + id: 'usera@example.org@10.10.10.62', + avatarColor: '#000', + isDefault: true, + }], + activeAccountId: 'usera@example.org@10.10.10.62', + defaultAccountId: 'usera@example.org@10.10.10.62', + }); + // Signing in on the old server again updates that account. + expect(useAccountStore.getState().addAccount(entry('usera@example.org', 'http://10.10.10.62:8120'))) + .toBe('usera@example.org@10.10.10.62'); + const b = useAccountStore.getState().addAccount(entry('usera@example.org', 'http://10.10.10.62:8016')); + expect(b).toBe('usera@example.org@10.10.10.62:8016'); + expect(useAccountStore.getState().accounts.map((x) => [x.id, x.serverUrl])).toEqual([ + ['usera@example.org@10.10.10.62', 'http://10.10.10.62:8120'], + ['usera@example.org@10.10.10.62:8016', 'http://10.10.10.62:8016'], + ]); + }); +}); + +describe('serverHostLabel', () => { + it('shows the port only when it is not the default', () => { + expect(serverHostLabel('https://mail.example.org')).toBe('mail.example.org'); + expect(serverHostLabel('https://mail.example.org:443')).toBe('mail.example.org'); + expect(serverHostLabel('http://10.10.10.62:8016/')).toBe('10.10.10.62:8016'); + expect(serverHostLabel('https://mail.example.org:8443/jmap')).toBe('mail.example.org:8443'); + expect(nonDefaultPort('http://mail.example.org:80')).toBeNull(); + expect(nonDefaultPort('https://mail.example.org:80')).toBe('80'); + }); +}); diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index 2b2e4edd..1c1989a6 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -80,6 +80,7 @@ vi.mock('../../api/jmap-client', () => ({ jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com', + isConnected: true, accountId: 'jmap-primary', currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } }, }, @@ -1582,6 +1583,45 @@ describe('resyncPushNotifications', () => { expect(createMock).not.toHaveBeenCalled(); }); + it('waits for the session instead of asking the server without one', async () => { + // An account being added: the client holds its credentials, no session yet. + const client = jmapClient as unknown as { isConnected: boolean }; + client.isConnected = false; + try { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + expect(result).toBeNull(); + expect(listMock).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); + } finally { + client.isConnected = true; + } + }); + + it('keeps push on when the client moved to another account during the revocation check', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID])); + const client = jmapClient as unknown as { serverUrl: string }; + // The listing answers for the account added meanwhile, which has none of ours. + listMock.mockImplementation(async () => { + client.serverUrl = 'https://mail.example.com:8443'; + return []; + }); + try { + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + expect(result).toBeNull(); + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); + expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing'); + expect(await readPushAccountIds()).toEqual([ACCOUNT_ID]); + } finally { + client.serverUrl = 'https://mail.example.com'; + listMock.mockReset(); + listMock.mockResolvedValue([]); + } + }); + it('does not re-register a subscription another device revoked', async () => { await AsyncStorage.setItem(SUB_KEY, 'existing'); await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); diff --git a/src/lib/account-utils.ts b/src/lib/account-utils.ts index a330ecbc..13903bfd 100644 --- a/src/lib/account-utils.ts +++ b/src/lib/account-utils.ts @@ -13,7 +13,34 @@ export class AccountLimitError extends Error { } } -export function generateAccountId(username: string, serverUrl: string): string { +/** + * The port of `serverUrl` when it is not the scheme's default (443 for + * https, 80 for http), else null. Read from the text, since React Native's + * URL keeps a default port where the WHATWG one drops it. + */ +export function nonDefaultPort(serverUrl: string): string | null { + const m = /^([a-z][a-z0-9+.-]*):\/\/(?:[^@/?#]*@)?(?:\[[^\]]*\]|[^:/?#]*):(\d+)(?=[/?#]|$)/i.exec(serverUrl.trim()); + if (!m) return null; + const scheme = m[1].toLowerCase(); + const port = String(Number(m[2])); + if ((scheme === 'https' && port === '443') || (scheme === 'http' && port === '80')) return null; + return port; +} + +/** `serverUrl`'s host, with its port when that is not the default. */ +export function serverHostLabel(serverUrl: string): string { + let host: string; + try { + host = new URL(serverUrl).hostname; + } catch { + return serverUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + } + const port = nonDefaultPort(serverUrl); + return port ? `${host}:${port}` : host; +} + +/** The id an account got before ports were part of it: username@host. */ +function hostOnlyAccountId(username: string, serverUrl: string): string { let host = serverUrl; try { host = new URL(serverUrl).hostname; @@ -23,6 +50,47 @@ export function generateAccountId(username: string, serverUrl: string): string { return `${username}@${host}`; } +/** What the registry tells generateAccountId (structural: no AccountEntry import). */ +export interface KnownAccount { + id: string; + username: string; + serverUrl: string; +} + +let knownAccounts: readonly KnownAccount[] = []; + +/** + * The registry's accounts, so generateAccountId keeps the ids accounts were + * signed in under. The account store feeds this whenever it changes; the + * headless push task, which reads the registry from storage, feeds it too. + */ +export function setKnownAccounts(accounts: readonly KnownAccount[]): void { + knownAccounts = accounts; +} + +/** + * The local id of the account `username` has on `serverUrl`: + * username@host, plus `:port` when the server is not on its scheme's + * default port. Two servers on one host are two accounts. + * + * Accounts were once keyed by username@host only. One signed in that way on + * a server with a port keeps that id, and with it its credentials, caches, + * outbox, push registration and settings: when the registry holds the + * host-only id for the same server, that is the answer. A second server on + * the same host then gets the id with its port, and the two stay apart. + * Accounts on the default ports have the same id either way. + */ +export function generateAccountId(username: string, serverUrl: string): string { + const legacy = hostOnlyAccountId(username, serverUrl); + const host = serverHostLabel(serverUrl); + const id = `${username}@${host}`; + if (id === legacy) return id; + const kept = knownAccounts.find((a) => a.id === legacy + && a.username === username + && `${a.username}@${serverHostLabel(a.serverUrl)}` === id); + return kept ? kept.id : id; +} + /** What naming an account in a list needs (structural: no AccountEntry import). */ export interface NamedAccount { id: string; diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 53c09bf7..86f22ea2 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -5,6 +5,7 @@ import { CAPABILITIES } from '../api/types'; import type { Email, JMAPMethodCall, JMAPSession, Mailbox } from '../api/types'; import { detectDeviceLocale, isSupportedLocale, translate, type LocaleCode } from '../i18n'; import { secureFetch } from './client-cert'; +import { setKnownAccounts, type KnownAccount } from './account-utils'; import { freshStoredCredentials } from './stored-token-refresh'; import { generateEmailAvatarColor, @@ -194,6 +195,7 @@ export function parseRelayPushData(data: unknown): RelayPushData { interface RegistryAccount { id: string; username?: string; + serverUrl?: string; } async function readAccountRegistry(): Promise { @@ -452,6 +454,9 @@ export async function pushBackgroundTask(data: unknown): Promise { if (accountIds.length === 0) return; const registry = await readAccountRegistry(); + // Without the account store loaded, account ids are still the ones the + // registry holds (see generateAccountId). + setKnownAccounts(registry.filter((a): a is KnownAccount => !!a.username && !!a.serverUrl)); const jmapAccountIds = await readPushJmapAccountIds(); const match = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry); if (match.ambiguous) await showPlainNotification(payload); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index c9a61d50..1993c44e 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1224,6 +1224,28 @@ async function pollVerificationCode( // PushVerification"). Callers share the first in-flight run instead. const inFlightSetups = new Map>(); +/** + * The local account the singleton client serves with a live session; null + * while it has none, such as during an account change, when it holds the + * credentials of the account being signed in to before its session is up. + * The setup asks the server through the client, so it must know the client + * still serves the account it is setting up: a listing of another account's + * subscriptions took the account's own for revoked and turned its push off. + */ +function servedAccountId(): string | null { + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + if (!jmapClient.isConnected || !username || !serverUrl) return null; + return generateAccountId(username, serverUrl); +} + +function accountChangedError(): PushSetupError { + return new PushSetupError( + 'account', + t('settings.notifications.push.err_account_changed', 'The account changed while push was being set up. Try again.'), + ); +} + /** * Full setup flow: ask permission, fetch the device's FCM token, register * with the relay, create a JMAP PushSubscription, poll for the verification @@ -1300,12 +1322,10 @@ async function setupPushNotificationsInner( // setupPushNotifications operates on the currently-loaded jmapClient. We // need its username/serverUrl up-front so we can key per-account state. - const username = jmapClient.username; - const serverUrl = jmapClient.serverUrl; - if (!username || !serverUrl) { + const accountId = servedAccountId(); + if (!accountId) { throw new PushSetupError('account', t('settings.notifications.push.err_no_account', 'No account loaded - cannot set up push.')); } - const accountId = generateAccountId(username, serverUrl); await migrateLegacyPushKeys(); @@ -1381,6 +1401,8 @@ async function setupPushNotificationsInner( // A failed listing ends the run here: taken for "the server has no // subscription", it would drop the key of a working registration. logPhase('jmap'); + // From here on the server is asked through the client (see servedAccountId). + if (servedAccountId() !== accountId) throw accountChangedError(); let existingSubs: PushSubscription[]; try { existingSubs = await listPushSubscriptions(); @@ -1868,12 +1890,15 @@ function isOwnSubscription( export async function resyncPushNotifications( params: PushSetupParams, ): Promise { - const username = jmapClient.username; - const serverUrl = jmapClient.serverUrl; - if (!username || !serverUrl) return null; - const accountId = generateAccountId(username, serverUrl); + // Without a live session (an account change under way) there is nothing + // to ask; the caller runs this again once the session is up. + const accountId = servedAccountId(); + if (!accountId) return null; if (await AsyncStorage.getItem(optedOutKey(accountId))) return null; - if (await wasRevokedOnServer(accountId)) { + const revoked = await wasRevokedOnServer(accountId); + // The listing went to whatever account the client served meanwhile. + if (servedAccountId() !== accountId) return null; + if (revoked) { logPhase('revoked', 'subscription gone before it was due to expire; leaving push off'); await disablePushForAccount(accountId); return null; diff --git a/src/stores/account-store.ts b/src/stores/account-store.ts index 58e9f946..e10a3110 100644 --- a/src/stores/account-store.ts +++ b/src/stores/account-store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createPersistStorage } from './persist-storage'; -import { AccountLimitError, generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils'; +import { AccountLimitError, generateAccountId, MAX_ACCOUNTS, setKnownAccounts } from '../lib/account-utils'; import { generateAvatarColor } from '../lib/avatar-utils'; export interface AccountEntry { @@ -58,6 +58,9 @@ export const useAccountStore = create()( a.id === id ? { ...a, + // The same server under another URL (a path, a scheme): + // the credentials just stored carry the new one. + serverUrl: entry.serverUrl || a.serverUrl, displayName: entry.displayName || a.displayName, email: entry.email || a.email, lastLoginAt: entry.lastLoginAt, @@ -180,3 +183,8 @@ export const useAccountStore = create()( }, ), ); + +// generateAccountId keeps the ids accounts were signed in under, which it +// reads from the registry (see lib/account-utils). +setKnownAccounts(useAccountStore.getState().accounts); +useAccountStore.subscribe((state) => setKnownAccounts(state.accounts)); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index b7eac632..69d80591 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -203,6 +203,19 @@ async function checkServerChange(accountId: string, session: JMAPSession): Promi } } +// The registry names the server the account's stored credentials sign in +// to. Before ports were part of account ids, signing in to a second server +// on the same host stored its credentials under the first server's account +// and left the registry naming the first server. That account has been the +// second server's since (its mail, its sends), and now says so; the ids +// stay consistent with it (see generateAccountId). +function followStoredServer(accountId: string): void { + const served = jmapClient.serverUrl; + const accountStore = useAccountStore.getState(); + const entry = accountStore.getAccountById(accountId); + if (served && entry && entry.serverUrl !== served) accountStore.updateAccount(accountId, { serverUrl: served }); +} + // The outbox replays against whatever account the JMAP client serves, and a // replaced server may have given the queued ids to other messages. So it // runs only once the account's session is up and its server was checked. @@ -993,6 +1006,7 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: false, error: 'Failed to load session' }); return; } + followStoredServer(accountId); await checkServerChange(accountId, session); if (!isCurrent()) return; @@ -1139,6 +1153,7 @@ export const useAuthStore = create((set, get) => ({ }); const session = jmapClient.currentSession!; + followStoredServer(target.id); await checkServerChange(target.id, session); applyConnectedState(set, session, target.serverUrl, target.username, target.id); perfMark('restore-done'); @@ -1183,6 +1198,7 @@ export const useAuthStore = create((set, get) => ({ errorMessage: undefined, }); const fresh = jmapClient.currentSession!; + followStoredServer(activeAccountId); await checkServerChange(activeAccountId, fresh); if (!isCurrent()) return false; applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);