Files
webmail/lib/offline/__tests__/cache.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

122 lines
4.8 KiB
TypeScript

import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it } from 'vitest';
import {
cacheUsage,
emailIndex,
emailKey,
enforceCap,
forgetIndexes,
getBlob,
getBody,
getMisc,
patchEmails,
putBlob,
putEmails,
putMisc,
wipeAccountCache,
} from '../cache';
import { forgetDeviceKey, openJson, sealJson } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb, idbGet, idbGetAll } from '../db';
const day = 24 * 60 * 60 * 1000;
const email = (id: string, ageDays: number, extra: Record<string, unknown> = {}) => ({
id,
threadId: `t-${id}`,
mailboxIds: { inbox: true },
keywords: {},
receivedAt: new Date(Date.now() - ageDays * day).toISOString(),
subject: `Subject ${id}`,
...extra,
});
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
});
describe('offline encryption', () => {
it('keeps nothing readable and a key that cannot be exported', async () => {
const sealed = await sealJson({ subject: 'Quarterly numbers' });
expect(new TextDecoder().decode(sealed.data)).not.toContain('Quarterly');
expect(await openJson(sealed)).toEqual({ subject: 'Quarterly numbers' });
const stored = await idbGet<{ key: CryptoKey }>('keys', 'aes');
expect(stored?.key.extractable).toBe(false);
await expect(crypto.subtle.exportKey('raw', stored!.key)).rejects.toThrow();
});
it('stores messages encrypted, list part and body apart', async () => {
await putEmails('acc', 'j1', [email('m1', 1, { bodyValues: { 1: { value: 'Secret text' } }, textBody: [{ partId: '1' }] })]);
const records = await idbGetAll<{ data: ArrayBuffer }>('emails');
expect(records).toHaveLength(1);
expect(new TextDecoder().decode(records[0].data)).not.toContain('Subject m1');
const bodies = await idbGetAll<{ data: ArrayBuffer }>('bodies');
expect(new TextDecoder().decode(bodies[0].data)).not.toContain('Secret text');
forgetIndexes();
const index = await emailIndex('acc');
const meta = index.get(emailKey('acc', 'j1', 'm1'))!;
expect(meta.subject).toBe('Subject m1');
expect(meta).not.toHaveProperty('bodyValues');
expect((await getBody('acc', 'j1', 'm1'))?.bodyValues).toEqual({ 1: { value: 'Secret text' } });
});
it('merges a later list fetch into the kept message', async () => {
await putEmails('acc', 'j1', [email('m1', 1, { bodyValues: { 1: { value: 'x' } } })]);
await putEmails('acc', 'j1', [{ id: 'm1', keywords: { $seen: true } }]);
const meta = (await emailIndex('acc')).get(emailKey('acc', 'j1', 'm1'))!;
expect(meta.keywords).toEqual({ $seen: true });
expect(meta.subject).toBe('Subject m1');
expect(await getBody('acc', 'j1', 'm1')).not.toBeNull();
});
it('applies keyword and folder patches', async () => {
await putEmails('acc', 'j1', [email('m1', 1)]);
await patchEmails('acc', 'j1', { m1: { 'keywords/$flagged': true, 'mailboxIds/inbox': null, 'mailboxIds/archive': true } });
const meta = (await emailIndex('acc')).get(emailKey('acc', 'j1', 'm1'))!;
expect(meta.keywords).toEqual({ $flagged: true });
expect(meta.mailboxIds).toEqual({ archive: true });
});
});
describe('the cache cap', () => {
it('drops the oldest messages first, with their bodies and attachments', async () => {
await putEmails('acc', 'j1', [
email('old', 20, { bodyValues: { 1: { value: 'a'.repeat(4000) } } }),
email('mid', 10, { bodyValues: { 1: { value: 'b'.repeat(4000) } } }),
email('new', 1, { bodyValues: { 1: { value: 'c'.repeat(4000) } } }),
]);
await putBlob('acc', 'j1', 'blob-old', new Blob(['x'.repeat(4000)]), Date.now() - 20 * day, emailKey('acc', 'j1', 'old'));
const { total } = await cacheUsage();
// Room for about two messages.
const freed = await enforceCap(total - 6000);
expect(freed).toBeGreaterThan(0);
const index = await emailIndex('acc');
expect(index.has(emailKey('acc', 'j1', 'old'))).toBe(false);
expect(index.has(emailKey('acc', 'j1', 'new'))).toBe(true);
expect(await getBody('acc', 'j1', 'old')).toBeNull();
expect(await getBlob('acc', 'j1', 'blob-old')).toBeNull();
expect(await getBody('acc', 'j1', 'new')).not.toBeNull();
expect((await cacheUsage()).total).toBeLessThanOrEqual(total - 6000);
});
it('leaves the cache alone under the cap', async () => {
await putEmails('acc', 'j1', [email('m1', 1)]);
expect(await enforceCap()).toBe(0);
});
});
describe('wiping one account', () => {
it('removes that account only', async () => {
await putEmails('a', 'j1', [email('m1', 1)]);
await putEmails('b', 'j1', [email('m2', 1)]);
await putMisc('a', 'session', { apiUrl: 'x' });
await wipeAccountCache('a');
expect((await emailIndex('a')).size).toBe(0);
expect(await getMisc('a', 'session')).toBeNull();
forgetIndexes();
expect((await emailIndex('b')).size).toBe(1);
});
});