Files
webmail/lib/store/area.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

135 lines
5.3 KiB
TypeScript

/**
* What one part of the local store (mail, calendar, contacts, files, ...)
* plugs into the engine: its tables, the JMAP methods it answers while the
* server can't be reached, and how it syncs.
*
* Migration id ranges: core 1-99, mail 100-199, outbox 200-299, search
* 300-399, drafts 400-499, calendar 500-599, contacts 600-699, files 700-799,
* settings and reminders 800-899, wipe and lock 900-999.
*/
import type { JmapTransport, MethodCall, StoreSession } from './engine/transport';
import type { Migration, StorageAdapter } from './storage';
export interface StoreScope {
/** How far back mail is kept, in days. Null keeps everything. */
days: number | null;
/** Whether attachments are kept. */
attachments: boolean;
/** The largest attachment kept, and the total for all of them. */
maxAttachmentBytes: number;
maxTotalBytes: number;
}
export const DEFAULT_SCOPE: StoreScope = {
days: 90,
attachments: true,
maxAttachmentBytes: 25 * 1024 * 1024,
maxTotalBytes: 2 * 1024 * 1024 * 1024,
};
/** The `area` of an error event for a write that failed: a full disk, the browser's quota. */
export const STORAGE_ERROR_AREA = 'storage';
export interface SyncStateRow {
state: string | null;
/** The newest state the server told us about, to know when a run is caught up. */
target: string | null;
/** The area's own phase name; null when in normal delta mode. */
phase: string | null;
/** The area's own resume point. */
cursor: Record<string, unknown> | null;
}
export type StoreEvent =
| { type: 'progress'; account: string; area: string; phase: string; done: number; total: number | null }
| { type: 'synced'; account: string; at: number }
| { type: 'changed'; account: string; collections: string[] }
| { type: 'created'; account: string; jmap: string; collection: string; ids: string[] }
| { type: 'error'; account: string; area: string; message: string };
export interface StoreContext {
account: string;
db: StorageAdapter;
session: StoreSession;
scope: StoreScope;
now(): number;
emit(event: StoreEvent): void;
/** Records of one type as this device shows them: see RecordOverlay. */
overlay<T extends { id: string }>(jmap: string, type: string, records: T[]): T[];
}
/**
* What an area changes about records another area stores: the outbox lays
* changes that wait for the server over what the server sent. Runs inside the
* caller's transaction. Returns the records to use; a record left out is one
* this device already treats as gone.
*/
export type RecordOverlay = (ctx: Pick<StoreContext, 'db' | 'now'>, jmap: string, type: string, records: Array<{ id: string }>) => Array<{ id: string }>;
export interface SyncContext extends StoreContext {
transport: JmapTransport;
/** True once the engine is closing or lost the network: stop at the next batch. */
stopped(): boolean;
/** A newer pass was asked for. Work that can wait stops, and the next pass carries on with it. */
syncWanted(): boolean;
/** Waits, to stay polite to the server. */
pause(ms: number): Promise<void>;
syncState(jmap: string, type: string): SyncStateRow;
/** Call inside the transaction that writes the data, so the two can't disagree. */
setSyncState(jmap: string, type: string, row: Partial<SyncStateRow>): void;
}
export interface Syncer {
/** A short name for progress and errors. */
name: string;
/** The JMAP capability an account must advertise for this to run for it. */
capability: string;
/** Runs after every other syncer of the pass, for work that can wait. */
low?: boolean;
/** Runs one pass for one JMAP account. Must be safe to stop between batches and start again. */
run(ctx: SyncContext, jmap: string): Promise<void>;
}
export type MethodResult = Record<string, unknown>;
/** Thrown by a method handler: the client gets a per-call JMAP error. */
export class MethodError extends Error {
constructor(readonly type: string, readonly description?: string) {
super(description ?? type);
this.name = 'MethodError';
}
}
export interface MethodContext extends StoreContext {
/** The account's own default JMAP account for calls that don't say. */
defaultJmap: string;
/** Whether keyword sort comparators arrive inverted from this client. */
invertedKeywordSort: boolean;
/** Earlier responses in this request, for creation-id references. */
createdIds: Map<string, string>;
/** The whole request this call came in. */
calls: readonly MethodCall[];
}
export type MethodHandler = (ctx: MethodContext, args: Record<string, unknown>, call: MethodCall) => MethodResult | Promise<MethodResult>;
/**
* A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached
* from any tab with `store.area(account, 'outbox.list', params)`. Names are
* unique across areas and start with the area's name. It gets the sync context,
* because some calls talk to the server (`calendar.flush`).
*/
export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise<unknown>;
export interface StoreArea {
name: string;
migrations: readonly Migration[];
methods: Readonly<Record<string, MethodHandler>>;
syncers: readonly Syncer[];
calls?: Readonly<Record<string, AreaCall>>;
overlay?: RecordOverlay;
/** At start and when the scope changes: a tighter policy applies at once, not at the next sync. */
scopeChanged?(ctx: StoreContext): void;
}