diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index 7282d6d62..a5bbc6a0d 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -460,6 +460,19 @@ describe('a send the server couldn\'t take right now', () => { expect(row).toMatchObject({ status: 'waiting', copyId: 'copy-1' }); }); + it('doesn\'t hand an online send a stand-in for a file the upload held again', async () => { + writeEnabled(['acc']); + stop = startOfflineMode(); + const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt'); + const client = fakeClient('https://mail.example.org', { uploadBlob: vi.fn(async () => ({ blobId: `${LOCAL_BLOB_PREFIX}stand-in`, size: 1, type: 'text/plain' })) }); + useAuthStore.setState({ client: client as never, activeAccountId: 'acc', getClientForAccount: () => client } as never); + try { + await expect(getOutboxHooks()!.resolveHeldAttachments(client as never, [{ blobId }])).rejects.toMatchObject({ name: 'SendNotStartedError' }); + } finally { + useAuthStore.setState({ getClientForAccount: useRealClient, client: null }); + } + }); + it('doesn\'t send a stand-in for a file the upload held again', async () => { writeEnabled(['acc']); const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt'); @@ -583,13 +596,16 @@ describe('a browser that can\'t run the store', () => { expect(await strandedLegacyMessages()).toBe(0); }); - it('turns the switch off in a window that refuses the private file system', async () => { - writeEnabled([]); + it('turns the switch off in a window that refuses the private file system, and wipes nothing', async () => { + writeEnabled(['acc']); Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => { throw new DOMException('The operation is insecure.', 'SecurityError'); } } }); try { stop = startOfflineMode(); await vi.waitFor(() => expect(useOfflineStore.getState().storageBlocked).toBe(true)); expect(offlineSupported()).toBe(false); + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); } finally { delete (window.navigator as { storage?: unknown }).storage; useOfflineStore.setState({ storageBlocked: false }); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 82e35b41c..b790335ac 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -39,7 +39,7 @@ import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; import { generateMessageId } from '@/lib/jmap/client'; -import { setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import { SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; import { setOutboxHooks, type OutboxHooks } from './outbox-hooks'; import { @@ -99,7 +99,8 @@ function hasIndexedDb(): boolean { /** Offline mode is possible here at all: not in Lite, with what the store needs. */ export function offlineSupported(): boolean { - if (IS_LITE || !hasIndexedDb() || useOfflineStore.getState().storageBlocked) return false; + if (IS_LITE || !hasIndexedDb()) return false; + if (useOfflineStore.getState().storageBlocked) return false; try { return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported(); } catch { @@ -457,6 +458,8 @@ const outbox: OutboxHooks = { const file = await heldFile(account, attachment.blobId); if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' }); const { blobId } = await real.uploadBlob(file); + // The server went away just now, and offline mode held the file again. + if (isLocalBlobId(blobId)) throw new SendNotStartedError(); out.push({ ...attachment, blobId }); } return out; @@ -814,7 +817,9 @@ export function enforcePolicy(): void { if (!loaded) return; for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; - if (!offlineAllowed(account)) { + // What the organisation says, not whether this window can open the store: + // a blocked store says nothing against the data in it. + if (devicesForAccount(account).noOffline) { // Whoever is here now wasn't signed in to a dropped account. const someoneElses = isEvicted(account); clearHandoffs(); diff --git a/lib/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index f27160d77..d820ab2d5 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -462,8 +462,10 @@ describe('sends', () => { it('doesn\'t call a try that never started an attempt', async () => { const id = await t.engine.areaCall('outbox.queueSend', { summary, item }) as number; - await t.engine.areaCall('outbox.claim', { id }); - await t.engine.areaCall('outbox.update', { id, status: 'waiting' }); + for (let i = 0; i < 2; i += 1) { + await t.engine.areaCall('outbox.claim', { id }); + await t.engine.areaCall('outbox.update', { id, status: 'waiting' }); + } expect((await read()).entries[0].firstAttemptAt).toBeUndefined(); });