Merge branch 'cu-iosperf' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 14:37:35 +01:00
7 changed files with 834 additions and 27 deletions
+10 -16
View File
@@ -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<RootStackParam
</Tab.Screen>
<Tab.Screen
name="Calendar"
component={CalendarScreen}
getComponent={() => require('./src/screens/CalendarScreen').default}
options={{
title: tabLabels.calendar,
tabBarButtonTestID: 'tab-calendar',
@@ -338,7 +332,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Contacts"
component={ContactsTab}
getComponent={() => require('./src/screens/ContactsTab').default}
options={{
title: tabLabels.contacts,
tabBarButtonTestID: 'tab-contacts',
@@ -354,7 +348,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Files"
component={FilesScreen}
getComponent={() => require('./src/screens/FilesScreen').default}
options={{
title: tabLabels.files,
tabBarButtonTestID: 'tab-files',
@@ -904,10 +898,10 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
getComponent={() => 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() {
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
name="ContactForm"
component={ContactFormScreen}
getComponent={() => require('./src/screens/ContactFormScreen').default}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
<Stack.Screen
name="AddAccount"
options={{
+10
View File
@@ -33,4 +33,14 @@ if (process.env.BULWARK_MODULE_TIMINGS) {
}
}
// Evaluate a module when it is first used instead of all of them before the
// first render. Expo's default leaves this off; React Native's own template
// turns it on. Side-effect-only imports (`import './x'`) still run in place.
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
});
module.exports = config;
@@ -0,0 +1,294 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const secure = new Map<string, string>();
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<void>((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();
});
});
+124
View File
@@ -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<boolean> {
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<JMAPSession | null> {
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<void> {
await SecureStore.deleteItemAsync(credentialsKey(accountId));
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
}
async clearAllCredentials(accountIds: string[]): Promise<void> {
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 ──────────────────────────────────────────
@@ -28,6 +28,12 @@ const { client } = vi.hoisted(() => {
client: {
session,
requests: [] as Array<Array<[string, Record<string, unknown>, 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, unknown>, 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();
});
});
+176 -1
View File
@@ -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<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType<typeof vi.fn>;
const mockRevalidate = jmapClient.revalidateSession as ReturnType<typeof vi.fn>;
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<T>() {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((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<unknown>();
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<unknown>();
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);
+140 -8
View File
@@ -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<void> {
// with the next session. Resolves to true when the caches were dropped.
async function checkServerChange(accountId: string, session: JMAPSession): Promise<boolean> {
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<void> | 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<void> {
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<void> {
// Fire-and-forget: each store handles its own errors.
const emailStore = useEmailStore.getState();
void emailStore.fetchMailboxes();
const reads: Promise<unknown>[] = [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<AuthState>((set, get) => ({
@@ -990,6 +1008,8 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((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<void>): Promise<void> {
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