fix: don't wipe a store just because this window can't open it, and don't send a stand-in when a file's upload is held again

This commit is contained in:
Linus Rath
2026-11-03 09:45:03 +01:00
parent 9dd6e61d35
commit 7dc4c775cd
3 changed files with 30 additions and 7 deletions
+18 -2
View File
@@ -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 });
+8 -3
View File
@@ -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();
+4 -2
View File
@@ -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();
});