fix: send waiting mail when a client connects after the first flush, and come back to stale sends and servers that were down

This commit is contained in:
Linus Rath
2026-11-03 09:18:42 +01:00
parent 9b3bbc6070
commit 340ad27e32
4 changed files with 108 additions and 3 deletions
+3
View File
@@ -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;
+62 -1
View File
@@ -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']);
+37 -2
View File
@@ -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<void> {
// 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<object>();
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<void> {
}
}
let staleCheck: ReturnType<typeof setTimeout> | 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;
+6
View File
@@ -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<OfflineState>()(() => ({
browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false,
downServers: [],
startedOffline: false,
storeLost: [],
outbox: [],
pendingChanges: 0,
conflicts: [],