Files
webmail/lib/offline/__tests__/client-fallback.test.ts
T
Linus Rath 4b60fcc1e6 fix: make the offline outbox never send twice, never lose mail silently and never skip a plugin #523
The review of offline mode found ways to send a message twice, to lose
unsent mail without a word, and to send mail without its signing,
encryption or check plugin. This fixes them and the smaller findings:

- each outbox message is taken for sending in one database transaction
  under a Web Lock per account; only a failure before the submission
  request (SendNotStartedError from the client) puts it back, any other
  one marks it "may have gone out"; read-backs after the submission no
  longer fail a send; a send of a closed tab turns uncertain after a minute
- every sign-out the user didn't confirm names the deleted messages on
  the sign-in page, or in a toast when another account stays signed in
- nothing is queued while an enabled plugin isn't running (after a start
  without the network none can); the composer says why
- the outbox waits for a fresh policy and holds messages the rules have
  since made stricter (a required label, a required send check)
- another tab switching offline mode off sticks: only switching it on
  may create the database
- only attachments of kept messages are kept, filed with their message,
  deleted with it and under the cap between syncs; file names and types
  are sealed; folders that went away leave the scope, and a folder that
  couldn't be asked keeps its mail without holding the others back
- a group scope that forbids offline mode waits until the account's
  groups are known; one server down no longer holds another's mail
- an offline start only when the server is really unreachable, and the
  real sign-in follows by itself once it answers
- the service worker files pages and chunks under the build that served
  them, and the shell list of another build isn't kept under this one
- a raw send counts as the composer's only from the plugin running its
  takeover, for that identity
- errors and refusals are in all languages; the banner tells "offline"
  from "server can't be reached"; outbox buttons name their message;
  changes and messages go in the order they were made
2026-11-01 14:19:54 +01:00

126 lines
5.9 KiB
TypeScript

import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { startOfflineMode, wipeEverything } from '../offline-mode';
import { forgetIndexes, putEmails, putMisc, emailIndex } from '../cache';
import { forgetDeviceKey } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
import { useOfflineStore, writeEnabled } from '../state';
import { syncAccount } from '../sync';
const SERVER = 'https://mail.example.org';
const SESSION = {
apiUrl: `${SERVER}/jmap/`,
downloadUrl: `${SERVER}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
uploadUrl: `${SERVER}/jmap/upload/{accountId}/`,
accounts: { j1: { name: 'alice', isPersonal: true, accountCapabilities: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'j1' },
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } },
username: 'alice@example.org',
};
let probeOk = true;
let apiFails = false;
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
localStorage.clear();
probeOk = true;
apiFails = false;
useOfflineStore.setState({ downServers: [], browserOffline: false });
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: SERVER, username: 'alice@example.org' } as never] });
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.mode === 'no-cors') {
if (!probeOk) throw new TypeError('Failed to fetch');
return new Response(null, { status: 200 });
}
if (String(url).endsWith('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 });
if (apiFails) throw new TypeError('Failed to fetch');
return new Response(JSON.stringify({ methodResponses: [['Email/get', { accountId: 'j1', list: [], notFound: ['m1'] }, '0']] }), { status: 200 });
}));
});
afterEach(async () => {
vi.unstubAllGlobals();
await wipeEverything();
});
async function connectedClient(): Promise<JMAPClient> {
const client = new JMAPClient(SERVER, 'alice@example.org', 'pw');
await client.connect();
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
return client;
}
describe('falling back to the device', () => {
it('answers from the device when the server is gone', async () => {
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString(), subject: 'Kept', bodyValues: { 1: { value: 'x' } } }]);
apiFails = true;
probeOk = false;
const email = await client.getEmail('m1');
expect(email?.subject).toBe('Kept');
expect(useOfflineStore.getState().downServers).toEqual(['https://mail.example.org']);
stop();
});
it('keeps a refused request an error while the server answers', async () => {
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString(), subject: 'Kept', bodyValues: { 1: { value: 'x' } } }]);
apiFails = true;
probeOk = true;
// getEmail swallows errors into null: the device's copy isn't passed off as the server's answer.
expect(await client.getEmail('m1')).toBeNull();
expect(useOfflineStore.getState().downServers).toEqual([]);
stop();
});
});
describe('the sync', () => {
function fakeClient(opts: { failQuery?: boolean }) {
return {
getSessionSnapshot: () => SESSION,
getAccountId: () => 'j1',
getAllMailboxes: vi.fn(async () => { await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', role: 'inbox' }] }); return []; }),
getMailboxes: vi.fn(async () => []),
getIdentities: vi.fn(async () => []),
getRecentEmailIds: vi.fn(async (mailboxId: string) => { if (opts.failQuery && mailboxId === 'archive') throw new Error('refused'); return mailboxId === 'inbox' ? ['new'] : []; }),
getSomeEmails: vi.fn(async () => []),
getFullEmails: vi.fn(async (ids: string[]) => {
await putEmails('acc', 'j1', ids.map((id) => ({ id, mailboxIds: { inbox: true }, receivedAt: new Date().toISOString(), bodyValues: {} })));
return [];
}),
fetchBlob: vi.fn(),
};
}
const deps = { stillActive: () => true, recorded: async () => {}, scopeMailboxes: async () => [{ jmap: 'j1', mailboxId: 'inbox', isInbox: true }] };
it('fetches what is missing and drops what left the Inbox', async () => {
await putEmails('acc', 'j1', [{ id: 'gone', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
const client = fakeClient({});
await syncAccount('acc', client as never, deps);
expect(client.getFullEmails).toHaveBeenCalledWith(['new'], 'j1');
const index = await emailIndex('acc');
expect([...index.values()].map((e) => e.id)).toEqual(['new']);
});
it('keeps everything when a folder couldn\'t be asked', async () => {
await putEmails('acc', 'j1', [{ id: 'kept', mailboxIds: { archive: true }, receivedAt: new Date().toISOString() }]);
const both = { ...deps, scopeMailboxes: async () => [{ jmap: 'j1', mailboxId: 'inbox', isInbox: true }, { jmap: 'j1', mailboxId: 'archive', isInbox: false }] };
await syncAccount('acc', fakeClient({ failQuery: true }) as never, both);
expect([...(await emailIndex('acc')).values()].map((e) => e.id).sort()).toEqual(['kept', 'new']);
});
});