mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Settings > Backup & reset gets "Keep mail on this device", off until the user turns it on in this browser. While it is on: - the JMAP client hands the server's answers to an encrypted cache in IndexedDB (AES-GCM, non-extractable key) and answers from it when the network is gone: the folder list, the last 30 days of the Inbox and of the folders the user opened, bodies and attachments, at most 250 MB, oldest out first; a background sync keeps it current - read, flag, tags and moves made offline are queued and sent in order when the server answers again; deleting for good and emptying a folder are refused offline - the composer, quick reply and the S/MIME and PGP plugins' sends that complete the composer's go to an outbox once every step up to the transport ran (checks, label, signature, plugin send checks); only the finished send waits, with files attached offline - the service worker keeps this build's app shell, so the app also starts offline from the kept session and the last policy - a full sign-out, the idle sign-out and signing one account out delete it, a sign-out the user starts asks first when mail is unsent, and the login page names what an automatic sign-out deleted; a policy fetch that forbids it (devices.noOffline) wipes that account Not in Lite.
121 lines
4.8 KiB
TypeScript
121 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 { 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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|