mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user