diff --git a/App.tsx b/App.tsx index efb67f55..2258bf4a 100644 --- a/App.tsx +++ b/App.tsx @@ -45,20 +45,14 @@ import { addUnifiedPushEndpointListener } from './src/lib/unified-push'; // Pause between a mute change and the push-filter update it sends (#78). const MUTES_RESYNC_DELAY_MS = 1_000; import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; -import ComposeScreen from './src/screens/ComposeScreen'; import EmailThreadScreen from './src/screens/EmailThreadScreen'; -import EmailSourceScreen from './src/screens/EmailSourceScreen'; import LoginScreen from './src/screens/LoginScreen'; import MailTab from './src/screens/MailTab'; -import FilesScreen from './src/screens/FilesScreen'; -import CalendarScreen from './src/screens/CalendarScreen'; -import ContactsTab from './src/screens/ContactsTab'; import ContactDetailScreen from './src/screens/ContactDetailScreen'; -import ContactFormScreen from './src/screens/ContactFormScreen'; import GroupDetailScreen from './src/screens/GroupDetailScreen'; import SettingsScreen from './src/screens/SettingsScreen'; -import ScheduledScreen from './src/screens/ScheduledScreen'; -import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen'; +// Screens that aren't on the way to the inbox load on first visit +// (getComponent below), so their modules stay out of the startup work. import { useAccountStore } from './src/stores/account-store'; import { useAuthStore } from './src/stores/auth-store'; import { useCalendarStore } from './src/stores/calendar-store'; @@ -322,7 +316,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps require('./src/screens/CalendarScreen').default} options={{ title: tabLabels.calendar, tabBarButtonTestID: 'tab-calendar', @@ -338,7 +332,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps require('./src/screens/ContactsTab').default} options={{ title: tabLabels.contacts, tabBarButtonTestID: 'tab-contacts', @@ -354,7 +348,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps require('./src/screens/FilesScreen').default} options={{ title: tabLabels.files, tabBarButtonTestID: 'tab-files', @@ -904,10 +898,10 @@ export default function App() { - + require('./src/screens/EmailSourceScreen').default} /> require('./src/screens/ComposeScreen').default} // A tablet shows the composer as a panel over the screen it came // from, so that screen stays drawn behind the dimmed backdrop. options={isSplit @@ -917,15 +911,15 @@ export default function App() { require('./src/screens/ContactFormScreen').default} options={{ presentation: 'modal', animation: 'slide_from_bottom', }} /> - - + require('./src/screens/ScheduledScreen').default} /> + require('./src/screens/UnifiedInboxScreen').default} /> ({ + transform: { + experimentalImportSupport: true, + inlineRequires: true, + }, +}); + module.exports = config; diff --git a/src/api/__tests__/jmap-client-session-cache.test.ts b/src/api/__tests__/jmap-client-session-cache.test.ts new file mode 100644 index 00000000..f0067c20 --- /dev/null +++ b/src/api/__tests__/jmap-client-session-cache.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const secure = new Map(); +vi.mock('expo-secure-store', () => ({ + setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }), + getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null), + deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }), +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { JMAPClient, AuthenticationError, SessionSupersededError } from '../jmap-client'; +import type { JMAPSession } from '../types'; +import { setKnownAccounts } from '../../lib/account-utils'; + +// Start-up used to wait for the server's session document before the +// account could show. The client now keeps the last one per account and +// starts from it (loadAccountFromCache), then fetches a fresh one. + +const ACCOUNT = 'user@mail.example.com'; +const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' }; + +const SESSION: JMAPSession = { + apiUrl: 'https://mail.example.com/jmap/', + downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://mail.example.com/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/eventsource/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' }, + accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } }, + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} }, + state: 'state-1', +}; + +function mockFetch(responses: Array<{ status: number; json?: unknown }>) { + let i = 0; + const fn = vi.fn(async (url: string) => { + const r = responses[i++] ?? responses[responses.length - 1]; + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + statusText: r.status === 200 ? 'OK' : 'Error', + url, + redirected: false, + headers: { get: () => null }, + json: async () => r.json, + text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)), + }; + }); + vi.stubGlobal('fetch', fn); + return fn; +} + +async function storeCredentials() { + const SecureStore = await import('expo-secure-store'); + await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS)); +} + +beforeEach(async () => { + secure.clear(); + await AsyncStorage.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setKnownAccounts([]); +}); + +describe('saved session', () => { + it('starts from the session of the last connect, without a request', async () => { + await storeCredentials(); + mockFetch([{ status: 200, json: SESSION }]); + expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true); + await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1)); + + const fetch = mockFetch([{ status: 500 }]); + const client = new JMAPClient(); + expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + expect(client.accountId).toBe('acc-1'); + expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl); + }); + + it('has nothing to start from before the first connect', async () => { + await storeCredentials(); + expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false); + }); + + it('ignores a session saved for another server', async () => { + await storeCredentials(); + await AsyncStorage.setItem( + `jmap_session_cache__${ACCOUNT}`, + JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }), + ); + expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false); + }); + + it('is dropped with the credentials', async () => { + await storeCredentials(); + await AsyncStorage.setItem( + `jmap_session_cache__${ACCOUNT}`, + JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }), + ); + await new JMAPClient().clearAccountCredentials(ACCOUNT); + expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull(); + }); + + it('revalidates against the server and reports refused credentials', async () => { + await storeCredentials(); + await AsyncStorage.setItem( + `jmap_session_cache__${ACCOUNT}`, + JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }), + ); + const client = new JMAPClient(); + await client.loadAccountFromCache(ACCOUNT); + mockFetch([{ status: 401 }]); + await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError); + }); + + it('drops a revalidation that finishes after a switch to another account', async () => { + await storeCredentials(); + await AsyncStorage.setItem( + `jmap_session_cache__${ACCOUNT}`, + JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }), + ); + const client = new JMAPClient(); + await client.loadAccountFromCache(ACCOUNT); + mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]); + const pending = client.revalidateSession(); + client.reset(); + expect(await pending).toBeNull(); + expect(client.currentSession).toBeNull(); + }); +}); + +// Account changes overlap (a switch while the start is still reading the +// saved session, or while it checks it with the server). The client's +// session epoch decides which one owns the client; the saved-session start +// takes part in it like any load. +describe('saved session and overlapping account changes', () => { + const OTHER = 'other@mail.example.com'; + const OTHER_CREDS = { serverUrl: 'https://mail.example.com', username: 'other', password: 'pass2' }; + const OTHER_SESSION: JMAPSession = { + ...SESSION, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-2', 'urn:ietf:params:jmap:core': 'acc-2' }, + accounts: { 'acc-2': { name: 'other@example.com', isPersonal: true, isReadOnly: false } }, + }; + + async function saveSession() { + await storeCredentials(); + await AsyncStorage.setItem( + `jmap_session_cache__${ACCOUNT}`, + JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }), + ); + } + + it('does not let a saved session read before a switch land after it', async () => { + await saveSession(); + const SecureStore = await import('expo-secure-store'); + await SecureStore.setItemAsync(`jmap_credentials__${OTHER.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(OTHER_CREDS)); + + // The saved session comes off storage slowly. + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const realGetItem = AsyncStorage.getItem.bind(AsyncStorage); + vi.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(async (key: string) => { + await gate; + return realGetItem(key); + }); + + const client = new JMAPClient(); + const cachedStart = client.loadAccountFromCache(ACCOUNT); + mockFetch([{ status: 200, json: OTHER_SESSION }]); + expect(await client.loadAccount(OTHER)).toBe(true); + release(); + + await expect(cachedStart).rejects.toBeInstanceOf(SessionSupersededError); + expect(client.accountId).toBe('acc-2'); + expect(client.username).toBe('other'); + }); + + it('drops a check of the saved session when a snapshot is put back meanwhile', async () => { + await saveSession(); + const client = new JMAPClient(); + await client.loadAccountFromCache(ACCOUNT); + const left = client.snapshot(); + mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]); + + const pending = client.revalidateSession(); + // A switch that failed puts the account back: same credentials, but a + // newer account change than the check. + client.restoreSnapshot(left); + + expect(await pending).toBeNull(); + expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl); + }); + + it('keeps a check of the saved session across a credential update of the same account', async () => { + await saveSession(); + const client = new JMAPClient(); + await client.loadAccountFromCache(ACCOUNT); + mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]); + + const pending = client.revalidateSession(); + // A password change replaces the credentials object; the account and + // the account change are the same. + await client.updatePassword('new-pass'); + + expect((await pending)?.apiUrl).toBe('https://mail.example.com/jmap2/'); + expect(client.currentSession?.apiUrl).toBe('https://mail.example.com/jmap2/'); + }); + + it('serves nothing on credentials the server refused', async () => { + await saveSession(); + const client = new JMAPClient(); + await client.loadAccountFromCache(ACCOUNT); + mockFetch([{ status: 401 }]); + + await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError); + expect(client.currentSession).toBeNull(); + expect(client.isConnected).toBe(false); + }); +}); + +// Accounts signed in before ports were part of ids keep username@host +// (generateAccountId with the registry). The saved session is keyed by the +// same id, so a host-only account and the account of another server on the +// same host never read each other's. +describe('saved session keys', () => { + const HOST_ONLY = 'usera@10.10.10.62'; + const PORT = 'usera@10.10.10.62:8016'; + const LEGACY_CREDS = { serverUrl: 'http://10.10.10.62:8120', username: 'usera', password: 'a' }; + const PORT_CREDS = { serverUrl: 'http://10.10.10.62:8016', username: 'usera', password: 'b' }; + const sessionAt = (base: string, jmapAccount: string): JMAPSession => ({ + ...SESSION, + apiUrl: `${base}/jmap/`, + downloadUrl: `${base}/download/{accountId}/{blobId}/{name}?type={type}`, + uploadUrl: `${base}/upload/{accountId}/`, + eventSourceUrl: `${base}/eventsource/`, + primaryAccounts: { 'urn:ietf:params:jmap:mail': jmapAccount, 'urn:ietf:params:jmap:core': jmapAccount }, + accounts: { [jmapAccount]: { name: 'usera', isPersonal: true, isReadOnly: false } }, + }); + + async function storeBoth() { + const SecureStore = await import('expo-secure-store'); + const key = (id: string) => `jmap_credentials__${id.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + await SecureStore.setItemAsync(key(HOST_ONLY), JSON.stringify(LEGACY_CREDS)); + await SecureStore.setItemAsync(key(PORT), JSON.stringify(PORT_CREDS)); + setKnownAccounts([ + { id: HOST_ONLY, username: 'usera', serverUrl: LEGACY_CREDS.serverUrl }, + { id: PORT, username: 'usera', serverUrl: PORT_CREDS.serverUrl }, + ]); + } + + it('keeps the host-only account and the port account apart', async () => { + await storeBoth(); + mockFetch([{ status: 200, json: sessionAt('http://10.10.10.62:8120', 'a') }]); + await new JMAPClient().loadAccount(HOST_ONLY); + mockFetch([{ status: 200, json: sessionAt('http://10.10.10.62:8016', 'b') }]); + await new JMAPClient().loadAccount(PORT); + await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(2)); + + expect([...(await AsyncStorage.getAllKeys())].sort()).toEqual([ + `jmap_session_cache__${HOST_ONLY}`, + `jmap_session_cache__${PORT}`, + ]); + mockFetch([{ status: 500 }]); + const legacy = new JMAPClient(); + expect(await legacy.loadAccountFromCache(HOST_ONLY)).toBe(true); + expect(legacy.accountId).toBe('a'); + const port = new JMAPClient(); + expect(await port.loadAccountFromCache(PORT)).toBe(true); + expect(port.accountId).toBe('b'); + }); + + it('saves a re-read session under the id the account was loaded with', async () => { + await storeBoth(); + mockFetch([{ status: 200, json: sessionAt('http://10.10.10.62:8120', 'a') }]); + const client = new JMAPClient(); + await client.loadAccount(HOST_ONLY); + await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1)); + + // The registry is read again (the headless task fills it from storage, + // an account entry follows its stored server): the session stays with + // the account it was loaded for, not with username@host:port. + setKnownAccounts([]); + mockFetch([{ status: 200, json: { ...sessionAt('http://10.10.10.62:8120', 'a'), state: 'state-2' } }]); + await client.refreshSession(); + await vi.waitFor(async () => { + const saved = JSON.parse((await AsyncStorage.getItem(`jmap_session_cache__${HOST_ONLY}`))!); + expect(saved.session.state).toBe('state-2'); + }); + expect(await AsyncStorage.getItem('jmap_session_cache__usera@10.10.10.62:8120')).toBeNull(); + }); +}); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 0a8d2ebb..afab8e08 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -1,4 +1,5 @@ import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import type { JMAPSession, JMAPMethodCall, @@ -46,6 +47,20 @@ export function credentialsKey(accountId: string): string { return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_'); } +// The session document from the account's last connect (URLs, account ids, +// capabilities; no secrets). Start-up uses it to show the account without +// waiting for the network, then fetches a fresh one (revalidateSession). +const SESSION_CACHE_PREFIX = 'jmap_session_cache__'; + +function sessionCacheKey(accountId: string): string { + return SESSION_CACHE_PREFIX + accountId; +} + +interface CachedSession { + serverUrl: string; + session: JMAPSession; +} + export interface StoredCredentials { serverUrl: string; username: string; @@ -155,6 +170,12 @@ export class JMAPClient { * install it (see SessionSupersededError). */ private sessionEpoch = 0; + /** + * The local account id the session was loaded or signed in for, which + * keys its saved session document (rememberSession). Null while no + * session is up. + */ + private sessionCacheId: string | null = null; private firstTouchGate = new FirstTouchGate(); private rateLimitedUntil = 0; private authFailureListeners = new Set<(err: AuthenticationError) => void>(); @@ -301,6 +322,7 @@ export class JMAPClient { this.firstTouchGate.reset(); const accountId = generateAccountId(username, baseUrl); + this.sessionCacheId = accountId; await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials)); return session; @@ -380,6 +402,7 @@ export class JMAPClient { this.credentials = credentials; const accountId = generateAccountId(username, baseUrl); + this.sessionCacheId = accountId; await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials)); return { session, username, accountId }; @@ -427,6 +450,7 @@ export class JMAPClient { this.credentials = credentials; const accountId = generateAccountId(username, baseUrl); + this.sessionCacheId = accountId; await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials)); return { session, username, accountId }; @@ -671,6 +695,8 @@ export class JMAPClient { const session = this.rewriteSessionUrls(fetched, creds.serverUrl); this.session = session; this._accountId = this.resolveAccountId(session); + this.sessionCacheId = accountId; + this.rememberSession(); return true; } @@ -685,6 +711,7 @@ export class JMAPClient { private detachSession(): number { this.session = null; this._accountId = null; + this.sessionCacheId = null; this.sessionEpoch += 1; return this.sessionEpoch; } @@ -699,6 +726,94 @@ export class JMAPClient { if (epoch !== this.sessionEpoch) throw new SessionSupersededError(); } + /** + * Start-up without a network round trip: the account's credentials and the + * session document saved at its last connect. Returns false when either is + * missing (or the saved one is for another server); the caller then uses + * loadAccount. A wrong password or revoked token surfaces on the first + * request (401, onAuthFailure) or in revalidateSession. Like loadAccount it + * hands the client to the account at once, and throws + * SessionSupersededError when another account change began while the saved + * session was read: that one owns the client. + */ + async loadAccountFromCache(accountId: string): Promise { + const epoch = this.detachSession(); + const [stored, cachedRaw] = await Promise.all([ + SecureStore.getItemAsync(credentialsKey(accountId)), + AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null), + ]); + this.assertCurrentSession(epoch); + if (!stored || !cachedRaw) return false; + let creds: StoredCredentials; + let cached: CachedSession; + try { + creds = JSON.parse(stored); + cached = JSON.parse(cachedRaw); + } catch { + return false; + } + if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false; + let accountIdInSession: string; + try { + accountIdInSession = this.resolveAccountId(cached.session); + } catch { + return false; + } + // As in loadAccount: iOS credentials saved before the notification + // extension existed move to the keychain group it can read. + void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined); + this.credentials = creds; + this.firstTouchGate.reset(); + this.session = cached.session; + this._accountId = accountIdInSession; + this.sessionCacheId = accountId; + return true; + } + + /** + * Fetch a fresh session document for the account the client holds now, + * after a start from the saved one. Resolves null when the client was + * handed to another account meanwhile (a switch, sign-in, sign-out or a + * snapshot put back: the session epoch moved), and the result is dropped. + * On a failure the saved session goes, as loadAccount leaves none behind, + * and the error is thrown: AuthenticationError for refused credentials, + * anything else for a server that could not be reached. + */ + async revalidateSession(): Promise { + const creds = this.credentials; + if (!creds || !this.session) return null; + const epoch = this.sessionEpoch; + let fresh: JMAPSession; + let accountIdInSession: string; + try { + fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl); + if (epoch !== this.sessionEpoch) return null; + accountIdInSession = this.resolveAccountId(fresh); + } catch (err) { + if (epoch !== this.sessionEpoch) return null; + this.session = null; + this._accountId = null; + throw err; + } + this.session = fresh; + this._accountId = accountIdInSession; + this.rememberSession(); + return fresh; + } + + /** + * Save the live session document for the next start, under the local + * account id the session was loaded for (the registry's, which for an + * account signed in before ports were part of ids is username@host). + */ + private rememberSession(): void { + if (!this.session || !this.credentials) return; + const accountId = this.sessionCacheId + ?? generateAccountId(this.credentials.username, this.credentials.serverUrl); + const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session }; + void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined); + } + /** * Snapshot of the live connection so a failed "add account" / switch can put * the previous account back without a network round-trip. @@ -708,6 +823,7 @@ export class JMAPClient { session: this.session, credentials: this.credentials, accountId: this._accountId, + sessionCacheId: this.sessionCacheId, }; } @@ -716,17 +832,20 @@ export class JMAPClient { this.session = snap.session; this.credentials = snap.credentials; this._accountId = snap.accountId; + this.sessionCacheId = snap.sessionCacheId ?? null; this.firstTouchGate.reset(); } async clearAccountCredentials(accountId: string): Promise { await SecureStore.deleteItemAsync(credentialsKey(accountId)); + await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined); } async clearAllCredentials(accountIds: string[]): Promise { await Promise.all([ SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY), ...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))), + ...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)), ]); } @@ -788,6 +907,7 @@ export class JMAPClient { this.session = null; this.credentials = null; this._accountId = null; + this.sessionCacheId = null; this.firstTouchGate.reset(); this.rateLimitedUntil = 0; } @@ -800,6 +920,7 @@ export class JMAPClient { await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY); if (currentAccountId) { await SecureStore.deleteItemAsync(credentialsKey(currentAccountId)); + await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined); } } @@ -1023,6 +1144,7 @@ export class JMAPClient { const session = this.rewriteSessionUrls(fetched, baseUrl); this.session = session; this._accountId = this.resolveAccountId(session); + this.rememberSession(); return session; } @@ -1390,6 +1512,8 @@ export interface ClientSnapshot { session: JMAPSession | null; credentials: StoredCredentials | null; accountId: string | null; + /** The local account id the session's saved document is kept under. */ + sessionCacheId?: string | null; } // ── URL helpers ────────────────────────────────────────── diff --git a/src/stores/__tests__/auth-store-server-change.test.ts b/src/stores/__tests__/auth-store-server-change.test.ts index 6b0a2e9a..8c43d9cd 100644 --- a/src/stores/__tests__/auth-store-server-change.test.ts +++ b/src/stores/__tests__/auth-store-server-change.test.ts @@ -28,6 +28,12 @@ const { client } = vi.hoisted(() => { client: { session, requests: [] as Array, string]>>, + // Milliseconds each request takes (0: answered on the next tick). + latency: 0, + // The new server takes the old states, and skips the changes since. + acceptsOldStates: false, + // What the new server's Mailbox/get lists. + mailboxes: [] as unknown[], }, }; }); @@ -35,6 +41,9 @@ const { client } = vi.hoisted(() => { vi.mock('../../api/jmap-client', () => ({ jmapClient: { loadAccount: vi.fn(async () => true), + loadAccountFromCache: vi.fn(async () => false), + revalidateSession: vi.fn(async () => null), + getStoredCredentials: vi.fn(async () => null), consumeLegacyCredentials: vi.fn(async () => null), clearAccountCredentials: vi.fn(async () => undefined), reset: vi.fn(), @@ -51,12 +60,18 @@ vi.mock('../../api/jmap-client', () => ({ getAccountName: () => 'user', request: vi.fn(async (calls: Array<[string, Record, string]>) => { client.requests.push(calls); + if (client.latency) await new Promise((r) => setTimeout(r, client.latency)); // The 1.0 server knows nothing of the 0.16.23 states. return { methodResponses: calls.map(([name, args, id]) => (name.endsWith('/changes') - ? ['error', { type: 'cannotCalculateChanges' }, id] + ? (client.acceptsOldStates + ? [name, { + accountId: args.accountId, oldState: args.sinceState, newState: 'sfy', + created: [], updated: [], destroyed: [], hasMoreChanges: false, + }, id] + : ['error', { type: 'cannotCalculateChanges' }, id]) : [name, { - accountId: args.accountId, state: 'sfy', list: [], notFound: [], + accountId: args.accountId, state: 'sfy', list: name === 'Mailbox/get' ? client.mailboxes : [], notFound: [], ...(name === 'Email/set' ? { updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])) } : {}), @@ -65,6 +80,7 @@ vi.mock('../../api/jmap-client', () => ({ }), getStoredOAuthTokens: vi.fn(async () => null), accountId: 'f', + connectedAccountId: 'f', isConnected: true, currentSession: client.session('1.0'), username: 'user', @@ -72,6 +88,7 @@ vi.mock('../../api/jmap-client', () => ({ }, AuthenticationError: class AuthenticationError extends Error {}, NetworkError: class NetworkError extends Error {}, + SessionSupersededError: class SessionSupersededError extends Error {}, })); vi.mock('../../lib/push-notifications', () => ({ @@ -103,6 +120,9 @@ beforeEach(async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); await AsyncStorage.clear(); client.requests.length = 0; + client.latency = 0; + client.acceptsOldStates = false; + client.mailboxes = []; // A cold start: nothing is attached yet. await useOutboxStore.getState().setAccount(null); await useOfflineCacheStore.getState().setAccount(null); @@ -208,3 +228,61 @@ describe('restoring the session on a replaced server', () => { expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); }); }); + +// A start from the session saved at the last connect sends its reads with +// that session, before the server answered. Until the server confirmed the +// session and its check ran, the queue waits; on a replaced server what the +// early reads bring back is dropped with the rest, never kept. +describe('starting from the saved session', () => { + async function startFromSavedSession(server: string) { + const { jmapClient } = await import('../../api/jmap-client'); + vi.mocked(jmapClient.loadAccountFromCache).mockResolvedValueOnce(true); + let confirm!: () => void; + vi.mocked(jmapClient.revalidateSession).mockReturnValueOnce( + new Promise((resolve) => { confirm = () => resolve(session(server)); }), + ); + expect(await useAuthStore.getState().restoreSession()).toBe(true); + return confirm; + } + + it('sends the queue only once the server confirmed the saved session (Stalwart 1.0)', async () => { + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('1.0'))); + await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(QUEUED)); + + const confirm = await startFromSavedSession('1.0'); + await new Promise((r) => setTimeout(r, 20)); + // The reads went out without waiting; the queue did not. + expect(client.requests.flat().some(([name]) => name === 'Mailbox/changes')).toBe(true); + expect(emailSets()).toEqual([]); + + confirm(); + await vi.waitFor(() => expect(emailSets()).toHaveLength(1)); + expect(emailSets()[0][1]).toEqual({ accountId: 'f', update: { b: { 'keywords/$seen': true } } }); + }); + + it('keeps nothing the early reads brought back from a replaced server (Stalwart 0.16.23 to 1.0)', async () => { + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23'))); + await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(QUEUED)); + // The early reads are still on their way when the session arrives, and + // the new server answers the old folder state with "no changes". + client.latency = 40; + client.acceptsOldStates = true; + client.mailboxes = [{ + id: 'm1', name: 'Inbox', role: 'inbox', parentId: null, sortOrder: 0, + totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: {}, isSubscribed: true, + }]; + + const confirm = await startFromSavedSession('1.0'); + confirm(); + await vi.waitFor(async () => expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0')))); + await new Promise((r) => setTimeout(r, 150)); + + const email = useEmailStore.getState(); + expect(JSON.stringify([email.mailboxState, email.emailStates, email.queryState, email.mailboxSnapshots])).not.toContain('s1iaq'); + // The folder list was read again after the drop, from the new server. + expect(email.mailboxes.map((m) => m.id)).toEqual(['m1']); + expect(emailSets()).toEqual([]); + expect(useOutboxStore.getState().entries).toEqual([]); + expect(await AsyncStorage.getItem(OUTBOX_KEY)).toBeNull(); + }); +}); diff --git a/src/stores/__tests__/auth-store.test.ts b/src/stores/__tests__/auth-store.test.ts index 9e301a75..192d9f38 100644 --- a/src/stores/__tests__/auth-store.test.ts +++ b/src/stores/__tests__/auth-store.test.ts @@ -6,6 +6,8 @@ vi.mock('../../api/jmap-client', () => ({ logout: vi.fn(), restoreSession: vi.fn(), loadAccount: vi.fn(), + loadAccountFromCache: vi.fn(async () => false), + revalidateSession: vi.fn(async () => null), consumeLegacyCredentials: vi.fn(async () => null), clearAccountCredentials: vi.fn(async () => undefined), clearAllCredentials: vi.fn(async () => undefined), @@ -17,7 +19,9 @@ vi.mock('../../api/jmap-client', () => ({ hasAccountCapability: vi.fn(() => false), request: vi.fn(async () => { throw new Error('not mocked'); }), getStoredOAuthTokens: vi.fn(async () => null), + getStoredCredentials: vi.fn(async () => null), accountId: 'acc-1', + connectedAccountId: 'acc-1', currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, username: 'user', serverUrl: 'https://mail.example.com', @@ -28,6 +32,7 @@ vi.mock('../../api/jmap-client', () => ({ NetworkError: class NetworkError extends Error { constructor(msg: string) { super(msg); this.name = 'NetworkError'; } }, + SessionSupersededError: class SessionSupersededError extends Error {}, })); vi.mock('../../lib/push-notifications', () => ({ @@ -35,12 +40,13 @@ vi.mock('../../lib/push-notifications', () => ({ teardownPushNotificationsForAccount: vi.fn(async () => undefined), })); -import { jmapClient } from '../../api/jmap-client'; +import { jmapClient, AuthenticationError, NetworkError } from '../../api/jmap-client'; import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store'; import { useAccountStore } from '../account-store'; import { useCalendarStore } from '../calendar-store'; import { useContactsStore } from '../contacts-store'; import { useEmailStore } from '../email-store'; +import { useOutboxStore } from '../outbox-store'; import type { Email } from '../../api/types'; import { peekRow, rememberRows } from '../../lib/email-detail-cache'; import { bodyDocument } from '../../lib/email-body-document'; @@ -49,6 +55,8 @@ import { lastBodyHeight, rememberBodyHeight } from '../../lib/body-heights'; const mockConnect = jmapClient.connect as ReturnType; const mockLogout = jmapClient.logout as ReturnType; const mockLoadAccount = jmapClient.loadAccount as ReturnType; +const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType; +const mockRevalidate = jmapClient.revalidateSession as ReturnType; function resetAccountStore(): void { useAccountStore.setState({ @@ -236,6 +244,173 @@ describe('auth-store', () => { expect(useAuthStore.getState().isAuthenticated).toBe(true); }); + describe('from the session saved at the last connect', () => { + const account = (id: string, isDefault: boolean) => ({ + id, serverUrl: 'https://mail.example.com', username: id, + displayName: id, email: id, avatarColor: '#000', lastLoginAt: 0, + isConnected: true, hasError: false, isDefault, + }); + + function registerAccount() { + useAccountStore.setState({ + accounts: [account('acc-1', true)], + activeAccountId: 'acc-1', + defaultAccountId: 'acc-1', + }); + } + + function registerTwoAccounts() { + useAccountStore.setState({ + accounts: [account('acc-1', true), account('acc-2', false)], + activeAccountId: 'acc-1', + defaultAccountId: 'acc-1', + }); + } + + function deferred() { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; + } + + const resume = vi.fn(async () => undefined); + const realResume = useOutboxStore.getState().resume; + beforeEach(() => { + // clearAllMocks keeps queued once-values; a test that fails early + // must not hand its leftovers to the next one. + mockLoadAccount.mockReset(); + mockLoadFromCache.mockReset().mockResolvedValue(false); + mockRevalidate.mockReset().mockResolvedValue(null); + resume.mockClear(); + useOutboxStore.setState({ resume }); + return () => useOutboxStore.setState({ resume: realResume }); + }); + + // Start-up used to wait for the server's session document before the + // cached inbox could show. + it('shows the account without waiting for the server', async () => { + registerAccount(); + mockLoadFromCache.mockResolvedValueOnce(true); + mockRevalidate.mockReturnValueOnce(new Promise(() => undefined)); + + const restored = await useAuthStore.getState().restoreSession(); + + expect(restored).toBe(true); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().activeAccountId).toBe('acc-1'); + expect(mockLoadAccount).not.toHaveBeenCalled(); + expect(mockRevalidate).toHaveBeenCalledTimes(1); + }); + + // What waits for a live session (push setup, the offline sync, the + // outbox) must not run on the saved one before the server confirmed it + // and its server check ran. + it('counts the session as live, and resumes the outbox, only once the server confirmed it', async () => { + registerAccount(); + mockLoadFromCache.mockResolvedValueOnce(true); + const check = deferred(); + mockRevalidate.mockReturnValueOnce(check.promise); + + await useAuthStore.getState().restoreSession(); + await new Promise((r) => setTimeout(r, 10)); + + expect(useAuthStore.getState().session).toBeNull(); + expect(resume).not.toHaveBeenCalledWith('acc-1'); + + check.resolve({ apiUrl: 'https://mail.example.com/jmap/' }); + await vi.waitFor(() => expect(useAuthStore.getState().session).not.toBeNull()); + expect(resume).toHaveBeenCalledWith('acc-1'); + expect(useAccountStore.getState().accounts[0]).toMatchObject({ isConnected: true, hasError: false }); + }); + + // A 401 used to evict the account (and everything kept for it) with + // "Session expired". + it('keeps the account for a new sign-in when the server refuses the stored credentials', async () => { + registerAccount(); + mockLoadFromCache.mockResolvedValueOnce(true); + mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials')); + + await useAuthStore.getState().restoreSession(); + await vi.waitFor(() => expect(useAuthStore.getState().reauth).not.toBeNull()); + + expect(useAuthStore.getState().reauth).toMatchObject({ accountId: 'acc-1' }); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().session).toBeNull(); + expect(useAccountStore.getState().accounts).toHaveLength(1); + expect(useAccountStore.getState().accounts[0]).toMatchObject({ needsSignIn: true, isConnected: false }); + // The refusal is the answer: no second round trip to hear it again. + expect(mockLoadAccount).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalledWith('acc-1'); + }); + + it('stays signed in and marks the account offline without network', async () => { + registerAccount(); + mockLoadFromCache.mockResolvedValueOnce(true); + mockRevalidate.mockRejectedValueOnce(new Error('Network request failed')); + + await useAuthStore.getState().restoreSession(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].hasError).toBe(true)); + + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAccountStore.getState().accounts[0].isConnected).toBe(false); + }); + + it('does not start from the saved session for an account that must sign in again', async () => { + registerAccount(); + useAccountStore.getState().updateAccount('acc-1', { needsSignIn: true }); + mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials')); + + await useAuthStore.getState().restoreSession(); + + expect(mockLoadFromCache).not.toHaveBeenCalled(); + expect(useAuthStore.getState().reauth).toMatchObject({ accountId: 'acc-1' }); + }); + + // The check of the saved session is the older account change once a + // switch began: what it learns must not land on the account in use, + // even when that is the same account again. + it('drops a refusal of the saved session that arrives after switching away and back', async () => { + registerTwoAccounts(); + mockLoadFromCache.mockResolvedValueOnce(true); + const check = deferred(); + mockRevalidate.mockReturnValueOnce(check.promise); + await useAuthStore.getState().restoreSession(); + + mockLoadAccount.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + await useAuthStore.getState().switchAccount('acc-2'); + await useAuthStore.getState().switchAccount('acc-1'); + expect(useAuthStore.getState().session).not.toBeNull(); + + check.reject(new AuthenticationError('Invalid credentials')); + await new Promise((r) => setTimeout(r, 10)); + + expect(useAuthStore.getState().activeAccountId).toBe('acc-1'); + expect(useAuthStore.getState().session).not.toBeNull(); + expect(useAuthStore.getState().reauth).toBeNull(); + expect(useAccountStore.getState().getAccountById('acc-1')?.needsSignIn).toBeFalsy(); + }); + + // A switch that fails puts the account back. Its saved session was + // never confirmed (the switch overtook the check), so the check runs + // again, and the outbox waits for it. + it('checks the saved session again when a switch that overtook the check fails', async () => { + registerTwoAccounts(); + mockLoadFromCache.mockResolvedValueOnce(true); + mockRevalidate.mockReturnValueOnce(new Promise(() => undefined)); + await useAuthStore.getState().restoreSession(); + + mockLoadAccount.mockRejectedValueOnce(new NetworkError('offline')); + mockRevalidate.mockResolvedValueOnce({ apiUrl: 'https://mail.example.com/jmap/' }); + await useAuthStore.getState().switchAccount('acc-2'); + + expect(useAuthStore.getState().activeAccountId).toBe('acc-1'); + expect(mockRevalidate).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(useAuthStore.getState().session).not.toBeNull()); + expect(resume).toHaveBeenCalledWith('acc-1'); + }); + }); + it('stops waiting for a persisted store that never finishes hydrating', async () => { vi.useFakeTimers(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 0b454cc0..8b514472 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -39,6 +39,7 @@ import { teardownPushNotificationsForAccount, } from '../lib/push-notifications'; import { reconcileServerFingerprint } from '../lib/server-change'; +import { readServerFingerprint, sessionFingerprint } from '../lib/server-fingerprint'; import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle'; import { perfMark } from '../lib/perf-marks'; import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort'; @@ -213,12 +214,13 @@ function clearAccountFeatureStores(accountId: string | null): void { // Once a session is up and before anything reads the account's caches: when // the server behind it is not the one that filled them, they go (see // lib/server-change). A failed check never blocks signing in; it runs again -// with the next session. -async function checkServerChange(accountId: string, session: JMAPSession): Promise { +// with the next session. Resolves to true when the caches were dropped. +async function checkServerChange(accountId: string, session: JMAPSession): Promise { try { - await reconcileServerFingerprint(accountId, session); + return await reconcileServerFingerprint(accountId, session); } catch (err) { console.warn('[auth-store] server change check failed:', err); + return false; } } @@ -317,6 +319,11 @@ let signInSaved: { client: ClientSnapshot; isCurrent: () => boolean } | null = n // the app went to the sign-in screen with every account still registered. let restoreUnderWay: Promise | null = null; +// The account a cold start brought up from the session saved at its last +// connect, until the server confirms it (revalidateRestoredSession). Its +// session is not live yet: its server was not checked and its outbox waits. +let unconfirmedStart: string | null = null; + async function switchesSettled(): Promise { while (pendingSwitch) await pendingSwitch.settled; } @@ -448,23 +455,31 @@ async function signIn( function refetchFeatureStores(accountId: string): void { resumeOutbox(accountId); + void reloadFeatureStores(); +} + +// The reads of refetchFeatureStores, without the outbox: a start from the +// saved session runs them before the server was checked (see +// revalidateRestoredSession). Resolves when they have all ended. +function reloadFeatureStores(): Promise { // Fire-and-forget: each store handles its own errors. const emailStore = useEmailStore.getState(); - void emailStore.fetchMailboxes(); + const reads: Promise[] = [emailStore.fetchMailboxes()]; // If a mailbox was selected before this restore (cached from last session), // refresh its contents so the user sees up-to-date mail without manually // pulling to refresh. if (emailStore.currentMailboxId) { - void emailStore.refreshEmails(); + reads.push(emailStore.refreshEmails()); } - void useContactsStore.getState().fetchContacts(); + reads.push(useContactsStore.getState().fetchContacts()); const calendarStore = useCalendarStore.getState(); - void calendarStore.fetchCalendars(); + reads.push(calendarStore.fetchCalendars()); // Refresh the event range cached from last session (if any) so recurring // events reflect new invitations / cancellations without the user swiping. if (calendarStore.loadedRange) { - void calendarStore.refresh(); + reads.push(calendarStore.refresh()); } + return Promise.allSettled(reads).then(() => undefined); } // Pairing codes this app process has redeemed or is redeeming. Codes are @@ -603,6 +618,9 @@ function applyConnectedState( client: jmapClient, reauth: null, }); + // A session from the server, or a start from the saved one, which sets + // this again right after. + unconfirmedStart = null; } export const useAuthStore = create((set, get) => ({ @@ -990,6 +1008,8 @@ export const useAuthStore = create((set, get) => ({ // leaving the previous account dead until relaunch. With switches under // way, that is the account the first of them left; with an add-account // sign-in under way, the connection it saved. + // (A start from the saved session is not live until the server + // confirmed it: its session in this store stays null till then.) const origin: SwitchOrigin = underWay?.origin ?? { accountId: get().activeAccountId, live: get().session !== null, @@ -1024,6 +1044,7 @@ export const useAuthStore = create((set, get) => ({ // still in the snapshot taken then, and its server was checked. jmapClient.restoreSnapshot(origin.client); if (origin.live) refetchFeatureStores(accountId); + else if (unconfirmedStart === accountId) void revalidateRestoredSession(accountId); set({ isLoading: false }); return; } @@ -1036,6 +1057,10 @@ export const useAuthStore = create((set, get) => ({ // Its session and server check still hold; without one, the next // session resumes the outbox. if (origin.accountId && origin.live) resumeOutbox(origin.accountId); + // A start from the saved session whose check this switch overtook. + else if (origin.accountId && unconfirmedStart === origin.accountId) { + void revalidateRestoredSession(origin.accountId); + } }; try { const ok = await jmapClient.loadAccount(accountId); @@ -1170,6 +1195,48 @@ export const useAuthStore = create((set, get) => ({ // data from a previous session. useEmailStore.getState().setActiveAccount(target.id); + // Start from the session saved at the last connect: the cached mail + // shows without waiting for the server, and a fresh session follows. + // Not for an account the server refused: only a new sign-in brings it + // back, and the load below asks for one. + let fromCache = false; + if (!target.needsSignIn) { + try { + fromCache = await jmapClient.loadAccountFromCache(target.id); + } catch (err) { + // A sign-in or sign-out that began meanwhile owns the client. + if (err instanceof SessionSupersededError) throw err; + fromCache = false; + } + } + if (fromCache) { + accountStore.setActiveAccount(target.id); + followStoredServer(target.id); + // The client serves the saved session, so the reads go out now. The + // session here stays null until the server confirmed it: what waits + // for a live one (push setup, the offline sync, the outbox) waits + // for that, as on a start without the saved session. + set({ + isAuthenticated: true, + isLoading: false, + hasRestoredSession: true, + error: null, + serverUrl: target.serverUrl, + username: target.username, + session: null, + accountId: jmapClient.accountId, + activeAccountId: target.id, + client: jmapClient, + reauth: null, + }); + unconfirmedStart = target.id; + perfMark('restore-done', { cached: true }); + const reads = reloadFeatureStores(); + void revalidateRestoredSession(target.id, reads); + void syncAccountDisplayName(target.id); + return true; + } + try { const ok = await jmapClient.loadAccount(target.id); perfMark('restore-session'); @@ -1317,6 +1384,71 @@ export const useAuthStore = create((set, get) => ({ clearReauth: () => set({ reauth: null }), })); +/** + * After a start from the saved session: fetch a fresh one, then do what a + * load from the server does before the account counts as up: follow the + * server its credentials sign in to, check the server was not replaced, mark + * the account connected, put the session in this store and resume the + * outbox. A refused sign-in keeps the account and asks for a new one, as a + * load refused at start does (keepForSignIn); no network marks the account + * offline and drops the saved session, as a start without network does, and + * the session retry brings it up. A switch, sign-in or sign-out that begins + * meanwhile owns the client and the stores: the client drops a session that + * arrives after it (its epoch moved), and the stores are left alone. + * + * `reads` are the ones the start sent with the saved session. When the + * server may have been replaced they end before the check, so nothing they + * bring back lands after it dropped the caches. + */ +async function revalidateRestoredSession(accountId: string, reads?: Promise): Promise { + const generation = accountChange; + const isCurrent = () => generation === accountChange + && useAuthStore.getState().activeAccountId === accountId; + const before = jmapClient.connectedAccountId; + let fresh: JMAPSession | null; + try { + fresh = await jmapClient.revalidateSession(); + } catch (err) { + if (!isCurrent() || err instanceof SessionSupersededError) return; + unconfirmedStart = null; + if (err instanceof AuthenticationError) { + const reauth = await keepForSignIn(accountId); + if (isCurrent()) useAuthStore.setState({ session: null, accountId: null, isLoading: false, error: null, reauth }); + return; + } + useAccountStore.getState().updateAccount(accountId, { + isConnected: false, + hasError: true, + errorMessage: err instanceof Error ? err.message : String(err), + }); + useAuthStore.setState({ session: null, accountId: null }); + return; + } + if (!fresh || !isCurrent()) return; + followStoredServer(accountId); + if (reads) { + const recorded = await readServerFingerprint(accountId).catch(() => null); + if (recorded !== sessionFingerprint(fresh)) await reads; + if (!isCurrent()) return; + } + const dropped = await checkServerChange(accountId, fresh); + // A keep-alive may have read the session again meanwhile. + const live = jmapClient.currentSession; + if (!isCurrent() || !live) return; + unconfirmedStart = null; + useAccountStore.getState().updateAccount(accountId, { + isConnected: true, + hasError: false, + errorMessage: undefined, + needsSignIn: false, + }); + useAuthStore.setState({ session: live, accountId: jmapClient.accountId }); + resumeOutbox(accountId); + // The reads made from the saved session went to another JMAP account, or + // into caches the server check has just dropped. + if (dropped || jmapClient.connectedAccountId !== before) void reloadFeatureStores(); +} + // A 401 on a live session (revoked password/token, expired refresh token) // used to leave the user on a dead session until relaunch: nothing outside // the login flow handled `AuthenticationError`, and `retrySession` bailed