diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml index d3c299c52..1ff1264e4 100644 --- a/.github/workflows/perf-guard.yml +++ b/.github/workflows/perf-guard.yml @@ -41,7 +41,7 @@ jobs: DEV_MOCK_JMAP: "true" NEXT_TELEMETRY_DISABLED: "1" GIT_COMMIT: ${{ github.sha }} - run: node scripts/build-plugin-sandbox.mjs && npx next build --webpack + run: node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs && npx next build --webpack - name: Run perf guard env: diff --git a/.gitignore b/.gitignore index a67d67b1c..24d233b38 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ /lite-image/ # scripts/build-plugin-sandbox.mjs /public/plugin-sandbox-assets/ +# scripts/build-pst-worker.mjs +/public/pst-worker.js # production /build diff --git a/Dockerfile b/Dockerfile index 515c30937..7485ced40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN rm -rf "app/api/dev-jmap" && \ find . -path ./node_modules -prune -o \ \( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \ -prune -exec rm -rf {} \; && \ + node scripts/build-pst-worker.mjs && \ node scripts/build-plugin-sandbox.mjs && \ npx next build --webpack diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 7a3c9e3e2..41be291a3 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -20,6 +20,7 @@ import { AccessibilityQuickPanelHost } from "@/components/accessibility/accessib import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; +import { MailTransferHost } from "@/components/mail-transfer/mail-transfer-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PluginAppEvents } from "@/components/providers/plugin-app-events"; import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; @@ -90,6 +91,7 @@ export default async function LocaleLayout({ + diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 6fc55b0ec..ae8b25a55 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,7 +3,7 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; -import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; +import { openMailImport } from "@/stores/mail-transfer-store"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { mailLanguage } from "@/lib/mail-language"; @@ -1309,6 +1309,7 @@ export function EmailViewer({ const archiveActionEnabled = useArchiveActionEnabled(); const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); + const tImport = useTranslations('mail_import'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); @@ -3619,52 +3620,9 @@ export function EmailViewer({ await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined }); }; - // Import email from .eml file or .zip archive containing .eml files + // Import mail into the open folder: the import dialog (lib/mail-import) does the rest. const handleImportEmail = () => { - if (!client) return; - const input = document.createElement('input'); - input.type = 'file'; - input.accept = EML_IMPORT_ACCEPT; - input.multiple = true; - input.onchange = async (e) => { - const files = Array.from((e.target as HTMLInputElement).files ?? []); - if (files.length === 0) return; - const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState(); - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - const mailboxId = mailbox?.originalId || selectedMailbox; - if (!mailboxId) { - toast.error(tNotifications('import_email_error')); - return; - } - - let emails; - try { - emails = await expandImportableEmails(files); - } catch { - toast.error(tNotifications('import_email_error')); - return; - } - - let imported = 0; - let failed = 0; - for (const { blob, receivedAt } of emails) { - try { - await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true }, undefined, receivedAt); - imported++; - } catch { - failed++; - } - } - - if (imported > 0) { - toast.success(tNotifications('import_email_success')); - await fetchEmails(client); - } - if (failed > 0 || emails.length === 0) { - toast.error(tNotifications('import_email_error')); - } - }; - input.click(); + openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined }); }; // Print only the email content in a new window. A print asked for from @@ -4145,7 +4103,7 @@ export function EmailViewer({ ...(effectiveEmailContent.isHtml ? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }] : []), - { kind: 'item', key: 'extra:import', icon: Upload, label: t('import_email'), run: handleImportEmail }, + { kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail }, ...(onShowShortcuts && !isMobile ? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }] : []), diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 0b739879a..3a2569113 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -21,6 +21,7 @@ import { FolderX, RefreshCw, Upload, + Download, Users, EyeOff, Bell, @@ -28,6 +29,7 @@ import { RotateCcw, } from "@/components/icons"; import { FolderRulesSubMenu } from "@/components/email/rules-menu"; +import { openMailExport } from "@/stores/mail-transfer-store"; interface Position { x: number; @@ -140,6 +142,8 @@ export function MailboxContextMenu({ notifications, }: MailboxContextMenuProps) { const t = useTranslations("mailbox_context_menu"); + const tImport = useTranslations("mail_import"); + const tExport = useTranslations("mail_export"); const handleAction = (action: () => void) => { action(); @@ -317,10 +321,17 @@ export function MailboxContextMenu({ handleAction(() => onImportEmail?.(mailbox.id))} disabled={!onImportEmail || !canAddItems} /> + handleAction(() => openMailExport({ mailboxId: mailbox.id }))} + disabled={mailbox.totalEmails === 0 || mailbox.myRights?.mayReadItems === false} + testId="mailbox-export" + /> ({ startExport: (...args: unknown[]) => startExport(...args) })); + +const box = (id: string, name: string, extra: Partial = {}): Mailbox => ({ + id, name, sortOrder: 0, totalEmails: 3, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, + myRights: {} as Mailbox['myRights'], ...extra, +}); + +beforeEach(() => { + startExport.mockReset(); + useMailTransferStore.setState({ request: null, visible: false, job: null }); + useEmailStore.setState({ mailboxes: [box('inbox', 'Inbox', { role: 'inbox' }), box('acc2:shared', 'Team', { originalId: 'shared', accountId: 'acc2', isShared: true })] } as never); + useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as never, username: 'alice@example.org' } as never); +}); + +afterEach(() => { + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; +}); + +describe('MailExportDialog', () => { + it('asks where to save first where the browser can, and exports a shared folder from its own account', async () => { + const writable = { write: vi.fn(), close: vi.fn(), abort: vi.fn() }; + const picker = vi.fn(async () => ({ createWritable: async () => writable })); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker; + render(); + act(() => openMailExport({ mailboxId: 'acc2:shared' })); + expect(screen.getByText('title_folder')).toBeTruthy(); + expect(screen.getByText('save_picker')).toBeTruthy(); + fireEvent.click(screen.getByTestId('mail-export-start')); + await waitFor(() => expect(startExport).toHaveBeenCalled()); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Team.mbox' })); + expect(startExport).toHaveBeenCalledWith(expect.objectContaining({ format: 'mbox', accountId: 'acc2', mailboxId: 'shared' })); + expect(startExport.mock.calls[0][0].sink.kind).toBe('picker'); + }); + + it('does nothing when the save picker is closed', async () => { + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = vi.fn(async () => { throw new DOMException('closed', 'AbortError'); }); + render(); + act(() => openMailExport({ mailboxId: 'inbox' })); + fireEvent.click(screen.getByTestId('mail-export-start')); + await waitFor(() => expect((screen.getByTestId('mail-export-start') as HTMLButtonElement).disabled).toBe(false)); + expect(startExport).not.toHaveBeenCalled(); + }); + + it('exports the whole account as a ZIP of .eml files into memory where nothing else exists', async () => { + render(); + act(() => openMailExport()); + expect(screen.getByText('title_account')).toBeTruthy(); + expect(screen.getByText('save_memory')).toBeTruthy(); + fireEvent.click(screen.getByTestId('mail-export-format-eml')); + fireEvent.click(screen.getByTestId('mail-export-start')); + await waitFor(() => expect(startExport).toHaveBeenCalled()); + const input = startExport.mock.calls[0][0]; + expect(input).toMatchObject({ format: 'eml', accountId: 'acc1', mailboxId: undefined }); + expect(input.sink.kind).toBe('memory'); + }); + + it('offers the file again when done and frees it on Close', async () => { + const discard = vi.fn(async () => {}); + const download = vi.fn(); + render(); + act(() => { + openMailExport({ mailboxId: 'inbox' }); + useMailTransferStore.getState().startJob({ + kind: 'export', controller: new AbortController(), + progress: { phase: 'done', exported: 3, failed: 0, failures: [], total: 3, fraction: 1, file: { name: 'Inbox.mbox', size: 1200, download, discard } }, + }); + }); + expect(screen.getByTestId('mail-export-status').getAttribute('aria-live')).toBe('polite'); + fireEvent.click(screen.getByTestId('mail-export-download')); + expect(download).toHaveBeenCalled(); + const closes = screen.getAllByRole('button', { name: 'close' }); + fireEvent.click(closes[closes.length - 1]); + expect(discard).toHaveBeenCalled(); + expect(useMailTransferStore.getState().request).toBeNull(); + }); + + it('signing out stops a running export, or frees a finished one\'s file', async () => { + const { runBeforeSignOut } = await import('@/lib/sign-out-cleanup'); + const controller = new AbortController(); + render(); + act(() => { + openMailExport({ mailboxId: 'inbox' }); + useMailTransferStore.getState().startJob({ kind: 'export', controller, progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } }); + }); + await act(() => runBeforeSignOut('acc1')); + expect(controller.signal.aborted).toBe(true); + + const discard = vi.fn(async () => {}); + act(() => { + useMailTransferStore.setState({ request: null, visible: false, job: null }); + openMailExport({ mailboxId: 'inbox' }); + useMailTransferStore.getState().startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'done', exported: 3, failed: 0, failures: [], total: 3, fraction: 1, file: { name: 'a.mbox', size: 1, discard } } }); + }); + await act(() => runBeforeSignOut('acc1')); + 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(); + act(() => { + openMailExport({ mailboxId: 'inbox' }); + useMailTransferStore.getState().startJob({ kind: 'export', controller, progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } }); + }); + fireEvent.click(screen.getByRole('button', { name: 'background' })); + expect(screen.getByTestId('mail-transfer-chip').textContent).toContain('chip'); + expect(controller.signal.aborted).toBe(false); + }); + + it('keeps the running count out of the live region', () => { + render(); + act(() => { + openMailExport({ mailboxId: 'inbox' }); + useMailTransferStore.getState().startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } }); + }); + expect(screen.getByTestId('mail-export-status').textContent).toBe('running_title'); + expect(screen.getByTestId('mail-export-count').textContent).toBe('status_exported'); + }); + + it('says the export stays in memory where the private file system can\'t write files', () => { + vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => ({}) } }); + vi.stubGlobal('FileSystemFileHandle', class {}); + try { + render(); + act(() => openMailExport()); + expect(screen.getByText('save_memory')).toBeTruthy(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); 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 new file mode 100644 index 000000000..7efef411c --- /dev/null +++ b/components/mail-transfer/__tests__/mail-import-dialog.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host'; +import { useMailTransferStore, 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'; +import type { ImportProgress } from '@/lib/mail-import/engine'; + +const startImport = vi.fn(); +vi.mock('@/components/mail-transfer/start-import', () => ({ startImport: (...args: unknown[]) => startImport(...args) })); + +const box = (id: string, name: string, extra: Partial = {}): Mailbox => ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, + myRights: { mayAddItems: true } as Mailbox['myRights'], ...extra, +}); + +const progress = (extra: Partial = {}): ImportProgress => ({ + phase: 'running', imported: 3, failed: 0, skipped: {}, failures: [], fraction: 0.4, foldersCreated: 0, ...extra, +}); + +beforeEach(() => { + startImport.mockReset(); + useMailTransferStore.setState({ request: null, visible: false, job: null }); + useEmailStore.setState({ + mailboxes: [ + box('inbox', 'Inbox', { role: 'inbox' }), + box('acc2:shared', 'Team', { originalId: 'shared', accountId: 'acc2', isShared: true, accountName: 'team@example.org' }), + box('ro', 'Read only', { myRights: { mayAddItems: false } as Mailbox['myRights'] }), + ], + } as never); + useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as never, activeAccountId: 'bulwark-1' } as never); +}); + +function pick(files: File[]) { + const input = screen.getByTestId('mail-import-file') as HTMLInputElement; + Object.defineProperty(input, 'files', { value: files, configurable: true }); + fireEvent.change(input); +} + +describe('MailImportDialog', () => { + it('imports into a shared folder in that folder\'s own account', async () => { + render(); + act(() => openMailImport({ mailboxId: 'acc2:shared' })); + expect(screen.getByRole('dialog')).toBeTruthy(); + // Folders the user can't add to aren't offered. + expect(screen.queryByRole('option', { name: 'Read only' })).toBeNull(); + 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, + files: [expect.objectContaining({ format: 'mbox' })], + })); + }); + + it('offers to keep a ZIP\'s folders and the top level as target', async () => { + const { ZipWriter } = await import('@/lib/mail-export/zip-writer'); + const parts: Uint8Array[] = []; + const z = new ZipWriter(async (c) => { parts.push(c.slice()); }); + await z.addFile('Archive/a.eml', new TextEncoder().encode('Subject: a\r\n\r\nA')); + await z.finish(); + render(); + act(() => openMailImport()); + pick([new File(parts as BlobPart[], 'export.zip')]); + 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 })); + }); + + it('loads the folders when opened from Settings before the mail view did, then picks the Inbox', async () => { + const fetchMailboxes = vi.fn(async () => {}); + const loaded = useEmailStore.getState().mailboxes; + useEmailStore.setState({ mailboxes: [], fetchMailboxes } as never); + render(); + act(() => openMailImport()); + expect(fetchMailboxes).toHaveBeenCalled(); + act(() => useEmailStore.setState({ mailboxes: loaded } as never)); + await waitFor(() => expect((screen.getByTestId('mail-import-target') as HTMLSelectElement).value).toBe('inbox')); + }); + + it('cannot start without files', () => { + render(); + act(() => openMailImport()); + expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(true); + }); + + it('shows a pause in a live region, cancels on Cancel import, and hides on Escape without cancelling', async () => { + const controller = new AbortController(); + render(); + act(() => { + openMailImport(); + useMailTransferStore.getState().startJob({ kind: 'import', controller, progress: progress({ phase: 'paused', pausedUntil: Date.now() + 3600_000, quota: { files: 1000, bytes: 50_000_000 } }) }); + }); + const status = screen.getByTestId('mail-import-status'); + expect(status.getAttribute('aria-live')).toBe('polite'); + expect(status.textContent).toBe('paused'); + expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('40'); + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(controller.signal.aborted).toBe(false); + // The chip brings it back. + fireEvent.click(screen.getByTestId('mail-transfer-chip')); + expect(screen.getByRole('dialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'cancel_import' })); + expect(controller.signal.aborted).toBe(true); + }); + + it('opening another import while one runs shows the running one', () => { + const controller = new AbortController(); + render(); + act(() => { + openMailImport(); + useMailTransferStore.getState().startJob({ kind: 'import', controller, progress: progress() }); + useMailTransferStore.getState().hide(); + }); + act(() => openMailImport({ mailboxId: 'inbox' })); + expect(screen.getByRole('heading', { name: 'running_title' })).toBeTruthy(); + }); + + it('says when a hidden import ends, with a way back to its summary', async () => { + const { useToastStore } = await import('@/stores/toast-store'); + useToastStore.setState({ toasts: [] }); + render(); + act(() => { + openMailImport(); + useMailTransferStore.getState().startJob({ kind: 'import', controller: new AbortController(), progress: progress() }); + useMailTransferStore.getState().hide(); + }); + act(() => useMailTransferStore.getState().updateProgress(progress({ phase: 'done', imported: 12, failed: 1 }))); + const toastShown = useToastStore.getState().toasts.find((x) => x.title === 'toast_done'); + expect(toastShown?.action?.label).toBe('toast_show'); + act(() => toastShown!.action!.onClick()); + expect(screen.getByRole('heading', { name: 'done_title' })).toBeTruthy(); + }); + + it('keeps the focus in the dialog when Import turns into the progress view and then the summary', async () => { + render(); + act(() => openMailImport()); + (document.activeElement as HTMLElement | null)?.blur(); + act(() => useMailTransferStore.getState().startJob({ kind: 'import', controller: new AbortController(), progress: progress() })); + await waitFor(() => expect(document.activeElement?.textContent).toBe('background')); + act(() => useMailTransferStore.getState().updateProgress(progress({ phase: 'done' }))); + await waitFor(() => expect(document.activeElement?.textContent).toBe('close')); + }); + + it('lists what was skipped and what failed when it ends', () => { + render(); + act(() => { + openMailImport(); + useMailTransferStore.getState().startJob({ + kind: 'import', controller: new AbortController(), + progress: progress({ phase: 'done', imported: 10, failed: 1, failures: [{ label: 'Hello', reason: 'invalid' }], skipped: { deleted: 2, too_large: 1 } }), + }); + }); + expect(screen.getByText('done_title')).toBeTruthy(); + expect(screen.getByText('skip_too_large')).toBeTruthy(); + expect(screen.getByText('skip_deleted')).toBeTruthy(); + expect(screen.getByText('Hello: failure_invalid')).toBeTruthy(); + // The footer's Close (the X in the corner is named the same). + const closes = screen.getAllByRole('button', { name: 'close' }); + fireEvent.click(closes[closes.length - 1]); + expect(useMailTransferStore.getState().job).toBeNull(); + }); +}); diff --git a/components/mail-transfer/__tests__/transfer-progress.test.ts b/components/mail-transfer/__tests__/transfer-progress.test.ts new file mode 100644 index 000000000..0701429fc --- /dev/null +++ b/components/mail-transfer/__tests__/transfer-progress.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { serverMegabytes } from '@/components/mail-transfer/transfer-progress'; + +describe('serverMegabytes', () => { + it('names Stalwart\'s upload quota as the server does', () => { + expect(serverMegabytes(50_000_000)).toBe('50 MB'); + }); +}); diff --git a/components/mail-transfer/mail-backup-settings.tsx b/components/mail-transfer/mail-backup-settings.tsx new file mode 100644 index 000000000..facc6e6e9 --- /dev/null +++ b/components/mail-transfer/mail-backup-settings.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { SettingItem, SettingsSection } from "@/components/settings/settings-section"; +import { Button } from "@/components/ui/button"; +import { openMailExport, openMailImport } from "@/stores/mail-transfer-store"; + +/** Settings > Backup & reset > Mail files: bring mail in from files, and save all of it as one file. */ +export function MailBackupSettings() { + const tImport = useTranslations("mail_import"); + const tExport = useTranslations("mail_export"); + return ( + + + + + + + + + ); +} diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx new file mode 100644 index 000000000..c3e7b6b99 --- /dev/null +++ b/components/mail-transfer/mail-export-dialog.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useId, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useEmailStore } from "@/stores/email-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store"; +import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine"; +import { safeName } from "@/lib/mail-export/engine"; +import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks"; +import { formatFileSize } from "@/lib/utils"; +import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress"; +import { startExport } from "./start-export"; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Where the file will go in this browser, said before the export starts. */ +function saveMode(): "picker" | "download" | "memory" { + if (canPickSaveFile()) return "picker"; + return canWritePrivateFile() ? "download" : "memory"; +} + +export function MailExportDialog({ request }: { request: ExportRequest }) { + const t = useTranslations("mail_export"); + const locale = useLocale(); + const id = useId(); + const visible = useMailTransferStore((s) => s.visible); + const job = useMailTransferStore((s) => s.job); + const close = useMailTransferStore((s) => s.close); + const hide = useMailTransferStore((s) => s.hide); + const cancelJob = useMailTransferStore((s) => s.cancelJob); + const mailboxes = useEmailStore((s) => s.mailboxes); + const client = useAuthStore((s) => s.client); + const username = useAuthStore((s) => s.username); + const [format, setFormat] = useState("mbox"); + const [starting, setStarting] = useState(false); + const mode = saveMode(); + + const mailbox = request.mailboxId ? mailboxes.find((m) => m.id === request.mailboxId) : undefined; + const folder = !!request.mailboxId; + const progress = job?.kind === "export" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); + + if (!visible) return null; + + const onStart = async () => { + if (!client || starting) return; + if (folder && !mailbox) return; + const zip = !folder || format === "eml"; + const base = folder ? safeName(mailbox!.name, "mail") : `${safeName(username ?? "mail", "mail")}-${today()}`; + const name = `${base}.${zip ? "zip" : "mbox"}`; + const mime = zip ? "application/zip" : "application/mbox"; + setStarting(true); + let sink: ExportSink | null = null; + try { + // The save picker must open straight from the click. + if (mode === "picker") { + try { + sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox"); + } catch (error) { + if ((error as { name?: string } | null)?.name === "AbortError") return; + sink = null; + } + } + if (!sink) { + await sweepPrivateExports(); + sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime); + } + startExport({ + format, + accountId: mailbox?.accountId || client.getAccountId(), + mailboxId: mailbox ? mailbox.originalId || mailbox.id : undefined, + sink, + }); + } finally { + setStarting(false); + } + }; + + const onClose = () => (active ? hide() : close()); + + if (progress) { + return ( + + + + + ) : ( + <> + {progress.phase === "done" && progress.file?.download && ( + + )} + + + )} + > + + + ); + } + + const formats: { value: ExportFormat; label: string; hint: string }[] = [ + { value: "mbox", label: folder ? t("format_mbox_folder") : t("format_mbox_account"), hint: t("hint_mbox") }, + { value: "eml", label: folder ? t("format_eml_folder") : t("format_eml_account"), hint: t("hint_eml") }, + ]; + + return ( + close()} + size="full-on-phone" + title={folder ? t("title_folder", { name: mailbox?.name ?? "" }) : t("title_account")} + description={folder ? t("description_folder") : t("description_account")} + testId="mail-export-dialog" + footer={( + <> + + + + )} + > +
+
+ {t("format_label")} +
+ {formats.map((f) => ( + + ))} +
+
+

{t(`save_${mode}`)}

+
+
+ ); +} + +type T = ReturnType; + +function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { + const running = p.phase === "running" || p.phase === "paused"; + // The live region says only what changes the user's plans (paused, done); + // the count beside it would otherwise be read out several times a second. + let status: string; + if (p.phase === "paused" && p.pausedUntil) status = t("paused", { time: formatClock(p.pausedUntil, locale) }); + else if (running) status = t("running_title"); + else status = t("summary_exported", { count: p.exported }); + const tooLarge = p.phase === "failed" && /larger than this browser can keep in memory/.test(p.error ?? ""); + return ( +
+ {running && 0 ? p.fraction : null} label={t("progress_label")} paused={p.phase === "paused"} />} + {running &&

{t("status_exported", { count: p.exported, total: p.total })}

} +

{status}

+ {p.phase === "done" && p.file && ( +

{t("summary_file", { name: p.file.name, size: formatFileSize(p.file.size) })}

+ )} + {p.failed > 0 && ( +
+

{t("summary_failed", { count: p.failed })}

+
    + {p.failures.map((f, i) =>
  • {f.label || t("untitled")}: {f.reason}
  • )} +
+
+ )} + {p.phase === "failed" &&

{tooLarge ? t("too_large") : p.error}

} +
+ ); +} diff --git a/components/mail-transfer/mail-import-dialog.tsx b/components/mail-transfer/mail-import-dialog.tsx new file mode 100644 index 000000000..f196ca97c --- /dev/null +++ b/components/mail-transfer/mail-import-dialog.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; +import { FileArchive, FileText, Upload, X } from "@/components/icons"; +import { useEmailStore } from "@/stores/email-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { isJobActive, useMailTransferStore, type ImportRequest } from "@/stores/mail-transfer-store"; +import { checkpointKey, readCheckpoint } from "@/lib/mail-import/checkpoints"; +import { hasFolders, sniffFormat, type ImportFormat, type SkipReason } from "@/lib/mail-import/sources"; +import type { ImportProgress } from "@/lib/mail-import/engine"; +import type { Mailbox } from "@/lib/jmap/types"; +import { formatFileSize, getMailboxPath } from "@/lib/utils"; +import { formatClock, serverMegabytes, TransferBar, useFocusOnStage } from "./transfer-progress"; +import { startImport } from "./start-import"; + +const ACCEPT = ".eml,.zip,.mbox,.mbx,.pst,.ost,message/rfc822,application/zip,application/mbox"; +const TOP = "\u0000top"; +const SKIP_ORDER: SkipReason[] = ["too_large", "deleted", "embedded", "unreadable", "contact", "calendar", "other"]; + +interface Picked { + file: File; + format: ImportFormat; + folders: boolean; +} + +function mailboxValue(m: Mailbox): string { + return m.id; +} + +export function MailImportDialog({ request }: { request: ImportRequest }) { + const t = useTranslations("mail_import"); + const locale = useLocale(); + const id = useId(); + const visible = useMailTransferStore((s) => s.visible); + const job = useMailTransferStore((s) => s.job); + const close = useMailTransferStore((s) => s.close); + const hide = useMailTransferStore((s) => s.hide); + const cancelJob = useMailTransferStore((s) => s.cancelJob); + const mailboxes = useEmailStore((s) => s.mailboxes); + const client = useAuthStore((s) => s.client); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const fileInput = useRef(null); + const chooseButton = useRef(null); + + const targets = useMemo( + () => mailboxes + .filter((m) => m.myRights?.mayAddItems !== false) + .map((m) => ({ + mailbox: m, + label: (m.isShared && m.accountName ? `${m.accountName} › ` : "") + getMailboxPath(m, mailboxes), + })) + .sort((a, b) => Number(!!a.mailbox.isShared) - Number(!!b.mailbox.isShared) || a.label.localeCompare(b.label)), + [mailboxes], + ); + const initialTarget = request.mailboxId && targets.some((x) => x.mailbox.id === request.mailboxId) + ? request.mailboxId + : targets.find((x) => x.mailbox.role === "inbox" && !x.mailbox.isShared)?.mailbox.id ?? targets[0]?.mailbox.id ?? TOP; + + const [picked, setPicked] = useState([]); + const [target, setTarget] = useState(initialTarget); + 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; + const accountId = targetMailbox?.accountId || client?.getAccountId() || ""; + const targetId = target === TOP ? null : targetMailbox ? targetMailbox.originalId || targetMailbox.id : null; + const effectiveKeep = anyFolders && keepFolders; + + // Opened from Settings, the mail view may not have loaded the folders yet. + useEffect(() => { + if (client && mailboxes.length === 0) void useEmailStore.getState().fetchMailboxes(client).catch(() => {}); + }, [client, mailboxes.length]); + + // Keep the choice valid when the folders arrive or the files change. + useEffect(() => { + const valid = target === TOP ? anyFolders : targets.some((x) => x.mailbox.id === target); + if (!valid && target !== initialTarget) setTarget(initialTarget); + }, [targets, target, anyFolders, initialTarget]); + + // 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); 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) 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]); + + const addFiles = async (list: FileList | null) => { + const files = Array.from(list ?? []); + const next = await Promise.all(files.map(async (file) => { + const format = await sniffFormat(file); + return { file, format, folders: await hasFolders(file, format) }; + })); + setPicked((prev) => [...prev, ...next.filter((n) => !prev.some((p) => p.file.name === n.file.name && p.file.size === n.file.size))]); + }; + + const formatName = (format: ImportFormat) => t(`format_${format}`); + const progress = job?.kind === "import" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); + + const onStart = () => { + if (picked.length === 0 || !accountId) return; + if (target === TOP && !effectiveKeep) return; + startImport({ + files: picked.map((p) => ({ file: p.file, format: p.format })), + accountId, + targetMailboxId: targetId, + keepFolders: effectiveKeep, + resume: resume && resumable > 0, + }); + }; + + const onClose = () => (active ? hide() : close()); + + if (!visible) return null; + + if (progress) { + return ( + + + + + ) : ( + + )} + > + + + ); + } + + const canStart = picked.length > 0 && !!accountId && !checking && !(target === TOP && !effectiveKeep); + + return ( + close()} + size="full-on-phone" + title={t("title")} + description={t("description")} + testId="mail-import-dialog" + initialFocus={chooseButton} + footer={( + <> + + + + )} + > +
+
+ { void addFiles(e.target.files); e.target.value = ""; }} + /> + + {picked.length > 0 && ( +
    + {picked.map((p, i) => ( +
  • + {p.format === "eml" ?
  • + ))} +
+ )} +
+ +
+ + +
+ + {anyFolders && ( + + )} + + {resumable > 0 && ( + + )} + + {picked.some((p) => p.format === "pst") &&

{t("pst_note")}

} +

{t("quota_note")}

+
+
+ ); +} + +type T = ReturnType; + +const PST_ERRORS = ["encrypted", "not_pst", "unreadable", "worker"]; +const IMPORT_ERRORS = ["over_quota", "forbidden", "failures", "network", "busy"]; + +/** Why an import stopped, in words; anything else as the server or browser said it. */ +function errorText(t: T, error: string): string { + const pst = /^pst:(\w+):/.exec(error)?.[1]; + if (pst && PST_ERRORS.includes(pst)) return t(`pst_error_${pst}`); + const own = /^import:(\w+)$/.exec(error)?.[1]; + if (own && IMPORT_ERRORS.includes(own)) return t(`error_${own}`); + return error; +} + +function progressTitle(t: T, p: ImportProgress): string { + if (p.phase === "done") return t("done_title"); + if (p.phase === "cancelled") return t("cancelled_title"); + if (p.phase === "failed") return t("failed_title"); + return t("running_title"); +} + +function ProgressBody({ progress: p, t, locale, statusId }: { progress: ImportProgress; t: T; locale: string; statusId: string }) { + const running = p.phase === "running" || p.phase === "paused"; + const skipped = SKIP_ORDER.filter((r) => (p.skipped[r] ?? 0) > 0); + let status: string; + if (p.phase === "paused" && p.pausedUntil) { + const time = formatClock(p.pausedUntil, locale); + status = p.quota?.files && p.quota.bytes + ? t("paused", { time, files: p.quota.files, size: serverMegabytes(p.quota.bytes) }) + : t("paused_generic", { time }); + } else if (running) { + status = p.fileName ? t("reading_file", { name: p.fileName }) : t("running_title"); + } else { + status = t("summary_imported", { count: p.imported }); + } + return ( +
+ {running && } +

+ {running ? t("status_imported", { count: p.imported }) : null} +

+

+ {status} +

+ {!running && ( +
+ {p.foldersCreated > 0 &&

{t("summary_folders", { count: p.foldersCreated })}

} + {skipped.length > 0 && ( +
+

{t("summary_skipped")}

+
    + {skipped.map((r) =>
  • {t(`skip_${r}`, { count: p.skipped[r] ?? 0 })}
  • )} +
+
+ )} + {p.failed > 0 && ( +
+

{t("summary_failed", { count: p.failed })}

+
    + {p.failures.map((f, i) => ( +
  • {f.label || t("untitled")}: {f.reason === "invalid" ? t("failure_invalid") : f.reason}
  • + ))} +
+
+ )} + {p.phase === "cancelled" &&

{t("cancelled_hint")}

} + {p.phase === "done" && (p.retryable ?? 0) > 0 &&

{t("retry_hint")}

} + {p.phase === "failed" && p.error &&

{errorText(t, p.error)}

} +
+ )} +
+ ); +} diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx new file mode 100644 index 000000000..981fb2bdf --- /dev/null +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Loader2, Pause } from "@/components/icons"; +import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store"; +import { onBeforeSignOut } from "@/lib/sign-out-cleanup"; +import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints"; +import { sweepPrivateExports } from "@/lib/mail-export/sinks"; +import { useAuthStore } from "@/stores/auth-store"; +import { toast } from "@/stores/toast-store"; +import { MailImportDialog } from "./mail-import-dialog"; +import { MailExportDialog } from "./mail-export-dialog"; +import { formatClock } from "./transfer-progress"; + +/** How long sign-out waits for a cancelled job to stop (its budget is 2 s). */ +const SIGN_OUT_WAIT_MS = 1500; + +/** + * Draws the mail import and export dialogs, and while it is hidden with a job running, + * a chip in the corner that shows the progress and brings it back. Mounted + * with the app, so a job outlives the menu or settings page that started it. + */ +export function MailTransferHost() { + const request = useMailTransferStore((s) => s.request); + const visible = useMailTransferStore((s) => s.visible); + const job = useMailTransferStore((s) => s.job); + + // An account that signs out takes its running job, a finished export's + // file in the browser's private storage, and its checkpoints with it. + // Another account signing out leaves the job alone. The job's checkpoint + // goes after the job has stopped, so a late write can't bring it back. + useEffect(() => onBeforeSignOut(async (accountId) => { + const store = useMailTransferStore.getState(); + const job = store.job; + if (job && (accountId === "all" || !job.scope || job.scope === accountId)) { + if (isJobActive(job)) { + store.cancelJob("signout"); + await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]); + } + useMailTransferStore.getState().close({ now: true }); + } + if (accountId !== "all") await clearCheckpointsFor(accountId); + }), []); + + // Export files a closed tab or a crash left in the browser's private + // storage go when the app starts and when someone signs in. + const activeAccountId = useAuthStore((s) => s.activeAccountId); + useEffect(() => { + if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports(); + }, [activeAccountId]); + + // A job that ends while its dialog is hidden says so, with a way back to its summary. + const tImport = useTranslations("mail_import"); + const tExport = useTranslations("mail_export"); + const lastPhase = useRef(undefined); + useEffect(() => { + const phase = job?.progress.phase; + const was = lastPhase.current; + lastPhase.current = phase; + if (!job || visible || (was !== "running" && was !== "paused")) return; + if (phase !== "done" && phase !== "failed") return; + const action = { label: (job.kind === "import" ? tImport : tExport)("toast_show"), onClick: () => useMailTransferStore.getState().show() }; + if (job.kind === "import") { + if (phase === "done") toast.success(tImport("toast_done", { imported: job.progress.imported, failed: job.progress.failed }), { action }); + else toast.warning(tImport("toast_stopped"), { action }); + } else if (phase === "done") { + toast.success(tExport("toast_done", { name: job.progress.file?.name ?? "" }), { action }); + } else { + toast.warning(tExport("toast_stopped"), { action }); + } + // The translators are new functions on every render in tests; the phase is what matters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [job, visible]); + + return ( + <> + {request?.kind === "import" && } + {request?.kind === "export" && } + {!visible && isJobActive(job) && } + + ); +} + +function TransferChip() { + const tImport = useTranslations("mail_import"); + const tExport = useTranslations("mail_export"); + const locale = useLocale(); + const job = useMailTransferStore((s) => s.job); + const show = useMailTransferStore((s) => s.show); + if (!job) return null; + const t = job.kind === "export" ? tExport : tImport; + const p = job.progress; + const paused = p.phase === "paused" && p.pausedUntil; + const label = paused + ? t("chip_paused", { time: formatClock(p.pausedUntil!, locale) }) + : t("chip", { percent: Math.round(p.fraction * 100) }); + return ( + + ); +} diff --git a/components/mail-transfer/start-export.ts b/components/mail-transfer/start-export.ts new file mode 100644 index 000000000..70960474a --- /dev/null +++ b/components/mail-transfer/start-export.ts @@ -0,0 +1,63 @@ +import { runExport, folderTree, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine"; +import type { ExportSink } from "@/lib/mail-export/sinks"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { useAuthStore } from "@/stores/auth-store"; +import { useMailTransferStore } from "@/stores/mail-transfer-store"; + +export interface StartExportInput { + format: ExportFormat; + /** JMAP account of the folder (or of the whole export). */ + accountId: string; + /** One folder (its id in its account); the whole account when absent. */ + mailboxId?: string; + sink: ExportSink; +} + +/** Runs an export as the dialog's job. */ +export function startExport(input: StartExportInput): void { + const auth = useAuthStore.getState(); + if (!auth.client) return; + const client: IJMAPClient = auth.client; + const controller = new AbortController(); + let ended: () => void = () => {}; + const finished = new Promise((resolve) => { ended = resolve; }); + useMailTransferStore.getState().startJob({ + kind: "export", + controller, + scope: auth.activeAccountId ?? "default", + finished, + progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 }, + }); + void (async () => { + try { + await run(); + } finally { + ended(); + } + })(); + async function run() { + let folders: ExportFolder[]; + try { + folders = input.mailboxId + ? [{ mailboxId: input.mailboxId, path: [] }] + : folderTree(await client.getMailboxes(input.accountId)); + } catch (error) { + await input.sink.abort().catch(() => {}); + useMailTransferStore.getState().updateProgress({ + phase: "failed", exported: 0, failed: 0, failures: [], total: 0, fraction: 0, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + const result = await runExport({ + client, + accountId: input.accountId, + folders, + format: input.format, + sink: input.sink, + signal: controller.signal, + onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress), + }); + useMailTransferStore.getState().updateProgress(result); + } +} diff --git a/components/mail-transfer/start-import.ts b/components/mail-transfer/start-import.ts new file mode 100644 index 000000000..dd28df0b9 --- /dev/null +++ b/components/mail-transfer/start-import.ts @@ -0,0 +1,68 @@ +import { runImport, type ImportFile } from "@/lib/mail-import/engine"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useMailTransferStore } from "@/stores/mail-transfer-store"; + +export interface StartImportInput { + files: ImportFile[]; + /** JMAP account of the target. */ + accountId: string; + /** null = the top level (with keepFolders). */ + targetMailboxId: string | null; + keepFolders: boolean; + resume: boolean; +} + +/** Runs an import as the dialog's job and refreshes the mail lists when it ends. */ +export function startImport(input: StartImportInput): void { + const auth = useAuthStore.getState(); + if (!auth.client) return; + const client: IJMAPClient = auth.client; + const store = useMailTransferStore.getState(); + const controller = new AbortController(); + const scope = auth.activeAccountId ?? "default"; + let ended: () => void = () => {}; + const finished = new Promise((resolve) => { ended = resolve; }); + store.startJob({ + kind: "import", + controller, + scope, + finished, + progress: { phase: "running", imported: 0, failed: 0, skipped: {}, failures: [], fraction: 0, foldersCreated: 0 }, + }); + const hasPst = input.files.some((f) => f.format === "pst"); + void (async () => { + try { + await run(); + } finally { + ended(); + } + })(); + async function run() { + // The PST reader (and its worker) load only for an Outlook file. + const openPst = hasPst ? (await import("@/lib/mail-import/pst-source")).openPstSource : undefined; + const result = await runImport({ + client, + accountId: input.accountId, + targetMailboxId: input.targetMailboxId, + keepFolders: input.keepFolders, + files: input.files, + scope, + resume: input.resume, + signal: controller.signal, + onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress), + openPst, + }); + useMailTransferStore.getState().updateProgress(result); + // Show what arrived, if the same account is still the one on screen. + if (useAuthStore.getState().client !== client || result.imported === 0) return; + const email = useEmailStore.getState(); + try { + await email.fetchMailboxes(client); + await email.fetchEmails(client, email.selectedMailbox || undefined, { background: true }); + } catch { + /* the next refresh shows them */ + } + } +} diff --git a/components/mail-transfer/transfer-progress.tsx b/components/mail-transfer/transfer-progress.tsx new file mode 100644 index 000000000..1363b9d9e --- /dev/null +++ b/components/mail-transfer/transfer-progress.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, type RefObject } from "react"; +import { cn } from "@/lib/utils"; + +/** + * Keeps the focus in the dialog when its buttons change under it: Import or + * Export turns into the progress view, then into the summary, and the button + * that had the focus is gone. `stage` names the view; `target` is the button + * to focus in it. + */ +export function useFocusOnStage(target: RefObject, stage: string): void { + useEffect(() => { + const el = target.current; + if (!el) return; + const dialog = el.closest('[role="dialog"], [role="alertdialog"]'); + if (!dialog || !dialog.contains(document.activeElement)) el.focus(); + }, [target, stage]); +} + +/** The bar of an import or export, 0 to 1; indeterminate while nothing is known. */ +export function TransferBar({ fraction, label, paused }: { fraction: number | null; label: string; paused?: boolean }) { + const percent = fraction === null ? null : Math.round(Math.max(0, Math.min(1, fraction)) * 100); + return ( +
+
+
+ ); +} + +/** A server limit in the server's own unit: Stalwart's 50000000 bytes are "50 MB" (not the 48 MB of a 1024-based size). */ +export function serverMegabytes(bytes: number): string { + return `${Math.round(bytes / 1_000_000)} MB`; +} + +/** "14:00", or a date and time when the moment isn't today. */ +export function formatClock(ms: number, locale: string, now = Date.now()): string { + const date = new Date(ms); + const sameDay = new Date(now).toDateString() === date.toDateString(); + return new Intl.DateTimeFormat(locale, sameDay ? { hour: "2-digit", minute: "2-digit" } : { dateStyle: "medium", timeStyle: "short" }).format(date); +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index cecc35ceb..310ef7dcb 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -126,7 +126,7 @@ import { isPhishingMarked } from "@/lib/phishing-links"; import { automaticLabel, classifyOutgoing, labelSourceHeaders, reopenedLabelChoice } from "@/lib/outgoing/classification"; import enMessages from "@/locales/en/common.json"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; -import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; +import { openMailImport } from "@/stores/mail-transfer-store"; import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; @@ -3775,48 +3775,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }; + // The import dialog (lib/mail-import) picks the files and runs the import. const handleImportEmailFromContextMenu = (mailboxId: string) => { - if (!client) return; - const mailbox = mailboxes.find(mb => mb.id === mailboxId); - if (!mailbox) return; - const targetMailboxId = mailbox.originalId || mailbox.id; - - const input = document.createElement('input'); - input.type = 'file'; - input.accept = EML_IMPORT_ACCEPT; - input.multiple = true; - input.onchange = async (e) => { - const files = Array.from((e.target as HTMLInputElement).files ?? []); - if (files.length === 0) return; - - let emails; - try { - emails = await expandImportableEmails(files); - } catch { - toast.error(t('notifications.import_email_error')); - return; - } - - let imported = 0; - let failed = 0; - for (const { blob, receivedAt } of emails) { - try { - await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }, undefined, receivedAt); - imported++; - } catch { - failed++; - } - } - - if (imported > 0) { - toast.success(t('notifications.import_email_success')); - if (selectedMailbox) await fetchEmails(client, selectedMailbox); - } - if (failed > 0 || (imported === 0 && emails.length === 0)) { - toast.error(t('notifications.import_email_error')); - } - }; - input.click(); + openMailImport({ mailboxId }); }; const handleRefreshMailboxes = async () => { diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 33fc31285..17db44064 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -16,6 +16,7 @@ import { SpamSiegeGame } from './spam-siege-game'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { toast } from '@/stores/toast-store'; import { DebugSettings } from './debug-settings'; +import { MailBackupSettings } from '@/components/mail-transfer/mail-backup-settings'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -214,6 +215,8 @@ export function AboutDataSettings() { )} + +