mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
A persisted account id with no subject yet read as decided: Everyone's noOffline wiped mail a group scope allowed, and a wider scope went out at every reload. A public, partial policy counted as the organisation's word, and turned on the desktop default in a forbidding organisation. - an organisation's cap on one file now binds a pin - Clear and the off switch read what would be lost when asked, messages, changes and drafts, and Clear waits for a connection - turning one account off survives the last one going off, and off then on no longer warns of an eviction - a tab that takes over the store sends no scope until it has heard the policy - a dropped account is judged by what applies to anyone - a choice reaches the store once it settles; progress announces phases only - restored Pro tabs ask for their titles
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
/**
|
|
* The page's way to the local store: one StoreClient per tab, accounts opened
|
|
* while this tab is the owner, sessions and auth handed over. Knows nothing of
|
|
* the app's stores: lib/offline/offline-mode.ts wires those in with
|
|
* configureStore, so the worker bundle stays free of them.
|
|
*/
|
|
|
|
import type { StoreEvent, StoreScope } from '../area';
|
|
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
|
|
import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, STORE_OPENED_KEY, type WrapLayer } from './keys';
|
|
import { StoreClient } from './store-client';
|
|
|
|
export interface StoreAccountInputs {
|
|
session: StoreSession | null;
|
|
auth: string | null;
|
|
/** Left out while the organisation's policy isn't known: the store keeps the last one. */
|
|
scope?: StoreScope;
|
|
invertedKeywordSort: boolean | null;
|
|
}
|
|
|
|
export interface StorePageHooks {
|
|
/** What this tab knows about an account right now. */
|
|
inputs(account: string): StoreAccountInputs;
|
|
/** Runs the client's refresh path; the fresh Authorization header, or null. */
|
|
refreshAuth(account: string): Promise<string | null>;
|
|
onEvent(event: StoreEvent): void;
|
|
/** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */
|
|
keyLayers?(): readonly WrapLayer[];
|
|
/**
|
|
* This tab just took over the store. Resolves when it has heard the
|
|
* organisation's policy since: until then it sends no scope, which may be old.
|
|
*/
|
|
ownerTaken?(): Promise<void>;
|
|
/** An account this device kept mail for has an empty store now: its key or its file went. */
|
|
onStoreLost?(account: string): void;
|
|
}
|
|
|
|
/** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */
|
|
export interface StoreBackend {
|
|
supported(): boolean;
|
|
/** Asks for the account to be open. `session` is for a first open with no client yet. */
|
|
ensureOpen(account: string, session?: StoreSession | null): Promise<void>;
|
|
/** Hands the owner what this tab now knows: session, auth, sort polarity. */
|
|
refresh(account: string): void;
|
|
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null>;
|
|
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T>;
|
|
/** What the store may keep. A tightened scope prunes at once, also without a network. */
|
|
setScope(account: string, scope: StoreScope): Promise<void>;
|
|
sync(account: string): Promise<void>;
|
|
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void>;
|
|
setOnline(online: boolean): Promise<void>;
|
|
/** Closes the account, forgets its key and deletes its file. */
|
|
remove(account: string): Promise<void>;
|
|
/** Stops this tab's worker at once (a sign-out; the files go separately). */
|
|
stop(): void;
|
|
}
|
|
|
|
/** How long a call waits for the owner to have opened an account. */
|
|
const OPEN_RETRIES = 25;
|
|
const OPEN_RETRY_MS = 200;
|
|
|
|
export { STORE_OPENED_KEY };
|
|
|
|
function readOpenedAccounts(): string[] {
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(STORE_OPENED_KEY) ?? '[]') as unknown;
|
|
return Array.isArray(parsed) ? parsed as string[] : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function rememberOpened(account: string, opened: boolean): void {
|
|
try {
|
|
const others = readOpenedAccounts().filter((id) => id !== account);
|
|
localStorage.setItem(STORE_OPENED_KEY, JSON.stringify(opened ? [...others, account] : others));
|
|
} catch {
|
|
// Without it a lost store goes unnoticed, and the store still works.
|
|
}
|
|
}
|
|
|
|
class PageStore implements StoreBackend {
|
|
private client: StoreClient | null = null;
|
|
private readonly wanted = new Map<string, StoreSession | null>();
|
|
private readonly opening = new Map<string, Promise<void>>();
|
|
/** The engine can't start in this browser: don't keep every caller waiting for an owner. */
|
|
private broken = false;
|
|
/**
|
|
* Accounts whose key was just made, until their store has opened. A new owner
|
|
* can start the open again, and the second try finds the key the first one made.
|
|
*/
|
|
private readonly keyJustMade = new Set<string>();
|
|
/** False from taking over the store until this tab has heard the policy again. */
|
|
private scopeCurrent = true;
|
|
|
|
constructor(private readonly hooks: () => StorePageHooks | null) {}
|
|
|
|
supported(): boolean {
|
|
return StoreClient.supported();
|
|
}
|
|
|
|
private started(): StoreClient {
|
|
if (this.client) return this.client;
|
|
this.broken = false;
|
|
const client = new StoreClient({
|
|
basePath: (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''),
|
|
buildId: process.env.NEXT_PUBLIC_BUILD_ID,
|
|
onEvent: (event) => {
|
|
if (event.type === 'error' && event.area === 'store' && !event.account) this.broken = true;
|
|
// Another tab cleared it: what this tab remembers opening is gone.
|
|
if (event.type === 'removed') this.opening.delete(event.account);
|
|
this.hooks()?.onEvent(event);
|
|
},
|
|
onOwner: () => {
|
|
this.opening.clear();
|
|
const hooks = this.hooks();
|
|
if (hooks?.ownerTaken) {
|
|
this.scopeCurrent = false;
|
|
const heard = () => {
|
|
this.scopeCurrent = true;
|
|
for (const account of this.wanted.keys()) this.refresh(account);
|
|
};
|
|
void hooks.ownerTaken().then(heard, heard);
|
|
}
|
|
for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {});
|
|
},
|
|
onNeedAuth: (account) => {
|
|
void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null))
|
|
.catch(() => null)
|
|
.then((auth) => this.client?.setAuth(account, auth));
|
|
},
|
|
onHalted: () => this.reset(),
|
|
});
|
|
client.start();
|
|
this.client = client;
|
|
return client;
|
|
}
|
|
|
|
private reset(): void {
|
|
this.client = null;
|
|
this.scopeCurrent = true;
|
|
this.wanted.clear();
|
|
this.opening.clear();
|
|
this.broken = false;
|
|
}
|
|
|
|
/** Only the owner may open: the key goes to this tab's own worker, never over the channel. */
|
|
private openHere(account: string): Promise<void> {
|
|
const running = this.opening.get(account);
|
|
if (running) return running;
|
|
const run = (async () => {
|
|
const client = this.started();
|
|
const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
|
|
if (fresh) this.keyJustMade.add(account);
|
|
// The old file, if the key alone went, can never be read again.
|
|
if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {});
|
|
const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null };
|
|
const opened = await client.openAccount({
|
|
account,
|
|
file: record.file,
|
|
keyHex,
|
|
session: inputs.session ?? this.wanted.get(account) ?? null,
|
|
scope: this.scopeCurrent ? inputs.scope : undefined,
|
|
invertedKeywordSort: inputs.invertedKeywordSort,
|
|
auth: inputs.auth,
|
|
});
|
|
// Whichever try of them gets here first says so.
|
|
if (this.keyJustMade.delete(account) || opened?.created) {
|
|
if (readOpenedAccounts().includes(account)) this.hooks()?.onStoreLost?.(account);
|
|
}
|
|
rememberOpened(account, true);
|
|
})();
|
|
this.opening.set(account, run);
|
|
run.catch(() => this.opening.delete(account));
|
|
return run;
|
|
}
|
|
|
|
async ensureOpen(account: string, session?: StoreSession | null): Promise<void> {
|
|
this.wanted.set(account, session ?? this.wanted.get(account) ?? null);
|
|
const client = this.started();
|
|
if (client.ownsStore) await this.openHere(account);
|
|
// Otherwise the owner opens it: calls below wait for that.
|
|
}
|
|
|
|
refresh(account: string): void {
|
|
const client = this.client;
|
|
if (!client?.ownsStore) return;
|
|
const inputs = this.hooks()?.inputs(account);
|
|
if (!inputs) return;
|
|
if (inputs.session) client.setSession(account, inputs.session);
|
|
client.setAuth(account, inputs.auth);
|
|
// The engine drops one that's no change.
|
|
if (inputs.scope && this.scopeCurrent) void client.setScope(account, inputs.scope).catch(() => {});
|
|
if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort);
|
|
}
|
|
|
|
/** A call for an account: the owner may still be opening it, in this tab or another. */
|
|
private async whenOpen<T>(account: string, call: (client: StoreClient) => Promise<T>): Promise<T> {
|
|
if (this.broken) throw new Error('The local store could not start');
|
|
await this.ensureOpen(account);
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
return await call(this.started());
|
|
} catch (error) {
|
|
if ((error as Error)?.name !== 'StoreAccountClosedError' || attempt >= OPEN_RETRIES) throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, OPEN_RETRY_MS));
|
|
}
|
|
}
|
|
}
|
|
|
|
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null> {
|
|
return this.whenOpen(account, (client) => client.answer(account, calls));
|
|
}
|
|
|
|
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T> {
|
|
return this.whenOpen(account, (client) => client.area<T>(account, name, params));
|
|
}
|
|
|
|
setScope(account: string, scope: StoreScope): Promise<void> {
|
|
return this.whenOpen(account, (client) => client.setScope(account, scope));
|
|
}
|
|
|
|
sync(account: string): Promise<void> {
|
|
return this.whenOpen(account, (client) => client.sync(account));
|
|
}
|
|
|
|
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void> {
|
|
return this.whenOpen(account, (client) => client.noteStateChange(account, changed));
|
|
}
|
|
|
|
async setOnline(online: boolean): Promise<void> {
|
|
if (!this.client || this.broken) return;
|
|
await this.client.setOnline(online);
|
|
}
|
|
|
|
async remove(account: string): Promise<void> {
|
|
const record = await getAccountRecord(account);
|
|
await forgetAccountKey(account);
|
|
rememberOpened(account, false);
|
|
this.wanted.delete(account);
|
|
this.opening.delete(account);
|
|
if (record && !this.broken) await this.started().removeAccount(account, record.file);
|
|
}
|
|
|
|
stop(): void {
|
|
this.client?.halt();
|
|
this.reset();
|
|
}
|
|
}
|
|
|
|
let hooks: StorePageHooks | null = null;
|
|
let backend: StoreBackend | null = null;
|
|
|
|
/** Registers what the store needs from the app. */
|
|
export function configureStore(next: StorePageHooks | null): void {
|
|
hooks = next;
|
|
}
|
|
|
|
export function getStore(): StoreBackend {
|
|
backend ??= new PageStore(() => hooks);
|
|
return backend;
|
|
}
|
|
|
|
/** Tests run the real engine in-process. */
|
|
export function setStoreBackend(next: StoreBackend | null): void {
|
|
backend = next;
|
|
}
|