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

251 lines
9.4 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.
*
* The state of an outbox entry (waiting, sending, ...) is kept unencrypted
* next to it, so that taking an entry for sending is one database
* transaction: two tabs can't both take it (claimForSending).
*/
import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, 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[];
}
/**
* - waiting: goes out by itself when the server answers;
* - sending: on its way (a tab took it);
* - failed: the server refused it; the error says why;
* - uncertain: the connection broke after it may have reached the server;
* never sent again by itself;
* - held: the organisation's rules got stricter while it waited.
*/
export type OutboxStatus = 'waiting' | 'sending' | 'failed' | 'uncertain' | 'held';
/** Why an entry failed: one of ours (a code the UI translates) or the server's own words. */
export interface OutboxError {
code?: 'file_missing' | 'no_sent_mailbox' | 'no_drafts_mailbox';
text?: string;
}
/** What the organisation required when the message was finished (see offline-mode.ts policyFingerprint). */
export interface PolicyFingerprint {
labelRequired: boolean;
hasLabel: boolean;
guards: string[];
}
export interface OutboxEntry {
id: number;
account: string;
createdAt: number;
summary: OutboxSummary;
status: OutboxStatus;
/** When a tab took it for sending (ms). */
sendingSince?: number;
error?: OutboxError;
item: SendItem;
fingerprint?: PolicyFingerprint;
}
interface QueueRecord extends Sealed {
id?: number;
account: string;
kind: 'patch' | 'send';
createdAt: number;
status?: OutboxStatus;
sendingSince?: number;
}
interface SendPayload {
summary: OutboxSummary;
error?: OutboxError;
item: SendItem;
fingerprint?: PolicyFingerprint;
}
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, fingerprint?: PolicyFingerprint): Promise<number> {
const payload: SendPayload = { summary, item, fingerprint };
return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(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,
status: record.status ?? 'waiting',
sendingSince: record.sendingSince,
...payload,
});
}
} catch {
// Sealed with a key that is gone; removed with the account.
}
}
return { patches, outbox };
}
/**
* Takes a waiting entry for sending, in one transaction: false when it
* isn't waiting any more (another tab took it, the user deleted it).
*/
export async function claimForSending(id: number): Promise<boolean> {
return idbUpdate<QueueRecord>('queue', id, (record) => {
if (!record || record.kind !== 'send' || (record.status ?? 'waiting') !== 'waiting') return undefined;
return { ...record, status: 'sending', sendingSince: Date.now() };
});
}
/** Sets an entry's state; with an error or a new after-send list, its payload too. */
export async function updateOutboxEntry(
entry: OutboxEntry,
patch: { status: OutboxStatus; error?: OutboxError },
): Promise<void> {
const payload: SendPayload = {
summary: entry.summary,
item: entry.item,
fingerprint: entry.fingerprint,
error: patch.error,
};
const sealed = await sealJson(payload);
await idbUpdate<QueueRecord>('queue', entry.id, (record) => {
// Deleted meanwhile: stays deleted.
if (!record) return undefined;
return { ...record, status: patch.status, sendingSince: patch.status === 'sending' ? Date.now() : undefined, ...sealed };
});
}
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);
}
interface UploadRecord extends Sealed {
key: string;
account: string;
createdAt: number;
/** The file's name and type, sealed: names say a lot about a message. */
meta: Sealed;
}
/** 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());
const meta = await sealJson({ name, type: file.type });
await idbWrite([{ store: 'uploads', put: { key: uploadKey(account, blobId), account, createdAt: Date.now(), meta, ...sealed } satisfies UploadRecord }]);
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<UploadRecord>('uploads', uploadKey(account, blobId));
if (!record) return null;
try {
const meta = await openJson<{ name?: string; type?: string }>(record.meta);
return new File([await openBytes(record)], meta.name || 'attachment', { type: meta.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']);
}