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

128 lines
5.5 KiB
TypeScript

/**
* Sends what waited on this device (#523), once the server answers again:
* first the changes (read, flagged, tags, moves) in the order they were
* made, then the outbox, oldest first.
*
* - A change the server refuses (the message is gone, moved elsewhere) is
* dropped: the server's state wins.
* - A message is sent as it was finished at Send (queue.ts); files attached
* offline are uploaded first. A hold for undo-send that has passed is
* dropped, so the message goes at once.
* - A send that fails on the server stays in the outbox, marked failed,
* with the server's reason. A send whose connection broke while it was
* on its way is marked uncertain and not tried again on its own: it may
* have gone out, and sending it twice is worse than asking.
* - When the organisation has meanwhile forbidden offline mode, nothing is
* sent: the data was wiped with the policy fetch.
*/
import type { JMAPClient } from '@/lib/jmap/client';
import { isNetworkFailure } from '@/lib/jmap/offline-hooks';
import type { SendEmailResult } from '@/lib/jmap/types';
import { heldFile, isLocalBlobId, readQueue, removeOutboxEntry, removeQueued, updateOutboxEntry, type OutboxEntry } from './queue';
import type { OfflineNotice } from './offline-mode';
interface FlushDeps {
notify: (notice: OfflineNotice) => void;
allowed: () => boolean;
}
const flushing = new Set<string>();
/** A hold shorter than this is dropped: the undo window passed while the message waited. */
const MIN_HOLD_MS = 10_000;
function stillOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
async function uploadHeld(account: string, client: JMAPClient, blobId: string): Promise<string> {
const file = await heldFile(account, blobId);
if (!file) throw new Error('A file attached offline is no longer on this device.');
return (await client.uploadBlob(file)).blobId;
}
async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry): Promise<SendEmailResult> {
const item = entry.item;
if (item.kind === 'raw') {
const file = await heldFile(account, item.blobId);
if (!file) throw new Error('The message is no longer on this device.');
const delayed = item.delayedUntil && Date.parse(item.delayedUntil) - Date.now() > MIN_HOLD_MS ? item.delayedUntil : undefined;
if (item.submitOnly) return client.submitRawEmail(file, item.identityId, delayed, item.envelopeRecipients);
const mailboxes = await client.getMailboxes();
const sent = mailboxes.find((m) => m.role === 'sent');
if (!sent) throw new Error('No sent mailbox found');
const drafts = mailboxes.find((m) => m.role === 'drafts');
return client.sendRawEmail(file, item.identityId, sent.id, drafts?.id, delayed, item.envelopeRecipients);
}
const args = [...item.args];
const attachments = args[10];
if (Array.isArray(attachments)) {
const resolved = [];
for (const attachment of attachments as Array<{ blobId: string }>) {
resolved.push(isLocalBlobId(attachment.blobId) ? { ...attachment, blobId: await uploadHeld(account, client, attachment.blobId) } : attachment);
}
args[10] = resolved;
}
const delayedUntil = args[13] as string | undefined;
if (delayedUntil && Date.parse(delayedUntil) - Date.now() <= MIN_HOLD_MS) args[13] = undefined;
const send = client.sendEmail.bind(client) as (...a: unknown[]) => Promise<SendEmailResult>;
return send(...args);
}
export async function flushQueue(account: string, client: JMAPClient, deps: FlushDeps): Promise<void> {
if (flushing.has(account) || !deps.allowed()) return;
flushing.add(account);
try {
const { patches, outbox } = await readQueue(account);
for (const patch of patches) {
if (stillOffline()) return;
try {
await client.applyEmailPatches(patch.item.jmap, patch.item.update);
} catch (error) {
// Still no server: try again later. Anything else: the server's state wins.
if (isNetworkFailure(error)) return;
}
await removeQueued([patch.id]);
}
for (const entry of outbox) {
if (entry.status !== 'waiting') continue;
if (stillOffline() || !deps.allowed()) return;
await updateOutboxEntry(entry, { status: 'sending' });
try {
await sendEntry(account, client, entry);
await removeOutboxEntry(entry);
void client.pruneEmailSubmissions();
deps.notify({ kind: 'sent', subject: entry.summary.subject });
} catch (error) {
if (isNetworkFailure(error)) {
// Offline again before anything went: it waits. Otherwise it may have gone.
await updateOutboxEntry(entry, stillOffline() ? { status: 'waiting' } : { status: 'uncertain' });
return;
}
const message = error instanceof Error ? error.message : String(error);
await updateOutboxEntry(entry, { status: 'failed', error: message });
deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: message });
}
}
} finally {
flushing.delete(account);
}
}
/** "Try again" for a failed or uncertain message. */
export async function retryOutboxEntry(id: number): Promise<void> {
const { outbox } = await readQueue();
const entry = outbox.find((e) => e.id === id);
if (entry) await updateOutboxEntry(entry, { status: 'waiting', error: undefined });
}
/** "Delete" in the outbox. */
export async function deleteOutboxEntry(id: number): Promise<void> {
const { outbox } = await readQueue();
const entry = outbox.find((e) => e.id === id);
if (entry) await removeOutboxEntry(entry);
}