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..e916b221 --- /dev/null +++ b/src/api/__tests__/jmap-client-session-cache.test.ts @@ -0,0 +1,130 @@ +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 } from '../jmap-client'; +import type { JMAPSession } from '../types'; + +// 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(); +}); + +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(); + }); +}); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 04c3ddc3..98c87cd4 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, @@ -44,6 +45,20 @@ 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; @@ -524,9 +539,74 @@ export class JMAPClient { ); } this._accountId = this.resolveAccountId(this.session); + this.rememberSession(accountId); return true; } + /** + * 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. + */ + async loadAccountFromCache(accountId: string): Promise { + const [stored, cachedRaw] = await Promise.all([ + SecureStore.getItemAsync(credentialsKey(accountId)), + AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null), + ]); + 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; + } + this.credentials = creds; + this.firstTouchGate.reset(); + this.session = cached.session; + this._accountId = accountIdInSession; + 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 moved to + * another account meanwhile (the result is dropped). Throws + * AuthenticationError / NetworkError like loadAccount. + */ + async revalidateSession(): Promise { + const creds = this.credentials; + if (!creds) return null; + let fresh: JMAPSession; + try { + fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl); + } catch (err) { + if (this.credentials !== creds) return null; + throw err; + } + if (this.credentials !== creds) return null; + this.session = fresh; + this._accountId = this.resolveAccountId(fresh); + this.rememberSession(generateAccountId(creds.username, creds.serverUrl)); + return fresh; + } + + private rememberSession(accountId: string): void { + if (!this.session || !this.credentials) return; + 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. @@ -548,12 +628,14 @@ export class JMAPClient { 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)), ]); } @@ -616,6 +698,7 @@ export class JMAPClient { await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY); if (currentAccountId) { await SecureStore.deleteItemAsync(credentialsKey(currentAccountId)); + await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined); } } @@ -835,6 +918,7 @@ export class JMAPClient { const baseUrl = this.credentials.serverUrl; this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this._accountId = this.resolveAccountId(this.session); + this.rememberSession(generateAccountId(this.credentials.username, baseUrl)); return this.session; } diff --git a/src/stores/__tests__/auth-store.test.ts b/src/stores/__tests__/auth-store.test.ts index 9e301a75..ed7a0280 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), @@ -35,7 +37,7 @@ vi.mock('../../lib/push-notifications', () => ({ teardownPushNotificationsForAccount: vi.fn(async () => undefined), })); -import { jmapClient } from '../../api/jmap-client'; +import { jmapClient, AuthenticationError } from '../../api/jmap-client'; import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store'; import { useAccountStore } from '../account-store'; import { useCalendarStore } from '../calendar-store'; @@ -49,6 +51,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 +240,61 @@ describe('auth-store', () => { expect(useAuthStore.getState().isAuthenticated).toBe(true); }); + describe('from the session saved at the last connect', () => { + function registerAccount() { + useAccountStore.setState({ + accounts: [{ + id: 'acc-1', serverUrl: 'https://mail.example.com', username: 'user', + displayName: 'user', email: 'user', avatarColor: '#000', lastLoginAt: 0, + isConnected: true, hasError: false, isDefault: true, + }], + activeAccountId: 'acc-1', + defaultAccountId: 'acc-1', + }); + } + + // 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); + }); + + it('signs out when the server refuses the stored credentials', async () => { + registerAccount(); + mockLoadFromCache.mockResolvedValueOnce(true); + mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials')); + mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials')); + + await useAuthStore.getState().restoreSession(); + await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false)); + + expect(useAuthStore.getState().error).toBe('Session expired'); + expect(useAccountStore.getState().accounts).toEqual([]); + }); + + 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('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 15d60770..b2c8bbb0 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -811,6 +811,24 @@ 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. + let fromCache = false; + try { + fromCache = await jmapClient.loadAccountFromCache(target.id); + } catch { + fromCache = false; + } + if (fromCache) { + accountStore.setActiveAccount(target.id); + applyConnectedState(set, jmapClient.currentSession!, target.serverUrl, target.username, target.id); + perfMark('restore-done', { cached: true }); + refetchFeatureStores(); + void revalidateRestoredSession(target.id); + void syncAccountDisplayName(target.id); + return true; + } + try { const ok = await jmapClient.loadAccount(target.id); perfMark('restore-session'); @@ -929,6 +947,35 @@ export const useAuthStore = create((set, get) => ({ clearError: () => set({ error: null }), })); +/** + * After a start from the saved session: fetch a fresh one. New capabilities + * or URLs replace the saved ones; bad credentials go the way of any 401 on a + * live session (retrySession, then the sign-in screen); no network marks the + * account offline, as a start without network always did. + */ +async function revalidateRestoredSession(accountId: string): Promise { + const before = jmapClient.accountId; + try { + const fresh = await jmapClient.revalidateSession(); + if (!fresh || useAuthStore.getState().activeAccountId !== accountId) return; + useAuthStore.setState({ session: fresh, accountId: jmapClient.accountId }); + useAccountStore.getState().updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined }); + if (jmapClient.accountId !== before) refetchFeatureStores(); + } catch (err) { + if (useAuthStore.getState().activeAccountId !== accountId) return; + if (err instanceof AuthenticationError) { + useAuthStore.setState({ session: null }); + await useAuthStore.getState().retrySession(); + return; + } + useAccountStore.getState().updateAccount(accountId, { + isConnected: false, + hasError: true, + errorMessage: err instanceof Error ? err.message : String(err), + }); + } +} + // 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