diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts index e6b16cac..d584bd50 100644 --- a/src/api/__tests__/jmap-client-hardening.test.ts +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -418,6 +418,38 @@ describe('JMAPClient hardening', () => { expect(client.authHeader).toBe('Bearer new'); }); + it('keeps tokens refreshed for the account being left with it, not with the one loaded meanwhile', async () => { + // B (OAuth, token about to expire) is tapped, then C before B is up. + const SecureStore = await import('expo-secure-store'); + const keyOf = (accountId: string) => `jmap_credentials__${accountId.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const B_ID = 'b@example.com@mail.example.com'; + const C_ID = 'c@example.com@mail.example.com'; + const tokens = { tokenEndpoint: 'https://mail.example.com/auth/token', clientId: 'app' }; + const stored: Record = { + [keyOf(B_ID)]: JSON.stringify({ + serverUrl: 'https://mail.example.com', username: 'b@example.com', password: '', + accessToken: 'b-old', refreshToken: 'b-r1', expiresAt: Date.now() + 10_000, ...tokens, + }), + [keyOf(C_ID)]: JSON.stringify({ serverUrl: 'https://mail.example.com', username: 'c@example.com', password: 'pw' }), + }; + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key: string) => stored[key] ?? null); + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key: string, value: string) => { stored[key] = value; }); + let refreshed!: (value: unknown) => void; + mockRefresh.mockImplementationOnce(() => new Promise((resolve) => { refreshed = resolve; })); + mockFetch([{ status: 200, json: SESSION }]); + const client = new JMAPClient(); + + const toB = client.loadAccount(B_ID); + await vi.waitFor(() => expect(mockRefresh).toHaveBeenCalled()); + expect(await client.loadAccount(C_ID)).toBe(true); + refreshed({ accessToken: 'b-new', refreshToken: 'b-r2', expiresAt: Date.now() + 3_600_000, ...tokens }); + + await expect(toB).rejects.toThrow('Another account was opened'); + expect(JSON.parse(stored[keyOf(B_ID)])).toMatchObject({ username: 'b@example.com', accessToken: 'b-new', refreshToken: 'b-r2' }); + expect(JSON.parse(stored[keyOf(C_ID)])).toEqual({ serverUrl: 'https://mail.example.com', username: 'c@example.com', password: 'pw' }); + expect(client.authHeader).toBe(`Basic ${btoa('c@example.com:pw')}`); + }); + it('updatePassword rewrites the stored credential', async () => { const SecureStore = await import('expo-secure-store'); const client = await connected(); diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index 0eb26aeb..4edf77b4 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({ })); import * as SecureStore from 'expo-secure-store'; -import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client'; +import { JMAPClient, AuthenticationError, RateLimitError, SessionSupersededError, parseHoldLimit } from '../jmap-client'; import type { JMAPSession } from '../types'; const MOCK_SESSION: JMAPSession = { @@ -281,6 +281,117 @@ describe('JMAPClient', () => { expect(client.isConnected).toBe(true); expect(client.accountId).toBe('acc-1'); }); + + describe('when loads overlap (B tapped, then C before B is up)', () => { + const sessionOf = (host: string, id: string): JMAPSession => ({ + ...MOCK_SESSION, + apiUrl: `https://${host}/jmap/`, + primaryAccounts: { 'urn:ietf:params:jmap:mail': id }, + accounts: { [id]: { name: id, isPersonal: true, isReadOnly: false } }, + }); + const B = { key: 'jmap_credentials__b_b.example.com', creds: { serverUrl: 'https://b.example.com', username: 'b', password: 'pw' } }; + const C = { key: 'jmap_credentials__c_c.example.com', creds: { serverUrl: 'https://c.example.com', username: 'c', password: 'pw' } }; + + // Session requests wait for the test to answer them, per host; a host + // answered with null is unreachable from then on (the retry fails too). + let answer: (host: string, session: JMAPSession | null) => Promise; + beforeEach(() => { + const waiting = new Map void>(); + const unreachable = new Set(); + global.fetch = vi.fn((url: string) => new Promise((resolve, reject) => { + const host = new URL(url).host; + const respond = (session: JMAPSession | null) => (session + ? resolve({ + ok: true, status: 200, statusText: 'OK', headers: { get: () => null }, + json: async () => session, text: async () => JSON.stringify(session), + }) + : reject(new TypeError('Network request failed'))); + if (unreachable.has(host)) respond(null); + else waiting.set(host, respond); + })) as any; + answer = async (host, session) => { + await vi.waitFor(() => expect(waiting.has(host)).toBe(true)); + const respond = waiting.get(host)!; + waiting.delete(host); + if (!session) unreachable.add(host); + respond(session); + }; + (SecureStore.getItemAsync as any).mockImplementation(async (key: string) => + ({ [B.key]: JSON.stringify(B.creds), [C.key]: JSON.stringify(C.creds) } as Record)[key] ?? null); + }); + + afterEach(() => { + (SecureStore.getItemAsync as any).mockReset(); + }); + + it('keeps the account loaded last when an earlier load answers after it', async () => { + const toB = client.loadAccount('b@b.example.com'); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + const toC = client.loadAccount('c@c.example.com'); + await answer('c.example.com', sessionOf('c.example.com', 'acc-c')); + expect(await toC).toBe(true); + + await answer('b.example.com', sessionOf('b.example.com', 'acc-b')); + + await expect(toB).rejects.toBeInstanceOf(SessionSupersededError); + expect(client.accountId).toBe('acc-c'); + expect(client.currentSession?.apiUrl).toBe('https://c.example.com/jmap/'); + expect(client.username).toBe('c'); + }); + + it('keeps the newer session up when an earlier load fails after it', async () => { + const toB = client.loadAccount('b@b.example.com'); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + const toC = client.loadAccount('c@c.example.com'); + await answer('c.example.com', sessionOf('c.example.com', 'acc-c')); + await toC; + + await answer('b.example.com', null); + + await expect(toB).rejects.toBeInstanceOf(SessionSupersededError); + expect(client.isConnected).toBe(true); + expect(client.accountId).toBe('acc-c'); + }); + + it('does not put in the credentials of a load whose read of them ended last', async () => { + let readB!: (value: string) => void; + (SecureStore.getItemAsync as any).mockImplementation((key: string) => (key === B.key + ? new Promise((resolve) => { readB = resolve; }) + : Promise.resolve(JSON.stringify(C.creds)))); + + const toB = client.loadAccount('b@b.example.com'); + const toC = client.loadAccount('c@c.example.com'); + await answer('c.example.com', sessionOf('c.example.com', 'acc-c')); + await toC; + readB(JSON.stringify(B.creds)); + + await expect(toB).rejects.toBeInstanceOf(SessionSupersededError); + expect(client.username).toBe('c'); + expect((global.fetch as any).mock.calls.map(([url]: [string]) => new URL(url).host)).toEqual(['c.example.com']); + }); + + it('gives way to a snapshot put back meanwhile (a failed switch)', async () => { + global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + const previous = client.snapshot(); + let respond!: (session: JMAPSession) => void; + global.fetch = vi.fn(() => new Promise((resolve) => { + respond = (session) => resolve({ + ok: true, status: 200, statusText: 'OK', headers: { get: () => null }, + json: async () => session, text: async () => JSON.stringify(session), + }); + })) as any; + + const toB = client.loadAccount('b@b.example.com'); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalled()); + client.restoreSnapshot(previous); + respond(sessionOf('b.example.com', 'acc-b')); + + await expect(toB).rejects.toBeInstanceOf(SessionSupersededError); + expect(client.accountId).toBe('acc-1'); + expect(client.username).toBe('user'); + }); + }); }); describe('logout', () => { diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 3e2c526b..b8c73cae 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -143,6 +143,13 @@ export class JMAPClient { private session: JMAPSession | null = null; private credentials: StoredCredentials | null = null; private _accountId: string | null = null; + /** + * Bumped whenever the client is handed to another account (a load, a + * sign-in, a snapshot put back, a reset). Account changes can overlap, and + * a load or sign-in whose session arrives after a newer one began must not + * install it (see SessionSupersededError). + */ + private sessionEpoch = 0; private firstTouchGate = new FirstTouchGate(); private rateLimitedUntil = 0; private authFailureListeners = new Set<(err: AuthenticationError) => void>(); @@ -251,23 +258,27 @@ export class JMAPClient { totp?: string, ): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); - this.detachSession(); + const epoch = this.detachSession(); if (totp) { - return this.connectWithTotp(baseUrl, username, password, totp); + return this.connectWithTotp(baseUrl, username, password, totp, epoch); } - this.credentials = { serverUrl: baseUrl, username, password }; + const credentials: StoredCredentials = { serverUrl: baseUrl, username, password }; + this.credentials = credentials; - this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); - this._accountId = this.resolveAccountId(this.session); + const fetched = await this.fetchSession(baseUrl); + this.assertCurrentSession(epoch); + const session = this.rewriteSessionUrls(fetched, baseUrl); + this.session = session; + this._accountId = this.resolveAccountId(session); this.firstTouchGate.reset(); const accountId = generateAccountId(username, baseUrl); await SecureStore.setItemAsync( credentialsKey(accountId), - JSON.stringify(this.credentials), + JSON.stringify(credentials), ); - return this.session; + return session; } /** @@ -282,6 +293,7 @@ export class JMAPClient { username: string, password: string, totp: string, + epoch: number, ): Promise { const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login'); let tokens: OAuthTokens; @@ -289,25 +301,31 @@ export class JMAPClient { tokens = await exchangePasswordForTokens(baseUrl, username, password, totp); } catch (err) { if (err instanceof LoginEndpointMissingError) { + this.assertCurrentSession(epoch); // Pre-0.16 Stalwart: TOTP rides along in the Basic password. return this.connect(baseUrl, username, `${password}$${totp}`); } throw err; } + // Signing in again would take the client back from a newer account change. + this.assertCurrentSession(epoch); const { session } = await this.connectWithOAuth(baseUrl, tokens, username); return session; } async connectWithToken(serverUrl: string, accessToken: string): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); - this.detachSession(); + const epoch = this.detachSession(); this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken }; - this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); - this._accountId = this.resolveAccountId(this.session); + const fetched = await this.fetchSession(baseUrl); + this.assertCurrentSession(epoch); + const session = this.rewriteSessionUrls(fetched, baseUrl); + this.session = session; + this._accountId = this.resolveAccountId(session); this.firstTouchGate.reset(); - return this.session; + return session; } // OAuth login via webmail handoff. The webmail did the OAuth dance against @@ -321,7 +339,7 @@ export class JMAPClient { preferredUsername?: string, ): Promise<{ session: JMAPSession; username: string; accountId: string }> { const baseUrl = serverUrl.replace(/\/+$/, ''); - this.detachSession(); + const epoch = this.detachSession(); this.credentials = { serverUrl: baseUrl, username: '', @@ -334,68 +352,95 @@ export class JMAPClient { tokenSource: tokens.source, }; - this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); - this._accountId = this.resolveAccountId(this.session); + const fetched = await this.fetchSession(baseUrl); + this.assertCurrentSession(epoch); + const session = this.rewriteSessionUrls(fetched, baseUrl); + this.session = session; + this._accountId = this.resolveAccountId(session); this.firstTouchGate.reset(); // The JMAP session document carries the authenticated user's identifier // — use it as the username so per-account storage keys are stable across // restarts and OAuth re-logins. const username = - preferredUsername || this.session.username || tokens.accessToken.slice(0, 8); - this.credentials.username = username; + preferredUsername || session.username || tokens.accessToken.slice(0, 8); + // Still this sign-in's, possibly with tokens refreshed during the fetch. + const credentials: StoredCredentials = { ...this.credentials!, username }; + this.credentials = credentials; const accountId = generateAccountId(username, baseUrl); await SecureStore.setItemAsync( credentialsKey(accountId), - JSON.stringify(this.credentials), + JSON.stringify(credentials), ); - return { session: this.session, username, accountId }; + return { session, username, accountId }; } - private async persistRefreshedTokens(next: OAuthTokens): Promise { - if (!this.credentials) return; - if (this.credentials.accessToken === next.accessToken) return; - this.credentials = { - ...this.credentials, + /** + * Keep refreshed tokens with the account they were refreshed for + * (`refreshed`). The client may serve another account by the time the + * token endpoint answers (a switch while the account being left + * refreshed): the tokens then go to that account's stored credentials, if + * it still has any, and never into the ones in use. They used to, and were + * saved under the other account, which then signed in as this one. + */ + private async persistRefreshedTokens(refreshed: StoredCredentials, next: OAuthTokens): Promise { + const accountId = generateAccountId(refreshed.username, refreshed.serverUrl); + const current = this.credentials; + const inUse = !!current + && generateAccountId(current.username, current.serverUrl) === accountId; + let base: StoredCredentials; + if (inUse && current) { + base = current; + } else { + // Signed out meanwhile: nothing to keep. + const stored = await this.getStoredCredentials(accountId); + if (!stored) return; + base = stored; + } + if (base.accessToken === next.accessToken) return; + const updated: StoredCredentials = { + ...base, accessToken: next.accessToken, // Some IdPs rotate refresh tokens; others reuse. Fall back to the // existing one when the response omits it. - refreshToken: next.refreshToken ?? this.credentials.refreshToken, + refreshToken: next.refreshToken ?? base.refreshToken, expiresAt: next.expiresAt, tokenEndpoint: next.tokenEndpoint, clientId: next.clientId, }; - const accountId = generateAccountId( - this.credentials.username, - this.credentials.serverUrl, - ); + if (inUse) this.credentials = updated; await SecureStore.setItemAsync( credentialsKey(accountId), - JSON.stringify(this.credentials), + JSON.stringify(updated), ); + if (!inUse) return; for (const l of this.tokenRefreshListeners) { try { l(); } catch { /* ignore */ } } } - private currentOAuthTokens(): OAuthTokens | null { + private currentOAuthTokens(): { credentials: StoredCredentials; tokens: OAuthTokens } | null { + const credentials = this.credentials; if ( - !this.credentials?.accessToken || - !this.credentials.refreshToken || - !this.credentials.tokenEndpoint || - !this.credentials.clientId + !credentials?.accessToken || + !credentials.refreshToken || + !credentials.tokenEndpoint || + !credentials.clientId ) { return null; } return { - accessToken: this.credentials.accessToken, - refreshToken: this.credentials.refreshToken, - expiresAt: this.credentials.expiresAt, - tokenEndpoint: this.credentials.tokenEndpoint, - clientId: this.credentials.clientId, - source: this.credentials.tokenSource, + credentials, + tokens: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + tokenEndpoint: credentials.tokenEndpoint, + clientId: credentials.clientId, + source: credentials.tokenSource, + }, }; } @@ -418,13 +463,14 @@ export class JMAPClient { // consumers of `authHeader` (SSE stream, file downloads) can make sure the // header they capture is not about to expire. async ensureFreshToken(): Promise { - const tokens = this.currentOAuthTokens(); - if (!tokens) return; + const current = this.currentOAuthTokens(); + if (!current) return; + const { credentials, tokens } = current; if (tokens.expiresAt == null) return; if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return; try { const next = await refreshOAuthAccessToken(tokens); - await this.persistRefreshedTokens(next); + await this.persistRefreshedTokens(credentials, next); } catch { // Surface as AuthenticationError on the next 401; refresh may be // temporarily failing (network) and the reactive retry path catches it. @@ -437,11 +483,11 @@ export class JMAPClient { // the account instead of evicting it (webmail 1.7.6 "keep the session when // the auth server is briefly unreachable"). async forceRefreshToken(): Promise { - const tokens = this.currentOAuthTokens(); - if (!tokens) return false; + const current = this.currentOAuthTokens(); + if (!current) return false; try { - const next = await refreshOAuthAccessToken(tokens); - await this.persistRefreshedTokens(next); + const next = await refreshOAuthAccessToken(current.tokens); + await this.persistRefreshedTokens(current.credentials, next); return true; } catch (err) { if (err instanceof TransientRefreshError) { @@ -492,8 +538,10 @@ export class JMAPClient { } async loadAccount(accountId: string): Promise { - this.detachSession(); + const epoch = this.detachSession(); const stored = await SecureStore.getItemAsync(credentialsKey(accountId)); + // A newer load may have put its credentials in by now. + this.assertCurrentSession(epoch); if (!stored) return false; let creds: StoredCredentials; @@ -509,12 +557,12 @@ export class JMAPClient { // avoid clearing credentials when the server is just unreachable. this.credentials = creds; this.firstTouchGate.reset(); + let fetched: JMAPSession; try { - this.session = this.rewriteSessionUrls( - await this.fetchSession(creds.serverUrl), - creds.serverUrl, - ); + fetched = await this.fetchSession(creds.serverUrl); } catch (err) { + // The client belongs to a newer account change, which may be up. + this.assertCurrentSession(epoch); // Don't leave a half-populated client behind; the caller needs to know // the session is unavailable. Credentials stay in memory so a retry // after the network comes back doesn't need a re-login. @@ -528,7 +576,10 @@ export class JMAPClient { err instanceof Error ? err.message : 'Server unreachable', ); } - this._accountId = this.resolveAccountId(this.session); + this.assertCurrentSession(epoch); + const session = this.rewriteSessionUrls(fetched, creds.serverUrl); + this.session = session; + this._accountId = this.resolveAccountId(session); return true; } @@ -537,11 +588,24 @@ export class JMAPClient { * 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. + * switch keep a snapshot() to put it back on failure. Resolves to the new + * epoch, which the load or sign-in checks once its session arrives. */ - private detachSession(): void { + private detachSession(): number { this.session = null; this._accountId = null; + this.sessionEpoch += 1; + return this.sessionEpoch; + } + + /** + * Throw SessionSupersededError when the client was handed to another + * account since `epoch`. Account changes overlap (B tapped, then C before + * B's session is up): the older one's session must not replace the newer + * one's, nor sit under its credentials. + */ + private assertCurrentSession(epoch: number): void { + if (epoch !== this.sessionEpoch) throw new SessionSupersededError(); } /** @@ -557,6 +621,7 @@ export class JMAPClient { } restoreSnapshot(snap: ClientSnapshot): void { + this.sessionEpoch += 1; this.session = snap.session; this.credentials = snap.credentials; this._accountId = snap.accountId; @@ -618,6 +683,7 @@ export class JMAPClient { // Reset in-memory state without touching persisted credentials (used on // account switch so the active account's stored creds remain available). reset(): void { + this.sessionEpoch += 1; this.session = null; this.credentials = null; this._accountId = null; @@ -849,10 +915,14 @@ export class JMAPClient { */ async refreshSession(): Promise { if (!this.credentials) throw new Error('No credentials'); + const epoch = this.sessionEpoch; const baseUrl = this.credentials.serverUrl; - this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); - this._accountId = this.resolveAccountId(this.session); - return this.session; + const fetched = await this.fetchSession(baseUrl); + this.assertCurrentSession(epoch); + const session = this.rewriteSessionUrls(fetched, baseUrl); + this.session = session; + this._accountId = this.resolveAccountId(session); + return session; } /** @@ -1281,6 +1351,16 @@ export class TotpRequiredError extends AuthenticationError { } } +// A load or sign-in whose session arrived after a newer account change took +// the client over (see JMAPClient.assertCurrentSession). Its session was not +// installed; the caller leaves the client and the stores to the newer change. +export class SessionSupersededError extends Error { + constructor() { + super('Another account was opened before this one finished loading'); + this.name = 'SessionSupersededError'; + } +} + // Server unreachable / transient transport failure. Callers should keep // stored credentials and surface an offline state rather than logging out. export class NetworkError extends Error { diff --git a/src/stores/__tests__/auth-store-add-account-outbox.test.ts b/src/stores/__tests__/auth-store-add-account-outbox.test.ts new file mode 100644 index 00000000..38dc15b9 --- /dev/null +++ b/src/stores/__tests__/auth-store-add-account-outbox.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Adding an account while the one in use has edits queued. The JMAP client +// serves the new account as soon as its session is up (connect), but the +// stores, and with them the outbox, move to it only once its server was +// checked. The queue must not replay through the new account meanwhile: +// Stalwart's ids repeat across accounts, so a queued move or destroy of A's +// "x" would hit C's "x". + +const { client, SessionSupersededError } = vi.hoisted(() => { + const A = 'usera@mail.example.org'; + const B = 'userb@mail.example.org'; + const C = 'userc@mail.example.org'; + const jmapIds: Record = { [A]: 'd', [B]: 'e', [C]: 'f' }; + const session = (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': {}, + 'urn:ietf:params:jmap:principals:owner': {}, + 'urn:ietf:params:jmap:metadata': {}, + }, + }, + }, + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {} }, + state: 'b3bed0bb', + }); + return { + client: { + A, + B, + C, + jmapIds, + session, + // The local account (username@host) the client serves; null while it + // serves none. + served: null as string | null, + // Bumped by every sign-in, load and restore, as JMAPClient's session + // epoch: one that a later one overtook installs nothing. + epoch: 0, + connectGate: Promise.resolve() as Promise, + connectError: null as Error | null, + loadGate: Promise.resolve() as Promise, + requestGate: null as Promise | null, + emailSets: [] as Array<{ served: string; args: Record }>, + }, + SessionSupersededError: class SessionSupersededError extends Error { + constructor() { + super('Another account was opened before this one finished loading'); + this.name = 'SessionSupersededError'; + } + }, + }; +}); + +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.jmapIds[client.served]) : null; + }, + get username() { return client.served ? client.served.split('@')[0] : null; }, + get serverUrl() { return client.served ? 'https://mail.example.org' : null; }, + connect: vi.fn(async (_url: string, username: string) => { + const epoch = ++client.epoch; + client.served = null; + await client.connectGate; + if (epoch !== client.epoch) throw new SessionSupersededError(); + if (client.connectError) throw client.connectError; + client.served = `${username}@mail.example.org`; + return client.session(client.jmapIds[client.served]); + }), + loadAccount: vi.fn(async (accountId: string) => { + const epoch = ++client.epoch; + client.served = null; + await client.loadGate; + if (epoch !== client.epoch) throw new SessionSupersededError(); + client.served = accountId; + return true; + }), + snapshot: vi.fn(() => ({ served: client.served })), + restoreSnapshot: vi.fn((snap: { served: string | null }) => { + client.epoch += 1; + 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'); + const served = client.served; + for (const [name, args] of calls) { + if (name === 'Email/set') client.emailSets.push({ served, args }); + } + if (client.requestGate) await client.requestGate; + 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 {}, + SessionSupersededError, +})); + +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, C } = client; +const queued = (emailId: string, createdAt = 1) => ({ + id: `q-${emailId}`, createdAt, attempts: 0, op: { kind: 'keywords', emailId, patch: { $seen: true } }, +}); +const storedQueue = async (account: string) => + (JSON.parse((await AsyncStorage.getItem(`webmail:outbox:v1:${account}`)) ?? '[]') as Array<{ op: { emailId: string } }>) + .map((e) => e.op.emailId); +const settle = async () => { + for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); +}; +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()!; +// Hold the new account's server check, the window between its session coming +// up and the stores moving to it. +function holdServerCheck(account: string): () => void { + const check = deferred(); + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => { + if (key === `server-fingerprint:v1:${account}`) await check.promise; + return readStored(key); + }); + return check.open; +} +const addC = () => useAuthStore.getState().login('https://mail.example.org', 'userc', 'pw', { addAccount: true }); + +// A has these ops queued (one the server refused once and that is retried, +// or made offline), stored as the outbox stores them. +async function queueForA(...ops: ReturnType[]): Promise { + await AsyncStorage.setItem(`webmail:outbox:v1:${A}`, JSON.stringify(ops)); + useOutboxStore.setState({ entries: ops as never }); +} + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await AsyncStorage.clear(); + client.connectGate = Promise.resolve(); + client.connectError = null; + client.loadGate = Promise.resolve(); + client.requestGate = 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 }); + for (const [account, jmapId] of [[A, 'd'], [B, 'e'], [C, 'f']]) { + await writeServerFingerprint(account, sessionFingerprint(client.session(jmapId) as unknown as JMAPSession)); + } + + // A is signed in and live. + 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('d') as unknown as JMAPSession, + activeAccountId: A, + accountId: 'd', + client: null, + }); +}); + +afterEach(() => { + vi.mocked(AsyncStorage.getItem).mockImplementation(readStored); +}); + +describe('adding an account while the one in use has queued edits (Stalwart 1.0)', () => { + it('replays nothing through the new account while it is set up', async () => { + await queueForA(queued('m1')); + const checked = holdServerCheck(C); + + const login = addC(); + await settle(); + expect(client.served).toBe(C); + expect(useOutboxStore.getState().activeAccountId).toBe(A); + // What the retry timer, a network flip or refreshAfterResume does. + await useOutboxStore.getState().flush(); + + checked(); + await login; + await settle(); + + expect(client.emailSets).toEqual([]); + expect(useOutboxStore.getState().activeAccountId).toBe(C); + // A's edit waits for A. + expect(await storedQueue(A)).toEqual(['m1']); + }); + + it('queues an edit made in that window for the account it was made in', async () => { + const checked = holdServerCheck(C); + + const login = addC(); + await settle(); + const result = await applyOrQueue({ kind: 'keywords', emailId: 'a1', patch: { $seen: true } }); + checked(); + await login; + await settle(); + + expect(result.queued).toBe(true); + expect(client.emailSets).toEqual([]); + expect(await storedQueue(A)).toEqual(['a1']); + }); + + it('stops a replay under way for the account in use once the new session is up', async () => { + await queueForA(queued('m1', 1), queued('m2', 2)); + const firstRequest = deferred(); + client.requestGate = firstRequest.promise; + + const flushing = useOutboxStore.getState().flush(); + await settle(); + // m1 is on its way to A. + expect(client.emailSets.map((s) => s.served)).toEqual([A]); + client.requestGate = null; + + // The user signs in to another account meanwhile; C's session comes up. + const checked = holdServerCheck(C); + const login = addC(); + await settle(); + expect(client.served).toBe(C); + + // m1's response arrives. + firstRequest.open(); + await flushing; + checked(); + await login; + await settle(); + + expect(client.emailSets.map((s) => [s.served, s.args.accountId])).toEqual([[A, 'd']]); + expect(await storedQueue(A)).toEqual(['m2']); + }); + + it('puts the account in use and its queue back to work when the sign-in fails', async () => { + await queueForA(queued('m1')); + const connecting = deferred(); + client.connectGate = connecting.promise; + client.connectError = new Error('Invalid username or password'); + + const login = addC(); + await settle(); + // Held while the client signs in. + await useOutboxStore.getState().flush(); + expect(client.emailSets).toEqual([]); + connecting.open(); + await expect(login).rejects.toThrow('Invalid username or password'); + await settle(); + + expect(client.served).toBe(A); + expect(useOutboxStore.getState()).toMatchObject({ activeAccountId: A, resumed: true }); + expect(client.emailSets.map((s) => [s.served, s.args.accountId])).toEqual([[A, 'd']]); + expect(await storedQueue(A)).toEqual([]); + }); + + it('leaves the client to a switch that began while the sign-in was under way', async () => { + // A notification tap switches to B while C's sign-in waits on its server. + const connecting = deferred(); + client.connectGate = connecting.promise; + + const login = addC(); + await settle(); + await useAuthStore.getState().switchAccount(B); + connecting.open(); + await expect(login).rejects.toThrow('Another account was opened'); + await settle(); + + expect(client.served).toBe(B); + expect(useAuthStore.getState().activeAccountId).toBe(B); + expect(useEmailStore.getState().activeAccountId).toBe(B); + expect(useOutboxStore.getState()).toMatchObject({ activeAccountId: B, resumed: true }); + }); +}); diff --git a/src/stores/__tests__/auth-store-switch-outbox.test.ts b/src/stores/__tests__/auth-store-switch-outbox.test.ts index cbc1ff65..aa7f3e22 100644 --- a/src/stores/__tests__/auth-store-switch-outbox.test.ts +++ b/src/stores/__tests__/auth-store-switch-outbox.test.ts @@ -5,10 +5,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // 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 { client, SessionSupersededError } = vi.hoisted(() => { const A = 'usera@mail.example.org'; const B = 'userb@mail.example.org'; - const jmapIds: Record = { [A]: 'd', [B]: 'e' }; + const C = 'userc@mail.example.org'; + const jmapIds: Record = { [A]: 'd', [B]: 'e', [C]: 'f' }; const session = (version: string, jmapId: string) => ({ apiUrl: 'https://mail.example.org/jmap/', downloadUrl: '', @@ -38,17 +39,32 @@ const { client } = vi.hoisted(() => { client: { A, B, + C, 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, + // Bumped by every load and restore, as JMAPClient's session epoch: a + // load that a later one overtook installs nothing. + epoch: 0, + // A client without that guard: an overtaken load's session lands anyway. + ignoreEpoch: false, // loadAccount() has the new session once this settles. loadGate: Promise.resolve() as Promise, + // Per account, where two loads overlap. + gates: {} as Record>, loadError: null as Error | null, + loadErrors: {} as Record, emailSets: [] as Array<{ served: string; args: Record }>, }, + SessionSupersededError: class SessionSupersededError extends Error { + constructor() { + super('Another account was opened before this one finished loading'); + this.name = 'SessionSupersededError'; + } + }, }; }); @@ -65,14 +81,20 @@ vi.mock('../../api/jmap-client', () => ({ 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) => { + const epoch = ++client.epoch; client.served = null; - await client.loadGate; - if (client.loadError) throw client.loadError; + await (client.gates[accountId] ?? client.loadGate); + if (epoch !== client.epoch && !client.ignoreEpoch) throw new SessionSupersededError(); + const error = client.loadErrors[accountId] ?? client.loadError; + if (error) throw error; client.served = accountId; return true; }), snapshot: vi.fn(() => ({ served: client.served })), - restoreSnapshot: vi.fn((snap: { served: string | null }) => { client.served = snap.served; }), + restoreSnapshot: vi.fn((snap: { served: string | null }) => { + client.epoch += 1; + client.served = snap.served; + }), consumeLegacyCredentials: vi.fn(async () => null), clearAccountCredentials: vi.fn(async () => undefined), reset: vi.fn(), @@ -103,6 +125,7 @@ vi.mock('../../api/jmap-client', () => ({ }, AuthenticationError: class AuthenticationError extends Error {}, NetworkError: class NetworkError extends Error {}, + SessionSupersededError, })); vi.mock('../../lib/push-notifications', () => ({ @@ -119,7 +142,7 @@ import { useAuthStore } from '../auth-store'; import { useEmailStore } from '../email-store'; import { applyOrQueue, useOutboxStore } from '../outbox-store'; -const { A, B } = client; +const { A, B, C } = client; const fingerprint = (version: string, account: string) => sessionFingerprint(client.session(version, client.jmapIds[account]) as unknown as JMAPSession); const queued = (emailId: string) => ({ @@ -143,15 +166,23 @@ beforeEach(async () => { await AsyncStorage.clear(); client.version = '1.0'; client.loadGate = Promise.resolve(); + client.gates = {}; client.loadError = null; + client.loadErrors = {}; + client.ignoreEpoch = false; 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 }); + useAccountStore.setState({ + accounts: [entry(A, 'usera'), entry(B, 'userb'), entry(C, 'userc')], + activeAccountId: A, + defaultAccountId: A, + }); await writeServerFingerprint(A, fingerprint('1.0', A)); await writeServerFingerprint(B, fingerprint('1.0', B)); + await writeServerFingerprint(C, fingerprint('1.0', C)); // A is signed in and live; B has an edit queued from before. await AsyncStorage.setItem(`webmail:outbox:v1:${B}`, JSON.stringify([queued('m1')])); @@ -241,3 +272,132 @@ describe('switching accounts with queued edits', () => { expect(JSON.parse((await AsyncStorage.getItem(`webmail:outbox:v1:${B}`))!)).toHaveLength(1); }); }); + +// The account rows have no busy state and a notification tap switches too: +// B can be tapped, then C, before B's session is up. +describe('switches that overlap (Stalwart 1.0)', () => { + const where = () => ({ + email: useEmailStore.getState().activeAccountId, + outbox: useOutboxStore.getState().activeAccountId, + resumed: useOutboxStore.getState().resumed, + auth: useAuthStore.getState().activeAccountId, + registry: useAccountStore.getState().activeAccountId, + client: client.served, + }); + const on = (account: string) => ({ + email: account, outbox: account, resumed: true, auth: account, registry: account, client: account, + }); + // An edit the user makes in the list on screen. + const editOnScreen = () => applyOrQueue({ kind: 'keywords', emailId: 'x1', patch: { $seen: true } }); + const sent = () => client.emailSets.map(({ served, args }) => [served, args.accountId, Object.keys((args.update ?? {}) as object)]); + + it('lands on the account tapped last when the switch before it ends later', async () => { + const toB = deferred(); + const toC = deferred(); + client.gates = { [B]: toB.promise, [C]: toC.promise }; + + const switchingToB = useAuthStore.getState().switchAccount(B); + await settle(); + const switchingToC = useAuthStore.getState().switchAccount(C); + await settle(); + toC.open(); + await switchingToC; + await settle(); + toB.open(); + await switchingToB; + await settle(); + + expect(where()).toEqual(on(C)); + expect((await editOnScreen()).queued).toBe(false); + // Neither B's queue nor the edit went to B. + expect(sent()).toEqual([[C, 'f', ['x1']]]); + expect(JSON.parse((await AsyncStorage.getItem(`webmail:outbox:v1:${B}`))!)).toHaveLength(1); + }); + + it('keeps an edit on screen from the account an overtaken switch left in the client', async () => { + // As above, with a client that installs the overtaken load's session. + client.ignoreEpoch = true; + const toB = deferred(); + const toC = deferred(); + client.gates = { [B]: toB.promise, [C]: toC.promise }; + + const switchingToB = useAuthStore.getState().switchAccount(B); + await settle(); + const switchingToC = useAuthStore.getState().switchAccount(C); + await settle(); + toC.open(); + await switchingToC; + toB.open(); + await switchingToB; + await settle(); + + expect(client.served).toBe(B); + expect(useEmailStore.getState().activeAccountId).toBe(C); + expect((await editOnScreen()).queued).toBe(true); + expect(client.emailSets).toEqual([]); + }); + + it('does not put back the account left when an overtaken switch fails', async () => { + client.ignoreEpoch = true; + client.loadErrors = { [B]: new Error('Server unreachable') }; + const toB = deferred(); + const toC = deferred(); + client.gates = { [B]: toB.promise, [C]: toC.promise }; + + const switchingToB = useAuthStore.getState().switchAccount(B); + await settle(); + const switchingToC = useAuthStore.getState().switchAccount(C); + await settle(); + toC.open(); + await switchingToC; + toB.open(); + await switchingToB; + await settle(); + + expect(where()).toEqual(on(C)); + expect(useAuthStore.getState().error).toBeNull(); + }); + + it('puts back the account the first switch left when the last one fails', async () => { + client.loadErrors = { [C]: new Error('Server unreachable') }; + const toB = deferred(); + const toC = deferred(); + client.gates = { [B]: toB.promise, [C]: toC.promise }; + + const switchingToB = useAuthStore.getState().switchAccount(B); + await settle(); + const switchingToC = useAuthStore.getState().switchAccount(C); + await settle(); + toB.open(); + await switchingToB; + toC.open(); + await switchingToC; + await settle(); + + // Not the account-less client C's switch began with. + expect(where()).toEqual(on(A)); + expect(useAuthStore.getState().error).toBe('Server unreachable'); + expect((await editOnScreen()).queued).toBe(false); + expect(sent()).toEqual([[A, 'd', ['x1']]]); + }); + + it('goes straight back when the account left is tapped while a switch is under way', async () => { + const toB = deferred(); + client.gates = { [B]: toB.promise }; + + const switchingToB = useAuthStore.getState().switchAccount(B); + await settle(); + await useAuthStore.getState().switchAccount(A); + expect(useAuthStore.getState().isLoading).toBe(false); + await settle(); + + expect(where()).toEqual(on(A)); + toB.open(); + await switchingToB; + await settle(); + + expect(where()).toEqual(on(A)); + expect((await editOnScreen()).queued).toBe(false); + expect(sent()).toEqual([[A, 'd', ['x1']]]); + }); +}); diff --git a/src/stores/__tests__/outbox-store.test.ts b/src/stores/__tests__/outbox-store.test.ts index 3c041913..2c88d927 100644 --- a/src/stores/__tests__/outbox-store.test.ts +++ b/src/stores/__tests__/outbox-store.test.ts @@ -9,8 +9,15 @@ vi.mock('@react-native-community/netinfo', () => ({ }, })); +// The account (username@host) the JMAP client serves; null while it serves +// none, as while it loads another one. +const client = vi.hoisted(() => ({ served: null as string | null })); vi.mock('../../api/jmap-client', () => ({ - jmapClient: { isConnected: true }, + jmapClient: { + get isConnected() { return client.served !== null; }, + get username() { return client.served ? client.served.split('@')[0] : null; }, + get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; }, + }, })); const patchKeywordsForEmails = vi.fn(async (..._a: any[]) => undefined); @@ -26,10 +33,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useOutboxStore, applyOrQueue } from '../outbox-store'; import { useNetworkStore } from '../network-store'; -const ACCOUNT = 'acc-1'; +const ACCOUNT = 'user1@mail.example.org'; beforeEach(async () => { vi.clearAllMocks(); + client.served = ACCOUNT; useNetworkStore.setState({ online: true, connected: true }); // Detach then attach a clean bucket (AsyncStorage is the in-memory mock). await useOutboxStore.getState().setAccount(null); @@ -245,10 +253,13 @@ describe('failed ops and archive replay', () => { }); describe('keyword ops queued by earlier builds', () => { + const LEGACY = 'legacy@mail.example.org'; + beforeEach(async () => { patchKeywordsForEmails.mockResolvedValue(undefined); - await AsyncStorage.removeItem('webmail:outbox:v1:acc-legacy'); - await AsyncStorage.removeItem('webmail:outbox:v1:acc-legacy:failed'); + await AsyncStorage.removeItem(`webmail:outbox:v1:${LEGACY}`); + await AsyncStorage.removeItem(`webmail:outbox:v1:${LEGACY}:failed`); + client.served = LEGACY; }); it('replays a whole keyword map as a patch that sets its keywords and clears nothing else', async () => { @@ -259,13 +270,13 @@ describe('keyword ops queued by earlier builds', () => { { id: 'b', createdAt: 2, attempts: 0, op: { kind: 'keywords', emailId: 'e2', accountId: 'grp-1', keywords: { $junk: true } } }, { id: 'c', createdAt: 3, attempts: 0, op: { kind: 'mailboxes', emailId: 'e3', mailboxIds: { trash: true } } }, ]; - await AsyncStorage.setItem('webmail:outbox:v1:acc-legacy', JSON.stringify(legacy)); - await useOutboxStore.getState().setAccount('acc-legacy'); + await AsyncStorage.setItem(`webmail:outbox:v1:${LEGACY}`, JSON.stringify(legacy)); + await useOutboxStore.getState().setAccount(LEGACY); expect(useOutboxStore.getState().entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', accountId: undefined, patch: { $seen: true, '$label:work': true }, }); - await useOutboxStore.getState().resume('acc-legacy'); + await useOutboxStore.getState().resume(LEGACY); expect(patchKeywordsForEmails).toHaveBeenNthCalledWith(1, ['e1'], { $seen: true, '$label:work': true }, undefined); // `$junk` and `$notjunk` exclude each other, so setting one clears the other. @@ -278,8 +289,9 @@ describe('keyword ops queued by earlier builds', () => { const legacy = [ { 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().resume('acc-legacy'); + await AsyncStorage.setItem(`webmail:outbox:v1:${LEGACY}:failed`, JSON.stringify(legacy)); + await useOutboxStore.getState().setAccount(LEGACY); + await useOutboxStore.getState().resume(LEGACY); await useOutboxStore.getState().retryFailed(); @@ -289,7 +301,7 @@ describe('keyword ops queued by earlier builds', () => { }); describe('waiting for the account session (resume)', () => { - const OTHER = 'acc-2'; + const OTHER = 'user2@mail.example.org'; const stored = (emailId: string) => ({ id: `q-${emailId}`, createdAt: 1, attempts: 0, op: { kind: 'keywords', emailId, patch: { $seen: true } }, }); @@ -308,6 +320,7 @@ describe('waiting for the account session (resume)', () => { await useOutboxStore.getState().flush(); expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + client.served = OTHER; await useOutboxStore.getState().resume(OTHER); expect(patchKeywordsForEmails).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined); @@ -316,7 +329,10 @@ describe('waiting for the account session (resume)', () => { it('replays nothing when another account was attached while the queue loaded', async () => { await AsyncStorage.setItem(`webmail:outbox:v1:${OTHER}`, JSON.stringify([stored('e1')])); + client.served = OTHER; + // The mail list shows OTHER, then ACCOUNT before OTHER's queue was read. + void useOutboxStore.getState().setAccount(OTHER); const resuming = useOutboxStore.getState().resume(OTHER); void useOutboxStore.getState().setAccount(ACCOUNT); await resuming; @@ -326,6 +342,17 @@ describe('waiting for the account session (resume)', () => { expect(useOutboxStore.getState().resumed).toBe(false); }); + it('does not take the queue over for an account the mail list does not show', async () => { + // A switch or sign-in that ends after the user moved on to another account. + await AsyncStorage.setItem(`webmail:outbox:v1:${OTHER}`, JSON.stringify([stored('e1')])); + client.served = OTHER; + + await useOutboxStore.getState().resume(OTHER); + + expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + expect(useOutboxStore.getState().activeAccountId).toBe(ACCOUNT); + }); + 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. @@ -337,6 +364,7 @@ describe('waiting for the account session (resume)', () => { expect(useOutboxStore.getState().entries).toEqual([]); expect(await AsyncStorage.getItem(`webmail:outbox:v1:${OTHER}`)).toBeNull(); + client.served = OTHER; await useOutboxStore.getState().resume(OTHER); expect(patchKeywordsForEmails).not.toHaveBeenCalled(); }); @@ -353,3 +381,57 @@ describe('waiting for the account session (resume)', () => { expect(persisted.map((e) => e.op.emailId)).toEqual(['e1', 'e2']); }); }); + +describe('while the JMAP client serves another account', () => { + // Signing in to an account being added moves the client first; the queue + // follows once the new account is set up. + const ADDED = 'user3@mail.example.org'; + + beforeEach(() => { + patchKeywordsForEmails.mockResolvedValue(undefined); + useNetworkStore.setState({ online: false }); + }); + + it('neither replays the queue nor runs an edit through it', async () => { + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } }); + useNetworkStore.setState({ online: true }); + client.served = ADDED; + + await useOutboxStore.getState().flush(); + const result = await applyOrQueue({ kind: 'keywords', emailId: 'e2', patch: { $seen: true } }); + + expect(result.queued).toBe(true); + expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + client.served = ACCOUNT; + await useOutboxStore.getState().flush(); + expect(patchKeywordsForEmails.mock.calls.map(([ids]) => ids)).toEqual([['e1'], ['e2']]); + }); + + it('stops a replay under way once the client moved on', async () => { + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } }); + useOutboxStore.getState().enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } }); + useNetworkStore.setState({ online: true }); + // The sign-in's session comes up while e1 is on its way. + patchKeywordsForEmails.mockImplementationOnce(async () => { client.served = ADDED; }); + + await useOutboxStore.getState().flush(); + + expect(patchKeywordsForEmails).toHaveBeenCalledTimes(1); + expect(setEmailMailboxes).not.toHaveBeenCalled(); + expect(useOutboxStore.getState().entries.map((e) => e.op.emailId)).toEqual(['e2']); + }); + + it('holds the queue on suspend and names the account to resume', async () => { + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } }); + useNetworkStore.setState({ online: true }); + + expect(useOutboxStore.getState().suspend()).toBe(ACCOUNT); + await useOutboxStore.getState().flush(); + expect(patchKeywordsForEmails).not.toHaveBeenCalled(); + // Held already: nothing to resume. + expect(useOutboxStore.getState().suspend()).toBeNull(); + + await useOutboxStore.getState().resume(ACCOUNT); + expect(patchKeywordsForEmails).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 70beb5f9..67aa146b 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -1,5 +1,11 @@ import { create } from 'zustand'; -import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client'; +import { + jmapClient, + AuthenticationError, + NetworkError, + SessionSupersededError, + type ClientSnapshot, +} from '../api/jmap-client'; import type { JMAPSession } from '../api/types'; import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security'; import { useAccountStore } from './account-store'; @@ -154,6 +160,120 @@ function resumeOutbox(accountId: string): void { void useOutboxStore.getState().resume(accountId); } +// Account changes overlap: the account rows have no busy state and a +// notification tap switches too, so B can be tapped and then C before B's +// session is up. Each change (a switch, a sign-in, a sign-out) takes a +// generation, and once its awaits return, one that a newer change superseded +// touches neither the client nor the stores; the client already refused its +// session (SessionSupersededError). An older switch that finished last used +// to put its account in the client under the newer one's mail list, and one +// that failed last put back the account it had left. +let accountChange = 0; + +function beginAccountChange(): () => boolean { + const generation = ++accountChange; + return () => generation === accountChange; +} + +interface SwitchOrigin { + accountId: string | null; + /** Its session was up, and with it its outbox. */ + live: boolean; + client: ClientSnapshot; +} + +// Set while switches are under way. They share the account the first one +// left, which a failed later one puts back: the client serves no account +// while a switch loads, so there is nothing else to snapshot. `settled` +// resolves when the switch that set it ends. +let pendingSwitch: { origin: SwitchOrigin; settled: Promise } | null = null; + +// Sign-ins under way. A session retry, which runs by itself, never takes the +// client from one, nor from a switch. +let signInsUnderWay = 0; + +async function switchesSettled(): Promise { + while (pendingSwitch) await pendingSwitch.settled; +} + +interface SignedIn { + session: JMAPSession; + serverUrl: string; + username: string; + /** The local account id (username@host). */ + accountId: string; +} + +// Shared by the password and OAuth sign-ins: `connect` puts the new +// account's session in the client. Adding an account must not destroy the +// live one: a snapshot of its connection is kept, and a failed sign-in puts +// it straight back. Its outbox is held meanwhile: the queue replays through +// whatever account the client serves, which is the new one as soon as its +// session is up, before the stores move to it once its server was checked. +async function signIn( + set: (partial: Partial) => void, + get: () => AuthState, + opts: { addAccount?: boolean } | undefined, + connect: () => Promise, +): Promise { + signInsUnderWay += 1; + try { + // A switch under way lands first, so there is one account to put back. + await switchesSettled(); + const isCurrent = beginAccountChange(); + const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null; + const resumable = previous ? useOutboxStore.getState().suspend() : null; + const putBack = () => { + // Not over a newer account change, which has the client now. + if (!previous || !isCurrent()) return; + jmapClient.restoreSnapshot(previous); + if (resumable) resumeOutbox(resumable); + }; + + let signedIn: SignedIn; + const accountStore = useAccountStore.getState(); + try { + signedIn = await connect(); + if (!isCurrent()) throw new SessionSupersededError(); + // Contacts/calendar are still single-bucket, so wipe those now that + // the new account is the one the client serves. + if (previous) { + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); + } + await checkServerChange(signedIn.accountId, signedIn.session); + if (!isCurrent()) throw new SessionSupersededError(); + accountStore.addAccount({ + serverUrl: signedIn.serverUrl, + username: signedIn.username, + displayName: signedIn.username, + email: signedIn.username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + }); + } catch (err) { + putBack(); + throw err; + } + const { session, serverUrl, username, accountId } = signedIn; + accountStore.setActiveAccount(accountId); + // Swap the email store's active view to the new account so the rest of + // this function (and refetchFeatureStores) writes to the right bucket. + useEmailStore.getState().setActiveAccount(accountId); + + applyConnectedState(set, session, serverUrl, 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(); + void syncAccountDisplayName(accountId); + return signedIn; + } finally { + signInsUnderWay -= 1; + } +} + function refetchFeatureStores(accountId: string): void { resumeOutbox(accountId); // Fire-and-forget: each store handles its own errors. @@ -231,44 +351,10 @@ async function completeOAuthHandoff( result: Extract, opts?: { addAccount?: boolean }, ): Promise { - // Adding an account must not destroy the live one: the singleton keeps the - // previous connection until the new sign-in has actually succeeded, and a - // failure puts it straight back. - const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null; - - let connected: { session: JMAPSession; username: string; accountId: string }; - try { - connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens); - } catch (err) { - if (previous) jmapClient.restoreSnapshot(previous); - throw err; - } - const { session, username, accountId } = connected; - if (previous) { - useContactsStore.getState().reset(); - useCalendarStore.getState().reset(); - } - await checkServerChange(accountId, session); - - const accountStore = useAccountStore.getState(); - accountStore.addAccount({ - serverUrl: result.serverUrl.replace(/\/+$/, ''), - username, - displayName: username, - email: username, - lastLoginAt: Date.now(), - isConnected: true, - hasError: false, + await signIn(set, get, opts, async () => { + const { session, username, accountId } = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens); + return { session, serverUrl: result.serverUrl.replace(/\/+$/, ''), username, accountId }; }); - accountStore.setActiveAccount(accountId); - 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(); - void syncAccountDisplayName(accountId); } function applyConnectedState( @@ -307,48 +393,15 @@ export const useAuthStore = create((set, get) => ({ login: async (serverUrl, username, password, opts) => { set({ isLoading: true, error: null }); - // Adding an additional account: keep the live connection until the new - // sign-in succeeded so a typo doesn't kill the current session. - const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null; try { - let session: JMAPSession; - try { - session = await jmapClient.connect(serverUrl, username, password, opts?.totp); - } catch (err) { - if (previous) jmapClient.restoreSnapshot(previous); - throw err; - } - // Contacts/calendar are still single-bucket, so wipe those now that - // the new account is the one the client serves. - if (previous) { - useContactsStore.getState().reset(); - useCalendarStore.getState().reset(); - } - const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, '')); - await checkServerChange(accountId, session); - - const accountStore = useAccountStore.getState(); - accountStore.addAccount({ - serverUrl: serverUrl.replace(/\/+$/, ''), - username, - displayName: username, - email: username, - lastLoginAt: Date.now(), - isConnected: true, - hasError: false, + // Adding an additional account keeps the live connection until the + // new sign-in succeeded, so a typo doesn't kill the current session. + await signIn(set, get, opts, async () => { + const session = await jmapClient.connect(serverUrl, username, password, opts?.totp); + const base = serverUrl.replace(/\/+$/, ''); + return { session, serverUrl: base, username, accountId: generateAccountId(username, base) }; }); - accountStore.setActiveAccount(accountId); - // Swap the email store's active view to the new account so the rest of - // this function (and refetchFeatureStores) writes to the right bucket. - useEmailStore.getState().setActiveAccount(accountId); - - 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(); - void syncAccountDisplayName(accountId); } catch (err) { if (err instanceof Error && err.name === 'TotpRequiredError') { // Keep what the user (or the webmail hand-off) supplied so the code @@ -486,6 +539,10 @@ export const useAuthStore = create((set, get) => ({ }, logout: async () => { + // A switch under way must not land after the sign-out, nor put back the + // account being signed out when it fails. + beginAccountChange(); + pendingSwitch = null; const accountStore = useAccountStore.getState(); const currentId = get().activeAccountId; @@ -537,6 +594,8 @@ export const useAuthStore = create((set, get) => ({ }, logoutAll: async () => { + beginAccountChange(); + pendingSwitch = null; const accountStore = useAccountStore.getState(); const ids = accountStore.accounts.map((a) => a.id); await teardownPushNotifications().catch(() => undefined); @@ -562,95 +621,121 @@ export const useAuthStore = create((set, get) => ({ }, switchAccount: async (accountId) => { - if (get().activeAccountId === accountId) return; + const underWay = pendingSwitch; + if (get().activeAccountId === accountId && !underWay) return; const accountStore = useAccountStore.getState(); const target = accountStore.getAccountById(accountId); if (!target) return; - set({ isLoading: true, error: null }); - - // Swap the email-store view to the new account *before* the network - // round-trip. The previous account's data is tucked into its snapshot; - // the new account's data (if previously cached) is restored to the - // top-level fields so the EmailListScreen immediately shows the new - // account's last-known mail instead of flashing empty. The network - // refresh below applies incremental updates on top. - useEmailStore.getState().setActiveAccount(accountId); - - // Contacts and calendar stores aren't yet per-account, so they still - // need a reset to avoid showing the previous account's data. - useContactsStore.getState().reset(); - useCalendarStore.getState().reset(); - - // Load the new account's session. loadAccount overwrites - // credentials/session/_accountId itself, so we don't need to reset - // 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; + // Once this one's awaits return, a newer account change may own the + // client and the stores (see beginAccountChange). + const isCurrent = beginAccountChange(); // 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. - const previousClient = jmapClient.snapshot(); - 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); + // leaving the previous account dead until relaunch. With switches under + // way, that is the account the first of them left. + const origin: SwitchOrigin = underWay?.origin ?? { + accountId: get().activeAccountId, + live: get().session !== null, + client: jmapClient.snapshot(), }; + let settle!: () => void; + const mine = { origin, settled: new Promise((resolve) => { settle = resolve; }) }; + pendingSwitch = mine; + try { - const ok = await jmapClient.loadAccount(accountId); - if (!ok) { - // Credentials missing - evict stale entry and surface error - accountStore.removeAccount(accountId); - useEmailStore.getState().removeAccount(accountId); - restorePrevious(); - set({ isLoading: false, error: 'Session expired for this account' }); + set({ isLoading: true, error: null }); + + // Swap the email-store view to the new account *before* the network + // round-trip. The previous account's data is tucked into its snapshot; + // the new account's data (if previously cached) is restored to the + // top-level fields so the EmailListScreen immediately shows the new + // account's last-known mail instead of flashing empty. The network + // refresh below applies incremental updates on top. + useEmailStore.getState().setActiveAccount(accountId); + + // Contacts and calendar stores aren't yet per-account, so they still + // need a reset to avoid showing the previous account's data. + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); + + if (accountId === origin.accountId) { + // Back to the account the switches under way left: its session is + // still in the snapshot taken then, and its server was checked. + jmapClient.restoreSnapshot(origin.client); + if (origin.live) refetchFeatureStores(accountId); + set({ isLoading: false }); return; } - } catch (err) { - if (err instanceof AuthenticationError) { - await jmapClient.clearAccountCredentials(accountId).catch(() => undefined); - accountStore.removeAccount(accountId); - useEmailStore.getState().removeAccount(accountId); + + // If the load fails, restore the previous active account so we don't + // leave the user stranded on a half-switched state. + const restorePrevious = () => { + jmapClient.restoreSnapshot(origin.client); + if (origin.accountId) useEmailStore.getState().setActiveAccount(origin.accountId); + // Its session and server check still hold; without one, the next + // session resumes the outbox. + if (origin.accountId && origin.live) resumeOutbox(origin.accountId); + }; + try { + const ok = await jmapClient.loadAccount(accountId); + if (!isCurrent()) return; + if (!ok) { + // Credentials missing - evict stale entry and surface error + accountStore.removeAccount(accountId); + useEmailStore.getState().removeAccount(accountId); + restorePrevious(); + set({ isLoading: false, error: 'Session expired for this account' }); + return; + } + } catch (err) { + if (!isCurrent() || err instanceof SessionSupersededError) return; + if (err instanceof AuthenticationError) { + await jmapClient.clearAccountCredentials(accountId).catch(() => undefined); + if (!isCurrent()) return; + accountStore.removeAccount(accountId); + useEmailStore.getState().removeAccount(accountId); + restorePrevious(); + set({ isLoading: false, error: 'Session expired for this account' }); + return; + } + // NetworkError or anything else - keep the previous active account + // intact instead of stranding the user on a half-switched state. restorePrevious(); - set({ isLoading: false, error: 'Session expired for this account' }); + accountStore.updateAccount(accountId, { + hasError: true, + errorMessage: err instanceof Error ? err.message : 'Failed to switch account', + }); + set({ + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to switch account', + }); return; } - // NetworkError or anything else - keep the previous active account - // intact instead of stranding the user on a half-switched state. - restorePrevious(); + + const session = jmapClient.currentSession; + if (!session) { + set({ isLoading: false, error: 'Failed to load session' }); + return; + } + await checkServerChange(accountId, session); + if (!isCurrent()) return; + + accountStore.setActiveAccount(accountId); accountStore.updateAccount(accountId, { - hasError: true, - errorMessage: err instanceof Error ? err.message : 'Failed to switch account', + isConnected: true, + hasError: false, + errorMessage: undefined, + lastLoginAt: Date.now(), }); - set({ - isLoading: false, - error: err instanceof Error ? err.message : 'Failed to switch account', - }); - return; + applyConnectedState(set, session, target.serverUrl, target.username, accountId); + refetchFeatureStores(accountId); + void syncAccountDisplayName(accountId); + } finally { + if (pendingSwitch === mine) pendingSwitch = null; + settle(); } - - accountStore.setActiveAccount(accountId); - accountStore.updateAccount(accountId, { - isConnected: true, - hasError: false, - errorMessage: undefined, - lastLoginAt: Date.now(), - }); - - const session = jmapClient.currentSession; - if (!session) { - set({ isLoading: false, error: 'Failed to load session' }); - return; - } - await checkServerChange(accountId, session); - - applyConnectedState(set, session, target.serverUrl, target.username, accountId); - refetchFeatureStores(accountId); - void syncAccountDisplayName(accountId); }, removeAccount: async (accountId) => { @@ -791,12 +876,18 @@ export const useAuthStore = create((set, get) => ({ const { activeAccountId, session } = get(); if (!activeAccountId) return false; if (session) return true; + // A switch or sign-in under way decides which account comes up; loading + // this one would take the client from it. + if (pendingSwitch || signInsUnderWay > 0) return false; + // And one that begins meanwhile has the client once this load returns. + const generation = accountChange; + const isCurrent = () => generation === accountChange; const accountStore = useAccountStore.getState(); const target = accountStore.getAccountById(activeAccountId); if (!target) return false; try { const ok = await jmapClient.loadAccount(activeAccountId); - if (!ok) return false; + if (!ok || !isCurrent()) return false; accountStore.updateAccount(activeAccountId, { isConnected: true, hasError: false, @@ -804,10 +895,12 @@ export const useAuthStore = create((set, get) => ({ }); const fresh = jmapClient.currentSession!; await checkServerChange(activeAccountId, fresh); + if (!isCurrent()) return false; applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId); refetchFeatureStores(activeAccountId); return true; } catch (err) { + if (!isCurrent() || err instanceof SessionSupersededError) return false; if (err instanceof AuthenticationError) { // Now we know the credentials are bad — fall back to logout flow. await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined); diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index d8c1521e..2f03d6de 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -23,6 +23,7 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { generateUUID } from '../lib/uuid'; +import { generateAccountId } from '../lib/account-utils'; import { isTransientNetworkError, isAuthError } from '../lib/network-error'; import { useNetworkStore } from './network-store'; import { jmapClient } from '../api/jmap-client'; @@ -92,18 +93,26 @@ interface OutboxState { /** * 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. + * checked (lib/server-change), and again while another account is being + * signed in (suspend). 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. + * The account's session is up and its server was checked: load its queue + * if needed, then replay it. Only for the account attached, which is the + * one the mail list shows (the email store attaches it). */ resume: (accountId: string) => Promise; + /** + * Hold the queue while the JMAP client signs in to another account (one + * being added). Returns the account it was resumed for, to resume should + * that sign-in fail. + */ + suspend: () => string | null; hydrate: () => Promise; enqueue: (op: OutboxOp) => void; count: () => number; @@ -225,6 +234,18 @@ function scheduleRetry(): void { }, delay); } +// Whether the JMAP client serves the account (username@host) whose queue it +// is. The client moves to an account being added or switched to before the +// auth store moves the queue, and it serves no account while it loads one. +// An op replayed through another account's session names the message by an +// id that account may use for another one: own-mail ops take the client's +// primary account. +function clientServes(accountId: string | null): boolean { + if (!accountId || !jmapClient.isConnected) return false; + const { username, serverUrl } = jmapClient; + return !!username && !!serverUrl && generateAccountId(username, serverUrl) === accountId; +} + // Revert the optimistic edit the dropped op stood for: re-read the list. async function refreshAfterDrop(): Promise { try { @@ -289,6 +310,9 @@ export const useOutboxStore = create((set, get) => ({ }, resume: async (accountId) => { + // An older sign-in or switch that ends after a newer one began must not + // take the queue over for the account it was after. + if (get().activeAccountId !== accountId) return; await get().setAccount(accountId); // Another account may have been attached while this one loaded. if (get().activeAccountId !== accountId) return; @@ -296,6 +320,13 @@ export const useOutboxStore = create((set, get) => ({ await get().flush(); }, + suspend: () => { + const { activeAccountId, resumed } = get(); + clearRetry(); + set({ resumed: false }); + return resumed ? activeAccountId : null; + }, + hydrate: async () => { const state = get(); if (state.hydrated) return; @@ -357,7 +388,7 @@ export const useOutboxStore = create((set, get) => ({ if (!accountId) return; if (get().entries.length === 0) return; // Need a live client that's actually serving this account, and a network. - if (!jmapClient.isConnected) return; + if (!clientServes(accountId)) return; if (!useNetworkStore.getState().online) return; set({ flushing: true }); @@ -371,8 +402,11 @@ export const useOutboxStore = create((set, get) => ({ for (const snapshot of ordered) { if (get().activeAccountId !== accountId) break; // account switched + if (!get().resumed) break; // another account signing in if (!useNetworkStore.getState().online) break; // went offline - if (!jmapClient.isConnected) break; + // The client can move on while an op is in flight: to an account + // being added, before the queue moves with it. + if (!clientServes(accountId)) break; const entry = get().entries.find((e) => e.id === snapshot.id); if (!entry) continue; // removed/coalesced @@ -524,7 +558,11 @@ export async function applyOrQueueBatch( const store = useOutboxStore.getState(); // 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; + // And only through the account whose messages these are. + const online = useNetworkStore.getState().online + && clientServes(store.activeAccountId) + && !store.paused + && store.resumed; const hasQueued = ops.some((op) => store.entries.some((e) => e.op.emailId === op.emailId), );