mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
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
230 lines
9.5 KiB
TypeScript
230 lines
9.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.
|
|
*
|
|
* - One tab at a time per account (Web Locks), and each message is taken
|
|
* for sending in one database transaction (claimForSending), so two tabs
|
|
* never send it twice.
|
|
* - Changes and messages go in the order they were made.
|
|
* - 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.
|
|
* - Only a failure before the submission request (SendNotStartedError, a
|
|
* file upload on a dead network) puts a message back to waiting. A send
|
|
* whose connection broke after that is marked uncertain and never sent
|
|
* again by itself: it may have gone out. A refusal by the server is marked
|
|
* failed, with the reason.
|
|
* - A message taken for sending by a tab that was closed on the way is
|
|
* uncertain after a minute.
|
|
* - A message finished under rules the organisation has since made stricter
|
|
* (a label now required, a send check now required) is held for the user.
|
|
*/
|
|
|
|
import type { JMAPClient } from '@/lib/jmap/client';
|
|
import { isNetworkFailure } from '@/lib/jmap/offline-hooks';
|
|
import type { SendEmailResult } from '@/lib/jmap/types';
|
|
import {
|
|
claimForSending,
|
|
heldFile,
|
|
isLocalBlobId,
|
|
readQueue,
|
|
removeOutboxEntry,
|
|
removeQueued,
|
|
updateOutboxEntry,
|
|
type OutboxEntry,
|
|
type OutboxError,
|
|
} from './queue';
|
|
import type { OfflineNotice } from './offline-mode';
|
|
|
|
export interface FlushDeps {
|
|
notify: (notice: OfflineNotice) => void;
|
|
allowed: () => boolean;
|
|
/** Whether the organisation's rules got stricter since this message was finished. */
|
|
stricterNow?: (entry: OutboxEntry) => boolean;
|
|
/** A held send went out (an undo window still open). */
|
|
onScheduled?: (entry: OutboxEntry, result: SendEmailResult) => void;
|
|
}
|
|
|
|
/** A send is taken for at most this long before a closed tab's send counts as uncertain. */
|
|
export const SENDING_STALE_MS = 60_000;
|
|
|
|
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;
|
|
|
|
/** Ours to tell the user, not the server's. */
|
|
class OutboxPrepError extends Error {
|
|
constructor(readonly code: NonNullable<OutboxError['code']>) {
|
|
super(code);
|
|
this.name = 'OutboxPrepError';
|
|
}
|
|
}
|
|
|
|
function notStartedError(message: string): Error {
|
|
return Object.assign(new Error(message), { name: 'SendNotStartedError' });
|
|
}
|
|
|
|
function stillOffline(): boolean {
|
|
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
|
}
|
|
|
|
/** Before anything was handed to the server: a dead network here means nothing went out. */
|
|
function notStarted(error: unknown): boolean {
|
|
if (!(error instanceof Error)) return false;
|
|
return error.name === 'SendNotStartedError' || error.name === 'OfflineUnavailableError';
|
|
}
|
|
|
|
async function uploadHeld(account: string, client: JMAPClient, blobId: string): Promise<string> {
|
|
const file = await heldFile(account, blobId);
|
|
if (!file) throw new OutboxPrepError('file_missing');
|
|
try {
|
|
return (await client.uploadBlob(file)).blobId;
|
|
} catch (error) {
|
|
if (isNetworkFailure(error)) throw notStartedError('upload failed');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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 OutboxPrepError('file_missing');
|
|
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);
|
|
let mailboxes;
|
|
try {
|
|
mailboxes = await client.getMailboxes();
|
|
} catch (error) {
|
|
if (isNetworkFailure(error)) throw notStartedError('folders unavailable');
|
|
throw error;
|
|
}
|
|
const sent = mailboxes.find((m) => m.role === 'sent');
|
|
if (!sent) throw new OutboxPrepError('no_sent_mailbox');
|
|
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);
|
|
}
|
|
|
|
/** Sends what waits for an account, one tab at a time. */
|
|
export async function flushQueue(account: string, client: JMAPClient, deps: FlushDeps): Promise<void> {
|
|
const run = () => flushLocked(account, client, deps);
|
|
const locks = typeof navigator !== 'undefined' ? (navigator as Navigator & { locks?: LockManager }).locks : undefined;
|
|
if (locks?.request) {
|
|
await locks.request(`bulwark-offline-flush:${account}`, run);
|
|
return;
|
|
}
|
|
await run();
|
|
}
|
|
|
|
/** Marks sends of closed tabs uncertain (they may have gone out). */
|
|
export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> {
|
|
const { outbox } = await readQueue(account);
|
|
for (const entry of outbox) {
|
|
if (entry.status === 'sending' && (entry.sendingSince ?? 0) < now - SENDING_STALE_MS) {
|
|
await updateOutboxEntry(entry, { status: 'uncertain' });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps): Promise<void> {
|
|
if (flushing.has(account) || !deps.allowed()) return;
|
|
flushing.add(account);
|
|
try {
|
|
await settleStaleSends(account);
|
|
const { patches, outbox } = await readQueue(account);
|
|
// In the order they were made: a reply's $answered, queued after the
|
|
// reply, goes after it.
|
|
const items = [
|
|
...patches.map((patch) => ({ id: patch.id, patch })),
|
|
...outbox.map((entry) => ({ id: entry.id, entry })),
|
|
].sort((x, y) => x.id - y.id);
|
|
|
|
for (const item of items) {
|
|
if ('patch' in item && item.patch) {
|
|
if (stillOffline()) return;
|
|
try {
|
|
await client.applyEmailPatches(item.patch.item.jmap, item.patch.item.update);
|
|
} catch (error) {
|
|
// Still no server: try again later. Anything else: the server's state wins.
|
|
if (isNetworkFailure(error)) return;
|
|
}
|
|
await removeQueued([item.patch.id]);
|
|
continue;
|
|
}
|
|
const entry = (item as { entry: OutboxEntry }).entry;
|
|
if (entry.status !== 'waiting') continue;
|
|
if (stillOffline() || !deps.allowed()) return;
|
|
if (deps.stricterNow?.(entry)) {
|
|
await updateOutboxEntry(entry, { status: 'held' });
|
|
deps.notify({ kind: 'held', subject: entry.summary.subject });
|
|
continue;
|
|
}
|
|
// Another tab took it, or it was deleted meanwhile.
|
|
if (!(await claimForSending(entry.id))) continue;
|
|
let result: SendEmailResult;
|
|
try {
|
|
result = await sendEntry(account, client, entry);
|
|
} catch (error) {
|
|
if (notStarted(error)) {
|
|
// Nothing left: it waits for the next try.
|
|
await updateOutboxEntry(entry, { status: 'waiting' });
|
|
return;
|
|
}
|
|
if (error instanceof Error && error.name === 'OutboxPrepError') {
|
|
const code = (error as OutboxPrepError).code;
|
|
await updateOutboxEntry(entry, { status: 'failed', error: { code } });
|
|
deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { code } });
|
|
continue;
|
|
}
|
|
if (isNetworkFailure(error) || (error instanceof Error && (error.name === 'RequestTimeoutError' || error.name === 'SendUnconfirmedError'))) {
|
|
// It may have reached the server: never sent again by itself.
|
|
await updateOutboxEntry(entry, { status: 'uncertain' });
|
|
return;
|
|
}
|
|
const text = error instanceof Error ? error.message : String(error);
|
|
await updateOutboxEntry(entry, { status: 'failed', error: { text } });
|
|
deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { text } });
|
|
continue;
|
|
}
|
|
await removeOutboxEntry(entry);
|
|
void client.pruneEmailSubmissions();
|
|
if (result.scheduled) deps.onScheduled?.(entry, result);
|
|
deps.notify({ kind: 'sent', subject: entry.summary.subject });
|
|
}
|
|
} finally {
|
|
flushing.delete(account);
|
|
}
|
|
}
|
|
|
|
/** "Send again" for a failed or uncertain message (a held one waits for its rules). */
|
|
export async function retryOutboxEntry(id: number): Promise<void> {
|
|
const { outbox } = await readQueue();
|
|
const entry = outbox.find((e) => e.id === id);
|
|
if (entry && (entry.status === 'failed' || entry.status === 'uncertain')) await updateOutboxEntry(entry, { status: 'waiting' });
|
|
}
|
|
|
|
/** "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);
|
|
}
|