mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Merge branch 'cu-iosperf' into catch-up-2026-11
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user