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 (