Files
webmail/lib/offline/migrate-legacy.ts
T
Linus Rath e0abc7fa18 chore: merge the reviewed store, pop-outs and desktop core into the settings branch
Eviction now goes by the base's signal: `storeLost` and the `store-reset`
notice, with `bulwark-store-opened` as the marker and `prune-files` for files
with no key. The storage page and the lost count hang off that. What a user
picks is stored under `bulwark-offline-scope`, the key `offlineScopeFor`
reads, and `email-snapshot` follows the base's rule. `OfflineEnabled` is read
from the desktop bridge when it's there.
2026-11-03 10:40:47 +01:00

215 lines
8.1 KiB
TypeScript

/**
* One time, on the first start after the update: what can't be fetched again
* moves from the old IndexedDB `bulwark-offline` into the local store. That's
* the outbox, the changes that wait, the files they hold and the session kept
* for a start without the server. The mail itself syncs again.
*
* The old database holds its own key, so it goes only after the store has
* committed every account. A move that fails leaves it in place and the next
* start tries again; the store takes each account's part only once.
*/
import { generateMessageId } from '@/lib/jmap/client';
import { getStore } from '@/lib/store/client/api';
import type { StoreSession } from '@/lib/store/engine/transport';
import type { LegacyImport } from '@/lib/store/outbox/calls';
import type { OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem } from '@/lib/store/outbox/items';
import { LEGACY_CHOICE } from './scope';
import { useOfflineStore, writeChoice } from './state';
export const LEGACY_DB_NAME = 'bulwark-offline';
interface Sealed {
iv: Uint8Array;
data: ArrayBuffer;
}
interface QueueRecord extends Sealed {
id: number;
account: string;
kind: 'patch' | 'send';
createdAt: number;
status?: string;
}
interface UploadRecord extends Sealed {
key: string;
account: string;
meta: Sealed;
}
interface SendPayload {
summary: OutboxSummary;
item: SendItem;
fingerprint?: PolicyFingerprint;
error?: OutboxError;
}
function result<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** Opens the old database only if it exists: opening it must never create it. */
function openLegacy(): Promise<IDBDatabase | null> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(LEGACY_DB_NAME);
let creating = false;
request.onupgradeneeded = () => {
creating = true;
request.transaction?.abort();
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
if (creating) resolve(null);
else reject(request.error);
};
request.onblocked = () => resolve(null);
});
}
function deleteLegacy(): Promise<void> {
return new Promise((resolve) => {
const request = indexedDB.deleteDatabase(LEGACY_DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
// An old tab still has it open: it goes when that tab does.
request.onblocked = () => resolve();
});
}
function storeNamesHold(db: IDBDatabase, ...names: string[]): boolean {
return names.every((name) => db.objectStoreNames.contains(name));
}
async function open(key: CryptoKey, sealed: Sealed): Promise<ArrayBuffer> {
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.iv as BufferSource }, key, sealed.data);
}
async function openJson<T>(key: CryptoKey, sealed: Sealed): Promise<T> {
return JSON.parse(new TextDecoder().decode(await open(key, sealed))) as T;
}
/** The Message-ID of a finished raw message, from its head. */
function rawMessageId(bytes: Uint8Array): string | undefined {
const head = new TextDecoder().decode(bytes.subarray(0, 64 * 1024));
const end = head.search(/\r?\n\r?\n/);
const block = (end >= 0 ? head.slice(0, end) : head).replace(/\r?\n[ \t]+/g, ' ');
return block.match(/^message-id:\s*<([^>\s]+)>/im)?.[1];
}
interface AccountPart extends LegacyImport {
session: StoreSession | null;
}
async function readAccount(db: IDBDatabase, key: CryptoKey | null, account: string): Promise<AccountPart> {
const part: AccountPart = { entries: [], patches: [], files: [], session: null };
if (!key) return part;
const stores = db.transaction(['queue', 'uploads', 'misc'], 'readonly');
const [queue, uploads, session] = await Promise.all([
result(stores.objectStore('queue').index('account').getAll(account)) as Promise<QueueRecord[]>,
result(stores.objectStore('uploads').index('account').getAll(account)) as Promise<UploadRecord[]>,
result(stores.objectStore('misc').get(`${account}\u0001misc\u0001session`)) as Promise<Sealed | undefined>,
]);
if (session) part.session = await openJson<StoreSession>(key, session).catch(() => null);
const rawIds = new Map<string, string | undefined>();
for (const record of uploads) {
// Sealed with a key that is gone, as the old code skipped it.
try {
const meta = await openJson<{ name?: string; type?: string }>(key, record.meta);
const data = new Uint8Array(await open(key, record));
const blobId = record.key.split('\u0001')[1];
part.files.push({ blobId, name: meta.name || 'attachment', type: meta.type || 'application/octet-stream', data });
rawIds.set(blobId, rawMessageId(data));
} catch {
continue;
}
}
// In the order they were made.
for (const record of [...queue].sort((a, b) => a.id - b.id)) {
try {
if (record.kind === 'patch') {
const item = await openJson<{ jmap: string; update: Record<string, Record<string, unknown>> }>(key, record);
part.patches.push({ jmap: item.jmap, update: item.update });
continue;
}
const payload = await openJson<SendPayload>(key, record);
// Sent by a tab that's gone: it may have left, so it's looked up or asked about.
const status = (record.status === 'sending' ? 'uncertain' : record.status ?? 'waiting') as OutboxStatus;
const item = payload.item;
let messageId: string | undefined;
if (item.kind === 'raw') messageId = rawIds.get(item.blobId);
// Not sent yet: it can still get a fixed id. One that left under the old
// code had another id, which nothing here knows.
// Only its domain is used, and the sender may be written "Name <address>".
else if (status === 'waiting' && typeof item.args[6] === 'string') messageId = generateMessageId(item.args[6].replace(/>.*$/, ''));
part.entries.push({
summary: payload.summary,
item,
fingerprint: payload.fingerprint,
error: payload.error,
status,
createdAt: record.createdAt,
messageId,
});
} catch {
continue;
}
}
return part;
}
let moving: Promise<void> | null = null;
async function moveNow(): Promise<void> {
if (typeof indexedDB === 'undefined') return;
const db = await openLegacy();
if (!db) return;
try {
if (!storeNamesHold(db, 'keys', 'queue', 'uploads', 'misc')) return;
const keyRecord = await result(db.transaction('keys', 'readonly').objectStore('keys').get('aes')) as { key: CryptoKey } | undefined;
const key = keyRecord?.key ?? null;
for (const account of useOfflineStore.getState().enabled) {
// Keeps the window it had: a choice now, so a later default can't change it.
if (!useOfflineStore.getState().choices[account]) writeChoice(account, LEGACY_CHOICE);
const part = await readAccount(db, key, account);
const { session, ...content } = part;
if (session) await getStore().ensureOpen(account, session);
if (content.entries.length + content.patches.length + content.files.length === 0) continue;
await getStore().area(account, 'outbox.importLegacy', content);
}
} finally {
db.close();
}
await deleteLegacy();
}
/** Runs the move once per page. A failure is kept for nobody to see: the old database stays and the next start tries again. */
export function moveLegacyOutbox(): Promise<void> {
moving ??= moveNow().catch(() => {});
return moving;
}
/** Messages an earlier version left in its database. For a browser the store can't run in, which can't move them. */
export async function strandedLegacyMessages(): Promise<number> {
if (typeof indexedDB === 'undefined') return 0;
const db = await openLegacy().catch(() => null);
if (!db) return 0;
try {
if (!storeNamesHold(db, 'queue')) return 0;
const records = await result(db.transaction('queue', 'readonly').objectStore('queue').getAll()) as Array<{ kind?: string }>;
return records.filter((record) => record.kind !== 'patch').length;
} finally {
db.close();
}
}
/** Tests only: forget that this page already ran it. */
export function forgetLegacyMove(): void {
moving = null;
}