diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index b67b8c3a..0eb26aeb 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -234,6 +234,55 @@ describe('JMAPClient', () => { }); }); + describe('loadAccount', () => { + const OTHER_SESSION: JMAPSession = { + ...MOCK_SESSION, + apiUrl: 'https://other.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-2' }, + accounts: { 'acc-2': { name: 'other@example.com', isPersonal: true, isReadOnly: false } }, + }; + + it('serves no account while the next one loads, then the new one', async () => { + global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + (SecureStore.getItemAsync as any).mockResolvedValue( + JSON.stringify({ serverUrl: 'https://other.example.com', username: 'other', password: 'pw' }), + ); + let respond!: (response: unknown) => void; + global.fetch = vi.fn(() => new Promise((resolve) => { respond = resolve; })) as any; + + const loading = client.loadAccount('other@other.example.com'); + // Before the stored credentials are even read: the stores already show + // the new account, and the old session must not serve its requests. + expect(client.isConnected).toBe(false); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(client.isConnected).toBe(false); + await expect(client.request([['Mailbox/get', { accountId: 'acc-1' }, '0']])).rejects.toThrow('Not connected'); + + respond({ + ok: true, status: 200, statusText: 'OK', headers: { get: () => null }, + json: async () => OTHER_SESSION, text: async () => JSON.stringify(OTHER_SESSION), + }); + expect(await loading).toBe(true); + expect(client.isConnected).toBe(true); + expect(client.accountId).toBe('acc-2'); + }); + + it('leaves the previous account to the snapshot when the next one cannot load', async () => { + global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + const previous = client.snapshot(); + (SecureStore.getItemAsync as any).mockResolvedValue(null); + + expect(await client.loadAccount('gone@other.example.com')).toBe(false); + expect(client.isConnected).toBe(false); + + client.restoreSnapshot(previous); + expect(client.isConnected).toBe(true); + expect(client.accountId).toBe('acc-1'); + }); + }); + describe('logout', () => { it('should clear session and stored credentials', async () => { global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index e92335be..3e2c526b 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -251,6 +251,7 @@ export class JMAPClient { totp?: string, ): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); + this.detachSession(); if (totp) { return this.connectWithTotp(baseUrl, username, password, totp); } @@ -299,6 +300,7 @@ export class JMAPClient { async connectWithToken(serverUrl: string, accessToken: string): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); + this.detachSession(); this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken }; this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); @@ -319,6 +321,7 @@ export class JMAPClient { preferredUsername?: string, ): Promise<{ session: JMAPSession; username: string; accountId: string }> { const baseUrl = serverUrl.replace(/\/+$/, ''); + this.detachSession(); this.credentials = { serverUrl: baseUrl, username: '', @@ -489,6 +492,7 @@ export class JMAPClient { } async loadAccount(accountId: string): Promise { + this.detachSession(); const stored = await SecureStore.getItemAsync(credentialsKey(accountId)); if (!stored) return false; @@ -528,6 +532,18 @@ export class JMAPClient { return true; } + /** + * Serve no account until the next session is up. The app's stores switch + * to the account being signed in first, and the old session would carry + * the new credentials once they are swapped in: a request in between (an + * outbox replay, a refresh) would reach the previous account. Callers that + * switch keep a snapshot() to put it back on failure. + */ + private detachSession(): void { + this.session = null; + this._accountId = null; + } + /** * Snapshot of the live connection so a failed "add account" / switch can put * the previous account back without a network round-trip. diff --git a/src/lib/__tests__/offline-sync.test.ts b/src/lib/__tests__/offline-sync.test.ts new file mode 100644 index 00000000..418cba03 --- /dev/null +++ b/src/lib/__tests__/offline-sync.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The offline sync reads through the JMAP client and writes into the cache's +// loaded bucket. An account switch moves the cache first and the client after +// it, so a sync running across one must stop instead of filing one account's +// mail under the other. + +const { client } = vi.hoisted(() => ({ + client: { + // The local account (username@host) the client serves. + served: 'usera@mail.example.org' as string | null, + fetchGate: Promise.resolve() as Promise, + }, +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + get isConnected() { return client.served !== null; }, + get username() { return client.served ? client.served.split('@')[0] : null; }, + get serverUrl() { return client.served ? 'https://mail.example.org' : null; }, + getSharedMailAccounts: () => [], + getMaxObjectsInGet: () => 500, + }, +})); + +const queryEmailsByFilter = vi.fn(async (..._args: unknown[]) => ['m1', 'm2']); +const getFullEmails = vi.fn(async (ids: string[]) => { + await client.fetchGate; + return ids.map((id) => ({ id, receivedAt: '2026-09-20T10:00:00Z', mailboxIds: { a: true }, keywords: {} })); +}); +vi.mock('../../api/email', () => ({ + queryEmailsByFilter: (...args: unknown[]) => queryEmailsByFilter(...args), + getFullEmails: (ids: string[]) => getFullEmails(ids), +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { runOfflineSync } from '../offline-sync'; +import { useOfflineCacheStore } from '../../stores/offline-cache-store'; + +const A = 'usera@mail.example.org'; +const B = 'userb@mail.example.org'; +const entryKeys = async (account: string) => + ((await AsyncStorage.getAllKeys()) as string[]).filter((k) => k.startsWith(`webmail:offline-cache:entry:v2:${account}:`)); + +beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + client.served = A; + client.fetchGate = Promise.resolve(); + await useOfflineCacheStore.getState().setAccount(null); + await useOfflineCacheStore.getState().setAccount(A); +}); + +describe('runOfflineSync', () => { + it('caches the bodies of the account it runs for', async () => { + await runOfflineSync({ days: 30 }); + + expect(useOfflineCacheStore.getState().totalCount()).toBe(2); + expect(await entryKeys(A)).toHaveLength(2); + expect(useOfflineCacheStore.getState().sync.phase).toBe('done'); + }); + + it('stops without writing anywhere when the account is switched while bodies download', async () => { + let open!: () => void; + client.fetchGate = new Promise((resolve) => { open = resolve; }); + + const syncing = runOfflineSync({ days: 30 }); + await vi.waitFor(() => expect(getFullEmails).toHaveBeenCalled()); + // switchAccount: the stores move to B, then loadAccount() gets B's session. + await useOfflineCacheStore.getState().setAccount(B); + client.served = B; + open(); + await syncing; + + expect(await entryKeys(A)).toEqual([]); + expect(await entryKeys(B)).toEqual([]); + expect(useOfflineCacheStore.getState().totalCount()).toBe(0); + // B's progress is B's own: nothing from A's run shows up there. + expect(useOfflineCacheStore.getState().sync.phase).toBe('idle'); + }); + + it('does not start while the client still serves the account being left', async () => { + await useOfflineCacheStore.getState().setAccount(B); + + await runOfflineSync({ days: 30 }); + + expect(queryEmailsByFilter).not.toHaveBeenCalled(); + expect(await entryKeys(B)).toEqual([]); + }); +}); diff --git a/src/lib/offline-sync.ts b/src/lib/offline-sync.ts index b8d3868a..220721a3 100644 --- a/src/lib/offline-sync.ts +++ b/src/lib/offline-sync.ts @@ -8,6 +8,19 @@ import { jmapClient } from '../api/jmap-client'; import { queryEmailsByFilter, getFullEmails } from '../api/email'; import { useOfflineCacheStore, cacheKey } from '../stores/offline-cache-store'; import type { Email } from '../api/types'; +import { generateAccountId } from './account-utils'; + +// The sync reads through the JMAP client and writes into the cache's loaded +// bucket. During an account switch the two name different accounts for a +// while (the stores switch first, then the client), so a sync goes on only +// while both are on the account it started for. When they are not, the +// progress on screen belongs to the other account and is left alone. +function onAccount(accountId: string): boolean { + if (useOfflineCacheStore.getState().activeAccountId !== accountId) return false; + const { username, serverUrl } = jmapClient; + if (!username || !serverUrl) return false; + return generateAccountId(username, serverUrl) === accountId; +} // Approximate the on-disk size of a serialised Email so we can show "Y MB // downloaded" without measuring AsyncStorage usage. Body values dominate; the @@ -50,6 +63,8 @@ export async function runOfflineSync(opts: RunOptions): Promise { return; } if (!cache.hydrated) await cache.hydrate(); + const account = useOfflineCacheStore.getState().activeAccountId; + if (!account || !onAccount(account)) return; cache.resetSync(); const startedAt = Date.now(); @@ -64,6 +79,7 @@ export async function runOfflineSync(opts: RunOptions): Promise { try { targets.push({ accountId: undefined, ids: await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT) }); } catch (err) { + if (!onAccount(account)) return; cache.setSyncState({ phase: 'error', message: err instanceof Error ? err.message : 'Discovery query failed', @@ -82,21 +98,25 @@ export async function runOfflineSync(opts: RunOptions): Promise { console.warn('[offline-sync] shared account discovery failed:', acc.id, err); } } + // The ids found name messages of whichever account the client served. + if (!onAccount(account)) return; // Drop entries from the cache that fell out of the lookback window — the // user expects the cache to track "the last X days", not grow forever. const keepSet = new Set(); for (const tgt of targets) for (const id of tgt.ids) keepSet.add(cacheKey(id, tgt.accountId)); const staleByAccount = new Map(); - for (const entry of Object.values(cache.index.entries)) { + for (const entry of Object.values(useOfflineCacheStore.getState().index.entries)) { const key = cacheKey(entry.id, entry.jmapAccountId); if (keepSet.has(key)) continue; const list = staleByAccount.get(entry.jmapAccountId); if (list) list.push(entry.id); else staleByAccount.set(entry.jmapAccountId, [entry.id]); } for (const [accountId, ids] of staleByAccount) { + if (!onAccount(account)) return; if (ids.length > 0) await cache.remove(ids, accountId); } + if (!onAccount(account)) return; const total = targets.reduce((n, tgt) => n + tgt.ids.length, 0); cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 }); @@ -130,6 +150,7 @@ export async function runOfflineSync(opts: RunOptions): Promise { for (const tgt of work) { for (let i = 0; i < tgt.ids.length; i += chunkSize) { + if (!onAccount(account)) return; if (useOfflineCacheStore.getState().consumeAbort()) { cache.setSyncState({ phase: 'cancelled', @@ -143,6 +164,7 @@ export async function runOfflineSync(opts: RunOptions): Promise { try { emails = await getFullEmails(chunk, tgt.accountId); } catch (err) { + if (!onAccount(account)) return; cache.setSyncState({ phase: 'error', message: err instanceof Error ? err.message : 'Fetch failed', @@ -150,10 +172,12 @@ export async function runOfflineSync(opts: RunOptions): Promise { }); return; } + // Read through a client that may have moved on to another account. + if (!onAccount(account)) return; for (const email of emails) { const size = approxSize(email); - await cache.put(email, size, tgt.accountId); + await cache.put(email, size, tgt.accountId, account); fetched += 1; bytes += size; completed += 1; @@ -170,6 +194,7 @@ export async function runOfflineSync(opts: RunOptions): Promise { // Trim the cache back under its size cap, shedding the oldest mail. Done // after fetching so we never evict something we're about to keep. + if (!onAccount(account)) return; if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024); cache.setSyncState({ diff --git a/src/stores/__tests__/auth-store-server-change.test.ts b/src/stores/__tests__/auth-store-server-change.test.ts index 20a43da8..2274dcf9 100644 --- a/src/stores/__tests__/auth-store-server-change.test.ts +++ b/src/stores/__tests__/auth-store-server-change.test.ts @@ -55,7 +55,12 @@ vi.mock('../../api/jmap-client', () => ({ return { methodResponses: calls.map(([name, args, id]) => (name.endsWith('/changes') ? ['error', { type: 'cannotCalculateChanges' }, id] - : [name, { accountId: args.accountId, state: 'sfy', list: [], notFound: [] }, id])), + : [name, { + accountId: args.accountId, state: 'sfy', list: [], notFound: [], + ...(name === 'Email/set' + ? { updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])) } + : {}), + }, id])), }; }), getStoredOAuthTokens: vi.fn(async () => null), @@ -79,17 +84,26 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuthStore } from '../auth-store'; import { useAccountStore } from '../account-store'; import { useEmailStore } from '../email-store'; +import { useOfflineCacheStore } from '../offline-cache-store'; import { useOutboxStore } from '../outbox-store'; import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint'; import type { JMAPSession } from '../../api/types'; const ACCOUNT = 'user@mail.example.com'; const session = (version: string) => client.session(version) as unknown as JMAPSession; +const OUTBOX_KEY = `webmail:outbox:v1:${ACCOUNT}`; +// An edit made offline in the last session: mark message "b" read. +const QUEUED = [{ id: 'q1', createdAt: 1, attempts: 0, op: { kind: 'keywords', emailId: 'b', patch: { $seen: true } } }]; + +const emailSets = () => client.requests.flat().filter(([name]) => name === 'Email/set'); beforeEach(async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); await AsyncStorage.clear(); client.requests.length = 0; + // A cold start: nothing is attached yet. + await useOutboxStore.getState().setAccount(null); + await useOfflineCacheStore.getState().setAccount(null); useAccountStore.setState({ accounts: [{ id: ACCOUNT, serverUrl: 'https://mail.example.com', username: 'user', displayName: 'user', email: 'user', @@ -117,6 +131,7 @@ beforeEach(async () => { describe('restoring the session on a replaced server', () => { it('drops the persisted 0.16.23 states before anything is sent to Stalwart 1.0', async () => { await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23'))); + await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(QUEUED)); expect(await useAuthStore.getState().restoreSession()).toBe(true); // Let the fire-and-forget refetch run. @@ -127,7 +142,11 @@ describe('restoring the session on a replaced server', () => { expect(JSON.stringify(sent)).not.toContain('s1iaq'); expect(sent.some(([name]) => name === 'Mailbox/changes')).toBe(false); expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); + // "b" is another message on the new server: the edit never goes out. + expect(emailSets()).toEqual([]); + expect(useOutboxStore.getState().activeAccountId).toBe(ACCOUNT); expect(useOutboxStore.getState().entries).toEqual([]); + expect(await AsyncStorage.getItem(OUTBOX_KEY)).toBeNull(); }); it('keeps the cached view and syncs from it when the server is the same (Stalwart 1.0)', async () => { @@ -139,4 +158,36 @@ describe('restoring the session on a replaced server', () => { const changes = client.requests.flat().filter(([name]) => name === 'Mailbox/changes'); expect(changes.map(([, args]) => args.sinceState)).toEqual(['s1iaq']); }); + + it('loads the outbox of the restored account and sends it when the server is the same (Stalwart 1.0)', async () => { + // The persisted view already shows the account, so the email store has + // no switch to make; the outbox and the offline cache attach anyway. + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('1.0'))); + await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(QUEUED)); + + expect(await useAuthStore.getState().restoreSession()).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + + expect(emailSets().map(([, args]) => args)).toEqual([ + { accountId: 'f', update: { b: { 'keywords/$seen': true } } }, + ]); + expect(useOutboxStore.getState().entries).toEqual([]); + expect(JSON.parse((await AsyncStorage.getItem(OUTBOX_KEY))!)).toEqual([]); + expect(useOfflineCacheStore.getState().activeAccountId).toBe(ACCOUNT); + }); + + it('keeps the outbox of a restored account the server cannot be reached for (Stalwart 0.16.23)', async () => { + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23'))); + await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(QUEUED)); + const { jmapClient, NetworkError } = await import('../../api/jmap-client'); + vi.mocked(jmapClient.loadAccount).mockRejectedValueOnce(new NetworkError('offline')); + + expect(await useAuthStore.getState().restoreSession()).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + + // No session, no check: nothing is dropped and nothing is sent. + expect(emailSets()).toEqual([]); + expect(useOutboxStore.getState().entries.map((e) => e.op.emailId)).toEqual(['b']); + expect(await AsyncStorage.getItem(OUTBOX_KEY)).not.toBeNull(); + }); }); diff --git a/src/stores/__tests__/auth-store-switch-outbox.test.ts b/src/stores/__tests__/auth-store-switch-outbox.test.ts new file mode 100644 index 00000000..cbc1ff65 --- /dev/null +++ b/src/stores/__tests__/auth-store-switch-outbox.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Switching accounts: the email store, and with it the outbox, moves to the +// new account first; the JMAP client only once loadAccount() has the new +// session, and the server behind it is checked after that. An op queued for +// one account must never reach another, nor a replaced server. + +const { client } = vi.hoisted(() => { + const A = 'usera@mail.example.org'; + const B = 'userb@mail.example.org'; + const jmapIds: Record = { [A]: 'd', [B]: 'e' }; + const session = (version: string, jmapId: string) => ({ + apiUrl: 'https://mail.example.org/jmap/', + downloadUrl: '', + uploadUrl: '', + eventSourceUrl: '', + primaryAccounts: { 'urn:ietf:params:jmap:mail': jmapId }, + accounts: { + [jmapId]: { + name: jmapId, + isPersonal: true, + isReadOnly: false, + accountCapabilities: { + 'urn:ietf:params:jmap:mail': {}, + 'urn:stalwart:jmap': {}, + ...(version === '1.0' ? { 'urn:ietf:params:jmap:principals:owner': {}, 'urn:ietf:params:jmap:metadata': {} } : {}), + }, + }, + }, + capabilities: { + 'urn:ietf:params:jmap:core': {}, + 'urn:ietf:params:jmap:mail': {}, + ...(version === '1.0' ? { 'urn:ietf:params:jmap:metadata': {} } : {}), + }, + state: 'b3bed0bb', + }); + return { + client: { + A, + B, + jmapIds, + session, + version: '1.0', + // The local account (username@host) the client serves; null while it + // serves none, as during loadAccount(). + served: null as string | null, + // loadAccount() has the new session once this settles. + loadGate: Promise.resolve() as Promise, + loadError: null as Error | null, + emailSets: [] as Array<{ served: string; args: Record }>, + }, + }; +}); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + get isConnected() { return client.served !== null; }, + get accountId() { + if (!client.served) throw new Error('Not authenticated - call connect() first'); + return client.jmapIds[client.served]; + }, + get currentSession() { + return client.served ? client.session(client.version, client.jmapIds[client.served]) : null; + }, + get username() { return client.served ? client.served.split('@')[0] : null; }, + get serverUrl() { return client.served ? 'https://mail.example.org' : null; }, + loadAccount: vi.fn(async (accountId: string) => { + client.served = null; + await client.loadGate; + if (client.loadError) throw client.loadError; + client.served = accountId; + return true; + }), + snapshot: vi.fn(() => ({ served: client.served })), + restoreSnapshot: vi.fn((snap: { served: string | null }) => { client.served = snap.served; }), + consumeLegacyCredentials: vi.fn(async () => null), + clearAccountCredentials: vi.fn(async () => undefined), + reset: vi.fn(), + onAuthFailure: vi.fn(() => () => undefined), + onTokenRefresh: vi.fn(() => () => undefined), + hasAccountCapability: vi.fn(() => true), + hasCapability: vi.fn(() => false), + getMaxObjectsInGet: () => 500, + getMaxObjectsInSet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [], + getAccountName: () => 'user', + getStoredOAuthTokens: vi.fn(async () => null), + request: vi.fn(async (calls: Array<[string, Record, string]>) => { + if (!client.served) throw new Error('Not connected'); + for (const [name, args] of calls) { + if (name === 'Email/set') client.emailSets.push({ served: client.served, args }); + } + return { + methodResponses: calls.map(([name, args, id]) => [name, { + accountId: args.accountId, state: 's1', list: [], notFound: [], + ...(name === 'Email/set' + ? { updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])) } + : {}), + }, id]), + }; + }), + }, + AuthenticationError: class AuthenticationError extends Error {}, + NetworkError: class NetworkError extends Error {}, +})); + +vi.mock('../../lib/push-notifications', () => ({ + teardownPushNotifications: vi.fn(async () => undefined), + teardownPushNotificationsForAccount: vi.fn(async () => undefined), + forgetPushServerState: vi.fn(async () => undefined), +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { JMAPSession } from '../../api/types'; +import { sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint'; +import { useAccountStore } from '../account-store'; +import { useAuthStore } from '../auth-store'; +import { useEmailStore } from '../email-store'; +import { applyOrQueue, useOutboxStore } from '../outbox-store'; + +const { A, B } = client; +const fingerprint = (version: string, account: string) => + sessionFingerprint(client.session(version, client.jmapIds[account]) as unknown as JMAPSession); +const queued = (emailId: string) => ({ + id: `q-${emailId}`, createdAt: 1, attempts: 0, op: { kind: 'keywords', emailId, patch: { $seen: true } }, +}); +const settle = async () => { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +}; +const updatedIds = () => client.emailSets.flatMap(({ args }) => Object.keys((args.update ?? {}) as object)); + +function deferred(): { promise: Promise; open: () => void } { + let open!: () => void; + const promise = new Promise((resolve) => { open = resolve; }); + return { promise, open }; +} + +const readStored = vi.mocked(AsyncStorage.getItem).getMockImplementation()!; + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await AsyncStorage.clear(); + client.version = '1.0'; + client.loadGate = Promise.resolve(); + client.loadError = null; + client.emailSets.length = 0; + const entry = (id: string, username: string) => ({ + id, serverUrl: 'https://mail.example.org', username, displayName: username, email: id, + avatarColor: '#000', lastLoginAt: 0, isConnected: true, hasError: false, isDefault: id === A, + }); + useAccountStore.setState({ accounts: [entry(A, 'usera'), entry(B, 'userb')], activeAccountId: A, defaultAccountId: A }); + await writeServerFingerprint(A, fingerprint('1.0', A)); + await writeServerFingerprint(B, fingerprint('1.0', B)); + + // A is signed in and live; B has an edit queued from before. + await AsyncStorage.setItem(`webmail:outbox:v1:${B}`, JSON.stringify([queued('m1')])); + client.served = A; + await useOutboxStore.getState().setAccount(null); + useEmailStore.setState({ activeAccountId: null, accountSnapshots: {} }); + useEmailStore.getState().setActiveAccount(A); + await useOutboxStore.getState().resume(A); + useAuthStore.setState({ + isAuthenticated: true, + isLoading: false, + session: client.session('1.0', 'd') as unknown as JMAPSession, + activeAccountId: A, + accountId: 'd', + client: null, + }); +}); + +afterEach(() => { + vi.mocked(AsyncStorage.getItem).mockImplementation(readStored); +}); + +describe('switching accounts with queued edits', () => { + it('sends the new account its queue only once the switch is done and its server was checked (Stalwart 1.0)', async () => { + const load = deferred(); + client.loadGate = load.promise; + const check = deferred(); + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => { + if (key === `server-fingerprint:v1:${B}`) await check.promise; + return readStored(key); + }); + + const switching = useAuthStore.getState().switchAccount(B); + await settle(); + // The session is still loading. What App.tsx does when the network comes + // back, and an edit the user makes in B's cached list: + expect(useOutboxStore.getState().activeAccountId).toBe(B); + await useOutboxStore.getState().flush(); + expect((await applyOrQueue({ kind: 'keywords', emailId: 'm2', patch: { $flagged: true } })).queued).toBe(true); + expect(client.emailSets).toEqual([]); + + // B's session is up, its server not checked yet. + load.open(); + await settle(); + expect(client.served).toBe(B); + await useOutboxStore.getState().flush(); + expect(client.emailSets).toEqual([]); + + check.open(); + await switching; + await settle(); + + expect(client.emailSets.length).toBeGreaterThan(0); + for (const { served, args } of client.emailSets) { + expect(served).toBe(B); + expect(args.accountId).toBe('e'); + } + expect(updatedIds()).toEqual(['m1', 'm2']); + expect(useOutboxStore.getState().entries).toEqual([]); + }); + + it('drops the queue of an account whose server was replaced before anything is sent (0.16.23 to 1.0)', async () => { + await writeServerFingerprint(B, fingerprint('0.16.23', B)); + + await useAuthStore.getState().switchAccount(B); + await settle(); + + expect(client.emailSets).toEqual([]); + expect(useOutboxStore.getState().activeAccountId).toBe(B); + expect(useOutboxStore.getState().entries).toEqual([]); + expect(await AsyncStorage.getItem(`webmail:outbox:v1:${B}`)).toBeNull(); + }); + + it('puts the previous account and its queue back to work when the switch fails (Stalwart 1.0)', async () => { + client.loadError = new Error('Server unreachable'); + + await useAuthStore.getState().switchAccount(B); + await settle(); + + expect(client.served).toBe(A); + expect(useOutboxStore.getState().activeAccountId).toBe(A); + expect(useOutboxStore.getState().resumed).toBe(true); + const result = await applyOrQueue({ kind: 'keywords', emailId: 'a1', patch: { $seen: true } }); + expect(result.queued).toBe(false); + expect(client.emailSets).toEqual([{ served: A, args: { accountId: 'd', update: { a1: { 'keywords/$seen': true } } } }]); + // B's queue stays for B. + expect(JSON.parse((await AsyncStorage.getItem(`webmail:outbox:v1:${B}`))!)).toHaveLength(1); + }); +}); diff --git a/src/stores/__tests__/offline-cache-store.test.ts b/src/stores/__tests__/offline-cache-store.test.ts index 8e896c20..4fd2b738 100644 --- a/src/stores/__tests__/offline-cache-store.test.ts +++ b/src/stores/__tests__/offline-cache-store.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useOfflineCacheStore } from '../offline-cache-store'; const ACCOUNT = 'acc-1'; @@ -38,3 +39,55 @@ describe('offline cache patch', () => { expect(cached?.keywords).toEqual({ $seen: true }); }); }); + +describe('buckets that change under a write or a read', () => { + const OTHER = 'acc-2'; + const body = (id: string) => ({ id, receivedAt: '2026-01-01T00:00:00Z', keywords: {}, mailboxIds: { inbox: true } } as any); + const entryKey = (account: string, id: string) => `webmail:offline-cache:entry:v2:${account}:${id}`; + const indexKey = (account: string) => `webmail:offline-cache:index:v2:${account}`; + + beforeEach(async () => { + await AsyncStorage.removeItem(indexKey(OTHER)); + await AsyncStorage.removeItem(entryKey(OTHER, 'e1')); + }); + + it('drops a put made for an account whose bucket is no longer loaded', async () => { + await useOfflineCacheStore.getState().setAccount(OTHER); + + await useOfflineCacheStore.getState().put(body('e1'), 100, undefined, ACCOUNT); + + expect(useOfflineCacheStore.getState().totalCount()).toBe(0); + expect(await AsyncStorage.getItem(entryKey(OTHER, 'e1'))).toBeNull(); + expect(await AsyncStorage.getItem(entryKey(ACCOUNT, 'e1'))).toBeNull(); + }); + + it('keeps a bucket cleared while its index was still loading cleared', async () => { + // The server check drops the cache of a replaced server while the cold + // start is still reading its index. + await AsyncStorage.setItem(entryKey(OTHER, 'e1'), JSON.stringify(body('e1'))); + await AsyncStorage.setItem(indexKey(OTHER), JSON.stringify({ + entries: { e1: { id: 'e1', receivedAt: '2026-01-01T00:00:00Z', size: 100, cachedAt: 1 } }, + })); + + const attaching = useOfflineCacheStore.getState().setAccount(OTHER); + await useOfflineCacheStore.getState().clearAccount(OTHER); + await attaching; + + expect(useOfflineCacheStore.getState().totalCount()).toBe(0); + expect(useOfflineCacheStore.getState().hydrated).toBe(true); + expect(await AsyncStorage.getItem(entryKey(OTHER, 'e1'))).toBeNull(); + expect(await AsyncStorage.getItem(indexKey(OTHER))).toBeNull(); + }); + + it('does not index a body a put was still writing when the bucket was cleared', async () => { + await useOfflineCacheStore.getState().setAccount(OTHER); + + const putting = useOfflineCacheStore.getState().put(body('e1'), 100); + await useOfflineCacheStore.getState().clearAccount(OTHER); + await putting; + + expect(useOfflineCacheStore.getState().totalCount()).toBe(0); + expect(await useOfflineCacheStore.getState().get('e1')).toBeNull(); + expect(await AsyncStorage.getItem(indexKey(OTHER))).toBeNull(); + }); +}); diff --git a/src/stores/__tests__/outbox-store.test.ts b/src/stores/__tests__/outbox-store.test.ts index babc2363..3c041913 100644 --- a/src/stores/__tests__/outbox-store.test.ts +++ b/src/stores/__tests__/outbox-store.test.ts @@ -36,6 +36,8 @@ beforeEach(async () => { await useOutboxStore.getState().clear(); await useOutboxStore.getState().setAccount(ACCOUNT); await useOutboxStore.getState().clear(); + // The account's session is up and its server was checked (auth-store). + await useOutboxStore.getState().resume(ACCOUNT); }); describe('outbox enqueue + coalescing', () => { @@ -263,7 +265,7 @@ describe('keyword ops queued by earlier builds', () => { expect(useOutboxStore.getState().entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', accountId: undefined, patch: { $seen: true, '$label:work': true }, }); - await useOutboxStore.getState().flush(); + await useOutboxStore.getState().resume('acc-legacy'); expect(patchKeywordsForEmails).toHaveBeenNthCalledWith(1, ['e1'], { $seen: true, '$label:work': true }, undefined); // `$junk` and `$notjunk` exclude each other, so setting one clears the other. @@ -277,7 +279,7 @@ describe('keyword ops queued by earlier builds', () => { { id: 'a', createdAt: 1, attempts: 5, op: { kind: 'keywords', emailId: 'e1', keywords: { $notjunk: true, $flagged: true } } }, ]; await AsyncStorage.setItem('webmail:outbox:v1:acc-legacy:failed', JSON.stringify(legacy)); - await useOutboxStore.getState().setAccount('acc-legacy'); + await useOutboxStore.getState().resume('acc-legacy'); await useOutboxStore.getState().retryFailed(); @@ -285,3 +287,69 @@ describe('keyword ops queued by earlier builds', () => { expect(useOutboxStore.getState().failed).toHaveLength(0); }); }); + +describe('waiting for the account session (resume)', () => { + const OTHER = 'acc-2'; + const stored = (emailId: string) => ({ + id: `q-${emailId}`, createdAt: 1, attempts: 0, op: { kind: 'keywords', emailId, patch: { $seen: true } }, + }); + + beforeEach(async () => { + await AsyncStorage.removeItem(`webmail:outbox:v1:${OTHER}`); + await AsyncStorage.removeItem(`webmail:outbox:v1:${OTHER}:failed`); + }); + + it('queues and holds the ops of a newly attached account until it is resumed', async () => { + // Attached on an account switch: the client may still serve the account + // being left, although it reports itself connected. + await useOutboxStore.getState().setAccount(OTHER); + const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } }); + expect(result.queued).toBe(true); + await useOutboxStore.getState().flush(); + expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + + await useOutboxStore.getState().resume(OTHER); + + expect(patchKeywordsForEmails).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined); + expect(useOutboxStore.getState().entries).toHaveLength(0); + }); + + it('replays nothing when another account was attached while the queue loaded', async () => { + await AsyncStorage.setItem(`webmail:outbox:v1:${OTHER}`, JSON.stringify([stored('e1')])); + + const resuming = useOutboxStore.getState().resume(OTHER); + void useOutboxStore.getState().setAccount(ACCOUNT); + await resuming; + + expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + expect(useOutboxStore.getState().activeAccountId).toBe(ACCOUNT); + expect(useOutboxStore.getState().resumed).toBe(false); + }); + + it('keeps a queue cleared while it was still loading cleared', async () => { + // The server check drops the queue of a replaced server while the cold + // start is still reading it. + await AsyncStorage.setItem(`webmail:outbox:v1:${OTHER}`, JSON.stringify([stored('e1')])); + + const attaching = useOutboxStore.getState().setAccount(OTHER); + await useOutboxStore.getState().clearAccount(OTHER); + await attaching; + + expect(useOutboxStore.getState().entries).toEqual([]); + expect(await AsyncStorage.getItem(`webmail:outbox:v1:${OTHER}`)).toBeNull(); + await useOutboxStore.getState().resume(OTHER); + expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + }); + + it('keeps ops made while the queue was loading, after the stored ones', async () => { + await AsyncStorage.setItem(`webmail:outbox:v1:${OTHER}`, JSON.stringify([stored('e1')])); + + const attaching = useOutboxStore.getState().setAccount(OTHER); + useOutboxStore.getState().enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } }); + await attaching; + + expect(useOutboxStore.getState().entries.map((e) => e.op.emailId)).toEqual(['e1', 'e2']); + const persisted = JSON.parse((await AsyncStorage.getItem(`webmail:outbox:v1:${OTHER}`))!) as Array<{ op: { emailId: string } }>; + expect(persisted.map((e) => e.op.emailId)).toEqual(['e1', 'e2']); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 0436df48..70beb5f9 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -8,6 +8,7 @@ import { useContactsStore } from './contacts-store'; import { useCalendarStore } from './calendar-store'; import { useSettingsStore } from './settings-store'; import { useFilterStore } from './filter-store'; +import { useOutboxStore } from './outbox-store'; import { flushPersistedWrites } from './persist-storage'; import { clearEmailDetailCache } from '../lib/email-detail-cache'; import { clearBodyDocuments } from '../lib/email-body-document'; @@ -146,7 +147,15 @@ async function checkServerChange(accountId: string, session: JMAPSession): Promi } } -function refetchFeatureStores(): void { +// The outbox replays against whatever account the JMAP client serves, and a +// replaced server may have given the queued ids to other messages. So it +// runs only once the account's session is up and its server was checked. +function resumeOutbox(accountId: string): void { + void useOutboxStore.getState().resume(accountId); +} + +function refetchFeatureStores(accountId: string): void { + resumeOutbox(accountId); // Fire-and-forget: each store handles its own errors. const emailStore = useEmailStore.getState(); void emailStore.fetchMailboxes(); @@ -255,6 +264,7 @@ async function completeOAuthHandoff( useEmailStore.getState().setActiveAccount(accountId); applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId); + resumeOutbox(accountId); // Start on the folder list now rather than once the mail screen has // mounted; the screen joins this load. void useEmailStore.getState().fetchMailboxes(); @@ -334,6 +344,7 @@ export const useAuthStore = create((set, get) => ({ applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId); set({ pendingTotpLogin: null }); + resumeOutbox(accountId); // Start on the folder list now rather than once the mail screen has // mounted; the screen joins this load. void useEmailStore.getState().fetchMailboxes(); @@ -577,6 +588,7 @@ export const useAuthStore = create((set, get) => ({ // jmapClient first. If it fails, restore the previous active account // so we don't leave the user stranded on a half-switched state. const previousActive = get().activeAccountId; + const previousLive = get().session !== null; // loadAccount overwrites the client's credentials/session; keep the live // connection around so a failed switch can put it back instead of // leaving the previous account dead until relaunch. @@ -584,6 +596,9 @@ export const useAuthStore = create((set, get) => ({ const restorePrevious = () => { jmapClient.restoreSnapshot(previousClient); if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); + // Its session and server check still hold; without one, the next + // session resumes the outbox. + if (previousActive && previousLive) resumeOutbox(previousActive); }; try { const ok = await jmapClient.loadAccount(accountId); @@ -634,7 +649,7 @@ export const useAuthStore = create((set, get) => ({ await checkServerChange(accountId, session); applyConnectedState(set, session, target.serverUrl, target.username, accountId); - refetchFeatureStores(); + refetchFeatureStores(accountId); void syncAccountDisplayName(accountId); }, @@ -759,7 +774,7 @@ export const useAuthStore = create((set, get) => ({ // Refresh the cached mailbox list + current folder now that the session // is live. Feature stores show persisted data immediately; this swaps // in fresh data once the network round-trip completes. - refetchFeatureStores(); + refetchFeatureStores(target.id); void syncAccountDisplayName(target.id); return true; } catch { @@ -790,7 +805,7 @@ export const useAuthStore = create((set, get) => ({ const fresh = jmapClient.currentSession!; await checkServerChange(activeAccountId, fresh); applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId); - refetchFeatureStores(); + refetchFeatureStores(activeAccountId); return true; } catch (err) { if (err instanceof AuthenticationError) { diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 906c3077..ce0bf6cf 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -882,6 +882,18 @@ export const useEmailStore = create()( // run the network refresh afterwards. setActiveAccount: (accountId) => { const state = get(); + + // Point the offline body cache at the same account so the viewer's + // cache-first open and selectMailbox's seed read from the right bucket, + // and load the account's outbox. Also when the view already shows the + // account: a cold start restores it from the persisted view, and neither + // store is attached yet. Both ignore a repeat. Fire-and-forget: the + // cache returns empty until hydration completes, which is the correct + // degraded behaviour. The auth store replays the outbox once the + // account's session is up and its server was checked. + void useOfflineCacheStore.getState().setAccount(accountId); + void useOutboxStore.getState().setAccount(accountId); + if (state.activeAccountId === accountId) return; const nextSnapshots = { ...state.accountSnapshots }; @@ -905,17 +917,6 @@ export const useEmailStore = create()( error: null, loading: false, }); - - // Point the offline body cache at the same account so the viewer's - // cache-first open and selectMailbox's seed read from the right bucket. Fire- - // and-forget — the cache returns empty until hydration completes, - // which is the correct degraded behaviour. - void useOfflineCacheStore.getState().setAccount(accountId); - // Load the new account's outbox and try to drain it (no-op when offline or - // the JMAP client isn't serving this account yet). - void useOutboxStore.getState().setAccount(accountId).then(() => { - void useOutboxStore.getState().flush(); - }); }, removeAccount: (accountId) => { diff --git a/src/stores/offline-cache-store.ts b/src/stores/offline-cache-store.ts index e776b2ae..c1b6c034 100644 --- a/src/stores/offline-cache-store.ts +++ b/src/stores/offline-cache-store.ts @@ -78,7 +78,12 @@ interface OfflineCacheState { hydrate: () => Promise; has: (id: string, jmapAccountId?: string) => boolean; get: (id: string, jmapAccountId?: string) => Promise; - put: (email: Email, approxBytes: number, jmapAccountId?: string) => Promise; + /** + * Cache a body in the loaded bucket. `forAccountId` names the account the + * caller read it for; the put is dropped when another bucket is loaded by + * then (an account switch during a sync). + */ + put: (email: Email, approxBytes: number, jmapAccountId?: string, forAccountId?: string) => Promise; // Apply a change to a cached email (no-op when not cached). Used to keep the // cached body consistent with an optimistic/queued mutation so a re-open // while offline reflects the new keywords / mailboxIds. `keywords` is a @@ -134,6 +139,11 @@ async function loadIndex(accountId: string): Promise { return { entries: {} }; } +// Bumped whenever another bucket is loaded or the loaded one is cleared: an +// index read or a write still under way from before must not bring old +// entries into what is loaded now. +let bucketEpoch = 0; + export const useOfflineCacheStore = create((set, get) => ({ activeAccountId: null, index: { entries: {} }, @@ -150,6 +160,7 @@ export const useOfflineCacheStore = create((set, get) => ({ } // Drop any in-flight sync state from the previous account; the abort flag // makes runOfflineSync stop on its next chunk boundary. + const epoch = ++bucketEpoch; set({ activeAccountId: accountId, index: { ...EMPTY_INDEX }, @@ -159,8 +170,9 @@ export const useOfflineCacheStore = create((set, get) => ({ }); if (accountId) { const index = await loadIndex(accountId); - // Re-check in case another setAccount call raced past us. - if (get().activeAccountId !== accountId) return; + // Re-check in case another setAccount call, or a clearAccount(), raced + // past us. + if (epoch !== bucketEpoch || get().activeAccountId !== accountId) return; set({ index, hydrated: true, abortRequested: false }); } else { set({ hydrated: true, abortRequested: false }); @@ -177,8 +189,9 @@ export const useOfflineCacheStore = create((set, get) => ({ set({ hydrated: true }); return; } + const epoch = bucketEpoch; const index = await loadIndex(accountId); - if (get().activeAccountId !== accountId) return; + if (epoch !== bucketEpoch || get().activeAccountId !== accountId) return; set({ index, hydrated: true }); }, @@ -199,10 +212,12 @@ export const useOfflineCacheStore = create((set, get) => ({ } }, - put: async (email, approxBytes, jmapAccountId) => { + put: async (email, approxBytes, jmapAccountId, forAccountId) => { const state = get(); const accountId = state.activeAccountId; if (!accountId) return; + if (forAccountId !== undefined && forAccountId !== accountId) return; + const epoch = bucketEpoch; const key = cacheKey(email.id, jmapAccountId); try { await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(email)); @@ -212,8 +227,9 @@ export const useOfflineCacheStore = create((set, get) => ({ } // Re-read the active account in case it changed while we were awaiting // AsyncStorage — don't stamp the new account's index with the old - // account's entry. - if (get().activeAccountId !== accountId) return; + // account's entry, nor put it back into a bucket cleared meanwhile. The + // body stays unindexed, so it is never read. + if (epoch !== bucketEpoch || get().activeAccountId !== accountId) return; const nextEntries = { ...get().index.entries, [key]: { @@ -233,6 +249,7 @@ export const useOfflineCacheStore = create((set, get) => ({ const accountId = get().activeAccountId; const key = cacheKey(id, jmapAccountId); if (!accountId || !get().index.entries[key]) return; + const epoch = bucketEpoch; try { const raw = await AsyncStorage.getItem(entryKey(accountId, key)); if (!raw) return; @@ -242,7 +259,7 @@ export const useOfflineCacheStore = create((set, get) => ({ ...(changes.mailboxIds ? { mailboxIds: changes.mailboxIds } : {}), ...(changes.keywords ? { keywords: applyKeywordPatch(email.keywords, changes.keywords) } : {}), }; - if (get().activeAccountId !== accountId) return; + if (epoch !== bucketEpoch || get().activeAccountId !== accountId) return; await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(updated)); } catch (err) { console.warn('[offline-cache] patch failed', id, err); @@ -308,13 +325,18 @@ export const useOfflineCacheStore = create((set, get) => ({ clearAccount: async (accountId) => { const loaded = get().activeAccountId === accountId; - const keys = Object.keys((loaded ? get().index : await loadIndex(accountId)).entries); + const keys = new Set(loaded ? Object.keys(get().index.entries) : []); if (loaded) { - // A sync still filling the bucket stops at its next chunk. - set({ index: { entries: {} }, sync: { ...IDLE }, abortRequested: true }); + // A sync still filling the bucket stops at its next chunk, and a read + // of the index still under way must not bring the old entries back. + bucketEpoch += 1; + set({ index: { entries: {} }, hydrated: true, sync: { ...IDLE }, abortRequested: true }); } + // The stored index as well: the loaded one may not have been read yet, + // or may not have been written yet. + for (const key of Object.keys((await loadIndex(accountId)).entries)) keys.add(key); await Promise.all( - keys.map((key) => AsyncStorage.removeItem(entryKey(accountId, key)).catch(() => undefined)), + [...keys].map((key) => AsyncStorage.removeItem(entryKey(accountId, key)).catch(() => undefined)), ); await AsyncStorage.removeItem(indexKey(accountId)).catch(() => undefined); }, diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 4b9c85d5..8ef98bcc 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -16,7 +16,8 @@ // When the device is online and nothing is already queued for a message, the // op runs immediately (preserving today's behaviour, including surfacing real // server errors to the caller). Otherwise it's persisted to AsyncStorage and -// replayed by flush() once connectivity returns. Storage is keyed per account, +// replayed by flush() once connectivity returns, and not before the auth store +// has resumed the account (see `resumed`). Storage is keyed per account, // mirroring offline-cache-store, so queues never leak between logins. import { create } from 'zustand'; @@ -86,10 +87,23 @@ interface OutboxState { failed: OutboxEntry[]; hydrated: boolean; flushing: boolean; - /** Set after an authentication failure; cleared by setAccount()/login. */ + /** Set after an authentication failure; cleared by setAccount()/resume(). */ paused: boolean; + /** + * False from attaching an account until the auth store calls resume() for + * it, once the account's session is up and the server behind it was + * checked (lib/server-change). Until then ops queue and flush waits: the + * JMAP client may still serve the previous account, and a replaced server + * may have given the queued ids to other messages. + */ + resumed: boolean; setAccount: (accountId: string | null) => Promise; + /** + * The account's session is up and its server was checked: attach and load + * its queue if needed, then replay it. + */ + resume: (accountId: string) => Promise; hydrate: () => Promise; enqueue: (op: OutboxOp) => void; count: () => number; @@ -189,6 +203,13 @@ function opFamily(op: OutboxOp): string { let retryTimer: ReturnType | null = null; let retryAttempt = 0; +// Reads of a bucket from storage. A read still running when the account +// changes or its queue is cleared must not put the old entries back, so +// each one carries the epoch it started in; callers of the same account +// share the read in flight. +let loadEpoch = 0; +let bucketLoad: { accountId: string; epoch: number; promise: Promise } | null = null; + function clearRetry(): void { if (retryTimer) clearTimeout(retryTimer); retryTimer = null; @@ -215,6 +236,30 @@ async function refreshAfterDrop(): Promise { } } +// Load the attached account's bucket, or join the read already under way. +function loadBucket(accountId: string): Promise { + if (bucketLoad && bucketLoad.accountId === accountId && bucketLoad.epoch === loadEpoch) { + return bucketLoad.promise; + } + const epoch = ++loadEpoch; + const promise = (async () => { + const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); + const store = useOutboxStore.getState(); + if (epoch !== loadEpoch || store.activeAccountId !== accountId) return; + // Ops the user made while the bucket was loading go after the stored + // ones, and the stored list is written again with them: their persist + // replaced it with only the new ops. + const early = store.entries; + const merged = early.length > 0 ? [...entries, ...early] : entries; + useOutboxStore.setState({ entries: merged, failed, hydrated: true }); + if (early.length > 0) persist(accountId, merged); + })().finally(() => { + if (bucketLoad?.promise === promise) bucketLoad = null; + }); + bucketLoad = { accountId, epoch, promise }; + return promise; +} + export const useOutboxStore = create((set, get) => ({ activeAccountId: null, entries: [], @@ -222,6 +267,7 @@ export const useOutboxStore = create((set, get) => ({ hydrated: false, flushing: false, paused: false, + resumed: false, setAccount: async (accountId) => { const state = get(); @@ -232,17 +278,24 @@ export const useOutboxStore = create((set, get) => ({ } clearRetry(); retryAttempt = 0; - set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false }); + // Each attach reads the bucket afresh; a read from before is dropped. + loadEpoch += 1; + set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false, resumed: false }); if (accountId) { - const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); - // Re-check in case another setAccount raced past us. - if (get().activeAccountId !== accountId) return; - set({ entries, failed, hydrated: true }); + await loadBucket(accountId); } else { set({ hydrated: true }); } }, + resume: async (accountId) => { + await get().setAccount(accountId); + // Another account may have been attached while this one loaded. + if (get().activeAccountId !== accountId) return; + set({ resumed: true, paused: false }); + await get().flush(); + }, + hydrate: async () => { const state = get(); if (state.hydrated) return; @@ -251,9 +304,7 @@ export const useOutboxStore = create((set, get) => ({ set({ hydrated: true }); return; } - const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); - if (get().activeAccountId !== accountId) return; - set({ entries, failed, hydrated: true }); + await loadBucket(accountId); }, enqueue: (op) => { @@ -301,7 +352,7 @@ export const useOutboxStore = create((set, get) => ({ pendingForEmail: (emailId) => get().entries.filter((e) => e.op.emailId === emailId), flush: async () => { - if (get().flushing || get().paused) return; + if (get().flushing || get().paused || !get().resumed || !get().hydrated) return; const accountId = get().activeAccountId; if (!accountId) return; if (get().entries.length === 0) return; @@ -398,7 +449,9 @@ export const useOutboxStore = create((set, get) => ({ clearAccount: async (accountId) => { if (get().activeAccountId === accountId) { clearRetry(); - set({ entries: [], failed: [] }); + // A read of the bucket still under way must not bring the ops back. + loadEpoch += 1; + set({ entries: [], failed: [], hydrated: true }); } await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); @@ -461,7 +514,9 @@ export async function applyOrQueueBatch( ): Promise { if (ops.length === 0) return { queued: false }; const store = useOutboxStore.getState(); - const online = useNetworkStore.getState().online && jmapClient.isConnected && !store.paused; + // Until the auth store resumes the queue, the client may still serve the + // account being left, or a server not yet checked: queue, replay later. + const online = useNetworkStore.getState().online && jmapClient.isConnected && !store.paused && store.resumed; const hasQueued = ops.some((op) => store.entries.some((e) => e.op.emailId === op.emailId), );