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
88 lines
3.8 KiB
TypeScript
88 lines
3.8 KiB
TypeScript
/**
|
|
* Where the JMAP client hands over to offline mode (#523, lib/offline/).
|
|
* The client knows nothing about the cache: it asks these hooks whether to
|
|
* skip the network, gives them what the server answered, and asks them for
|
|
* an answer when the network is gone. Nothing is registered while offline
|
|
* mode is off for every account, so the client then behaves as before.
|
|
*
|
|
* Kept on globalThis: Next can bundle the client module more than once, and
|
|
* every copy has to find the same hooks.
|
|
*/
|
|
|
|
export type OfflineMethodCall = [string, Record<string, unknown>, string];
|
|
export interface OfflineResponse {
|
|
// Shaped like the server's answer (any per method, as the client reads it).
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
methodResponses: Array<[string, any, string]>;
|
|
}
|
|
|
|
/** What the hooks need to know about the client asking. */
|
|
export interface OfflineClientRef {
|
|
/** Server URL and login, to find the Bulwark account. */
|
|
getServerUrl(): string;
|
|
getUsername(): string;
|
|
getAccountId(): string;
|
|
}
|
|
|
|
export interface OfflineHooks {
|
|
/** Answer without trying the network: the browser says it is offline (or the client was started offline). */
|
|
skipNetwork(client: OfflineClientRef): boolean;
|
|
/** An answer from the device for a request the network couldn't take, or null. */
|
|
answer(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | null>;
|
|
/** What the server answered, for the cache. */
|
|
record(client: OfflineClientRef, methodCalls: OfflineMethodCall[], response: OfflineResponse): void;
|
|
/** A request reached the server (true) or failed on the network (false). */
|
|
noteNetwork(client: OfflineClientRef, reachable: boolean): void;
|
|
/**
|
|
* After a request failed on the network: whether the server can't be
|
|
* reached at all (then the device answers), or it only refused that
|
|
* request (then it stays an error).
|
|
*/
|
|
serverGone(client: OfflineClientRef): Promise<boolean>;
|
|
/** A blob kept on the device, or null. */
|
|
blob(client: OfflineClientRef, blobId: string, accountId: string): Promise<Blob | null>;
|
|
/** A blob the server sent, for the cache. */
|
|
recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void;
|
|
/** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */
|
|
holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>;
|
|
}
|
|
|
|
const KEY = '__bulwarkOfflineHooks';
|
|
|
|
type HookGlobal = typeof globalThis & { [KEY]?: OfflineHooks | null };
|
|
|
|
export function setOfflineHooks(hooks: OfflineHooks | null): void {
|
|
(globalThis as HookGlobal)[KEY] = hooks;
|
|
}
|
|
|
|
export function getOfflineHooks(): OfflineHooks | null {
|
|
return (globalThis as HookGlobal)[KEY] ?? null;
|
|
}
|
|
|
|
/** The network refused the request (not an HTTP error): offline, DNS, a dropped connection. */
|
|
export function isNetworkFailure(error: unknown): boolean {
|
|
if (!(error instanceof Error)) return false;
|
|
if (error.name === 'OfflineUnavailableError') return true;
|
|
return error.name === 'TypeError' && /fetch|network|load failed|internet/i.test(error.message);
|
|
}
|
|
|
|
/** Thrown for a request the device can't answer while the server can't be reached. */
|
|
export class OfflineUnavailableError extends Error {
|
|
constructor(message = 'Not available offline') {
|
|
super(message);
|
|
this.name = 'OfflineUnavailableError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A send failed before its submission request reached the network: nothing
|
|
* left, so the offline outbox may keep it and try again (#523). Any other
|
|
* failure of a send may have come after the server took the message.
|
|
*/
|
|
export class SendNotStartedError extends Error {
|
|
constructor(readonly cause?: unknown) {
|
|
super(cause instanceof Error ? cause.message : 'The send did not start');
|
|
this.name = 'SendNotStartedError';
|
|
}
|
|
}
|