Files
webmail/lib/offline/cache.ts
T
Linus Rath db5ac82f53 feat: keep recent mail on this device and send mail written offline #523
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.
2026-11-01 11:46:11 +01:00

282 lines
11 KiB
TypeScript

/**
* The mail kept on this device for offline mode (#523), per Bulwark account
* (`account`, the local account id) and JMAP account (`jmap`).
*
* Messages are kept in two parts: what the list shows (emails) and the text
* and structure of the message (bodies), so a folder list never decrypts
* the bodies. Attachments and inline images are blobs. Every value is
* encrypted (crypto.ts); only the ledger (size, date) and the keys (ids) are
* not.
*
* The cache holds at most CACHE_CAP_BYTES on the device, across accounts;
* the oldest messages go first, with their bodies and attachments. The
* outbox (queue.ts) doesn't count and never goes.
*/
import { idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbWrite, type StoreName } from './db';
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
/** Most bytes the cache keeps on the device (messages, bodies, attachments). */
export const CACHE_CAP_BYTES = 250 * 1024 * 1024;
/** How far back the Inbox (and the folders the user opened) are kept. */
export const CACHE_DAYS = 30;
export const CACHE_WINDOW_MS = CACHE_DAYS * 24 * 60 * 60 * 1000;
/** The parts of a message that only the reader needs. */
const BODY_KEYS = ['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments'] as const;
export type CachedEmail = Record<string, unknown> & {
id: string;
threadId?: string;
mailboxIds?: Record<string, boolean>;
keywords?: Record<string, boolean>;
receivedAt?: string;
};
interface SealedRecord extends Sealed {
key: string;
account: string;
}
interface LedgerEntry {
key: string;
account: string;
store: StoreName;
ref: string;
/** The message's date (ms), for oldest-first eviction. */
at: number;
bytes: number;
/** The message a body or blob belongs to. */
emailKey?: string;
}
const join = (...parts: string[]) => parts.join('\u0001');
export const emailKey = (account: string, jmap: string, id: string) => join(account, jmap, id);
const blobKey = (account: string, jmap: string, blobId: string) => join(account, jmap, 'blob', blobId);
const miscKey = (account: string, name: string) => join(account, 'misc', name);
const ledgerKey = (store: StoreName, key: string) => `${store}:${key}`;
const dateOf = (email: { receivedAt?: unknown }) => {
const at = typeof email.receivedAt === 'string' ? Date.parse(email.receivedAt) : NaN;
return Number.isFinite(at) ? at : 0;
};
const byteSize = (sealed: Sealed) => sealed.data.byteLength + 64;
// Decrypted message metadata per account, loaded on first use.
const indexes = new Map<string, Promise<Map<string, CachedEmail>>>();
/** The decrypted metadata of every cached message of an account, by emailKey. */
export function emailIndex(account: string): Promise<Map<string, CachedEmail>> {
let index = indexes.get(account);
if (!index) {
index = (async () => {
const map = new Map<string, CachedEmail>();
const records = await idbGetAllByAccount<SealedRecord>('emails', account);
for (const record of records) {
try {
map.set(record.key, await openJson<CachedEmail>(record));
} catch {
// Sealed with a key that is gone: dropped at the next write.
}
}
return map;
})();
index.catch(() => indexes.delete(account));
indexes.set(account, index);
}
return index;
}
/** Forget what is held in memory (after a wipe). */
export function forgetIndexes(account?: string): void {
if (account) indexes.delete(account);
else indexes.clear();
}
function splitBody(email: CachedEmail): { meta: CachedEmail; body: Record<string, unknown> | null } {
const meta: CachedEmail = { ...email };
let body: Record<string, unknown> | null = null;
for (const key of BODY_KEYS) {
if (key in meta) {
body ??= {};
body[key] = meta[key];
delete meta[key];
}
}
// Only a fetch with the text counts as the message's body.
return { meta, body: body && 'bodyValues' in body ? body : null };
}
/** Keep messages (as the server sent them, any properties) and their bodies when they came with one. */
export async function putEmails(account: string, jmap: string, emails: CachedEmail[]): Promise<void> {
if (emails.length === 0) return;
const index = await emailIndex(account);
const ops: Array<{ store: StoreName; put?: unknown; del?: IDBValidKey }> = [];
for (const email of emails) {
if (!email || typeof email.id !== 'string') continue;
const key = emailKey(account, jmap, email.id);
const { meta, body } = splitBody(email);
const merged = { ...(index.get(key) ?? {}), ...meta } as CachedEmail;
index.set(key, merged);
const at = dateOf(merged);
const sealed = await sealJson(merged);
ops.push({ store: 'emails', put: { key, account, ...sealed } });
ops.push({ store: 'ledger', put: { key: ledgerKey('emails', key), account, store: 'emails', ref: key, at, bytes: byteSize(sealed), emailKey: key } satisfies LedgerEntry });
if (body) {
const sealedBody = await sealJson(body);
ops.push({ store: 'bodies', put: { key, account, ...sealedBody } });
ops.push({ store: 'ledger', put: { key: ledgerKey('bodies', key), account, store: 'bodies', ref: key, at, bytes: byteSize(sealedBody), emailKey: key } satisfies LedgerEntry });
}
}
await idbWrite(ops);
}
/** Apply a change made to messages (keywords, folders), here or on the server. */
export async function patchEmails(account: string, jmap: string, patches: Record<string, Record<string, unknown>>): Promise<void> {
const index = await emailIndex(account);
const changed: CachedEmail[] = [];
for (const [id, patch] of Object.entries(patches)) {
const current = index.get(emailKey(account, jmap, id));
if (!current) continue;
changed.push(applyPatch(current, patch));
}
if (changed.length) await putEmails(account, jmap, changed);
}
/** A JMAP PatchObject (RFC 8620 §5.3) applied to a message, for the properties offline mode changes. */
export function applyPatch(email: CachedEmail, patch: Record<string, unknown>): CachedEmail {
const next: CachedEmail = { ...email, keywords: { ...(email.keywords ?? {}) }, mailboxIds: { ...(email.mailboxIds ?? {}) } };
for (const [path, value] of Object.entries(patch)) {
const [prop, ...rest] = path.split('/');
if (rest.length === 0) {
(next as Record<string, unknown>)[prop] = value;
continue;
}
if (prop !== 'keywords' && prop !== 'mailboxIds') continue;
const name = rest.join('/').replace(/~1/g, '/').replace(/~0/g, '~');
const map = next[prop] as Record<string, boolean>;
if (value === null || value === false) delete map[name];
else map[name] = true;
}
return next;
}
export async function removeEmails(account: string, keys: string[]): Promise<void> {
if (keys.length === 0) return;
const index = await emailIndex(account);
const ops: Array<{ store: StoreName; del: IDBValidKey }> = [];
for (const key of keys) {
index.delete(key);
ops.push({ store: 'emails', del: key }, { store: 'bodies', del: key });
ops.push({ store: 'ledger', del: ledgerKey('emails', key) }, { store: 'ledger', del: ledgerKey('bodies', key) });
}
await idbWrite(ops);
}
/** A message's body parts, or null when only its list entry is kept. */
export async function getBody(account: string, jmap: string, id: string): Promise<Record<string, unknown> | null> {
const record = await idbGet<SealedRecord>('bodies', emailKey(account, jmap, id));
if (!record) return null;
try {
return await openJson<Record<string, unknown>>(record);
} catch {
return null;
}
}
export async function hasBody(account: string, jmap: string, id: string): Promise<boolean> {
return (await idbGet<SealedRecord>('bodies', emailKey(account, jmap, id))) !== undefined;
}
export async function putBlob(account: string, jmap: string, blobId: string, blob: Blob, at: number, ofEmail?: string): Promise<void> {
const key = blobKey(account, jmap, blobId);
const sealed = await sealBytes(await blob.arrayBuffer());
await idbWrite([
{ store: 'blobs', put: { key, account, type: blob.type, ...sealed } },
{ store: 'ledger', put: { key: ledgerKey('blobs', key), account, store: 'blobs', ref: key, at, bytes: byteSize(sealed), emailKey: ofEmail } satisfies LedgerEntry },
]);
}
export async function getBlob(account: string, jmap: string, blobId: string): Promise<Blob | null> {
const record = await idbGet<SealedRecord & { type?: string }>('blobs', blobKey(account, jmap, blobId));
if (!record) return null;
try {
return new Blob([await openBytes(record)], { type: record.type || 'application/octet-stream' });
} catch {
return null;
}
}
export async function hasBlob(account: string, jmap: string, blobId: string): Promise<boolean> {
return (await idbGet<SealedRecord>('blobs', blobKey(account, jmap, blobId))) !== undefined;
}
/** Small named values of an account (session, folders, identities, opened folders). */
export async function putMisc(account: string, name: string, value: unknown): Promise<void> {
const key = miscKey(account, name);
await idbWrite([{ store: 'misc', put: { key, account, ...(await sealJson(value)) } }]);
}
export async function getMisc<T>(account: string, name: string): Promise<T | null> {
const record = await idbGet<SealedRecord>('misc', miscKey(account, name));
if (!record) return null;
try {
return await openJson<T>(record);
} catch {
return null;
}
}
/** Bytes the cache uses on this device, and per account. */
export async function cacheUsage(): Promise<{ total: number; byAccount: Record<string, number> }> {
const entries = await idbGetAll<LedgerEntry>('ledger');
const byAccount: Record<string, number> = {};
let total = 0;
for (const entry of entries) {
total += entry.bytes;
byAccount[entry.account] = (byAccount[entry.account] ?? 0) + entry.bytes;
}
return { total, byAccount };
}
/**
* Keeps the cache under `cap`: the oldest messages go first, each with its
* body and attachments. Returns the bytes freed.
*/
export async function enforceCap(cap: number = CACHE_CAP_BYTES): Promise<number> {
const entries = await idbGetAll<LedgerEntry>('ledger');
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
if (total <= cap) return 0;
// Group by message: a message and everything that belongs to it go together.
const groups = new Map<string, { at: number; bytes: number; account: string; entries: LedgerEntry[] }>();
for (const entry of entries) {
const id = entry.emailKey ?? entry.key;
const group = groups.get(id) ?? { at: entry.at, bytes: 0, account: entry.account, entries: [] };
group.at = Math.max(group.at, entry.at);
group.bytes += entry.bytes;
group.entries.push(entry);
groups.set(id, group);
}
const oldestFirst = [...groups.values()].sort((a, b) => a.at - b.at);
let freed = 0;
const ops: Array<{ store: StoreName; del: IDBValidKey }> = [];
for (const group of oldestFirst) {
if (total <= cap) break;
for (const entry of group.entries) {
ops.push({ store: entry.store, del: entry.ref }, { store: 'ledger', del: entry.key });
if (entry.store === 'emails') (await indexes.get(entry.account))?.delete(entry.ref);
}
total -= group.bytes;
freed += group.bytes;
}
await idbWrite(ops);
return freed;
}
/** Everything cached for one account (not its outbox). */
export async function wipeAccountCache(account: string): Promise<void> {
forgetIndexes(account);
await idbDeleteAccount(account, ['emails', 'bodies', 'blobs', 'misc', 'ledger']);
}