From 340ad27e32d105b7dd2a4d0ad9fea4bb10ce66d4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:18:42 +0100 Subject: [PATCH] fix: send waiting mail when a client connects after the first flush, and come back to stale sends and servers that were down --- components/offline/offline-host.tsx | 3 ++ lib/offline/__tests__/review-fixes.test.ts | 63 +++++++++++++++++++++- lib/offline/offline-mode.ts | 39 +++++++++++++- lib/offline/state.ts | 6 +++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 3287a4d29..2eec8199b 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -53,6 +53,9 @@ export function OfflineHost() { case 'storage-failed': toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 }); break; + case 'store-reset': + toast.warning(tConflictsRef.current('store_reset'), { duration: 0 }); + break; case 'store-unavailable': toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); break; diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index 93e28b9f2..af25802d3 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -61,6 +61,7 @@ function setOnline(online: boolean) { } let stop: () => void = () => {}; +const useRealClient = useAuthStore.getState().getClientForAccount; let store: InProcessStore; beforeEach(async () => { @@ -365,8 +366,10 @@ describe('what a sign-out would delete', () => { it('answers with the last known count when the store hangs', async () => { useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, pendingChanges: 2 }); - vi.spyOn(store, 'area').mockImplementation(() => new Promise(() => {})); + const hung: Array<(error: Error) => void> = []; + vi.spyOn(store, 'area').mockImplementation(() => new Promise((_resolve, reject) => { hung.push(reject); })); expect(await unsentCount('acc', 10)).toEqual({ messages: 1, changes: 2 }); + for (const reject of hung) reject(new Error('Gone')); }); it('doesn\'t count another signed-out account\'s waiting mail', async () => { @@ -466,6 +469,64 @@ describe('a send the server couldn\'t take right now', () => { }); }); +describe('waiting mail after a normal start', () => { + const realFetchPolicy = usePolicyStore.getState().fetchPolicy; + + beforeEach(() => { + vi.useFakeTimers(); + // rawPolicy: what the policy is worked out from again when the subject changes. + usePolicyStore.setState({ fetchPolicy: vi.fn(async () => {}), source: 'server', rawPolicy: usePolicyStore.getState().policy } as never); + useOfflineStore.setState({ downServers: [] }); + writeEnabled(['acc']); + }); + + afterEach(() => { + vi.useRealTimers(); + usePolicyStore.setState({ fetchPolicy: realFetchPolicy }); + useOfflineStore.setState({ downServers: [] }); + }); + + it('goes when the client becomes real after the flush at the start', async () => { + await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + stop = startOfflineMode(); + // The flush at the start finds no client yet. + await vi.advanceTimersByTimeAsync(5_000); + const client = fakeClient(); + useAuthStore.setState({ getClientForAccount: () => client, client: client as never } as never); + try { + await vi.advanceTimersByTimeAsync(1_500); + expect(client.sendEmail).toHaveBeenCalledTimes(1); + } finally { + useAuthStore.setState({ getClientForAccount: useRealClient, client: null }); + } + }); + + it('comes back to a send a closed tab left behind', async () => { + const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + await claimForSending('acc', id); + stop = startOfflineMode(); + await vi.advanceTimersByTimeAsync(5_000); + expect((await readQueue('acc')).outbox[0].status).toBe('sending'); + await vi.advanceTimersByTimeAsync(SENDING_STALE_MS); + expect((await readQueue('acc')).outbox[0].status).toBe('uncertain'); + }); + + it('asks a server that was down, on an interval, and flushes when it answers', async () => { + const fetcher = vi.fn(async (_url: string) => new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetcher); + try { + stop = startOfflineMode(); + await vi.advanceTimersByTimeAsync(5_000); + setServerDown('https://mail.example.org', true); + await vi.advanceTimersByTimeAsync(30_000); + expect(fetcher.mock.calls.map(([url]) => String(url))).toContain('https://mail.example.org/.well-known/jmap'); + expect(isOfflineNow('https://mail.example.org')).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + describe('looking up a send that may have gone', () => { it('waits until about 30 seconds after the last try', async () => { writeEnabled(['acc']); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 96578ce2c..943aadb31 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -55,7 +55,7 @@ import { } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, offlineScopeFor, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; -import { flushQueue, LOOKUP_WAIT_MS, settleStaleSends } from './flush'; +import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; @@ -69,6 +69,7 @@ export type OfflineNotice = | { kind: 'conflict'; count: number } | { kind: 'storage-failed' } | { kind: 'store-unavailable' } + | { kind: 'store-reset' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -169,6 +170,12 @@ const storeHooks: StorePageHooks = { await client.requestNetworkOnly([['Core/echo', {}, '0']]); return client.getAuthHeader(); }, + onStoreLost(account) { + // Whoever is here wasn't signed in to a dropped account: nothing to tell them. + if (isEvicted(account)) return; + useOfflineStore.setState((s) => ({ storeLost: [...new Set([...s.storeLost, account])] })); + notify({ kind: 'store-reset' }); + }, onEvent(event) { if (event.type === 'synced') { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); @@ -508,6 +515,7 @@ export async function refreshQueueState(): Promise { // What an account's store can't tell right now is still there. const unread = useOfflineStore.getState().outbox.filter((row) => failed.includes(row.account)); useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts }); + if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater(); } catch { // The store is being deleted, or can't start. noteStoreUnavailable(); @@ -935,13 +943,22 @@ export function startOfflineMode(): () => void { void sweepAccountsGone(); // Session, then a sync, for every account signed in with offline mode on. + // A client that just became real is what the waiting mail was waiting for: + // the flush at the start may have run before it was connected. + const flushedFor = new WeakSet(); const syncSignedIn = () => { + let connected = false; for (const account of useOfflineStore.getState().enabled) { const client = onlineClientFor(account); if (!client || !offlineActive(account)) continue; void getStore().ensureOpen(account).then(() => getStore().refresh(account)).catch(() => {}); scheduleSync(account, 3_000); + if (!flushedFor.has(client)) { + flushedFor.add(client); + if (!isOfflineNow(client.getServerUrl())) connected = true; + } } + if (connected) scheduleFlushAll(1_000); }; cleanups.push(useAuthStore.subscribe((state, prev) => { if (state.client !== prev.client || state.connectedAccountsRevision !== prev.connectedAccountsRevision) syncSignedIn(); @@ -951,10 +968,17 @@ export function startOfflineMode(): () => void { const interval = setInterval(() => { for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0); }, SYNC_INTERVAL_MS); - const reconnect = setInterval(() => { void leaveOfflineStart(); }, RECONNECT_MS); + const reconnect = setInterval(() => { + void leaveOfflineStart(); + // Nothing else tells a server that was down is back: a client that thinks + // it's down has no reason to ask it. A passing probe starts the flush. + for (const server of useOfflineStore.getState().downServers) void probeServer(server); + }, RECONNECT_MS); cleanups.push(() => { clearInterval(interval); clearInterval(reconnect); + if (staleCheck) clearTimeout(staleCheck); + staleCheck = null; }); if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true); @@ -989,6 +1013,17 @@ async function sweepAccountsGone(now = Date.now()): Promise { } } +let staleCheck: ReturnType | null = null; + +/** A send taken by a tab that closed on the way: nothing else comes back to it. */ +function checkStaleSendsLater(): void { + if (staleCheck) return; + staleCheck = setTimeout(() => { + staleCheck = null; + void refreshQueueState().then(() => scheduleFlushAll(0)); + }, SENDING_STALE_MS + 1_000); +} + /** How often the store catches up while the tab is open, on top of pushes. */ export const SYNC_INTERVAL_MS = 15 * 60 * 1000; diff --git a/lib/offline/state.ts b/lib/offline/state.ts index edada7809..31971e2db 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -32,6 +32,11 @@ interface OfflineState { downServers: string[]; /** The app started without the server, from the mail on this device. */ startedOffline: boolean; + /** + * Accounts whose store came back empty since this page loaded: the browser + * cleared their key or file. Whatever warns about eviction can go by this. + */ + storeLost: string[]; outbox: OutboxRow[]; pendingChanges: number; /** Changes the server refused, or that met a message changed on another device. */ @@ -57,6 +62,7 @@ export const useOfflineStore = create()(() => ({ browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false, downServers: [], startedOffline: false, + storeLost: [], outbox: [], pendingChanges: 0, conflicts: [],