Files
webmail/lib/jmap/offline-hooks.ts
T
Linus Rath 5c5d982f9d feat: save drafts and keep attachments while offline
A draft saved offline now stays on the device under a stand-in id and
goes to the server by itself when it's back. Until now every save was
refused and the composer showed "not saved".

- `lib/store/drafts`: one pending create per draft, however often it's
  saved. A draft from the server edited offline is replaced by create,
  then destroy, so a failure leaves a copy. Each create has a fixed
  Message-ID, looked up in Drafts after a lost response. A refused
  draft stays on the device with its error and can be opened. Changed
  on another device: both are kept and the user is told.
- `JMAPClient`: stand-in ids in Email/get and Email/set resolve like
  calendar's, also when answering from the device. A file attached
  offline stays readable, and is uploaded before a save that names it.
- Held files and kept mail files are stored in 1 MB chunks. Orphans are
  dropped at the end of a flush, counting what drafts need.
- `lib/store/mail/attachments.ts`: attachments, inline images and raw
  messages per scope, newest first, after everything else in a pass,
  with eviction that spares pinned mail and never sees held files.
- `mail.rawBlob` and `mail.rawStatus` for an offline export.

Changed test assertions: `outbox.heldFile` and `mail.getBlob` return
`blob` instead of `data`, so a file isn't held whole in memory. The
attachment tests ignore raw downloads, and mail-sync runs with
attachments off, because raw messages add a commit per message.
2026-11-03 08:49:19 +01:00

117 lines
5.5 KiB
TypeScript

/**
* Where the JMAP client hands over to offline mode (#523, lib/offline/).
* The client knows nothing about the local store: 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.
* `networkTried`: the request went out and its connection broke, so the server
* may have taken it even though no answer came.
*/
answer(client: OfflineClientRef, methodCalls: OfflineMethodCall[], options?: { networkTried?: boolean }): 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). `error` is what the request failed with.
*/
serverGone(client: OfflineClientRef, error?: unknown): Promise<boolean>;
/** The server pushed new states, for the local store to catch up. */
stateChange?(client: OfflineClientRef, change: { changed?: Record<string, Record<string, string>> }): void;
/** 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>;
/**
* Search calls answered from the local store while the server is asked too
* (lib/jmap/search-merge.ts). Null when the store doesn't cover the client.
* Left out when there is no store, and searches then go to the server alone.
*/
searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | null>;
/**
* Real ids for stand-in ids the page still holds (lib/jmap/stand-in-ids.ts), or
* null for one that hasn't reached the server yet. Without it the client sends
* ids as they are.
*/
resolveStandIns?(client: OfflineClientRef, ids: string[]): Promise<Record<string, string | null>>;
/**
* Files attached offline, uploaded now that the server answers, for a save
* that names them. Throws SendNotStartedError when the upload hits an outage.
*/
resolveHeldAttachments?<T extends { blobId: string }>(client: OfflineClientRef, attachments: T[], accountId?: string): Promise<T[]>;
/** The user opened this file: the local store keeps recently opened files. */
fileOpened?(client: OfflineClientRef, accountId: string, id: string): void;
}
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 server can't be talked to: offline, DNS, a dropped connection, a request
* that hung until its deadline, a proxy answering for a server that's down.
* Not an answer from the server itself.
*/
export function isNetworkFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === 'OfflineUnavailableError' || error.name === 'RequestTimeoutError' || error.name === 'GatewayError') 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';
}
}