diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx new file mode 100644 index 000000000..477270d85 --- /dev/null +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -0,0 +1,463 @@ +import 'fake-indexeddb/auto'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host'; +import { localExportAllowed, startLocalExport } from '@/components/mail-transfer/start-local-export'; +import { OfflineSettings } from '@/components/offline/offline-settings'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { getStrictestDevices, devicesForAccount } from '@/lib/device-policy'; +import { setDownloadGuard } from '@/lib/download-guard'; +import type { ExportProgress } from '@/lib/mail-export/engine'; +import type { ExportFile, ExportSink } from '@/lib/mail-export/sinks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { isJobActive, openLocalExport, useMailTransferStore } from '@/stores/mail-transfer-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +const FOLDERS = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 }, + { id: 'archive', name: 'Archive', sortOrder: 1 }, +]; +const RAW = new TextEncoder().encode('Subject: x\r\n\r\nbody\r\n'); + +/** A kept account where Inbox has 2 of 3 originals and Archive 1 of 1. */ +function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown } = {}) { + const area = vi.fn(async (_account: string, name: string, params?: { mailboxId?: string; blobId?: string }): Promise => { + if (name === 'mail.session') return { primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-a' } }; + if (name === 'mail.rawStatus') return params?.mailboxId === 'inbox' ? { total: 3, kept: 2 } : { total: 1, kept: 1 }; + if (name === 'mail.rawBlob') return overrides.rawBlob ? overrides.rawBlob(params!.blobId!) : { ok: true, blob: new Blob([RAW]) }; + if (name === 'mail.rawKept') return []; + return null; + }); + const answer = vi.fn(async (_account: string, calls: [string, Record, string][]) => { + const [name, args] = calls[0]; + if (name === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] }; + if (name === 'Email/query') return { methodResponses: [['Email/query', { ids: (args.position as number) > 0 ? [] : ['m1'] }, '0']] }; + return { methodResponses: [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1', size: RAW.length, keywords: {}, receivedAt: '2026-10-01T00:00:00Z', subject: 'x' }] }, '0']] }; + }); + return { supported: () => true, setScope: vi.fn(async () => {}), area, answer }; +} + +function setDevices(devices: Partial) { + usePolicyStore.setState({ + loaded: true, source: 'server', partial: false, subject: { email: 'alice@example.org', groups: [] }, + policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }, + } as never); +} + +function fakeSink(): ExportSink & { write: ReturnType; abort: ReturnType; close: ReturnType } { + const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) }; + return { kind: 'picker', write: vi.fn(async () => {}), close: vi.fn(async () => file), abort: vi.fn(async () => {}) }; +} + +/** The one lib/device-policy.ts installs, for tests that put their own in for a moment. */ +const installedGuard: Parameters[0] = (kind, subject) => { + const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject as string | null); + return kind === 'export' ? !devices.noExport : !devices.noDownloads; +}; + +const realClient = { getServerUrl: () => 'https://mail.example.org', getAccountId: () => 'jmap-a', getMaxConcurrentRequests: () => 4 }; + +function setOnline(online: boolean) { + useOfflineStore.setState({ browserOffline: !online, downServers: [] }); +} + +beforeEach(() => { + useMailTransferStore.setState({ request: null, visible: false, job: null }); + useAuthStore.setState({ + client: realClient as never, + activeAccountId: 'acc', + getClientForAccount: ((id: string) => (id === 'acc' ? realClient : undefined)) as never, + getAllConnectedClients: (() => new Map([['acc', realClient]])) as never, + } as never); + useAccountStore.setState({ accounts: [{ id: 'acc', email: 'alice@example.org', username: 'alice@example.org', serverUrl: 'https://mail.example.org' }] } as never); + useOfflineStore.setState({ + enabled: ['acc', 'other'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {}, + storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false, + usage: { acc: { bytes: 5000, parts: [] } }, + }); + setDevices({}); + setStoreBackend(fakeStore() as unknown as StoreBackend); +}); + +afterEach(() => { + cleanup(); + setStoreBackend(null); + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.unstubAllGlobals(); +}); + +async function openDialog() { + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); +} + +/** Runs one export to its end through the job. */ +async function finishedJob(sink = fakeSink()) { + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: ['Inbox'] }], sink })).toBe(true); + await act(async () => { await useMailTransferStore.getState().job!.finished; }); + return sink; +} + +describe('before an export starts', () => { + it('says how many originals are here and how many come from the server when online', async () => { + await openDialog(); + expect(screen.getByTestId('local-export-kept').textContent).toBe('kept'); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + expect(screen.queryByTestId('local-export-reason')).toBeNull(); + }); + + it('says offline how many can\'t be exported, and why', async () => { + setOnline(false); + await openDialog(); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline'); + expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_not_kept'); + }); + + it('gives attachments being off as the reason when they are', async () => { + setOnline(false); + useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } } as never); + await openDialog(); + expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_attachments_off'); + }); + + it('counts only the folders chosen, and offers none of the rest', async () => { + setOnline(false); + await openDialog(); + const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType; + expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', { jmap: 'jmap-a', mailboxId: 'inbox' }); + const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[]; + expect(boxes.map((box) => box.dataset.folder)).toEqual(['Inbox', 'Archive']); + fireEvent.click(boxes[0]); + // Only Archive now: all of its originals are here. + expect(screen.queryByTestId('local-export-missing')).toBeNull(); + const all = screen.getByTestId('local-export-all') as HTMLInputElement; + expect(all.indeterminate).toBe(true); + fireEvent.click(all); + expect(boxes.every((box) => box.checked)).toBe(true); + }); + + it('can\'t start with nothing chosen', async () => { + await openDialog(); + fireEvent.click(screen.getByTestId('local-export-all')); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('local-export-nothing')).toBeTruthy(); + }); + + it('opens the picker from the click, with a name for what was chosen', async () => { + const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) }; + const picker = vi.fn(async () => ({ createWritable: async () => writable })); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker; + await openDialog(); + fireEvent.click(screen.getAllByTestId('local-export-folder')[1]); + fireEvent.click(screen.getByTestId('local-export-start')); + await waitFor(() => expect(picker).toHaveBeenCalled()); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Inbox.mbox' })); + await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done')); + expect(writable.close).toHaveBeenCalled(); + }); +}); + +describe('the device policy "No export"', () => { + it('locks the action in settings, with the reason beside it', () => { + setDevices({ noExport: true }); + render(); + expect((screen.getByTestId('local-export-open') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('settings_label').closest('[data-search-label]')!.querySelector('[data-org-locked]')).toBeTruthy(); + }); + + it('leaves the action in settings open when it\'s off, and that opens the dialog', async () => { + render(); + fireEvent.click(screen.getByTestId('local-export-open')); + expect(useMailTransferStore.getState().request).toEqual({ kind: 'local-export', account: 'acc' }); + }); + + it('keeps the dialog from opening', () => { + setDevices({ noExport: true }); + act(() => openLocalExport('acc')); + expect(useMailTransferStore.getState().request).toBeNull(); + }); + + it('refuses to start, gives back the sink and reads nothing', () => { + const store = fakeStore(); + setStoreBackend(store as unknown as StoreBackend); + setDevices({ noExport: true }); + const sink = fakeSink(); + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(false); + expect(sink.abort).toHaveBeenCalled(); + expect(store.answer).not.toHaveBeenCalled(); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('counts as on while the organisation\'s word isn\'t known, as at a first start offline', () => { + usePolicyStore.setState({ loaded: false, source: 'none' } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'fallback' } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'server', partial: true } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'cache', partial: false } as never); + expect(localExportAllowed('acc')).toBe(true); + }); + + it('stops a running export the moment it\'s switched on, and its file goes', async () => { + let release: () => void = () => {}; + const store = fakeStore(); + store.answer.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); })); + setStoreBackend(store as unknown as StoreBackend); + const sink = fakeSink(); + startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); + const job = useMailTransferStore.getState().job!; + expect(isJobActive(job)).toBe(true); + await waitFor(() => expect(store.answer).toHaveBeenCalled()); + act(() => setDevices({ noExport: true })); + expect(job.controller.signal.aborted).toBe(true); + release(); + await act(async () => { await job.finished; }); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(sink.abort).toHaveBeenCalled(); + expect(sink.close).not.toHaveBeenCalled(); + }); + + it('asks about the account the export is for, not the active one', async () => { + useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never); + const asked: unknown[] = []; + setDownloadGuard((_kind, subject) => { asked.push(subject); return true; }); + try { + const sink = fakeSink(); + expect(startLocalExport({ account: 'other', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + expect(useMailTransferStore.getState().job?.scope).toBe('other'); + await act(async () => { await useMailTransferStore.getState().job!.finished; }); + expect(sink.close).toHaveBeenCalled(); + expect(asked.length).toBeGreaterThan(1); + expect(new Set(asked)).toEqual(new Set(['other'])); + } finally { + setDownloadGuard(installedGuard); + } + }); + + it('stops at the file when it came on without the store noticing, as the save picker has no other gate', async () => { + const sink = fakeSink(); + let allowed = true; + setDownloadGuard(() => allowed); + try { + sink.write.mockImplementation(async () => { allowed = false; }); + await finishedJob(sink); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed'); + expect(sink.close).not.toHaveBeenCalled(); + expect(sink.abort).toHaveBeenCalled(); + } finally { + setDownloadGuard(installedGuard); + } + }); + + it('shows the organisation as the reason when that stopped it', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + let allowed = true; + setDownloadGuard(() => allowed); + try { + sink.write.mockImplementation(async () => { allowed = false; }); + await finishedJob(sink); + expect(screen.getByText('export_blocked')).toBeTruthy(); + } finally { + setDownloadGuard(installedGuard); + } + }); +}); + +describe('what an export couldn\'t write', () => { + const notKept = () => setStoreBackend(fakeStore({ rawBlob: () => ({ ok: false, reason: 'not_kept' }) }) as unknown as StoreBackend); + + it('counts it offline with the reason and no way to retry until online', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect((useMailTransferStore.getState().job?.progress as ExportProgress).notKept).toEqual({ not_kept: 1 }); + expect(screen.getByTestId('local-export-not-kept-missing').textContent).toBe('not_kept_not_kept'); + expect(screen.queryByTestId('local-export-not-kept-attachments')).toBeNull(); + expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_offline'); + expect((screen.getByTestId('local-export-retry') as HTMLButtonElement).disabled).toBe(true); + }); + + it('offers the retry once the connection is back, which goes back to the question with fresh counts', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + act(() => setOnline(true)); + expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_online'); + const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType; + area.mockClear(); + fireEvent.click(screen.getByTestId('local-export-retry')); + await screen.findByTestId('local-export-summary'); + expect(useMailTransferStore.getState().job).toBeNull(); + expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', expect.anything()); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + }); + + it('offers no retry when everything was written', async () => { + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + expect(screen.queryByTestId('local-export-retry')).toBeNull(); + }); +}); + +describe('while it runs', () => { + it('shows the progress, and Cancel stops it and gives up its file', async () => { + const sink = fakeSink(); + let finish: () => void = () => {}; + const store = fakeStore(); + store.answer.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); })); + setStoreBackend(store as unknown as StoreBackend); + render(); + act(() => openLocalExport('acc')); + act(() => { startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); }); + expect(screen.getByRole('progressbar')).toBeTruthy(); + expect(screen.getByTestId('mail-export-status').getAttribute('role')).toBe('status'); + await waitFor(() => expect(store.answer).toHaveBeenCalled()); + const job = useMailTransferStore.getState().job!; + fireEvent.click(screen.getByTestId('local-export-cancel')); + expect(job.controller.signal.aborted).toBe(true); + finish(); + await act(async () => { await job.finished; }); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(sink.abort).toHaveBeenCalled(); + expect(sink.close).not.toHaveBeenCalled(); + expect(screen.getByText('cancelled_title')).toBeTruthy(); + }); +}); + +describe('an export is cleaned up', () => { + /** A job that doesn't end until it's aborted. */ + function runningJob(account = 'acc') { + const sink = fakeSink(); + const store = fakeStore(); + // Like a store read: under way until the job is stopped. + store.answer.mockImplementation(() => new Promise((_resolve, reject) => { + const signal = useMailTransferStore.getState().job!.controller.signal; + const stop = () => reject(new DOMException('stopped', 'AbortError')); + if (signal.aborted) stop(); + else signal.addEventListener('abort', stop); + })); + setStoreBackend(store as unknown as StoreBackend); + expect(startLocalExport({ account, jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + return { sink, job: useMailTransferStore.getState().job! }; + } + + function stubSweep() { + const removed: string[] = []; + const dir = { + keys: async function* () { yield 'old-spool'; }, + removeEntry: async (name: string) => { removed.push(name); }, + }; + const root = { + getDirectoryHandle: async () => dir, + removeEntry: vi.fn(async () => {}), + }; + vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => root }, locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) } }); + return { removed, root }; + } + + it('on a wipe of its account: stops the job and forgets it', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('a wipe of another account leaves the job alone', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runWipeCleaners('someone-else'); }); + expect(job.controller.signal.aborted).toBe(false); + expect(useMailTransferStore.getState().job).toBe(job); + }); + + it('on a wipe of everything: also every spool, whoever holds it', async () => { + render(); + const { job } = runningJob(); + const { root } = stubSweep(); + await act(async () => { await runWipeCleaners(null); }); + expect(job.controller.signal.aborted).toBe(true); + expect(root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true }); + }); + + it('on a wipe of one account: the spools nobody holds go too', async () => { + render(); + const { removed, root } = stubSweep(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(removed).toEqual(['old-spool']); + expect(root.removeEntry).not.toHaveBeenCalled(); + }); + + it('on sign-out of its account: stops the job and forgets it', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runBeforeSignOut('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('frees a finished export\'s file at once on a wipe', async () => { + render(); + const sink = fakeSink(); + await finishedJob(sink); + const file = (useMailTransferStore.getState().job!.progress as ExportProgress).file!; + await act(async () => { await runWipeCleaners('acc'); }); + expect(file.discard).toHaveBeenCalledWith(true); + }); + + it('when the account stops keeping mail here: stops the job', () => { + const { job } = runningJob(); + act(() => useOfflineStore.setState({ enabled: ['other'] })); + expect(job.controller.signal.aborted).toBe(true); + }); + + it('at start-up: sweeps what a crash left', async () => { + const { removed } = stubSweep(); + render(); + await waitFor(() => expect(removed).toEqual(['old-spool'])); + }); +}); + +describe('the taskbar in the desktop app', () => { + it('shows a running export and takes it away at the end', async () => { + const setProgress = vi.fn(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } }; + render(); + await finishedJob(); + const calls = setProgress.mock.calls; + expect(calls[0]).toEqual([0, 'normal']); + expect(calls[calls.length - 1][0]).toBeNull(); + }); + + it('says when it\'s paused', async () => { + const setProgress = vi.fn(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } }; + render(); + const store = useMailTransferStore.getState(); + act(() => store.startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'paused', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } })); + expect(setProgress).toHaveBeenLastCalledWith(0.25, 'paused'); + }); + + it('asks nothing of a shell without it, or of a browser', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' }; + render(); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + }); +}); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx new file mode 100644 index 000000000..248fe5cf2 --- /dev/null +++ b/components/mail-transfer/local-export-dialog.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { folderTree, safeName, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine"; +import { openExportSink } from "@/lib/mail-export/sinks"; +import { scopeForAccount } from "@/lib/offline/offline-mode"; +import { useOfflineStore } from "@/lib/offline/state"; +import { getStore } from "@/lib/store/client/api"; +import { useAccountStore } from "@/stores/account-store"; +import { isJobActive, useMailTransferStore, type LocalExportRequest } from "@/stores/mail-transfer-store"; +import { ExportProgressBody, saveMode } from "./mail-export-dialog"; +import { localExportAllowed, localExportReachable, localExportReader, primaryJmapAccount, startLocalExport } from "./start-local-export"; +import { useFocusOnStage } from "./transfer-progress"; + +interface FolderRow { + folder: ExportFolder; + total: number; + kept: number; +} + +type Loaded = { jmap: string; rows: FolderRow[] }; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** What an account keeps, folder by folder, for the question before an export starts. */ +async function readFolders(account: string): Promise { + const jmap = await primaryJmapAccount(account); + if (!jmap) throw new Error("The account has no mail account in the store"); + const folders = folderTree(await localExportReader(account, jmap).getMailboxes()); + const rows: FolderRow[] = []; + for (const folder of folders) { + const status = await getStore().area<{ total: number; kept: number }>(account, "mail.rawStatus", { jmap, mailboxId: folder.mailboxId }); + rows.push({ folder, ...status }); + } + return { jmap, rows }; +} + +/** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */ +export function LocalExportDialog({ request }: { request: LocalExportRequest }) { + const t = useTranslations("offlineExport"); + const tExport = useTranslations("mail_export"); + const tPolicy = useTranslations("device_policy"); + const locale = useLocale(); + const id = useId(); + const { account } = request; + 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 address = useAccountStore((s) => { + const entry = s.accounts.find((candidate) => candidate.id === account); + return entry?.email || entry?.username || account; + }); + // Re-renders when the connection changes; what it means is asked below. + useOfflineStore((s) => s.browserOffline); + useOfflineStore((s) => s.downServers); + const reachable = localExportReachable(account); + const [loaded, setLoaded] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [excluded, setExcluded] = useState>(new Set()); + const [format, setFormat] = useState("mbox"); + const [starting, setStarting] = useState(false); + const mode = saveMode(); + const progress = job?.kind === "export" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); + const inSetup = !progress; + + // Read again each time the setup shows, so a try online sees what's kept by then. + useEffect(() => { + if (!inSetup) return; + let live = true; + setLoadFailed(false); + readFolders(account).then( + (result) => { if (live) setLoaded(result); }, + () => { if (live) setLoadFailed(true); }, + ); + return () => { live = false; }; + }, [account, inSetup]); + + if (!visible) return null; + + if (progress) { + const notKept = progress.notKept; + const notKeptCount = (notKept?.attachments_off ?? 0) + (notKept?.not_kept ?? 0); + const blocked = progress.phase === "failed" && !localExportAllowed(account); + return ( + (active ? hide() : close())} + size="full-on-phone" + preventOutsideClose + title={progress.phase === "done" ? tExport("done_title") : progress.phase === "cancelled" ? tExport("cancelled_title") : progress.phase === "failed" ? tExport("failed_title") : tExport("running_title")} + testId="local-export-dialog" + footer={active ? ( + <> + + + + ) : ( + <> + {progress.phase === "done" && progress.file?.download && ( + + )} + {notKeptCount > 0 && ( + + )} + + + )} + > + + {notKeptCount > 0 && ( +
+ {!!notKept?.attachments_off &&

{t("not_kept_attachments_off", { count: notKept.attachments_off })}

} + {!!notKept?.not_kept &&

{t("not_kept_not_kept", { count: notKept.not_kept })}

} +

{reachable ? t("retry_online") : t("retry_offline")}

+
+ )} +
+ ); + } + + const rows = loaded?.rows ?? []; + const chosen = rows.filter((row) => !excluded.has(row.folder.mailboxId)); + const total = chosen.reduce((sum, row) => sum + row.total, 0); + const kept = chosen.reduce((sum, row) => sum + row.kept, 0); + const missing = total - kept; + const single = chosen.length === 1; + const attachmentsOff = !scopeForAccount(account).scope.attachments; + + const onStart = async () => { + if (!loaded || starting || chosen.length === 0) return; + const zip = !single || format === "eml"; + const base = single + ? safeName(chosen[0].folder.path[chosen[0].folder.path.length - 1] ?? "", "mail") + : `${safeName(address, "mail")}-${today()}`; + setStarting(true); + try { + // The save picker must open straight from the click. In the desktop app it's the OS's own Save dialog. + const sink = await openExportSink(`${base}.${zip ? "zip" : "mbox"}`, zip ? "application/zip" : "application/mbox", zip ? ".zip" : ".mbox"); + if (!sink) return; + startLocalExport({ account, jmap: loaded.jmap, format, folders: chosen.map((row) => row.folder), sink }); + } finally { + setStarting(false); + } + }; + + const toggleFolder = (mailboxId: string, on: boolean) => { + const next = new Set(excluded); + if (on) next.delete(mailboxId); + else next.add(mailboxId); + setExcluded(next); + }; + + const formats: { value: ExportFormat; label: string; hint: string }[] = [ + { value: "mbox", label: single ? tExport("format_mbox_folder") : tExport("format_mbox_account"), hint: tExport("hint_mbox") }, + { value: "eml", label: single ? tExport("format_eml_folder") : tExport("format_eml_account"), hint: tExport("hint_eml") }, + ]; + + return ( + close()} + size="full-on-phone" + title={t("title")} + description={t("description")} + testId="local-export-dialog" + footer={( + <> + + + + )} + > +
+ {loadFailed ? ( +

{t("load_failed")}

+ ) : !loaded ? ( +

{t("loading")}

+ ) : ( + <> +
+ {t("folders_label")} + +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+
+
+ {tExport("format_label")} +
+ {formats.map((f) => ( + + ))} +
+
+
+ {total === 0 ? ( +

{t("nothing_kept")}

+ ) : ( +

{t("kept", { kept, total })}

+ )} + {missing > 0 && ( +

+ {reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })} +

+ )} + {missing > 0 && !reachable && ( +

{attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")}

+ )} +
+

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

+ + )} +
+
+ ); +} diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index 981fb2bdf..e98a865bf 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -4,6 +4,8 @@ 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 { showTaskbarProgress } from "@/lib/desktop/taskbar-progress"; +import { onDeviceWipe } from "@/lib/offline/wipe-registry"; import { onBeforeSignOut } from "@/lib/sign-out-cleanup"; import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints"; import { sweepPrivateExports } from "@/lib/mail-export/sinks"; @@ -11,11 +13,27 @@ 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 { LocalExportDialog } from "./local-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; +/** + * Stops the job of an account that goes, and frees what its export kept in this + * browser. `all`: whoever's job it is. Another account's job goes on. + */ +async function stopJobOf(accountId: string): Promise { + const store = useMailTransferStore.getState(); + const job = store.job; + if (!job || !(accountId === "all" || !job.scope || job.scope === accountId)) return; + 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 }); +} + /** * 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 @@ -31,18 +49,16 @@ export function MailTransferHost() { // 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 }); - } + await stopJobOf(accountId); if (accountId !== "all") await clearCheckpointsFor(accountId); }), []); + // A wipe takes the same, and every spool no tab holds. `null` is everything on this device. + useEffect(() => onDeviceWipe(async (account) => { + await stopJobOf(account ?? "all"); + await sweepPrivateExports({ force: account === null }); + }), []); + // 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); @@ -50,6 +66,20 @@ export function MailTransferHost() { if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports(); }, [activeAccountId]); + // The desktop app shows a running export on its taskbar button. + const exportPhase = job?.kind === "export" ? job.progress.phase : null; + const exportFraction = job?.kind === "export" ? job.progress.fraction : 0; + const onTaskbar = useRef(false); + useEffect(() => { + if (exportPhase === "running" || exportPhase === "paused") { + onTaskbar.current = true; + showTaskbarProgress(exportFraction, exportPhase === "paused" ? "paused" : "normal"); + } else if (onTaskbar.current) { + onTaskbar.current = false; + showTaskbarProgress(null); + } + }, [exportPhase, exportFraction]); + // 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"); @@ -77,6 +107,7 @@ export function MailTransferHost() { <> {request?.kind === "import" && } {request?.kind === "export" && } + {request?.kind === "local-export" && } {!visible && isJobActive(job) && } ); diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts new file mode 100644 index 000000000..965033304 --- /dev/null +++ b/components/mail-transfer/start-local-export.ts @@ -0,0 +1,104 @@ +import { downloadAllowed, downloadAllowedQuietly } from "@/lib/download-guard"; +import { localExportClient } from "@/lib/mail-export/local-store-client"; +import { runExport, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine"; +import { guardedSink, type ExportSink } from "@/lib/mail-export/sinks"; +import { isOfflineNow, useOfflineStore } from "@/lib/offline/state"; +import { getStore } from "@/lib/store/client/api"; +import type { StoreSession } from "@/lib/store/engine/transport"; +import { useAuthStore } from "@/stores/auth-store"; +import { useMailTransferStore } from "@/stores/mail-transfer-store"; +import { usePolicyStore } from "@/stores/policy-store"; + +const MAIL_CAPABILITY = "urn:ietf:params:jmap:mail"; + +/** + * Whether the organisation lets this account export. Unlike a browser online, a + * start offline may not have heard the organisation: not known means no. + */ +export function localExportAllowed(account: string): boolean { + const { loaded, partial, source } = usePolicyStore.getState(); + if (!loaded || partial || source === "none" || source === "fallback") return false; + return downloadAllowedQuietly("export", account); +} + +function clientOf(account: string) { + const auth = useAuthStore.getState(); + return auth.getClientForAccount?.(account) ?? (account === auth.activeAccountId ? auth.client : undefined) ?? null; +} + +/** The real client while its server can be reached. */ +function reachableClient(account: string) { + const client = clientOf(account); + if (!client || isOfflineNow(client.getServerUrl())) return null; + return client; +} + +/** Whether a message with no original here could be fetched right now. */ +export function localExportReachable(account: string): boolean { + return reachableClient(account) !== null; +} + +/** The account's own mail account in the store. Shared accounts aren't offered yet. */ +export async function primaryJmapAccount(account: string): Promise { + const session = await getStore().area(account, "mail.session"); + return session?.primaryAccounts?.[MAIL_CAPABILITY] ?? clientOf(account)?.getAccountId() ?? null; +} + +export function localExportReader(account: string, jmap: string) { + return localExportClient({ account, jmap, store: getStore(), online: () => reachableClient(account) }); +} + +export interface StartLocalExportInput { + account: string; + jmap: string; + format: ExportFormat; + folders: ExportFolder[]; + sink: ExportSink; +} + +/** Runs an export from the local store as the dialog's job. False, and nothing read, when the policy says no. */ +export function startLocalExport(input: StartLocalExportInput): boolean { + const { account, jmap, format, folders } = input; + if (!localExportAllowed(account) || !downloadAllowed("export", account)) { + void input.sink.abort().catch(() => {}); + return false; + } + const controller = new AbortController(); + // Stops at once, also for an account that isn't the active one, and when no write is due. + const unwatchPolicy = usePolicyStore.subscribe(() => { + if (!localExportAllowed(account)) controller.abort("policy"); + }); + const unwatchOffline = useOfflineStore.subscribe((state) => { + if (!state.enabled.includes(account)) controller.abort("offline-off"); + }); + let ended: () => void = () => {}; + const finished = new Promise((resolve) => { ended = resolve; }); + useMailTransferStore.getState().startJob({ + kind: "export", + controller, + scope: account, + finished, + progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 }, + }); + void (async () => { + try { + const result = await runExport({ + client: localExportReader(account, jmap), + accountId: jmap, + folders, + format, + // The save picker passes the guard in saveBlobAs, so every write asks. + sink: guardedSink(input.sink, () => localExportAllowed(account)), + signal: controller.signal, + requestsPerMinute: Infinity, + onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress), + }); + useMailTransferStore.getState().updateProgress(result); + } finally { + unwatchPolicy(); + unwatchOffline(); + ended(); + } + })(); + return true; +} diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index d58d07a1d..80666f60c 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -13,7 +13,9 @@ import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offl import { isOfflineNow, useOfflineStore } from '@/lib/offline/state'; import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress'; import { devicesForAccount } from '@/lib/device-policy'; +import { localExportAllowed } from '@/components/mail-transfer/start-local-export'; import { useAccountStore } from '@/stores/account-store'; +import { openLocalExport } from '@/stores/mail-transfer-store'; import { KeptFiles } from './kept-files'; const MB = 1024 * 1024; @@ -65,6 +67,7 @@ function useUnsent(account: string) { export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) { const t = useTranslations('offline.settings'); const tStorage = useTranslations('offlineStorage'); + const tExport = useTranslations('offlineExport'); const format = useFormatter(); const enabled = useOfflineStore((s) => s.enabled.includes(account)); const usage = useOfflineStore((s) => s.usage[account]); @@ -220,6 +223,9 @@ export function AccountStorage({ account, primary, heading }: { account: string; )} + + + diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts new file mode 100644 index 000000000..cd0f6af91 --- /dev/null +++ b/integration/tests/24-do-export.spec.ts @@ -0,0 +1,300 @@ +/** + * Export of the local store, through the real UI, as ent-dave, against a VM 106 + * Stalwart: a folder written with the browser offline is the bytes the server has, + * the private files never hold readable mail, what has no original here is counted + * and can be tried again online, and "No export" holds. + * + * Run with desktop-offline-rig/run-spec.sh. Works in its own folder and deletes it + * with its mail at the end. + */ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { ENT, ENT_PASSWORD, adminContext, jmapAs, requireEntEnv, shot, userPage, withPolicy } from './helpers/ent'; +import { JMAP_URL } from './helpers/config'; +import { listZipEntries, readZipEntry } from '../../lib/mail-import/zip-reader'; +import { MboxSplitter } from '../../lib/mail-import/mbox'; +import { withoutStateHeaders } from '../../lib/mail-import/mbox-status'; + +requireEntEnv(); +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const USER = ENT.dave; +const STAMP = `D10-${Date.now().toString(36)}`; +const FOLDER = `${STAMP}-folder`; +const SECRET = `Secret${STAMP.replace(/\W/g, '')}`; +const COUNT = 3; +const DEVICES_OFF = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false }; + +const auth = () => 'Basic ' + Buffer.from(`${USER}:${ENT_PASSWORD}`).toString('base64'); + +/** Bytes a rebuilt message wouldn't have: a Latin-1 byte, a bare LF, odd spacing, signature-like headers, mbox traps. */ +function rawMessage(n: number): Buffer { + return Buffer.concat([ + Buffer.from(`From: Seed \r\nTo: ${USER}\r\nSubject: ${SECRET} subject ${n} \r\nDate: Mon, 1 Jan 2018 08:00:0${n} +0000\r\nMessage-ID: <${STAMP}-${n}@example.org>\r\nDKIM-Signature: v=1; a=rsa-sha256; b=abc${n}\r\nContent-Type: text/plain; charset=iso-8859-1\r\n\r\n`), + Buffer.from([0xe4, 0x0a]), + Buffer.from(`From the start of a line ${n}\r\n>From quoted once\n${SECRET} body ${n}\r\n`), + ]); +} + +let mailbox = ''; +const blobIds: string[] = []; +const originals: Buffer[] = []; + +test.beforeAll(async () => { + const client = await jmapAs(USER); + mailbox = await client.createMailbox(FOLDER); + for (let n = 1; n <= COUNT; n += 1) { + const up = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth(), 'Content-Type': 'message/rfc822' }, body: rawMessage(n) }); + expect(up.ok).toBe(true); + const { blobId } = await up.json(); + const res = await client.request([['Email/import', { accountId: client.accountId, emails: { e: { blobId, mailboxIds: { [mailbox]: true }, keywords: { $seen: true }, receivedAt: new Date(Date.now() + n * 1000).toISOString().replace(/\.\d+Z/, 'Z') } } }, '0']]); + const created = res.methodResponses[0][1].created?.e; + expect(created?.id, JSON.stringify(res.methodResponses[0][1])).toBeTruthy(); + blobIds.push(created.blobId); + const download = await fetch(`${JMAP_URL}/jmap/download/${client.accountId}/${created.blobId}/m.eml?accept=message/rfc822`, { headers: { Authorization: auth() } }); + expect(download.ok).toBe(true); + // What the server has is the reference, whatever it did with the upload. + originals.push(Buffer.from(await download.arrayBuffer())); + } +}); + +test.afterAll(async () => { + const client = await jmapAs(USER); + await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [mailbox], onDestroyRemoveEmails: true }, '0']]).catch(() => {}); +}); + +/** Every write to a private file is kept, and the export has no save picker, so it goes to the spool. */ +const INIT = () => { + const proto = FileSystemWritableFileStream.prototype as unknown as { write(data: unknown): Promise }; + const write = proto.write; + const seen: Uint8Array[] = []; + (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites = seen; + proto.write = function patched(data: unknown) { + if (data instanceof Uint8Array) seen.push(data.slice()); + return write.call(this, data); + }; + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; +}; + +let page: Page; +let context: BrowserContext; + +async function settings(target: Page): Promise { + await target.goto('/settings/backup/offline'); + await expect(target.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 }); +} + +async function openDialog(): Promise { + await page.getByTestId('local-export-open').click(); + await expect(page.getByTestId('local-export-dialog')).toBeVisible(); + await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 }); + // The test's folder only. + await page.getByTestId('local-export-all').click(); + await page.locator(`[data-folder="${FOLDER}"]`).click(); +} + +async function closeSetup(): Promise { + await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByTestId('local-export-dialog')).toBeHidden(); +} + +async function closeSummary(): Promise { + await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Close', exact: true }).last().click(); + await expect(page.getByTestId('local-export-dialog')).toBeHidden(); +} + +async function waitForOriginals(wanted: string): Promise { + await expect.poll(async () => { + await openDialog(); + const text = await page.getByTestId('local-export-kept').textContent(); + await closeSetup(); + return text; + }, { timeout: 240_000, intervals: [5_000] }).toContain(wanted); +} + +async function exported(format: 'mbox' | 'eml'): Promise { + await page.getByTestId(`local-export-format-${format}`).check(); + const download = page.waitForEvent('download', { timeout: 60_000 }); + await page.getByTestId('local-export-start').click(); + const file = await download; + expect(file.suggestedFilename()).toBe(`${FOLDER}.${format === 'mbox' ? 'mbox' : 'zip'}`); + await expect(page.getByTestId('mail-export-status')).toBeVisible(); + return readFileSync(await file.path()); +} + +function mboxMessages(bytes: Buffer): Buffer[] { + const splitter = new MboxSplitter(); + return [...splitter.push(new Uint8Array(bytes)), ...splitter.end()].map((message) => Buffer.concat(withoutStateHeaders(message.parts))); +} + +function expectOriginals(messages: Buffer[]): void { + expect(messages.length).toBe(COUNT); + for (let i = 0; i < COUNT; i += 1) expect(messages[i].equals(originals[i]), `message ${i + 1} differs`).toBe(true); +} + +async function spoolReport(): Promise<{ left: string[]; bytes: number; readable: boolean }> { + return page.evaluate(async (secret) => { + const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites; + const decoder = new TextDecoder('latin1'); + const root = await navigator.storage.getDirectory(); + const left: string[] = []; + try { + const dir = await root.getDirectoryHandle('bulwark-exports'); + for await (const name of (dir as unknown as { keys(): AsyncIterable }).keys()) left.push(name); + } catch { /* never made */ } + return { + left, + bytes: writes.reduce((sum, write) => sum + write.length, 0), + readable: writes.some((write) => decoder.decode(write).includes(secret)), + }; + }, SECRET); +} + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.addInitScript(INIT); + await settings(page); + const toggle = page.getByTestId('offline-switch'); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); + await waitForOriginals(`${COUNT} of ${COUNT}`); +}); + +test.afterAll(async () => { + await context?.close(); +}); + +test('a folder exported offline as mbox is the bytes the server has, and the private files hold nothing readable', async () => { + await context.setOffline(true); + await openDialog(); + await expect(page.getByTestId('local-export-kept')).toContainText(`${COUNT} of ${COUNT}`); + await expect(page.getByTestId('local-export-missing')).toHaveCount(0); + await shot(page, 'd10-export-setup'); + const mbox = await exported('mbox'); + expectOriginals(mboxMessages(mbox)); + await expect(page.getByTestId('mail-export-status')).toContainText(`Messages exported: ${COUNT}`); + await shot(page, 'd10-export-done'); + const report = await spoolReport(); + expect(report.bytes).toBeGreaterThan(0); + expect(report.readable).toBe(false); + // Gone the moment the export was done, with the download still free to read its Blob. + expect(report.left).toEqual([]); + await closeSummary(); +}); + +test('the same folder as a ZIP of .eml files has the same bytes', async () => { + await openDialog(); + const zip = new Blob([new Uint8Array(await exported('eml'))]); + const entries = await listZipEntries(zip); + expect(entries.map((entry) => entry.name).every((name) => name.startsWith(`${FOLDER}/`) && name.endsWith('.eml'))).toBe(true); + expectOriginals(await Promise.all(entries.map(async (entry) => Buffer.from(await readZipEntry(zip, entry))))); + const report = await spoolReport(); + expect(report.readable).toBe(false); + expect(report.left).toEqual([]); + await closeSummary(); +}); + +test('with a save picker, it writes straight to the chosen file and no private file is used', async () => { + const before = (await spoolReport()).bytes; + await page.evaluate(() => { + const parts: Uint8Array[] = []; + (window as unknown as { __picked: Uint8Array[] }).__picked = parts; + (window as unknown as { showSaveFilePicker: unknown }).showSaveFilePicker = async () => ({ + createWritable: async () => ({ + write: async (data: Uint8Array) => { parts.push(data.slice()); }, + close: async () => {}, + abort: async () => {}, + }), + }); + }); + await openDialog(); + await page.getByTestId('local-export-format-mbox').check(); + await page.getByTestId('local-export-start').click(); + await expect(page.getByTestId('mail-export-status')).toContainText(`Messages exported: ${COUNT}`); + const picked = await page.evaluate(() => { + const parts = (window as unknown as { __picked: Uint8Array[] }).__picked; + return parts.flatMap((part) => Array.from(part)); + }); + expectOriginals(mboxMessages(Buffer.from(picked))); + expect((await spoolReport()).bytes).toBe(before); + await closeSummary(); + await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; }); +}); + +test('with attachments off, offline counts every message and says why; the retry online writes them all', async () => { + await context.setOffline(false); + await page.getByTestId('offline-attachments').click(); + await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'false'); + // A choice reaches the store after a short wait, so a quick change of mind costs nothing. + await waitForOriginals(`0 of ${COUNT}`); + await context.setOffline(true); + await openDialog(); + await expect(page.getByTestId('local-export-kept')).toContainText(`0 of ${COUNT}`); + await expect(page.getByTestId('local-export-missing')).toContainText(`offline: ${COUNT}`); + await expect(page.getByTestId('local-export-reason')).toContainText('Keep attachments'); + await shot(page, 'd10-export-offline-missing'); + await page.getByTestId('local-export-format-mbox').check(); + const download = page.waitForEvent('download', { timeout: 60_000 }); + await page.getByTestId('local-export-start').click(); + await download; + await expect(page.getByTestId('local-export-not-kept-attachments')).toContainText(`attachments aren't kept on this device: ${COUNT}`); + await expect(page.getByTestId('local-export-retry')).toBeDisabled(); + await expect(page.getByTestId('local-export-retry-hint')).toHaveText("You can try again when you're online."); + await shot(page, 'd10-export-not-kept'); + + await context.setOffline(false); + await expect(page.getByTestId('local-export-retry')).toBeEnabled({ timeout: 30_000 }); + await page.getByTestId('local-export-retry').click(); + // The same folder is still the one chosen. + await expect(page.getByTestId('local-export-kept')).toContainText(`0 of ${COUNT}`, { timeout: 30_000 }); + await expect(page.getByTestId('local-export-missing')).toContainText(`To download from the server: ${COUNT}`); + const again = await exported('mbox'); + expectOriginals(mboxMessages(again)); + await closeSummary(); + + await page.getByTestId('offline-attachments').click(); + await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'true'); +}); + +test('at 390 px the dialog fits, and works by keyboard', async () => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByTestId('local-export-open').scrollIntoViewIfNeeded(); + await page.getByTestId('local-export-open').focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByTestId('local-export-dialog'); + await expect(dialog).toBeVisible(); + await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 }); + const widths = await page.evaluate(() => { + const panel = document.querySelector('[data-testid="local-export-dialog"]') as HTMLElement; + return { page: document.documentElement.scrollWidth, panel: panel.getBoundingClientRect().width, inner: panel.scrollWidth }; + }); + expect(widths.page).toBeLessThanOrEqual(390); + expect(widths.panel).toBeLessThanOrEqual(390); + expect(widths.inner).toBeLessThanOrEqual(390); + await shot(page, 'd10-export-phone'); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await page.setViewportSize({ width: 1440, height: 900 }); +}); + +test('"No export" locks it, also once offline', async ({ browser }) => { + const admin = await adminContext(browser); + try { + await withPolicy(admin, { organisation: { devices: { ...DEVICES_OFF, noExport: true } } }, async () => { + await settings(page); + await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 }); + await expect(page.getByText('Set by your organisation').first()).toBeVisible(); + await context.setOffline(true); + await expect(page.getByTestId('local-export-open')).toBeDisabled(); + await expect(page.getByTestId('local-export-dialog')).toHaveCount(0); + await context.setOffline(false); + }); + } finally { + await admin.close(); + } + await settings(page); + await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 30_000 }); +}); diff --git a/lib/desktop/taskbar-progress.ts b/lib/desktop/taskbar-progress.ts new file mode 100644 index 000000000..69098f37b --- /dev/null +++ b/lib/desktop/taskbar-progress.ts @@ -0,0 +1,13 @@ +import { getDesktopBridge } from './bridge'; + +type ProgressMode = 'normal' | 'indeterminate' | 'paused' | 'error'; + +/** + * The taskbar's progress bar: 0 to 1, or null to take it away. Does nothing in a + * browser, or in a shell with no `os.setProgress`, which arrives with + * feat/os-integration. When that merges, its setTaskbarProgress can replace this. + */ +export function showTaskbarProgress(progress: number | null, mode?: ProgressMode): void { + const bridge = getDesktopBridge() as { os?: { setProgress?: (progress: number | null, mode?: ProgressMode) => void } } | null; + bridge?.os?.setProgress?.(progress, mode); +} diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts new file mode 100644 index 000000000..ea21f2982 --- /dev/null +++ b/lib/mail-export/__tests__/local-store-export.test.ts @@ -0,0 +1,261 @@ +// @vitest-environment node +/** + * An export from the local store, on real SQLite: the store keeps raw messages as it + * does in a browser, and what comes out is read back from the file. + */ +import { describe, expect, it, vi } from 'vitest'; +import { runExport, type ExportFormat, type ExportProgress } from '../engine'; +import { localExportClient } from '../local-store-client'; +import { memorySink, type ExportSink } from '../sinks'; +import { MboxSplitter } from '@/lib/mail-import/mbox'; +import { withoutStateHeaders } from '@/lib/mail-import/mbox-status'; +import { listZipEntries, readZipEntry } from '@/lib/mail-import/zip-reader'; +import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area'; +import { AccountEngine } from '@/lib/store/engine/account-engine'; +import { TransportError, type MethodCall } from '@/lib/store/engine/transport'; +import { mailArea } from '@/lib/store/mail'; +import { outboxArea } from '@/lib/store/outbox'; +import { FakeJmapDrafts } from '@/lib/store/__tests__/helpers/fake-drafts'; +import { FAKE_SESSION, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter'; + +const SCOPE: StoreScope = { ...DEFAULT_SCOPE, days: null }; +const enc = new TextEncoder(); + +/** Bytes a rebuilt or re-encoded message wouldn't have: bare LF, CRLF, Latin-1, trailing space, an mbox trap. */ +function rawMessage(n: number): Uint8Array { + const head = enc.encode(`From: a${n}@example.org\r\nSubject: Secret subject ${n} \r\nDKIM-Signature: v=1; b=abc${n}\r\n\r\n`); + const body = enc.encode(`From the start of a line\n>From quoted once\r\nlast line ${n}\r\n`); + const out = new Uint8Array(head.length + 2 + body.length); + out.set(head); + out.set([0xe4, 0x0a], head.length); + out.set(body, head.length + 2); + return out; +} + +class Server extends FakeJmapDrafts { + readonly files = new Map(); + readonly fetched: string[] = []; + gone = new Set(); + + private read(blobId: string): Uint8Array { + this.fetched.push(blobId); + if (this.gone.has(blobId)) throw new TransportError('http', 'Gone', 404); + return this.files.get(blobId) ?? new Uint8Array(); + } + + override async download(_jmap?: string, blobId?: string): Promise { + return this.read(String(blobId)); + } + + async downloadStream(_jmap: string, blobId: string, _type: string, _name: string, sink: (part: Uint8Array) => void): Promise { + sink(this.read(blobId)); + } +} + +function serverWith(ids: Record): Server { + const server = new Server({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Archive' }] }); + let n = 0; + for (const [id, box] of Object.entries(ids)) { + n += 1; + const data = rawMessage(n); + server.files.set(`raw-${id}`, data); + server.addEmail({ id, blobId: `raw-${id}`, size: data.length, subject: `Secret subject ${n}`, mailboxIds: { [box]: true }, receivedAt: new Date(Date.UTC(2026, 9, n)).toISOString() }); + } + return server; +} + +async function storeOf(server: Server, scope: StoreScope = SCOPE, areas = [mailArea]) { + const engine = new AccountEngine({ + account: 'acc', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: server, + areas, + scope, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + return { + engine, + store: { + answer: (_account: string, calls: MethodCall[]) => engine.answer(calls), + area: (_account: string, name: string, params?: unknown) => engine.areaCall(name, params) as Promise, + }, + }; +} + +function collecting(): { sink: ExportSink; bytes: () => Uint8Array } { + const inner = memorySink('x', 'application/octet-stream'); + const parts: Uint8Array[] = []; + return { + sink: { ...inner, write: async (chunk) => { parts.push(chunk.slice()); } }, + bytes: () => new Uint8Array(Buffer.concat(parts)), + }; +} + +type Store = Awaited>['store']; +type Online = Parameters[0]['online']; + +async function run(store: Store, format: ExportFormat, mailboxes: string[], online: Online = () => null): Promise<{ result: ExportProgress; bytes: Uint8Array }> { + const { sink, bytes } = collecting(); + const result = await runExport({ + client: localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online }), + accountId: JMAP_ACCOUNT, + folders: mailboxes.map((mailboxId) => ({ mailboxId, path: [mailboxId] })), + format, + sink, + signal: new AbortController().signal, + requestsPerMinute: Infinity, + sleep: async () => {}, + onProgress: () => {}, + }); + return { result, bytes: bytes() }; +} + +async function zipEntries(bytes: Uint8Array): Promise> { + const file = new Blob([bytes as BlobPart]); + const out = new Map(); + for (const entry of await listZipEntries(file)) out.set(entry.name, await readZipEntry(file, entry)); + return out; +} + +/** The messages of an mbox file with the quoting and state undone, the way the import reads it. */ +function mboxMessages(bytes: Uint8Array): Uint8Array[] { + const splitter = new MboxSplitter(); + const out = [...splitter.push(bytes), ...splitter.end()]; + return out.map((message) => new Uint8Array(Buffer.concat(withoutStateHeaders(message.parts)))); +} + +describe('an export from the local store', () => { + it('writes a folder offline as a ZIP of .eml files with the exact bytes the server has', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', other: 'archive' }); + const { store } = await storeOf(server); + const before = server.fetched.length; + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(2); + expect(result.failed).toBe(0); + const entries = await zipEntries(bytes); + expect([...entries.keys()]).toEqual(['inbox/00001 Secret subject 1.eml', 'inbox/00002 Secret subject 2.eml']); + expect([...entries.values()][0]).toEqual(server.files.get('raw-a')); + expect([...entries.values()][1]).toEqual(server.files.get('raw-b')); + // Offline in truth: nothing was asked of the server. + expect(server.fetched.length).toBe(before); + }); + + it('writes a folder offline as an mbox whose messages read back as the exact bytes', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', other: 'archive' }); + const { store } = await storeOf(server); + const { result, bytes } = await run(store, 'mbox', ['inbox']); + expect(result.phase).toBe('done'); + const messages = mboxMessages(bytes); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual(server.files.get('raw-a')); + expect(messages[1]).toEqual(server.files.get('raw-b')); + }); + + it('counts what it has no original for by reason, and still writes the rest', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + server.gone.add('raw-b'); + server.gone.add('raw-c'); + const { store } = await storeOf(server); + expect(await store.area('acc', 'mail.rawStatus', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ total: 3, kept: 1 }); + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(1); + expect(result.failed).toBe(2); + expect(result.notKept).toEqual({ not_kept: 2 }); + // Counted, not a list of 2 errors the dialog would print one by one. + expect(result.failures).toEqual([]); + expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a')]); + }); + + it('says it is because attachments are off when they are', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + const { result } = await run(store, 'mbox', ['inbox']); + expect(result.notKept).toEqual({ attachments_off: 2 }); + expect(result.exported).toBe(0); + }); + + it('fetches what isn\'t kept when online, one at a time or in a batch, and reads the rest from the store', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + server.gone.add('raw-b'); + server.gone.add('raw-c'); + const { store } = await storeOf(server); + const fetchBlobForTransfer = vi.fn(async (blobId: string) => server.files.get(blobId)!); + const getBlobsAsBase64 = vi.fn(async (blobIds: string[]) => ({ + data: new Map([[blobIds[0], Buffer.from(server.files.get(blobIds[0])!).toString('base64')]]), + failed: [], + })); + const { result, bytes } = await run(store, 'eml', ['inbox'], () => ({ fetchBlobForTransfer, getBlobsAsBase64, getMaxConcurrentRequests: () => 4 })); + expect(result.exported).toBe(3); + expect(result.notKept).toBeUndefined(); + // The kept one isn't asked for, once it isn't missing from the batch. + expect(getBlobsAsBase64.mock.calls[0][0]).toEqual(['raw-b', 'raw-c']); + expect(fetchBlobForTransfer.mock.calls.map((call) => call[0])).toEqual(['raw-c']); + expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a'), server.files.get('raw-b'), server.files.get('raw-c')]); + }); + + it('counts the rest as not kept when the connection goes during the run, instead of waiting on each', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + for (const id of ['raw-a', 'raw-b', 'raw-c']) server.gone.add(id); + const { store } = await storeOf(server); + const fetchBlobForTransfer = vi.fn(async () => { throw new TypeError('Load failed'); }); + const { result } = await run(store, 'mbox', ['inbox'], () => ({ fetchBlobForTransfer })); + expect(result.phase).toBe('done'); + expect(result.notKept).toEqual({ not_kept: 3 }); + // Three tries in all, not three per message. + expect(fetchBlobForTransfer).toHaveBeenCalledTimes(3); + }); + + it('reports a server\'s refusal of one message as that message\'s failure', async () => { + const server = serverWith({ a: 'inbox' }); + server.gone.add('raw-a'); + const { store } = await storeOf(server); + const { result } = await run(store, 'mbox', ['inbox'], () => ({ + fetchBlobForTransfer: async () => { throw new Error('Failed to fetch blob: 403'); }, + })); + expect(result.notKept).toBeUndefined(); + expect(result.failures).toEqual([{ label: 'Secret subject 1', reason: 'Failed to fetch blob: 403' }]); + }); + + it('counts a draft written on this device, which has no original yet, and never asks the server for it', async () => { + const server = new Server({ mailboxes: [{ id: 'drafts', name: 'Drafts', role: 'drafts' }] }); + const { engine, store } = await storeOf(server, SCOPE, [mailArea, outboxArea]); + await engine.answer([['Email/set', { create: { c: { subject: 'Draft', keywords: { $draft: true }, mailboxIds: { drafts: true }, receivedAt: new Date().toISOString(), bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } } }, 's']]); + const fetchBlobForTransfer = vi.fn(async () => new Uint8Array()); + const { result } = await run(store, 'mbox', ['drafts'], () => ({ fetchBlobForTransfer })); + expect(result.total).toBe(1); + expect(result.notKept).toEqual({ not_kept: 1 }); + expect(fetchBlobForTransfer).not.toHaveBeenCalled(); + }); + + it('reads the folder list and every message past one page', async () => { + const ids: Record = {}; + for (let i = 0; i < 503; i += 1) ids[`m${i}`] = 'inbox'; + const server = serverWith(ids); + for (let i = 0; i < 503; i += 1) server.gone.add(`raw-m${i}`); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + const client = localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online: () => null }); + expect((await client.getMailboxes()).map((box) => box.id).sort()).toEqual(['archive', 'inbox']); + const list = await client.queryEmailFields({ inMailbox: 'inbox' }, ['blobId', 'subject'], JMAP_ACCOUNT, Number.MAX_SAFE_INTEGER); + expect(list).toHaveLength(503); + expect(new Set(list.map((email) => email.id)).size).toBe(503); + }); + + it('keeps to the server\'s pace only for what goes to the network', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + server.gone.add('raw-b'); + const { store } = await storeOf(server); + const take = vi.fn(async () => {}); + const fetchBlobForTransfer = vi.fn(async (blobId: string) => server.files.get(blobId)!); + const client = localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online: () => ({ fetchBlobForTransfer }), gate: { take } }); + await client.fetchBlobForTransfer!('raw-a', JMAP_ACCOUNT); + expect(take).not.toHaveBeenCalled(); + await client.fetchBlobForTransfer!('raw-b', JMAP_ACCOUNT); + expect(take).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/mail-export/engine.ts b/lib/mail-export/engine.ts index a49f5a724..802a75672 100644 --- a/lib/mail-export/engine.ts +++ b/lib/mail-export/engine.ts @@ -30,11 +30,26 @@ export interface ExportFolder { path: string[]; } +export type NotKeptReason = "attachments_off" | "not_kept"; + +/** + * A local-store export has no original of this message and can't reach the + * server. Counted by reason in `notKept`, not listed one by one. + */ +export class MessageNotKeptError extends Error { + constructor(readonly reason: NotKeptReason) { + super("The original of this message is not on this device"); + this.name = "MessageNotKeptError"; + } +} + export interface ExportProgress { phase: ExportPhase; exported: number; failed: number; failures: { label: string; reason: string }[]; + /** The part of `failed` a local-store export had no original for, by reason. */ + notKept?: Partial>; total: number; fraction: number; pausedUntil?: number; @@ -42,8 +57,11 @@ export interface ExportProgress { error?: string; } +/** What an export reads through: the real client, or lib/mail-export/local-store-client.ts. */ +export type ExportClient = Pick; + export interface ExportOptions { - client: IJMAPClient; + client: ExportClient; accountId: string; folders: ExportFolder[]; format: ExportFormat; @@ -53,7 +71,10 @@ export interface ExportOptions { now?: () => number; sleep?: (ms: number, signal: AbortSignal) => Promise; reportEveryMs?: number; - /** Requests per minute the export allows itself (the server's limit is 1000 for everything). */ + /** + * Requests per minute the export allows itself (the server's limit is 1000 for + * everything). Infinity for a client that keeps to the limit itself. + */ requestsPerMinute?: number; } @@ -141,10 +162,11 @@ export function folderTree(mailboxes: Mailbox[]): ExportFolder[] { } /** Keeps the export under a number of requests per minute. */ -class RateGate { +export class RateGate { private times: number[] = []; constructor(private readonly perMinute: number, private readonly now: () => number, private readonly sleep: (ms: number) => Promise) {} async take(): Promise { + if (this.perMinute === Infinity) return; for (;;) { const t = this.now(); this.times = this.times.filter((x) => t - x < 60_000); @@ -167,7 +189,7 @@ export async function runExport(options: ExportOptions): Promise if (!force && t - lastReport < (options.reportEveryMs ?? 150)) return; lastReport = t; progress.fraction = progress.total > 0 ? Math.min(1, (progress.exported + progress.failed) / progress.total) : 0; - options.onProgress({ ...progress, failures: [...progress.failures] }); + options.onProgress({ ...progress, failures: [...progress.failures], ...(progress.notKept ? { notKept: { ...progress.notKept } } : {}) }); }; const fail = (label: string, reason: string) => { progress.failed++; @@ -319,7 +341,14 @@ export async function runExport(options: ExportOptions): Promise const raw = results[i]; seq++; if (raw instanceof Error) { - fail(ref.subject, raw.message); + if (raw.name === "MessageNotKeptError") { + const { reason } = raw as MessageNotKeptError; + progress.failed++; + progress.notKept = { ...progress.notKept, [reason]: (progress.notKept?.[reason] ?? 0) + 1 }; + } else { + fail(ref.subject, raw.message); + } + report(); continue; } if (options.format === "mbox") { @@ -335,6 +364,8 @@ export async function runExport(options: ExportOptions): Promise } if (stream) await stream.close(); } + // A cancel with nothing left to fetch must not close and hand out the file. + if (signal.aborted) throw cancelled(); if (zip) await zip.finish(); progress.file = await sink.close(); progress.phase = "done"; diff --git a/lib/mail-export/local-store-client.ts b/lib/mail-export/local-store-client.ts new file mode 100644 index 000000000..9c4f555a3 --- /dev/null +++ b/lib/mail-export/local-store-client.ts @@ -0,0 +1,126 @@ +/** + * What an export reads from when it runs from the local store: the methods + * lib/mail-export/engine.ts uses, answered from the store. A message comes as the + * raw original the store kept, never rebuilt, since a rebuilt one loses signatures + * and exact bytes. For one that isn't kept, the real client fetches it as usual + * when the server can be reached. Otherwise it's a MessageNotKeptError, which the + * engine counts. + * + * Store reads have no rate limit, so the engine runs with no gate of its own and + * this keeps to the server's limit for what goes to the network. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { StoreBackend } from '@/lib/store/client/api'; +import type { MethodCall } from '@/lib/store/engine/transport'; +import { MessageNotKeptError, RateGate, type ExportClient, type NotKeptReason } from './engine'; + +const PAGE = 500; +const REQUESTS_PER_MINUTE = 600; +/** Failed downloads in a row before the rest of the run stops trying the network. */ +const NETWORK_TRIES = 3; + +type RawAnswer = { ok: true; blob: Blob } | { ok: false; reason: NotKeptReason }; + +export interface LocalExportClientOptions { + account: string; + /** The JMAP account in the store (not the local account id). */ + jmap: string; + store: Pick; + /** The real client if the server may be reached right now, else null. Asked for each message. */ + online: () => Partial> | null; + /** Keeps what goes to the network to the server's pace. A test can pass its own. */ + gate?: Pick; +} + +export type LocalExportClient = ExportClient & { getMailboxes(): Promise }; + +function isAbort(error: unknown): boolean { + return (error as { name?: string } | null)?.name === 'AbortError'; +} + +/** A fetch that never got an answer: TypeError in every browser, whatever it says, or the client's own time-out. */ +function isNetworkFailure(error: unknown): boolean { + if (isAbort(error)) return false; + const { name, message } = (error ?? {}) as { name?: string; message?: string }; + return name === 'TypeError' || /network|timed out/i.test(message ?? ''); +} + +function networkGate(): RateGate { + return new RateGate(REQUESTS_PER_MINUTE, Date.now, (ms) => new Promise((resolve) => setTimeout(resolve, ms))); +} + +export function localExportClient({ account, jmap, store, online, gate = networkGate() }: LocalExportClientOptions): LocalExportClient { + let networkMisses = 0; + const reachable = () => (networkMisses >= NETWORK_TRIES ? null : online()); + + const call = async (name: string, args: Record): Promise => { + const calls: MethodCall[] = [[name, { accountId: jmap, ...args }, '0']]; + const answer = await store.answer(account, calls); + const [method, result] = answer?.methodResponses?.[0] ?? []; + if (!answer || method === 'error') throw new Error(String((result as { description?: string; type?: string } | undefined)?.description ?? (result as { type?: string } | undefined)?.type ?? `${name} got no answer`)); + return result as T; + }; + + const fetchBlobForTransfer = async (blobId: string, _accountId: string | undefined, signal?: AbortSignal): Promise => { + // A draft written on this device: the server has never heard of it, and it has no original yet. + if (isStandInId(blobId)) throw new MessageNotKeptError('not_kept'); + const kept = await store.area(account, 'mail.rawBlob', { jmap, blobId }); + if (kept.ok) return new Uint8Array(await kept.blob.arrayBuffer()); + const real = reachable(); + if (!real?.fetchBlobForTransfer) throw new MessageNotKeptError(kept.reason); + await gate.take(); + try { + const bytes = await real.fetchBlobForTransfer(blobId, jmap, signal); + networkMisses = 0; + return bytes; + } catch (error) { + if (!isNetworkFailure(error)) throw error; + networkMisses += 1; + // The engine tries this again; by the last try this message counts as not kept. + throw new Error('network failure'); + } + }; + + return { + getMailboxes: async () => (await call<{ list: Mailbox[] }>('Mailbox/get', { ids: null })).list, + + queryEmailFields: async (filter, properties, _accountId, limit = Number.MAX_SAFE_INTEGER) => { + const emails: Record[] = []; + for (let position = 0; emails.length < limit;) { + const { ids } = await call<{ ids: string[] }>('Email/query', { + filter, + sort: [{ property: 'receivedAt', isAscending: false }], + position, + limit: Math.min(PAGE, limit - emails.length), + }); + if (ids.length === 0) break; + const { list } = await call<{ list: Record[] }>('Email/get', { ids, properties }); + // A message with no blobId still counts: the engine leaves such a row out silently. + emails.push(...list.map((email) => (typeof email.blobId === 'string' ? email : { ...email, blobId: newStandInId() }))); + position += ids.length; + if (ids.length < PAGE) break; + } + return emails as never; + }, + + getBlobsAsBase64: async (blobIds) => { + const real = reachable(); + const wanted = blobIds.filter((blobId) => !isStandInId(blobId)); + if (!real?.getBlobsAsBase64 || wanted.length === 0) return null; + const kept = new Set(await store.area(account, 'mail.rawKept', { jmap, blobIds: wanted })); + const missing = wanted.filter((blobId) => !kept.has(blobId)); + if (missing.length === 0) return null; + await gate.take(); + return real.getBlobsAsBase64(missing, jmap, { sized: true }); + }, + + fetchBlobForTransfer, + + fetchBlob: async (blobId) => new Blob([await fetchBlobForTransfer(blobId, jmap) as BlobPart]), + + getMaxConcurrentRequests: () => online()?.getMaxConcurrentRequests?.() ?? 4, + }; +} diff --git a/lib/offline/wipe-registry.ts b/lib/offline/wipe-registry.ts new file mode 100644 index 000000000..2b9d8f393 --- /dev/null +++ b/lib/offline/wipe-registry.ts @@ -0,0 +1,39 @@ +/** + * Where a feature with data of its own on this device signs up for wipes: + * edit-in-place's opened files, for one. Free of the stores, so anything can + * import it. Registering is all a feature has to do. + * + * A cleaner runs on every remote wipe, policy wipe, wipe from settings and + * refused login, before the account signs out. `account` is an account id, or + * `null` when everything on this device goes. A cleaner should finish quickly + * and may not ask the user anything. Bridge calls are fine, network isn't: a + * wipe often runs because the server said no. + */ + +export type WipeCleaner = (account: string | null) => void | Promise; + +const cleaners = new Set(); + +export function onDeviceWipe(cleaner: WipeCleaner): () => void { + cleaners.add(cleaner); + return () => cleaners.delete(cleaner); +} + +/** How long a wipe waits for one cleaner. What's on screen waits with it. */ +const CLEANER_BUDGET_MS = 10_000; + +export async function runWipeCleaners(account: string | null): Promise { + await Promise.all([...cleaners].map(async (cleaner) => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + cleaner(account), + new Promise((resolve) => { timer = setTimeout(resolve, CLEANER_BUDGET_MS); }), + ]); + } catch { + // One feature failing must not keep the others' data. + } finally { + if (timer) clearTimeout(timer); + } + })); +} diff --git a/lib/store/mail/attachments.ts b/lib/store/mail/attachments.ts index 12feba84a..83a39d859 100644 --- a/lib/store/mail/attachments.ts +++ b/lib/store/mail/attachments.ts @@ -169,6 +169,17 @@ export function rawBlob(ctx: Pick, jmap: string, b return { ok: true, type: RAW_TYPE, size: kept.size, blob: new Blob([kept.blob], { type: RAW_TYPE }) }; } +/** Which of these whole messages are here, so an online export asks the server only for the rest. */ +export function rawKept(ctx: Pick, jmap: string, blobIds: readonly string[]): string[] { + if (!ctx.scope.attachments || blobIds.length === 0) return []; + const marks = blobIds.map(() => '?').join(', '); + return ctx.db.all<{ blob_id: string }>( + `SELECT DISTINCT p.blob_id FROM email_part p JOIN blob b ON b.jmap = p.jmap AND b.blob_id = p.blob_id + WHERE p.jmap = ? AND p.raw = 1 AND p.blob_id IN (${marks})`, + [jmap, ...blobIds], + ).map((row) => String(row.blob_id)); +} + /** How many messages (of one folder, or all) an export could write right now. */ export function rawStatus(ctx: Pick, jmap: string, mailboxId?: string): { total: number; kept: number } { const inFolder = mailboxId ? 'JOIN email_mailbox m ON m.email_rowid = e.rowid AND m.mailbox_id = ?' : ''; diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index b8202c86e..7139c395e 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -1,7 +1,7 @@ /** What the page asks the mail part of the store outside JMAP. */ import type { AreaCall, StoreContext } from '../area'; -import { keptBlob, rawBlob, rawStatus, recordKeptBlob } from './attachments'; +import { keptBlob, rawBlob, rawKept, rawStatus, recordKeptBlob } from './attachments'; import { storeBody, upsertEmail, type EmailObject } from './records'; /** @@ -55,6 +55,8 @@ export const MAIL_CALLS: Readonly> = { 'mail.getBlob': getBlob, /** A whole message for an offline export, or why not. */ 'mail.rawBlob': (ctx, params: { jmap: string; blobId: string }) => rawBlob(ctx, params.jmap, params.blobId), + /** Which of a batch are here: an online export fetches the others in one request. */ + 'mail.rawKept': (ctx, params: { jmap: string; blobIds: string[] }) => rawKept(ctx, params.jmap, params.blobIds), /** How many messages an export could write now, so it can say how many it can't. */ 'mail.rawStatus': (ctx, params: { jmap: string; mailboxId?: string }) => rawStatus(ctx, params.jmap, params.mailboxId), 'mail.usage': usage, diff --git a/locales/ar/common.json b/locales/ar/common.json index 6c467aa5c..dba61c101 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "المسودات التي رفضها الخادم: {count}", "banner_waiting": "مسودات في انتظار الخادم: {count}" }, + "offlineExport": { + "settings_label": "تصدير البريد المحفوظ", + "settings_description": "احفظ بريد هذا الجهاز كملفات mbox أو ‎.eml. يعمل ذلك دون اتصال أيضًا.", + "title": "تصدير البريد المحفوظ", + "description": "يحفظ هذا ما يحتفظ به هذا الجهاز كما هو تمامًا. البريد الأقدم من الفترة التي اخترتها غير مشمول. لحفظ كل ما على الخادم، استخدم «تصدير كل البريد».", + "folders_label": "المجلدات", + "all_folders": "كل المجلدات", + "folder_kept": "هنا: {kept} من {total}", + "loading": "جارٍ قراءة ما على هذا الجهاز…", + "load_failed": "تعذّرت قراءة البريد على هذا الجهاز.", + "nothing_kept": "لا يوجد بريد محفوظ على هذا الجهاز بعد.", + "kept": "النسخ الأصلية على هذا الجهاز: {kept} من {total} رسالة", + "missing_online": "للتنزيل من الخادم: {count}", + "missing_offline": "يتعذّر التصدير دون اتصال: {count}. يمكنك المحاولة مرة أخرى عند الاتصال.", + "reason_attachments_off": "لا تُحفظ الرسائل الأصلية إلا عند تشغيل «الاحتفاظ بالمرفقات».", + "reason_not_kept": "تُنزَّل الرسائل الأصلية في النهاية، وما دامت هناك مساحة لها فقط.", + "not_kept_attachments_off": "لا توجد نسخة أصلية لأن هذا الجهاز لا يحتفظ بالمرفقات: {count}", + "not_kept_not_kept": "لا توجد نسخة أصلية على هذا الجهاز بعد: {count}", + "retry": "إعادة المحاولة عند الاتصال", + "retry_online": "أنت متصل الآن، لذا يمكن جلبها من الخادم.", + "retry_offline": "يمكنك المحاولة مرة أخرى عند الاتصال.", + "settings_button": "تصدير…" + }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", "thread_not_found": "لم تعد هذه المحادثة متاحة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index f793ac547..1f54e077f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Esborranys rebutjats pel servidor: {count}", "banner_waiting": "Esborranys que esperen el servidor: {count}" }, + "offlineExport": { + "settings_label": "Exporta el correu desat", + "settings_description": "Desa el correu d'aquest dispositiu com a fitxers mbox o .eml. Funciona sense connexió.", + "title": "Exporta el correu desat", + "description": "Desa el que conserva aquest dispositiu, byte a byte. El correu anterior al període triat no hi és. Per desar-ho tot del servidor, fes servir «Exporta tot el correu».", + "folders_label": "Carpetes", + "all_folders": "Totes les carpetes", + "folder_kept": "{kept} de {total} aquí", + "loading": "S'està llegint el que hi ha en aquest dispositiu…", + "load_failed": "No s'ha pogut llegir el correu d'aquest dispositiu.", + "nothing_kept": "Encara no hi ha correu desat en aquest dispositiu.", + "kept": "Originals en aquest dispositiu: {kept} de {total} missatges", + "missing_online": "Per baixar del servidor: {count}", + "missing_offline": "No es poden exportar sense connexió: {count}. Pots tornar-ho a provar quan tinguis connexió.", + "reason_attachments_off": "Els missatges originals només es conserven si «Desa els fitxers adjunts» està activat.", + "reason_not_kept": "Els missatges originals es baixen al final, i només si queda espai.", + "not_kept_attachments_off": "Sense original aquí perquè aquest dispositiu no conserva adjunts: {count}", + "not_kept_not_kept": "Encara sense original en aquest dispositiu: {count}", + "retry": "Torna-ho a provar amb connexió", + "retry_online": "Ja tens connexió, així que poden venir del servidor.", + "retry_offline": "Pots tornar-ho a provar quan tinguis connexió.", + "settings_button": "Exporta…" + }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", "thread_not_found": "Aquesta conversa ja no està disponible.", diff --git a/locales/cs/common.json b/locales/cs/common.json index a873b79b5..a14d93cb8 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Koncepty odmítnuté serverem: {count}", "banner_waiting": "Koncepty čekající na server: {count}" }, + "offlineExport": { + "settings_label": "Exportovat uloženou poštu", + "settings_description": "Uložte poštu z tohoto zařízení jako soubory mbox nebo .eml. Funguje to i offline.", + "title": "Exportovat uloženou poštu", + "description": "Uloží to, co toto zařízení uchovává, bajt po bajtu. Pošta starší než zvolené období v tom není. Chcete-li uložit vše ze serveru, použijte „Exportovat všechnu poštu“.", + "folders_label": "Složky", + "all_folders": "Všechny složky", + "folder_kept": "{kept} z {total} zde", + "loading": "Čtení dat z tohoto zařízení…", + "load_failed": "Poštu na tomto zařízení se nepodařilo přečíst.", + "nothing_kept": "Na tomto zařízení zatím není uložená žádná pošta.", + "kept": "Originály na tomto zařízení: {kept} z {total} zpráv", + "missing_online": "Ke stažení ze serveru: {count}", + "missing_offline": "Nelze exportovat offline: {count}. Můžete to zkusit znovu, až budete online.", + "reason_attachments_off": "Původní zprávy se uchovávají jen tehdy, když je zapnuto „Uchovávat přílohy“.", + "reason_not_kept": "Původní zprávy se stahují jako poslední a jen tehdy, když je na ně místo.", + "not_kept_attachments_off": "Bez originálu, protože toto zařízení neuchovává přílohy: {count}", + "not_kept_not_kept": "Zatím bez originálu na tomto zařízení: {count}", + "retry": "Zkusit znovu online", + "retry_online": "Jste online, takže mohou přijít ze serveru.", + "retry_offline": "Můžete to zkusit znovu, až budete online.", + "settings_button": "Exportovat…" + }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", "thread_not_found": "Tato konverzace už není dostupná.", diff --git a/locales/da/common.json b/locales/da/common.json index 58c79b036..65436fe69 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Kladder afvist af serveren: {count}", "banner_waiting": "Kladder, der venter på serveren: {count}" }, + "offlineExport": { + "settings_label": "Eksportér gemt post", + "settings_description": "Gem posten på denne enhed som mbox- eller .eml-filer. Det virker også offline.", + "title": "Eksportér gemt post", + "description": "Dette gemmer det, enheden opbevarer, byte for byte. Post, der er ældre end den valgte periode, er ikke med. Brug “Eksportér al mail” for at gemme alt fra serveren.", + "folders_label": "Mapper", + "all_folders": "Alle mapper", + "folder_kept": "{kept} af {total} her", + "loading": "Læser det, der ligger på denne enhed…", + "load_failed": "Posten på denne enhed kunne ikke læses.", + "nothing_kept": "Der er endnu ikke gemt post på denne enhed.", + "kept": "Originaler på denne enhed: {kept} af {total} beskeder", + "missing_online": "Skal hentes fra serveren: {count}", + "missing_offline": "Kan ikke eksporteres offline: {count}. Du kan prøve igen, når du er online.", + "reason_attachments_off": "Originale beskeder gemmes kun, når “Behold vedhæftede filer” er slået til.", + "reason_not_kept": "Originale beskeder hentes til sidst, og kun så længe der er plads.", + "not_kept_attachments_off": "Ingen original her, fordi vedhæftede filer ikke gemmes på denne enhed: {count}", + "not_kept_not_kept": "Endnu ingen original på denne enhed: {count}", + "retry": "Prøv igen online", + "retry_online": "Du er online nu, så de kan komme fra serveren.", + "retry_offline": "Du kan prøve igen, når du er online.", + "settings_button": "Eksportér…" + }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", "thread_not_found": "Denne samtale er ikke længere tilgængelig.", diff --git a/locales/de/common.json b/locales/de/common.json index 0388c8994..82790cc10 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Vom Server abgelehnte Entwürfe: {count}", "banner_waiting": "Entwürfe warten auf den Server: {count}" }, + "offlineExport": { + "settings_label": "Gespeicherte E-Mails exportieren", + "settings_description": "Speichern Sie die E-Mails auf diesem Gerät als mbox- oder .eml-Dateien. Das geht auch offline.", + "title": "Gespeicherte E-Mails exportieren", + "description": "Das speichert, was dieses Gerät aufbewahrt, Byte für Byte. E-Mails, die älter als der gewählte Zeitraum sind, fehlen darin. Um alles auf dem Server zu speichern, nutzen Sie „Alle E-Mails exportieren“.", + "folders_label": "Ordner", + "all_folders": "Alle Ordner", + "folder_kept": "{kept} von {total} hier", + "loading": "Daten auf diesem Gerät werden gelesen…", + "load_failed": "Die E-Mails auf diesem Gerät konnten nicht gelesen werden.", + "nothing_kept": "Auf diesem Gerät sind noch keine E-Mails gespeichert.", + "kept": "Originale auf diesem Gerät: {kept} von {total} Nachrichten", + "missing_online": "Vom Server zu laden: {count}", + "missing_offline": "Offline nicht exportierbar: {count}. Sie können es erneut versuchen, sobald Sie online sind.", + "reason_attachments_off": "Originalnachrichten werden nur aufbewahrt, wenn „Anhänge behalten“ eingeschaltet ist.", + "reason_not_kept": "Originalnachrichten werden zuletzt geladen, und nur, solange Platz dafür ist.", + "not_kept_attachments_off": "Kein Original hier, weil Anhänge auf diesem Gerät nicht aufbewahrt werden: {count}", + "not_kept_not_kept": "Noch kein Original auf diesem Gerät: {count}", + "retry": "Online erneut versuchen", + "retry_online": "Sie sind jetzt online, diese Nachrichten können also vom Server kommen.", + "retry_offline": "Sie können es erneut versuchen, sobald Sie online sind.", + "settings_button": "Exportieren…" + }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", "thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.", diff --git a/locales/en/common.json b/locales/en/common.json index c7cf59956..7dfbb4397 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Drafts the server refused: {count}", "banner_waiting": "Drafts waiting for the server: {count}" }, + "offlineExport": { + "settings_label": "Export kept mail", + "settings_description": "Save the mail on this device as mbox or .eml files. This works offline.", + "title": "Export kept mail", + "description": "This saves what this device keeps, byte for byte. Mail older than the period you chose isn't in it. To save everything on the server, use “Export all mail”.", + "folders_label": "Folders", + "all_folders": "All folders", + "folder_kept": "{kept} of {total} here", + "loading": "Reading what's on this device…", + "load_failed": "The mail on this device couldn't be read.", + "nothing_kept": "No mail is kept on this device yet.", + "kept": "Originals on this device: {kept} of {total} messages", + "missing_online": "To download from the server: {count}", + "missing_offline": "Can't be exported while you're offline: {count}. You can try again when you're online.", + "reason_attachments_off": "Original messages are only kept when “Keep attachments” is on.", + "reason_not_kept": "Original messages download last, and only while there's room for them.", + "not_kept_attachments_off": "No original here because attachments aren't kept on this device: {count}", + "not_kept_not_kept": "No original on this device yet: {count}", + "retry": "Try again online", + "retry_online": "You're online now, so these can come from the server.", + "retry_offline": "You can try again when you're online.", + "settings_button": "Export…" + }, "deep_link": { "message_not_found": "This message is no longer available.", "thread_not_found": "This conversation is no longer available.", diff --git a/locales/es/common.json b/locales/es/common.json index 35c0af539..9c06e9efb 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Borradores rechazados por el servidor: {count}", "banner_waiting": "Borradores esperando al servidor: {count}" }, + "offlineExport": { + "settings_label": "Exportar el correo guardado", + "settings_description": "Guarda el correo de este dispositivo como archivos mbox o .eml. Funciona sin conexión.", + "title": "Exportar el correo guardado", + "description": "Guarda lo que conserva este dispositivo, byte a byte. El correo anterior al periodo elegido no está incluido. Para guardar todo lo del servidor, usa «Exportar todo el correo».", + "folders_label": "Carpetas", + "all_folders": "Todas las carpetas", + "folder_kept": "{kept} de {total} aquí", + "loading": "Leyendo lo que hay en este dispositivo…", + "load_failed": "No se pudo leer el correo de este dispositivo.", + "nothing_kept": "Todavía no hay correo guardado en este dispositivo.", + "kept": "Originales en este dispositivo: {kept} de {total} mensajes", + "missing_online": "Por descargar del servidor: {count}", + "missing_offline": "No se pueden exportar sin conexión: {count}. Puedes volver a intentarlo cuando tengas conexión.", + "reason_attachments_off": "Los mensajes originales solo se guardan si «Guardar adjuntos» está activado.", + "reason_not_kept": "Los mensajes originales se descargan al final, y solo si queda espacio.", + "not_kept_attachments_off": "Sin original aquí porque este dispositivo no guarda adjuntos: {count}", + "not_kept_not_kept": "Todavía sin original en este dispositivo: {count}", + "retry": "Reintentar con conexión", + "retry_online": "Ya tienes conexión, así que estos pueden venir del servidor.", + "retry_offline": "Puedes volver a intentarlo cuando tengas conexión.", + "settings_button": "Exportar…" + }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", "thread_not_found": "Esta conversación ya no está disponible.", diff --git a/locales/et/common.json b/locales/et/common.json index d0fc05409..6032131c7 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Serveri tagasi lükatud mustandid: {count}", "banner_waiting": "Serverit ootavad mustandid: {count}" }, + "offlineExport": { + "settings_label": "Hoitud kirjade eksport", + "settings_description": "Salvestage selle seadme kirjad mbox- või .eml-failidena. See töötab ka võrguühenduseta.", + "title": "Hoitud kirjade eksport", + "description": "Salvestab selle, mida seade hoiab, baidi täpsusega. Valitud ajavahemikust vanemaid kirju selles pole. Kõige salvestamiseks serverist kasutage valikut „Ekspordi kõik kirjad”.", + "folders_label": "Kaustad", + "all_folders": "Kõik kaustad", + "folder_kept": "Siin: {kept} / {total}", + "loading": "Selle seadme andmete lugemine…", + "load_failed": "Selle seadme kirju ei õnnestunud lugeda.", + "nothing_kept": "Selles seadmes pole veel ühtegi kirja hoitud.", + "kept": "Originaalid selles seadmes: {kept} / {total} kirja", + "missing_online": "Serverist alla laadida: {count}", + "missing_offline": "Võrguühenduseta ei saa eksportida: {count}. Proovige uuesti, kui olete võrgus.", + "reason_attachments_off": "Originaalkirju hoitakse ainult siis, kui „Hoia manuseid” on sees.", + "reason_not_kept": "Originaalkirjad laaditakse alla viimasena ja ainult siis, kui ruumi on.", + "not_kept_attachments_off": "Originaali pole, sest see seade ei hoia manuseid: {count}", + "not_kept_not_kept": "Selles seadmes pole veel originaali: {count}", + "retry": "Proovi uuesti võrgus", + "retry_online": "Olete nüüd võrgus, nii et need võivad tulla serverist.", + "retry_offline": "Proovige uuesti, kui olete võrgus.", + "settings_button": "Ekspordi…" + }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", "thread_not_found": "See vestlus ei ole enam saadaval.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 5aef56be6..c5d189e36 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "پیش‌نویس‌های ردشده توسط سرور: {count}", "banner_waiting": "پیش‌نویس‌های در انتظار سرور: {count}" }, + "offlineExport": { + "settings_label": "برون‌بری نامه‌های نگه‌داشته‌شده", + "settings_description": "نامه‌های این دستگاه را به‌صورت فایل mbox یا ‎.eml ذخیره کنید. بدون اتصال هم کار می‌کند.", + "title": "برون‌بری نامه‌های نگه‌داشته‌شده", + "description": "آنچه این دستگاه نگه داشته است را عیناً ذخیره می‌کند. نامه‌های قدیمی‌تر از بازه‌ای که انتخاب کرده‌اید در آن نیستند. برای ذخیرهٔ همه‌چیز از سرور، از «برون‌بری همه نامه‌ها» استفاده کنید.", + "folders_label": "پوشه‌ها", + "all_folders": "همهٔ پوشه‌ها", + "folder_kept": "اینجا: {kept} از {total}", + "loading": "در حال خواندن داده‌های این دستگاه…", + "load_failed": "نامه‌های این دستگاه خوانده نشد.", + "nothing_kept": "هنوز نامه‌ای روی این دستگاه نگه داشته نشده است.", + "kept": "نسخه‌های اصلی روی این دستگاه: {kept} از {total} پیام", + "missing_online": "برای دانلود از سرور: {count}", + "missing_offline": "بدون اتصال برون‌بری نمی‌شود: {count}. وقتی آنلاین شدید دوباره امتحان کنید.", + "reason_attachments_off": "پیام‌های اصلی فقط وقتی نگه داشته می‌شوند که «نگه داشتن پیوست‌ها» روشن باشد.", + "reason_not_kept": "پیام‌های اصلی آخر از همه و فقط تا وقتی جا باشد دانلود می‌شوند.", + "not_kept_attachments_off": "نسخهٔ اصلی نیست چون این دستگاه پیوست‌ها را نگه نمی‌دارد: {count}", + "not_kept_not_kept": "هنوز نسخهٔ اصلی روی این دستگاه نیست: {count}", + "retry": "تلاش دوباره به‌صورت آنلاین", + "retry_online": "اکنون آنلاین هستید، پس می‌توانند از سرور بیایند.", + "retry_offline": "وقتی آنلاین شدید دوباره امتحان کنید.", + "settings_button": "برون‌بری…" + }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", "thread_not_found": "این گفتگو دیگر در دسترس نیست.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 277d30d79..d9f3cb91d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Brouillons refusés par le serveur : {count}", "banner_waiting": "Brouillons en attente du serveur : {count}" }, + "offlineExport": { + "settings_label": "Exporter le courrier conservé", + "settings_description": "Enregistrez le courrier de cet appareil en fichiers mbox ou .eml. Cela fonctionne hors ligne.", + "title": "Exporter le courrier conservé", + "description": "Enregistre ce que cet appareil conserve, à l'octet près. Le courrier plus ancien que la période choisie n'y figure pas. Pour tout enregistrer depuis le serveur, utilisez « Exporter tous les e-mails ».", + "folders_label": "Dossiers", + "all_folders": "Tous les dossiers", + "folder_kept": "{kept} sur {total} ici", + "loading": "Lecture des données de cet appareil…", + "load_failed": "Le courrier de cet appareil n'a pas pu être lu.", + "nothing_kept": "Aucun courrier n'est encore conservé sur cet appareil.", + "kept": "Originaux sur cet appareil : {kept} messages sur {total}", + "missing_online": "À télécharger depuis le serveur : {count}", + "missing_offline": "Exportation impossible hors ligne : {count}. Vous pourrez réessayer une fois en ligne.", + "reason_attachments_off": "Les messages originaux ne sont conservés que si « Garder les pièces jointes » est activé.", + "reason_not_kept": "Les messages originaux sont téléchargés en dernier, et seulement s'il reste de la place.", + "not_kept_attachments_off": "Pas d'original ici car les pièces jointes ne sont pas conservées sur cet appareil : {count}", + "not_kept_not_kept": "Pas encore d'original sur cet appareil : {count}", + "retry": "Réessayer en ligne", + "retry_online": "Vous êtes en ligne : ces messages peuvent venir du serveur.", + "retry_offline": "Vous pourrez réessayer une fois en ligne.", + "settings_button": "Exporter…" + }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", "thread_not_found": "Cette conversation n'est plus disponible.", diff --git a/locales/he/common.json b/locales/he/common.json index 58dfb56e7..0efa0b618 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5845,6 +5845,29 @@ "banner_refused": "טיוטות שהשרת דחה: {count}", "banner_waiting": "טיוטות שממתינות לשרת: {count}" }, + "offlineExport": { + "settings_label": "ייצוא דואר שנשמר", + "settings_description": "שמרו את הדואר שבמכשיר הזה כקובצי mbox או ‎.eml. זה עובד גם בלי חיבור.", + "title": "ייצוא דואר שנשמר", + "description": "שומר את מה שהמכשיר הזה מחזיק, בית אחר בית. דואר ישן יותר מהתקופה שבחרתם לא נכלל. כדי לשמור הכול מהשרת, השתמשו ב„ייצוא כל הדואר”.", + "folders_label": "תיקיות", + "all_folders": "כל התיקיות", + "folder_kept": "כאן: {kept} מתוך {total}", + "loading": "קורא את מה שיש במכשיר הזה…", + "load_failed": "לא ניתן היה לקרוא את הדואר במכשיר הזה.", + "nothing_kept": "עדיין לא נשמר דואר במכשיר הזה.", + "kept": "מקוריים במכשיר הזה: {kept} מתוך {total} הודעות", + "missing_online": "להורדה מהשרת: {count}", + "missing_offline": "לא ניתן לייצא בלי חיבור: {count}. אפשר לנסות שוב כשיהיה חיבור.", + "reason_attachments_off": "הודעות מקוריות נשמרות רק כש„שמירת קבצים מצורפים” פעיל.", + "reason_not_kept": "הודעות מקוריות יורדות אחרונות, ורק כל עוד יש מקום.", + "not_kept_attachments_off": "אין מקור כי המכשיר הזה לא שומר קבצים מצורפים: {count}", + "not_kept_not_kept": "עדיין אין מקור במכשיר הזה: {count}", + "retry": "לנסות שוב עם חיבור", + "retry_online": "יש חיבור עכשיו, אז הן יכולות להגיע מהשרת.", + "retry_offline": "אפשר לנסות שוב כשיהיה חיבור.", + "settings_button": "ייצוא…" + }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", "thread_not_found": "שיחה זו אינה זמינה עוד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 410ba5ff5..e933fd336 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "A szerver által elutasított piszkozatok: {count}", "banner_waiting": "A szerverre váró piszkozatok: {count}" }, + "offlineExport": { + "settings_label": "Megőrzött levelek exportálása", + "settings_description": "Mentse az eszközön lévő leveleket mbox vagy .eml fájlként. Offline is működik.", + "title": "Megőrzött levelek exportálása", + "description": "Elmenti, amit ez az eszköz megőriz, bájtról bájtra. A választott időszaknál régebbi levelek nincsenek benne. A szerveren lévő összes levél mentéséhez használja ezt: „Az összes levél exportálása”.", + "folders_label": "Mappák", + "all_folders": "Minden mappa", + "folder_kept": "{kept} / {total} itt", + "loading": "Az eszközön lévő adatok olvasása…", + "load_failed": "Az eszközön lévő leveleket nem sikerült beolvasni.", + "nothing_kept": "Ezen az eszközön még nincsenek megőrzött levelek.", + "kept": "Eredetik ezen az eszközön: {kept} / {total} üzenet", + "missing_online": "A szerverről letöltendő: {count}", + "missing_offline": "Offline nem exportálható: {count}. Ha újra online lesz, megpróbálhatja újra.", + "reason_attachments_off": "Az eredeti üzeneteket csak akkor őrzi meg az eszköz, ha be van kapcsolva ez: „Mellékletek megtartása”.", + "reason_not_kept": "Az eredeti üzenetek utoljára töltődnek le, és csak addig, amíg van hely.", + "not_kept_attachments_off": "Nincs itt eredeti, mert ez az eszköz nem őrzi meg a mellékleteket: {count}", + "not_kept_not_kept": "Még nincs eredeti ezen az eszközön: {count}", + "retry": "Újra online", + "retry_online": "Most online van, így ezek jöhetnek a szerverről.", + "retry_offline": "Ha újra online lesz, megpróbálhatja újra.", + "settings_button": "Exportálás…" + }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", "thread_not_found": "Ez a beszélgetés már nem érhető el.", diff --git a/locales/it/common.json b/locales/it/common.json index 051aca7a3..e301258ed 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Bozze rifiutate dal server: {count}", "banner_waiting": "Bozze in attesa del server: {count}" }, + "offlineExport": { + "settings_label": "Esporta la posta conservata", + "settings_description": "Salva la posta di questo dispositivo come file mbox o .eml. Funziona anche offline.", + "title": "Esporta la posta conservata", + "description": "Salva ciò che questo dispositivo conserva, byte per byte. La posta più vecchia del periodo scelto non è inclusa. Per salvare tutto dal server, usa «Esporta tutta la posta».", + "folders_label": "Cartelle", + "all_folders": "Tutte le cartelle", + "folder_kept": "{kept} di {total} qui", + "loading": "Lettura dei dati su questo dispositivo…", + "load_failed": "Impossibile leggere la posta su questo dispositivo.", + "nothing_kept": "Su questo dispositivo non è ancora conservata posta.", + "kept": "Originali su questo dispositivo: {kept} di {total} messaggi", + "missing_online": "Da scaricare dal server: {count}", + "missing_offline": "Non esportabili offline: {count}. Puoi riprovare quando sei online.", + "reason_attachments_off": "I messaggi originali vengono conservati solo se «Conserva gli allegati» è attivo.", + "reason_not_kept": "I messaggi originali vengono scaricati per ultimi e solo se c'è spazio.", + "not_kept_attachments_off": "Nessun originale qui perché su questo dispositivo gli allegati non vengono conservati: {count}", + "not_kept_not_kept": "Ancora nessun originale su questo dispositivo: {count}", + "retry": "Riprova online", + "retry_online": "Ora sei online, quindi possono arrivare dal server.", + "retry_offline": "Puoi riprovare quando sei online.", + "settings_button": "Esporta…" + }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", "thread_not_found": "Questa conversazione non è più disponibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3b7a425db..53240591a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "サーバーが拒否した下書き: {count}", "banner_waiting": "サーバーを待っている下書き: {count}" }, + "offlineExport": { + "settings_label": "保存済みメールをエクスポート", + "settings_description": "このデバイスのメールを mbox または .eml ファイルとして保存します。オフラインでも使えます。", + "title": "保存済みメールをエクスポート", + "description": "このデバイスに保存されているものをそのまま書き出します。選んだ期間より古いメールは含まれません。サーバーのすべてを保存するには「すべてのメールをエクスポート」を使ってください。", + "folders_label": "フォルダー", + "all_folders": "すべてのフォルダー", + "folder_kept": "ここに {kept} / {total}", + "loading": "このデバイスのデータを読み込んでいます…", + "load_failed": "このデバイスのメールを読み込めませんでした。", + "nothing_kept": "このデバイスにはまだメールが保存されていません。", + "kept": "このデバイスにある原本: {kept} / {total} 件", + "missing_online": "サーバーからダウンロード: {count}", + "missing_offline": "オフラインではエクスポートできません: {count}。オンラインになったらもう一度お試しください。", + "reason_attachments_off": "元のメッセージは「添付ファイルを保存」がオンのときだけ保存されます。", + "reason_not_kept": "元のメッセージは最後に、空きがある間だけダウンロードされます。", + "not_kept_attachments_off": "このデバイスに添付ファイルを保存していないため原本なし: {count}", + "not_kept_not_kept": "このデバイスにまだ原本なし: {count}", + "retry": "オンラインでもう一度試す", + "retry_online": "オンラインなので、サーバーから取得できます。", + "retry_offline": "オンラインになったらもう一度お試しください。", + "settings_button": "エクスポート…" + }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", "thread_not_found": "この会話は利用できなくなりました。", diff --git a/locales/ko/common.json b/locales/ko/common.json index b453ba6e2..057bff4cb 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "서버가 거부한 임시 보관 메일: {count}", "banner_waiting": "서버를 기다리는 임시 보관 메일: {count}" }, + "offlineExport": { + "settings_label": "보관된 메일 내보내기", + "settings_description": "이 기기의 메일을 mbox 또는 .eml 파일로 저장합니다. 오프라인에서도 됩니다.", + "title": "보관된 메일 내보내기", + "description": "이 기기에 보관된 내용을 그대로 저장합니다. 선택한 기간보다 오래된 메일은 포함되지 않습니다. 서버의 모든 메일을 저장하려면 “모든 메일 내보내기”을(를) 사용하세요.", + "folders_label": "폴더", + "all_folders": "모든 폴더", + "folder_kept": "여기에 {kept}/{total}", + "loading": "이 기기의 데이터를 읽는 중…", + "load_failed": "이 기기의 메일을 읽지 못했습니다.", + "nothing_kept": "이 기기에 아직 보관된 메일이 없습니다.", + "kept": "이 기기의 원본: {kept}/{total}개 메시지", + "missing_online": "서버에서 다운로드: {count}", + "missing_offline": "오프라인에서는 내보낼 수 없음: {count}. 온라인이 되면 다시 시도할 수 있습니다.", + "reason_attachments_off": "원본 메시지는 “첨부 파일 보관”이(가) 켜져 있을 때만 보관됩니다.", + "reason_not_kept": "원본 메시지는 맨 마지막에, 공간이 있을 때만 다운로드됩니다.", + "not_kept_attachments_off": "이 기기에 첨부 파일을 보관하지 않아 원본 없음: {count}", + "not_kept_not_kept": "이 기기에 아직 원본 없음: {count}", + "retry": "온라인에서 다시 시도", + "retry_online": "지금 온라인이므로 서버에서 가져올 수 있습니다.", + "retry_offline": "온라인이 되면 다시 시도할 수 있습니다.", + "settings_button": "내보내기…" + }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", "thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 06ae58e27..25a58a069 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Servera noraidītie melnraksti: {count}", "banner_waiting": "Melnraksti, kas gaida serveri: {count}" }, + "offlineExport": { + "settings_label": "Eksportēt saglabāto pastu", + "settings_description": "Saglabājiet šīs ierīces pastu kā mbox vai .eml failus. Darbojas arī bezsaistē.", + "title": "Eksportēt saglabāto pastu", + "description": "Saglabā to, ko glabā šī ierīce, baitu pa baitam. Pasta, kas vecāks par izvēlēto periodu, tajā nav. Lai saglabātu visu no servera, izmantojiet “Eksportēt visu pastu”.", + "folders_label": "Mapes", + "all_folders": "Visas mapes", + "folder_kept": "Šeit: {kept} no {total}", + "loading": "Tiek lasīti šīs ierīces dati…", + "load_failed": "Neizdevās nolasīt šīs ierīces pastu.", + "nothing_kept": "Šajā ierīcē vēl nav saglabāta pasta.", + "kept": "Oriģināli šajā ierīcē: {kept} no {total} ziņojumiem", + "missing_online": "Jālejupielādē no servera: {count}", + "missing_offline": "Bezsaistē nevar eksportēt: {count}. Varat mēģināt vēlreiz, kad būsiet tiešsaistē.", + "reason_attachments_off": "Oriģinālie ziņojumi tiek glabāti tikai tad, ja ir ieslēgts “Glabāt pielikumus”.", + "reason_not_kept": "Oriģinālie ziņojumi tiek lejupielādēti pēdējie un tikai tad, ja ir vieta.", + "not_kept_attachments_off": "Oriģināla nav, jo šī ierīce neglabā pielikumus: {count}", + "not_kept_not_kept": "Šajā ierīcē vēl nav oriģināla: {count}", + "retry": "Mēģināt vēlreiz tiešsaistē", + "retry_online": "Tagad esat tiešsaistē, tāpēc tie var nākt no servera.", + "retry_offline": "Varat mēģināt vēlreiz, kad būsiet tiešsaistē.", + "settings_button": "Eksportēt…" + }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", "thread_not_found": "Šī sarakste vairs nav pieejama.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 59782e7af..1793a5a5f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Серверийн татгалзсан ноорог: {count}", "banner_waiting": "Серверийг хүлээж буй ноорог: {count}" }, + "offlineExport": { + "settings_label": "Хадгалсан захидлыг экспортлох", + "settings_description": "Энэ төхөөрөмжийн захидлыг mbox эсвэл .eml файлаар хадгална. Офлайн үед ч ажиллана.", + "title": "Хадгалсан захидлыг экспортлох", + "description": "Энэ төхөөрөмжид хадгалагдсан зүйлийг яг байгаагаар нь хадгална. Сонгосон хугацаанаас хуучин захидал орохгүй. Серверт байгаа бүгдийг хадгалахын тулд “Бүх шуудан экспортлох”-г ашиглана уу.", + "folders_label": "Хавтаснууд", + "all_folders": "Бүх хавтас", + "folder_kept": "Энд: {total}-с {kept}", + "loading": "Энэ төхөөрөмжийн өгөгдлийг уншиж байна…", + "load_failed": "Энэ төхөөрөмжийн захидлыг уншиж чадсангүй.", + "nothing_kept": "Энэ төхөөрөмжид хадгалсан захидал одоогоор алга.", + "kept": "Энэ төхөөрөмж дээрх эх хувь: {total}-с {kept}", + "missing_online": "Серверээс татах: {count}", + "missing_offline": "Офлайн үед экспортлох боломжгүй: {count}. Онлайн болсны дараа дахин оролдоно уу.", + "reason_attachments_off": "Эх захидлуудыг зөвхөн “Хавсралтуудыг хадгалах” асаалттай үед хадгална.", + "reason_not_kept": "Эх захидлууд хамгийн сүүлд, зөвхөн зай байвал татагдана.", + "not_kept_attachments_off": "Энэ төхөөрөмж хавсралт хадгалдаггүй тул эх хувь алга: {count}", + "not_kept_not_kept": "Энэ төхөөрөмжид эх хувь одоогоор алга: {count}", + "retry": "Онлайнаар дахин оролдох", + "retry_online": "Та одоо онлайн байгаа тул серверээс авч болно.", + "retry_offline": "Онлайн болсны дараа дахин оролдоно уу.", + "settings_button": "Экспортлох…" + }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", "thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 460ed0217..d2d4497da 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Utkast avvist av serveren: {count}", "banner_waiting": "Utkast som venter på serveren: {count}" }, + "offlineExport": { + "settings_label": "Eksporter lagret e-post", + "settings_description": "Lagre e-posten på denne enheten som mbox- eller .eml-filer. Dette virker også frakoblet.", + "title": "Eksporter lagret e-post", + "description": "Dette lagrer det enheten beholder, byte for byte. E-post eldre enn perioden du valgte, er ikke med. Bruk «Eksporter all e-post» for å lagre alt fra serveren.", + "folders_label": "Mapper", + "all_folders": "Alle mapper", + "folder_kept": "{kept} av {total} her", + "loading": "Leser det som ligger på denne enheten…", + "load_failed": "Kunne ikke lese e-posten på denne enheten.", + "nothing_kept": "Ingen e-post er lagret på denne enheten ennå.", + "kept": "Originaler på denne enheten: {kept} av {total} meldinger", + "missing_online": "Skal lastes ned fra serveren: {count}", + "missing_offline": "Kan ikke eksporteres frakoblet: {count}. Du kan prøve igjen når du er tilkoblet.", + "reason_attachments_off": "Originale meldinger beholdes bare når «Behold vedlegg» er på.", + "reason_not_kept": "Originale meldinger lastes ned sist, og bare så lenge det er plass.", + "not_kept_attachments_off": "Ingen original her fordi vedlegg ikke beholdes på denne enheten: {count}", + "not_kept_not_kept": "Ingen original på denne enheten ennå: {count}", + "retry": "Prøv igjen tilkoblet", + "retry_online": "Du er tilkoblet nå, så disse kan komme fra serveren.", + "retry_offline": "Du kan prøve igjen når du er tilkoblet.", + "settings_button": "Eksporter…" + }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", "thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8b260a849..f0c96dd0d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Door de server geweigerde concepten: {count}", "banner_waiting": "Concepten die op de server wachten: {count}" }, + "offlineExport": { + "settings_label": "Bewaarde e-mail exporteren", + "settings_description": "Sla de e-mail op dit apparaat op als mbox- of .eml-bestanden. Dit werkt ook offline.", + "title": "Bewaarde e-mail exporteren", + "description": "Dit slaat op wat dit apparaat bewaart, byte voor byte. E-mail ouder dan de gekozen periode zit er niet bij. Gebruik “Alle e-mail exporteren” om alles van de server op te slaan.", + "folders_label": "Mappen", + "all_folders": "Alle mappen", + "folder_kept": "{kept} van {total} hier", + "loading": "Gegevens op dit apparaat lezen…", + "load_failed": "De e-mail op dit apparaat kon niet worden gelezen.", + "nothing_kept": "Op dit apparaat is nog geen e-mail bewaard.", + "kept": "Originelen op dit apparaat: {kept} van {total} berichten", + "missing_online": "Te downloaden van de server: {count}", + "missing_offline": "Niet te exporteren terwijl u offline bent: {count}. U kunt het opnieuw proberen als u online bent.", + "reason_attachments_off": "Originele berichten worden alleen bewaard als “Bijlagen bewaren” aan staat.", + "reason_not_kept": "Originele berichten worden als laatste gedownload, en alleen als er ruimte voor is.", + "not_kept_attachments_off": "Geen origineel hier omdat bijlagen niet op dit apparaat worden bewaard: {count}", + "not_kept_not_kept": "Nog geen origineel op dit apparaat: {count}", + "retry": "Opnieuw proberen online", + "retry_online": "U bent nu online, dus deze kunnen van de server komen.", + "retry_offline": "U kunt het opnieuw proberen als u online bent.", + "settings_button": "Exporteren…" + }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", "thread_not_found": "Dit gesprek is niet meer beschikbaar.", diff --git a/locales/pl/common.json b/locales/pl/common.json index f8c9161f9..54a373e0c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Wersje robocze odrzucone przez serwer: {count}", "banner_waiting": "Wersje robocze czekające na serwer: {count}" }, + "offlineExport": { + "settings_label": "Eksportuj zachowaną pocztę", + "settings_description": "Zapisz pocztę z tego urządzenia jako pliki mbox lub .eml. Działa to też offline.", + "title": "Eksportuj zachowaną pocztę", + "description": "Zapisuje to, co przechowuje to urządzenie, bajt w bajt. Poczty starszej niż wybrany okres tu nie ma. Aby zapisać wszystko z serwera, użyj opcji „Eksportuj całą pocztę”.", + "folders_label": "Foldery", + "all_folders": "Wszystkie foldery", + "folder_kept": "{kept} z {total} tutaj", + "loading": "Odczytywanie danych z tego urządzenia…", + "load_failed": "Nie udało się odczytać poczty na tym urządzeniu.", + "nothing_kept": "Na tym urządzeniu nie ma jeszcze zachowanej poczty.", + "kept": "Oryginały na tym urządzeniu: {kept} z {total} wiadomości", + "missing_online": "Do pobrania z serwera: {count}", + "missing_offline": "Nie można wyeksportować offline: {count}. Spróbuj ponownie, gdy będziesz online.", + "reason_attachments_off": "Oryginalne wiadomości są zachowywane tylko wtedy, gdy opcja „Przechowuj załączniki” jest włączona.", + "reason_not_kept": "Oryginalne wiadomości są pobierane na końcu i tylko wtedy, gdy jest na nie miejsce.", + "not_kept_attachments_off": "Brak oryginału, bo załączniki nie są zachowywane na tym urządzeniu: {count}", + "not_kept_not_kept": "Jeszcze bez oryginału na tym urządzeniu: {count}", + "retry": "Spróbuj ponownie online", + "retry_online": "Masz połączenie, więc mogą przyjść z serwera.", + "retry_offline": "Spróbuj ponownie, gdy będziesz online.", + "settings_button": "Eksportuj…" + }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", "thread_not_found": "Ta rozmowa nie jest już dostępna.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0b2a251fc..e3f369efa 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Rascunhos recusados pelo servidor: {count}", "banner_waiting": "Rascunhos a aguardar o servidor: {count}" }, + "offlineExport": { + "settings_label": "Exportar correio guardado", + "settings_description": "Guarde o correio deste dispositivo em ficheiros mbox ou .eml. Funciona sem ligação.", + "title": "Exportar correio guardado", + "description": "Guarda o que este dispositivo conserva, byte a byte. O correio mais antigo do que o período escolhido não está incluído. Para guardar tudo do servidor, use “Exportar todos os e-mails”.", + "folders_label": "Pastas", + "all_folders": "Todas as pastas", + "folder_kept": "{kept} de {total} aqui", + "loading": "A ler o que está neste dispositivo…", + "load_failed": "Não foi possível ler o correio deste dispositivo.", + "nothing_kept": "Ainda não há correio guardado neste dispositivo.", + "kept": "Originais neste dispositivo: {kept} de {total} mensagens", + "missing_online": "A transferir do servidor: {count}", + "missing_offline": "Não podem ser exportadas sem ligação: {count}. Pode tentar de novo quando tiver ligação.", + "reason_attachments_off": "As mensagens originais só são guardadas quando “Manter anexos” está ativo.", + "reason_not_kept": "As mensagens originais são transferidas por último, e só enquanto houver espaço.", + "not_kept_attachments_off": "Sem original aqui porque este dispositivo não guarda anexos: {count}", + "not_kept_not_kept": "Ainda sem original neste dispositivo: {count}", + "retry": "Tentar de novo com ligação", + "retry_online": "Já tem ligação, por isso estas podem vir do servidor.", + "retry_offline": "Pode tentar de novo quando tiver ligação.", + "settings_button": "Exportar…" + }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", "thread_not_found": "Esta conversa não está mais disponível.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7186c3627..409029675 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Ciorne refuzate de server: {count}", "banner_waiting": "Ciorne care așteaptă serverul: {count}" }, + "offlineExport": { + "settings_label": "Exportă mesajele păstrate", + "settings_description": "Salvați mesajele de pe acest dispozitiv ca fișiere mbox sau .eml. Funcționează și offline.", + "title": "Exportă mesajele păstrate", + "description": "Salvează ce păstrează acest dispozitiv, octet cu octet. Mesajele mai vechi decât perioada aleasă nu sunt incluse. Pentru a salva tot de pe server, folosiți „Exportă toate e-mailurile”.", + "folders_label": "Dosare", + "all_folders": "Toate dosarele", + "folder_kept": "{kept} din {total} aici", + "loading": "Se citesc datele de pe acest dispozitiv…", + "load_failed": "Mesajele de pe acest dispozitiv nu au putut fi citite.", + "nothing_kept": "Pe acest dispozitiv nu sunt încă mesaje păstrate.", + "kept": "Originale pe acest dispozitiv: {kept} din {total} mesaje", + "missing_online": "De descărcat de pe server: {count}", + "missing_offline": "Nu pot fi exportate offline: {count}. Puteți încerca din nou când sunteți online.", + "reason_attachments_off": "Mesajele originale sunt păstrate doar când „Păstrează atașamentele” este activat.", + "reason_not_kept": "Mesajele originale se descarcă ultimele și doar cât timp este loc.", + "not_kept_attachments_off": "Fără original aici, deoarece atașamentele nu sunt păstrate pe acest dispozitiv: {count}", + "not_kept_not_kept": "Încă fără original pe acest dispozitiv: {count}", + "retry": "Încearcă din nou online", + "retry_online": "Sunteți online acum, deci pot veni de pe server.", + "retry_offline": "Puteți încerca din nou când sunteți online.", + "settings_button": "Exportă…" + }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", "thread_not_found": "Această conversație nu mai este disponibilă.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 05b7baaab..78f5cfd37 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Черновики, отклонённые сервером: {count}", "banner_waiting": "Черновики, ожидающие сервер: {count}" }, + "offlineExport": { + "settings_label": "Экспорт сохранённой почты", + "settings_description": "Сохраните почту с этого устройства в файлы mbox или .eml. Работает и без сети.", + "title": "Экспорт сохранённой почты", + "description": "Сохраняет то, что хранится на этом устройстве, байт в байт. Почты старше выбранного периода здесь нет. Чтобы сохранить всё с сервера, используйте «Экспортировать всю почту».", + "folders_label": "Папки", + "all_folders": "Все папки", + "folder_kept": "Здесь: {kept} из {total}", + "loading": "Чтение данных на этом устройстве…", + "load_failed": "Не удалось прочитать почту на этом устройстве.", + "nothing_kept": "На этом устройстве ещё нет сохранённой почты.", + "kept": "Оригиналы на этом устройстве: {kept} из {total}", + "missing_online": "Загрузить с сервера: {count}", + "missing_offline": "Нельзя экспортировать без сети: {count}. Повторите попытку, когда появится сеть.", + "reason_attachments_off": "Исходные сообщения хранятся, только если включено «Хранить вложения».", + "reason_not_kept": "Исходные сообщения загружаются в последнюю очередь и только пока есть место.", + "not_kept_attachments_off": "Нет оригинала, потому что на этом устройстве не хранятся вложения: {count}", + "not_kept_not_kept": "Пока нет оригинала на этом устройстве: {count}", + "retry": "Повторить в сети", + "retry_online": "Сеть есть, поэтому их можно получить с сервера.", + "retry_offline": "Повторите попытку, когда появится сеть.", + "settings_button": "Экспортировать…" + }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", "thread_not_found": "Эта переписка больше недоступна.", diff --git a/locales/sk/common.json b/locales/sk/common.json index a21785e59..bc15cd35c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Koncepty odmietnuté serverom: {count}", "banner_waiting": "Koncepty čakajúce na server: {count}" }, + "offlineExport": { + "settings_label": "Exportovať uloženú poštu", + "settings_description": "Uložte poštu z tohto zariadenia ako súbory mbox alebo .eml. Funguje to aj offline.", + "title": "Exportovať uloženú poštu", + "description": "Uloží to, čo toto zariadenie uchováva, bajt po bajte. Pošta staršia ako zvolené obdobie v tom nie je. Ak chcete uložiť všetko zo servera, použite „Exportovať všetku poštu“.", + "folders_label": "Priečinky", + "all_folders": "Všetky priečinky", + "folder_kept": "{kept} z {total} tu", + "loading": "Čítanie údajov z tohto zariadenia…", + "load_failed": "Poštu na tomto zariadení sa nepodarilo prečítať.", + "nothing_kept": "Na tomto zariadení ešte nie je uložená žiadna pošta.", + "kept": "Originály na tomto zariadení: {kept} z {total} správ", + "missing_online": "Na stiahnutie zo servera: {count}", + "missing_offline": "Nedá sa exportovať offline: {count}. Môžete to skúsiť znova, keď budete online.", + "reason_attachments_off": "Pôvodné správy sa uchovávajú, len keď je zapnuté „Uchovávať prílohy“.", + "reason_not_kept": "Pôvodné správy sa sťahujú ako posledné a len vtedy, keď je na ne miesto.", + "not_kept_attachments_off": "Bez originálu, pretože toto zariadenie neuchováva prílohy: {count}", + "not_kept_not_kept": "Zatiaľ bez originálu na tomto zariadení: {count}", + "retry": "Skúsiť znova online", + "retry_online": "Ste online, takže môžu prísť zo servera.", + "retry_offline": "Môžete to skúsiť znova, keď budete online.", + "settings_button": "Exportovať…" + }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", "thread_not_found": "Táto konverzácia už nie je dostupná.", diff --git a/locales/th/common.json b/locales/th/common.json index 7b92fca95..5bf4c56af 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2189,6 +2189,29 @@ "banner_refused": "ฉบับร่างที่เซิร์ฟเวอร์ปฏิเสธ: {count}", "banner_waiting": "ฉบับร่างที่รอเซิร์ฟเวอร์: {count}" }, + "offlineExport": { + "settings_label": "ส่งออกอีเมลที่เก็บไว้", + "settings_description": "บันทึกอีเมลในอุปกรณ์นี้เป็นไฟล์ mbox หรือ .eml ใช้งานแบบออฟไลน์ได้", + "title": "ส่งออกอีเมลที่เก็บไว้", + "description": "บันทึกสิ่งที่อุปกรณ์นี้เก็บไว้ตามต้นฉบับทุกไบต์ อีเมลที่เก่ากว่าช่วงเวลาที่คุณเลือกจะไม่รวมอยู่ด้วย หากต้องการบันทึกทุกอย่างจากเซิร์ฟเวอร์ ให้ใช้ “ส่งออกอีเมลทั้งหมด”", + "folders_label": "โฟลเดอร์", + "all_folders": "ทุกโฟลเดอร์", + "folder_kept": "ที่นี่ {kept} จาก {total}", + "loading": "กำลังอ่านข้อมูลในอุปกรณ์นี้…", + "load_failed": "อ่านอีเมลในอุปกรณ์นี้ไม่ได้", + "nothing_kept": "ยังไม่มีอีเมลที่เก็บไว้ในอุปกรณ์นี้", + "kept": "ต้นฉบับในอุปกรณ์นี้: {kept} จาก {total} ข้อความ", + "missing_online": "ต้องดาวน์โหลดจากเซิร์ฟเวอร์: {count}", + "missing_offline": "ส่งออกขณะออฟไลน์ไม่ได้: {count} ลองอีกครั้งได้เมื่อออนไลน์", + "reason_attachments_off": "ข้อความต้นฉบับจะเก็บไว้เฉพาะเมื่อเปิด “เก็บไฟล์แนบ”", + "reason_not_kept": "ข้อความต้นฉบับจะดาวน์โหลดเป็นลำดับสุดท้าย และเฉพาะเมื่อมีพื้นที่", + "not_kept_attachments_off": "ไม่มีต้นฉบับเพราะอุปกรณ์นี้ไม่เก็บไฟล์แนบ: {count}", + "not_kept_not_kept": "ยังไม่มีต้นฉบับในอุปกรณ์นี้: {count}", + "retry": "ลองอีกครั้งแบบออนไลน์", + "retry_online": "ตอนนี้คุณออนไลน์แล้ว จึงดึงจากเซิร์ฟเวอร์ได้", + "retry_offline": "ลองอีกครั้งได้เมื่อออนไลน์", + "settings_button": "ส่งออก…" + }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", "not_available_title": "หน้านี้ไม่พร้อมใช้งาน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 2517fb477..3c630b29f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Sunucunun reddettiği taslaklar: {count}", "banner_waiting": "Sunucuyu bekleyen taslaklar: {count}" }, + "offlineExport": { + "settings_label": "Saklanan postayı dışa aktar", + "settings_description": "Bu cihazdaki postayı mbox veya .eml dosyası olarak kaydedin. Çevrimdışı da çalışır.", + "title": "Saklanan postayı dışa aktar", + "description": "Bu cihazın sakladıklarını bayt bayt kaydeder. Seçtiğiniz süreden eski postalar buna dahil değildir. Sunucudaki her şeyi kaydetmek için “Tüm e-postaları dışa aktar” seçeneğini kullanın.", + "folders_label": "Klasörler", + "all_folders": "Tüm klasörler", + "folder_kept": "Burada: {kept} / {total}", + "loading": "Bu cihazdakiler okunuyor…", + "load_failed": "Bu cihazdaki posta okunamadı.", + "nothing_kept": "Bu cihazda henüz saklanan posta yok.", + "kept": "Bu cihazdaki orijinaller: {kept} / {total} ileti", + "missing_online": "Sunucudan indirilecek: {count}", + "missing_offline": "Çevrimdışıyken dışa aktarılamaz: {count}. Çevrimiçi olduğunuzda yeniden deneyebilirsiniz.", + "reason_attachments_off": "Orijinal iletiler yalnızca “Ekleri tut” açıkken saklanır.", + "reason_not_kept": "Orijinal iletiler en son indirilir, o da yalnızca yer varsa.", + "not_kept_attachments_off": "Bu cihazda ekler saklanmadığı için orijinal yok: {count}", + "not_kept_not_kept": "Bu cihazda henüz orijinal yok: {count}", + "retry": "Çevrimiçi yeniden dene", + "retry_online": "Artık çevrimiçisiniz, bunlar sunucudan gelebilir.", + "retry_offline": "Çevrimiçi olduğunuzda yeniden deneyebilirsiniz.", + "settings_button": "Dışa aktar…" + }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", "thread_not_found": "Bu sohbet artık kullanılamıyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index efc829880..fc7449ac4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "Чернетки, відхилені сервером: {count}", "banner_waiting": "Чернетки, що чекають на сервер: {count}" }, + "offlineExport": { + "settings_label": "Експорт збереженої пошти", + "settings_description": "Збережіть пошту з цього пристрою у файли mbox або .eml. Працює і без мережі.", + "title": "Експорт збереженої пошти", + "description": "Зберігає те, що зберігається на цьому пристрої, байт у байт. Пошти, старшої за вибраний період, тут немає. Щоб зберегти все із сервера, скористайтеся «Експортувати всю пошту».", + "folders_label": "Папки", + "all_folders": "Усі папки", + "folder_kept": "Тут: {kept} із {total}", + "loading": "Читання даних на цьому пристрої…", + "load_failed": "Не вдалося прочитати пошту на цьому пристрої.", + "nothing_kept": "На цьому пристрої ще немає збереженої пошти.", + "kept": "Оригінали на цьому пристрої: {kept} із {total}", + "missing_online": "Завантажити із сервера: {count}", + "missing_offline": "Не можна експортувати без мережі: {count}. Спробуйте ще раз, коли з'явиться мережа.", + "reason_attachments_off": "Початкові повідомлення зберігаються, лише якщо ввімкнено «Зберігати вкладення».", + "reason_not_kept": "Початкові повідомлення завантажуються останніми й лише поки є місце.", + "not_kept_attachments_off": "Немає оригіналу, бо на цьому пристрої не зберігаються вкладення: {count}", + "not_kept_not_kept": "Поки немає оригіналу на цьому пристрої: {count}", + "retry": "Повторити в мережі", + "retry_online": "Мережа є, тож їх можна отримати із сервера.", + "retry_offline": "Спробуйте ще раз, коли з'явиться мережа.", + "settings_button": "Експортувати…" + }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", "thread_not_found": "Ця розмова більше недоступна.", diff --git a/locales/vi/common.json b/locales/vi/common.json index e57089754..729378a0f 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5856,6 +5856,29 @@ "banner_refused": "Bản nháp bị máy chủ từ chối: {count}", "banner_waiting": "Bản nháp đang chờ máy chủ: {count}" }, + "offlineExport": { + "settings_label": "Xuất thư đã lưu", + "settings_description": "Lưu thư trên thiết bị này thành tệp mbox hoặc .eml. Hoạt động cả khi ngoại tuyến.", + "title": "Xuất thư đã lưu", + "description": "Lưu đúng nguyên bản những gì thiết bị này giữ. Thư cũ hơn khoảng thời gian bạn chọn không có trong đó. Để lưu mọi thứ trên máy chủ, hãy dùng “Xuất toàn bộ thư”.", + "folders_label": "Thư mục", + "all_folders": "Tất cả thư mục", + "folder_kept": "Ở đây {kept}/{total}", + "loading": "Đang đọc dữ liệu trên thiết bị này…", + "load_failed": "Không đọc được thư trên thiết bị này.", + "nothing_kept": "Chưa có thư nào được lưu trên thiết bị này.", + "kept": "Bản gốc trên thiết bị này: {kept}/{total} thư", + "missing_online": "Cần tải từ máy chủ: {count}", + "missing_offline": "Không xuất được khi ngoại tuyến: {count}. Bạn có thể thử lại khi trực tuyến.", + "reason_attachments_off": "Thư gốc chỉ được giữ khi “Giữ tệp đính kèm” đang bật.", + "reason_not_kept": "Thư gốc được tải sau cùng và chỉ khi còn chỗ.", + "not_kept_attachments_off": "Không có bản gốc vì thiết bị này không giữ tệp đính kèm: {count}", + "not_kept_not_kept": "Chưa có bản gốc trên thiết bị này: {count}", + "retry": "Thử lại khi trực tuyến", + "retry_online": "Bạn đang trực tuyến nên có thể lấy từ máy chủ.", + "retry_offline": "Bạn có thể thử lại khi trực tuyến.", + "settings_button": "Xuất…" + }, "deep_link": { "message_not_found": "Thư này không còn nữa.", "thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index bd69c19d3..ba92e0d83 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "伺服器拒絕的草稿:{count}", "banner_waiting": "等待伺服器的草稿:{count}" }, + "offlineExport": { + "settings_label": "匯出已保存的郵件", + "settings_description": "將此裝置上的郵件儲存為 mbox 或 .eml 檔案。離線時也可使用。", + "title": "匯出已保存的郵件", + "description": "依原樣儲存此裝置保留的內容。早於所選期間的郵件不包含在內。要儲存伺服器上的全部內容,請使用「匯出所有郵件」。", + "folders_label": "資料夾", + "all_folders": "所有資料夾", + "folder_kept": "此處 {kept} / {total}", + "loading": "正在讀取此裝置上的內容…", + "load_failed": "無法讀取此裝置上的郵件。", + "nothing_kept": "此裝置上尚未保存郵件。", + "kept": "此裝置上的原件:{kept} / {total} 封郵件", + "missing_online": "要從伺服器下載:{count}", + "missing_offline": "離線時無法匯出:{count}。連線後可以重試。", + "reason_attachments_off": "只有開啟「保留附件」時才會保留原始郵件。", + "reason_not_kept": "原始郵件最後下載,且僅在有空間時下載。", + "not_kept_attachments_off": "此裝置不保留附件,因此沒有原件:{count}", + "not_kept_not_kept": "此裝置上尚無原件:{count}", + "retry": "連線後重試", + "retry_online": "現在已連線,可以從伺服器取得。", + "retry_offline": "連線後可以重試。", + "settings_button": "匯出…" + }, "deep_link": { "message_not_found": "已無法使用此郵件。", "thread_not_found": "已無法使用此對話串。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4356828ad..d34fbe623 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5844,6 +5844,29 @@ "banner_refused": "服务器拒绝的草稿:{count}", "banner_waiting": "等待服务器的草稿:{count}" }, + "offlineExport": { + "settings_label": "导出已保存的邮件", + "settings_description": "将此设备上的邮件保存为 mbox 或 .eml 文件。离线时也可使用。", + "title": "导出已保存的邮件", + "description": "按原样保存此设备保留的内容。早于所选时间范围的邮件不包含在内。要保存服务器上的全部内容,请使用“导出所有邮件”。", + "folders_label": "文件夹", + "all_folders": "所有文件夹", + "folder_kept": "此处 {kept} / {total}", + "loading": "正在读取此设备上的内容…", + "load_failed": "无法读取此设备上的邮件。", + "nothing_kept": "此设备上还没有保存邮件。", + "kept": "此设备上的原件:{kept} / {total} 封邮件", + "missing_online": "要从服务器下载:{count}", + "missing_offline": "离线时无法导出:{count}。联网后可以重试。", + "reason_attachments_off": "只有开启“保留附件”时才会保留原始邮件。", + "reason_not_kept": "原始邮件最后下载,且仅在有空间时下载。", + "not_kept_attachments_off": "此设备不保留附件,因此没有原件:{count}", + "not_kept_not_kept": "此设备上还没有原件:{count}", + "retry": "联网后重试", + "retry_online": "现在已联网,可以从服务器获取。", + "retry_offline": "联网后可以重试。", + "settings_button": "导出…" + }, "deep_link": { "message_not_found": "此邮件已不可用。", "thread_not_found": "此会话已不可用。", diff --git a/stores/mail-transfer-store.ts b/stores/mail-transfer-store.ts index e5ca8c792..79978fe50 100644 --- a/stores/mail-transfer-store.ts +++ b/stores/mail-transfer-store.ts @@ -22,7 +22,13 @@ export interface ExportRequest { mailboxId?: string; } -export type TransferRequest = ImportRequest | ExportRequest; +/** Mail kept on this device, of a signed-in account, not only the active one. */ +export interface LocalExportRequest { + kind: "local-export"; + account: string; +} + +export type TransferRequest = ImportRequest | ExportRequest | LocalExportRequest; interface JobBase { controller: AbortController; @@ -72,6 +78,7 @@ export const useMailTransferStore = create()((set, get) => ({ // The device policy "No export" of the active account (Organisation > // Devices): the dialog doesn't open and the app says why. Import stays. if (request.kind === "export" && !downloadAllowed("export", null)) return false; + if (request.kind === "local-export" && !downloadAllowed("export", request.account)) return false; if (isJobActive(get().job)) { set({ visible: true }); return false; @@ -103,6 +110,11 @@ export function openMailImport(request: Omit = {}): void useMailTransferStore.getState().open({ kind: "import", ...request }); } +/** Opens the export of what an account keeps on this device. */ +export function openLocalExport(account: string): void { + useMailTransferStore.getState().open({ kind: "local-export", account }); +} + /** Opens the export dialog for a folder, or for the whole account. */ export function openMailExport(request: Omit = {}): void { useMailTransferStore.getState().open({ kind: "export", ...request });