/** * Offline mode (#523): "Keep mail on this device". * * - Opt-in per account and per browser (state.ts), off by default, hidden * and wiped when the organisation forbids it (policy.json * `organisation.devices.noOffline`, per account) and in Lite. * - While on, the local store (lib/store, a database in a worker) keeps the * account's mail in sync by itself, and the JMAP client asks it when the * network is gone. This file is the orchestration around it: who may use * it, when it syncs and flushes, the wipes. * - Changes and sends made offline wait in the store's outbox and go out in * order once the server answers again and the organisation's policy was * fetched afresh (flush.ts). * - Everything is wiped at a full sign-out, by lib/sign-out-cleanup.ts; for one * account when it signs out, is removed or switches offline mode off, and when * the organisation forbids it (checked on every policy fetch). An account the * app drops by itself keeps its data for 30 days or the next sign-in, hidden from * everyone else: when it couldn't confirm the login (an outage, an unknown * refusal, a session cookie that ran out), at the organisation's session limit, * and when the server refuses the login (`invalid_grant`, a lasting 401), unless * the organisation asks for deletion at once (lib/offline/device-wipe.ts). Unsent * messages a sign-out deletes without the user's word are named (lost.ts). */ import { onStoreKeyMissing, storeKeyLayers } from '@/lib/app-lock/controller'; import { IS_LITE } from '@/lib/lite'; import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount, devicesForNoOne, signedInAccountIds } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; import { emitStoreEvent } from '@/lib/offline/store-events'; import { STORAGE_ERROR_AREA, type StoreScope, type StoreUsage } from '@/lib/store/area'; import type { DraftError } from '@/lib/store/drafts/pending'; import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; import { purgeStoreFiles } from '@/lib/store/client/purge'; import type { StoreSession } from '@/lib/store/engine/transport'; import { onAccountRemoved, useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; import { accountRefusedByDesktopPolicy } from '@/lib/desktop/refused-accounts'; import { generateMessageId } from '@/lib/jmap/client'; import { isNetworkFailure, SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; import { setOutboxHooks, type OutboxHooks } from './outbox-hooks'; import { heldFile, holdFile, isLocalBlobId, queueSend, readLocalDrafts, readQueue, type OutboxEntry, type OutboxError, type OutboxSummary, type PolicyFingerprint, } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearEvicted, EVICTED_KEEP_MS, onStoreOwnerChanged, forgetAccountState, forgetAllAccountState, forgetMailSnapshot, isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, OFFLINE_HEALTH_KEY, OFFLINE_SCOPE_KEY, readEnabledFrom, readEvicted, readStoredChoices, readStoredHealth, serverKey, setServerDown, useOfflineStore, visibleAccounts, writeChoice, writeEnabled, writeHealth, type AreaConflict, type OutboxRow, type UserChoice, } from './state'; import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope'; import { isStandaloneDisplayMode } from '@/lib/standalone-display'; import { readPersistence, requestPersistence, safariMayDelete } from './storage-health'; import { nextProgress } from './sync-progress'; import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; import { forgetEvictedSecrets } from './evicted-secrets'; import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = | { kind: 'queued'; subject: string } | { kind: 'sent'; subject: string } | { kind: 'send-failed'; subject: string; error: OutboxError } | { kind: 'held'; subject: string } | { kind: 'lost'; subjects: string[] } | { kind: 'conflict'; count: number } | { kind: 'draft-refused'; subject: string; error: DraftError } | { kind: 'draft-conflict'; subject: string } | { kind: 'storage-failed' } | { kind: 'store-unavailable' } | { kind: 'key-unprotected' } | { kind: 'rekey-failed' } | { kind: 'store-reset'; lost?: number } | { kind: 'legacy-stranded' } | { kind: 'default-on' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } | { kind: 'back-online-reload' }; let notifier: ((notice: OfflineNotice) => void) | null = null; /** * The store opens at a start from the local copy, before the host component has * mounted. What it has to say by then, like "this store couldn't be read", waits. */ const earlyNotices: OfflineNotice[] = []; const MOST_EARLY_NOTICES = 10; function notify(notice: OfflineNotice): void { if (notifier) notifier(notice); else if (earlyNotices.length < MOST_EARLY_NOTICES) earlyNotices.push(notice); } /** Where the notices go (the host component turns them into toasts). Null: nothing is there at the moment. */ export function setOfflineNotifier(fn: ((notice: OfflineNotice) => void) | null): void { notifier = fn; if (fn) for (const notice of earlyNotices.splice(0)) fn(notice); } export function offlineNotify(notice: OfflineNotice): void { notify(notice); } // ---- Who may use it ---- function hasIndexedDb(): boolean { try { return typeof indexedDB !== 'undefined' && indexedDB !== null; } catch { return false; } } /** Offline mode is possible here at all: not in Lite, with what the store needs. */ export function offlineSupported(): boolean { if (IS_LITE || !hasIndexedDb()) return false; if (useOfflineStore.getState().storageBlocked) return false; try { return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported(); } catch { return false; } } /** The organisation allows this account to keep mail on the device. */ export function offlineAllowed(account: string | null | undefined): boolean { return offlineSupported() && !devicesForAccount(account ?? null).noOffline; } /** A dropped account's data isn't judged by the groups of whoever is signed in now. */ function policyOfKept(account: string) { return isEvicted(account) ? devicesForNoOne() : devicesForAccount(account); } /** What this account keeps here: the organisation's policy over the user's choice over the platform's default. */ export function scopeForAccount(account: string): EffectiveScope { return effectiveScope(account, userChoiceFor(account), policyOfKept(account), platformDefault()); } /** "Keep mail on this device" is on for this account here, and allowed. */ export function offlineActive(account: string | null | undefined): boolean { return !!account && useOfflineStore.getState().enabled.includes(account) && offlineAllowed(account); } /** The local account a JMAP client belongs to. */ export function accountForClient(client: OfflineClientRef): string | null { const auth = useAuthStore.getState(); if (client === (auth.client as unknown) && auth.activeAccountId) return auth.activeAccountId; try { for (const [id, c] of auth.getAllConnectedClients()) if ((c as unknown) === client) return id; } catch { // A stand-in auth store in tests. } const server = client.getServerUrl().replace(/\/+$/, ''); const user = client.getUsername(); const entry = useAccountStore.getState().accounts.find((a) => a.serverUrl.replace(/\/+$/, '') === server && a.username === user); return entry?.id ?? null; } function onlineClientFor(account: string) { const client = useAuthStore.getState().getClientForAccount(account); if (!client || client.isOfflineOnly()) return null; return client; } // ---- The store's way to this tab ---- /** How long a tab that took over the store waits for the policy before going on as if it hadn't heard. */ const OWNER_POLICY_WAIT_MS = 5_000; /** A full disk stays full: once in a while is enough to say so. */ const STORAGE_NOTICE_MS = 30 * 60_000; let lastStorageNotice = 0; let lastUnavailableNotice = 0; /** The store won't open: what waits in it is out of reach, and the user should know why. */ function noteStoreUnavailable(): void { if (useOfflineStore.getState().enabled.length === 0) return; const now = Date.now(); if (now - lastUnavailableNotice < STORAGE_NOTICE_MS) return; lastUnavailableNotice = now; notify({ kind: 'store-unavailable' }); } const storeHooks: StorePageHooks = { keyLayers: storeKeyLayers, onKeyWrapFailed() { if (useOfflineStore.getState().keyUnprotected) return; useOfflineStore.setState({ keyUnprotected: true }); notify({ kind: 'key-unprotected' }); }, onRekeyFailed(account) { useOfflineStore.setState((s) => ({ rekey: { ...s.rekey, [account]: 'failed' } })); notify({ kind: 'rekey-failed' }); }, onKeyLocked: onStoreKeyMissing, inputs(account) { const client = onlineClientFor(account); return { session: (client?.getSessionSnapshot() as StoreSession | undefined) ?? null, auth: client?.getAuthHeader() ?? null, // Until the policy is known the store keeps its last scope, so a slow // start doesn't read as a wider window that has to be fetched. scope: scopeDecided(account) ? scopeForAccount(account).scope : undefined, invertedKeywordSort: client?.keywordSortInverted() ?? null, }; }, async refreshAuth(account) { const client = onlineClientFor(account); if (!client) return null; // Through the client: a 401 there runs its token renewal or re-auth. await client.requestNetworkOnly([['Core/echo', {}, '0']]); return client.getAuthHeader(); }, async ownerTaken() { // This tab may have sat in the background: its policy can be old. await Promise.race([ usePolicyStore.getState().fetchPolicy(), new Promise((resolve) => setTimeout(resolve, OWNER_POLICY_WAIT_MS)), ]); }, onStoreLost(account) { // Whoever is here wasn't signed in to a dropped account: nothing to tell them. if (isEvicted(account)) return; const lost = useOfflineStore.getState().health[account]?.pending ?? 0; useOfflineStore.setState((s) => ({ storeLost: [...new Set([...s.storeLost, account])] })); writeHealth(account, { pending: 0, reset: { at: Date.now(), lost } }); notify({ kind: 'store-reset', lost }); void refreshQueueState(); }, onEvent(event) { emitStoreEvent(event); if (event.type === 'created' && event.collection === 'Email') { const client = onlineClientFor(event.account); if (client) reportStoreCreatedMail(client, event.jmap, event.ids); } if (event.type === 'synced') { useOfflineStore.setState((s) => { const { [event.account]: _full, ...storageFull } = s.storageFull; const { [event.account]: _progress, ...progress } = s.progress; return { lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at }, storageFull, progress }; }); writeHealth(event.account, { lastSync: event.at }); forgetMailSnapshot(event.account); void refreshUsage(); // A pass also sends what the calendar and contacts have waiting. void refreshQueueState(); } if (event.type === 'rekey') { useOfflineStore.setState((s) => { const { [event.account]: _before, ...rest } = s.rekey; return { rekey: event.percent >= 100 ? rest : { ...rest, [event.account]: event.percent } }; }); } if (event.type === 'progress') { useOfflineStore.setState((s) => ({ progress: { ...s.progress, [event.account]: nextProgress(s.progress[event.account], event, Date.now()) } })); } if (event.type === 'error' && event.area === STORAGE_ERROR_AREA) { useOfflineStore.setState((s) => ({ storageFull: { ...s.storageFull, [event.account]: s.storageFull[event.account] ?? Date.now() } })); const now = Date.now(); if (now - lastStorageNotice < STORAGE_NOTICE_MS) return; lastStorageNotice = now; notify({ kind: 'storage-failed' }); } }, }; // ---- What the client asks and tells ---- /** Parts cut short for a list view aren't what the user opened. */ function hasWholeText(email: { bodyValues?: Record }): boolean { const values = Object.values(email.bodyValues ?? {}); return values.length > 0 && !values.some((value) => value?.isTruncated); } /** Mutating calls the device can't take: an import, calendar and contact changes. */ const NEEDS_SERVER = /^(EmailSubmission|Email\/import|CalendarEvent\/set|ContactCard\/set|AddressBook\/set|Calendar\/set|Mailbox\/set|FileNode\/set|SieveScript\/set|VacationResponse\/set|Identity\/set)/; let lastRefusalNotice = 0; function noticeRefused(answered: OfflineResponse | null, methodCalls: OfflineMethodCall[]): void { const refusedCall = methodCalls.some(([name, , id]) => NEEDS_SERVER.test(name) && (!answered || answered.methodResponses.some(([responseName, , responseId]) => responseId === id && responseName === 'error'))); const refusedChange = answered?.methodResponses.some(([name, result]) => name === 'Email/set' && result?.notUpdated) ?? false; if (!refusedCall && !refusedChange) return; const now = Date.now(); if (now - lastRefusalNotice < 5_000) return; lastRefusalNotice = now; const emptied = answered?.methodResponses.some(([name, result]) => name === 'Email/set' && Object.values((result?.notUpdated ?? {}) as Record).some((error) => error.type === 'invalidProperties')); notify({ kind: emptied ? 'refused-delete' : 'refused-other' }); } /** What the store may keep for later when it answers for the server. */ const QUEUEING_METHOD = /^(Email|CalendarEvent|Calendar|ContactCard|AddressBook)\/set$/; const probes = new Map>(); /** A black-holed connection never answers the probe either. */ const PROBE_TIMEOUT_MS = 5_000; /** * Whether a mail server is gone. Browsers report a refusal without CORS * headers (Stalwart answering "too many requests at once") the same way as * no network at all, so the server is asked once more without CORS: only * when that fails too is the server counted as gone, and sends to it go to * the outbox. Per server, so one server down doesn't hold the others' mail. */ export function probeServer(serverUrl: string): Promise { const key = serverKey(serverUrl); const running = probes.get(key); if (running) return running; const probe = (async () => { try { await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); if (setServerDown(serverUrl, false)) scheduleFlushAll(500); return false; } catch { setServerDown(serverUrl, true); return true; } })().finally(() => { probes.delete(key); }); probes.set(key, probe); return probe; } const hooks: OfflineHooks = { skipNetwork(client) { if (typeof navigator === 'undefined' || navigator.onLine !== false) return false; return offlineActive(accountForClient(client)); }, async answer(client, methodCalls, options) { const account = accountForClient(client); if (!offlineActive(account) || !account) return null; let answered: OfflineResponse | null = null; try { // A draft whose connection broke may be on the server already. const calls = options?.networkTried ? methodCalls.map((call): OfflineMethodCall => (call[0] === 'Email/set' && call[1].create ? [call[0], { ...call[1], networkTried: true }, call[2]] : call)) : methodCalls; answered = await getStore().answer(account, calls); } catch { // The store couldn't start or lost its owner: as if nothing were kept. } noticeRefused(answered, methodCalls); if (answered && methodCalls.some(([name]) => QUEUEING_METHOD.test(name))) { requestOutboxSync(); void refreshQueueState(); } return answered; }, async resolveStandIns(client, ids) { const account = accountForClient(client); if (!offlineActive(account) || !account) return {}; const ask = (call: string) => getStore().area>(account, call, { ids }).catch(() => ({} as Record)); const [drafts, events] = await Promise.all([ask('drafts.resolveStandIns'), ask('calendar.resolveStandIns')]); return Object.fromEntries(ids.map((id) => [id, events[id] ?? drafts[id] ?? null])); }, resolveHeldAttachments(client, attachments, accountId) { return resolveHeld(client, attachments, accountId); }, fileOpened(client, accountId, id) { const account = accountForClient(client); if (!offlineActive(account) || !account) return; void getStore().area(account, 'files.noteOpened', { jmap: accountId, id }).catch(() => {}); }, async searchLocal(client, methodCalls) { const account = accountForClient(client); if (!offlineActive(account) || !account) return null; try { return await getStore().answer(account, methodCalls); } catch { return null; } }, record(client, methodCalls, response) { // The store syncs by itself. What it may not have is a message the user // opened from outside the chosen window, so only those come through here. const account = accountForClient(client); if (!offlineActive(account) || !account) return; for (const [name, result, callId] of response.methodResponses) { if (name !== 'Email/get' || !Array.isArray(result?.list)) continue; // Past the organisation's cap a message isn't kept, however it got opened. const cap = devicesForAccount(account).offlineMaxDays; const now = Date.now(); const opened = (result.list as Array<{ bodyValues?: Record; receivedAt?: unknown }>) .filter((email) => hasWholeText(email) && !olderThanCap(email.receivedAt, cap, now)); if (opened.length === 0) continue; const args = methodCalls.find(([n, , id]) => id === callId && n === name)?.[1]; const jmap = (result.accountId as string) || (args?.accountId as string) || client.getAccountId(); // Copied now: callers change the answer's objects after this returns. const emails = JSON.parse(JSON.stringify(opened)) as unknown[]; void getStore().area(account, 'mail.recordOpened', { jmap, emails }).catch(() => {}); } }, noteNetwork(client, reachable) { if (reachable) { if (setServerDown(client.getServerUrl(), false)) scheduleFlushAll(500); } else { void probeServer(client.getServerUrl()); } }, async serverGone(client, error) { if (!offlineActive(accountForClient(client))) return false; if (typeof navigator !== 'undefined' && navigator.onLine === false) return true; // The proxy answered, so a probe would pass; the server behind it is what's gone. if (error instanceof Error && error.name === 'GatewayError') { setServerDown(client.getServerUrl(), true); return true; } return probeServer(client.getServerUrl()); }, stateChange(client, change) { const account = accountForClient(client); if (!offlineActive(account) || !account || !change.changed) return; void getStore().noteStateChange(account, change.changed).catch(() => {}); }, async blob(client, blobId, jmap) { const account = accountForClient(client); if (!account || !offlineActive(account)) return null; if (isLocalBlobId(blobId)) return heldFile(account, blobId); // An attachment, or a file from the Files area. const kept = await getStore().area(account, 'mail.getBlob', { jmap, blobId }).catch(() => null) ?? await getStore().area(account, 'files.blob', { jmap, blobId }).catch(() => null); if (!kept) return null; return new Blob(kept.blob ? [kept.blob] : [kept.data as BlobPart], { type: kept.type || 'application/octet-stream' }); }, recordBlob(client, blobId, jmap, blob) { const account = accountForClient(client); if (!account || !offlineActive(account) || blob.size > MAX_RECORDED_BLOB_BYTES) return; // Filed under its kept message by the store: never a whole message an // export reads, never a stray file. void blob.arrayBuffer() .then((buffer) => getStore().area(account, 'mail.recordBlob', { jmap, blobId, type: blob.type || null, data: new Uint8Array(buffer) })) .catch(() => {}); }, async holdUpload(client, file, accountId) { const account = accountForClient(client); if (!account || !offlineActive(account)) return null; return holdFile(account, file, file.name, accountId); }, }; interface KeptBlob { type: string | null; blob?: Blob; data?: Uint8Array } /** Files attached offline, uploaded now that the server answers. An outage here means nothing went anywhere. */ async function resolveHeld(client: OfflineClientRef, attachments: T[], accountId?: string): Promise { const account = accountForClient(client); if (!account || !attachments.some((a) => isLocalBlobId(a.blobId))) return attachments; const real = onlineClientFor(account); if (!real) return attachments; const out: T[] = []; for (const attachment of attachments) { if (!isLocalBlobId(attachment.blobId)) { out.push(attachment); continue; } const file = await heldFile(account, attachment.blobId); if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' }); try { const { blobId } = await real.uploadBlob(file, { accountId, noHold: true }); // Held again after all: that's no upload. if (isLocalBlobId(blobId)) throw new SendNotStartedError(); out.push({ ...attachment, blobId }); } catch (error) { if (isNetworkFailure(error)) throw new SendNotStartedError(error); throw error; } } return out; } /** Attachments and images opened while online are kept up to this size. */ const MAX_RECORDED_BLOB_BYTES = 5 * 1024 * 1024; // ---- The outbox ---- function summaryOf(args: unknown[]): OutboxSummary { const [to, subject, , cc, bcc] = args as [string[] | undefined, string | undefined, unknown, string[] | undefined, string[] | undefined]; return { subject: subject ?? '', to: [...(to ?? []), ...(cc ?? []), ...(bcc ?? [])] }; } /** Thrown instead of queueing while a plugin that may have to see the message isn't running. */ export class OfflinePluginsDownError extends Error { constructor() { super('A plugin is not running offline'); this.name = 'OfflinePluginsDownError'; } } /** * An enabled plugin isn't running (after a start without the network the * plugins can't load). It may be the user's signing or encryption plugin, * or a send check, so the message isn't queued: it would go out without it. */ export function pluginsNotRunning(): boolean { const source = getSendGuardSource(); if (!source) return false; const { plugins, pluginsOff } = source.snapshot(); if (pluginsOff) return false; return plugins.some((plugin) => plugin.status !== 'disabled' && plugin.status !== 'installed' && guardState(plugin) !== null); } /** The send checks the organisation requires now (forced plugins that check sends). */ function requiredGuards(): string[] { const source = getSendGuardSource(); if (!source) return []; const { plugins, forced, pluginsOff } = source.snapshot(); if (pluginsOff) return []; return [...new Set(plugins.filter((plugin) => forced(plugin) && guardsSending(plugin)).map((plugin) => plugin.id))].sort(); } export function policyFingerprint(account: string, hasLabel: boolean): PolicyFingerprint { const labels = labelsPolicyFor({ accountId: account }); return { labelRequired: labels.required && labels.list.length > 0, hasLabel, guards: requiredGuards() }; } /** The organisation's rules got stricter since the message was finished. */ export function stricterNow(entry: OutboxEntry): boolean { const then = entry.fingerprint; if (!then) return false; const now = policyFingerprint(entry.account, then.hasLabel); if (now.labelRequired && !then.hasLabel) return true; return now.guards.some((id) => !then.guards.includes(id)); } function queuedResult(outboxId: number): SendEmailResult { return { scheduled: false, queued: true, outboxId }; } const outbox: OutboxHooks = { shouldQueue(client) { const account = accountForClient(client); if (!account || !offlineActive(account)) return false; // A local copy has no way to the server, whatever the probes last said. if (client.isOfflineOnly?.()) return true; return isOfflineNow(client.getServerUrl()) || (typeof navigator !== 'undefined' && navigator.onLine === false); }, async queueEmail(client, args) { const account = accountForClient(client); if (!account) throw new Error('No account for this send'); if (pluginsNotRunning()) throw new OfflinePluginsDownError(); const options = { ...((args[15] as Record | undefined) ?? {}) }; delete options.outbox; const stored = [...args.slice(0, 15), options]; const summary = summaryOf(stored); const labelled = !!options.labelHeaders && Object.keys(options.labelHeaders as object).length > 0; // Fixed now and kept across retries: how a lost response is told from a send that never arrived. // Only its domain is used, and the sender may be written "Name
". const sender = ((args[6] as string | undefined) || client.getUsername()).replace(/>.*$/, ''); const messageId = generateMessageId(sender); const id = await queueSend(account, summary, { kind: 'email', args: stored }, policyFingerprint(account, labelled), messageId); await refreshQueueState(); notify({ kind: 'queued', subject: summary.subject }); return queuedResult(id); }, async queueRaw(client, raw, options) { const account = accountForClient(client); if (!account) throw new Error('No account for this send'); if (pluginsNotRunning()) throw new OfflinePluginsDownError(); const held = await holdFile(account, raw, 'message.eml'); const { summary, labelled, messageId } = await rawSummary(raw); const id = await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options }, policyFingerprint(account, labelled), messageId); await refreshQueueState(); notify({ kind: 'queued', subject: summary.subject }); return queuedResult(id); }, resolveHeldAttachments(client, attachments) { return resolveHeld(client, attachments); }, }; /** Subject, recipients, label and Message-ID of a raw message, for the outbox. */ async function rawSummary(raw: Blob): Promise<{ summary: OutboxSummary; labelled: boolean; messageId?: string }> { const head = await raw.slice(0, 64 * 1024).text(); const end = head.search(/\r?\n\r?\n/); const block = (end >= 0 ? head.slice(0, end) : head).replace(/\r?\n[ \t]+/g, ' '); const header = (name: string) => block.split(/\r?\n/).find((line) => line.toLowerCase().startsWith(`${name}:`))?.slice(name.length + 1).trim() ?? ''; const to = ['to', 'cc'].flatMap((name) => header(name).split(',')).map((s) => s.trim()).filter(Boolean); return { summary: { subject: decodeMimeWords(header('subject')), to }, labelled: header('sio-label') !== '', messageId: header('message-id').match(/<([^>\s]+)>/)?.[1], }; } function decodeMimeWords(value: string): string { return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, encoding: string, text: string) => { try { const bytes = encoding.toUpperCase() === 'B' ? Uint8Array.from(atob(text), (c) => c.charCodeAt(0)) : Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0)); return new TextDecoder(charset).decode(bytes); } catch { return whole; } }); } /** Reads the queue into the store, for the outbox view and the banner. */ export async function refreshQueueState(): Promise { if (!offlineSupported()) return; // Nothing is kept for an account that hasn't it on; and opening the // store would create it (on the sign-in page, after a sign-out). if (visibleAccounts().length === 0) { useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [] }); return; } try { // Sends of a tab closed on the way: uncertain, so they can be looked up. await settleStaleSends(); const [{ failed, patches, patchesBy, outbox: entries, conflicts }, drafts] = await Promise.all([readQueue(), readLocalDrafts()]); if (failed.length > 0) noteStoreUnavailable(); const rows: OutboxRow[] = entries.map((entry) => ({ id: entry.id, account: entry.account, createdAt: entry.createdAt, subject: entry.summary.subject, to: entry.summary.to, status: entry.status, error: entry.error, })); // What an account's store can't tell right now is still there. const shown = useOfflineStore.getState(); const unread = shown.outbox.filter((row) => failed.includes(row.account)); const unreadDrafts = shown.localDrafts.filter((row) => failed.includes(row.account)); const accounts = visibleAccounts(); const areas = await Promise.all(accounts.map(readAreaState)); const changesBy = Object.fromEntries(accounts.map((account, index) => [account, (patchesBy[account] ?? 0) + areas[index].pending])); useOfflineStore.setState((s) => ({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, localDrafts: [...unreadDrafts, ...drafts], areaConflicts: areas.flatMap((area) => area.conflicts), // An account that couldn't answer counts with what it last said. changesBy: { ...changesBy, ...Object.fromEntries(failed.map((account) => [account, s.changesBy[account] ?? 0])) }, })); if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater(); // What a browser clearing the store would lose. Counted here, and the // count is all that's kept outside the store. A store that couldn't be // read keeps its last count. accounts.forEach((account, index) => { if (failed.includes(account)) return; const sends = rows.filter((row) => row.account === account).length + drafts.filter((row) => row.account === account).length; writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending }); }); } catch { // The store is being deleted, or can't start. noteStoreUnavailable(); } } interface StoredCalendarConflict { id: number; createdAt: number; title: string | null; reason: 'refused' | 'changedOnServer' | 'destroyedOnServer'; kind: string; description: string | null; errorType: string | null } interface StoredContactConflict { id: number; createdAt: number; summary: string; kind: 'refused' | 'changed' | 'gone'; local: { op?: string }; error: string | null } /** What the calendar and contacts have waiting or couldn't send. An area this build doesn't have counts as empty. */ async function readAreaState(account: string): Promise<{ conflicts: AreaConflict[]; pending: number }> { const read = (name: string, none: T) => getStore().area(account, name).then((value) => value ?? none, () => none); const [calendar, contacts, calendarPending, contactsPending] = await Promise.all([ read('calendar.conflicts', []), read('contacts.conflicts', []), read('calendar.pending', 0), read('contacts.pending', 0), ]); const conflicts: AreaConflict[] = [ ...calendar.map((row): AreaConflict => ({ area: 'calendar', account, id: row.id, createdAt: row.createdAt, title: row.title ?? '', reason: row.reason === 'refused' ? 'refused' : row.reason === 'destroyedOnServer' ? 'gone' : 'changed', destroy: row.kind === 'destroy', detail: row.description ?? row.errorType, })), ...contacts.map((row): AreaConflict => ({ area: 'contacts', account, id: row.id, createdAt: row.createdAt, title: row.summary, reason: row.kind, destroy: row.local?.op === 'destroy', detail: row.error, })), ]; return { conflicts, pending: calendarPending + contactsPending }; } /** The user has seen it: the calendar's or the contacts' copy stays as the server has it. */ export async function dismissAreaConflict(conflict: AreaConflict): Promise { await getStore().area(conflict.account, `${conflict.area}.dismissConflict`, { id: conflict.id }); await refreshQueueState(); } export async function refreshUsage(): Promise { const accounts = visibleAccounts(); if (!offlineSupported() || accounts.length === 0) return; try { const settled = await Promise.allSettled(accounts.map((account) => getStore().area(account, 'store.usage'))); // One account's store being in trouble says nothing about the others'. useOfflineStore.setState((s) => { const usage = { ...s.usage }; accounts.forEach((account, index) => { const result = settled[index]; if (result.status === 'fulfilled' && result.value) usage[account] = result.value; }); return { usage, usageBytes: accounts.reduce((sum, account) => sum + (usage[account]?.bytes ?? 0), 0) }; }); } catch { /* deleted meanwhile */ } } // ---- Sync and flush timing ---- const syncTimers = new Map>(); export function scheduleSync(account: string, delayMs = 2_000): void { if (!offlineActive(account)) return; const existing = syncTimers.get(account); if (existing) clearTimeout(existing); syncTimers.set(account, setTimeout(() => { syncTimers.delete(account); void runSync(account); }, delayMs)); } const syncsRunning = new Set(); export async function runSync(account: string): Promise { // Stopped by the device's OS policy (stores/auth-store.ts): its data waits. const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); if (entry && accountRefusedByDesktopPolicy(entry)) return; if (syncsRunning.has(account) || !offlineActive(account)) return; const client = onlineClientFor(account); if (!client || isOfflineNow(client.getServerUrl())) return; syncsRunning.add(account); useOfflineStore.setState((s) => ({ syncing: [...new Set([...s.syncing, account])] })); try { await getStore().ensureOpen(account); getStore().refresh(account); await getStore().sync(account); } catch { // Tried again at the next change or in 15 minutes. } finally { syncsRunning.delete(account); useOfflineStore.setState((s) => ({ syncing: s.syncing.filter((id) => id !== account) })); void refreshUsage(); } } let flushTimer: ReturnType | null = null; export function scheduleFlushAll(delayMs = 1_000): void { if (flushTimer) clearTimeout(flushTimer); flushTimer = setTimeout(() => { flushTimer = null; void flushAll(); }, delayMs); } /** How long after a failed policy fetch the outbox asks again. */ const POLICY_RETRY_MS = 60_000; /** A lost response with the server still there: nothing else would start the lookup. */ const LOOKUP_AGAIN_MS = LOOKUP_WAIT_MS + 1_000; /** A send held by the server for undo, from the outbox: the undo toast as after any send. */ function offerUndo(entry: OutboxEntry, result: SendEmailResult): void { if (!result.emailSubmissionId || !result.sendAt) return; const identityId = entry.item.kind === 'raw' ? entry.item.identityId : (entry.item.args[5] as string | undefined); useEmailStore.setState({ pendingUndoSend: { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: entry.item.kind === 'raw', submissionAccountId: result.submissionAccountId, localAccountId: entry.account, }, }); } /** * What the other areas queued. The page may hold stand-in ids for what just * went out, so it reads again, the way a push makes it. Only the types go by * that way: the "state" isn't one, and nothing stores it. */ const AREA_FLUSHES = [ { call: 'calendar.flush', types: ['Calendar', 'CalendarEvent'] }, { call: 'contacts.flush', types: ['AddressBook', 'ContactCard'] }, ]; /** The page reads these types again. */ function emitTypesChanged(client: NonNullable>, types: string[]): void { const role = (useAuthStore.getState().client as unknown) === client ? 'active' : 'background'; const changed = { [client.getAccountId()]: Object.fromEntries(types.map((type) => [type, 'flushed'])) }; emitPushStateChange({ '@type': 'StateChange', changed } as never, client, role); } async function flushAreas(account: string, client: NonNullable>): Promise { for (const { call, types } of AREA_FLUSHES) { // Contacts may not be there in this build: nothing to flush then. const tally = await getStore().area<{ sent?: number; conflicts?: number | unknown[] } | null>(account, call).catch(() => null); if (!tally) continue; const conflicts = Array.isArray(tally.conflicts) ? tally.conflicts.length : tally.conflicts ?? 0; if (conflicts > 0) notify({ kind: 'conflict', count: conflicts }); if (!tally.sent && !conflicts) continue; emitTypesChanged(client, types); } } export async function flushAll(): Promise { if (useOfflineStore.getState().startedOffline) { void leaveOfflineStart(); return; } // Calendar and contact edits wait in their own areas, so what waits isn't known here. const { outbox: rows, enabled } = useOfflineStore.getState(); if (rows.length === 0 && enabled.length === 0) return; // The organisation's current word before anything goes: a label now // required, a send check now required or offline mode now forbidden must // stop what waited under the old rules. await usePolicyStore.getState().fetchPolicy(); if (usePolicyStore.getState().source !== 'server') { scheduleFlushAll(POLICY_RETRY_MS); return; } enforcePolicy(); const accounts = new Set([...rows.map((r) => r.account), ...useOfflineStore.getState().enabled]); for (const account of accounts) { const client = onlineClientFor(account); if (!client || !offlineActive(account)) continue; await flushQueue(account, client, { notify, allowed: () => offlineAllowed(account), stricterNow, onScheduled: offerUndo, retryLater: () => scheduleFlushAll(LOOKUP_AGAIN_MS), onDraftSent: () => emitTypesChanged(client, ['Email', 'Mailbox', 'Thread']), }).catch(() => {}); await flushAreas(account, client); } await refreshQueueState(); for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 3_000); } // ---- Switching it on and off, wiping ---- export async function enableOffline(account: string): Promise { if (!offlineAllowed(account)) return; const enabled = useOfflineStore.getState().enabled; // Before the list: an account on the list without a choice is one from before the local store. writeChoice(account, { on: true }); if (!enabled.includes(account)) writeEnabled([...enabled, account]); // Ask the browser not to clear it when space runs low. It may say no, which // the settings page shows, with a way to ask again. void requestPersistence(); await getStore().ensureOpen(account).catch(() => {}); await tellServiceWorker(true); scheduleSync(account, 0); } /** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */ export async function disableOffline(account: string): Promise { confirmLoss(account); clearHandoffs(); disabling.add(account); try { writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); await wipeAccount(account); // Remembered where the app would turn it on by itself. if (offlineDefaultOn()) writeChoice(account, { on: false }); } finally { disabling.delete(account); } } /** On their way off: the desktop default doesn't turn them back on meanwhile. */ const disabling = new Set(); /** * Removes everything kept for an account, unsent messages included (the user * confirmed), and its key, and starts again from the server if it stays on. */ export async function clearAccount(account: string): Promise { confirmLoss(account); const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); await nameLost(account, false); named.delete(account); try { await getStore().remove(account); } catch { // No store: nothing to remove. The key is gone in any case. } useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) })); writeHealth(account, { pending: 0, reset: undefined, lastSync: undefined, clearedAt: Date.now() }); useOfflineStore.setState((s) => { const { [account]: _usage, ...usage } = s.usage; const { [account]: _full, ...storageFull } = s.storageFull; const { [account]: _progress, ...progress } = s.progress; const { [account]: _synced, ...lastSyncedAt } = s.lastSyncedAt; return { usage, storageFull, progress, lastSyncedAt }; }); await refreshQueueState(); if (useOfflineStore.getState().enabled.includes(account)) { await getStore().ensureOpen(account).catch(() => {}); scheduleSync(account, 0); } } /** The user changed what an account keeps. Written first, so a reload holds to it. */ export function chooseScope(account: string, patch: UserChoice): void { // Over what's in force: someone from before the local store has a choice nobody wrote down. writeChoice(account, { ...userChoiceFor(account), ...patch }); // Arrowing through a list passes shorter windows on the way: those prune. const waiting = scopeTimers.get(account); if (waiting) clearTimeout(waiting); scopeTimers.set(account, setTimeout(() => { scopeTimers.delete(account); pushScope(account); }, SCOPE_SETTLE_MS)); } /** How long a choice has to stay before the store acts on it. */ const SCOPE_SETTLE_MS = 800; const scopeTimers = new Map>(); /** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */ function pushScope(account: string): void { if (!visibleAccounts().includes(account) || !scopeDecided(account) || !offlineActive(account)) return; const scope: StoreScope = scopeForAccount(account).scope; void getStore().setScope(account, scope).then(() => refreshUsage()).catch(() => {}); } /** The notice about the desktop default was shown on this device. Not account data, so it outlives a sign-out. */ const DEFAULT_ON_NOTICE_KEY = 'bulwark-offline-default-notice'; /** * The desktop app keeps mail for every account without asking, unless the * organisation forbids it or the user turned it off. Says so once. */ function applyDefaultOn(): void { if (!offlineDefaultOn() || !offlineSupported()) return; for (const account of signedInAccountIds()) { if (useOfflineStore.getState().enabled.includes(account) || disabling.has(account) || !scopeDecided(account)) continue; if (!scopeForAccount(account).enabled) continue; void enableOffline(account); try { if (localStorage.getItem(DEFAULT_ON_NOTICE_KEY)) continue; localStorage.setItem(DEFAULT_ON_NOTICE_KEY, '1'); } catch { continue; } notify({ kind: 'default-on' }); } } /** The warning that the store came back empty was read. */ export function dismissStoreReset(account: string): void { useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) })); writeHealth(account, { reset: undefined }); } export { readPersistence, requestPersistence, safariMayDelete }; // ---- Unsent mail a wipe deletes ---- // The user agreed to lose unsent mail (the sign-out question, switching // offline mode off): no word about it afterwards. const confirmed = new Set(); // Already named for this sign-out. const named = new Set(); export function confirmLoss(scope: string): void { confirmed.add(scope); } async function unsentSubjects(scope: string): Promise { const known = useOfflineStore.getState(); let rows: Array<{ account: string; subject: string }> = [...known.outbox, ...known.localDrafts]; // Reading an account's store would create it if it never had one. if (scope !== 'all' && !known.enabled.includes(scope)) return rows.filter((row) => row.account === scope).map((row) => row.subject); let timer: ReturnType | undefined; try { const wanted = scope === 'all' ? undefined : scope; const read = Promise.all([readQueue(wanted), readLocalDrafts(wanted)]); // A wipe and a sign-out wait for this before they delete anything, and a store // with no owner or a silent one takes up to a minute to say so. const answer = await Promise.race([read, new Promise((resolve) => { timer = setTimeout(() => resolve(null), UNSENT_READ_MS); })]); if (answer) { const [{ outbox: entries }, drafts] = answer; rows = [...entries.map((entry) => ({ account: entry.account, subject: entry.summary.subject })), ...drafts]; } } catch { // No store: what the state last saw. } finally { clearTimeout(timer); } return rows.filter((row) => scope === 'all' || row.account === scope).map((row) => row.subject); } /** How long a sign-out waits for the store's word on what would be lost. */ const UNSENT_READ_MS = 2_000; /** * What a sign-out of `scope` would delete: messages, drafts only on this device, * and edits the server hasn't got. A draft a waiting send names isn't listed as a * draft, so it counts once. */ export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> { const shown = useOfflineStore.getState(); const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: scope === 'all' ? Object.values(shown.changesBy).reduce((sum, count) => sum + count, 0) : shown.changesBy[scope] ?? 0 }; const accounts = (scope === 'all' ? visibleAccounts() : [scope]).filter((account) => shown.enabled.includes(account)); if (accounts.length === 0) return known; const pendingIn = (account: string, area: string) => getStore().area(account, `${area}.pending`).catch(() => 0); const read = (async () => { const wanted = scope === 'all' ? undefined : scope; const [{ failed, outbox, patches }, drafts] = await Promise.all([readQueue(wanted), readLocalDrafts(wanted)]); const edits = await Promise.all(accounts.flatMap((account) => [pendingIn(account, 'calendar'), pendingIn(account, 'contacts')])); return { // An account that can't answer counts with what it last said. messages: outbox.length + drafts.length + [...shown.outbox, ...shown.localDrafts].filter((row) => failed.includes(row.account)).length, changes: patches + edits.reduce((sum, count) => sum + count, 0), }; })().catch(() => known); return Promise.race([read, new Promise((resolve) => setTimeout(() => resolve(known), waitMs))]); } /** How long a wipe waits for the count. A wipe's report is the only record of what it deleted. */ export const UNSENT_FOR_WIPE_MS = 10_000; async function unsentOfAccount(account: string, waitMs: number): Promise { const shown = useOfflineStore.getState(); // Reading an account's store would create it if it never had one. if (!shown.enabled.includes(account)) return [...shown.outbox, ...shown.localDrafts].filter((row) => row.account === account).length; let timer: ReturnType | undefined; const read = Promise.all([readQueue(account), readLocalDrafts(account)]) .then(([{ failed, outbox: entries }, drafts]) => (failed.length > 0 ? null : entries.length + drafts.length), () => null); const answer = await Promise.race([read, new Promise((resolve) => { timer = setTimeout(() => resolve(null), waitMs); })]); clearTimeout(timer); return answer; } /** * How many unsent messages a wipe of `scope` deletes, one account or 'all', hidden * accounts included. Null when the store can't say: that isn't the same as none. */ export async function unsentForWipe(scope: string, waitMs = UNSENT_FOR_WIPE_MS): Promise { const accounts = scope === 'all' ? useOfflineStore.getState().enabled : [scope]; const counts = await Promise.all(accounts.map((account) => unsentOfAccount(account, waitMs))); return counts.includes(null) ? null : (counts as number[]).reduce((sum, count) => sum + count, 0); } /** Names the unsent messages a wipe of `scope` deletes, unless the user agreed. */ async function nameLost(scope: string, stillSignedIn: boolean): Promise { if (named.has(scope) || named.has('all')) return; if (confirmed.has(scope) || confirmed.has('all')) { // Agreed once, for this sign-out: the wipes that follow it stay quiet too. named.add(confirmed.has('all') ? 'all' : scope); confirmed.delete(scope); confirmed.delete('all'); return; } const subjects = await unsentSubjects(scope); if (subjects.length === 0) return; named.add(scope); rememberLost(subjects); if (stillSignedIn) notify({ kind: 'lost', subjects }); } export async function wipeAccount(account: string): Promise { clearDesktopSurfaces(); const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); // Waited for a sign-in that never came: whoever is here now mustn't read its subjects. if (isEvicted(account)) { named.add(account); void forgetEvictedSecrets(account).catch(() => {}); } const before = useOfflineStore.getState(); if (before.enabled.includes(account) || before.outbox.some((row) => row.account === account) || before.localDrafts.some((row) => row.account === account)) { await nameLost(account, useAuthStore.getState().isAuthenticated); } named.delete(account); clearEvicted(account); if (useOfflineStore.getState().enabled.includes(account)) { writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); } forgetAccountState(account); // Nobody keeps mail here any more: everything goes (and isn't opened // again, which would create it anew). if (useOfflineStore.getState().enabled.length === 0) { await wipeEverything(); return; } try { await getStore().remove(account); } catch { // No store: nothing to remove. The key is gone in any case. } await refreshQueueState(); await refreshUsage(); } /** Nothing of offline mode stays on this device. */ export async function wipeEverything(): Promise { clearDesktopSurfaces(); for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); // Turned off by hand and still signed in: that stays their word. forgetAllAccountState(signedInAccountIds()); getStore().stop(); await purgeStoreFiles(); try { indexedDB.deleteDatabase(LEGACY_DB_NAME); } catch { /* no IndexedDB */ } await tellServiceWorker(false); await deleteShellCaches(); clearHandoffs(); confirmed.clear(); named.clear(); useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} }); } /** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */ export function scopeDecided(account: string): boolean { const { loaded, source, partial } = usePolicyStore.getState(); // The public part a page gets before it's signed in has no caps and no scopes in it. return loaded && !partial && (source === 'server' || source === 'cache') && policyDecided(account); } /** Whether the policy of an account is known well enough to decide on it. */ function policyDecided(account: string): boolean { // Dropped, with no groups to wait for: what applies to everyone reaches it. if (isEvicted(account)) return true; const auth = useAuthStore.getState(); if (account === auth.activeAccountId || !auth.activeAccountId) { const { subject, subjectPending } = usePolicyStore.getState(); if (subjectPending) return false; // The active account's id is kept across a reload and its subject isn't: until // it's there only Everyone's values show, and a group scope may say otherwise. return !auth.activeAccountId || subject !== null || !(auth.isLoading || auth.client); } // Another signed-in account: its groups have to be known first (a group // scope may forbid it); a signed-out one is wiped with its sign-out. if (!auth.getClientForAccount?.(account)) return false; return !accountSubjectPending(account); } /** The organisation forbids it for an account that has it on: wipe that account. */ export function enforcePolicy(): void { const { loaded } = usePolicyStore.getState(); if (!loaded) return; for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; // What the organisation says, not whether this window can open the store: // a blocked store says nothing against the data in it. if (policyOfKept(account).noOffline) { // Whoever is here now wasn't signed in to a dropped account. const someoneElses = isEvicted(account); clearHandoffs(); void wipeAccount(account); if (!someoneElses) notify({ kind: 'forbidden' }); continue; } // A lowered cap prunes what's kept, and a raised one starts fetching. pushScope(account); } applyDefaultOn(); } // ---- Started without the server ---- const RECONNECT_MS = 30_000; let reconnecting = false; /** * After a start without the server: once the server answers, sign in for * real and replace the device-only clients (no reload). With a message * being written that would close the composer, so the user is asked to * reload instead. */ async function leaveOfflineStart(): Promise { if (reconnecting || !useOfflineStore.getState().startedOffline) return; if (typeof navigator !== 'undefined' && navigator.onLine === false) return; reconnecting = true; try { const accounts = useAccountStore.getState().accounts; const servers = [...new Set(accounts.map((a) => a.serverUrl))]; const reachable = (await Promise.all(servers.map((url) => probeServer(url).then((gone) => !gone)))).some(Boolean); if (!reachable) return; const { useUIStore } = await import('@/stores/ui-store'); if ((useUIStore.getState().composingCount ?? 0) > 0) { notify({ kind: 'back-online-reload' }); return; } const swapped = await useAuthStore.getState().reconnectOfflineClients(); if (swapped) { useOfflineStore.setState({ startedOffline: false }); scheduleFlushAll(1_000); } } finally { reconnecting = false; } } // ---- Starting ---- let started = false; const cleanups: Array<() => void> = []; /** * The JMAP client's hooks. Separate from startOfflineMode because the auth * store needs them before it restores accounts: a start from the local copy * asks the store long before the host component mounts. */ export function registerOfflineHooks(): void { if (!offlineSupported()) return; configureStore(storeHooks); onStoreOwnerChanged((account) => wipeAccount(account)); setOfflineHooks(hooks); setOutboxHooks(outbox); void moveLegacyOutbox(); } /** Runs offline mode in this tab. Safe to call more than once. */ export function startOfflineMode(): () => void { if (!started && !offlineSupported()) void noteStrandedMail(); if (started || !offlineSupported()) return () => {}; started = true; registerOfflineHooks(); const onOnline = () => { useOfflineStore.setState({ browserOffline: false }); void getStore().setOnline(true).catch(() => {}); scheduleFlushAll(500); }; const onOffline = () => { useOfflineStore.setState({ browserOffline: true }); void getStore().setOnline(false).catch(() => {}); }; window.addEventListener('online', onOnline); window.addEventListener('offline', onOffline); cleanups.push(() => { window.removeEventListener('online', onOnline); window.removeEventListener('offline', onOffline); }); // The desktop app going to sleep. Waking comes with the two events above and below. cleanups.push(addSleepHandler(() => { void getStore().setOnline(false).catch(() => {}); })); // Back in front: catch up now rather than at the next interval. const onVisible = () => { if (document.visibilityState !== 'visible') return; for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0); }; document.addEventListener('visibilitychange', onVisible); cleanups.push(() => document.removeEventListener('visibilitychange', onVisible)); // Another tab switched it off for an account (or everyone signed out // there): stop here too, and don't write anything for that account. One // switched on there has to be opened here if this tab owns the store. const onStorage = (event: StorageEvent) => { if (event.key === OFFLINE_SCOPE_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() }); if (event.key === OFFLINE_HEALTH_KEY || event.key === null) { const before = useOfflineStore.getState().health; const health = readStoredHealth(); useOfflineStore.setState({ health }); // Another tab cleared an account that stays on: if this tab owns the store, it opens the new one. for (const id of useOfflineStore.getState().enabled) { if (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) { void getStore().ensureOpen(id).catch(() => {}); void refreshQueueState(); void refreshUsage(); } } } if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return; const next = readEnabledFrom(event.key === null ? null : event.newValue); const before = useOfflineStore.getState().enabled; const removed = before.filter((id) => !next.includes(id)); for (const id of removed) { const timer = syncTimers.get(id); if (timer) clearTimeout(timer); syncTimers.delete(id); } useOfflineStore.setState({ enabled: next }); for (const id of next) if (!before.includes(id)) void getStore().ensureOpen(id).catch(() => {}); void refreshQueueState(); }; window.addEventListener('storage', onStorage); cleanups.push(() => window.removeEventListener('storage', onStorage)); // The organisation's word, on every policy fetch (and the cached copy at // an offline start), and once another account's groups are known. cleanups.push(usePolicyStore.subscribe((state, prev) => { if (state.rawPolicy !== prev.rawPolicy || state.loaded !== prev.loaded || state.subject !== prev.subject || state.subjectPending !== prev.subjectPending) enforcePolicy(); })); cleanups.push(useAccountSubjects.subscribe(() => enforcePolicy())); enforcePolicy(); // An account signed out or removed takes its data with it. One the app // dropped by itself doesn't: its session ran out, or the server can't // confirm it right now, and that says nothing against the mail on this device. cleanups.push(onAccountRemoved((id, reason) => { if (reason === 'explicit') void wipeAccount(id); })); void sweepAccountsGone(); // Also for an app that stays open past the 30 days. const sweeping = setInterval(() => void sweepAccountsGone(), EVICTED_SWEEP_MS); cleanups.push(() => clearInterval(sweeping)); void checkStorageAccess(); // Session, then a sync, for every account signed in with offline mode on. // A client that just became real is what the waiting mail was waiting for: // the flush at the start may have run before it was connected. const flushedFor = new WeakSet(); const syncSignedIn = () => { applyDefaultOn(); let connected = false; for (const account of useOfflineStore.getState().enabled) { const client = onlineClientFor(account); if (!client || !offlineActive(account)) continue; void getStore().ensureOpen(account).then(() => getStore().refresh(account)).catch(() => {}); scheduleSync(account, 3_000); if (!flushedFor.has(client)) { flushedFor.add(client); if (!isOfflineNow(client.getServerUrl())) connected = true; } } if (connected) scheduleFlushAll(1_000); }; cleanups.push(useAuthStore.subscribe((state, prev) => { if (state.client !== prev.client || state.connectedAccountsRevision !== prev.connectedAccountsRevision) syncSignedIn(); })); syncSignedIn(); const interval = setInterval(() => { for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0); }, SYNC_INTERVAL_MS); const reconnect = setInterval(() => { void leaveOfflineStart(); // Nothing else tells a server that was down is back: a client that thinks // it's down has no reason to ask it. A passing probe starts the flush. for (const server of useOfflineStore.getState().downServers) void probeServer(server); }, RECONNECT_MS); cleanups.push(() => { clearInterval(interval); clearInterval(reconnect); if (staleCheck) clearTimeout(staleCheck); staleCheck = null; }); if (useOfflineStore.getState().enabled.length > 0) { void tellServiceWorker(true); void readPersistence().then((state) => { if (state === 'refused' && isStandaloneDisplayMode()) void requestPersistence(); }); } // An installed app is usually told yes where the tab was told no. const onInstalled = () => { if (useOfflineStore.getState().enabled.length > 0) void requestPersistence(); }; window.addEventListener('appinstalled', onInstalled); cleanups.push(() => window.removeEventListener('appinstalled', onInstalled)); void (async () => { await moveLegacyOutbox(); await refreshQueueState(); if (!isOfflineNow()) scheduleFlushAll(2_000); })(); void refreshUsage(); return () => { for (const cleanup of cleanups.splice(0)) cleanup(); setOfflineHooks(null); setOutboxHooks(null); configureStore(null); started = false; }; } /** How often a running app looks whether a dropped account's 30 days are over. */ export const EVICTED_SWEEP_MS = 60 * 60 * 1000; /** * Data no signed-in account vouches for. A dropped account's waits 30 days. One * that left the registry on purpose before this ran (a login without "remember * me" ends at a reload, before the host mounts) goes now, naming lost mail. */ export async function sweepAccountsGone(now = Date.now()): Promise { const evicted = readEvicted(); const accounts = useAccountStore.getState().accounts; for (const account of useOfflineStore.getState().enabled) { const at = evicted[account]; const gone = at !== undefined ? now - at > EVICTED_KEEP_MS : !accounts.some((entry) => entry.id === account); if (gone) await wipeAccount(account).catch(() => {}); } } let staleCheck: ReturnType | null = null; /** A send taken by a tab that closed on the way: nothing else comes back to it. */ function checkStaleSendsLater(): void { if (staleCheck) return; staleCheck = setTimeout(() => { staleCheck = null; void refreshQueueState().then(() => scheduleFlushAll(0)); }, SENDING_STALE_MS + 1_000); } /** Firefox's private windows refuse this at once, where the worker would try for half a minute. */ async function checkStorageAccess(): Promise { try { await navigator.storage.getDirectory(); } catch (error) { if ((error as Error)?.name === 'SecurityError') useOfflineStore.setState({ storageBlocked: true }); } } let strandedNoted = false; /** An earlier version's unsent mail, in a browser that can't run what would move it. */ async function noteStrandedMail(): Promise { if (strandedNoted || IS_LITE || !hasIndexedDb()) return; if ((await strandedLegacyMessages().catch(() => 0)) === 0) return; strandedNoted = true; notify({ kind: 'legacy-stranded' }); } /** How often the store catches up while the tab is open, on top of pushes. */ export const SYNC_INTERVAL_MS = 15 * 60 * 1000; // A full sign-out deletes the store (lib/sign-out-cleanup.ts); forget what // is held in memory as well. onFullSignOut(({ keepLocalStore }) => { for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); if (!keepLocalStore) getStore().stop(); confirmed.clear(); named.clear(); useOfflineStore.setState((s) => ({ enabled: keepLocalStore ? s.enabled : [], choices: keepLocalStore ? s.choices : {}, health: keepLocalStore ? s.health : {}, outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, usage: {}, storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false, keyUnprotected: false, rekey: {}, })); }); // Every sign-out the user didn't agree to lose mail at (the session limits, // a refused token renewal, a removed account) names the unsent messages. onBeforeSignOut(async (accountId, { keepLocalStore }) => { // Nothing is lost: it waits for the next sign-in. if (keepLocalStore) return; const stillSignedIn = accountId !== 'all' && useAccountStore.getState().accounts.some((a) => a.id !== accountId); await nameLost(accountId, stillSignedIn); }); /** Whether the mail server can't be reached: the browser is offline or a probe fails. */ export async function serverUnreachable(serverUrl: string): Promise { if (typeof navigator !== 'undefined' && navigator.onLine === false) return true; return probeServer(serverUrl); } /** How long a start waits for the store before it signs in the usual way. */ const SESSION_READ_TIMEOUT_MS = 5_000; /** The session kept for an account, for starting from the local copy. */ /** A start doesn't hang on a black-holed fetch or a stuck database. */ const BOOT_WAIT_MS = 3_000; function withinBootWait(work: Promise): Promise { return Promise.race([work.catch(() => {}), new Promise((resolve) => setTimeout(resolve, BOOT_WAIT_MS))]); } export async function offlineSessionFor(account: string, options: { withFolders?: boolean } = {}): Promise { // The organisation's last word: the policy kept from the last fetch (the // fetch itself fails without a network), never the defaults. const policy = usePolicyStore.getState(); await withinBootWait(!policy.loaded && !policy.fetching ? policy.fetchPolicy() : whenPolicySettled(BOOT_WAIT_MS)); // Still nothing to go on: the usual start, which has its own errors. if (!usePolicyStore.getState().loaded || !offlineActive(account)) return null; registerOfflineHooks(); try { // What's in the old database goes on moving behind the start. await withinBootWait(moveLegacyOutbox()); return await Promise.race([ (async () => { if (options.withFolders && !(await getStore().area(account, 'mail.hasFolders'))) return null; return getStore().area(account, 'mail.session'); })(), new Promise((resolve) => setTimeout(() => resolve(null), SESSION_READ_TIMEOUT_MS)), ]); } catch { return null; } } export function markStartedOffline(): void { useOfflineStore.setState({ startedOffline: true }); }