mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +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.
179 lines
7.1 KiB
TypeScript
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']);
|
|
}
|