Files
webmail/lib/store/engine/account-engine.ts
T
Linus Rath b635986506 chore: merge drafts and kept attachments into the settings branch
One attachment scope: the kept-attachments code, which takes chunks with a
file and spares pinned mail. A draft only on this device stays through a
tightened window. Mail's budget still gives way to what Files keeps.
2026-11-03 12:04:47 +01:00

385 lines
16 KiB
TypeScript

/**
* One local account in the store: its database, its sync runs, its answers.
* It acts as a JMAP client toward the real server and as a JMAP server toward
* the UI. Nothing here touches the DOM, so tests run it on node:sqlite.
*/
import { DEFAULT_SCOPE, MethodError, STORAGE_ERROR_AREA, type AreaCall, type MethodHandler, type RecordOverlay, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type StoreUsage, type SyncContext, type SyncStateRow, type Syncer } from '../area';
import { isStorageFailure, migrate, type StorageAdapter } from '../storage';
import { answerRequest, buildMethodTable } from './responder';
import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport';
import { CORE_MIGRATIONS, metaGet, metaSet } from './core-schema';
export interface AccountEngineOptions {
account: string;
db: StorageAdapter;
session: StoreSession;
transport: JmapTransport;
areas: readonly StoreArea[];
/** Left out when the page can't tell yet (the policy hasn't arrived): the last one stays. */
scope?: StoreScope;
invertedKeywordSort?: boolean;
emit?: (event: StoreEvent) => void;
now?: () => number;
/** Waits before a retry after the server said it's busy. Tests pass a stub. */
sleep?: (ms: number) => Promise<void>;
}
const RATE_RETRIES = 5;
const RATE_BACKOFF_MS = 2_000;
/** After a failed write: the first wait, and the longest. */
const STORAGE_BACKOFF_MS = 60_000;
const STORAGE_BACKOFF_MAX_MS = 60 * 60_000;
/** The window moves on without anything changing on the server. */
const SCOPE_TIDY_MS = 24 * 60 * 60_000;
const SCOPE_KEY = 'scope';
const SCOPE_APPLIED_KEY = 'scope_applied';
/** Set while one is being applied: part of it may be done, so that's what the next one starts from. */
const SCOPE_APPLYING_KEY = 'scope_applying';
const SCOPE_TIDIED_KEY = 'scope_tidied_at';
export class AccountEngine {
readonly account: string;
private readonly db: StorageAdapter;
private session: StoreSession;
private scope: StoreScope;
private readonly transport: JmapTransport;
private readonly syncers: readonly Syncer[];
private readonly areas: readonly StoreArea[];
private readonly methods: ReadonlyMap<string, MethodHandler>;
private readonly calls = new Map<string, AreaCall>();
private readonly overlays: readonly RecordOverlay[];
private readonly emitter: (event: StoreEvent) => void;
private readonly now: () => number;
private readonly sleep: (ms: number) => Promise<void>;
private invertedKeywordSort: boolean;
private closed = false;
private online = true;
private running: Promise<void> | null = null;
private again = false;
/** A write failed (full disk, quota): no sync before this time, however much the server pushes. */
private storageBlockedUntil = 0;
private storageFailures = 0;
/** The scope changed: a pass under way stops, and what's kept is brought in line first. */
private scopeChanged = false;
constructor(options: AccountEngineOptions) {
this.account = options.account;
this.db = options.db;
this.session = options.session;
this.transport = options.transport;
this.areas = options.areas;
const syncers = options.areas.flatMap((area) => area.syncers);
this.syncers = [...syncers.filter((syncer) => !syncer.low), ...syncers.filter((syncer) => syncer.low)];
this.methods = buildMethodTable(options.areas);
this.overlays = options.areas.flatMap((area) => (area.overlay ? [area.overlay] : []));
for (const area of options.areas) {
for (const [name, call] of Object.entries(area.calls ?? {})) {
if (this.calls.has(name)) throw new Error(`Two store areas define ${name}`);
this.calls.set(name, call);
}
}
this.emitter = options.emit ?? (() => {});
this.now = options.now ?? Date.now;
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
this.calls.set('store.usage', () => this.usage());
migrate(this.db, [...CORE_MIGRATIONS, ...options.areas.flatMap((area) => area.migrations)]);
metaSet(this.db, 'session', JSON.stringify(options.session));
const storedScope = metaGet(this.db, SCOPE_KEY);
this.scope = options.scope ?? (storedScope ? JSON.parse(storedScope) as StoreScope : DEFAULT_SCOPE);
metaSet(this.db, SCOPE_KEY, JSON.stringify(this.scope));
// An offline start has no way to probe the server, so the last answer stays.
this.invertedKeywordSort = options.invertedKeywordSort ?? metaGet(this.db, 'inverted_keyword_sort') === '1';
if (options.invertedKeywordSort !== undefined) metaSet(this.db, 'inverted_keyword_sort', options.invertedKeywordSort ? '1' : '0');
this.runScopeHooks();
}
private runScopeHooks(): void {
const ctx = this.storeContext();
for (const area of this.areas) area.scopeChanged?.(ctx);
}
/** The session kept from the last time, for a start without a network. */
static storedSession(db: StorageAdapter): StoreSession | null {
migrate(db, CORE_MIGRATIONS);
const raw = metaGet(db, 'session');
return raw ? JSON.parse(raw) as StoreSession : null;
}
setSession(session: StoreSession): void {
this.session = session;
metaSet(this.db, 'session', JSON.stringify(session));
}
/** True when this is a change. The caller asks for a sync, which applies it, also offline. */
setScope(scope: StoreScope): boolean {
const json = JSON.stringify(scope);
if (json === JSON.stringify(this.scope)) return false;
this.scope = scope;
this.scopeChanged = true;
// The user may just have made room by keeping less.
this.storageBlockedUntil = 0;
metaSet(this.db, SCOPE_KEY, json);
this.runScopeHooks();
return true;
}
/** False when something more urgent than a sync came up: the disk is full. */
private async applyScope(): Promise<boolean> {
const wanted = JSON.stringify(this.scope);
const applied = metaGet(this.db, SCOPE_APPLYING_KEY) ?? metaGet(this.db, SCOPE_APPLIED_KEY);
const tidied = Number(metaGet(this.db, SCOPE_TIDIED_KEY) ?? 0);
this.scopeChanged = false;
if (applied === wanted && metaGet(this.db, SCOPE_APPLYING_KEY) === null && this.now() - tidied < SCOPE_TIDY_MS) return true;
const ctx: SyncContext = { ...this.syncContext(), stopped: () => this.closed || this.scopeChanged };
const change = { previous: applied ? JSON.parse(applied) as StoreScope : null, next: this.scope };
metaSet(this.db, SCOPE_APPLYING_KEY, wanted);
try {
for (const area of this.areas) await area.applyScope?.(ctx, change);
} catch (error) {
if (isStorageFailure(error)) {
this.noteStorageFailure(error);
return false;
}
// What's kept stays as it is, and this runs again at the next pass.
this.emitter({ type: 'error', account: this.account, area: 'scope', message: error instanceof Error ? error.message : String(error) });
return true;
}
if (ctx.stopped()) return !this.closed;
this.db.transaction(() => {
metaSet(this.db, SCOPE_APPLIED_KEY, wanted);
metaSet(this.db, SCOPE_APPLYING_KEY, null);
metaSet(this.db, SCOPE_TIDIED_KEY, String(this.now()));
});
return true;
}
private keptByOthers(name: string): number {
return this.areas.reduce((sum, area) => sum + (area.name === name ? 0 : area.keptBytes?.({ db: this.db }) ?? 0), 0);
}
/** Free pages stay in the file for the next writes, so they don't count as used. */
private usage(): StoreUsage {
const pages = Number(this.db.get<{ page_count: number }>('PRAGMA page_count')?.page_count ?? 0);
const free = Number(this.db.get<{ freelist_count: number }>('PRAGMA freelist_count')?.freelist_count ?? 0);
const size = Number(this.db.get<{ page_size: number }>('PRAGMA page_size')?.page_size ?? 0);
const bytes = (pages - free) * size;
const parts = this.areas.flatMap((area) => area.usage?.({ db: this.db }) ?? []);
const counted = parts.reduce((sum, part) => sum + part.bytes, 0);
return {
bytes,
parts: parts.map((part) => (part.part === 'mail' ? { ...part, bytes: Math.max(0, bytes - counted) } : part)),
};
}
setInvertedKeywordSort(inverted: boolean): void {
this.invertedKeywordSort = inverted;
metaSet(this.db, 'inverted_keyword_sort', inverted ? '1' : '0');
}
private overlay<T extends { id: string }>(jmap: string, type: string, records: T[]): T[] {
let out: Array<{ id: string }> = records;
for (const overlay of this.overlays) out = overlay({ db: this.db, now: this.now }, jmap, type, out);
return out as T[];
}
setOnline(online: boolean): void {
this.online = online;
if (online) this.requestSync();
}
private syncState(jmap: string, type: string): SyncStateRow {
const row = this.db.get<{ state: string | null; target: string | null; phase: string | null; cursor: string | null }>(
'SELECT state, target, phase, cursor FROM sync_state WHERE jmap = ? AND type = ?',
[jmap, type],
);
return {
state: row?.state ?? null,
target: row?.target ?? null,
phase: row?.phase ?? null,
cursor: row?.cursor ? JSON.parse(row.cursor) as Record<string, unknown> : null,
};
}
private setSyncState(jmap: string, type: string, patch: Partial<SyncStateRow>): void {
const row = { ...this.syncState(jmap, type), ...patch };
this.db.run(
`INSERT INTO sync_state (jmap, type, state, target, phase, cursor, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (jmap, type) DO UPDATE SET state = excluded.state, target = excluded.target, phase = excluded.phase,
cursor = excluded.cursor, updated_at = excluded.updated_at`,
[jmap, type, row.state, row.target, row.phase, row.cursor ? JSON.stringify(row.cursor) : null, this.now()],
);
}
/** What the server pushed: jmap account id to { type: state }. */
noteStateChange(changed: Record<string, Record<string, string>>): void {
this.db.transaction(() => {
for (const [jmap, types] of Object.entries(changed)) {
for (const [type, state] of Object.entries(types)) this.setSyncState(jmap, type, { target: state });
}
});
this.requestSync();
}
/** Starts a run, or asks the running one to go again when it's done. */
requestSync(): Promise<void> {
if (this.closed || this.now() < this.storageBlockedUntil) return Promise.resolve();
if (this.running) {
this.again = true;
return this.running;
}
this.running = this.runLoop().finally(() => {
this.running = null;
});
return this.running;
}
private async runLoop(): Promise<void> {
do {
this.again = false;
if (await this.applyScope()) await this.runOnce();
// A changed scope is applied without the network too.
} while (!this.closed && this.now() >= this.storageBlockedUntil && (this.scopeChanged || (this.again && this.online)));
}
private async runOnce(): Promise<void> {
if (!this.online) return;
const ctx = this.syncContext();
const jmapAccounts = Object.keys(this.session.accounts);
for (const syncer of this.syncers) {
for (const jmap of jmapAccounts) {
const capabilities = this.session.accounts[jmap].accountCapabilities ?? this.session.capabilities;
if (!(syncer.capability in capabilities)) continue;
if (this.closed || !this.online || this.scopeChanged) return;
if (!(await this.runSyncer(ctx, syncer, jmap))) return;
}
}
if (this.scopeChanged) return;
this.storageFailures = 0;
if (!this.closed && this.online) this.emitter({ type: 'synced', account: this.account, at: this.now() });
}
/** False when the run should end: no network, no credentials. */
private async runSyncer(ctx: SyncContext, syncer: Syncer, jmap: string): Promise<boolean> {
for (let attempt = 0; ; attempt += 1) {
try {
await syncer.run(ctx, jmap);
return true;
} catch (error) {
if (error instanceof TransportError) {
if (error.kind === 'network' || error.kind === 'auth') return false;
if (error.kind === 'rate' && attempt < RATE_RETRIES) {
await this.sleep(error.retryAfterMs ?? RATE_BACKOFF_MS * 2 ** attempt);
continue;
}
}
// The others would fail on the same disk: the run ends, and sync waits.
if (isStorageFailure(error)) {
this.noteStorageFailure(error);
return false;
}
// One part failing doesn't hold up the others. It runs again next time.
this.emitter({
type: 'error',
account: this.account,
area: syncer.name,
message: error instanceof Error ? error.message : String(error),
});
return true;
}
}
}
private noteStorageFailure(error: unknown): void {
this.storageFailures += 1;
const wait = Math.min(STORAGE_BACKOFF_MS * 2 ** (this.storageFailures - 1), STORAGE_BACKOFF_MAX_MS);
this.storageBlockedUntil = this.now() + wait;
this.emitter({
type: 'error',
account: this.account,
area: STORAGE_ERROR_AREA,
message: error instanceof Error ? error.message : String(error),
});
}
private syncContext(): SyncContext {
const scope = () => this.scope;
return {
account: this.account,
db: this.db,
session: this.session,
// A pass can take long, and a tighter policy applies to the rest of it.
get scope() {
return scope();
},
transport: this.transport,
now: this.now,
emit: this.emitter,
stopped: () => this.closed || !this.online || this.scopeChanged,
requestSync: () => this.requestSync(),
syncWanted: () => this.again,
pause: this.sleep,
syncState: (jmap, type) => this.syncState(jmap, type),
setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row),
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
keptByOthers: (area) => this.keptByOthers(area),
sparedRecords: (jmap, type) => this.areas.flatMap((area) => area.spares?.({ db: this.db }, jmap, type) ?? []),
};
}
private storeContext(): StoreContext {
return {
account: this.account,
db: this.db,
session: this.session,
scope: this.scope,
now: this.now,
emit: this.emitter,
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
keptByOthers: (area) => this.keptByOthers(area),
sparedRecords: (jmap, type) => this.areas.flatMap((area) => area.spares?.({ db: this.db }, jmap, type) ?? []),
requestSync: () => this.requestSync(),
};
}
/** A non-JMAP call into one of the areas. */
async areaCall(name: string, params: unknown): Promise<unknown> {
const call = this.calls.get(name);
if (!call) throw new Error(`Unknown store call ${name}`);
try {
return await call(this.syncContext(), params as never);
} catch (error) {
if (isStorageFailure(error)) this.noteStorageFailure(error);
throw error;
}
}
/** An answer from the device. Null when nothing in the request could be answered. */
async answer(calls: MethodCall[]): Promise<JmapResponse | null> {
if (this.closed) throw new MethodError('serverUnavailable', 'The local store is closed');
const primary = this.session.primaryAccounts['urn:ietf:params:jmap:mail'] ?? Object.keys(this.session.accounts)[0];
try {
return await answerRequest(
{
...this.storeContext(),
defaultJmap: primary,
invertedKeywordSort: this.invertedKeywordSort,
},
this.methods,
calls,
);
} catch (error) {
if (isStorageFailure(error)) this.noteStorageFailure(error);
throw error;
}
}
/** Stops syncing and waits for the current batch. The database stays open for the caller to close. */
async close(): Promise<void> {
this.closed = true;
// A large download could otherwise hold up removing the account.
this.transport.abort?.();
await this.running?.catch(() => {});
}
}