From 8d48f982619e514b19b2cba8b8f27a4bb6adef8a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 06:37:08 +0100
Subject: [PATCH] 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.
---
.../__tests__/mail-export-dialog.test.tsx | 14 ++
.../mail-import-dialog-resume.test.tsx | 31 +++
.../__tests__/mail-import-dialog.test.tsx | 4 +
.../mail-transfer/mail-export-dialog.tsx | 2 +-
.../mail-transfer/mail-import-dialog.tsx | 26 +-
.../mail-transfer/mail-transfer-host.tsx | 15 +-
components/mail-transfer/start-export.ts | 19 +-
components/mail-transfer/start-import.ts | 21 +-
lib/__tests__/jmap-mail-transfer.test.ts | 22 ++
lib/jmap/client-interface.ts | 2 +
lib/jmap/client.ts | 22 +-
lib/mail-export/engine.ts | 1 +
lib/mail-import/__tests__/engine.test.ts | 110 ++++++++-
lib/mail-import/checkpoints.ts | 4 +-
lib/mail-import/engine.ts | 227 +++++++++++++-----
lib/mail-import/locks.ts | 36 +++
lib/mail-import/pst-source.ts | 2 +-
lib/mail-import/pst/items.ts | 4 +-
lib/mail-import/pst/worker.ts | 6 +-
lib/mail-import/sources.ts | 17 +-
locales/ar/common.json | 8 +-
locales/ca/common.json | 8 +-
locales/cs/common.json | 8 +-
locales/da/common.json | 8 +-
locales/de/common.json | 8 +-
locales/en/common.json | 8 +-
locales/es/common.json | 8 +-
locales/et/common.json | 8 +-
locales/fa/common.json | 8 +-
locales/fr/common.json | 8 +-
locales/he/common.json | 8 +-
locales/hu/common.json | 8 +-
locales/it/common.json | 8 +-
locales/ja/common.json | 8 +-
locales/ko/common.json | 8 +-
locales/lv/common.json | 8 +-
locales/mn/common.json | 8 +-
locales/nb/common.json | 8 +-
locales/nl/common.json | 8 +-
locales/pl/common.json | 8 +-
locales/pt/common.json | 8 +-
locales/ro/common.json | 8 +-
locales/ru/common.json | 8 +-
locales/sk/common.json | 8 +-
locales/th/common.json | 8 +-
locales/tr/common.json | 8 +-
locales/uk/common.json | 8 +-
locales/vi/common.json | 8 +-
locales/zh-TW/common.json | 8 +-
locales/zh/common.json | 8 +-
stores/mail-transfer-store.ts | 17 +-
51 files changed, 708 insertions(+), 134 deletions(-)
create mode 100644 components/mail-transfer/__tests__/mail-import-dialog-resume.test.tsx
create mode 100644 lib/mail-import/locks.ts
diff --git a/components/mail-transfer/__tests__/mail-export-dialog.test.tsx b/components/mail-transfer/__tests__/mail-export-dialog.test.tsx
index 2b0c24149..53656da6f 100644
--- a/components/mail-transfer/__tests__/mail-export-dialog.test.tsx
+++ b/components/mail-transfer/__tests__/mail-export-dialog.test.tsx
@@ -104,6 +104,20 @@ describe('MailExportDialog', () => {
expect(discard).toHaveBeenCalled();
});
+ it('another account signing out leaves the job alone; its own sign-out stops it with the sign-out reason', async () => {
+ const { runBeforeSignOut } = await import('@/lib/sign-out-cleanup');
+ const controller = new AbortController();
+ render();
+ act(() => {
+ openMailExport({ mailboxId: 'inbox' });
+ useMailTransferStore.getState().startJob({ kind: 'export', controller, scope: 'bulwark-a', finished: Promise.resolve(), progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } });
+ });
+ await act(() => runBeforeSignOut('bulwark-b'));
+ expect(controller.signal.aborted).toBe(false);
+ await act(() => runBeforeSignOut('bulwark-a'));
+ expect(controller.signal.reason).toBe('signout');
+ });
+
it('runs on in the background and the chip names the export', () => {
const controller = new AbortController();
render();
diff --git a/components/mail-transfer/__tests__/mail-import-dialog-resume.test.tsx b/components/mail-transfer/__tests__/mail-import-dialog-resume.test.tsx
new file mode 100644
index 000000000..631ed5dbd
--- /dev/null
+++ b/components/mail-transfer/__tests__/mail-import-dialog-resume.test.tsx
@@ -0,0 +1,31 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
+import { openMailImport } from '@/stores/mail-transfer-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useAuthStore } from '@/stores/auth-store';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// The lookup of an earlier run never answers here.
+vi.mock('@/lib/mail-import/checkpoints', () => ({
+ checkpointKey: () => 'k',
+ readCheckpoint: () => new Promise(() => {}),
+ clearCheckpointsFor: async () => {},
+}));
+
+describe('MailImportDialog and an earlier run', () => {
+ it('keeps Import off until it knows whether the file can be continued', async () => {
+ useEmailStore.setState({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } } as Mailbox] } as never);
+ useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as never, activeAccountId: 'b1' } as never);
+ render();
+ act(() => openMailImport());
+ const input = screen.getByTestId('mail-import-file') as HTMLInputElement;
+ Object.defineProperty(input, 'files', { value: [new File(['From a Tue May 5 10:11:12 2020\n\nb\n'], 'a.mbox')], configurable: true });
+ fireEvent.change(input);
+ await screen.findByText('a.mbox');
+ const start = screen.getByTestId('mail-import-start') as HTMLButtonElement;
+ await waitFor(() => expect(start.disabled).toBe(true));
+ await new Promise((r) => setTimeout(r, 100));
+ expect(start.disabled).toBe(true);
+ });
+});
diff --git a/components/mail-transfer/__tests__/mail-import-dialog.test.tsx b/components/mail-transfer/__tests__/mail-import-dialog.test.tsx
index f191fd56e..ceab01aec 100644
--- a/components/mail-transfer/__tests__/mail-import-dialog.test.tsx
+++ b/components/mail-transfer/__tests__/mail-import-dialog.test.tsx
@@ -48,6 +48,8 @@ describe('MailImportDialog', () => {
pick([new File(['From a Tue May 5 10:11:12 2020\nSubject: x\n\nb\n'], 'old.mbox')]);
await screen.findByText('old.mbox');
expect(screen.getByText(/format_mbox/)).toBeTruthy();
+ // Import waits until it knows whether an earlier run can be continued.
+ await waitFor(() => expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('mail-import-start'));
expect(startImport).toHaveBeenCalledWith(expect.objectContaining({
accountId: 'acc2', targetMailboxId: 'shared', keepFolders: false, resume: false,
@@ -67,6 +69,8 @@ describe('MailImportDialog', () => {
const keep = await screen.findByRole('checkbox', { name: /keep_folders/ });
expect((keep as HTMLInputElement).checked).toBe(true);
fireEvent.change(screen.getByTestId('mail-import-target'), { target: { value: '\u0000top' } });
+ // Import waits until it knows whether an earlier run can be continued.
+ await waitFor(() => expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('mail-import-start'));
expect(startImport).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'acc1', targetMailboxId: null, keepFolders: true }));
});
diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx
index 20a46d437..af90c91a9 100644
--- a/components/mail-transfer/mail-export-dialog.tsx
+++ b/components/mail-transfer/mail-export-dialog.tsx
@@ -96,7 +96,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
footer={active ? (
<>
-
+
>
) : (
<>
diff --git a/components/mail-transfer/mail-import-dialog.tsx b/components/mail-transfer/mail-import-dialog.tsx
index f6cdfd8dd..aa057ae52 100644
--- a/components/mail-transfer/mail-import-dialog.tsx
+++ b/components/mail-transfer/mail-import-dialog.tsx
@@ -65,6 +65,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
const [keepFolders, setKeepFolders] = useState(true);
const [resume, setResume] = useState(true);
const [resumable, setResumable] = useState(0);
+ const [checking, setChecking] = useState(false);
const anyFolders = picked.some((p) => p.folders);
const targetMailbox = targets.find((x) => x.mailbox.id === target)?.mailbox;
@@ -86,12 +87,16 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
// An earlier import of the same file into the same place that stopped part way.
useEffect(() => {
let current = true;
- if (picked.length === 0 || !accountId) { setResumable(0); return; }
+ if (picked.length === 0 || !accountId) { setResumable(0); setChecking(false); return; }
+ // Import waits for this: starting before it knows would throw the checkpoint away.
+ setChecking(true);
void Promise.all(picked.map((p) => readCheckpoint(checkpointKey({
scope: activeAccountId ?? "default", accountId, target: targetId, keepFolders: effectiveKeep, file: p.file,
})))).then((found) => {
- if (current) setResumable(found.reduce((n, c) => n + (c?.done ?? 0), 0));
- });
+ if (!current) return;
+ setResumable(found.reduce((n, c) => n + (c?.done ?? 0) + (c?.ahead?.length ?? 0), 0));
+ setChecking(false);
+ }, () => { if (current) setChecking(false); });
return () => { current = false; };
}, [picked, accountId, targetId, effectiveKeep, activeAccountId]);
@@ -136,7 +141,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
footer={active ? (
<>
-
+
>
) : (
@@ -147,7 +152,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
);
}
- const canStart = picked.length > 0 && !!accountId && !(target === TOP && !effectiveKeep);
+ const canStart = picked.length > 0 && !!accountId && !checking && !(target === TOP && !effectiveKeep);
return (