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