Files
webmail/lib/mail-import/dedupe.ts
T
Linus Rath b92b488e39 fix: stop a local import losing messages on a held file and name what a resume leaves out
Fixes from the review of the desktop import.

- A message another program holds is no longer recorded as deleted. Only a
  real absence moves the import past it; anything else is tried again and
  then reported as a failure, and the next run reads it.
- A resume that can't read some folders names them in a toast with a choice
  to stop trying, keeps the job open and retries only those at the next start.
  Files chosen by hand in the same dialog are named, since they can't come
  back by themselves.
- A wipe or sign-out releases the job in the shell before the record goes.
  Leaving the dialog without starting releases the confirmed files. Job
  records are per account, and a start is refused while another window holds
  that account's job.
- Dedupe goes by the bytes without state headers: a reused Message-ID of the
  same length dropped a real message. Older records still count, so nothing
  goes in twice.
- A lost connection on a folder or Email/import pauses instead of counting
  towards the failures that stop an import.
- The taskbar goes through `lib/desktop/taskbar-progress.ts`.

Test changes: the taskbar test now renders the host, because its helper in
`local-import-job.ts` is gone, and the dedupe tests use `check`, which replaced
`keyFor` and `has`.
2026-11-03 19:17:38 +01:00

90 lines
3.8 KiB
TypeScript

import { done, hasIdb, openDb, seenInMemory, SEEN_STORE } from "./checkpoints";
import { withoutStateHeaders } from "./mbox-status";
import type { ImportMessage } from "./sources";
/**
* Remembers what an import put on the server, per folder, so a second run skips it.
* Email/import doesn't skip duplicates and a checkpoint counts positions in one file as
* it was: a running Thunderbird compacts a folder or adds to it, the file gets a new size
* and date, the checkpoint no longer applies, and the file starts over. The same message
* into another folder is not a duplicate: Thunderbird can have a copy in both.
*
* Only hashes are kept. A message is known by its bytes without the state headers, which
* are all compacting changes. Going by its Message-ID would drop a real message: mailers
* reuse one, say a reminder sent again with the same length and another Date.
*/
export interface ImportDedupe {
/** The key to remember this message by, and whether an earlier import already did. */
check(item: Pick<ImportMessage, "data" | "size">, mailboxId: string): Promise<{ key: string; seen: boolean }>;
add(keys: string[]): Promise<void>;
}
const HEADER_BYTES = 64 * 1024;
/** The Message-ID of a message's header block, without the brackets, or null. */
export function messageIdOf(headerBlock: string): string | null {
const headers = headerBlock.split(/\r?\n\r?\n/, 1)[0];
const match = /^message-id:[ \t]*(?:\r?\n[ \t]+)?<([^>\r\n]+)>/im.exec(headers);
return match ? match[1].trim() : null;
}
async function sha256(data: BufferSource): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function localDedupe(scope: string, accountId: string): ImportDedupe {
const keyOf = (mailboxId: string, hash: string) => [scope, accountId, mailboxId, hash].join("\u0000");
const has = async (key: string): Promise<boolean> => {
if (seenInMemory.has(key)) return true;
if (!hasIdb()) return false;
try {
const db = await openDb();
return (await done(db.transaction(SEEN_STORE, "readonly").objectStore(SEEN_STORE).getKey(key))) !== undefined;
} catch {
return false;
}
};
const add = async (keys: string[]): Promise<void> => {
if (!hasIdb()) {
for (const key of keys) seenInMemory.set(key, scope);
return;
}
try {
const db = await openDb();
const store = db.transaction(SEEN_STORE, "readwrite").objectStore(SEEN_STORE);
await Promise.all(keys.map((key) => done(store.put({ key, scope }))));
} catch {
for (const key of keys) seenInMemory.set(key, scope);
}
};
/**
* What a record from before this format is under, so nothing already imported goes in
* again. It can't be turned into the new form: the bytes aren't stored. A hit moves the
* message to the new key, so a real message is only at risk from a record that old.
*/
const legacyKey = async (bytes: ArrayBuffer, size: number, mailboxId: string): Promise<string> => {
const id = messageIdOf(new TextDecoder("latin1").decode(bytes.slice(0, HEADER_BYTES)));
return keyOf(mailboxId, id ? await sha256(new TextEncoder().encode(`id:${id}:${size}`)) : await sha256(bytes));
};
return {
async check(item, mailboxId) {
const bytes = await item.data.arrayBuffer();
const stripped = await new Blob(withoutStateHeaders([new Uint8Array(bytes)]) as BlobPart[]).arrayBuffer();
const key = keyOf(mailboxId, `v2:${await sha256(stripped)}`);
if (await has(key)) return { key, seen: true };
if (await has(await legacyKey(bytes, item.size, mailboxId))) {
await add([key]);
return { key, seen: true };
}
return { key, seen: false };
},
add,
};
}