Files
webmail/lib/mail-import/sources.ts
T
Linus Rath 8d48f98261 fix: keep failed messages for the next import run and stop an import that can't go on
A message that failed for a passing reason (a dropped connection, a
server error, an expired token) used to count as done, so continuing
the import skipped it for good, and a slow upload followed by a cancel
imported the messages after it twice.

- The checkpoint keeps every message that is done, also past a gap,
  and the next run of the same import tries only the rest again.
- Ten failures in a row, a full mailbox (overQuota) or a refusal
  (forbidden) stop the import with a message instead of going through
  the whole file.
- An upload without an answer pauses the import instead of failing the
  message: 30 seconds, then until the upload window ends.
- Email/import, the export's Blob/get and folder listing pass the
  client's own 429 pause by, and a 429 on them doesn't pause the rest
  of the app.
- Signing out stops the job of that account only, writes no checkpoint
  and imports nothing more afterwards; another account's sign-out
  leaves the job alone.
- One tab imports a file at a time (Web Locks), and Import waits until
  the dialog knows whether an earlier run can be continued.

The import dialog tests now wait for the Import button to be enabled
before clicking it, since it stays off during that lookup.
2026-11-01 06:37:08 +01:00

298 lines
12 KiB
TypeScript

import { dateFromHeaderBlock } from "@/lib/eml-import";
import { MboxSplitter, envelopeDate, readHeaderBlock, type MboxMessage } from "./mbox";
import { mboxState, withoutStateHeaders } from "./mbox-status";
import { listZipEntries, readZipEntry, streamZipEntry } from "./zip-reader";
/**
* What the import reads from each file: messages with their folder, state and
* date, in a stable order (the index the checkpoint counts), read piece by
* piece so a large file never has to fit in memory.
*/
export type ImportFormat = "eml" | "zip" | "mbox" | "pst";
export type SkipReason =
/** Larger than the server takes (maxSizeUpload). */
| "too_large"
/** Deleted in Thunderbird, still in the file until the folder is compacted. */
| "deleted"
/** PST contacts and distribution lists. */
| "contact"
/** PST appointments, meeting requests and tasks. */
| "calendar"
/** Other PST items (notes, journal entries) and files in a ZIP that aren't mail. */
| "other"
/** Messages attached to a PST message, which pst-extractor can't read. */
| "embedded"
/** Imported by an earlier run of the same import. */
| "resumed";
export interface ImportMessage {
kind: "message";
index: number;
/** Folder path inside the file; [] is the top. */
folder: string[];
data: Blob;
size: number;
keywords: Record<string, true>;
receivedAt?: string;
/** Names the message in the summary when it fails: its subject or entry name. */
label: string;
/** Messages attached to this one that couldn't be read. */
embeddedSkipped?: number;
/** The special folder (inbox, sent, drafts, trash, junk) the top folder of `folder` is, when the file says so. */
folderRole?: string;
}
export interface ImportSkip {
kind: "skipped";
index: number;
reason: SkipReason;
label?: string;
}
export type ImportItem = ImportMessage | ImportSkip;
export interface SourceOptions {
/** Messages larger than this are skipped as too_large without reading them in. */
maxMessageBytes: number;
/** Items before this index were handled by an earlier run: report them as `resumed`. */
skipBefore: number;
/** Items after `skipBefore` an earlier run handled too (it stopped with gaps). */
skipAlso?: Set<number>;
/** Called as the file is read: the part done, 0 to 1. */
onProgress?: (fraction: number) => void;
/** Called once the number of messages in the file is known. */
onTotal?: (count: number) => void;
signal?: AbortSignal;
}
export type ItemSource = AsyncGenerator<ImportItem, void, undefined>;
/** Whether an earlier run of the same import handled item `index`. */
export function isResumed(options: Pick<SourceOptions, "skipBefore" | "skipAlso">, index: number): boolean {
return index < options.skipBefore || !!options.skipAlso?.has(index);
}
const READ_CHUNK = 1 << 20;
const MIME = "message/rfc822";
const PST_MAGIC = [0x21, 0x42, 0x44, 0x4e]; // "!BDN"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04];
const ZIP_EMPTY = [0x50, 0x4b, 0x05, 0x06];
function startsWith(head: Uint8Array, magic: number[]): boolean {
return magic.every((b, i) => head[i] === b);
}
/** The format of a file, from its first bytes; the name only breaks ties. */
export async function sniffFormat(file: Blob & { name?: string }): Promise<ImportFormat> {
const head = new Uint8Array(await file.slice(0, 512).arrayBuffer());
if (startsWith(head, PST_MAGIC)) return "pst";
if (startsWith(head, ZIP_MAGIC) || startsWith(head, ZIP_EMPTY)) return "zip";
// mbox opens with an envelope line, maybe after a byte order mark or empty lines.
let at = head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
while (head[at] === 0x0d || head[at] === 0x0a) at++;
const first = String.fromCharCode(...head.subarray(at, at + 5));
if (first === "From ") return "mbox";
if (/\.(mbox|mbx)$/i.test(file.name ?? "")) return "mbox";
return "eml";
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("Import cancelled", "AbortError");
}
function emlLabel(name: string): string {
return name.split(/[\\/]/).pop() || name;
}
const encodedWord = /=\?([^?]+)\?([bq])\?([^?]*)\?=/gi;
/** The Subject of a header block, for the summary; encoded words decoded when possible. */
export function subjectOf(headerBlock: string): string {
const m = /^subject:[ \t]*(.*(?:\r?\n[ \t].*)*)/im.exec(headerBlock);
if (!m) return "";
const raw = m[1].replace(/\r?\n[ \t]+/g, " ").trim();
return raw.replace(/\?=\s+=\?/g, "?==?").replace(encodedWord, (all, charset: string, enc: string, text: string) => {
try {
const bytes = enc.toLowerCase() === "b"
? Uint8Array.from(atob(text), (c) => c.charCodeAt(0))
: Uint8Array.from(text.replace(/_/g, " ").replace(/=([0-9a-f]{2})/gi, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
return new TextDecoder(charset).decode(bytes);
} catch {
return all;
}
}).slice(0, 200);
}
export async function* emlSource(file: File, options: SourceOptions): ItemSource {
options.onTotal?.(1);
if (isResumed(options, 0)) {
yield { kind: "skipped", index: 0, reason: "resumed" };
options.onProgress?.(1);
return;
}
if (file.size > options.maxMessageBytes) {
yield { kind: "skipped", index: 0, reason: "too_large", label: file.name };
options.onProgress?.(1);
return;
}
const head = new TextDecoder("latin1").decode(await file.slice(0, 64 * 1024).arrayBuffer());
yield {
kind: "message",
index: 0,
folder: [],
data: file.slice(0, file.size, MIME),
size: file.size,
// Thunderbird writes X-Mozilla-Status into the .eml files it saves; none means read, as before.
keywords: mboxState(head.split(/\r?\n\r?\n/, 1)[0]).keywords,
receivedAt: dateFromHeaderBlock(head),
label: emlLabel(file.name),
};
options.onProgress?.(1);
}
/** A date between 1980 and a day from now, else undefined. */
function plausible(date: string | undefined, now = Date.now()): string | undefined {
if (!date) return undefined;
const t = Date.parse(date);
return t >= Date.UTC(1980, 0, 1) && t - now <= 24 * 60 * 60 * 1000 ? date : undefined;
}
function mboxItem(message: MboxMessage, index: number, folder: string[]): ImportItem {
const label = message.tooLarge ? message.fromLine : "";
if (message.tooLarge) return { kind: "skipped", index, reason: "too_large", label };
const headers = readHeaderBlock(message.parts);
const state = mboxState(headers);
const subject = subjectOf(headers);
if (state.expunged) return { kind: "skipped", index, reason: "deleted", label: subject };
const parts = withoutStateHeaders(message.parts);
return {
kind: "message",
index,
folder,
data: new Blob(parts as BlobPart[], { type: MIME }),
size: parts.reduce((n, p) => n + p.length, 0),
keywords: state.keywords,
// The envelope line holds when the message arrived (Bulwark's export writes
// receivedAt there); the Date header, when it was written, comes second.
receivedAt: plausible(envelopeDate(message.fromLine)) ?? dateFromHeaderBlock(headers),
label: subject,
};
}
/** Splits chunks into messages; `startIndex` continues the numbering inside a ZIP. */
async function* mboxFromChunks(
chunks: AsyncIterable<Uint8Array>,
folder: string[],
options: SourceOptions,
startIndex: number,
onIndex: (next: number) => void,
/** Called with each message's offset in the stream, for a progress that moves message by message. */
onOffset?: (offset: number) => void,
): ItemSource {
const splitter = new MboxSplitter({ maxMessageBytes: options.maxMessageBytes });
let index = startIndex;
const emit = function* (messages: MboxMessage[]): Generator<ImportItem> {
for (const m of messages) {
onOffset?.(m.offset);
const i = index++;
yield isResumed(options, i) ? { kind: "skipped", index: i, reason: "resumed" } : mboxItem(m, i, folder);
}
};
for await (const chunk of chunks) {
throwIfAborted(options.signal);
yield* emit(splitter.push(chunk));
}
yield* emit(splitter.end());
onIndex(index);
}
async function* fileChunks(file: Blob, onProgress?: (fraction: number) => void): AsyncGenerator<Uint8Array> {
for (let at = 0; at < file.size; at += READ_CHUNK) {
const end = Math.min(file.size, at + READ_CHUNK);
yield new Uint8Array(await file.slice(at, end).arrayBuffer());
onProgress?.(end / Math.max(1, file.size));
}
}
export async function* mboxSource(file: File, options: SourceOptions): ItemSource {
// Reads run ahead in 1 MB pieces; the messages handed over say how far the import really is.
yield* mboxFromChunks(fileChunks(file), [], options, 0, () => {}, (offset) => options.onProgress?.(offset / Math.max(1, file.size)));
options.onProgress?.(1);
}
/** `Archive/2020.mbox` → ["Archive", "2020"]; `Inbox/a.eml` → ["Inbox"]. */
function zipFolder(name: string, isMbox: boolean): string[] {
const parts = name.split("/").filter((p) => p && p !== "." && p !== "..");
if (isMbox) {
const last = parts.pop() ?? "";
const folder = last.replace(/\.(mbox|mbx)$/i, "");
return folder ? [...parts, folder] : parts;
}
parts.pop();
return parts;
}
export async function* zipSource(file: File, options: SourceOptions): ItemSource {
const entries = (await listZipEntries(file)).filter((e) => !e.directory && !/(^|\/)(__MACOSX|\.DS_Store)/.test(e.name));
const isMbox = (name: string) => /\.(mbox|mbx)$/i.test(name);
const isEml = (name: string) => /\.eml$/i.test(name);
if (!entries.some((e) => isMbox(e.name))) options.onTotal?.(entries.filter((e) => isEml(e.name)).length);
const totalBytes = entries.reduce((n, e) => n + e.compressedSize, 0) || 1;
let doneBytes = 0;
let index = 0;
for (const entry of entries) {
throwIfAborted(options.signal);
if (isMbox(entry.name)) {
const before = doneBytes;
const chunks = (async function* () {
let seen = 0;
for await (const chunk of streamZipEntry(file, entry)) {
seen += chunk.length;
options.onProgress?.((before + entry.compressedSize * Math.min(1, seen / Math.max(1, entry.size))) / totalBytes);
yield chunk;
}
})();
yield* mboxFromChunks(chunks, zipFolder(entry.name, true), options, index, (next) => { index = next; });
} else if (isEml(entry.name)) {
const i = index++;
if (isResumed(options, i)) {
yield { kind: "skipped", index: i, reason: "resumed" };
} else if (entry.size > options.maxMessageBytes) {
yield { kind: "skipped", index: i, reason: "too_large", label: emlLabel(entry.name) };
} else {
const bytes = await readZipEntry(file, entry);
const head = new TextDecoder("latin1").decode(bytes.subarray(0, 64 * 1024));
yield {
kind: "message",
index: i,
folder: zipFolder(entry.name, false),
data: new Blob([bytes as BlobPart], { type: MIME }),
size: bytes.length,
keywords: mboxState(head.split(/\r?\n\r?\n/, 1)[0]).keywords,
receivedAt: dateFromHeaderBlock(head),
label: emlLabel(entry.name),
};
}
} else {
const i = index++;
yield isResumed(options, i) ? { kind: "skipped", index: i, reason: "resumed" } : { kind: "skipped", index: i, reason: "other", label: emlLabel(entry.name) };
}
doneBytes += entry.compressedSize;
options.onProgress?.(doneBytes / totalBytes);
}
}
/** Whether the file has folders of its own, so the dialog offers to recreate them. */
export async function hasFolders(file: File, format: ImportFormat): Promise<boolean> {
if (format === "pst") return true;
if (format !== "zip") return false;
try {
return (await listZipEntries(file)).some((e) => e.name.includes("/") || /\.(mbox|mbx)$/i.test(e.name));
} catch {
return false;
}
}