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

179 lines
7.1 KiB
TypeScript

/**
* What waits on this device for the network (#523): changes made offline
* (read, flagged, tags, moves) and the outbox. Both are kept encrypted in
* the offline database and sent in the order they were made.
*
* An outbox entry is a finished message: everything a send does up to the
* transport ran when the user clicked Send (the organisation's checks,
* the classification label, signatures and disclaimers, plugin send checks,
* S/MIME or PGP with the user present). What is kept is the arguments of the
* final send, with the files that couldn't be uploaded yet, or the finished
* raw message of a plugin's send. Sending it later can't skip any of that.
*/
import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbWrite } from './db';
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
import { generateUUID } from '@/lib/utils';
export interface PatchItem {
kind: 'patch';
jmap: string;
update: Record<string, Record<string, unknown>>;
}
/** The arguments of useEmailStore.sendEmail after the client (positional, as the client takes them). */
export interface EmailSendItem {
kind: 'email';
args: unknown[];
}
export interface RawSendItem {
kind: 'raw';
/** The finished message, kept as an upload until it is sent. */
blobId: string;
identityId: string;
delayedUntil?: string;
envelopeRecipients?: string[];
/** submitRaw: no copy in Sent. */
submitOnly?: boolean;
}
export type SendItem = EmailSendItem | RawSendItem;
export interface OutboxSummary {
subject: string;
to: string[];
}
export type OutboxStatus = 'waiting' | 'sending' | 'failed' | 'uncertain';
export interface OutboxEntry {
id: number;
account: string;
createdAt: number;
summary: OutboxSummary;
status: OutboxStatus;
error?: string;
item: SendItem;
}
interface QueueRecord extends Sealed {
id?: number;
account: string;
kind: 'patch' | 'send';
createdAt: number;
}
interface SendPayload {
summary: OutboxSummary;
status: OutboxStatus;
error?: string;
item: SendItem;
}
export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise<void> {
const item: PatchItem = { kind: 'patch', jmap, update };
await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord);
}
export async function queueSend(account: string, summary: OutboxSummary, item: SendItem): Promise<number> {
const payload: SendPayload = { summary, status: 'waiting', item };
return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), ...(await sealJson(payload)) } satisfies QueueRecord));
}
export interface QueuedPatch { id: number; account: string; item: PatchItem }
/** Everything waiting for one account (or all), oldest first. */
export async function readQueue(account?: string): Promise<{ patches: QueuedPatch[]; outbox: OutboxEntry[] }> {
const records = account ? await idbGetAllByAccount<QueueRecord>('queue', account) : await idbGetAll<QueueRecord>('queue');
records.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
const patches: QueuedPatch[] = [];
const outbox: OutboxEntry[] = [];
for (const record of records) {
try {
if (record.kind === 'patch') {
patches.push({ id: record.id!, account: record.account, item: await openJson<PatchItem>(record) });
} else {
const payload = await openJson<SendPayload>(record);
outbox.push({ id: record.id!, account: record.account, createdAt: record.createdAt, ...payload });
}
} catch {
// Sealed with a key that is gone; removed with the account.
}
}
return { patches, outbox };
}
export async function updateOutboxEntry(entry: OutboxEntry, patch: Partial<Pick<OutboxEntry, 'status' | 'error'>>): Promise<void> {
const payload: SendPayload = { summary: entry.summary, status: patch.status ?? entry.status, error: patch.error, item: entry.item };
await idbWrite([{ store: 'queue', put: { id: entry.id, account: entry.account, kind: 'send', createdAt: entry.createdAt, ...(await sealJson(payload)) } satisfies QueueRecord }]);
}
export async function removeQueued(ids: number[]): Promise<void> {
await idbWrite(ids.map((id) => ({ store: 'queue' as const, del: id })));
}
/** Removes an outbox entry and the files it held. */
export async function removeOutboxEntry(entry: OutboxEntry): Promise<void> {
const blobIds = heldBlobIds(entry.item);
await idbWrite([
{ store: 'queue', del: entry.id },
...blobIds.map((blobId) => ({ store: 'uploads' as const, del: uploadKey(entry.account, blobId) })),
]);
}
// ---- Files attached while offline ----
/** Stand-in blob ids of files kept on this device start with this. */
export const LOCAL_BLOB_PREFIX = 'bulwark-local-';
const uploadKey = (account: string, blobId: string) => `${account}\u0001${blobId}`;
export function isLocalBlobId(blobId: unknown): blobId is string {
return typeof blobId === 'string' && blobId.startsWith(LOCAL_BLOB_PREFIX);
}
/** Keeps a file under a stand-in blob id until its message is sent. */
export async function holdFile(account: string, file: Blob, name: string): Promise<{ blobId: string; size: number; type: string }> {
const blobId = `${LOCAL_BLOB_PREFIX}${generateUUID()}`;
const sealed = await sealBytes(await file.arrayBuffer());
await idbWrite([{ store: 'uploads', put: { key: uploadKey(account, blobId), account, name, type: file.type, size: file.size, createdAt: Date.now(), ...sealed } }]);
return { blobId, size: file.size, type: file.type || 'application/octet-stream' };
}
export async function heldFile(account: string, blobId: string): Promise<File | null> {
const record = await idbGet<Sealed & { name: string; type: string }>('uploads', uploadKey(account, blobId));
if (!record) return null;
try {
return new File([await openBytes(record)], record.name || 'attachment', { type: record.type || 'application/octet-stream' });
} catch {
return null;
}
}
/** The stand-in blob ids an outbox entry needs uploaded first. */
export function heldBlobIds(item: SendItem): string[] {
if (item.kind === 'raw') return isLocalBlobId(item.blobId) ? [item.blobId] : [];
const attachments = item.args[10];
if (!Array.isArray(attachments)) return [];
return attachments.map((a) => (a as { blobId?: unknown }).blobId).filter(isLocalBlobId);
}
/** Files held for no outbox entry (attached offline to a message that was then discarded). */
export async function dropOrphanFiles(account: string, olderThanMs = 24 * 60 * 60 * 1000): Promise<void> {
const { outbox } = await readQueue(account);
const needed = new Set(outbox.flatMap((entry) => heldBlobIds(entry.item)));
const held = await idbGetAllByAccount<{ key: string; createdAt?: number }>('uploads', account);
const cutoff = Date.now() - olderThanMs;
const stale = held.filter((record) => {
const blobId = record.key.split('\u0001')[1];
return !needed.has(blobId) && (record.createdAt ?? 0) < cutoff;
});
await idbWrite(stale.map((record) => ({ store: 'uploads' as const, del: record.key })));
}
/** The queue and held files of one account. */
export async function wipeAccountQueue(account: string): Promise<void> {
await idbDeleteAccount(account, ['queue', 'uploads']);
}