From 436703b0f78a26b236c5551465c06b25b9aef013 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:40:54 +0100 Subject: [PATCH 1/4] fix: keep mail encrypted in the browser's private files during an export Firefox and Safari have no save picker, so an export wrote a plain file into the origin private file system for as long as it ran, and a crash left a whole mailbox readable there until the next start. The check that nothing readable sits in OPFS would find it. The file is now a spool: each piece is sealed with AES-GCM under a key that exists only in the tab's memory. When the export is done it opens into a Blob the browser holds, and the spool goes at once. The test that the private file stays while its download reads it changes to say the same of the Blob: the file no longer outlives the export, so what a download depends on is what gets released after the grace period. --- .../mail-transfer/mail-export-dialog.tsx | 21 +-- lib/mail-export/__tests__/sinks.test.ts | 163 ++++++++++++++++-- lib/mail-export/sinks.ts | 124 +++++++++++-- 3 files changed, 257 insertions(+), 51 deletions(-) diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx index c3e7b6b99..9c09d14b7 100644 --- a/components/mail-transfer/mail-export-dialog.tsx +++ b/components/mail-transfer/mail-export-dialog.tsx @@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store"; import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine"; import { safeName } from "@/lib/mail-export/engine"; -import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks"; +import { canPickSaveFile, canWritePrivateFile, openExportSink } from "@/lib/mail-export/sinks"; import { formatFileSize } from "@/lib/utils"; import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress"; import { startExport } from "./start-export"; @@ -19,7 +19,7 @@ function today(): string { } /** Where the file will go in this browser, said before the export starts. */ -function saveMode(): "picker" | "download" | "memory" { +export function saveMode(): "picker" | "download" | "memory" { if (canPickSaveFile()) return "picker"; return canWritePrivateFile() ? "download" : "memory"; } @@ -57,21 +57,10 @@ export function MailExportDialog({ request }: { request: ExportRequest }) { const name = `${base}.${zip ? "zip" : "mbox"}`; const mime = zip ? "application/zip" : "application/mbox"; setStarting(true); - let sink: ExportSink | null = null; try { // The save picker must open straight from the click. - if (mode === "picker") { - try { - sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox"); - } catch (error) { - if ((error as { name?: string } | null)?.name === "AbortError") return; - sink = null; - } - } - if (!sink) { - await sweepPrivateExports(); - sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime); - } + const sink = await openExportSink(name, mime, zip ? ".zip" : ".mbox"); + if (!sink) return; startExport({ format, accountId: mailbox?.accountId || client.getAccountId(), @@ -165,7 +154,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) { type T = ReturnType; -function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { +export function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { const running = p.phase === "running" || p.phase === "paused"; // The live region says only what changes the user's plans (paused, done); // the count beside it would otherwise be read out several times a second. diff --git a/lib/mail-export/__tests__/sinks.test.ts b/lib/mail-export/__tests__/sinks.test.ts index 915cb4932..f2e7f1551 100644 --- a/lib/mail-export/__tests__/sinks.test.ts +++ b/lib/mail-export/__tests__/sinks.test.ts @@ -1,15 +1,18 @@ +// @vitest-environment node import { describe, it, expect, vi, afterEach } from 'vitest'; -import { canWritePrivateFile, downloadGraceMs, privateFileSink, sweepPrivateExports } from '@/lib/mail-export/sinks'; +import { canWritePrivateFile, downloadGraceMs, guardedSink, openExportSink, privateFileSink, sweepPrivateExports, ExportForbiddenError, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; /** A tiny origin private file system: one directory of files. */ function fakeOpfs() { const files = new Map(); + /** Everything ever written to this file system, deleted or not. */ + const written: Uint8Array[] = []; const dir = { getFileHandle: async (name: string) => { if (!files.has(name)) files.set(name, []); return { createWritable: async () => ({ - write: async (data: Uint8Array) => { files.get(name)!.push(data); }, + write: async (data: Uint8Array) => { files.get(name)!.push(data); written.push(data); }, close: async () => {}, abort: async () => {}, }), @@ -29,7 +32,7 @@ function fakeOpfs() { removeEntry: vi.fn(async () => { files.clear(); }), }; let created = false; - return { files, root }; + return { files, written, root }; } /** Web Locks where `held` names belong to another tab. */ @@ -66,30 +69,158 @@ describe('sweepPrivateExports', () => { }); describe('privateFileSink', () => { - it('keeps the file while its download may still read it, and deletes it after', async () => { - vi.useFakeTimers(); + const MAIL = new TextEncoder().encode('Subject: Very secret mail\r\n\r\nThe plain body\r\n'); + + /** A sink on a fake file system; `handed` is what would be downloaded. */ + function setUp() { const opfs = fakeOpfs(); + const handed: Blob[] = []; + const revoke = vi.fn(); vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() })); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: (blob: Blob) => { handed.push(blob); return 'blob:x'; }, revokeObjectURL: revoke })); + return { opfs, handed, revoke }; + } + + const asText = (parts: Uint8Array[]) => Buffer.concat(parts).toString('latin1'); + + it('never writes readable mail to the private files, and hands out the exact bytes', async () => { + const { opfs, handed } = setUp(); const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; - await sink.write(new Uint8Array(10)); + // More than one piece, so the pieces have to come back in order. + const big = new Uint8Array(1024 * 1024 + 7).map((_, i) => i % 251); + await sink.write(MAIL); + await sink.write(big); + await sink.write(big); const file = await sink.close(); - const gone = file.discard(); - await vi.advanceTimersByTimeAsync(30_000); + expect(opfs.written.length).toBeGreaterThanOrEqual(2); + expect(asText(opfs.written)).not.toContain('secret'); + expect(asText(opfs.written)).not.toContain('plain body'); + expect(handed).toHaveLength(1); + expect(handed[0].type).toBe('application/mbox'); + // Not toEqual: it walks 2 MB element by element. + expect(Buffer.from(await handed[0].arrayBuffer()).equals(Buffer.concat([MAIL, big, big]))).toBe(true); + expect(file.size).toBe(MAIL.length + 2 * big.length); + }); + + it('seals the same mail differently each time', async () => { + const { opfs } = setUp(); + for (let i = 0; i < 2; i += 1) { + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await sink.close(); + } + expect(opfs.written).toHaveLength(2); + expect(asText([opfs.written[0]])).not.toBe(asText([opfs.written[1]])); + }); + + it('deletes the spool the moment the export is done, before any download is over', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); expect(opfs.files.size).toBe(1); - await vi.advanceTimersByTimeAsync(downloadGraceMs(10)); - await gone; + await sink.close(); expect(opfs.files.size).toBe(0); }); - it('deletes at once on sign-out', async () => { + it('deletes the spool when the export is cancelled', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + + it('keeps the Blob while its download may still read it, and frees it after', async () => { + const { revoke } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + const file: ExportFile = await sink.close(); + vi.useFakeTimers(); + const gone = file.discard(); + await vi.advanceTimersByTimeAsync(30_000); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(downloadGraceMs(MAIL.length)); + await gone; + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); + + it('frees the Blob at once on sign-out', async () => { + const { revoke } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await (await sink.close()).discard(true); + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); + + it('leaves nothing behind when the Blob can\'t be made', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + vi.stubGlobal('Response', class { blob() { return Promise.reject(new Error('out of memory')); } }); + await expect(sink.close()).rejects.toThrow('out of memory'); + await sink.abort(); + expect(handed).toEqual([]); + expect(opfs.files.size).toBe(0); + }); + + it('a crash leaves only sealed bytes, which the sweep at the next start deletes', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL.slice()); + // 1 MB forces a piece out; a crash here never reaches close or abort. + await sink.write(new Uint8Array(1 << 20)); + expect(asText(opfs.written)).not.toContain('secret'); + expect(opfs.files.size).toBe(1); + // The tab that held the file is gone: its lock is free again. + await sweepPrivateExports(); + expect(opfs.files.size).toBe(0); + }); +}); + +describe('guardedSink', () => { + function fakeSink(): ExportSink & { write: ReturnType; close: ReturnType } { + return { + kind: 'picker', + write: vi.fn(async () => {}), + close: vi.fn(async () => ({ name: 'x', size: 0, discard: async () => {} })), + abort: vi.fn(async () => {}), + }; + } + + it('passes writes through while allowed', async () => { + const inner = fakeSink(); + const sink = guardedSink(inner, () => true); + await sink.write(new Uint8Array(1)); + await sink.close(); + expect(inner.write).toHaveBeenCalledTimes(1); + expect(inner.close).toHaveBeenCalledTimes(1); + }); + + it('stops a write and a close the moment the policy says no', async () => { + const inner = fakeSink(); + const sink = guardedSink(inner, () => false); + await expect(sink.write(new Uint8Array(1))).rejects.toBeInstanceOf(ExportForbiddenError); + await expect(sink.close()).rejects.toBeInstanceOf(ExportForbiddenError); + expect(inner.write).not.toHaveBeenCalled(); + expect(inner.close).not.toHaveBeenCalled(); + }); +}); + +describe('openExportSink', () => { + it('uses the picker where there is one, and nothing else when the user closes it', async () => { const opfs = fakeOpfs(); vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() })); - const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; - await sink.write(new Uint8Array(10)); - await (await sink.close()).discard(true); + vi.stubGlobal('window', { showSaveFilePicker: async () => { throw new DOMException('closed', 'AbortError'); } }); + expect(await openExportSink('a.mbox', 'application/mbox', '.mbox')).toBeNull(); expect(opfs.files.size).toBe(0); + vi.stubGlobal('window', { showSaveFilePicker: async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) }) }); + expect((await openExportSink('a.mbox', 'application/mbox', '.mbox'))?.kind).toBe('picker'); + }); + + it('goes to the spool where there is no picker', async () => { + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); + expect((await openExportSink('a.mbox', 'application/mbox', '.mbox'))?.kind).toBe('private'); }); }); diff --git a/lib/mail-export/sinks.ts b/lib/mail-export/sinks.ts index 30908697d..ac849578f 100644 --- a/lib/mail-export/sinks.ts +++ b/lib/mail-export/sinks.ts @@ -7,12 +7,18 @@ import { useMailTransferStore } from "@/stores/mail-transfer-store"; * * 1. File System Access (Chrome, Edge): the user picks the file first and the * export writes straight into it. - * 2. The origin private file system (Firefox, Safari): written to a private - * file on disk, then downloaded. The file is deleted a while after the - * dialog closes (so the download can finish), at once on sign-out, and by - * a sweep when the app starts, someone signs in or the next export starts. - * Each file is held under a Web Lock while its tab uses it, so the sweep - * of another tab leaves it alone. + * 2. The origin private file system (Firefox, Safari): an encrypted spool, so + * no readable mail is ever in those files. Each 1 MB piece is sealed with + * AES-GCM under a key made for this export, which can't be exported and is + * only ever in this tab's memory: a crash leaves bytes nobody can read. + * When the export is done the pieces are opened into a Blob the browser + * holds (Firefox and Chromium keep a large one in their own temporary + * storage), the spool is deleted at once, and the Blob is downloaded. The + * Blob goes a while after the dialog closes, so the download can finish, or + * at once on sign-out or a wipe. A sweep deletes what a crash left when the + * app starts, someone signs in or the next export starts. Each spool is + * held under a Web Lock while its tab uses it, so the sweep of another tab + * leaves it alone. * 3. Memory, for small exports only, where neither exists. * * Writes are gathered into 1 MB pieces: a message arrives in many small parts. @@ -41,11 +47,19 @@ export class ExportTooLargeError extends Error { } } +/** The device policy "No export" came on while the export ran. */ +export class ExportForbiddenError extends Error { + constructor() { + super("The organisation doesn't allow exporting mail on this device"); + this.name = "ExportForbiddenError"; + } +} + /** The most a memory-only export may hold. */ export const MEMORY_EXPORT_LIMIT = 200 * 1024 * 1024; const PRIVATE_DIR = "bulwark-exports"; const LOCK_PREFIX = "bulwark-export-file:"; -/** How long a closed export's private file stays for its download: a minute, plus a second per 50 MB. */ +/** How long a closed export's Blob stays for its download: a minute, plus a second per 50 MB. */ export function downloadGraceMs(size: number): number { return 60_000 + Math.ceil(size / 50_000_000) * 1000; } @@ -139,10 +153,37 @@ async function privateDir(): Promise { } } -/** A private file on disk, downloaded when done; null where the browser can't write one from the page. */ +/** The IV of a piece: its number. A key seals one spool, so a number never repeats under it. */ +function pieceIv(piece: number): Uint8Array { + const iv = new Uint8Array(12); + new DataView(iv.buffer).setUint32(8, piece); + return iv; +} + +/** Reads the pieces back in order, one at a time, opened. A piece that was changed fails the read. */ +function openedPieces(file: Blob, sizes: readonly number[], key: CryptoKey): ReadableStream { + let piece = 0; + let at = 0; + return new ReadableStream({ + async pull(controller) { + if (piece === sizes.length) { + controller.close(); + return; + } + const size = sizes[piece]; + const sealed = await file.slice(at, at + size).arrayBuffer(); + controller.enqueue(new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: pieceIv(piece) }, key, sealed))); + piece += 1; + at += size; + }, + }); +} + +/** An encrypted spool on disk, downloaded when done; null where the browser can't write one from the page. */ export async function privateFileSink(name: string, mime: string): Promise { const dir = await privateDir(); - if (!dir) return null; + if (!dir || !globalThis.crypto?.subtle) return null; + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); const entry = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Held until the file is gone, so no other tab's sweep deletes it meanwhile. const release = (await holdLock(`${LOCK_PREFIX}${entry}`)) ?? (() => {}); @@ -162,7 +203,12 @@ export async function privateFileSink(name: string, mime: string): Promise writable.write(chunk as BufferSource)); + const sizes: number[] = []; + const buf = buffered(async (chunk) => { + const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: pieceIv(sizes.length) }, key, chunk as BufferSource)); + await writable.write(sealed); + sizes.push(sealed.length); + }); const remove = async () => { await dir.removeEntry(entry).catch(() => {}); release(); @@ -173,18 +219,19 @@ export async function privateFileSink(name: string, mime: string): Promise { await buf.flush(); await writable.close(); - const file = await handle.getFile(); - const url = URL.createObjectURL(new Blob([file], { type: mime })); + // Through a Response so the browser may spill a large Blob to its own storage. + const opened = await new Response(openedPieces(await handle.getFile(), sizes, key)).blob(); + await remove(); + const url = URL.createObjectURL(new Blob([opened], { type: mime })); triggerDownload(url, name); return { name, - size: file.size, + size: opened.size, download: () => triggerDownload(url, name), discard: async (now = false) => { - // Deleting the file under a download that is still reading it would break the download. - if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(file.size))); + // Taking the Blob away from a download that is still reading it would break the download. + if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(opened.size))); URL.revokeObjectURL(url); - await remove(); }, }; }, @@ -192,6 +239,23 @@ export async function privateFileSink(name: string, mime: string): Promise { + if (canPickSaveFile()) { + try { + return await pickSaveFile(name, mime, extension); + } catch (error) { + if ((error as { name?: string } | null)?.name === "AbortError") return null; + } + } + await sweepPrivateExports(); + return (await privateFileSink(name, mime)) ?? memorySink(name, mime); +} + /** Everything in memory, then downloaded: only for small exports. */ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIMIT): ExportSink { let parts: Uint8Array[] = []; @@ -223,9 +287,31 @@ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIM } /** - * Deletes private export files no tab uses any more (a closed tab, a crash), - * so no copy of the mail stays in the browser profile. A file another tab - * holds is left alone; `force` deletes everything (everyone signed out). + * Stops an export the moment `allowed` says no, whichever sink it writes to: the + * save picker writes straight to the user's file, past the guard in saveBlobAs. + */ +export function guardedSink(sink: ExportSink, allowed: () => boolean): ExportSink { + const check = () => { + if (!allowed()) throw new ExportForbiddenError(); + }; + return { + kind: sink.kind, + write: async (chunk) => { + check(); + await sink.write(chunk); + }, + close: async () => { + check(); + return sink.close(); + }, + abort: () => sink.abort(), + }; +} + +/** + * Deletes private export files no tab uses any more (a closed tab, a crash). + * They're encrypted under a key that died with the tab, but one from an older + * version is plain, and they take up room. A file another tab holds is left alone; `force` deletes everything (everyone signed out). * Without Web Locks only `force` sweeps, since another tab can't be asked. */ export async function sweepPrivateExports({ force = false }: { force?: boolean } = {}): Promise { From 3cda05a1fa207e593643de5fa9223a5a2ae0e9f7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:40:57 +0100 Subject: [PATCH 2/4] feat: export mail kept on this device to mbox or EML zip, also offline --- .../__tests__/local-export.test.tsx | 463 ++++++++++++++++++ .../mail-transfer/local-export-dialog.tsx | 265 ++++++++++ .../mail-transfer/mail-transfer-host.tsx | 49 +- .../mail-transfer/start-local-export.ts | 104 ++++ components/offline/account-storage.tsx | 6 + integration/tests/24-do-export.spec.ts | 300 ++++++++++++ lib/desktop/taskbar-progress.ts | 13 + .../__tests__/local-store-export.test.ts | 261 ++++++++++ lib/mail-export/engine.ts | 41 +- lib/mail-export/local-store-client.ts | 126 +++++ lib/offline/wipe-registry.ts | 39 ++ lib/store/mail/attachments.ts | 11 + lib/store/mail/calls.ts | 4 +- locales/ar/common.json | 23 + locales/ca/common.json | 23 + locales/cs/common.json | 23 + locales/da/common.json | 23 + locales/de/common.json | 23 + locales/en/common.json | 23 + locales/es/common.json | 23 + locales/et/common.json | 23 + locales/fa/common.json | 23 + locales/fr/common.json | 23 + locales/he/common.json | 23 + locales/hu/common.json | 23 + locales/it/common.json | 23 + locales/ja/common.json | 23 + locales/ko/common.json | 23 + locales/lv/common.json | 23 + locales/mn/common.json | 23 + locales/nb/common.json | 23 + locales/nl/common.json | 23 + locales/pl/common.json | 23 + locales/pt/common.json | 23 + locales/ro/common.json | 23 + locales/ru/common.json | 23 + locales/sk/common.json | 23 + locales/th/common.json | 23 + locales/tr/common.json | 23 + locales/uk/common.json | 23 + locales/vi/common.json | 23 + locales/zh-TW/common.json | 23 + locales/zh/common.json | 23 + stores/mail-transfer-store.ts | 14 +- 44 files changed, 2370 insertions(+), 16 deletions(-) create mode 100644 components/mail-transfer/__tests__/local-export.test.tsx create mode 100644 components/mail-transfer/local-export-dialog.tsx create mode 100644 components/mail-transfer/start-local-export.ts create mode 100644 integration/tests/24-do-export.spec.ts create mode 100644 lib/desktop/taskbar-progress.ts create mode 100644 lib/mail-export/__tests__/local-store-export.test.ts create mode 100644 lib/mail-export/local-store-client.ts create mode 100644 lib/offline/wipe-registry.ts 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 }); From a52cd8c82963823a6274e9d58241cab57f25a778 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:57:19 +0100 Subject: [PATCH 3/4] fix: save no empty file when an export could write no message at all --- .../__tests__/local-export.test.tsx | 37 ++++++++++++++++++- .../mail-transfer/local-export-dialog.tsx | 16 +++++++- .../mail-transfer/mail-transfer-host.tsx | 4 +- integration/tests/24-do-export.spec.ts | 31 ++++++++++++---- .../__tests__/local-store-export.test.ts | 7 +++- lib/mail-export/engine.ts | 9 ++++- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx index 477270d85..3a7f9a5d3 100644 --- a/components/mail-transfer/__tests__/local-export.test.tsx +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -17,6 +17,7 @@ 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'; +import { toast } from '@/stores/toast-store'; const FOLDERS = [ { id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 }, @@ -25,10 +26,10 @@ const FOLDERS = [ 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 } = {}) { +function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown; status?: { total: number; kept: number } } = {}) { 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.rawStatus') return overrides.status ?? (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; @@ -143,6 +144,22 @@ describe('before an export starts', () => { expect(boxes.every((box) => box.checked)).toBe(true); }); + it('can\'t start offline with no original of anything, and can as soon as the server is back', async () => { + setOnline(false); + setStoreBackend(fakeStore({ status: { total: 4, kept: 0 } }) as unknown as StoreBackend); + await openDialog(); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline'); + act(() => setOnline(true)); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + }); + + it('puts the focus on the folders once they are read', async () => { + await openDialog(); + expect(document.activeElement).toBe(screen.getByTestId('local-export-all')); + }); + it('can\'t start with nothing chosen', async () => { await openDialog(); fireEvent.click(screen.getByTestId('local-export-all')); @@ -284,6 +301,9 @@ describe('what an export couldn\'t write', () => { 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'); + // Nothing was written: no file, and nothing to download again. + expect(screen.queryByTestId('mail-export-file')).toBeNull(); + expect(screen.queryByTestId('mail-export-download')).toBeNull(); 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); @@ -306,6 +326,19 @@ describe('what an export couldn\'t write', () => { expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); }); + it('says the export stopped, not that a file is ready, when it ends hidden with nothing written', async () => { + setOnline(false); + notKept(); + const warning = vi.spyOn(toast, 'warning'); + const success = vi.spyOn(toast, 'success'); + render(); + await finishedJob(); + expect(warning).toHaveBeenCalledWith('toast_stopped', expect.anything()); + expect(success).not.toHaveBeenCalled(); + warning.mockRestore(); + success.mockRestore(); + }); + it('offers no retry when everything was written', async () => { render(); act(() => openLocalExport('acc')); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx index 248fe5cf2..32187ce47 100644 --- a/components/mail-transfer/local-export-dialog.tsx +++ b/components/mail-transfer/local-export-dialog.tsx @@ -70,6 +70,7 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) const progress = job?.kind === "export" ? job.progress : null; const active = isJobActive(job); const stageButton = useRef(null); + const allFolders = useRef(null); useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); const inSetup = !progress; @@ -85,6 +86,12 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) return () => { live = false; }; }, [account, inSetup]); + // The dialog opens before the folders are read, with only its footer to focus. + const foldersShown = inSetup && loaded !== null; + useEffect(() => { + if (foldersShown) allFolders.current?.focus(); + }, [foldersShown]); + if (!visible) return null; if (progress) { @@ -135,6 +142,8 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) const missing = total - kept; const single = chosen.length === 1; const attachmentsOff = !scopeForAccount(account).scope.attachments; + // Offline with no original of anything: the save picker would ask where to put nothing. + const nothingToWrite = total === 0 || (kept === 0 && !reachable); const onStart = async () => { if (!loaded || starting || chosen.length === 0) return; @@ -176,7 +185,7 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) footer={( <> - + )} > @@ -193,7 +202,10 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) { if (input) input.indeterminate = excluded.size > 0 && excluded.size < rows.length; }} + ref={(input) => { + allFolders.current = input; + if (input) input.indeterminate = excluded.size > 0 && excluded.size < rows.length; + }} onChange={(event) => setExcluded(event.target.checked ? new Set() : new Set(rows.map((row) => row.folder.mailboxId)))} data-testid="local-export-all" /> diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index e98a865bf..51a43b19f 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -94,8 +94,8 @@ export function MailTransferHost() { if (job.kind === "import") { if (phase === "done") toast.success(tImport("toast_done", { imported: job.progress.imported, failed: job.progress.failed }), { action }); else toast.warning(tImport("toast_stopped"), { action }); - } else if (phase === "done") { - toast.success(tExport("toast_done", { name: job.progress.file?.name ?? "" }), { action }); + } else if (phase === "done" && job.progress.file) { + toast.success(tExport("toast_done", { name: job.progress.file.name }), { action }); } else { toast.warning(tExport("toast_stopped"), { action }); } diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts index cd0f6af91..d4a431151 100644 --- a/integration/tests/24-do-export.spec.ts +++ b/integration/tests/24-do-export.spec.ts @@ -224,7 +224,7 @@ test('with a save picker, it writes straight to the chosen file and no private f 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 () => { +test('with attachments off it says offline why it can\'t; a connection lost during the run is counted; the retry writes them all', async () => { await context.setOffline(false); await page.getByTestId('offline-attachments').click(); await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'false'); @@ -235,18 +235,35 @@ test('with attachments off, offline counts every message and says why; the retry 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'); + // Nothing could be written, so it doesn't offer to. + await expect(page.getByTestId('local-export-start')).toBeDisabled(); await shot(page, 'd10-export-offline-missing'); + + // The browser says online, and the server goes as the export starts. + await context.setOffline(false); + await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 30_000 }); + await context.route(`${new URL(JMAP_URL).origin}/**`, (route) => route.abort('internetdisconnected')); + let downloads = 0; + const counted = () => { downloads += 1; }; + page.on('download', counted); 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 expect(page.getByTestId('local-export-not-kept-attachments')).toContainText(`attachments aren't kept on this device: ${COUNT}`, { timeout: 60_000 }); + await expect(page.getByTestId('mail-export-status')).toContainText('Messages exported: 0'); + // No empty file that looks like a backup. + await expect(page.getByTestId('mail-export-file')).toHaveCount(0); + await expect(page.getByTestId('mail-export-download')).toHaveCount(0); + page.off('download', counted); + expect(downloads).toBe(0); await shot(page, 'd10-export-not-kept'); + await context.unroute(`${new URL(JMAP_URL).origin}/**`); + // The browser going off says so in the summary, and coming back opens the retry. + await context.setOffline(true); + 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 context.setOffline(false); - await expect(page.getByTestId('local-export-retry')).toBeEnabled({ timeout: 30_000 }); + await expect(page.getByTestId('local-export-retry')).toBeEnabled({ timeout: 60_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 }); diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts index ea21f2982..96de1ba46 100644 --- a/lib/mail-export/__tests__/local-store-export.test.ts +++ b/lib/mail-export/__tests__/local-store-export.test.ts @@ -172,12 +172,15 @@ describe('an export from the local store', () => { expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a')]); }); - it('says it is because attachments are off when they are', async () => { + it('says it is because attachments are off when they are, and makes no empty file of nothing', async () => { const server = serverWith({ a: 'inbox', b: 'inbox' }); const { store } = await storeOf(server, { ...SCOPE, attachments: false }); - const { result } = await run(store, 'mbox', ['inbox']); + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); expect(result.notKept).toEqual({ attachments_off: 2 }); expect(result.exported).toBe(0); + expect(result.file).toBeUndefined(); + expect(bytes.length).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 () => { diff --git a/lib/mail-export/engine.ts b/lib/mail-export/engine.ts index 802a75672..68453ac67 100644 --- a/lib/mail-export/engine.ts +++ b/lib/mail-export/engine.ts @@ -366,8 +366,13 @@ export async function runExport(options: ExportOptions): Promise } // 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(); + if (progress.exported === 0 && progress.failed > 0) { + // Every message failed: an empty file would only look like a backup. + await sink.abort(); + } else { + if (zip) await zip.finish(); + progress.file = await sink.close(); + } progress.phase = "done"; progress.fraction = 1; } catch (error) { From 530364b11c3f4524a980f9825b154be38753b739 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:29:42 +0100 Subject: [PATCH 4/4] fix: stop a cancelled or wiped export from downloading, and say why an export stopped Review of the local store export. A cancel, sign-out or wipe during the final step still downloaded the file; now the sink takes the job's signal and drops the Blob, and a job that outlasts the wait is forgotten when it ends. Finished exports are in a list a later wipe or sign-out can empty, also after the dialog closed. - `localExportAllowed` uses `scopeDecided`, so a domain or group scope can't be missed before the subject arrives. It's checked before the save picker, in the same tick as the click. - WebKit holds the final Blob in memory: exports stop at 1 GB, and a larger choice or one over `storage.estimate()` is refused up front. - An empty folder that refuses during a full listing counts as empty, only when the store's own count agrees. Any other refusal stops the run. - A picked file goes on an abort or an all-failed run; if it can't, the dialog names it. Failure texts come from our strings, not the browser's. - A selection keeps to the chosen folders on a retry. The test that the organisation shows as the reason now flips the policy through the policy store, because the old one replaced the guard and so never reached the watch that a real change goes through. The spec moves to an account of its own, dso-export: other specs running at the same time work in ent-dave, and its folder went missing there mid-run. --- .../device/__tests__/device-gates.test.tsx | 2 + components/device/device-policy-host.tsx | 4 +- .../__tests__/local-export.test.tsx | 368 +++++++++++++++++- .../mail-transfer/local-export-dialog.tsx | 85 +++- .../mail-transfer/mail-transfer-host.tsx | 15 +- .../mail-transfer/start-local-export.ts | 22 +- integration/tests/24-do-export.spec.ts | 179 +++++++-- .../__tests__/local-store-export.test.ts | 69 +++- lib/mail-export/__tests__/sinks.test.ts | 178 ++++++++- lib/mail-export/engine.ts | 28 +- lib/mail-export/local-store-client.ts | 27 +- lib/mail-export/sinks.ts | 154 ++++++-- lib/offline/offline-mode.ts | 2 +- lib/store/mail/attachments.ts | 14 + lib/store/mail/calls.ts | 4 +- locales/ar/common.json | 13 +- locales/ca/common.json | 13 +- locales/cs/common.json | 13 +- locales/da/common.json | 13 +- locales/de/common.json | 13 +- locales/en/common.json | 13 +- locales/es/common.json | 13 +- locales/et/common.json | 13 +- locales/fa/common.json | 13 +- locales/fr/common.json | 13 +- locales/he/common.json | 13 +- locales/hu/common.json | 13 +- locales/it/common.json | 13 +- locales/ja/common.json | 13 +- locales/ko/common.json | 13 +- locales/lv/common.json | 13 +- locales/mn/common.json | 13 +- locales/nb/common.json | 13 +- locales/nl/common.json | 13 +- locales/pl/common.json | 13 +- locales/pt/common.json | 13 +- locales/ro/common.json | 13 +- locales/ru/common.json | 13 +- locales/sk/common.json | 13 +- locales/th/common.json | 13 +- locales/tr/common.json | 13 +- locales/uk/common.json | 13 +- locales/vi/common.json | 13 +- locales/zh-TW/common.json | 13 +- locales/zh/common.json | 13 +- 45 files changed, 1368 insertions(+), 173 deletions(-) diff --git a/components/device/__tests__/device-gates.test.tsx b/components/device/__tests__/device-gates.test.tsx index bb86a6b7b..5557f2eed 100644 --- a/components/device/__tests__/device-gates.test.tsx +++ b/components/device/__tests__/device-gates.test.tsx @@ -129,6 +129,8 @@ describe('No export', () => { await waitFor(() => expect(discard).toHaveBeenCalledWith(true)); expect(controller.signal.aborted).toBe(true); expect(useMailTransferStore.getState().job).toBeNull(); + // The dialog just went: this is all that says why. + expect(toast.info).toHaveBeenCalledWith('export_blocked'); }); }); diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx index d7465be08..acfaa9c7c 100644 --- a/components/device/device-policy-host.tsx +++ b/components/device/device-policy-host.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useTranslations } from 'next-intl'; import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy'; -import { DOWNLOAD_BLOCKED_EVENT, type DownloadKind } from '@/lib/download-guard'; +import { DOWNLOAD_BLOCKED_EVENT, downloadAllowed, type DownloadKind } from '@/lib/download-guard'; // Installs the download guard (lib/download-guard.ts) for every page. import '@/lib/device-policy'; import { useMailTransferStore } from '@/stores/mail-transfer-store'; @@ -48,6 +48,8 @@ export function DevicePolicyHost() { if (store.job?.kind !== 'export') return; const job = store.job; store.cancelJob('policy'); + // This closes the dialog too, which would otherwise be gone with no reason given. + downloadAllowed('export', null); // The private file goes at once, once the job has stopped: nothing may download it any more. const discard = () => { if (useMailTransferStore.getState().job?.controller === job.controller) useMailTransferStore.getState().close({ now: true }); diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx index 3a7f9a5d3..4d25a4cbe 100644 --- a/components/mail-transfer/__tests__/local-export.test.tsx +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -8,9 +8,10 @@ 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 { memorySink, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; import { useOfflineStore } from '@/lib/offline/state'; import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +import { useAccountSubjects } from '@/lib/org/account-subjects'; import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api'; import { useAccountStore } from '@/stores/account-store'; @@ -19,18 +20,19 @@ import { isJobActive, openLocalExport, useMailTransferStore } from '@/stores/mai import { usePolicyStore } from '@/stores/policy-store'; import { toast } from '@/stores/toast-store'; -const FOLDERS = [ +const FOLDERS: { id: string; name: string; role?: string; sortOrder: number }[] = [ { 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; status?: { total: number; kept: number } } = {}) { +function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown; status?: { total: number; kept: number }; size?: { bytes: number; keptBytes: number } } = {}) { 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 overrides.status ?? (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.rawSize') return overrides.size ?? { bytes: 10, keptBytes: 10 }; if (name === 'mail.rawKept') return []; return null; }); @@ -157,7 +159,7 @@ describe('before an export starts', () => { it('puts the focus on the folders once they are read', async () => { await openDialog(); - expect(document.activeElement).toBe(screen.getByTestId('local-export-all')); + await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId('local-export-all'))); }); it('can\'t start with nothing chosen', async () => { @@ -244,6 +246,7 @@ describe('the device policy "No export"', () => { it('asks about the account the export is for, not the active one', async () => { useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never); + useAccountSubjects.setState({ settled: { other: true } } as never); const asked: unknown[] = []; setDownloadGuard((_kind, subject) => { asked.push(subject); return true; }); try { @@ -274,19 +277,201 @@ describe('the device policy "No export"', () => { } }); - it('shows the organisation as the reason when that stopped it', async () => { + it('shows the organisation as the reason when a real change of policy 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); + // Through the policy store, the way a fetched policy arrives. + sink.write.mockImplementation(async () => { act(() => setDevices({ noExport: true })); }); + await finishedJob(sink); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('export_blocked'); + expect(sink.close).not.toHaveBeenCalled(); + }); + + it('says why when the account stopped keeping mail here during the run', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + sink.write.mockImplementation(async () => { act(() => useOfflineStore.setState({ enabled: ['other'] })); }); + await finishedJob(sink); + expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('stopped_offline_off'); + }); + + it('gives no reason beyond the title when the person cancelled it', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + sink.write.mockImplementation(async () => { act(() => useMailTransferStore.getState().cancelJob()); }); + await finishedJob(sink); + expect(screen.queryByTestId('local-export-stop-reason')).toBeNull(); + }); + + describe('with a scope the subject decides', () => { + /** Everyone may export; one domain or group may not. */ + function scoped(match: Record) { + const base = usePolicyStore.getState().rawPolicy; + usePolicyStore.setState({ + rawPolicy: { ...base, scopes: [{ id: 'locked', label: 'Locked', match, policy: { organisation: { devices: { noExport: true } } } }] }, + loaded: true, source: 'server', partial: false, subject: null, subjectPending: false, + } as never); } + + afterEach(() => { + usePolicyStore.setState({ rawPolicy: { ...usePolicyStore.getState().rawPolicy, scopes: [] }, subjectPending: false } as never); + usePolicyStore.getState().setPolicySubject(null); + }); + + it.each([['domain', { domains: ['example.org'] }], ['group', { groups: ['team@example.org'] }]])('%s: says no until the subject is known, whatever Everyone may do', (_kind, match) => { + scoped(match as Record); + // What the policy alone would say: nobody has been matched yet. + expect(devicesForAccount('acc').noExport).toBe(false); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ subjectPending: true } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ subjectPending: false } as never); + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] }); + expect(devicesForAccount('acc').noExport).toBe(true); + expect(localExportAllowed('acc')).toBe(false); + }); + + it('opens once the subject is known and the scope isn\'t for it', () => { + scoped({ domains: ['elsewhere.example'] }); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] }); + expect(localExportAllowed('acc')).toBe(true); + }); + + it('waits for another account\'s own subject too', () => { + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] }); + useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never); + useAccountSubjects.setState({ settled: {} } as never); + expect(localExportAllowed('other')).toBe(false); + useAccountSubjects.setState({ settled: { other: true } } as never); + expect(localExportAllowed('other')).toBe(true); + }); + }); + + describe('at the click on Export', () => { + function withPicker() { + const picker = vi.fn(async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) })); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker; + return picker; + } + + it('opens the picker in the same tick as the click, so the gesture counts', async () => { + const picker = withPicker(); + await openDialog(); + fireEvent.click(screen.getByTestId('local-export-start')); + expect(picker).toHaveBeenCalledTimes(1); + await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done')); + }); + + it('opens no picker under the policy, and says why', async () => { + const picker = withPicker(); + await openDialog(); + setDevices({ noExport: true }); + fireEvent.click(screen.getByTestId('local-export-start')); + expect(picker).not.toHaveBeenCalled(); + expect((await screen.findByTestId('local-export-refused')).textContent).toBe('export_blocked'); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('opens no picker while the policy isn\'t known, and says that', async () => { + const picker = withPicker(); + await openDialog(); + usePolicyStore.setState({ subjectPending: true } as never); + fireEvent.click(screen.getByTestId('local-export-start')); + expect(picker).not.toHaveBeenCalled(); + expect((await screen.findByTestId('local-export-refused')).textContent).toBe('policy_unknown'); + usePolicyStore.setState({ subjectPending: false } as never); + }); + }); +}); + +describe('what the browser can hold', () => { + const SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'; + const MB = 1024 * 1024; + + /** A browser with no save picker but a private file system: the spool path. */ + function spoolBrowser(agent: string, free: number) { + vi.stubGlobal('navigator', { + ...navigator, userAgent: agent, + storage: { getDirectory: async () => ({}), estimate: async () => ({ quota: free + 100, usage: 100 }) }, + locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) }, + }); + vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} }); + } + + it('on WebKit stops it before it starts when the choice is over the cap, and says what to do', async () => { + spoolBrowser(SAFARI, 100 * 1024 * MB); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.getByTestId('local-export-over-limit').textContent).toBe('over_limit'); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + // Fewer folders fit. + fireEvent.click(screen.getAllByTestId('local-export-folder')[0]); + expect(screen.queryByTestId('local-export-over-limit')).toBeNull(); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false); + }); + + it('puts no such cap on Firefox', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 100 * 1024 * MB); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.queryByTestId('local-export-over-limit')).toBeNull(); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false); + }); + + it('says so when the browser has no room for the spool', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 3 * MB, keptBytes: 3 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.getByTestId('local-export-no-room').textContent).toBe('no_room'); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + }); + + it('counts only what will be written: offline, what is kept', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB); + setOnline(false); + setStoreBackend(fakeStore({ status: { total: 2, kept: 1 }, size: { bytes: 300 * MB, keptBytes: 1 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.queryByTestId('local-export-no-room')).toBeNull(); + act(() => setOnline(true)); + expect(screen.getByTestId('local-export-no-room')).toBeTruthy(); + }); + + it('doesn\'t ask about room when the save picker writes elsewhere', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36', 5 * MB); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = vi.fn(); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 300 * MB, keptBytes: 300 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.queryByTestId('local-export-no-room')).toBeNull(); + expect(screen.queryByTestId('local-export-over-limit')).toBeNull(); + }); + + it.each([ + ['ExportTooLargeError', 'stopped_limit'], + ['QuotaExceededError', 'stopped_no_room'], + ['TypeError', 'failed_generic'], + ])('says %s in the user\'s language, never the browser\'s text', async (name, key) => { + const sink = fakeSink(); + sink.write.mockImplementation(async () => { throw Object.assign(new Error('Some English from a browser'), { name, limit: 1024 }); }); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed'); + expect(screen.getByText(key)).toBeTruthy(); + expect(screen.queryByText(/Some English/)).toBeNull(); + }); + + it('says a message failed in the user\'s language too', async () => { + setStoreBackend(fakeStore({ rawBlob: () => { throw new Error('Some English from a server'); } }) as unknown as StoreBackend); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect(screen.getByText(/message_failed/)).toBeTruthy(); + expect(screen.queryByText(/Some English/)).toBeNull(); }); }); @@ -339,6 +524,85 @@ describe('what an export couldn\'t write', () => { success.mockRestore(); }); + it('says when the picked file couldn\'t be removed after a run that wrote nothing', async () => { + setOnline(false); + notKept(); + const sink = fakeSink(); + sink.abort.mockImplementation(async () => ({ leftover: 'a.mbox' })); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(sink.close).not.toHaveBeenCalled(); + expect(screen.getByTestId('local-export-leftover').textContent).toBe('leftover_file'); + }); + + it('says nothing about a file that was removed', async () => { + setOnline(false); + notKept(); + const sink = fakeSink(); + sink.abort.mockImplementation(async () => ({})); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(sink.abort).toHaveBeenCalled(); + expect(screen.queryByTestId('local-export-leftover')).toBeNull(); + }); + + it('keeps to the chosen folders on the retry, also when a new folder has turned up', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); + fireEvent.click(screen.getAllByTestId('local-export-folder')[1]); + await finishedJob(); + act(() => setOnline(true)); + FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 }); + try { + fireEvent.click(screen.getByTestId('local-export-retry')); + await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3)); + const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[]; + expect(boxes.map((box) => [box.dataset.folder, box.checked])).toEqual([['Inbox', true], ['Archive', false], ['New', false]]); + } finally { + FOLDERS.pop(); + } + }); + + it('takes a new folder in when all were chosen', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); + await finishedJob(); + act(() => setOnline(true)); + FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 }); + try { + fireEvent.click(screen.getByTestId('local-export-retry')); + await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3)); + expect((screen.getAllByTestId('local-export-folder') as HTMLInputElement[]).every((box) => box.checked)).toBe(true); + } finally { + FOLDERS.pop(); + } + }); + + it('makes no empty file when the folders turned out empty by the time it ran, and says so', async () => { + const store = fakeStore(); + store.answer.mockImplementation(async (_account: string, calls: [string, Record, string][]) => ( + calls[0][0] === 'Mailbox/get' + ? { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] } + : { methodResponses: [['Email/query', { ids: [] }, '0']] } + ) as never); + setStoreBackend(store as unknown as StoreBackend); + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(sink.close).not.toHaveBeenCalled(); + expect(sink.abort).toHaveBeenCalled(); + expect(screen.getByTestId('local-export-wrote-nothing').textContent).toBe('nothing_kept'); + }); + it('offers no retry when everything was written', async () => { render(); act(() => openLocalExport('acc')); @@ -373,6 +637,84 @@ describe('while it runs', () => { }); }); +describe('stopped while the file is being finished', () => { + /** A sink whose close doesn't end until told, the way opening a large spool takes a while. */ + function slowClose() { + const sink = fakeSink(); + const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) }; + let finish: () => void = () => {}; + let signal: AbortSignal | undefined; + sink.close.mockImplementation((given?: AbortSignal) => new Promise((resolve) => { + signal = given; + finish = () => resolve(file); + })); + return { sink, file, finish: () => finish(), signal: () => signal }; + } + + async function closing(sink: ExportSink) { + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + await waitFor(() => expect((sink.close as ReturnType)).toHaveBeenCalled()); + return useMailTransferStore.getState().job!; + } + + it('passes the job\'s signal to the sink', async () => { + const slow = slowClose(); + const job = await closing(slow.sink); + expect(slow.signal()).toBe(job.controller.signal); + slow.finish(); + await act(async () => { await job.finished; }); + }); + + it('a cancel: the file goes and the run counts as cancelled, also if the sink finished anyway', async () => { + const slow = slowClose(); + const job = await closing(slow.sink); + act(() => useMailTransferStore.getState().cancelJob()); + slow.finish(); + await act(async () => { await job.finished; }); + const progress = useMailTransferStore.getState().job?.progress as ExportProgress; + expect(progress.phase).toBe('cancelled'); + expect(progress.file).toBeUndefined(); + expect(slow.file.discard).toHaveBeenCalledWith(true); + }); + + it('a wipe that outlasts its wait: the job and its file still go when it ends', async () => { + render(); + const slow = slowClose(); + const job = await closing(slow.sink); + await act(async () => { await runWipeCleaners('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + // Still running after the 1.5 s the wipe gives it. + expect(useMailTransferStore.getState().job).not.toBeNull(); + slow.finish(); + await act(async () => { await job.finished; }); + await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull()); + expect(slow.file.discard).toHaveBeenCalledWith(true); + }); + + it('a sign-out that outlasts its wait: the same', async () => { + render(); + const slow = slowClose(); + const job = await closing(slow.sink); + await act(async () => { await runBeforeSignOut('acc'); }); + slow.finish(); + await act(async () => { await job.finished; }); + await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull()); + expect(slow.file.discard).toHaveBeenCalledWith(true); + }); + + it('a wipe after the dialog closed drops the finished file at once', async () => { + const revoke = vi.fn(); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: revoke })); + render(); + await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType); + act(() => useMailTransferStore.getState().close()); + expect(useMailTransferStore.getState().job).toBeNull(); + expect(revoke).not.toHaveBeenCalled(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); +}); + describe('an export is cleaned up', () => { /** A job that doesn't end until it's aborted. */ function runningJob(account = 'acc') { diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx index 32187ce47..c72d05329 100644 --- a/components/mail-transfer/local-export-dialog.tsx +++ b/components/mail-transfer/local-export-dialog.tsx @@ -4,24 +4,29 @@ 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 { downloadAllowed } from "@/lib/download-guard"; +import { folderTree, safeName, type ExportFolder, type ExportFormat, type ExportProgress } from "@/lib/mail-export/engine"; +import { MEMORY_EXPORT_LIMIT, openExportSink, spoolLimit } 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 { formatFileSize } from "@/lib/utils"; 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 { localExportAllowed, localExportDecided, localExportReachable, localExportReader, primaryJmapAccount, startLocalExport } from "./start-local-export"; import { useFocusOnStage } from "./transfer-progress"; interface FolderRow { folder: ExportFolder; total: number; kept: number; + bytes: number; + keptBytes: number; } -type Loaded = { jmap: string; rows: FolderRow[] }; +/** `free`: what the browser says is left for this site; null where it doesn't say. */ +type Loaded = { jmap: string; rows: FolderRow[]; free: number | null }; function today(): string { return new Date().toISOString().slice(0, 10); @@ -34,10 +39,22 @@ async function readFolders(account: string): Promise { 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 }); + const params = { jmap, mailboxId: folder.mailboxId }; + const status = await getStore().area<{ total: number; kept: number }>(account, "mail.rawStatus", params); + const size = await getStore().area<{ bytes: number; keptBytes: number }>(account, "mail.rawSize", params); + rows.push({ folder, ...status, ...size }); + } + return { jmap, rows, free: await freeRoom() }; +} + +async function freeRoom(): Promise { + try { + const estimate = await navigator.storage?.estimate?.(); + if (typeof estimate?.quota !== "number") return null; + return Math.max(0, estimate.quota - (estimate.usage ?? 0)); + } catch { + return null; } - return { jmap, rows }; } /** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */ @@ -66,11 +83,13 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) const [excluded, setExcluded] = useState>(new Set()); const [format, setFormat] = useState("mbox"); const [starting, setStarting] = useState(false); + const [refused, setRefused] = useState(null); const mode = saveMode(); const progress = job?.kind === "export" ? job.progress : null; const active = isJobActive(job); const stageButton = useRef(null); const allFolders = useRef(null); + const knownFolders = useRef>(new Set()); useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); const inSetup = !progress; @@ -80,7 +99,16 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) let live = true; setLoadFailed(false); readFolders(account).then( - (result) => { if (live) setLoaded(result); }, + (result) => { + if (!live) return; + // A folder that turned up since isn't one the person chose: after a try online + // only "All folders" takes it in. + const ids = result.rows.map((row) => row.folder.mailboxId); + const known = knownFolders.current; + knownFolders.current = new Set(ids); + setExcluded((was) => (was.size === 0 ? was : new Set([...was, ...ids.filter((folderId) => !known.has(folderId))]))); + setLoaded(result); + }, () => { if (live) setLoadFailed(true); }, ); return () => { live = false; }; @@ -92,12 +120,27 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) if (foldersShown) allFolders.current?.focus(); }, [foldersShown]); + /** Why a run stopped, in the user's language, whatever the error said. */ + function stopMessage(run: ExportProgress): string { + if (run.errorCode === "ExportForbiddenError" || !localExportAllowed(account)) return tPolicy("export_blocked"); + if (run.errorCode === "ExportTooLargeError") return t("stopped_limit", { limit: formatFileSize(run.errorLimit ?? 0) }); + if (run.errorCode === "QuotaExceededError") return t("stopped_no_room"); + return t("failed_generic"); + } + 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); + const reason: unknown = job?.controller.signal.reason; + const leftover = progress.leftover; + const shown: ExportProgress = { + ...progress, + // Nothing from a server or a browser reaches the user in English. + failures: progress.failures.map((failure) => ({ ...failure, reason: t("message_failed") })), + ...(progress.phase === "failed" ? { error: stopMessage(progress) } : {}), + }; return ( )} > - + + {progress.phase === "cancelled" && reason === "policy" &&

{tPolicy("export_blocked")}

} + {progress.phase === "cancelled" && reason === "offline-off" &&

{t("stopped_offline_off")}

} + {progress.phase === "done" && progress.exported === 0 && progress.failed === 0 &&

{t("nothing_kept")}

} + {leftover &&

{t("leftover_file", { name: leftover })}

} {notKeptCount > 0 && (
{!!notKept?.attachments_off &&

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

} @@ -144,9 +191,22 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) const attachmentsOff = !scopeForAccount(account).scope.attachments; // Offline with no original of anything: the save picker would ask where to put nothing. const nothingToWrite = total === 0 || (kept === 0 && !reachable); + // What the run will need, against what this browser can give it. Asked up front: a + // tab killed with no message after hours is the outcome to avoid. + const needed = chosen.reduce((sum, row) => sum + (reachable ? row.bytes : row.keptBytes), 0); + const limit = mode === "memory" ? MEMORY_EXPORT_LIMIT : mode === "download" ? spoolLimit() : Infinity; + const overLimit = needed > limit; + const noRoom = mode === "download" && loaded?.free != null && needed > loaded.free; const onStart = async () => { if (!loaded || starting || chosen.length === 0) return; + // Before the picker, with nothing awaited, so the click still counts for it. + if (!localExportAllowed(account)) { + setRefused(localExportDecided(account) ? tPolicy("export_blocked") : t("policy_unknown")); + downloadAllowed("export", account); + return; + } + setRefused(null); const zip = !single || format === "eml"; const base = single ? safeName(chosen[0].folder.path[chosen[0].folder.path.length - 1] ?? "", "mail") @@ -185,7 +245,7 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) footer={( <> - + )} > @@ -267,6 +327,9 @@ export function LocalExportDialog({ request }: { request: LocalExportRequest }) {missing > 0 && !reachable && (

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

)} + {overLimit &&

{t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}

} + {noRoom && !overLimit &&

{t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}

} + {refused &&

{refused}

}

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

diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index 51a43b19f..5f9296947 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -8,7 +8,7 @@ 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"; +import { dropExportFiles, sweepPrivateExports } from "@/lib/mail-export/sinks"; import { useAuthStore } from "@/stores/auth-store"; import { toast } from "@/stores/toast-store"; import { MailImportDialog } from "./mail-import-dialog"; @@ -24,14 +24,25 @@ const SIGN_OUT_WAIT_MS = 1500; * browser. `all`: whoever's job it is. Another account's job goes on. */ async function stopJobOf(accountId: string): Promise { + // Finished exports waiting out their grace period, with the dialog long closed. + dropExportFiles(accountId); const store = useMailTransferStore.getState(); const job = store.job; if (!job || !(accountId === "all" || !job.scope || job.scope === accountId)) return; + const forget = () => { + const now = useMailTransferStore.getState(); + if (now.job?.controller === job.controller) now.close({ now: true }); + }; if (isJobActive(job)) { store.cancelJob("signout"); await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]); + // Still going after the wait: close() leaves a running job be, so it goes when it ends. + if (isJobActive(useMailTransferStore.getState().job)) { + void job.finished?.then(forget, forget); + return; + } } - useMailTransferStore.getState().close({ now: true }); + forget(); } /** diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts index 965033304..ca40ea492 100644 --- a/components/mail-transfer/start-local-export.ts +++ b/components/mail-transfer/start-local-export.ts @@ -2,23 +2,29 @@ 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 { scopeDecided } from "@/lib/offline/offline-mode"; 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"; +import { useAccountSubjects } from "@/lib/org/account-subjects"; 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. + * Whether the organisation has said what applies to this account, down to its + * groups. A start offline may not have heard it, and a group or domain scope may + * forbid what Everyone may do: not known means no. */ +export function localExportDecided(account: string): boolean { + return scopeDecided(account); +} + +/** Whether the organisation lets this account export. */ 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); + return localExportDecided(account) && downloadAllowedQuietly("export", account); } function clientOf(account: string) { @@ -68,6 +74,10 @@ export function startLocalExport(input: StartLocalExportInput): boolean { const unwatchPolicy = usePolicyStore.subscribe(() => { if (!localExportAllowed(account)) controller.abort("policy"); }); + // What another account's groups turn out to be can say no as well. + const unwatchSubjects = useAccountSubjects.subscribe(() => { + if (!localExportAllowed(account)) controller.abort("policy"); + }); const unwatchOffline = useOfflineStore.subscribe((state) => { if (!state.enabled.includes(account)) controller.abort("offline-off"); }); @@ -91,11 +101,13 @@ export function startLocalExport(input: StartLocalExportInput): boolean { sink: guardedSink(input.sink, () => localExportAllowed(account)), signal: controller.signal, requestsPerMinute: Infinity, + noEmptyFile: true, onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress), }); useMailTransferStore.getState().updateProgress(result); } finally { unwatchPolicy(); + unwatchSubjects(); unwatchOffline(); ended(); } diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts index d4a431151..682ad72fe 100644 --- a/integration/tests/24-do-export.spec.ts +++ b/integration/tests/24-do-export.spec.ts @@ -1,16 +1,22 @@ /** - * Export of the local store, through the real UI, as ent-dave, against a VM 106 + * Export of the local store, through the real UI, as dso-export, 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. + * An account of its own: other specs running at the same time work in the ent-* + * accounts, and folders of this one went missing there mid-run. Check or create it + * on the VM first (~/desktop-offline/dso_export_provision.py), then run + * desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item + * "Stalwart test – dso-* throwaway accounts". 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 { adminContext, contextOptions, requireEntEnv, shot, withPolicy } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; import { JMAP_URL } from './helpers/config'; +import { JmapClient } from './helpers/jmap'; import { listZipEntries, readZipEntry } from '../../lib/mail-import/zip-reader'; import { MboxSplitter } from '../../lib/mail-import/mbox'; import { withoutStateHeaders } from '../../lib/mail-import/mbox-status'; @@ -19,14 +25,18 @@ requireEntEnv(); test.describe.configure({ mode: 'serial' }); test.setTimeout(300_000); -const USER = ENT.dave; +const USER = 'dso-export@example.org'; +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file'); 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'); +const auth = () => 'Basic ' + Buffer.from(`${USER}:${PASSWORD}`).toString('base64'); +let jmap: Promise | null = null; +const account = () => (jmap ??= JmapClient.connect(USER, PASSWORD)); /** 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 { @@ -42,7 +52,7 @@ const blobIds: string[] = []; const originals: Buffer[] = []; test.beforeAll(async () => { - const client = await jmapAs(USER); + const client = await account(); 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) }); @@ -60,7 +70,7 @@ test.beforeAll(async () => { }); test.afterAll(async () => { - const client = await jmapAs(USER); + const client = await account(); await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [mailbox], onDestroyRemoveEmails: true }, '0']]).catch(() => {}); }); @@ -85,6 +95,17 @@ async function settings(target: Page): Promise { await expect(target.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 }); } +/** A page load with no network: the app shell from the service worker, the rest from the device. */ +async function startOffline(): Promise { + await page.reload(); + await expect(page.getByTestId('offline-banner')).toContainText("You're offline", { timeout: 60_000 }); + // The shell can open Settings on its first page: from there through the menu, as a person would. + if (!(await page.getByTestId('offline-switch').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true, () => false))) { + await page.getByText('Backup & reset', { exact: true }).first().click(); + } + await expect(page.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(); @@ -133,6 +154,21 @@ function expectOriginals(messages: Buffer[]): void { for (let i = 0; i < COUNT; i += 1) expect(messages[i].equals(originals[i]), `message ${i + 1} differs`).toBe(true); } +/** What this run wrote to the private files, for looking inside it. */ +async function spoolBytes(): Promise { + const base64 = await page.evaluate(() => { + const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites; + let binary = ''; + for (const write of writes) for (const byte of write) binary += String.fromCharCode(byte); + return btoa(binary); + }); + return Buffer.from(base64, 'base64'); +} + +async function forgetSpoolWrites(): Promise { + await page.evaluate(() => { (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites.length = 0; }); +} + async function spoolReport(): Promise<{ left: string[]; bytes: number; readable: boolean }> { return page.evaluate(async (secret) => { const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites; @@ -152,8 +188,14 @@ async function spoolReport(): Promise<{ left: string[]; bytes: number; readable: } test.beforeAll(async ({ browser }: { browser: Browser }) => { - page = await userPage(browser, USER); - context = page.context(); + context = await browser.newContext(contextOptions()); + page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', USER); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); await context.addInitScript(INIT); await settings(page); const toggle = page.getByTestId('offline-switch'); @@ -186,42 +228,72 @@ test('a folder exported offline as mbox is the bytes the server has, and the pri }); test('the same folder as a ZIP of .eml files has the same bytes', async () => { + await forgetSpoolWrites(); 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([]); + // Deflated mail wouldn't show up in a search for its text: it has to be no ZIP at all. + const spool = await spoolBytes(); + expect(spool.length).toBeGreaterThan(0); + expect(spool.includes(Buffer.from('PK\x03\x04', 'latin1'))).toBe(false); + expect(spool.includes(Buffer.from(FOLDER))).toBe(false); + await expect(listZipEntries(new Blob([new Uint8Array(spool)]))).rejects.toThrow(); 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 () => {}, - }), - }); - }); +/** + * The picker's dialog is the only fake: what it answers is a real file handle, with + * a real writable and a real remove. `keepRemove: false` is a browser without remove. + */ +async function pickerAnswers(name: string, keepRemove = true): Promise { + await page.evaluate(async ({ name: file, keepRemove: keep }) => { + const root = await navigator.storage.getDirectory(); + const window_ = window as unknown as { showSaveFilePicker: unknown; __pickerClicks: number }; + window_.__pickerClicks = 0; + window_.showSaveFilePicker = async () => { + // The real one refuses a call that lost the click. + if (!navigator.userActivation.isActive) throw new DOMException('Must be handling a user gesture', 'SecurityError'); + window_.__pickerClicks += 1; + const handle = await root.getFileHandle(file, { create: true }); + return keep ? handle : { name: handle.name, createWritable: () => handle.createWritable() }; + }; + }, { name, keepRemove }); +} + +async function pickedFile(name: string): Promise { + const base64 = await page.evaluate(async (file) => { + const root = await navigator.storage.getDirectory(); + try { + const bytes = new Uint8Array(await (await (await root.getFileHandle(file)).getFile()).arrayBuffer()); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); + } catch { + return null; + } + }, name); + return base64 === null ? null : Buffer.from(base64, 'base64'); +} + +async function noPicker(): Promise { + await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; }); +} + +test('with a save picker, it writes straight to the chosen file, from the click, and no spool is left', async () => { + await pickerAnswers(`${STAMP}-picked.mbox`); 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); + expect(await page.evaluate(() => (window as unknown as { __pickerClicks: number }).__pickerClicks)).toBe(1); + expectOriginals(mboxMessages((await pickedFile(`${STAMP}-picked.mbox`))!)); + expect((await spoolReport()).left).toEqual([]); await closeSummary(); - await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; }); + await noPicker(); }); test('with attachments off it says offline why it can\'t; a connection lost during the run is counted; the retry writes them all', async () => { @@ -242,7 +314,8 @@ test('with attachments off it says offline why it can\'t; a connection lost duri // The browser says online, and the server goes as the export starts. await context.setOffline(false); await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 30_000 }); - await context.route(`${new URL(JMAP_URL).origin}/**`, (route) => route.abort('internetdisconnected')); + const SERVER = `${new URL(JMAP_URL).origin}/**`; + await context.route(SERVER, (route) => route.abort('internetdisconnected')); let downloads = 0; const counted = () => { downloads += 1; }; page.on('download', counted); @@ -257,7 +330,7 @@ test('with attachments off it says offline why it can\'t; a connection lost duri expect(downloads).toBe(0); await shot(page, 'd10-export-not-kept'); - await context.unroute(`${new URL(JMAP_URL).origin}/**`); + await context.unroute(SERVER); // The browser going off says so in the summary, and coming back opens the retry. await context.setOffline(true); await expect(page.getByTestId('local-export-retry')).toBeDisabled(); @@ -272,6 +345,32 @@ test('with attachments off it says offline why it can\'t; a connection lost duri expectOriginals(mboxMessages(again)); await closeSummary(); + // The same with a save picker: a run that wrote nothing takes the file the picker made with it. + const removed = `${STAMP}-removed.mbox`; + const stays = `${STAMP}-stays.mbox`; + for (const [file, keepRemove] of [[removed, true], [stays, false]] as const) { + await pickerAnswers(file, keepRemove); + await openDialog(); + await page.getByTestId('local-export-format-mbox').check(); + // The server goes once it has counted as reachable: offline with nothing here there's no Export to click. + await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 60_000 }); + await context.route(SERVER, (route) => route.abort('internetdisconnected')); + await page.getByTestId('local-export-start').click(); + await expect(page.getByTestId('local-export-not-kept-attachments')).toBeVisible({ timeout: 60_000 }); + await context.unroute(SERVER); + if (keepRemove) { + expect(await pickedFile(file), 'the picked file is gone').toBeNull(); + await expect(page.getByTestId('local-export-leftover')).toHaveCount(0); + } else { + await expect(page.getByTestId('local-export-leftover')).toContainText(file); + await shot(page, 'd10-export-leftover'); + } + await context.setOffline(true); + await context.setOffline(false); + await closeSummary(); + } + await noPicker(); + await page.getByTestId('offline-attachments').click(); await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'true'); }); @@ -304,9 +403,12 @@ test('"No export" locks it, also once offline', async ({ browser }) => { await settings(page); await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 }); await expect(page.getByText('Set by your organisation').first()).toBeVisible(); + // A start with no network: all it has is the policy it kept from the last fetch. await context.setOffline(true); - await expect(page.getByTestId('local-export-open')).toBeDisabled(); + await startOffline(); + await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 }); await expect(page.getByTestId('local-export-dialog')).toHaveCount(0); + await shot(page, 'd10-export-locked-offline-start'); await context.setOffline(false); }); } finally { @@ -314,4 +416,13 @@ test('"No export" locks it, also once offline', async ({ browser }) => { } await settings(page); await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 30_000 }); + // The other way round: a start with no network, allowed by the kept policy, exports. + await waitForOriginals(`${COUNT} of ${COUNT}`); + await context.setOffline(true); + await startOffline(); + await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 60_000 }); + await openDialog(); + expectOriginals(mboxMessages(await exported('mbox'))); + await closeSummary(); + await context.setOffline(false); }); diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts index 96de1ba46..23c55dc4c 100644 --- a/lib/mail-export/__tests__/local-store-export.test.ts +++ b/lib/mail-export/__tests__/local-store-export.test.ts @@ -66,9 +66,10 @@ function serverWith(ids: Record): Server { } async function storeOf(server: Server, scope: StoreScope = SCOPE, areas = [mailArea]) { + const db = openMemoryStore(); const engine = new AccountEngine({ account: 'acc', - db: openMemoryStore(), + db, session: FAKE_SESSION, transport: server, areas, @@ -79,6 +80,7 @@ async function storeOf(server: Server, scope: StoreScope = SCOPE, areas = [mailA await engine.requestSync(); return { engine, + db, store: { answer: (_account: string, calls: MethodCall[]) => engine.answer(calls), area: (_account: string, name: string, params?: unknown) => engine.areaCall(name, params) as Promise, @@ -156,6 +158,71 @@ describe('an export from the local store', () => { expect(messages[1]).toEqual(server.files.get('raw-b')); }); + it('keeps the exact bytes in an .eml, and in an mbox puts the store\'s state in place of the state headers it had', async () => { + const headers = 'Status: O\r\nX-Status: F\r\nX-Keywords: oldword\r\nX-Mozilla-Status: 0009\r\nX-Mozilla-Status2: 00800000\r\nX-Mozilla-Keys: oldkey\r\n'; + const rest = 'From: a@example.org\r\nSubject: Stateful\r\n\r\nStatus: in the body stays\r\n'; + const data = enc.encode(`${headers}${rest}`); + const server = new Server({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }] }); + server.files.set('raw-s', data); + server.addEmail({ id: 's', blobId: 'raw-s', size: data.length, subject: 'Stateful', keywords: { $seen: true }, mailboxIds: { inbox: true } }); + const { store } = await storeOf(server); + const eml = await run(store, 'eml', ['inbox']); + expect([...(await zipEntries(eml.bytes)).values()]).toEqual([data]); + const mbox = await run(store, 'mbox', ['inbox']); + const text = Buffer.from(mbox.bytes).toString('latin1'); + expect(text).toContain('Status: RO\r\n'); + for (const gone of ['X-Status: F', 'oldword', 'oldkey', 'X-Mozilla-Status']) expect(text).not.toContain(gone); + expect(text).toContain('Status: in the body stays'); + expect(Buffer.from(mboxMessages(mbox.bytes)[0]).toString('latin1')).toBe(rest); + }); + + it('writes what\'s kept while a full listing runs, when an empty folder refuses to answer', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store, db } = await storeOf(server); + // As during a first sync or after a lost state: the store can\'t tell empty from not listed yet. + db.run("UPDATE sync_state SET state = NULL WHERE type = 'Email'"); + expect(await store.answer('acc', [['Email/query', { accountId: JMAP_ACCOUNT, filter: { inMailbox: 'archive' } }, '0']])).toBeNull(); + const { result, bytes } = await run(store, 'eml', ['inbox', 'archive']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(2); + expect([...(await zipEntries(bytes)).keys()].filter((name) => name.endsWith('.eml'))).toHaveLength(2); + }); + + it('stops, rather than pass off a refusal as an empty folder, when the store says the folder has mail', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store } = await storeOf(server); + const refusing = { ...store, answer: async () => null }; + const { sink } = collecting(); + const result = await runExport({ + client: localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store: refusing, online: () => null }), + accountId: JMAP_ACCOUNT, + folders: [{ mailboxId: 'inbox', path: ['inbox'] }], + format: 'mbox', + sink, + signal: new AbortController().signal, + requestsPerMinute: Infinity, + sleep: async () => {}, + onProgress: () => {}, + }); + expect(result.phase).toBe('failed'); + expect(result.file).toBeUndefined(); + }); + + it('says how large an export is and how much of it is here', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'archive' }); + server.gone.add('raw-b'); + const { store } = await storeOf(server); + const size = (id: string) => server.files.get(id)!.length; + expect(await store.area('acc', 'mail.rawSize', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ bytes: size('raw-a') + size('raw-b'), keptBytes: size('raw-a') }); + expect(await store.area('acc', 'mail.rawSize', { jmap: JMAP_ACCOUNT, mailboxId: 'archive' })).toEqual({ bytes: size('raw-c'), keptBytes: size('raw-c') }); + }); + + it('says nothing is here with attachments off', async () => { + const server = serverWith({ a: 'inbox' }); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + expect(await store.area('acc', 'mail.rawSize', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ bytes: server.files.get('raw-a')!.length, keptBytes: 0 }); + }); + 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'); diff --git a/lib/mail-export/__tests__/sinks.test.ts b/lib/mail-export/__tests__/sinks.test.ts index f2e7f1551..b882b5033 100644 --- a/lib/mail-export/__tests__/sinks.test.ts +++ b/lib/mail-export/__tests__/sinks.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node import { describe, it, expect, vi, afterEach } from 'vitest'; -import { canWritePrivateFile, downloadGraceMs, guardedSink, openExportSink, privateFileSink, sweepPrivateExports, ExportForbiddenError, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; +import { blobHeldInMemory, canWritePrivateFile, downloadGraceMs, dropExportFiles, guardedSink, memorySink, openExportSink, pickSaveFile, privateFileSink, spoolLimit, sweepPrivateExports, ExportForbiddenError, ExportTooLargeError, SPOOL_MEMORY_LIMIT, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; +import { ZipWriter } from '@/lib/mail-export/zip-writer'; +import { useMailTransferStore } from '@/stores/mail-transfer-store'; /** A tiny origin private file system: one directory of files. */ function fakeOpfs() { @@ -163,6 +165,60 @@ describe('privateFileSink', () => { expect(opfs.files.size).toBe(0); }); + it('never lets a ZIP through readable either, where a search for the text would find nothing', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.zip', 'application/zip'))!; + const zip = new ZipWriter((chunk) => sink.write(chunk)); + await zip.addFile('Inbox/1.eml', MAIL, new Date()); + await zip.finish(); + await sink.close(); + const asZip = (await import('@/lib/mail-import/zip-reader')).listZipEntries; + expect(asText(opfs.written)).not.toContain('PK\x03\x04'); + expect(asText(opfs.written)).not.toContain('Inbox/1.eml'); + await expect(asZip(new Blob(opfs.written as BlobPart[]))).rejects.toThrow(); + expect((await asZip(handed[0])).map((entry) => entry.name)).toEqual(['Inbox/1.eml']); + }); + + it('downloads nothing when stopped while the spool is being opened, and leaves no Blob', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + // Three pieces: a large spool must stop where it is, not be opened to the end first. + for (let i = 0; i < 3; i += 1) await sink.write(new Uint8Array(1 << 20)); + expect(opfs.written.length).toBe(3); + const controller = new AbortController(); + const decrypt = crypto.subtle.decrypt.bind(crypto.subtle); + const spy = vi.spyOn(crypto.subtle, 'decrypt').mockImplementation(async (...args) => { + controller.abort(); + return decrypt(...args); + }); + await expect(sink.close(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + // A stream reads one piece ahead at most before it hears the abort. + expect(spy.mock.calls.length).toBeLessThan(3); + spy.mockRestore(); + expect(handed).toEqual([]); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + + it('downloads nothing when stopped just as the Blob is ready', async () => { + const { handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + const controller = new AbortController(); + vi.stubGlobal('Response', class { blob() { controller.abort(); return Promise.resolve(new Blob(['x'])); } }); + await expect(sink.close(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(handed).toEqual([]); + }); + + it('stops at its limit while writing, and the spool goes', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox', 10))!; + await sink.write(new Uint8Array(10)); + await expect(sink.write(new Uint8Array(1))).rejects.toBeInstanceOf(ExportTooLargeError); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + it('a crash leaves only sealed bytes, which the sweep at the next start deletes', async () => { const { opfs } = setUp(); const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; @@ -177,6 +233,126 @@ describe('privateFileSink', () => { }); }); +describe('where the final Blob sits in memory', () => { + const AGENTS: [string, string, boolean][] = [ + ['Safari', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15', true], + ['Chrome on iOS, which is WebKit', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0 Mobile/15E148 Safari/604.1', true], + ['Firefox on iOS, which is WebKit', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/130.0 Mobile/15E148 Safari/605.1.15', true], + ['Chrome', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36', false], + ['Edge', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0', false], + ['Firefox', 'Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', false], + ['the desktop app', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Electron/40.0.0 Safari/537.36', false], + ]; + + it.each(AGENTS)('%s', (_name, userAgent, inMemory) => { + vi.stubGlobal('navigator', { userAgent }); + expect(blobHeldInMemory()).toBe(inMemory); + expect(spoolLimit()).toBe(inMemory ? SPOOL_MEMORY_LIMIT : Infinity); + }); + + it('a spool on such an engine takes the cap by default', async () => { + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { userAgent: AGENTS[0][1], storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + // Nothing this size is allocated: the check is on the length it is told. + const huge = { length: SPOOL_MEMORY_LIMIT + 1 } as Uint8Array; + await expect(sink.write(huge)).rejects.toMatchObject({ name: 'ExportTooLargeError', limit: SPOOL_MEMORY_LIMIT }); + }); +}); + +describe('a finished export held for its download', () => { + function stubUrls() { + // What earlier tests left held. + dropExportFiles('all'); + const revoke = vi.fn(); + let n = 0; + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => `blob:${++n}`, revokeObjectURL: revoke })); + return revoke; + } + + async function finished(scope: string) { + useMailTransferStore.setState({ job: { kind: 'export', scope, controller: new AbortController(), progress: { phase: 'running', exported: 0, failed: 0, failures: [], total: 0, fraction: 0 } } }); + const sink = memorySink('a.mbox', 'application/mbox'); + await sink.write(new Uint8Array(3)); + const file = await sink.close(); + useMailTransferStore.setState({ job: null }); + return file; + } + + it('goes at a sign-out of its account while it waits out the grace period, with the dialog closed', async () => { + const revoke = stubUrls(); + vi.useFakeTimers(); + const file = await finished('acc'); + void file.discard(); + expect(revoke).not.toHaveBeenCalled(); + dropExportFiles('someone-else'); + expect(revoke).not.toHaveBeenCalled(); + dropExportFiles('acc'); + expect(revoke).toHaveBeenCalledTimes(1); + // Nothing to do when the wait ends. + await vi.advanceTimersByTimeAsync(downloadGraceMs(3)); + expect(revoke).toHaveBeenCalledTimes(1); + }); + + it('goes when everything goes', async () => { + const revoke = stubUrls(); + await finished('acc'); + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root } }); + await sweepPrivateExports({ force: true }); + expect(revoke).toHaveBeenCalledTimes(1); + }); + + it('can\'t be downloaded once it has gone', async () => { + stubUrls(); + const anchors: string[] = []; + vi.stubGlobal('document', { createElement: () => ({ click() { anchors.push('clicked'); }, remove() {} }), body: { appendChild() {} } }); + const file = await finished('acc'); + expect(anchors).toEqual(['clicked']); + dropExportFiles('acc'); + file.download?.(); + expect(anchors).toEqual(['clicked']); + }); +}); + +describe('the file the user picked', () => { + function picked(handle: Record) { + const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) }; + vi.stubGlobal('window', { showSaveFilePicker: async () => ({ createWritable: async () => writable, ...handle }) }); + return writable; + } + + it('is removed on an abort where the browser can', async () => { + const remove = vi.fn(async () => {}); + const writable = picked({ remove }); + const sink = await pickSaveFile('a.mbox', 'application/mbox', '.mbox'); + expect(await sink.abort()).toEqual({}); + expect(writable.abort).toHaveBeenCalled(); + expect(remove).toHaveBeenCalled(); + }); + + it('says so where it can\'t be removed', async () => { + picked({}); + expect(await (await pickSaveFile('a.mbox', 'application/mbox', '.mbox')).abort()).toEqual({ leftover: 'a.mbox' }); + picked({ remove: async () => { throw new Error('no'); } }); + expect(await (await pickSaveFile('a.mbox', 'application/mbox', '.mbox')).abort()).toEqual({ leftover: 'a.mbox' }); + }); + + it('names the file as the person named it', async () => { + picked({ name: 'my backup.mbox' }); + expect(await (await pickSaveFile('a.mbox', 'application/mbox', '.mbox')).abort()).toEqual({ leftover: 'my backup.mbox' }); + }); + + it('isn\'t finished when stopped first', async () => { + const writable = picked({}); + const sink = await pickSaveFile('a.mbox', 'application/mbox', '.mbox'); + const controller = new AbortController(); + controller.abort(); + await expect(sink.close(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(writable.close).not.toHaveBeenCalled(); + }); +}); + describe('guardedSink', () => { function fakeSink(): ExportSink & { write: ReturnType; close: ReturnType } { return { diff --git a/lib/mail-export/engine.ts b/lib/mail-export/engine.ts index 68453ac67..c70386353 100644 --- a/lib/mail-export/engine.ts +++ b/lib/mail-export/engine.ts @@ -55,6 +55,12 @@ export interface ExportProgress { pausedUntil?: number; file?: ExportFile; error?: string; + /** What stopped it, as the error's name: a dialog says this in the user's language. */ + errorCode?: string; + /** The limit an ExportTooLargeError ran into, in bytes. */ + errorLimit?: number; + /** A file in the place the user picked that couldn't be removed after a run that wrote nothing whole. */ + leftover?: string; } /** What an export reads through: the real client, or lib/mail-export/local-store-client.ts. */ @@ -71,6 +77,8 @@ export interface ExportOptions { now?: () => number; sleep?: (ms: number, signal: AbortSignal) => Promise; reportEveryMs?: number; + /** No file when nothing was written, also when nothing failed: the person chose mail, not an empty file. */ + noEmptyFile?: boolean; /** * 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. @@ -95,6 +103,10 @@ const BATCH_COUNT = 100; const MAX_FAILURES_LISTED = 20; const RETRY_DELAYS_MS = [500, 2000, 5000]; +function leftoverOf(aborted: { leftover?: string } | void | undefined): string | undefined { + return aborted ? aborted.leftover : undefined; +} + function isAbort(error: unknown): boolean { return (error as { name?: string } | null)?.name === "AbortError"; } @@ -366,23 +378,31 @@ export async function runExport(options: ExportOptions): Promise } // A cancel with nothing left to fetch must not close and hand out the file. if (signal.aborted) throw cancelled(); - if (progress.exported === 0 && progress.failed > 0) { + if (progress.exported === 0 && (progress.failed > 0 || options.noEmptyFile)) { // Every message failed: an empty file would only look like a backup. - await sink.abort(); + progress.leftover = leftoverOf(await sink.abort()); } else { if (zip) await zip.finish(); - progress.file = await sink.close(); + const file = await sink.close(signal); + // Stopped while the file was being finished: it goes, and nobody gets to download it. + if (signal.aborted) { + await file.discard(true).catch(() => {}); + throw cancelled(); + } + progress.file = file; } progress.phase = "done"; progress.fraction = 1; } catch (error) { - await sink.abort().catch(() => {}); + progress.leftover = leftoverOf(await sink.abort().catch(() => undefined)); progress.pausedUntil = undefined; if (isAbort(error) || signal.aborted) { progress.phase = "cancelled"; } else { progress.phase = "failed"; progress.error = error instanceof Error ? error.message : String(error); + progress.errorCode = (error as { name?: string } | null)?.name; + progress.errorLimit = (error as { limit?: number } | null)?.limit; } } report(true); diff --git a/lib/mail-export/local-store-client.ts b/lib/mail-export/local-store-client.ts index 9c4f555a3..edd4943c6 100644 --- a/lib/mail-export/local-store-client.ts +++ b/lib/mail-export/local-store-client.ts @@ -60,10 +60,33 @@ export function localExportClient({ account, jmap, store, online, gate = network 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`)); + if (!answer || method === 'error') { + const refusal = result as { description?: string; type?: string } | undefined; + // A request whose only call is refused comes back as no answer at all. + throw Object.assign(new Error(String(refusal?.description ?? refusal?.type ?? `${name} got no answer`)), { refusal: refusal?.type ?? 'serverUnavailable' }); + } return result as T; }; + /** + * A folder with nothing in it refuses while a full listing runs: the store can't + * tell empty from not listed yet. To an export that's an empty folder, so what's + * kept in the other folders still goes out. Only when the store's own count says + * it holds nothing: any other refusal must not pass for an empty folder, or the + * person gets a file that's missing mail and no word about it. + */ + const queryIds = async (args: Record): Promise => { + try { + return (await call<{ ids: string[] }>('Email/query', args)).ids; + } catch (error) { + if ((error as { refusal?: string }).refusal !== 'serverUnavailable') throw error; + const mailboxId = (args.filter as { inMailbox?: string } | undefined)?.inMailbox; + const { total } = await store.area<{ total: number }>(account, 'mail.rawStatus', { jmap, mailboxId }); + if (total === 0) return []; + throw error; + } + }; + 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'); @@ -90,7 +113,7 @@ export function localExportClient({ account, jmap, store, online, gate = network 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', { + const ids = await queryIds({ filter, sort: [{ property: 'receivedAt', isAscending: false }], position, diff --git a/lib/mail-export/sinks.ts b/lib/mail-export/sinks.ts index ac849578f..ec0c82446 100644 --- a/lib/mail-export/sinks.ts +++ b/lib/mail-export/sinks.ts @@ -13,7 +13,8 @@ import { useMailTransferStore } from "@/stores/mail-transfer-store"; * only ever in this tab's memory: a crash leaves bytes nobody can read. * When the export is done the pieces are opened into a Blob the browser * holds (Firefox and Chromium keep a large one in their own temporary - * storage), the spool is deleted at once, and the Blob is downloaded. The + * storage; WebKit holds it in memory, so there the export has a cap), the + * spool is deleted at once, and the Blob is downloaded. The * Blob goes a while after the dialog closes, so the download can finish, or * at once on sign-out or a wipe. A sweep deletes what a crash left when the * app starts, someone signs in or the next export starts. Each spool is @@ -36,8 +37,10 @@ export interface ExportFile { export interface ExportSink { kind: "picker" | "private" | "memory"; write(chunk: Uint8Array): Promise; - close(): Promise; - abort(): Promise; + /** Finishes the file. If `signal` fires, nothing is handed out and it rejects. */ + close(signal?: AbortSignal): Promise; + /** Throws away what was written. `leftover`: a file it couldn't remove from where the user put it. */ + abort(): Promise<{ leftover?: string } | void>; } export class ExportTooLargeError extends Error { @@ -57,6 +60,8 @@ export class ExportForbiddenError extends Error { /** The most a memory-only export may hold. */ export const MEMORY_EXPORT_LIMIT = 200 * 1024 * 1024; +/** The most a spool may hold where its final Blob sits in memory: see spoolLimit. */ +export const SPOOL_MEMORY_LIMIT = 1024 * 1024 * 1024; const PRIVATE_DIR = "bulwark-exports"; const LOCK_PREFIX = "bulwark-export-file:"; /** How long a closed export's Blob stays for its download: a minute, plus a second per 50 MB. */ @@ -109,7 +114,79 @@ function triggerDownload(url: string, name: string): void { saveBlobAs(url, name, { kind: "export", subject: scope && scope !== "default" ? scope : null }); } -type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record }[] }) => Promise<{ createWritable(): Promise }>; +type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record }[] }) => Promise<{ name?: string; createWritable(): Promise; remove?(): Promise }>; + +function stopped(): DOMException { + return new DOMException("Export cancelled", "AbortError"); +} + +/** + * An engine that keeps a large Blob in memory: every browser on iOS, and Safari. + * Firefox and Chromium move a large one to their own temporary storage. + */ +export function blobHeldInMemory(): boolean { + const agent = typeof navigator === "undefined" ? "" : navigator.userAgent ?? ""; + return /AppleWebKit/.test(agent) && !/Chrome\/|Chromium\/|Edg\//.test(agent); +} + +/** + * How much a spool may hold before it stops. On an engine holding the Blob in memory + * it fails early: a tab killed with no message after hours is what this is for. + */ +export function spoolLimit(): number { + return blobHeldInMemory() ? SPOOL_MEMORY_LIMIT : Infinity; +} + +/** What holds a finished export in this browser, until it's released. */ +interface LiveFile { + url: string; + scope: string | null; +} + +const liveFiles = new Set(); + +/** The object URL is what keeps the Blob alive: once it's revoked nothing else here refers to it. */ +function holdFile(blob: Blob): LiveFile { + const scope = useMailTransferStore.getState().job?.scope; + const file: LiveFile = { url: URL.createObjectURL(blob), scope: scope && scope !== "default" ? scope : null }; + liveFiles.add(file); + return file; +} + +function releaseFile(file: LiveFile): void { + if (!liveFiles.delete(file)) return; + URL.revokeObjectURL(file.url); +} + +/** + * Drops finished exports still held for their download, also after the dialog closed: + * a sign-out or a wipe doesn't wait out the grace period. `all`: whoever's they are. + */ +export function dropExportFiles(account: string): void { + for (const file of [...liveFiles]) { + if (account === "all" || file.scope === null || file.scope === account) releaseFile(file); + } +} + +/** A file downloaded when done, for as long as the Blob is held. */ +function downloadableFile(name: string, blob: Blob): ExportFile { + const size = blob.size; + const held = holdFile(blob); + const download = () => { + if (liveFiles.has(held)) triggerDownload(held.url, name); + }; + download(); + return { + name, + size, + download, + discard: async (now = false) => { + // Taking the Blob away from a download that is still reading it would break the download. + if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(size))); + releaseFile(held); + }, + }; +} /** Whether this browser can write a private file from the page (Safari before 26 can't). */ export function canWritePrivateFile(): boolean { @@ -135,12 +212,25 @@ export async function pickSaveFile(name: string, mime: string, extension: string return { kind: "picker", write: (chunk) => buf.write(chunk), - close: async () => { + close: async (signal) => { await buf.flush(); + if (signal?.aborted) throw stopped(); await writable.close(); return { name, size: buf.total, discard: async () => {} }; }, - abort: async () => { await writable.abort().catch(() => {}); }, + abort: async () => { + await writable.abort().catch(() => {}); + // The picker made or emptied the file the moment it answered. Not every browser can remove it. + // What the person called it in the picker, which needn't be what was suggested. + const leftover = { leftover: handle.name ?? name }; + if (typeof handle.remove !== "function") return leftover; + try { + await handle.remove(); + return {}; + } catch { + return leftover; + } + }, }; } @@ -161,11 +251,12 @@ function pieceIv(piece: number): Uint8Array { } /** Reads the pieces back in order, one at a time, opened. A piece that was changed fails the read. */ -function openedPieces(file: Blob, sizes: readonly number[], key: CryptoKey): ReadableStream { +function openedPieces(file: Blob, sizes: readonly number[], key: CryptoKey, signal?: AbortSignal): ReadableStream { let piece = 0; let at = 0; return new ReadableStream({ async pull(controller) { + if (signal?.aborted) throw stopped(); if (piece === sizes.length) { controller.close(); return; @@ -180,7 +271,7 @@ function openedPieces(file: Blob, sizes: readonly number[], key: CryptoKey): Rea } /** An encrypted spool on disk, downloaded when done; null where the browser can't write one from the page. */ -export async function privateFileSink(name: string, mime: string): Promise { +export async function privateFileSink(name: string, mime: string, limit = spoolLimit()): Promise { const dir = await privateDir(); if (!dir || !globalThis.crypto?.subtle) return null; const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); @@ -215,25 +306,19 @@ export async function privateFileSink(name: string, mime: string): Promise buf.write(chunk), - close: async () => { + write: async (chunk) => { + if (buf.total + chunk.length > limit) throw new ExportTooLargeError(limit); + await buf.write(chunk); + }, + close: async (signal) => { await buf.flush(); await writable.close(); // Through a Response so the browser may spill a large Blob to its own storage. - const opened = await new Response(openedPieces(await handle.getFile(), sizes, key)).blob(); + const opened = await new Response(openedPieces(await handle.getFile(), sizes, key, signal)).blob(); + // A cancel, sign-out or wipe during that: the Blob is dropped here and nothing downloads. + if (signal?.aborted) throw stopped(); await remove(); - const url = URL.createObjectURL(new Blob([opened], { type: mime })); - triggerDownload(url, name); - return { - name, - size: opened.size, - download: () => triggerDownload(url, name), - discard: async (now = false) => { - // Taking the Blob away from a download that is still reading it would break the download. - if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(opened.size))); - URL.revokeObjectURL(url); - }, - }; + return downloadableFile(name, new Blob([opened], { type: mime })); }, abort: async () => { await writable.abort().catch(() => {}); await remove(); }, }; @@ -267,20 +352,14 @@ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIM if (size > limit) throw new ExportTooLargeError(limit); parts.push(chunk.slice()); }, - close: async () => { + close: async (signal) => { + if (signal?.aborted) { + parts = []; + throw stopped(); + } const blob = new Blob(parts as BlobPart[], { type: mime }); parts = []; - const url = URL.createObjectURL(blob); - triggerDownload(url, name); - return { - name, - size: blob.size, - download: () => triggerDownload(url, name), - discard: async (now = false) => { - if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(blob.size))); - URL.revokeObjectURL(url); - }, - }; + return downloadableFile(name, blob); }, abort: async () => { parts = []; }, }; @@ -300,9 +379,9 @@ export function guardedSink(sink: ExportSink, allowed: () => boolean): ExportSin check(); await sink.write(chunk); }, - close: async () => { + close: async (signal) => { check(); - return sink.close(); + return sink.close(signal); }, abort: () => sink.abort(), }; @@ -315,6 +394,7 @@ export function guardedSink(sink: ExportSink, allowed: () => boolean): ExportSin * Without Web Locks only `force` sweeps, since another tab can't be asked. */ export async function sweepPrivateExports({ force = false }: { force?: boolean } = {}): Promise { + if (force) dropExportFiles("all"); if (!force && !hasWebLocks()) return; try { const root = await navigator.storage?.getDirectory?.(); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index e473ccc0f..34d81f53a 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -1100,7 +1100,7 @@ export async function wipeEverything(): Promise { } /** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */ -function scopeDecided(account: string): boolean { +export function scopeDecided(account: string): boolean { const { loaded, source, partial } = usePolicyStore.getState(); // The public part a page gets before it's signed in has no caps and no scopes in it. return loaded && !partial && (source === 'server' || source === 'cache') && policyDecided(account); diff --git a/lib/store/mail/attachments.ts b/lib/store/mail/attachments.ts index 83a39d859..4b2fbc34d 100644 --- a/lib/store/mail/attachments.ts +++ b/lib/store/mail/attachments.ts @@ -180,6 +180,20 @@ export function rawKept(ctx: Pick, jmap: string, b ).map((row) => String(row.blob_id)); } +/** How large the originals of a folder are, and the part of that kept here: what an export will need room for. */ +export function rawSize(ctx: Pick, jmap: string, mailboxId?: string): { bytes: number; keptBytes: number } { + const inFolder = mailboxId ? 'JOIN email_mailbox m ON m.email_rowid = e.rowid AND m.mailbox_id = ?' : ''; + const row = ctx.db.get<{ bytes: number | null; kept: number | null }>( + `SELECT SUM(p.size) AS bytes, SUM(CASE WHEN b.blob_id IS NULL THEN 0 ELSE p.size END) AS kept + FROM email e ${inFolder} + JOIN email_part p ON p.email_rowid = e.rowid AND p.raw = 1 + LEFT JOIN blob b ON b.jmap = p.jmap AND b.blob_id = p.blob_id + WHERE e.jmap = ?`, + mailboxId ? [mailboxId, jmap] : [jmap], + ); + return { bytes: Number(row?.bytes ?? 0), keptBytes: ctx.scope.attachments ? Number(row?.kept ?? 0) : 0 }; +} + /** 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 7139c395e..0169eceff 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, rawKept, rawStatus, recordKeptBlob } from './attachments'; +import { keptBlob, rawBlob, rawKept, rawSize, rawStatus, recordKeptBlob } from './attachments'; import { storeBody, upsertEmail, type EmailObject } from './records'; /** @@ -59,6 +59,8 @@ export const MAIL_CALLS: Readonly> = { '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), + /** How much room an export of them takes. */ + 'mail.rawSize': (ctx, params: { jmap: string; mailboxId?: string }) => rawSize(ctx, params.jmap, params.mailboxId), 'mail.usage': usage, /** The session kept with the account, for a start from the local copy. */ 'mail.session': (ctx) => ctx.session, diff --git a/locales/ar/common.json b/locales/ar/common.json index dba61c101..fc19bd404 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5848,7 +5848,7 @@ "settings_label": "تصدير البريد المحفوظ", "settings_description": "احفظ بريد هذا الجهاز كملفات mbox أو ‎.eml. يعمل ذلك دون اتصال أيضًا.", "title": "تصدير البريد المحفوظ", - "description": "يحفظ هذا ما يحتفظ به هذا الجهاز كما هو تمامًا. البريد الأقدم من الفترة التي اخترتها غير مشمول. لحفظ كل ما على الخادم، استخدم «تصدير كل البريد».", + "description": "يحفظ هذا النسخ الأصلية التي يحتفظ بها هذا الجهاز. ملفات ‎.eml نسخ مطابقة تمامًا. أما ملف mbox فيضيف العلامات التي يتطلبها تنسيقه ويكتب حالة القراءة والتمييز في ترويسات كل رسالة بدلًا مما كان فيها. البريد الأقدم من الفترة التي اخترتها غير مشمول. لحفظ كل ما على الخادم، استخدم «تصدير كل البريد».", "folders_label": "المجلدات", "all_folders": "كل المجلدات", "folder_kept": "هنا: {kept} من {total}", @@ -5865,7 +5865,16 @@ "retry": "إعادة المحاولة عند الاتصال", "retry_online": "أنت متصل الآن، لذا يمكن جلبها من الخادم.", "retry_offline": "يمكنك المحاولة مرة أخرى عند الاتصال.", - "settings_button": "تصدير…" + "settings_button": "تصدير…", + "message_failed": "لم يسلّم الخادم هذه الرسالة.", + "failed_generic": "توقف التصدير بسبب خطأ. حاول مرة أخرى.", + "stopped_limit": "توقف التصدير عند {limit}، وهو أقصى ما يستطيع هذا المتصفح الاحتفاظ به. اختر مجلدات أقل، أو استخدم Chromium أو Firefox أو تطبيق سطح المكتب.", + "over_limit": "يستطيع هذا المتصفح الاحتفاظ بما لا يزيد عن {limit}، وهذا الاختيار نحو {size}. اختر مجلدات أقل، أو استخدم Chromium أو Firefox أو تطبيق سطح المكتب.", + "no_room": "يحتاج هذا التصدير إلى نحو {size}، والمتاح لهذا المتصفح {free}. اختر مجلدات أقل أو أفرغ مساحة.", + "stopped_no_room": "نفدت مساحة المتصفح أثناء التصدير. اختر مجلدات أقل أو أفرغ مساحة.", + "stopped_offline_off": "توقف التصدير لأن هذا الحساب لم يعد يحتفظ بالبريد على هذا الجهاز.", + "policy_unknown": "قواعد مؤسستك لهذا الحساب غير معروفة بعد، لذا يبقى التصدير متوقفًا حاليًا.", + "leftover_file": "تعذّرت إزالة الملف {name} من المكان الذي حفظته فيه. إنه غير مكتمل، ويمكنك حذفه." }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 1f54e077f..2f47f909c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5848,7 +5848,7 @@ "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».", + "description": "Desa els originals que conserva aquest dispositiu. Els fitxers .eml són còpies exactes. Un fitxer mbox afegeix les marques que requereix el seu format i escriu l'estat de llegit i marcat a les capçaleres de cada missatge, en lloc de les que tingués. 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í", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exporta…", + "message_failed": "El servidor no ha lliurat aquest missatge.", + "failed_generic": "L'exportació s'ha aturat per un error. Torna-ho a provar.", + "stopped_limit": "L'exportació s'ha aturat a {limit}, el màxim que pot contenir aquest navegador. Tria menys carpetes o fes servir Chromium, Firefox o l'aplicació d'escriptori.", + "over_limit": "Aquest navegador pot contenir com a màxim {limit}, i aquesta selecció ocupa uns {size}. Tria menys carpetes o fes servir Chromium, Firefox o l'aplicació d'escriptori.", + "no_room": "Aquesta exportació necessita uns {size}, i aquest navegador en té {free} disponibles. Tria menys carpetes o allibera espai.", + "stopped_no_room": "El navegador s'ha quedat sense espai per a l'exportació. Tria menys carpetes o allibera espai.", + "stopped_offline_off": "L'exportació s'ha aturat perquè aquest compte ja no conserva correu en aquest dispositiu.", + "policy_unknown": "Encara no es coneixen les regles de la teva organització per a aquest compte, així que l'exportació continua desactivada de moment.", + "leftover_file": "No s'ha pogut treure el fitxer {name} d'on l'has desat. Està incomplet, el pots suprimir." }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", diff --git a/locales/cs/common.json b/locales/cs/common.json index a14d93cb8..073ffde4f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5848,7 +5848,7 @@ "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“.", + "description": "Uloží originály, které toto zařízení uchovává. Soubory .eml jsou přesné kopie. Soubor mbox přidá značky, které jeho formát vyžaduje, a zapíše stav přečtení a označení do záhlaví každé zprávy místo těch, která měla. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exportovat…", + "message_failed": "Server tuto zprávu nedodal.", + "failed_generic": "Export se zastavil kvůli chybě. Zkuste to znovu.", + "stopped_limit": "Export se zastavil na {limit}, což je nejvíc, co tento prohlížeč pojme. Vyberte méně složek nebo použijte Chromium, Firefox či desktopovou aplikaci.", + "over_limit": "Tento prohlížeč pojme nejvýše {limit} a tento výběr má asi {size}. Vyberte méně složek nebo použijte Chromium, Firefox či desktopovou aplikaci.", + "no_room": "Tento export potřebuje asi {size} a tento prohlížeč má k dispozici {free}. Vyberte méně složek nebo uvolněte místo.", + "stopped_no_room": "Prohlížeči došlo místo pro export. Vyberte méně složek nebo uvolněte místo.", + "stopped_offline_off": "Export se zastavil, protože tento účet už na tomto zařízení poštu neuchovává.", + "policy_unknown": "Pravidla vaší organizace pro tento účet zatím nejsou známa, proto export zatím zůstává vypnutý.", + "leftover_file": "Soubor {name} se nepodařilo odebrat z místa, kam jste ho uložili. Je neúplný, můžete ho smazat." }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", diff --git a/locales/da/common.json b/locales/da/common.json index 65436fe69..840bc964c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5848,7 +5848,7 @@ "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.", + "description": "Dette gemmer de originaler, enheden opbevarer. .eml-filer er nøjagtige kopier. En mbox-fil tilføjer de markeringer, formatet kræver, og skriver status for læst og markeret i hver beskeds overskrifter i stedet for dem, den havde. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Eksportér…", + "message_failed": "Serveren leverede ikke denne besked.", + "failed_generic": "Eksporten stoppede på grund af en fejl. Prøv igen.", + "stopped_limit": "Eksporten stoppede ved {limit}, det meste denne browser kan rumme. Vælg færre mapper, eller brug Chromium, Firefox eller skrivebordsappen.", + "over_limit": "Denne browser kan højst rumme {limit}, og dette valg fylder cirka {size}. Vælg færre mapper, eller brug Chromium, Firefox eller skrivebordsappen.", + "no_room": "Denne eksport kræver cirka {size}, og denne browser har {free} tilbage. Vælg færre mapper, eller frigør plads.", + "stopped_no_room": "Browseren løb tør for plads til eksporten. Vælg færre mapper, eller frigør plads.", + "stopped_offline_off": "Eksporten stoppede, fordi denne konto ikke længere gemmer post på denne enhed.", + "policy_unknown": "Din organisations regler for denne konto kendes endnu ikke, så eksport er slået fra indtil videre.", + "leftover_file": "Filen {name} kunne ikke fjernes fra det sted, du gemte den. Den er ufuldstændig, så du kan slette den." }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", diff --git a/locales/de/common.json b/locales/de/common.json index 82790cc10..d48f9b35a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5848,7 +5848,7 @@ "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“.", + "description": "Das speichert die Originale, die dieses Gerät aufbewahrt. .eml-Dateien sind exakte Kopien. Eine mbox-Datei ergänzt die Markierungen, die ihr Format braucht, und schreibt den Gelesen- und Markiert-Status in die Kopfzeilen jeder Nachricht, anstelle vorhandener Angaben. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exportieren…", + "message_failed": "Der Server hat diese Nachricht nicht geliefert.", + "failed_generic": "Der Export wurde wegen eines Fehlers beendet. Versuchen Sie es erneut.", + "stopped_limit": "Der Export wurde bei {limit} beendet, mehr kann dieser Browser nicht halten. Wählen Sie weniger Ordner aus, oder nutzen Sie Chromium, Firefox oder die Desktop-App.", + "over_limit": "Dieser Browser kann höchstens {limit} halten, und diese Auswahl hat etwa {size}. Wählen Sie weniger Ordner aus, oder nutzen Sie Chromium, Firefox oder die Desktop-App.", + "no_room": "Dieser Export braucht etwa {size}, und dieser Browser hat dafür noch {free} frei. Wählen Sie weniger Ordner aus, oder schaffen Sie Platz.", + "stopped_no_room": "Dem Browser ging der Platz für den Export aus. Wählen Sie weniger Ordner aus, oder schaffen Sie Platz.", + "stopped_offline_off": "Der Export wurde beendet, weil dieses Konto keine E-Mails mehr auf diesem Gerät aufbewahrt.", + "policy_unknown": "Die Regeln Ihrer Organisation für dieses Konto sind noch nicht bekannt, daher bleibt der Export vorerst aus.", + "leftover_file": "Die Datei {name} konnte nicht vom Speicherort entfernt werden. Sie ist unvollständig, Sie können sie löschen." }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", diff --git a/locales/en/common.json b/locales/en/common.json index 7dfbb4397..57fabc314 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5848,7 +5848,7 @@ "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”.", + "description": "This saves the originals this device keeps. .eml files are exact copies. An mbox file adds the markings its format needs and writes read and flag state into each message's headers, in place of any it had. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Export…", + "message_failed": "The server didn't give this message.", + "failed_generic": "The export stopped because of an error. Try again.", + "stopped_limit": "The export stopped at {limit}, the most this browser can hold. Choose fewer folders, or use Chromium, Firefox or the desktop app.", + "over_limit": "This browser can hold at most {limit}, and this choice is about {size}. Choose fewer folders, or use Chromium, Firefox or the desktop app.", + "no_room": "This export needs about {size}, and this browser has {free} left for it. Choose fewer folders or free up space.", + "stopped_no_room": "The browser ran out of room for the export. Choose fewer folders or free up space.", + "stopped_offline_off": "The export stopped because this account no longer keeps mail on this device.", + "policy_unknown": "Your organisation's rules for this account aren't known yet, so exporting stays off for now.", + "leftover_file": "The file {name} couldn't be removed from where you saved it. It's incomplete, so you can delete it." }, "deep_link": { "message_not_found": "This message is no longer available.", diff --git a/locales/es/common.json b/locales/es/common.json index 9c06e9efb..8190a6363 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5848,7 +5848,7 @@ "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».", + "description": "Guarda los originales que conserva este dispositivo. Los archivos .eml son copias exactas. Un archivo mbox añade las marcas que requiere su formato y escribe el estado de leído y marcado en los encabezados de cada mensaje, en lugar de los que tuviera. 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í", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exportar…", + "message_failed": "El servidor no entregó este mensaje.", + "failed_generic": "La exportación se detuvo por un error. Inténtalo de nuevo.", + "stopped_limit": "La exportación se detuvo en {limit}, lo máximo que puede contener este navegador. Elige menos carpetas o usa Chromium, Firefox o la aplicación de escritorio.", + "over_limit": "Este navegador puede contener como máximo {limit}, y esta selección ocupa unos {size}. Elige menos carpetas o usa Chromium, Firefox o la aplicación de escritorio.", + "no_room": "Esta exportación necesita unos {size}, y este navegador tiene {free} disponibles. Elige menos carpetas o libera espacio.", + "stopped_no_room": "El navegador se quedó sin espacio para la exportación. Elige menos carpetas o libera espacio.", + "stopped_offline_off": "La exportación se detuvo porque esta cuenta ya no guarda correo en este dispositivo.", + "policy_unknown": "Aún no se conocen las reglas de tu organización para esta cuenta, así que la exportación sigue desactivada por ahora.", + "leftover_file": "No se pudo quitar el archivo {name} de donde lo guardaste. Está incompleto, puedes borrarlo." }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", diff --git a/locales/et/common.json b/locales/et/common.json index 6032131c7..2c5549d3e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5848,7 +5848,7 @@ "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”.", + "description": "Salvestab originaalid, mida see seade hoiab. .eml-failid on täpsed koopiad. mbox-fail lisab vormingu nõutud märgid ja kirjutab loetud ja märgistatud oleku iga kirja päistesse senise asemele. 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}", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Ekspordi…", + "message_failed": "Server ei andnud seda kirja.", + "failed_generic": "Eksport peatus vea tõttu. Proovige uuesti.", + "stopped_limit": "Eksport peatus piiril {limit}, rohkem see brauser ei mahuta. Valige vähem kaustu või kasutage Chromiumi, Firefoxi või töölauarakendust.", + "over_limit": "See brauser mahutab kuni {limit}, see valik on umbes {size}. Valige vähem kaustu või kasutage Chromiumi, Firefoxi või töölauarakendust.", + "no_room": "See eksport vajab umbes {size}, brauseril on selleks {free}. Valige vähem kaustu või vabastage ruumi.", + "stopped_no_room": "Brauseril sai ekspordi jaoks ruum otsa. Valige vähem kaustu või vabastage ruumi.", + "stopped_offline_off": "Eksport peatus, sest see konto ei hoia enam selles seadmes kirju.", + "policy_unknown": "Teie organisatsiooni reeglid selle konto kohta pole veel teada, seega on eksport praegu väljas.", + "leftover_file": "Faili {name} ei saanud salvestuskohast eemaldada. See on poolik, võite selle kustutada." }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", diff --git a/locales/fa/common.json b/locales/fa/common.json index c5d189e36..46ee8b82d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5848,7 +5848,7 @@ "settings_label": "برون‌بری نامه‌های نگه‌داشته‌شده", "settings_description": "نامه‌های این دستگاه را به‌صورت فایل mbox یا ‎.eml ذخیره کنید. بدون اتصال هم کار می‌کند.", "title": "برون‌بری نامه‌های نگه‌داشته‌شده", - "description": "آنچه این دستگاه نگه داشته است را عیناً ذخیره می‌کند. نامه‌های قدیمی‌تر از بازه‌ای که انتخاب کرده‌اید در آن نیستند. برای ذخیرهٔ همه‌چیز از سرور، از «برون‌بری همه نامه‌ها» استفاده کنید.", + "description": "نسخه‌های اصلی که این دستگاه نگه داشته است را ذخیره می‌کند. فایل‌های ‎.eml کپی دقیق هستند. فایل mbox نشانه‌هایی را که قالبش لازم دارد اضافه می‌کند و وضعیت خوانده‌شده و پرچم‌دار را به جای مقادیر قبلی در سرآیندهای هر پیام می‌نویسد. نامه‌های قدیمی‌تر از بازه‌ای که انتخاب کرده‌اید در آن نیستند. برای ذخیرهٔ همه‌چیز از سرور، از «برون‌بری همه نامه‌ها» استفاده کنید.", "folders_label": "پوشه‌ها", "all_folders": "همهٔ پوشه‌ها", "folder_kept": "اینجا: {kept} از {total}", @@ -5865,7 +5865,16 @@ "retry": "تلاش دوباره به‌صورت آنلاین", "retry_online": "اکنون آنلاین هستید، پس می‌توانند از سرور بیایند.", "retry_offline": "وقتی آنلاین شدید دوباره امتحان کنید.", - "settings_button": "برون‌بری…" + "settings_button": "برون‌بری…", + "message_failed": "سرور این پیام را تحویل نداد.", + "failed_generic": "برون‌بری به دلیل خطا متوقف شد. دوباره امتحان کنید.", + "stopped_limit": "برون‌بری در {limit} متوقف شد، بیشترین مقداری که این مرورگر می‌تواند نگه دارد. پوشه‌های کمتری انتخاب کنید یا از Chromium، Firefox یا برنامهٔ رومیزی استفاده کنید.", + "over_limit": "این مرورگر حداکثر {limit} را نگه می‌دارد و این انتخاب حدود {size} است. پوشه‌های کمتری انتخاب کنید یا از Chromium، Firefox یا برنامهٔ رومیزی استفاده کنید.", + "no_room": "این برون‌بری حدود {size} نیاز دارد و این مرورگر {free} جا دارد. پوشه‌های کمتری انتخاب کنید یا فضا آزاد کنید.", + "stopped_no_room": "جای مرورگر برای برون‌بری تمام شد. پوشه‌های کمتری انتخاب کنید یا فضا آزاد کنید.", + "stopped_offline_off": "برون‌بری متوقف شد چون این حساب دیگر نامه‌ای روی این دستگاه نگه نمی‌دارد.", + "policy_unknown": "قوانین سازمان شما برای این حساب هنوز مشخص نیست، پس برون‌بری فعلاً خاموش می‌ماند.", + "leftover_file": "فایل {name} از جایی که ذخیره کرده‌اید حذف نشد. ناقص است و می‌توانید پاکش کنید." }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", diff --git a/locales/fr/common.json b/locales/fr/common.json index d9f3cb91d..4ce78f8f3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5848,7 +5848,7 @@ "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 ».", + "description": "Enregistre les originaux que cet appareil conserve. Les fichiers .eml sont des copies exactes. Un fichier mbox ajoute les marques que son format exige et écrit l'état lu et suivi dans les en-têtes de chaque message, à la place de ceux qu'il avait. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exporter…", + "message_failed": "Le serveur n'a pas fourni ce message.", + "failed_generic": "L'exportation s'est arrêtée à cause d'une erreur. Réessayez.", + "stopped_limit": "L'exportation s'est arrêtée à {limit}, le maximum que ce navigateur peut contenir. Choisissez moins de dossiers, ou utilisez Chromium, Firefox ou l'application de bureau.", + "over_limit": "Ce navigateur peut contenir au plus {limit}, et ce choix fait environ {size}. Choisissez moins de dossiers, ou utilisez Chromium, Firefox ou l'application de bureau.", + "no_room": "Cette exportation demande environ {size}, et ce navigateur dispose de {free}. Choisissez moins de dossiers ou libérez de l'espace.", + "stopped_no_room": "Le navigateur n'a plus de place pour l'exportation. Choisissez moins de dossiers ou libérez de l'espace.", + "stopped_offline_off": "L'exportation s'est arrêtée car ce compte ne conserve plus de courrier sur cet appareil.", + "policy_unknown": "Les règles de votre organisation pour ce compte ne sont pas encore connues, l'exportation reste donc désactivée pour l'instant.", + "leftover_file": "Le fichier {name} n'a pas pu être retiré de l'endroit où vous l'avez enregistré. Il est incomplet, vous pouvez le supprimer." }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", diff --git a/locales/he/common.json b/locales/he/common.json index 0efa0b618..dbe4f90c7 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5849,7 +5849,7 @@ "settings_label": "ייצוא דואר שנשמר", "settings_description": "שמרו את הדואר שבמכשיר הזה כקובצי mbox או ‎.eml. זה עובד גם בלי חיבור.", "title": "ייצוא דואר שנשמר", - "description": "שומר את מה שהמכשיר הזה מחזיק, בית אחר בית. דואר ישן יותר מהתקופה שבחרתם לא נכלל. כדי לשמור הכול מהשרת, השתמשו ב„ייצוא כל הדואר”.", + "description": "שומר את המקוריים שהמכשיר הזה מחזיק. קובצי ‎.eml הם העתקים מדויקים. קובץ mbox מוסיף את הסימונים שהפורמט שלו דורש וכותב את מצב הקריאה והסימון בכותרות של כל הודעה, במקום מה שהיה בהן. דואר ישן יותר מהתקופה שבחרתם לא נכלל. כדי לשמור הכול מהשרת, השתמשו ב„ייצוא כל הדואר”.", "folders_label": "תיקיות", "all_folders": "כל התיקיות", "folder_kept": "כאן: {kept} מתוך {total}", @@ -5866,7 +5866,16 @@ "retry": "לנסות שוב עם חיבור", "retry_online": "יש חיבור עכשיו, אז הן יכולות להגיע מהשרת.", "retry_offline": "אפשר לנסות שוב כשיהיה חיבור.", - "settings_button": "ייצוא…" + "settings_button": "ייצוא…", + "message_failed": "השרת לא מסר את ההודעה הזו.", + "failed_generic": "הייצוא נעצר בגלל שגיאה. נסו שוב.", + "stopped_limit": "הייצוא נעצר ב־{limit}, המקסימום שהדפדפן הזה יכול להחזיק. בחרו פחות תיקיות, או השתמשו ב־Chromium, ב־Firefox או באפליקציה למחשב.", + "over_limit": "הדפדפן הזה יכול להחזיק עד {limit}, והבחירה הזו היא בערך {size}. בחרו פחות תיקיות, או השתמשו ב־Chromium, ב־Firefox או באפליקציה למחשב.", + "no_room": "הייצוא הזה צריך בערך {size}, ולדפדפן הזה נשארו {free}. בחרו פחות תיקיות או פנו מקום.", + "stopped_no_room": "לדפדפן נגמר המקום לייצוא. בחרו פחות תיקיות או פנו מקום.", + "stopped_offline_off": "הייצוא נעצר כי החשבון הזה כבר לא שומר דואר במכשיר הזה.", + "policy_unknown": "הכללים של הארגון שלכם לחשבון הזה עדיין לא ידועים, ולכן הייצוא כבוי בינתיים.", + "leftover_file": "לא ניתן היה להסיר את הקובץ {name} מהמקום שבו שמרתם אותו. הוא חלקי, ואפשר למחוק אותו." }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index e933fd336..720ef638f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5848,7 +5848,7 @@ "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”.", + "description": "Elmenti az eszközön megőrzött eredeti üzeneteket. Az .eml fájlok pontos másolatok. Az mbox fájl hozzáadja a formátumához szükséges jelöléseket, és az olvasott és megjelölt állapotot minden üzenet fejlécébe írja a korábbiak helyett. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exportálás…", + "message_failed": "A szerver nem adta át ezt az üzenetet.", + "failed_generic": "Az exportálás hiba miatt leállt. Próbálja újra.", + "stopped_limit": "Az exportálás {limit} méretnél leállt, ennél többet ez a böngésző nem tud tárolni. Válasszon kevesebb mappát, vagy használja a Chromiumot, a Firefoxot vagy az asztali alkalmazást.", + "over_limit": "Ez a böngésző legfeljebb {limit} méretet tud tárolni, ez a választás pedig körülbelül {size}. Válasszon kevesebb mappát, vagy használja a Chromiumot, a Firefoxot vagy az asztali alkalmazást.", + "no_room": "Ehhez az exportáláshoz körülbelül {size} kell, a böngészőnek pedig {free} áll rendelkezésére. Válasszon kevesebb mappát, vagy szabadítson fel helyet.", + "stopped_no_room": "A böngészőnek elfogyott a helye az exportáláshoz. Válasszon kevesebb mappát, vagy szabadítson fel helyet.", + "stopped_offline_off": "Az exportálás leállt, mert ez a fiók már nem őriz meg leveleket ezen az eszközön.", + "policy_unknown": "A szervezet erre a fiókra vonatkozó szabályai még nem ismertek, ezért az exportálás egyelőre ki van kapcsolva.", + "leftover_file": "A(z) {name} fájlt nem sikerült eltávolítani a mentés helyéről. Hiányos, ezért törölheti." }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", diff --git a/locales/it/common.json b/locales/it/common.json index e301258ed..6a5968158 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5848,7 +5848,7 @@ "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».", + "description": "Salva gli originali che questo dispositivo conserva. I file .eml sono copie esatte. Un file mbox aggiunge i segni richiesti dal suo formato e scrive lo stato di letto e contrassegnato nelle intestazioni di ogni messaggio, al posto di quelle presenti. 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", @@ -5865,7 +5865,16 @@ "retry": "Riprova online", "retry_online": "Ora sei online, quindi possono arrivare dal server.", "retry_offline": "Puoi riprovare quando sei online.", - "settings_button": "Esporta…" + "settings_button": "Esporta…", + "message_failed": "Il server non ha fornito questo messaggio.", + "failed_generic": "L'esportazione si è interrotta per un errore. Riprova.", + "stopped_limit": "L'esportazione si è interrotta a {limit}, il massimo che questo browser può contenere. Scegli meno cartelle, oppure usa Chromium, Firefox o l'app desktop.", + "over_limit": "Questo browser può contenere al massimo {limit}, e questa scelta occupa circa {size}. Scegli meno cartelle, oppure usa Chromium, Firefox o l'app desktop.", + "no_room": "Questa esportazione richiede circa {size}, e questo browser ha {free} disponibili. Scegli meno cartelle o libera spazio.", + "stopped_no_room": "Il browser ha esaurito lo spazio per l'esportazione. Scegli meno cartelle o libera spazio.", + "stopped_offline_off": "L'esportazione si è interrotta perché questo account non conserva più posta su questo dispositivo.", + "policy_unknown": "Le regole della tua organizzazione per questo account non sono ancora note, quindi l'esportazione resta disattivata per ora.", + "leftover_file": "Impossibile rimuovere il file {name} dal punto in cui l'hai salvato. È incompleto, puoi eliminarlo." }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 53240591a..00716f791 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5848,7 +5848,7 @@ "settings_label": "保存済みメールをエクスポート", "settings_description": "このデバイスのメールを mbox または .eml ファイルとして保存します。オフラインでも使えます。", "title": "保存済みメールをエクスポート", - "description": "このデバイスに保存されているものをそのまま書き出します。選んだ期間より古いメールは含まれません。サーバーのすべてを保存するには「すべてのメールをエクスポート」を使ってください。", + "description": "このデバイスに保存されている原本を書き出します。.eml ファイルは完全なコピーです。mbox ファイルでは、形式に必要な印が加わり、既読とフラグの状態が各メッセージのヘッダーに、元の値に代えて書き込まれます。選んだ期間より古いメールは含まれません。サーバーのすべてを保存するには「すべてのメールをエクスポート」を使ってください。", "folders_label": "フォルダー", "all_folders": "すべてのフォルダー", "folder_kept": "ここに {kept} / {total}", @@ -5865,7 +5865,16 @@ "retry": "オンラインでもう一度試す", "retry_online": "オンラインなので、サーバーから取得できます。", "retry_offline": "オンラインになったらもう一度お試しください。", - "settings_button": "エクスポート…" + "settings_button": "エクスポート…", + "message_failed": "サーバーからこのメッセージを取得できませんでした。", + "failed_generic": "エラーのためエクスポートが止まりました。もう一度お試しください。", + "stopped_limit": "このブラウザーが保持できる上限の {limit} でエクスポートが止まりました。フォルダーを減らすか、Chromium、Firefox、またはデスクトップアプリをお使いください。", + "over_limit": "このブラウザーが保持できるのは最大 {limit} で、この選択は約 {size} です。フォルダーを減らすか、Chromium、Firefox、またはデスクトップアプリをお使いください。", + "no_room": "このエクスポートには約 {size} 必要ですが、このブラウザーの空きは {free} です。フォルダーを減らすか、空き容量を増やしてください。", + "stopped_no_room": "ブラウザーの空き容量が足りなくなりました。フォルダーを減らすか、空き容量を増やしてください。", + "stopped_offline_off": "このアカウントがこのデバイスにメールを保存しなくなったため、エクスポートが止まりました。", + "policy_unknown": "このアカウントに対する組織のルールがまだ分からないため、エクスポートは今のところオフです。", + "leftover_file": "保存先からファイル {name} を削除できませんでした。不完全なファイルなので削除してかまいません。" }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 057bff4cb..3cc18e4ad 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5848,7 +5848,7 @@ "settings_label": "보관된 메일 내보내기", "settings_description": "이 기기의 메일을 mbox 또는 .eml 파일로 저장합니다. 오프라인에서도 됩니다.", "title": "보관된 메일 내보내기", - "description": "이 기기에 보관된 내용을 그대로 저장합니다. 선택한 기간보다 오래된 메일은 포함되지 않습니다. 서버의 모든 메일을 저장하려면 “모든 메일 내보내기”을(를) 사용하세요.", + "description": "이 기기에 보관된 원본을 저장합니다. .eml 파일은 정확한 사본입니다. mbox 파일은 형식에 필요한 표시를 추가하고, 읽음 및 플래그 상태를 각 메시지의 헤더에 기존 값 대신 기록합니다. 선택한 기간보다 오래된 메일은 포함되지 않습니다. 서버의 모든 메일을 저장하려면 “모든 메일 내보내기”을(를) 사용하세요.", "folders_label": "폴더", "all_folders": "모든 폴더", "folder_kept": "여기에 {kept}/{total}", @@ -5865,7 +5865,16 @@ "retry": "온라인에서 다시 시도", "retry_online": "지금 온라인이므로 서버에서 가져올 수 있습니다.", "retry_offline": "온라인이 되면 다시 시도할 수 있습니다.", - "settings_button": "내보내기…" + "settings_button": "내보내기…", + "message_failed": "서버가 이 메시지를 주지 않았습니다.", + "failed_generic": "오류로 내보내기가 중단되었습니다. 다시 시도하세요.", + "stopped_limit": "이 브라우저가 담을 수 있는 최대치인 {limit}에서 내보내기가 중단되었습니다. 폴더를 줄이거나 Chromium, Firefox 또는 데스크톱 앱을 사용하세요.", + "over_limit": "이 브라우저는 최대 {limit}까지 담을 수 있는데 이 선택은 약 {size}입니다. 폴더를 줄이거나 Chromium, Firefox 또는 데스크톱 앱을 사용하세요.", + "no_room": "이 내보내기에는 약 {size}이(가) 필요한데 이 브라우저에는 {free} 남아 있습니다. 폴더를 줄이거나 공간을 확보하세요.", + "stopped_no_room": "브라우저에 내보내기 공간이 부족해졌습니다. 폴더를 줄이거나 공간을 확보하세요.", + "stopped_offline_off": "이 계정이 더 이상 이 기기에 메일을 보관하지 않아 내보내기가 중단되었습니다.", + "policy_unknown": "이 계정에 대한 조직의 규칙을 아직 알 수 없어 내보내기가 당분간 꺼져 있습니다.", + "leftover_file": "저장한 위치에서 {name} 파일을 제거하지 못했습니다. 불완전한 파일이므로 삭제해도 됩니다." }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 25a58a069..30b781c9e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5848,7 +5848,7 @@ "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”.", + "description": "Saglabā oriģinālus, ko glabā šī ierīce. .eml faili ir precīzas kopijas. mbox fails pievieno formātam vajadzīgās atzīmes un ieraksta izlasīts un atzīmēts statusu katra ziņojuma galvenēs iepriekšējo vietā. 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}", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Eksportēt…", + "message_failed": "Serveris nedeva šo ziņojumu.", + "failed_generic": "Eksports apstājās kļūdas dēļ. Mēģiniet vēlreiz.", + "stopped_limit": "Eksports apstājās pie {limit}, vairāk šī pārlūkprogramma nevar turēt. Izvēlieties mazāk mapju vai izmantojiet Chromium, Firefox vai datora lietotni.", + "over_limit": "Šī pārlūkprogramma var turēt ne vairāk kā {limit}, un šī izvēle ir aptuveni {size}. Izvēlieties mazāk mapju vai izmantojiet Chromium, Firefox vai datora lietotni.", + "no_room": "Šim eksportam vajag aptuveni {size}, un šai pārlūkprogrammai ir pieejami {free}. Izvēlieties mazāk mapju vai atbrīvojiet vietu.", + "stopped_no_room": "Pārlūkprogrammai eksportam pietrūka vietas. Izvēlieties mazāk mapju vai atbrīvojiet vietu.", + "stopped_offline_off": "Eksports apstājās, jo šis konts vairs neglabā pastu šajā ierīcē.", + "policy_unknown": "Jūsu organizācijas noteikumi šim kontam vēl nav zināmi, tāpēc eksports pagaidām ir izslēgts.", + "leftover_file": "Failu {name} neizdevās noņemt no saglabāšanas vietas. Tas ir nepilnīgs, varat to izdzēst." }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 1793a5a5f..124673d2a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5848,7 +5848,7 @@ "settings_label": "Хадгалсан захидлыг экспортлох", "settings_description": "Энэ төхөөрөмжийн захидлыг mbox эсвэл .eml файлаар хадгална. Офлайн үед ч ажиллана.", "title": "Хадгалсан захидлыг экспортлох", - "description": "Энэ төхөөрөмжид хадгалагдсан зүйлийг яг байгаагаар нь хадгална. Сонгосон хугацаанаас хуучин захидал орохгүй. Серверт байгаа бүгдийг хадгалахын тулд “Бүх шуудан экспортлох”-г ашиглана уу.", + "description": "Энэ төхөөрөмжид хадгалсан эх хувиудыг хадгална. .eml файлууд яг хуулбар байна. mbox файл нь форматад шаардлагатай тэмдгүүдийг нэмж, уншсан болон тэмдэглэсэн төлөвийг захидал бүрийн толгойд өмнөхийнх нь оронд бичнэ. Сонгосон хугацаанаас хуучин захидал орохгүй. Серверт байгаа бүгдийг хадгалахын тулд “Бүх шуудан экспортлох”-г ашиглана уу.", "folders_label": "Хавтаснууд", "all_folders": "Бүх хавтас", "folder_kept": "Энд: {total}-с {kept}", @@ -5865,7 +5865,16 @@ "retry": "Онлайнаар дахин оролдох", "retry_online": "Та одоо онлайн байгаа тул серверээс авч болно.", "retry_offline": "Онлайн болсны дараа дахин оролдоно уу.", - "settings_button": "Экспортлох…" + "settings_button": "Экспортлох…", + "message_failed": "Сервер энэ захидлыг өгсөнгүй.", + "failed_generic": "Алдааны улмаас экспорт зогслоо. Дахин оролдоно уу.", + "stopped_limit": "Экспорт {limit} дээр зогслоо, энэ хөтчийн багтаах дээд хэмжээ. Цөөн хавтас сонгох, эсвэл Chromium, Firefox, ширээний аппыг ашиглана уу.", + "over_limit": "Энэ хөтөч дээд тал нь {limit} багтаана, энэ сонголт ойролцоогоор {size}. Цөөн хавтас сонгох, эсвэл Chromium, Firefox, ширээний аппыг ашиглана уу.", + "no_room": "Энэ экспортод ойролцоогоор {size} хэрэгтэй, энэ хөтөчид {free} үлдсэн. Цөөн хавтас сонгох эсвэл зай чөлөөлнө үү.", + "stopped_no_room": "Хөтчид экспортын зай дууслаа. Цөөн хавтас сонгох эсвэл зай чөлөөлнө үү.", + "stopped_offline_off": "Энэ бүртгэл энэ төхөөрөмжид захидал хадгалахаа больсон тул экспорт зогслоо.", + "policy_unknown": "Танай байгууллагын энэ бүртгэлд хамаарах дүрэм одоогоор тодорхойгүй тул экспорт түр унтраалттай байна.", + "leftover_file": "{name} файлыг хадгалсан газраас нь устгаж чадсангүй. Энэ нь бүрэн бус тул та устгаж болно." }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", diff --git a/locales/nb/common.json b/locales/nb/common.json index d2d4497da..ede91e3fd 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5848,7 +5848,7 @@ "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.", + "description": "Dette lagrer originalene denne enheten beholder. .eml-filer er nøyaktige kopier. En mbox-fil legger til merkene formatet trenger, og skriver status for lest og flagget i hver meldings overskrifter, i stedet for dem den hadde. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Eksporter…", + "message_failed": "Serveren leverte ikke denne meldingen.", + "failed_generic": "Eksporten stoppet på grunn av en feil. Prøv igjen.", + "stopped_limit": "Eksporten stoppet ved {limit}, det meste denne nettleseren kan holde. Velg færre mapper, eller bruk Chromium, Firefox eller skrivebordsappen.", + "over_limit": "Denne nettleseren kan holde høyst {limit}, og dette valget er omtrent {size}. Velg færre mapper, eller bruk Chromium, Firefox eller skrivebordsappen.", + "no_room": "Denne eksporten trenger omtrent {size}, og denne nettleseren har {free} igjen. Velg færre mapper, eller frigjør plass.", + "stopped_no_room": "Nettleseren gikk tom for plass til eksporten. Velg færre mapper, eller frigjør plass.", + "stopped_offline_off": "Eksporten stoppet fordi denne kontoen ikke lenger beholder e-post på denne enheten.", + "policy_unknown": "Organisasjonens regler for denne kontoen er ikke kjent ennå, så eksport er av inntil videre.", + "leftover_file": "Filen {name} kunne ikke fjernes fra stedet du lagret den. Den er ufullstendig, så du kan slette den." }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", diff --git a/locales/nl/common.json b/locales/nl/common.json index f0c96dd0d..c4640fcef 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5848,7 +5848,7 @@ "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.", + "description": "Dit slaat de originelen op die dit apparaat bewaart. .eml-bestanden zijn exacte kopieën. Een mbox-bestand voegt de markeringen toe die het formaat nodig heeft en schrijft de status gelezen en gemarkeerd in de kopregels van elk bericht, in plaats van wat daar stond. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exporteren…", + "message_failed": "De server heeft dit bericht niet geleverd.", + "failed_generic": "De export is gestopt door een fout. Probeer het opnieuw.", + "stopped_limit": "De export is gestopt bij {limit}, het maximum dat deze browser kan bevatten. Kies minder mappen, of gebruik Chromium, Firefox of de desktop-app.", + "over_limit": "Deze browser kan maximaal {limit} bevatten, en deze keuze is ongeveer {size}. Kies minder mappen, of gebruik Chromium, Firefox of de desktop-app.", + "no_room": "Deze export heeft ongeveer {size} nodig, en deze browser heeft nog {free} beschikbaar. Kies minder mappen of maak ruimte vrij.", + "stopped_no_room": "De browser had geen ruimte meer voor de export. Kies minder mappen of maak ruimte vrij.", + "stopped_offline_off": "De export is gestopt omdat dit account geen e-mail meer op dit apparaat bewaart.", + "policy_unknown": "De regels van uw organisatie voor dit account zijn nog niet bekend, dus exporteren blijft voorlopig uit.", + "leftover_file": "Het bestand {name} kon niet worden verwijderd van de plek waar u het hebt opgeslagen. Het is onvolledig, u kunt het verwijderen." }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 54a373e0c..bafb67580 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5848,7 +5848,7 @@ "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ę”.", + "description": "Zapisuje oryginały przechowywane na tym urządzeniu. Pliki .eml są dokładnymi kopiami. Plik mbox dodaje oznaczenia wymagane przez ten format i zapisuje stan przeczytania i oflagowania w nagłówkach każdej wiadomości, zamiast dotychczasowych. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Eksportuj…", + "message_failed": "Serwer nie dostarczył tej wiadomości.", + "failed_generic": "Eksport zatrzymał się z powodu błędu. Spróbuj ponownie.", + "stopped_limit": "Eksport zatrzymał się przy {limit}, to najwięcej, ile ta przeglądarka może pomieścić. Wybierz mniej folderów albo użyj Chromium, Firefoksa lub aplikacji na komputer.", + "over_limit": "Ta przeglądarka może pomieścić najwyżej {limit}, a ten wybór to około {size}. Wybierz mniej folderów albo użyj Chromium, Firefoksa lub aplikacji na komputer.", + "no_room": "Ten eksport wymaga około {size}, a ta przeglądarka ma do dyspozycji {free}. Wybierz mniej folderów lub zwolnij miejsce.", + "stopped_no_room": "W przeglądarce zabrakło miejsca na eksport. Wybierz mniej folderów lub zwolnij miejsce.", + "stopped_offline_off": "Eksport zatrzymał się, bo to konto nie przechowuje już poczty na tym urządzeniu.", + "policy_unknown": "Zasady Twojej organizacji dla tego konta nie są jeszcze znane, więc eksport pozostaje na razie wyłączony.", + "leftover_file": "Nie udało się usunąć pliku {name} z miejsca zapisu. Jest niekompletny, możesz go usunąć." }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", diff --git a/locales/pt/common.json b/locales/pt/common.json index e3f369efa..42e8c08c7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5848,7 +5848,7 @@ "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”.", + "description": "Guarda os originais que este dispositivo conserva. Os ficheiros .eml são cópias exatas. Um ficheiro mbox acrescenta as marcas que o seu formato exige e escreve o estado de lido e assinalado nos cabeçalhos de cada mensagem, em vez dos que tinha. 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", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Exportar…", + "message_failed": "O servidor não entregou esta mensagem.", + "failed_generic": "A exportação parou devido a um erro. Tente de novo.", + "stopped_limit": "A exportação parou em {limit}, o máximo que este navegador consegue conter. Escolha menos pastas ou use o Chromium, o Firefox ou a aplicação de ambiente de trabalho.", + "over_limit": "Este navegador consegue conter no máximo {limit}, e esta escolha tem cerca de {size}. Escolha menos pastas ou use o Chromium, o Firefox ou a aplicação de ambiente de trabalho.", + "no_room": "Esta exportação precisa de cerca de {size}, e este navegador tem {free} disponíveis. Escolha menos pastas ou liberte espaço.", + "stopped_no_room": "O navegador ficou sem espaço para a exportação. Escolha menos pastas ou liberte espaço.", + "stopped_offline_off": "A exportação parou porque esta conta já não guarda correio neste dispositivo.", + "policy_unknown": "As regras da sua organização para esta conta ainda não são conhecidas, por isso a exportação fica desativada por agora.", + "leftover_file": "Não foi possível remover o ficheiro {name} do local onde o guardou. Está incompleto, pode apagá-lo." }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 409029675..69a9d6a83 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5848,7 +5848,7 @@ "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”.", + "description": "Salvează originalele păstrate pe acest dispozitiv. Fișierele .eml sunt copii exacte. Un fișier mbox adaugă marcajele cerute de format și scrie starea de citit și marcat în antetele fiecărui mesaj, în locul celor existente. 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", @@ -5865,7 +5865,16 @@ "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ă…" + "settings_button": "Exportă…", + "message_failed": "Serverul nu a livrat acest mesaj.", + "failed_generic": "Exportul s-a oprit din cauza unei erori. Încercați din nou.", + "stopped_limit": "Exportul s-a oprit la {limit}, maximul pe care îl poate păstra acest browser. Alegeți mai puține dosare sau folosiți Chromium, Firefox ori aplicația desktop.", + "over_limit": "Acest browser poate păstra cel mult {limit}, iar această alegere are aproximativ {size}. Alegeți mai puține dosare sau folosiți Chromium, Firefox ori aplicația desktop.", + "no_room": "Acest export are nevoie de aproximativ {size}, iar acest browser mai are {free}. Alegeți mai puține dosare sau eliberați spațiu.", + "stopped_no_room": "Browserul a rămas fără spațiu pentru export. Alegeți mai puține dosare sau eliberați spațiu.", + "stopped_offline_off": "Exportul s-a oprit deoarece acest cont nu mai păstrează mesaje pe acest dispozitiv.", + "policy_unknown": "Regulile organizației dvs. pentru acest cont nu se cunosc încă, așa că exportul rămâne oprit deocamdată.", + "leftover_file": "Fișierul {name} nu a putut fi eliminat din locul unde l-ați salvat. Este incomplet, îl puteți șterge." }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 78f5cfd37..2f688113a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5848,7 +5848,7 @@ "settings_label": "Экспорт сохранённой почты", "settings_description": "Сохраните почту с этого устройства в файлы mbox или .eml. Работает и без сети.", "title": "Экспорт сохранённой почты", - "description": "Сохраняет то, что хранится на этом устройстве, байт в байт. Почты старше выбранного периода здесь нет. Чтобы сохранить всё с сервера, используйте «Экспортировать всю почту».", + "description": "Сохраняет оригиналы, которые хранятся на этом устройстве. Файлы .eml — точные копии. Файл mbox добавляет отметки, которых требует его формат, и записывает состояние «прочитано» и «помечено» в заголовки каждого сообщения вместо прежних. Почты старше выбранного периода здесь нет. Чтобы сохранить всё с сервера, используйте «Экспортировать всю почту».", "folders_label": "Папки", "all_folders": "Все папки", "folder_kept": "Здесь: {kept} из {total}", @@ -5865,7 +5865,16 @@ "retry": "Повторить в сети", "retry_online": "Сеть есть, поэтому их можно получить с сервера.", "retry_offline": "Повторите попытку, когда появится сеть.", - "settings_button": "Экспортировать…" + "settings_button": "Экспортировать…", + "message_failed": "Сервер не выдал это сообщение.", + "failed_generic": "Экспорт остановлен из-за ошибки. Повторите попытку.", + "stopped_limit": "Экспорт остановлен на {limit}: больше этот браузер удержать не может. Выберите меньше папок или используйте Chromium, Firefox либо приложение для компьютера.", + "over_limit": "Этот браузер может удержать не больше {limit}, а выбрано примерно {size}. Выберите меньше папок или используйте Chromium, Firefox либо приложение для компьютера.", + "no_room": "Для этого экспорта нужно примерно {size}, а этому браузеру доступно {free}. Выберите меньше папок или освободите место.", + "stopped_no_room": "Браузеру не хватило места для экспорта. Выберите меньше папок или освободите место.", + "stopped_offline_off": "Экспорт остановлен: эта учётная запись больше не хранит почту на этом устройстве.", + "policy_unknown": "Правила вашей организации для этой учётной записи пока неизвестны, поэтому экспорт пока выключен.", + "leftover_file": "Не удалось удалить файл {name} из места сохранения. Он неполный, его можно удалить." }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", diff --git a/locales/sk/common.json b/locales/sk/common.json index bc15cd35c..1a83cb9f2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5848,7 +5848,7 @@ "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“.", + "description": "Uloží originály, ktoré toto zariadenie uchováva. Súbory .eml sú presné kópie. Súbor mbox pridá značky, ktoré jeho formát vyžaduje, a zapíše stav prečítania a označenia do hlavičiek každej správy namiesto tých, ktoré mala. 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", @@ -5865,7 +5865,16 @@ "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ť…" + "settings_button": "Exportovať…", + "message_failed": "Server túto správu nedodal.", + "failed_generic": "Export sa zastavil pre chybu. Skúste to znova.", + "stopped_limit": "Export sa zastavil na {limit}, čo je najviac, čo tento prehliadač pojme. Vyberte menej priečinkov alebo použite Chromium, Firefox či desktopovú aplikáciu.", + "over_limit": "Tento prehliadač pojme najviac {limit} a tento výber má asi {size}. Vyberte menej priečinkov alebo použite Chromium, Firefox či desktopovú aplikáciu.", + "no_room": "Tento export potrebuje asi {size} a tento prehliadač má k dispozícii {free}. Vyberte menej priečinkov alebo uvoľnite miesto.", + "stopped_no_room": "Prehliadaču došlo miesto na export. Vyberte menej priečinkov alebo uvoľnite miesto.", + "stopped_offline_off": "Export sa zastavil, pretože tento účet už na tomto zariadení poštu neuchováva.", + "policy_unknown": "Pravidlá vašej organizácie pre tento účet zatiaľ nie sú známe, preto export zatiaľ zostáva vypnutý.", + "leftover_file": "Súbor {name} sa nepodarilo odstrániť z miesta, kam ste ho uložili. Je neúplný, môžete ho vymazať." }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", diff --git a/locales/th/common.json b/locales/th/common.json index 5bf4c56af..f854f28f4 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2193,7 +2193,7 @@ "settings_label": "ส่งออกอีเมลที่เก็บไว้", "settings_description": "บันทึกอีเมลในอุปกรณ์นี้เป็นไฟล์ mbox หรือ .eml ใช้งานแบบออฟไลน์ได้", "title": "ส่งออกอีเมลที่เก็บไว้", - "description": "บันทึกสิ่งที่อุปกรณ์นี้เก็บไว้ตามต้นฉบับทุกไบต์ อีเมลที่เก่ากว่าช่วงเวลาที่คุณเลือกจะไม่รวมอยู่ด้วย หากต้องการบันทึกทุกอย่างจากเซิร์ฟเวอร์ ให้ใช้ “ส่งออกอีเมลทั้งหมด”", + "description": "บันทึกต้นฉบับที่อุปกรณ์นี้เก็บไว้ ไฟล์ .eml เป็นสำเนาที่ตรงทุกประการ ส่วนไฟล์ mbox จะเพิ่มเครื่องหมายที่รูปแบบไฟล์ต้องการ และเขียนสถานะอ่านแล้วและติดธงลงในส่วนหัวของแต่ละข้อความแทนค่าเดิม อีเมลที่เก่ากว่าช่วงเวลาที่คุณเลือกจะไม่รวมอยู่ด้วย หากต้องการบันทึกทุกอย่างจากเซิร์ฟเวอร์ ให้ใช้ “ส่งออกอีเมลทั้งหมด”", "folders_label": "โฟลเดอร์", "all_folders": "ทุกโฟลเดอร์", "folder_kept": "ที่นี่ {kept} จาก {total}", @@ -2210,7 +2210,16 @@ "retry": "ลองอีกครั้งแบบออนไลน์", "retry_online": "ตอนนี้คุณออนไลน์แล้ว จึงดึงจากเซิร์ฟเวอร์ได้", "retry_offline": "ลองอีกครั้งได้เมื่อออนไลน์", - "settings_button": "ส่งออก…" + "settings_button": "ส่งออก…", + "message_failed": "เซิร์ฟเวอร์ไม่ได้ส่งข้อความนี้มา", + "failed_generic": "การส่งออกหยุดเพราะเกิดข้อผิดพลาด โปรดลองอีกครั้ง", + "stopped_limit": "การส่งออกหยุดที่ {limit} ซึ่งเป็นขนาดสูงสุดที่เบราว์เซอร์นี้เก็บได้ เลือกโฟลเดอร์ให้น้อยลง หรือใช้ Chromium, Firefox หรือแอปเดสก์ท็อป", + "over_limit": "เบราว์เซอร์นี้เก็บได้สูงสุด {limit} แต่ที่เลือกไว้มีขนาดประมาณ {size} เลือกโฟลเดอร์ให้น้อยลง หรือใช้ Chromium, Firefox หรือแอปเดสก์ท็อป", + "no_room": "การส่งออกนี้ต้องใช้ประมาณ {size} แต่เบราว์เซอร์นี้เหลือ {free} เลือกโฟลเดอร์ให้น้อยลงหรือเพิ่มพื้นที่ว่าง", + "stopped_no_room": "เบราว์เซอร์มีพื้นที่ไม่พอสำหรับการส่งออก เลือกโฟลเดอร์ให้น้อยลงหรือเพิ่มพื้นที่ว่าง", + "stopped_offline_off": "การส่งออกหยุดเพราะบัญชีนี้ไม่ได้เก็บอีเมลไว้ในอุปกรณ์นี้แล้ว", + "policy_unknown": "ยังไม่ทราบกฎขององค์กรของคุณสำหรับบัญชีนี้ จึงปิดการส่งออกไว้ก่อน", + "leftover_file": "นำไฟล์ {name} ออกจากตำแหน่งที่คุณบันทึกไม่ได้ ไฟล์นี้ไม่สมบูรณ์ คุณจึงลบได้" }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", diff --git a/locales/tr/common.json b/locales/tr/common.json index 3c630b29f..3a46de03b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5848,7 +5848,7 @@ "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.", + "description": "Bu cihazın sakladığı orijinalleri kaydeder. .eml dosyaları birebir kopyalardır. mbox dosyası, biçiminin gerektirdiği işaretleri ekler ve okundu ile bayraklı durumunu her iletinin üst bilgilerine, var olanların yerine yazar. 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}", @@ -5865,7 +5865,16 @@ "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…" + "settings_button": "Dışa aktar…", + "message_failed": "Sunucu bu iletiyi vermedi.", + "failed_generic": "Dışa aktarma bir hata nedeniyle durdu. Yeniden deneyin.", + "stopped_limit": "Dışa aktarma {limit} boyutunda durdu; bu tarayıcı daha fazlasını tutamaz. Daha az klasör seçin veya Chromium, Firefox ya da masaüstü uygulamasını kullanın.", + "over_limit": "Bu tarayıcı en fazla {limit} tutabilir, bu seçim ise yaklaşık {size}. Daha az klasör seçin veya Chromium, Firefox ya da masaüstü uygulamasını kullanın.", + "no_room": "Bu dışa aktarma yaklaşık {size} gerektiriyor, bu tarayıcıda ise {free} kaldı. Daha az klasör seçin veya yer açın.", + "stopped_no_room": "Tarayıcıda dışa aktarma için yer kalmadı. Daha az klasör seçin veya yer açın.", + "stopped_offline_off": "Dışa aktarma durdu çünkü bu hesap artık bu cihazda posta saklamıyor.", + "policy_unknown": "Kuruluşunuzun bu hesap için kuralları henüz bilinmiyor, bu nedenle dışa aktarma şimdilik kapalı.", + "leftover_file": "{name} dosyası kaydettiğiniz yerden kaldırılamadı. Eksik olduğu için silebilirsiniz." }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index fc7449ac4..67ec2fd59 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5848,7 +5848,7 @@ "settings_label": "Експорт збереженої пошти", "settings_description": "Збережіть пошту з цього пристрою у файли mbox або .eml. Працює і без мережі.", "title": "Експорт збереженої пошти", - "description": "Зберігає те, що зберігається на цьому пристрої, байт у байт. Пошти, старшої за вибраний період, тут немає. Щоб зберегти все із сервера, скористайтеся «Експортувати всю пошту».", + "description": "Зберігає оригінали, які є на цьому пристрої. Файли .eml — точні копії. Файл mbox додає позначки, яких вимагає його формат, і записує стан «прочитано» й «позначено» в заголовки кожного повідомлення замість попередніх. Пошти, старшої за вибраний період, тут немає. Щоб зберегти все із сервера, скористайтеся «Експортувати всю пошту».", "folders_label": "Папки", "all_folders": "Усі папки", "folder_kept": "Тут: {kept} із {total}", @@ -5865,7 +5865,16 @@ "retry": "Повторити в мережі", "retry_online": "Мережа є, тож їх можна отримати із сервера.", "retry_offline": "Спробуйте ще раз, коли з'явиться мережа.", - "settings_button": "Експортувати…" + "settings_button": "Експортувати…", + "message_failed": "Сервер не видав це повідомлення.", + "failed_generic": "Експорт зупинено через помилку. Спробуйте ще раз.", + "stopped_limit": "Експорт зупинено на {limit}: більше цей браузер утримати не може. Виберіть менше папок або скористайтеся Chromium, Firefox чи застосунком для комп'ютера.", + "over_limit": "Цей браузер може утримати щонайбільше {limit}, а вибрано приблизно {size}. Виберіть менше папок або скористайтеся Chromium, Firefox чи застосунком для комп'ютера.", + "no_room": "Для цього експорту потрібно приблизно {size}, а цьому браузеру доступно {free}. Виберіть менше папок або звільніть місце.", + "stopped_no_room": "Браузеру забракло місця для експорту. Виберіть менше папок або звільніть місце.", + "stopped_offline_off": "Експорт зупинено: цей обліковий запис більше не зберігає пошту на цьому пристрої.", + "policy_unknown": "Правила вашої організації для цього облікового запису поки невідомі, тому експорт поки вимкнено.", + "leftover_file": "Не вдалося вилучити файл {name} з місця збереження. Він неповний, його можна видалити." }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 729378a0f..996f6485f 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5860,7 +5860,7 @@ "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ư”.", + "description": "Lưu các bản gốc mà thiết bị này giữ. Tệp .eml là bản sao chính xác. Tệp mbox thêm các dấu mà định dạng của nó cần và ghi trạng thái đã đọc và gắn cờ vào tiêu đề của mỗi thư, thay cho giá trị cũ. 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}", @@ -5877,7 +5877,16 @@ "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…" + "settings_button": "Xuất…", + "message_failed": "Máy chủ không trả về thư này.", + "failed_generic": "Việc xuất đã dừng do lỗi. Hãy thử lại.", + "stopped_limit": "Việc xuất đã dừng ở {limit}, mức tối đa trình duyệt này có thể giữ. Hãy chọn ít thư mục hơn, hoặc dùng Chromium, Firefox hay ứng dụng máy tính.", + "over_limit": "Trình duyệt này giữ được tối đa {limit}, còn lựa chọn này khoảng {size}. Hãy chọn ít thư mục hơn, hoặc dùng Chromium, Firefox hay ứng dụng máy tính.", + "no_room": "Lần xuất này cần khoảng {size}, còn trình duyệt này còn {free}. Hãy chọn ít thư mục hơn hoặc giải phóng dung lượng.", + "stopped_no_room": "Trình duyệt đã hết chỗ cho việc xuất. Hãy chọn ít thư mục hơn hoặc giải phóng dung lượng.", + "stopped_offline_off": "Việc xuất đã dừng vì tài khoản này không còn giữ thư trên thiết bị này.", + "policy_unknown": "Chưa biết quy tắc của tổ chức bạn cho tài khoản này, nên việc xuất tạm thời tắt.", + "leftover_file": "Không thể xóa tệp {name} khỏi nơi bạn đã lưu. Tệp chưa hoàn chỉnh, bạn có thể xóa nó." }, "deep_link": { "message_not_found": "Thư này không còn nữa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index ba92e0d83..b45d509bc 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5848,7 +5848,7 @@ "settings_label": "匯出已保存的郵件", "settings_description": "將此裝置上的郵件儲存為 mbox 或 .eml 檔案。離線時也可使用。", "title": "匯出已保存的郵件", - "description": "依原樣儲存此裝置保留的內容。早於所選期間的郵件不包含在內。要儲存伺服器上的全部內容,請使用「匯出所有郵件」。", + "description": "儲存此裝置保留的原件。.eml 檔案是完全相同的副本。mbox 檔案會加上其格式需要的標記,並把已讀和標幟狀態寫入每封郵件的標頭,取代原有內容。早於所選期間的郵件不包含在內。要儲存伺服器上的全部內容,請使用「匯出所有郵件」。", "folders_label": "資料夾", "all_folders": "所有資料夾", "folder_kept": "此處 {kept} / {total}", @@ -5865,7 +5865,16 @@ "retry": "連線後重試", "retry_online": "現在已連線,可以從伺服器取得。", "retry_offline": "連線後可以重試。", - "settings_button": "匯出…" + "settings_button": "匯出…", + "message_failed": "伺服器沒有提供這封郵件。", + "failed_generic": "匯出因錯誤而停止。請重試。", + "stopped_limit": "匯出在 {limit} 處停止,這是此瀏覽器能容納的上限。請選擇較少的資料夾,或使用 Chromium、Firefox 或桌面應用程式。", + "over_limit": "此瀏覽器最多能容納 {limit},而此選擇約為 {size}。請選擇較少的資料夾,或使用 Chromium、Firefox 或桌面應用程式。", + "no_room": "此次匯出約需 {size},而此瀏覽器還剩 {free}。請選擇較少的資料夾或釋放空間。", + "stopped_no_room": "瀏覽器沒有足夠空間完成匯出。請選擇較少的資料夾或釋放空間。", + "stopped_offline_off": "匯出已停止,因為此帳號不再於此裝置上保留郵件。", + "policy_unknown": "尚不清楚貴組織對此帳號的規則,因此匯出暫時關閉。", + "leftover_file": "無法從儲存位置移除檔案 {name}。它不完整,您可以將其刪除。" }, "deep_link": { "message_not_found": "已無法使用此郵件。", diff --git a/locales/zh/common.json b/locales/zh/common.json index d34fbe623..53de52cc3 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5848,7 +5848,7 @@ "settings_label": "导出已保存的邮件", "settings_description": "将此设备上的邮件保存为 mbox 或 .eml 文件。离线时也可使用。", "title": "导出已保存的邮件", - "description": "按原样保存此设备保留的内容。早于所选时间范围的邮件不包含在内。要保存服务器上的全部内容,请使用“导出所有邮件”。", + "description": "保存此设备保留的原件。.eml 文件是完全相同的副本。mbox 文件会加上其格式需要的标记,并把已读和标记状态写入每封邮件的邮件头,替换原有内容。早于所选时间范围的邮件不包含在内。要保存服务器上的全部内容,请使用“导出所有邮件”。", "folders_label": "文件夹", "all_folders": "所有文件夹", "folder_kept": "此处 {kept} / {total}", @@ -5865,7 +5865,16 @@ "retry": "联网后重试", "retry_online": "现在已联网,可以从服务器获取。", "retry_offline": "联网后可以重试。", - "settings_button": "导出…" + "settings_button": "导出…", + "message_failed": "服务器没有提供这封邮件。", + "failed_generic": "导出因错误而停止。请重试。", + "stopped_limit": "导出在 {limit} 处停止,这是此浏览器能容纳的上限。请选择较少的文件夹,或使用 Chromium、Firefox 或桌面应用。", + "over_limit": "此浏览器最多能容纳 {limit},而此选择约为 {size}。请选择较少的文件夹,或使用 Chromium、Firefox 或桌面应用。", + "no_room": "此次导出约需 {size},而此浏览器还剩 {free}。请选择较少的文件夹或释放空间。", + "stopped_no_room": "浏览器没有足够空间完成导出。请选择较少的文件夹或释放空间。", + "stopped_offline_off": "导出已停止,因为此账户不再在此设备上保留邮件。", + "policy_unknown": "尚不清楚贵组织对此账户的规则,因此导出暂时关闭。", + "leftover_file": "无法从保存位置移除文件 {name}。它不完整,您可以将其删除。" }, "deep_link": { "message_not_found": "此邮件已不可用。",