/** * 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>; } /** 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 { 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 { 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('queue', account) : await idbGetAll('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(record) }); } else { const payload = await openJson(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>): Promise { 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 { 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 { 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 { const record = await idbGet('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 { 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 { await idbDeleteAccount(account, ['queue', 'uploads']); }