Files
webmail/lib/store/worker/host.ts
T
Linus Rath 687e7cc012 feat: move offline mode onto the encrypted local store
The IndexedDB cache, its crypto, the responder and the page-side sync
go. lib/offline keeps its structure, policy, wipes, sign-out gates and
notices; answers, the outbox and sync now live in lib/store, in a worker
behind lib/store/client/api.ts.

What users get besides that:
- every folder is kept, not just the Inbox and opened ones
- delete for good and empty trash work offline
- a change the server refuses, or that meets a message changed on
  another device, is shown instead of dropped
- a send whose response got lost is looked up by a Message-ID fixed when
  it was queued, then finished, submitted or sent again
- with the store on, a start paints from the local copy, then connects

An account the app drops by itself no longer wipes its data: only a
sign-out or an explicit removal does. The outbox of the old database
moves over once, in lib/offline/migrate-legacy.ts, and the old database
goes only after the store committed. `isNetworkFailure` now counts
timeouts and proxy 5xx. Sends and outbox requests are never answered
from the device.

Tests moved with the code: cache.test.ts and responder.test.ts are
replaced by lib/store/__tests__/, and review-fixes.test.ts, outbox.test.ts
and client-fallback.test.ts now run the real engine on node:sqlite.
Changed assertions: an uncertain send is looked up rather than left alone
forever, destroy and empty trash are queued rather than refused, and #9
(opened folders) became "a folder the server deleted goes", since all
folders are kept.
2026-11-03 05:54:39 +01:00

190 lines
6.5 KiB
TypeScript

/**
* What runs in the owner tab's worker: one database and engine per account.
* The list of areas is the only thing that decides what the store answers.
*/
import type { StoreArea, StoreEvent } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { FetchTransport, type StoreSession } from '../engine/transport';
import type { StorageAdapter } from '../storage';
import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol';
export interface HostStorage {
open(file: string, keyHex: string): Promise<StorageAdapter>;
remove(file: string): boolean;
}
interface OpenAccount {
engine: AccountEngine;
db: StorageAdapter;
file: string;
auth: string | null;
pendingAuth: Array<(header: string | null) => void>;
session: StoreSession;
}
export interface StoreHostOptions {
areas: readonly StoreArea[];
storage: HostStorage;
emit(event: StoreEvent): void;
needAuth(account: string): void;
}
/** The owner hasn't opened this account yet. The caller may try again shortly. */
function accountClosed(): Error {
const error = new Error('The local store is not open for this account');
error.name = 'StoreAccountClosedError';
return error;
}
export class StoreHost {
private readonly accounts = new Map<string, OpenAccount>();
private online = true;
private readonly synced = new Map<string, number>();
constructor(private readonly options: StoreHostOptions) {}
async openAccount(params: OpenAccountParams): Promise<void> {
const existing = this.accounts.get(params.account);
if (existing) {
if (params.session) this.setSession(params.account, params.session);
this.setAuth(params.account, params.auth);
existing.engine.setScope(params.scope);
if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort);
void existing.engine.requestSync();
return;
}
const db = await this.options.storage.open(params.file, params.keyHex);
const session = params.session ?? AccountEngine.storedSession(db);
if (!session) {
db.close();
throw new Error('No session for this account yet');
}
const account: OpenAccount = { db, file: params.file, auth: params.auth, pendingAuth: [], session, engine: undefined as unknown as AccountEngine };
const transport = new FetchTransport({
session: () => account.session,
auth: () => account.auth,
refreshAuth: () => new Promise((resolve) => {
account.pendingAuth.push(resolve);
this.options.needAuth(params.account);
}),
});
account.engine = new AccountEngine({
account: params.account,
db,
session,
transport,
areas: this.options.areas,
scope: params.scope,
invertedKeywordSort: params.invertedKeywordSort ?? undefined,
emit: (event) => {
if (event.type === 'synced') this.synced.set(event.account, event.at);
this.options.emit(event);
},
});
account.engine.setOnline(this.online);
this.accounts.set(params.account, account);
void account.engine.requestSync();
}
setAuth(account: string, auth: string | null): void {
const open = this.accounts.get(account);
if (!open) return;
open.auth = auth;
for (const resolve of open.pendingAuth.splice(0)) resolve(auth);
}
setSession(account: string, session: StoreSession): void {
const open = this.accounts.get(account);
if (!open) return;
open.session = session;
open.engine.setSession(session);
}
setScope(account: string, scope: Parameters<AccountEngine['setScope']>[0]): void {
const open = this.accounts.get(account);
if (!open) return;
open.engine.setScope(scope);
void open.engine.requestSync();
}
setInvertedKeywordSort(account: string, inverted: boolean): void {
this.accounts.get(account)?.engine.setInvertedKeywordSort(inverted);
}
async closeAccount(account: string): Promise<void> {
const open = this.accounts.get(account);
if (!open) return;
this.accounts.delete(account);
await open.engine.close();
for (const resolve of open.pendingAuth.splice(0)) resolve(null);
open.db.close();
}
async removeAccount(account: string, file: string): Promise<boolean> {
await this.closeAccount(account);
this.synced.delete(account);
return this.options.storage.remove(file);
}
async closeAll(): Promise<void> {
await Promise.all([...this.accounts.keys()].map((account) => this.closeAccount(account)));
}
/** The calls other tabs can make. */
async call(method: keyof StoreRpc, params: unknown): Promise<unknown> {
switch (method) {
case 'answer': {
const { account, calls } = params as Parameters<StoreRpc['answer']>[0];
const open = this.accounts.get(account);
if (!open) throw accountClosed();
return open.engine.answer(calls);
}
case 'sync': {
const { account } = params as Parameters<StoreRpc['sync']>[0];
await this.accounts.get(account)?.engine.requestSync();
return undefined;
}
case 'noteStateChange': {
const { account, changed } = params as Parameters<StoreRpc['noteStateChange']>[0];
this.accounts.get(account)?.engine.noteStateChange(changed);
return undefined;
}
case 'setOnline': {
const { online } = params as Parameters<StoreRpc['setOnline']>[0];
this.online = online;
for (const open of this.accounts.values()) open.engine.setOnline(online);
return undefined;
}
case 'status': {
const { account } = params as Parameters<StoreRpc['status']>[0];
return this.status(account);
}
case 'area': {
const { account, name, params: callParams } = params as Parameters<StoreRpc['area']>[0];
const open = this.accounts.get(account);
if (!open) throw accountClosed();
return open.engine.areaCall(name, callParams);
}
case 'removeAccount': {
const { account, file } = params as Parameters<StoreRpc['removeAccount']>[0];
return this.removeAccount(account, file);
}
default:
throw new Error(`Unknown store call ${String(method)}`);
}
}
private status(account: string): StoreStatus | null {
const open = this.accounts.get(account);
if (!open) return null;
return {
account,
open: true,
emails: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n ?? 0),
bodies: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email_body')?.n ?? 0),
lastSyncedAt: this.synced.get(account) ?? null,
};
}
}