mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: never send an account's offline edits to another account
Adding an account put the new account's session in the JMAP client as soon as it was up, while the queue of offline edits (outbox) stayed on the account in use, and ready to replay, until the new account's server was checked. A retry, a network flip or the return from the OAuth browser in that window replayed the queue through the new account, and edits to the user's own mail took its JMAP account id. Stalwart's ids repeat across accounts, so a queued move or delete of one account's message "x" hit the other account's "x". An edit made in that window ran the same way, and so did the rest of a replay already under way. Account switches could overlap too: tapping B, then C before B's session was up, left the mail list on C while B's switch, finishing last, put B in the client and its queue in the outbox, so edits in C's list went to B under C's ids. An older switch that failed last put back the account it had left, under the newer one. - the outbox replays and runs edits only while the client serves the account the queue belongs to, checked before every op, and holds the queue while another account signs in; a failed sign-in gives the client and the queue back to the account in use - the outbox resumes only for the account the mail list shows - every account change (a switch, a sign-in, a sign-out) takes a generation, and one a newer change overtook touches neither the client nor the stores; the client no longer installs the session of a load or sign-in that a newer one overtook - a failed switch puts back the account the first of overlapping switches left, and tapping that account while they are under way goes straight back to it - a session retry gives way to a switch or sign-in under way - tokens refreshed for an account being left stay with that account; they were saved into the credentials of the account loaded meanwhile, which then signed in as the other one
This commit is contained in:
@@ -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<string, string> = {
|
||||
[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();
|
||||
|
||||
@@ -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<void>;
|
||||
beforeEach(() => {
|
||||
const waiting = new Map<string, (session: JMAPSession | null) => void>();
|
||||
const unreachable = new Set<string>();
|
||||
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<string, string>)[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<string>((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', () => {
|
||||
|
||||
+138
-58
@@ -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<JMAPSession> {
|
||||
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<JMAPSession> {
|
||||
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<JMAPSession> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<JMAPSession> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, string> = { [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<unknown>,
|
||||
connectError: null as Error | null,
|
||||
loadGate: Promise.resolve() as Promise<unknown>,
|
||||
requestGate: null as Promise<unknown> | null,
|
||||
emailSets: [] as Array<{ served: string; args: Record<string, unknown> }>,
|
||||
},
|
||||
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, unknown>, 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<void>; open: () => void } {
|
||||
let open!: () => void;
|
||||
const promise = new Promise<void>((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<typeof queued>[]): Promise<void> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = { [A]: 'd', [B]: 'e' };
|
||||
const C = 'userc@mail.example.org';
|
||||
const jmapIds: Record<string, string> = { [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<unknown>,
|
||||
// Per account, where two loads overlap.
|
||||
gates: {} as Record<string, Promise<unknown>>,
|
||||
loadError: null as Error | null,
|
||||
loadErrors: {} as Record<string, Error>,
|
||||
emailSets: [] as Array<{ served: string; args: Record<string, unknown> }>,
|
||||
},
|
||||
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']]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+243
-150
@@ -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<void> } | 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<void> {
|
||||
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<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
opts: { addAccount?: boolean } | undefined,
|
||||
connect: () => Promise<SignedIn>,
|
||||
): Promise<SignedIn> {
|
||||
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<HandoffResult, { flow: 'oauth' }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
// 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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((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<void>((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<AuthState>((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<AuthState>((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);
|
||||
|
||||
@@ -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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
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<void> {
|
||||
try {
|
||||
@@ -289,6 +310,9 @@ export const useOutboxStore = create<OutboxState>((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<OutboxState>((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<OutboxState>((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<OutboxState>((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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user