Files
webmail/lib/mail-import/pst-source.ts
T
Linus Rath 084f93a786 feat: import Thunderbird and Outlook data from this computer in the desktop app
The import dialog in the desktop app can now find Thunderbird profiles and
Outlook PST files, show what it found and import what the user confirms.
Browsers behave as before.

- The bridge's `localImport` gives opaque handles and range URLs. The shell
  asks natively before it looks, before it reads and before it goes on after
  a restart.
- `lib/mail-import` gets a Maildir source and a per-file `folder`, so
  Thunderbird's `.sbd` tree maps onto the account's folders. The PST worker
  reads through a synchronous range XHR when there is no Blob.
- What was imported is remembered by Message-ID and size per folder, because
  compacting a folder changes its checkpoint key and Email/import doesn't
  dedupe. It runs from the dialog and on a resume.
- The job's record lives in IndexedDB, since Chromium writes localStorage too
  late to survive a kill. Windows wait for the job's lock, so one takes over
  when another goes.
- The desktop dialog says how long an import takes at the upload limit, warns
  when Thunderbird is running, and turns down .ost files with the reason.

The engine used to be able to lose an import while offline: uploadBlob kept
the file on this device under a stand-in id and reported success. It now
passes `noHold`, Email/import is network only, an offline spell pauses without
using up a message's tries, and a device-only client stops at once. The same
fix applies to imports in the browser.
2026-11-03 17:57:54 +01:00

130 lines
5.2 KiB
TypeScript

import { withBasePath } from "@/lib/browser-navigation";
import { LITE_BUILD_ID } from "@/lib/lite";
import type { PstItem } from "./pst/items";
import type { ImportItem, ItemSource, SourceOptions } from "./sources";
/**
* Reads an Outlook .pst/.ost file through the PST worker (public/pst-worker.js,
* built by scripts/build-pst-worker.mjs), which is only fetched now. pst-extractor
* is synchronous, so it can't run on the page; the worker reads the file in
* pieces and hands over a few messages at a time as the import asks for them.
*/
/** Errors carry a code the dialog explains: encrypted, not_pst, unreadable, worker. */
export class PstReadError extends Error {
constructor(readonly code: string, message: string) {
super(`pst:${code}: ${message}`);
this.name = "PstReadError";
}
}
/**
* The worker's URL. Its name has no hash, so the build goes in the query:
* Stalwart serves Lite's files as immutable, and an old worker must not
* meet a new page.
*/
export function pstWorkerUrl(): string {
const build = LITE_BUILD_ID || `${process.env.NEXT_PUBLIC_APP_VERSION ?? "0"}-${process.env.NEXT_PUBLIC_GIT_COMMIT ?? "dev"}`;
return withBasePath(`/pst-worker.js?v=${encodeURIComponent(build)}`);
}
/** A PstItem as posted: the MIME bytes arrive as a transferred ArrayBuffer. */
type WireItem =
| (Omit<Extract<PstItem, { kind: "message" }>, "mime"> & { mime: ArrayBuffer })
| Extract<PstItem, { kind: "skipped" }>;
type WorkerMessage =
| { type: "opened"; total: number }
| { type: "item"; item: WireItem; fraction: number }
| { type: "done" }
| { type: "error"; code: string; message: string }
| { type: "abort" };
/** Messages asked for ahead of the import, so the worker keeps busy without filling memory. */
const AHEAD = 4;
/** How long the worker may stay silent while a message is awaited before the import gives up (it may have run out of memory). */
const WATCHDOG_MS = 120_000;
export async function* openPstSource(file: File, options: SourceOptions, tuning: { watchdogMs?: number } = {}): ItemSource {
const worker = new Worker(pstWorkerUrl());
const queue: WorkerMessage[] = [];
let wake: (() => void) | null = null;
const push = (message: WorkerMessage) => {
queue.push(message);
wake?.();
};
worker.onmessage = (event: MessageEvent) => push(event.data as WorkerMessage);
worker.onerror = (event: ErrorEvent) => {
event.preventDefault?.();
push({ type: "error", code: "worker", message: event.message || "The PST reader didn't start" });
};
const onAbort = () => push({ type: "abort" });
options.signal?.addEventListener("abort", onAbort, { once: true });
// A worker killed for memory sends no error: don't wait for it forever.
const next = async (): Promise<WorkerMessage> => {
while (queue.length === 0) {
let timer: ReturnType<typeof setTimeout> | undefined;
await new Promise<void>((resolve) => {
wake = resolve;
timer = setTimeout(() => push({ type: "error", code: "unreadable", message: "The PST reader stopped answering" }), tuning.watchdogMs ?? WATCHDOG_MS);
});
clearTimeout(timer);
}
wake = null;
return queue.shift()!;
};
try {
// A file the desktop app reads from disk has no Blob: the worker reads it by range.
const rangeUrl = (file as File & { rangeUrl?: string }).rangeUrl;
const source = rangeUrl ? { range: { url: rangeUrl, size: file.size } } : { file };
worker.postMessage({ type: "open", ...source, skipBefore: options.skipBefore, skipAlso: [...(options.skipAlso ?? [])], maxMessageBytes: options.maxMessageBytes });
const opened = await next();
if (opened.type === "abort") throw new DOMException("Import cancelled", "AbortError");
if (opened.type === "error") throw new PstReadError(opened.code, opened.message);
if (opened.type !== "opened") throw new PstReadError("unreadable", "unexpected reply");
options.onTotal?.(opened.total);
let asked = AHEAD;
worker.postMessage({ type: "pull", count: AHEAD });
for (;;) {
const message = await next();
if (message.type === "abort") throw new DOMException("Import cancelled", "AbortError");
if (message.type === "error") throw new PstReadError(message.code, message.message);
if (message.type === "done") break;
if (message.type !== "item") continue;
asked--;
if (asked < AHEAD / 2) {
worker.postMessage({ type: "pull", count: AHEAD });
asked += AHEAD;
}
options.onProgress?.(message.fraction);
const item = message.item;
let out: ImportItem;
if (item.kind === "message") {
const mime = item.mime;
out = {
kind: "message",
index: item.index,
folder: item.folder,
data: new Blob([mime], { type: "message/rfc822" }),
size: mime.byteLength,
keywords: item.keywords,
receivedAt: item.receivedAt,
label: item.label,
embeddedSkipped: item.embeddedSkipped,
folderRole: item.folderRole,
};
} else {
out = item;
}
yield out;
}
options.onProgress?.(1);
} finally {
options.signal?.removeEventListener("abort", onAbort);
worker.terminate();
}
}