mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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).
|
// Pause between a mute change and the push-filter update it sends (#78).
|
||||||
const MUTES_RESYNC_DELAY_MS = 1_000;
|
const MUTES_RESYNC_DELAY_MS = 1_000;
|
||||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||||
import ComposeScreen from './src/screens/ComposeScreen';
|
|
||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
import LoginScreen from './src/screens/LoginScreen';
|
||||||
import MailTab from './src/screens/MailTab';
|
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 ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
|
||||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import ScheduledScreen from './src/screens/ScheduledScreen';
|
// Screens that aren't on the way to the inbox load on first visit
|
||||||
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
|
// (getComponent below), so their modules stay out of the startup work.
|
||||||
import { useAccountStore } from './src/stores/account-store';
|
import { useAccountStore } from './src/stores/account-store';
|
||||||
import { useAuthStore } from './src/stores/auth-store';
|
import { useAuthStore } from './src/stores/auth-store';
|
||||||
import { useCalendarStore } from './src/stores/calendar-store';
|
import { useCalendarStore } from './src/stores/calendar-store';
|
||||||
@@ -322,7 +316,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
</Tab.Screen>
|
</Tab.Screen>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Calendar"
|
name="Calendar"
|
||||||
component={CalendarScreen}
|
getComponent={() => require('./src/screens/CalendarScreen').default}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.calendar,
|
title: tabLabels.calendar,
|
||||||
tabBarButtonTestID: 'tab-calendar',
|
tabBarButtonTestID: 'tab-calendar',
|
||||||
@@ -338,7 +332,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Contacts"
|
name="Contacts"
|
||||||
component={ContactsTab}
|
getComponent={() => require('./src/screens/ContactsTab').default}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.contacts,
|
title: tabLabels.contacts,
|
||||||
tabBarButtonTestID: 'tab-contacts',
|
tabBarButtonTestID: 'tab-contacts',
|
||||||
@@ -354,7 +348,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Files"
|
name="Files"
|
||||||
component={FilesScreen}
|
getComponent={() => require('./src/screens/FilesScreen').default}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.files,
|
title: tabLabels.files,
|
||||||
tabBarButtonTestID: 'tab-files',
|
tabBarButtonTestID: 'tab-files',
|
||||||
@@ -904,10 +898,10 @@ export default function App() {
|
|||||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
||||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||||
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Compose"
|
name="Compose"
|
||||||
component={ComposeScreen}
|
getComponent={() => require('./src/screens/ComposeScreen').default}
|
||||||
// A tablet shows the composer as a panel over the screen it came
|
// A tablet shows the composer as a panel over the screen it came
|
||||||
// from, so that screen stays drawn behind the dimmed backdrop.
|
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||||
options={isSplit
|
options={isSplit
|
||||||
@@ -917,15 +911,15 @@ export default function App() {
|
|||||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="ContactForm"
|
name="ContactForm"
|
||||||
component={ContactFormScreen}
|
getComponent={() => require('./src/screens/ContactFormScreen').default}
|
||||||
options={{
|
options={{
|
||||||
presentation: 'modal',
|
presentation: 'modal',
|
||||||
animation: 'slide_from_bottom',
|
animation: 'slide_from_bottom',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
|
||||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="AddAccount"
|
name="AddAccount"
|
||||||
options={{
|
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;
|
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 * as SecureStore from 'expo-secure-store';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import type {
|
import type {
|
||||||
JMAPSession,
|
JMAPSession,
|
||||||
JMAPMethodCall,
|
JMAPMethodCall,
|
||||||
@@ -46,6 +47,20 @@ export function credentialsKey(accountId: string): string {
|
|||||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
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 {
|
export interface StoredCredentials {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -155,6 +170,12 @@ export class JMAPClient {
|
|||||||
* install it (see SessionSupersededError).
|
* install it (see SessionSupersededError).
|
||||||
*/
|
*/
|
||||||
private sessionEpoch = 0;
|
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 firstTouchGate = new FirstTouchGate();
|
||||||
private rateLimitedUntil = 0;
|
private rateLimitedUntil = 0;
|
||||||
private authFailureListeners = new Set<(err: AuthenticationError) => void>();
|
private authFailureListeners = new Set<(err: AuthenticationError) => void>();
|
||||||
@@ -301,6 +322,7 @@ export class JMAPClient {
|
|||||||
this.firstTouchGate.reset();
|
this.firstTouchGate.reset();
|
||||||
|
|
||||||
const accountId = generateAccountId(username, baseUrl);
|
const accountId = generateAccountId(username, baseUrl);
|
||||||
|
this.sessionCacheId = accountId;
|
||||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
@@ -380,6 +402,7 @@ export class JMAPClient {
|
|||||||
this.credentials = credentials;
|
this.credentials = credentials;
|
||||||
|
|
||||||
const accountId = generateAccountId(username, baseUrl);
|
const accountId = generateAccountId(username, baseUrl);
|
||||||
|
this.sessionCacheId = accountId;
|
||||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
||||||
|
|
||||||
return { session, username, accountId };
|
return { session, username, accountId };
|
||||||
@@ -427,6 +450,7 @@ export class JMAPClient {
|
|||||||
this.credentials = credentials;
|
this.credentials = credentials;
|
||||||
|
|
||||||
const accountId = generateAccountId(username, baseUrl);
|
const accountId = generateAccountId(username, baseUrl);
|
||||||
|
this.sessionCacheId = accountId;
|
||||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
||||||
|
|
||||||
return { session, username, accountId };
|
return { session, username, accountId };
|
||||||
@@ -671,6 +695,8 @@ export class JMAPClient {
|
|||||||
const session = this.rewriteSessionUrls(fetched, creds.serverUrl);
|
const session = this.rewriteSessionUrls(fetched, creds.serverUrl);
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this._accountId = this.resolveAccountId(session);
|
this._accountId = this.resolveAccountId(session);
|
||||||
|
this.sessionCacheId = accountId;
|
||||||
|
this.rememberSession();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +711,7 @@ export class JMAPClient {
|
|||||||
private detachSession(): number {
|
private detachSession(): number {
|
||||||
this.session = null;
|
this.session = null;
|
||||||
this._accountId = null;
|
this._accountId = null;
|
||||||
|
this.sessionCacheId = null;
|
||||||
this.sessionEpoch += 1;
|
this.sessionEpoch += 1;
|
||||||
return this.sessionEpoch;
|
return this.sessionEpoch;
|
||||||
}
|
}
|
||||||
@@ -699,6 +726,94 @@ export class JMAPClient {
|
|||||||
if (epoch !== this.sessionEpoch) throw new SessionSupersededError();
|
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
|
* Snapshot of the live connection so a failed "add account" / switch can put
|
||||||
* the previous account back without a network round-trip.
|
* the previous account back without a network round-trip.
|
||||||
@@ -708,6 +823,7 @@ export class JMAPClient {
|
|||||||
session: this.session,
|
session: this.session,
|
||||||
credentials: this.credentials,
|
credentials: this.credentials,
|
||||||
accountId: this._accountId,
|
accountId: this._accountId,
|
||||||
|
sessionCacheId: this.sessionCacheId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,17 +832,20 @@ export class JMAPClient {
|
|||||||
this.session = snap.session;
|
this.session = snap.session;
|
||||||
this.credentials = snap.credentials;
|
this.credentials = snap.credentials;
|
||||||
this._accountId = snap.accountId;
|
this._accountId = snap.accountId;
|
||||||
|
this.sessionCacheId = snap.sessionCacheId ?? null;
|
||||||
this.firstTouchGate.reset();
|
this.firstTouchGate.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
||||||
|
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearAllCredentials(accountIds: string[]): Promise<void> {
|
async clearAllCredentials(accountIds: string[]): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
|
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
|
||||||
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
|
...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.session = null;
|
||||||
this.credentials = null;
|
this.credentials = null;
|
||||||
this._accountId = null;
|
this._accountId = null;
|
||||||
|
this.sessionCacheId = null;
|
||||||
this.firstTouchGate.reset();
|
this.firstTouchGate.reset();
|
||||||
this.rateLimitedUntil = 0;
|
this.rateLimitedUntil = 0;
|
||||||
}
|
}
|
||||||
@@ -800,6 +920,7 @@ export class JMAPClient {
|
|||||||
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||||
if (currentAccountId) {
|
if (currentAccountId) {
|
||||||
await SecureStore.deleteItemAsync(credentialsKey(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);
|
const session = this.rewriteSessionUrls(fetched, baseUrl);
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this._accountId = this.resolveAccountId(session);
|
this._accountId = this.resolveAccountId(session);
|
||||||
|
this.rememberSession();
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1390,6 +1512,8 @@ export interface ClientSnapshot {
|
|||||||
session: JMAPSession | null;
|
session: JMAPSession | null;
|
||||||
credentials: StoredCredentials | null;
|
credentials: StoredCredentials | null;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
/** The local account id the session's saved document is kept under. */
|
||||||
|
sessionCacheId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── URL helpers ──────────────────────────────────────────
|
// ── URL helpers ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ const { client } = vi.hoisted(() => {
|
|||||||
client: {
|
client: {
|
||||||
session,
|
session,
|
||||||
requests: [] as Array<Array<[string, Record<string, unknown>, string]>>,
|
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', () => ({
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
jmapClient: {
|
jmapClient: {
|
||||||
loadAccount: vi.fn(async () => true),
|
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),
|
consumeLegacyCredentials: vi.fn(async () => null),
|
||||||
clearAccountCredentials: vi.fn(async () => undefined),
|
clearAccountCredentials: vi.fn(async () => undefined),
|
||||||
reset: vi.fn(),
|
reset: vi.fn(),
|
||||||
@@ -51,12 +60,18 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
getAccountName: () => 'user',
|
getAccountName: () => 'user',
|
||||||
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => {
|
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => {
|
||||||
client.requests.push(calls);
|
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.
|
// The 1.0 server knows nothing of the 0.16.23 states.
|
||||||
return {
|
return {
|
||||||
methodResponses: calls.map(([name, args, id]) => (name.endsWith('/changes')
|
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, {
|
: [name, {
|
||||||
accountId: args.accountId, state: 'sfy', list: [], notFound: [],
|
accountId: args.accountId, state: 'sfy', list: name === 'Mailbox/get' ? client.mailboxes : [], notFound: [],
|
||||||
...(name === 'Email/set'
|
...(name === 'Email/set'
|
||||||
? { updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])) }
|
? { 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),
|
getStoredOAuthTokens: vi.fn(async () => null),
|
||||||
accountId: 'f',
|
accountId: 'f',
|
||||||
|
connectedAccountId: 'f',
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
currentSession: client.session('1.0'),
|
currentSession: client.session('1.0'),
|
||||||
username: 'user',
|
username: 'user',
|
||||||
@@ -72,6 +88,7 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
},
|
},
|
||||||
AuthenticationError: class AuthenticationError extends Error {},
|
AuthenticationError: class AuthenticationError extends Error {},
|
||||||
NetworkError: class NetworkError extends Error {},
|
NetworkError: class NetworkError extends Error {},
|
||||||
|
SessionSupersededError: class SessionSupersededError extends Error {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/push-notifications', () => ({
|
vi.mock('../../lib/push-notifications', () => ({
|
||||||
@@ -103,6 +120,9 @@ beforeEach(async () => {
|
|||||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
await AsyncStorage.clear();
|
await AsyncStorage.clear();
|
||||||
client.requests.length = 0;
|
client.requests.length = 0;
|
||||||
|
client.latency = 0;
|
||||||
|
client.acceptsOldStates = false;
|
||||||
|
client.mailboxes = [];
|
||||||
// A cold start: nothing is attached yet.
|
// A cold start: nothing is attached yet.
|
||||||
await useOutboxStore.getState().setAccount(null);
|
await useOutboxStore.getState().setAccount(null);
|
||||||
await useOfflineCacheStore.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')));
|
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(),
|
logout: vi.fn(),
|
||||||
restoreSession: vi.fn(),
|
restoreSession: vi.fn(),
|
||||||
loadAccount: vi.fn(),
|
loadAccount: vi.fn(),
|
||||||
|
loadAccountFromCache: vi.fn(async () => false),
|
||||||
|
revalidateSession: vi.fn(async () => null),
|
||||||
consumeLegacyCredentials: vi.fn(async () => null),
|
consumeLegacyCredentials: vi.fn(async () => null),
|
||||||
clearAccountCredentials: vi.fn(async () => undefined),
|
clearAccountCredentials: vi.fn(async () => undefined),
|
||||||
clearAllCredentials: vi.fn(async () => undefined),
|
clearAllCredentials: vi.fn(async () => undefined),
|
||||||
@@ -17,7 +19,9 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
hasAccountCapability: vi.fn(() => false),
|
hasAccountCapability: vi.fn(() => false),
|
||||||
request: vi.fn(async () => { throw new Error('not mocked'); }),
|
request: vi.fn(async () => { throw new Error('not mocked'); }),
|
||||||
getStoredOAuthTokens: vi.fn(async () => null),
|
getStoredOAuthTokens: vi.fn(async () => null),
|
||||||
|
getStoredCredentials: vi.fn(async () => null),
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
|
connectedAccountId: 'acc-1',
|
||||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||||
username: 'user',
|
username: 'user',
|
||||||
serverUrl: 'https://mail.example.com',
|
serverUrl: 'https://mail.example.com',
|
||||||
@@ -28,6 +32,7 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
NetworkError: class NetworkError extends Error {
|
NetworkError: class NetworkError extends Error {
|
||||||
constructor(msg: string) { super(msg); this.name = 'NetworkError'; }
|
constructor(msg: string) { super(msg); this.name = 'NetworkError'; }
|
||||||
},
|
},
|
||||||
|
SessionSupersededError: class SessionSupersededError extends Error {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/push-notifications', () => ({
|
vi.mock('../../lib/push-notifications', () => ({
|
||||||
@@ -35,12 +40,13 @@ vi.mock('../../lib/push-notifications', () => ({
|
|||||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
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 { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
|
||||||
import { useAccountStore } from '../account-store';
|
import { useAccountStore } from '../account-store';
|
||||||
import { useCalendarStore } from '../calendar-store';
|
import { useCalendarStore } from '../calendar-store';
|
||||||
import { useContactsStore } from '../contacts-store';
|
import { useContactsStore } from '../contacts-store';
|
||||||
import { useEmailStore } from '../email-store';
|
import { useEmailStore } from '../email-store';
|
||||||
|
import { useOutboxStore } from '../outbox-store';
|
||||||
import type { Email } from '../../api/types';
|
import type { Email } from '../../api/types';
|
||||||
import { peekRow, rememberRows } from '../../lib/email-detail-cache';
|
import { peekRow, rememberRows } from '../../lib/email-detail-cache';
|
||||||
import { bodyDocument } from '../../lib/email-body-document';
|
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 mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
||||||
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
||||||
const mockLoadAccount = jmapClient.loadAccount 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 {
|
function resetAccountStore(): void {
|
||||||
useAccountStore.setState({
|
useAccountStore.setState({
|
||||||
@@ -236,6 +244,173 @@ describe('auth-store', () => {
|
|||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
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 () => {
|
it('stops waiting for a persisted store that never finishes hydrating', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
|||||||
+140
-8
@@ -39,6 +39,7 @@ import {
|
|||||||
teardownPushNotificationsForAccount,
|
teardownPushNotificationsForAccount,
|
||||||
} from '../lib/push-notifications';
|
} from '../lib/push-notifications';
|
||||||
import { reconcileServerFingerprint } from '../lib/server-change';
|
import { reconcileServerFingerprint } from '../lib/server-change';
|
||||||
|
import { readServerFingerprint, sessionFingerprint } from '../lib/server-fingerprint';
|
||||||
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
||||||
import { perfMark } from '../lib/perf-marks';
|
import { perfMark } from '../lib/perf-marks';
|
||||||
import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort';
|
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
|
// 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
|
// 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
|
// lib/server-change). A failed check never blocks signing in; it runs again
|
||||||
// with the next session.
|
// with the next session. Resolves to true when the caches were dropped.
|
||||||
async function checkServerChange(accountId: string, session: JMAPSession): Promise<void> {
|
async function checkServerChange(accountId: string, session: JMAPSession): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await reconcileServerFingerprint(accountId, session);
|
return await reconcileServerFingerprint(accountId, session);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[auth-store] server change check failed:', 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.
|
// the app went to the sign-in screen with every account still registered.
|
||||||
let restoreUnderWay: Promise<void> | null = null;
|
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> {
|
async function switchesSettled(): Promise<void> {
|
||||||
while (pendingSwitch) await pendingSwitch.settled;
|
while (pendingSwitch) await pendingSwitch.settled;
|
||||||
}
|
}
|
||||||
@@ -448,23 +455,31 @@ async function signIn(
|
|||||||
|
|
||||||
function refetchFeatureStores(accountId: string): void {
|
function refetchFeatureStores(accountId: string): void {
|
||||||
resumeOutbox(accountId);
|
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.
|
// Fire-and-forget: each store handles its own errors.
|
||||||
const emailStore = useEmailStore.getState();
|
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),
|
// 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
|
// refresh its contents so the user sees up-to-date mail without manually
|
||||||
// pulling to refresh.
|
// pulling to refresh.
|
||||||
if (emailStore.currentMailboxId) {
|
if (emailStore.currentMailboxId) {
|
||||||
void emailStore.refreshEmails();
|
reads.push(emailStore.refreshEmails());
|
||||||
}
|
}
|
||||||
void useContactsStore.getState().fetchContacts();
|
reads.push(useContactsStore.getState().fetchContacts());
|
||||||
const calendarStore = useCalendarStore.getState();
|
const calendarStore = useCalendarStore.getState();
|
||||||
void calendarStore.fetchCalendars();
|
reads.push(calendarStore.fetchCalendars());
|
||||||
// Refresh the event range cached from last session (if any) so recurring
|
// Refresh the event range cached from last session (if any) so recurring
|
||||||
// events reflect new invitations / cancellations without the user swiping.
|
// events reflect new invitations / cancellations without the user swiping.
|
||||||
if (calendarStore.loadedRange) {
|
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
|
// Pairing codes this app process has redeemed or is redeeming. Codes are
|
||||||
@@ -603,6 +618,9 @@ function applyConnectedState(
|
|||||||
client: jmapClient,
|
client: jmapClient,
|
||||||
reauth: null,
|
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) => ({
|
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
|
// leaving the previous account dead until relaunch. With switches under
|
||||||
// way, that is the account the first of them left; with an add-account
|
// way, that is the account the first of them left; with an add-account
|
||||||
// sign-in under way, the connection it saved.
|
// 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 ?? {
|
const origin: SwitchOrigin = underWay?.origin ?? {
|
||||||
accountId: get().activeAccountId,
|
accountId: get().activeAccountId,
|
||||||
live: get().session !== null,
|
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.
|
// still in the snapshot taken then, and its server was checked.
|
||||||
jmapClient.restoreSnapshot(origin.client);
|
jmapClient.restoreSnapshot(origin.client);
|
||||||
if (origin.live) refetchFeatureStores(accountId);
|
if (origin.live) refetchFeatureStores(accountId);
|
||||||
|
else if (unconfirmedStart === accountId) void revalidateRestoredSession(accountId);
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1036,6 +1057,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// Its session and server check still hold; without one, the next
|
// Its session and server check still hold; without one, the next
|
||||||
// session resumes the outbox.
|
// session resumes the outbox.
|
||||||
if (origin.accountId && origin.live) resumeOutbox(origin.accountId);
|
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 {
|
try {
|
||||||
const ok = await jmapClient.loadAccount(accountId);
|
const ok = await jmapClient.loadAccount(accountId);
|
||||||
@@ -1170,6 +1195,48 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// data from a previous session.
|
// data from a previous session.
|
||||||
useEmailStore.getState().setActiveAccount(target.id);
|
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 {
|
try {
|
||||||
const ok = await jmapClient.loadAccount(target.id);
|
const ok = await jmapClient.loadAccount(target.id);
|
||||||
perfMark('restore-session');
|
perfMark('restore-session');
|
||||||
@@ -1317,6 +1384,71 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
clearReauth: () => set({ reauth: null }),
|
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)
|
// 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
|
// used to leave the user on a dead session until relaunch: nothing outside
|
||||||
// the login flow handled `AuthenticationError`, and `retrySession` bailed
|
// the login flow handled `AuthenticationError`, and `retrySession` bailed
|
||||||
|
|||||||
Reference in New Issue
Block a user