mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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.
135 lines
5.3 KiB
TypeScript
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;
|
|
}
|