diff --git a/components/mail-transfer/__tests__/local-import.test.tsx b/components/mail-transfer/__tests__/local-import.test.tsx index db1cba87c..eb171b3ce 100644 --- a/components/mail-transfer/__tests__/local-import.test.tsx +++ b/components/mail-transfer/__tests__/local-import.test.tsx @@ -3,18 +3,14 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { BulwarkDesktopBridge, DesktopLocalImportBridge, DesktopLocalImportFile } from '@/lib/desktop/types'; import { LocalImportPanel } from '../local-import-panel'; import { MailTransferHost } from '../mail-transfer-host'; -import { - forgetLocalImportFor, - forgetLocalImportJob, - readLocalImportJob, - resumeLocalImport, - saveLocalImportJob, - setImportTaskbarProgress, - startLocalImport, -} from '../local-import-job'; +import { forgetLocalImportFor, resumeLocalImport, startLocalImport, stopTryingRefused } from '../local-import-job'; +import { forgetLocalImportJob, readLocalImportJob, saveLocalImportJob, type LocalImportJobRecord } from '@/lib/desktop/local-import'; import { startImport } from '../start-import'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; import { useAuthStore } from '@/stores/auth-store'; import { useMailTransferStore } from '@/stores/mail-transfer-store'; +import { toast } from '@/stores/toast-store'; import type { ImportProgress } from '@/lib/mail-import/engine'; vi.mock('next-intl', () => ({ @@ -23,11 +19,11 @@ vi.mock('next-intl', () => ({ })); vi.mock('../start-import', () => ({ startImport: vi.fn() })); -const candidate = (over: Partial[0]['candidates'][number]>) => ({ +const candidate = (over: Record) => ({ handle: 'h', app: 'thunderbird' as const, kind: 'mbox' as const, group: 'default', account: 'Local Folders', folder: ['Inbox'], name: 'default/Local Folders/Inbox', size: 1000, lastModified: 1, ...over, }); -function makeScan(over: { candidates: Array & { handle: string }>; refused?: { name: string; reason: 'ost' | 'in-use' }[]; thunderbirdRunning?: boolean; calendars?: number; books?: { handle: string; group: string; name: string; contacts: number }[] }) { +function makeScan(over: { candidates: Array>; refused?: { name: string; reason: 'ost' | 'in-use' }[]; thunderbirdRunning?: boolean; calendars?: number; incomplete?: boolean; books?: { handle: string; group: string; name: string; contacts: number }[] }) { return { ok: true as const, books: [], refused: [], calendars: 0, thunderbirdRunning: false, ...over }; } @@ -44,18 +40,25 @@ function bridge(over: Partial = {}): DesktopLocalImpor return localImport; } +const SCOPE = 'acct'; const grantedFile: DesktopLocalImportFile = { url: 'bulwark-import://f/t', name: 'default/Local Folders/Inbox', kind: 'mbox', folder: ['Inbox'], size: 1000, lastModified: 1 }; +const job: LocalImportJobRecord = { id: 'job', scope: SCOPE, accountId: 'acc', targetMailboxId: null, keepFolders: true, dedupe: true }; +const granted = (over: Record = {}) => ({ ok: true as const, jobId: 'job', files: [grantedFile], contacts: [], refused: [] as string[], thunderbirdRunning: false, ...over }); beforeEach(async () => { - await forgetLocalImportJob(); + await forgetLocalImportJob(SCOPE); + await forgetLocalImportJob('someone-else'); vi.mocked(startImport).mockReset(); - useAuthStore.setState({ client: { supportsContacts: () => false } as never, activeAccountId: 'acct' }); + useAuthStore.setState({ client: { supportsContacts: () => false, getAccountId: () => 'acc' } as never, activeAccountId: SCOPE }); useMailTransferStore.setState({ request: null, visible: false, job: null }); }); afterEach(() => { delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.restoreAllMocks(); }); +const ended = (phase: ImportProgress['phase'], call = 0) => vi.mocked(startImport).mock.calls[call][0].onEnd!({ phase } as ImportProgress); + describe('the list of what was found', () => { it('is not there in a browser', () => { const { container } = render( {}} />); @@ -65,7 +68,7 @@ describe('the list of what was found', () => { it('asks the shell, shows the list, and only asks to read after the user confirms it', async () => { const localImport = bridge({ scan: vi.fn(async () => makeScan({ candidates: [candidate({ handle: 'a' }), candidate({ handle: 'b', folder: ['Archives', '2020'], name: 'x/Archives/2020', size: 2000 })] })) as never, - grant: vi.fn(async () => ({ ok: true as const, jobId: 'job', files: [grantedFile], contacts: [], refused: [], thunderbirdRunning: false })), + grant: vi.fn(async () => granted()), }); const onPick = vi.fn(); render(); @@ -93,6 +96,19 @@ describe('the list of what was found', () => { expect(onPick).not.toHaveBeenCalled(); }); + it('says so when the search failed, and when it was stopped part way', async () => { + const scan = vi.fn(); + bridge({ scan }); + render( {}} />); + scan.mockResolvedValueOnce({ ok: false, reason: 'failed' }); + fireEvent.click(screen.getByTestId('local-import-find')); + expect((await screen.findByTestId('local-import-note')).textContent).toBe('scan_failed'); + scan.mockResolvedValueOnce(makeScan({ candidates: [candidate({ handle: 'a' })], incomplete: true }) as never); + fireEvent.click(screen.getByTestId('local-import-find')); + expect((await screen.findByTestId('local-import-incomplete')).textContent).toBe('scan_incomplete'); + expect(screen.getAllByTestId('local-import-item')).toHaveLength(1); + }); + it('names an .ost as not offered, with the reason, and it has no checkbox', async () => { bridge({ scan: vi.fn(async () => makeScan({ candidates: [], refused: [{ name: 'me.ost', reason: 'ost' }] })) as never }); render( {}} />); @@ -151,37 +167,48 @@ describe('the list of what was found', () => { }); describe('an import that goes on after a restart', () => { - const job = { id: 'job', scope: 'acct', accountId: 'acc', targetMailboxId: null, keepFolders: true, dedupe: true }; + const start = (over: Partial[0]> = {}) => + startLocalImport({ jobId: 'job', files: [], handPicked: [], accountId: 'acc', targetMailboxId: null, keepFolders: true, resume: false, dedupe: true, ...over }); it('remembers what it needs when it starts, and forgets it and tells the shell when it ends', async () => { const localImport = bridge(); - await startLocalImport({ jobId: 'job', files: [], accountId: 'acc', targetMailboxId: null, keepFolders: true, resume: false, dedupe: true }); - expect(await readLocalImportJob()).toMatchObject({ id: 'job', scope: 'acct', dedupe: true }); + expect(await start()).toBe(true); + expect(await readLocalImportJob(SCOPE)).toMatchObject({ id: 'job', scope: SCOPE, dedupe: true }); const input = vi.mocked(startImport).mock.calls[0][0]; expect(input.dedupe).toBe(true); expect(localImport.release).not.toHaveBeenCalled(); // Failed comes back at the next start; done does not. input.onEnd!({ phase: 'failed' } as ImportProgress); - expect(await readLocalImportJob()).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(await readLocalImportJob(SCOPE)).not.toBeNull(); expect(localImport.release).not.toHaveBeenCalled(); input.onEnd!({ phase: 'done' } as ImportProgress); - await waitFor(async () => expect(await readLocalImportJob()).toBeNull()); - expect(localImport.release).toHaveBeenCalledWith('job'); + await waitFor(async () => expect(await readLocalImportJob(SCOPE)).toBeNull()); + await waitFor(() => expect(localImport.release).toHaveBeenCalledWith('job')); }); it('a cancel is the user deciding: it does not come back', async () => { const localImport = bridge(); - await startLocalImport({ jobId: 'job', files: [], accountId: 'acc', targetMailboxId: null, keepFolders: true, resume: false, dedupe: true }); - vi.mocked(startImport).mock.calls[0][0].onEnd!({ phase: 'cancelled' } as ImportProgress); - await waitFor(async () => expect(await readLocalImportJob()).toBeNull()); - expect(localImport.release).toHaveBeenCalledWith('job'); + await start(); + ended('cancelled'); + await waitFor(async () => expect(await readLocalImportJob(SCOPE)).toBeNull()); + await waitFor(() => expect(localImport.release).toHaveBeenCalledWith('job')); + }); + + it('one account starting an import leaves another account\'s job alone', async () => { + bridge(); + await saveLocalImportJob({ ...job, id: 'theirs', scope: 'someone-else' }); + await start(); + expect(await readLocalImportJob('someone-else')).toMatchObject({ id: 'theirs' }); + expect(await readLocalImportJob(SCOPE)).toMatchObject({ id: 'job' }); + ended('done'); }); it('goes on with the checkpoints and the same target when the user says continue', async () => { await saveLocalImportJob(job); - const localImport = bridge({ resume: vi.fn(async () => ({ ok: true as const, jobId: 'job', files: [grantedFile], contacts: [], refused: [], thunderbirdRunning: true })) }); - expect(await resumeLocalImport()).toBe('resumed-thunderbird-running'); + const localImport = bridge({ resume: vi.fn(async () => granted({ thunderbirdRunning: true })) }); + expect(await resumeLocalImport()).toMatchObject({ outcome: 'resumed', thunderbirdRunning: true }); expect(localImport.resume).toHaveBeenCalledWith('job'); const input = vi.mocked(startImport).mock.calls[0][0]; expect(input).toMatchObject({ resume: true, dedupe: true, accountId: 'acc', targetMailboxId: null, keepFolders: true }); @@ -189,7 +216,7 @@ describe('an import that goes on after a restart', () => { // A dialog to come back to from the chip, hidden. expect(useMailTransferStore.getState().request).toEqual({ kind: 'import' }); expect(useMailTransferStore.getState().visible).toBe(false); - input.onEnd!({ phase: 'done' } as ImportProgress); + ended('done'); }); it('"not now" keeps the job, "stop" and missing files drop it, and another account leaves it alone', async () => { @@ -198,26 +225,128 @@ describe('an import that goes on after a restart', () => { bridge({ resume }); resume.mockResolvedValueOnce({ ok: false, reason: 'later' }); - expect(await resumeLocalImport()).toBe('later'); - expect(await readLocalImportJob()).not.toBeNull(); + expect((await resumeLocalImport()).outcome).toBe('later'); + expect(await readLocalImportJob(SCOPE)).not.toBeNull(); useAuthStore.setState({ activeAccountId: 'someone-else' }); resume.mockClear(); - expect(await resumeLocalImport()).toBe('none'); + expect((await resumeLocalImport()).outcome).toBe('none'); expect(resume).not.toHaveBeenCalled(); - useAuthStore.setState({ activeAccountId: 'acct' }); + useAuthStore.setState({ activeAccountId: SCOPE }); - resume.mockResolvedValueOnce({ ok: false, reason: 'invalid' }); - expect(await resumeLocalImport()).toBe('gone'); - expect(await readLocalImportJob()).toBeNull(); + resume.mockResolvedValueOnce({ ok: false, reason: 'invalid', refused: ['p/Inbox'] }); + expect(await resumeLocalImport()).toEqual({ outcome: 'gone', refused: ['p/Inbox'] }); + expect(await readLocalImportJob(SCOPE)).toBeNull(); await saveLocalImportJob(job); resume.mockResolvedValueOnce({ ok: false, reason: 'stopped' }); - expect(await resumeLocalImport()).toBe('none'); - expect(await readLocalImportJob()).toBeNull(); + expect((await resumeLocalImport()).outcome).toBe('none'); + expect(await readLocalImportJob(SCOPE)).toBeNull(); expect(startImport).not.toHaveBeenCalled(); }); + it('files that are held right now keep the job, and are named', async () => { + await saveLocalImportJob(job); + bridge({ resume: vi.fn(async () => ({ ok: false as const, reason: 'in-use' as const, refused: ['p/Inbox'] })) }); + expect(await resumeLocalImport()).toEqual({ outcome: 'later', refused: ['p/Inbox'] }); + expect(await readLocalImportJob(SCOPE)).not.toBeNull(); + }); + + describe('when a resume can only read some of the folders', () => { + const other: DesktopLocalImportFile = { ...grantedFile, name: 'p/Other', url: 'bulwark-import://f/o' }; + + it('goes on with the rest, keeps the job open for them, and at the next start asks for only those', async () => { + await saveLocalImportJob(job); + const resume = vi.fn().mockResolvedValueOnce(granted({ refused: ['p/Other'] })); + const localImport = bridge({ resume }); + expect(await resumeLocalImport()).toMatchObject({ outcome: 'resumed', refused: ['p/Other'] }); + expect(vi.mocked(startImport).mock.calls[0][0].files).toHaveLength(1); + + // The rest finished, and the job is still open: the shell keeps the paths. + ended('done'); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(await readLocalImportJob(SCOPE)).toMatchObject({ retryOnly: ['p/Other'] }); + expect(localImport.release).not.toHaveBeenCalled(); + + // Next start: the folder is back. What finished before is not read again. + useMailTransferStore.setState({ request: null, visible: false, job: null }); + resume.mockResolvedValueOnce(granted({ files: [grantedFile, other] })); + expect((await resumeLocalImport()).outcome).toBe('resumed'); + expect(vi.mocked(startImport).mock.calls[1][0].files.map((f) => f.file.name)).toEqual(['p/Other']); + ended('done', 1); + await waitFor(async () => expect(await readLocalImportJob(SCOPE)).toBeNull()); + await waitFor(() => expect(localImport.release).toHaveBeenCalledWith('job')); + }); + + it('names them in a toast with the choice to stop trying, which ends the job when it is done', async () => { + await saveLocalImportJob(job); + const localImport = bridge({ resume: vi.fn(async () => granted({ refused: ['p/Other'] })) }); + const warn = vi.spyOn(toast, 'warning'); + render(); + await waitFor(() => expect(warn).toHaveBeenCalled()); + const [text, options] = warn.mock.calls.find(([message]) => String(message).startsWith('resume_refused'))!; + expect(text).toContain('p/Other'); + const action = (options as { action: { label: string; onClick: () => void } }).action; + expect(action.label).toBe('resume_stop_trying'); + + // The rest is still importing: that goes on, and the job ends with it. + act(() => useMailTransferStore.setState({ job: { kind: 'import', controller: new AbortController(), progress: { phase: 'running', fraction: 0.1 } as ImportProgress } })); + await act(async () => { + action.onClick(); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(await readLocalImportJob(SCOPE)).toMatchObject({ retryOnly: [] }); + expect(localImport.release).not.toHaveBeenCalled(); + ended('done'); + await waitFor(async () => expect(await readLocalImportJob(SCOPE)).toBeNull()); + await waitFor(() => expect(localImport.release).toHaveBeenCalledWith('job')); + }); + + it('with no job running, stop trying lets it go at once', async () => { + await saveLocalImportJob({ ...job, retryOnly: ['p/Other'] }); + const localImport = bridge(); + await stopTryingRefused(SCOPE); + expect(await readLocalImportJob(SCOPE)).toBeNull(); + expect(localImport.release).toHaveBeenCalledWith('job'); + }); + + it('with nothing readable among what is left, the job waits and says so', async () => { + await saveLocalImportJob({ ...job, retryOnly: ['p/Other'] }); + bridge({ resume: vi.fn(async () => granted({ refused: ['p/Other'] })) }); + expect(await resumeLocalImport()).toMatchObject({ outcome: 'later', refused: ['p/Other'] }); + expect(startImport).not.toHaveBeenCalled(); + expect(await readLocalImportJob(SCOPE)).toMatchObject({ retryOnly: ['p/Other'] }); + }); + }); + + it('names files chosen by hand once, since they can not come back by themselves', async () => { + bridge(); + await start({ handPicked: ['old.mbox'] }); + expect(await readLocalImportJob(SCOPE)).toMatchObject({ handPicked: ['old.mbox'] }); + ended('failed'); + await new Promise((resolve) => setTimeout(resolve, 10)); + + useMailTransferStore.setState({ request: null, visible: false, job: null }); + bridge({ resume: vi.fn(async () => granted()) }); + const warn = vi.spyOn(toast, 'warning'); + render(); + await waitFor(() => expect(warn.mock.calls.some(([message]) => String(message).includes('resume_hand_picked') && String(message).includes('old.mbox'))).toBe(true)); + expect((await readLocalImportJob(SCOPE))?.handPicked).toEqual([]); + ended('done', 1); + }); + + it('refuses to start while another window holds this account\'s job', async () => { + const release = vi.fn(); + vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { + locks: { request: (_name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => unknown) => (options.ifAvailable ? Promise.resolve(callback(null)) : new Promise(release)) }, + })); + bridge(); + expect(await start()).toBe(false); + expect(startImport).not.toHaveBeenCalled(); + expect(await readLocalImportJob(SCOPE)).toBeNull(); + vi.unstubAllGlobals(); + }); + describe('with another window holding the job', () => { /** Web Locks as the browser does them, for what a window holds and what another waits for. */ function fakeLocks() { @@ -243,16 +372,16 @@ describe('an import that goes on after a restart', () => { it('waits, and takes over when that window goes away', async () => { const request = fakeLocks(); await saveLocalImportJob(job); - const localImport = bridge({ resume: vi.fn(async () => ({ ok: true as const, jobId: 'job', files: [grantedFile], contacts: [], refused: [], thunderbirdRunning: false })) }); + const localImport = bridge({ resume: vi.fn(async () => granted()) }); let goAway: () => void = () => {}; - void request('bulwark-local-import-job', {}, () => new Promise((resolve) => { goAway = resolve; })); + void request(`bulwark-local-import-job:${SCOPE}`, {}, () => new Promise((resolve) => { goAway = resolve; })); const outcome = resumeLocalImport(); await new Promise((resolve) => setTimeout(resolve, 30)); expect(localImport.resume).not.toHaveBeenCalled(); goAway(); - expect(await outcome).toBe('resumed'); + expect((await outcome).outcome).toBe('resumed'); expect(localImport.resume).toHaveBeenCalledWith('job'); - vi.mocked(startImport).mock.calls[0][0].onEnd!({ phase: 'done' } as ImportProgress); + ended('done'); }); it('does nothing once it has the lock if the job ended meanwhile', async () => { @@ -260,71 +389,106 @@ describe('an import that goes on after a restart', () => { await saveLocalImportJob(job); const localImport = bridge(); let finish: () => void = () => {}; - void request('bulwark-local-import-job', {}, () => new Promise((resolve) => { finish = resolve; })); + void request(`bulwark-local-import-job:${SCOPE}`, {}, () => new Promise((resolve) => { finish = resolve; })); const outcome = resumeLocalImport(); await new Promise((resolve) => setTimeout(resolve, 30)); - await forgetLocalImportJob(); + await forgetLocalImportJob(SCOPE); finish(); - expect(await outcome).toBe('none'); + expect((await outcome).outcome).toBe('none'); expect(localImport.resume).not.toHaveBeenCalled(); }); }); it('leaves a device-only client alone, and goes on when the real one takes over', async () => { await saveLocalImportJob(job); - const localImport = bridge({ resume: vi.fn(async () => ({ ok: true as const, jobId: 'job', files: [grantedFile], contacts: [], refused: [], thunderbirdRunning: false })) }); + const localImport = bridge({ resume: vi.fn(async () => granted()) }); useAuthStore.setState({ client: { isOfflineOnly: () => true, supportsContacts: () => false, getAccountId: () => 'acc' } as never }); render(); await new Promise((resolve) => setTimeout(resolve, 30)); expect(localImport.resume).not.toHaveBeenCalled(); - expect(await readLocalImportJob()).not.toBeNull(); + expect(await readLocalImportJob(SCOPE)).not.toBeNull(); // A job on it would stay on it for good. - expect(await resumeLocalImport()).toBe('none'); + expect((await resumeLocalImport()).outcome).toBe('none'); act(() => useAuthStore.setState({ client: { isOfflineOnly: () => false, supportsContacts: () => false, getAccountId: () => 'acc' } as never })); await waitFor(() => expect(localImport.resume).toHaveBeenCalledWith('job')); await waitFor(() => expect(startImport).toHaveBeenCalled()); - vi.mocked(startImport).mock.calls[0][0].onEnd!({ phase: 'done' } as ImportProgress); + ended('done'); }); it('nothing happens in a browser, or with a job already running', async () => { await saveLocalImportJob(job); - expect(await resumeLocalImport()).toBe('none'); + expect((await resumeLocalImport()).outcome).toBe('none'); const localImport = bridge(); useMailTransferStore.setState({ job: { kind: 'import', controller: new AbortController(), progress: { phase: 'running' } as ImportProgress } }); - expect(await resumeLocalImport()).toBe('none'); + expect((await resumeLocalImport()).outcome).toBe('none'); expect(localImport.resume).not.toHaveBeenCalled(); }); - it('a sign-out of that account takes the job along; another account leaves it', async () => { + it('forgetting by account takes that one, and everyone takes them all', async () => { await saveLocalImportJob(job); + await saveLocalImportJob({ ...job, id: 'theirs', scope: 'someone-else' }); const localImport = bridge(); await forgetLocalImportFor('another'); - expect(await readLocalImportJob()).not.toBeNull(); - await forgetLocalImportFor('acct'); - expect(await readLocalImportJob()).toBeNull(); + expect(await readLocalImportJob(SCOPE)).not.toBeNull(); + await forgetLocalImportFor(SCOPE); + expect(await readLocalImportJob(SCOPE)).toBeNull(); + expect(await readLocalImportJob('someone-else')).not.toBeNull(); expect(localImport.release).toHaveBeenCalledWith('job'); - await saveLocalImportJob(job); await forgetLocalImportFor('all'); - expect(await readLocalImportJob()).toBeNull(); + expect(await readLocalImportJob('someone-else')).toBeNull(); expect(localImport.releaseAll).toHaveBeenCalled(); }); }); -describe('the taskbar', () => { - it('follows the import where the shell has the bar, and does nothing where it has not', () => { - bridge(); - expect(() => setImportTaskbarProgress({ phase: 'running', fraction: 0.5 } as ImportProgress)).not.toThrow(); +describe('what the mounted host is wired to', () => { + it('a sign-out of the account releases its job in the shell, through the real registry', async () => { + await saveLocalImportJob(job); + await saveLocalImportJob({ ...job, id: 'theirs', scope: 'someone-else' }); + const localImport = bridge({ resume: vi.fn(async () => ({ ok: false as const, reason: 'later' as const })) }); + const { unmount } = render(); + await runBeforeSignOut(SCOPE); + expect(await readLocalImportJob(SCOPE)).toBeNull(); + expect(localImport.release).toHaveBeenCalledWith('job'); + expect(await readLocalImportJob('someone-else')).not.toBeNull(); + expect(localImport.release).not.toHaveBeenCalledWith('theirs'); + + await runBeforeSignOut('all'); + expect(await readLocalImportJob('someone-else')).toBeNull(); + expect(localImport.releaseAll).toHaveBeenCalled(); + unmount(); + // Unmounted, it's no longer signed up. + await saveLocalImportJob(job); + await runBeforeSignOut(SCOPE); + expect(await readLocalImportJob(SCOPE)).not.toBeNull(); + }); + + it('a wipe does the same through the wipe registry', async () => { + await saveLocalImportJob(job); + const localImport = bridge({ resume: vi.fn(async () => ({ ok: false as const, reason: 'later' as const })) }); + render(); + await runWipeCleaners(SCOPE); + expect(await readLocalImportJob(SCOPE)).toBeNull(); + expect(localImport.release).toHaveBeenCalledWith('job'); + await runWipeCleaners(null); + expect(localImport.releaseAll).toHaveBeenCalled(); + }); + + it('the taskbar follows an import through the one helper, and does nothing where the shell has no bar', async () => { + bridge({ resume: vi.fn(async () => ({ ok: false as const, reason: 'later' as const })) }); const setProgress = vi.fn(); + render(); + const running = (progress: Partial) => + act(() => useMailTransferStore.setState({ job: { kind: 'import', controller: new AbortController(), progress: progress as ImportProgress } })); + // No bar here: nothing breaks. + running({ phase: 'running', fraction: 0.2 }); (window as unknown as { bulwarkDesktop: { os: unknown } }).bulwarkDesktop.os = { setProgress }; - act(() => setImportTaskbarProgress({ phase: 'running', fraction: 0.5 } as ImportProgress)); + running({ phase: 'running', fraction: 0.5 }); expect(setProgress).toHaveBeenLastCalledWith(0.5, 'normal'); - setImportTaskbarProgress({ phase: 'paused', fraction: 0.6 } as ImportProgress); + running({ phase: 'paused', fraction: 0.6 }); expect(setProgress).toHaveBeenLastCalledWith(0.6, 'paused'); - setImportTaskbarProgress({ phase: 'done', fraction: 1 } as ImportProgress); - expect(setProgress).toHaveBeenLastCalledWith(null); - setImportTaskbarProgress(null); - expect(setProgress).toHaveBeenLastCalledWith(null); + running({ phase: 'done', fraction: 1 }); + expect(setProgress.mock.lastCall?.[0]).toBeNull(); }); }); diff --git a/components/mail-transfer/__tests__/mail-import-dialog.test.tsx b/components/mail-transfer/__tests__/mail-import-dialog.test.tsx index 80f89e537..9676edfa7 100644 --- a/components/mail-transfer/__tests__/mail-import-dialog.test.tsx +++ b/components/mail-transfer/__tests__/mail-import-dialog.test.tsx @@ -74,6 +74,60 @@ describe('MailImportDialog', () => { expect(screen.queryByText('renamed.pst')).toBeNull(); }); + const confirmedBridge = () => { + const release = vi.fn(async () => {}); + appBridge({ + scan: vi.fn(async () => ({ ok: true, candidates: [{ handle: 'h', app: 'thunderbird', kind: 'mbox', group: 'default', account: 'Local Folders', folder: ['Inbox'], name: 'p/Inbox', size: 10, lastModified: 1 }], books: [], refused: [], calendars: 0, thunderbirdRunning: false })), + grant: vi.fn(async () => ({ ok: true, jobId: 'job', files: [{ url: 'bulwark-import://f/t', name: 'p/Inbox', kind: 'mbox', folder: ['Inbox'], size: 10, lastModified: 1 }], contacts: [], refused: [], thunderbirdRunning: false })), + resume: vi.fn(async () => ({ ok: false, reason: 'later' })), + release, + }); + return release; + }; + + const confirmOne = async () => { + fireEvent.click(screen.getByTestId('local-import-find')); + await screen.findByTestId('local-import-list'); + fireEvent.click(screen.getByTestId('local-import-confirm')); + await screen.findByTestId('local-import-picked'); + }; + + it('closing without starting gives up the confirmed files in the shell', async () => { + const release = confirmedBridge(); + render(); + act(() => openMailImport()); + await confirmOne(); + expect(release).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(release).toHaveBeenCalledWith('job'); + expect(useMailTransferStore.getState().request).toBeNull(); + }); + + it('closing by the X or Escape does too', async () => { + const release = confirmedBridge(); + render(); + act(() => openMailImport()); + await confirmOne(); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + await waitFor(() => expect(release).toHaveBeenCalledWith('job')); + }); + + it('says so, and starts nothing, when another window is importing for this account', async () => { + const release = confirmedBridge(); + vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { + locks: { request: (_name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => unknown) => (options.ifAvailable ? Promise.resolve(callback(null)) : new Promise(() => {})) }, + })); + render(); + act(() => openMailImport()); + await confirmOne(); + await waitFor(() => expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(screen.getByTestId('mail-import-start')); + expect((await screen.findByTestId('local-import-busy')).textContent).toBe('error_busy'); + expect(startImport).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + it('holds the rest of the dialog back while the list is open, and hands the confirmed files to the import', async () => { const files = [{ url: 'bulwark-import://f/t', name: 'p/Inbox', kind: 'mbox', folder: ['Inbox'], size: 10, lastModified: 1 }]; appBridge({ diff --git a/components/mail-transfer/local-import-job.ts b/components/mail-transfer/local-import-job.ts index b1c4e8f9b..dee6c8c88 100644 --- a/components/mail-transfer/local-import-job.ts +++ b/components/mail-transfer/local-import-job.ts @@ -1,6 +1,12 @@ import { getDesktopBridge } from "@/lib/desktop/bridge"; -import { toImportFile } from "@/lib/desktop/local-import"; -import { readStoredJob, writeStoredJob } from "@/lib/mail-import/checkpoints"; +import { + forgetLocalImportJob, + forgetLocalImportOf, + readLocalImportJob, + saveLocalImportJob, + toImportFile, + type LocalImportJobRecord, +} from "@/lib/desktop/local-import"; import { holdLock, waitForLock } from "@/lib/mail-import/locks"; import type { ImportFile, ImportProgress } from "@/lib/mail-import/engine"; import { useAuthStore } from "@/stores/auth-store"; @@ -15,39 +21,21 @@ import { startImport } from "./start-import"; * paths. The shell asks the user again before it reads anything after a restart. */ -/** Across the windows of the app: the one that has this runs the job. */ -const JOB_LOCK = "bulwark-local-import-job"; - -export interface LocalImportJobRecord { - id: string; - scope: string; - accountId: string; - targetMailboxId: string | null; - keepFolders: boolean; - dedupe: boolean; -} - -export function readLocalImportJob(): Promise { - return readStoredJob(); -} - -export function saveLocalImportJob(job: LocalImportJobRecord): Promise { - return writeStoredJob(job); -} - -export function forgetLocalImportJob(): Promise { - return writeStoredJob(null); -} +/** Across the windows of the app: the one that has this runs the account's job. */ +const jobLock = (scope: string) => `bulwark-local-import-job:${scope}`; let releaseLock: (() => void) | null = null; -function ended(job: LocalImportJobRecord, result: ImportProgress): void { +async function ended(job: LocalImportJobRecord, result: ImportProgress): Promise { releaseLock?.(); releaseLock = null; // A failed import comes back at the next start: the network or the server is usually why. if (result.phase === "failed") return; - void forgetLocalImportJob(); - void getDesktopBridge()?.localImport?.release(job.id).catch(() => {}); + // Folders a resume couldn't read stay to be tried, unless the user said to stop trying. + const latest = await readLocalImportJob(job.scope); + if (result.phase === "done" && latest?.retryOnly?.length) return; + await forgetLocalImportJob(job.scope); + await getDesktopBridge()?.localImport?.release(job.id).catch(() => {}); } async function run(job: LocalImportJobRecord, files: ImportFile[], resume: boolean): Promise { @@ -60,7 +48,7 @@ async function run(job: LocalImportJobRecord, files: ImportFile[], resume: boole keepFolders: job.keepFolders, resume, dedupe: job.dedupe, - onEnd: (result) => ended(job, result), + onEnd: (result) => void ended(job, result), }); } @@ -68,6 +56,8 @@ export interface StartLocalImport { jobId: string; /** Files of this job, and any files chosen the ordinary way in the same dialog. */ files: ImportFile[]; + /** Those chosen by hand. There's no way back to them after a restart. */ + handPicked: string[]; accountId: string; targetMailboxId: string | null; keepFolders: boolean; @@ -75,15 +65,27 @@ export interface StartLocalImport { dedupe: boolean; } -export async function startLocalImport(input: StartLocalImport): Promise { +/** False when another window of the app is importing for this account already: nothing started. */ +export async function startLocalImport(input: StartLocalImport): Promise { const scope = useAuthStore.getState().activeAccountId ?? "default"; + const lock = await holdLock(jobLock(scope)); + if (!lock) return false; releaseLock?.(); - releaseLock = await holdLock(JOB_LOCK); + releaseLock = lock; await run( - { id: input.jobId, scope, accountId: input.accountId, targetMailboxId: input.targetMailboxId, keepFolders: input.keepFolders, dedupe: input.dedupe }, + { + id: input.jobId, + scope, + accountId: input.accountId, + targetMailboxId: input.targetMailboxId, + keepFolders: input.keepFolders, + dedupe: input.dedupe, + handPicked: input.handPicked, + }, input.files, input.resume, ); + return true; } /** Started from the device's copy of the session: it answers from here, and the real one comes later. */ @@ -91,7 +93,14 @@ export function isDeviceOnlyClient(client: unknown): boolean { return !!(client as { isOfflineOnly?: () => boolean } | null)?.isOfflineOnly?.(); } -export type ResumeOutcome = "none" | "resumed" | "resumed-thunderbird-running" | "later" | "gone"; +export interface ResumeResult { + outcome: "none" | "resumed" | "later" | "gone"; + thunderbirdRunning?: boolean; + /** Folders that can't be read now. Said out loud: the job stays open for them. */ + refused?: string[]; + /** Files chosen by hand in the dialog, which can't come back by themselves. */ + handPicked?: string[]; +} /** * Goes on with an import an earlier run of the app left, once the same account is signed @@ -103,54 +112,68 @@ export type ResumeOutcome = "none" | "resumed" | "resumed-thunderbird-running" | * window can be closed. Whoever waits then takes over. When the job ended instead, there * is no record left and this does nothing. */ -export async function resumeLocalImport(): Promise { +export async function resumeLocalImport(): Promise { + const none: ResumeResult = { outcome: "none" }; const bridge = getDesktopBridge()?.localImport; const auth = useAuthStore.getState(); const store = useMailTransferStore.getState(); - if (!bridge || !auth.client || isDeviceOnlyClient(auth.client) || isJobActive(store.job)) return "none"; + if (!bridge || !auth.client || isDeviceOnlyClient(auth.client) || isJobActive(store.job)) return none; const scope = auth.activeAccountId ?? "default"; - const waiting = await readLocalImportJob(); - if (!waiting || waiting.scope !== scope) return "none"; - if (/^(\/[a-zA-Z-]{2,10})?\/popout(\/|$)/.test(window.location.pathname)) return "none"; - const lock = await waitForLock(JOB_LOCK); + const waiting = await readLocalImportJob(scope); + if (!waiting) return none; + if (/^(\/[a-zA-Z-]{2,10})?\/popout(\/|$)/.test(window.location.pathname)) return none; + const lock = await waitForLock(jobLock(scope)); let keepLock = false; try { // A long wait: look again at what's true now. - const job = await readLocalImportJob(); + const job = await readLocalImportJob(scope); const now = useAuthStore.getState(); - if (!job || job.scope !== scope || !now.client || isDeviceOnlyClient(now.client) || (now.activeAccountId ?? "default") !== scope || isJobActive(useMailTransferStore.getState().job)) return "none"; + if (!job || !now.client || isDeviceOnlyClient(now.client) || (now.activeAccountId ?? "default") !== scope || isJobActive(useMailTransferStore.getState().job)) return none; const result = await bridge.resume(job.id); if (!result.ok) { - if (result.reason === "later" || result.reason === "busy") return "later"; - await forgetLocalImportJob(); - return result.reason === "stopped" ? "none" : "gone"; + // In use: they're there and may be readable next time, so the job stays. + if (result.reason === "in-use") return { outcome: "later", refused: result.refused }; + if (result.reason === "later" || result.reason === "busy") return { outcome: "later" }; + await forgetLocalImportJob(scope); + return result.reason === "stopped" ? none : { outcome: "gone", refused: result.refused }; + } + const wanted = job.retryOnly?.length ? new Set(job.retryOnly) : null; + const files = wanted ? result.files.filter((file) => wanted.has(file.name)) : result.files; + const refused = wanted ? result.refused.filter((name) => wanted.has(name)) : result.refused; + const handPicked = job.handPicked ?? []; + const next: LocalImportJobRecord = { ...job, handPicked: [], retryOnly: refused }; + if (files.length === 0) { + // Nothing to do now, and the record still says what's left. + await saveLocalImportJob(next); + return { outcome: "later", refused, handPicked }; } keepLock = true; releaseLock = lock; // A dialog to come back to from the corner chip, closed for now. store.open({ kind: "import" }); store.hide(); - await run(job, result.files.map(toImportFile), true); - return result.thunderbirdRunning ? "resumed-thunderbird-running" : "resumed"; + await run(next, files.map((file) => toImportFile(file)), true); + return { outcome: "resumed", thunderbirdRunning: result.thunderbirdRunning, refused, handPicked }; } finally { if (!keepLock) lock(); } } -/** A sign-out takes the job along: the checkpoints and what's remembered are gone too. */ -export async function forgetLocalImportFor(accountId: string): Promise { - const bridge = getDesktopBridge()?.localImport; - if (accountId === "all") { - await forgetLocalImportJob(); - await bridge?.releaseAll().catch(() => {}); +/** "Stop trying": the folders a resume couldn't read are let go. What's running goes on. */ +export async function stopTryingRefused(scope: string): Promise { + const job = await readLocalImportJob(scope); + if (!job) return; + const store = useMailTransferStore.getState(); + if (isJobActive(store.job)) { + await saveLocalImportJob({ ...job, retryOnly: [] }); return; } - const job = await readLocalImportJob(); - if (job?.scope !== accountId) return; - await forgetLocalImportJob(); - await bridge?.release(job.id).catch(() => {}); + await forgetLocalImportOf(scope); } +/** A sign-out takes the job along: the checkpoints and what's remembered are gone too. */ +export const forgetLocalImportFor = forgetLocalImportOf; + /** Contacts of the confirmed address books, the ones not there yet. How many went in. */ export async function importLocalContacts(vcards: string[]): Promise { const client = useAuthStore.getState().client; @@ -160,11 +183,3 @@ export async function importLocalContacts(vcards: string[]): Promise { const duplicates = detectDuplicates(store.contacts, parsed); return store.importContacts(client, parsed.filter((_, index) => !duplicates.has(index))); } - -/** The taskbar's bar, where the shell has one (feat/os-integration's os.setProgress). */ -export function setImportTaskbarProgress(progress: ImportProgress | null): void { - const os = (getDesktopBridge() as { os?: { setProgress?: (value: number | null, mode?: "normal" | "paused") => void } } | null)?.os; - if (!os?.setProgress) return; - if (!progress || (progress.phase !== "running" && progress.phase !== "paused")) os.setProgress(null); - else os.setProgress(progress.fraction, progress.phase === "paused" ? "paused" : "normal"); -} diff --git a/components/mail-transfer/local-import-panel.tsx b/components/mail-transfer/local-import-panel.tsx index 18c645f1d..e5585a2a3 100644 --- a/components/mail-transfer/local-import-panel.tsx +++ b/components/mail-transfer/local-import-panel.tsx @@ -132,7 +132,7 @@ export function LocalImportPanel({ onPick, onListing }: { try { const result = await bridge.scan(); if (!result.ok) { - setNote(result.reason === "declined" ? t("scan_declined") : null); + setNote(result.reason === "declined" ? t("scan_declined") : result.reason === "failed" ? t("scan_failed") : null); return; } setScan(result); @@ -196,6 +196,7 @@ export function LocalImportPanel({ onPick, onListing }: { return (
{nothing &&

{t("nothing_found")}

} + {scan.incomplete &&

{t("scan_incomplete")}

} {scan.thunderbirdRunning && } {!nothing && (
diff --git a/components/mail-transfer/mail-import-dialog.tsx b/components/mail-transfer/mail-import-dialog.tsx index 4d258eb60..998758896 100644 --- a/components/mail-transfer/mail-import-dialog.tsx +++ b/components/mail-transfer/mail-import-dialog.tsx @@ -75,6 +75,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) { const [skipSeen, setSkipSeen] = useState(true); const [listing, setListing] = useState(false); const [ostChosen, setOstChosen] = useState([]); + const [lockHeld, setLockHeld] = useState(false); const inDesktopApp = !!getDesktopBridge()?.localImport; const localFiles = useMemo(() => (local?.files ?? []).map(toImportFile), [local]); const [target, setTarget] = useState(initialTarget); @@ -148,15 +149,17 @@ export function MailImportDialog({ request }: { request: ImportRequest }) { } const files: ImportFile[] = [...localFiles, ...picked.map((p) => ({ file: p.file, format: p.format }))]; if (files.length === 0) { close(); return; } + setLockHeld(false); void startLocalImport({ jobId: local.jobId, files, + handPicked: picked.map((p) => p.file.name), accountId, targetMailboxId: targetId, keepFolders: effectiveKeep, resume: resume && resumable > 0, dedupe: skipSeen, - }); + }).then((started) => setLockHeld(!started)); return; } startImport({ @@ -169,6 +172,11 @@ export function MailImportDialog({ request }: { request: ImportRequest }) { }; const onClose = () => (active ? hide() : close()); + // Left without starting: the shell gives up the files the user confirmed, and their paths. + const leaveSetup = () => { + if (local) void getDesktopBridge()?.localImport?.release(local.jobId).catch(() => {}); + close(); + }; if (!visible) return null; @@ -202,7 +210,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) { return ( close()} + onClose={leaveSetup} size="full-on-phone" title={t("title")} description={inDesktopApp ? tLocal("description") : t("description")} @@ -210,12 +218,13 @@ export function MailImportDialog({ request }: { request: ImportRequest }) { initialFocus={chooseButton} footer={( <> - + )} >
+ {lockHeld &&

{t("error_busy")}

} {inDesktopApp && !local && ( {t("summary_failed", { count: p.failed })}

    {p.failures.map((f, i) => ( -
  • {f.label || t("untitled")}: {f.reason === "invalid" ? t("failure_invalid") : f.reason}
  • +
  • {f.label || t("untitled")}: {f.reason === "invalid" ? t("failure_invalid") : f.reason === LOCAL_FILE_UNAVAILABLE ? tLocal("file_unavailable") : f.reason}
  • ))}
diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index bacd2ff95..a9b448c5a 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -15,7 +15,7 @@ import { MailImportDialog } from "./mail-import-dialog"; import { MailExportDialog } from "./mail-export-dialog"; import { LocalExportDialog } from "./local-export-dialog"; import { formatClock } from "./transfer-progress"; -import { forgetLocalImportFor, isDeviceOnlyClient, resumeLocalImport, setImportTaskbarProgress } from "./local-import-job"; +import { forgetLocalImportFor, isDeviceOnlyClient, resumeLocalImport, stopTryingRefused } from "./local-import-job"; /** How long sign-out waits for a cancelled job to stop (its budget is 2 s). */ const SIGN_OUT_WAIT_MS = 1500; @@ -69,6 +69,7 @@ export function MailTransferHost() { // 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 forgetLocalImportFor(account ?? "all"); await sweepPrivateExports({ force: account === null }); }), []); @@ -80,18 +81,18 @@ export function MailTransferHost() { }, [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 jobPhase = job?.progress.phase ?? null; + const jobFraction = job?.progress.fraction ?? 0; const onTaskbar = useRef(false); useEffect(() => { - if (exportPhase === "running" || exportPhase === "paused") { + if (jobPhase === "running" || jobPhase === "paused") { onTaskbar.current = true; - showTaskbarProgress(exportFraction, exportPhase === "paused" ? "paused" : "normal"); + showTaskbarProgress(jobFraction, jobPhase === "paused" ? "paused" : "normal"); } else if (onTaskbar.current) { onTaskbar.current = false; showTaskbarProgress(null); } - }, [exportPhase, exportFraction]); + }, [jobPhase, jobFraction]); // A job that ends while its dialog is hidden says so, with a way back to its summary. const tImport = useTranslations("mail_import"); @@ -108,24 +109,21 @@ export function MailTransferHost() { const scope = activeAccountId ?? "default"; if (asked.current === scope) return; asked.current = scope; - void resumeLocalImport().then((outcome) => { - if (outcome === "resumed-thunderbird-running") toast.warning(tLocal("thunderbird_running")); - else if (outcome === "gone") toast.warning(tLocal("resume_gone")); + void resumeLocalImport().then(({ outcome, thunderbirdRunning, refused, handPicked }) => { + if (thunderbirdRunning) toast.warning(tLocal("thunderbird_running")); + if (outcome === "gone") { + toast.warning(refused?.length ? tLocal("files_refused", { names: refused.join(", ") }) : tLocal("resume_gone")); + } else if (refused?.length) { + toast.warning(tLocal("resume_refused", { names: refused.join(", ") }), { + action: { label: tLocal("resume_stop_trying"), onClick: () => void stopTryingRefused(scope) }, + }); + } + if (handPicked?.length) toast.warning(tLocal("resume_hand_picked", { names: handPicked.join(", ") })); }).catch(() => {}); // The translator is a new function on every render in tests; the account is what matters. // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, activeAccountId]); - // The bar on the taskbar follows an import, where the app has one. - const importProgress = job?.kind === "import" ? job.progress : null; - const barShown = useRef(false); - useEffect(() => { - // Not when it was never ours: the bar may be showing an export. - if (!importProgress && !barShown.current) return; - barShown.current = !!importProgress; - setImportTaskbarProgress(importProgress); - }, [importProgress]); - const lastPhase = useRef(undefined); useEffect(() => { const phase = job?.progress.phase; diff --git a/lib/desktop/local-import.ts b/lib/desktop/local-import.ts index a9a2143dd..6dbfac413 100644 --- a/lib/desktop/local-import.ts +++ b/lib/desktop/local-import.ts @@ -6,12 +6,35 @@ * confirmed in a native dialog (src/main/features/import.ts in the desktop repo). */ +import { getDesktopBridge } from './bridge'; import type { DesktopLocalImportFile } from './types'; +import { clearStoredJobs, readStoredJob, writeStoredJob } from '@/lib/mail-import/checkpoints'; import type { ImportFile } from '@/lib/mail-import/engine'; /** Why an import stopped when a file can't be read any more. See errorText in the dialog. */ export const LOCAL_FILE_UNAVAILABLE = 'import:local_file_unavailable'; +/** Another program holding a file is usually over in moments: a virus scanner, a compaction. */ +const RETRY_DELAYS_MS = [1000, 3000]; + +type Sleep = (ms: number) => Promise; +const realSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A 5xx or no answer is tried again; anything else is the shell's final word. */ +async function fetchRetrying(url: string, init: RequestInit | undefined, sleep: Sleep): Promise { + for (let attempt = 0; ; attempt++) { + let response: Response | null = null; + try { + response = init ? await fetch(url, init) : await fetch(url); + } catch { + response = null; + } + if (response && response.status < 500) return response; + if (attempt >= RETRY_DELAYS_MS.length) throw new Error(LOCAL_FILE_UNAVAILABLE); + await sleep(RETRY_DELAYS_MS[attempt]); + } +} + class LocalFileSlice { readonly size: number; constructor( @@ -19,13 +42,14 @@ class LocalFileSlice { private readonly start: number, private readonly end: number, readonly type: string, + private readonly sleep: Sleep, ) { this.size = Math.max(0, end - start); } async arrayBuffer(): Promise { if (this.size === 0) return new ArrayBuffer(0); - const response = await fetch(this.url, { headers: { Range: `bytes=${this.start}-${this.end - 1}` } }); + const response = await fetchRetrying(this.url, { headers: { Range: `bytes=${this.start}-${this.end - 1}` } }, this.sleep); if (response.status !== 206) throw new Error(LOCAL_FILE_UNAVAILABLE); const bytes = await response.arrayBuffer(); if (bytes.byteLength !== this.size) throw new Error(LOCAL_FILE_UNAVAILABLE); @@ -42,7 +66,10 @@ export class LocalFile { readonly rangeUrl: string; readonly entrySizes: number[]; - constructor(file: DesktopLocalImportFile) { + constructor( + file: DesktopLocalImportFile, + private readonly sleep: Sleep = realSleep, + ) { this.name = file.name; this.size = file.size; this.lastModified = file.lastModified; @@ -52,12 +79,12 @@ export class LocalFile { slice(start = 0, end = this.size, type = ''): LocalFileSlice { const from = Math.min(start, this.size); - return new LocalFileSlice(this.rangeUrl, from, Math.min(end, this.size), type); + return new LocalFileSlice(this.rangeUrl, from, Math.min(end, this.size), type, this.sleep); } - /** A Maildir folder's message. Null when it has gone since the folder was listed. */ + /** A Maildir folder's message. Null only when it has really gone since the folder was listed. */ async readEntry(index: number): Promise { - const response = await fetch(`${this.rangeUrl}/${index}`); + const response = await fetchRetrying(`${this.rangeUrl}/${index}`, undefined, this.sleep); if (response.status === 404) return null; if (!response.ok) throw new Error(LOCAL_FILE_UNAVAILABLE); return new Uint8Array(await response.arrayBuffer()); @@ -75,3 +102,47 @@ export async function isOstFile(file: Blob & { name?: string }): Promise { + return readStoredJob(scope); +} + +export function saveLocalImportJob(job: LocalImportJobRecord): Promise { + return writeStoredJob(job.scope, job); +} + +export function forgetLocalImportJob(scope: string): Promise { + return writeStoredJob(scope, null); +} + +/** + * A sign-out or a wipe takes the job along, and the shell's list of paths with it. Has to run + * before the checkpoints' database goes: after that nothing here knows which job to name. A + * bridge call and nothing else, so it's fine in a wipe. + */ +export async function forgetLocalImportOf(account: string): Promise { + const bridge = getDesktopBridge()?.localImport; + if (account === 'all') { + await bridge?.releaseAll().catch(() => {}); + await clearStoredJobs(); + return; + } + const job = await readLocalImportJob(account); + if (!job) return; + await forgetLocalImportJob(account); + await bridge?.release(job.id).catch(() => {}); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 888a1a454..720153c5f 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -328,8 +328,10 @@ export type DesktopLocalImportScanResult = /** Thunderbird calendars seen, which aren't read. */ calendars: number; thunderbirdRunning: boolean; + /** The search took too long and was stopped: this is what it found until then. */ + incomplete?: boolean; } - | { ok: false; reason: 'declined' | 'busy' }; + | { ok: false; reason: 'declined' | 'busy' | 'failed' }; /** A file the user confirmed. `url` takes Range requests; a Maildir's messages are at `url/`. */ export interface DesktopLocalImportFile { @@ -357,7 +359,8 @@ export type DesktopLocalImportGrantResult = export type DesktopLocalImportResumeResult = | Extract - | { ok: false; reason: 'later' | 'stopped' | 'busy' | 'invalid' | 'unknown'; refused?: string[] }; + /** `in-use`: none can be read right now, which may pass, so the job stays. `invalid`: they're gone, and so is the job. */ + | { ok: false; reason: 'later' | 'stopped' | 'busy' | 'invalid' | 'in-use' | 'unknown'; refused?: string[] }; /** * Thunderbird and Outlook data on this computer. Each step that widens access asks the diff --git a/lib/mail-import/__tests__/local-data-store.test.ts b/lib/mail-import/__tests__/local-data-store.test.ts index ba9016a31..9321f4210 100644 --- a/lib/mail-import/__tests__/local-data-store.test.ts +++ b/lib/mail-import/__tests__/local-data-store.test.ts @@ -19,42 +19,54 @@ beforeEach(async () => { }); describe('what an import keeps on disk', () => { + const item = (text: string) => ({ data: new Blob([text]), size: text.length }); + it('what was imported is still known after a restart, with nothing in memory', async () => { const before = await fresh(); - await before.dedupe.localDedupe('a', 'acc').add(['k1']); + const dedupe = before.dedupe.localDedupe('a', 'acc'); + const { key } = await dedupe.check(item('Subject: one\r\n\r\nx'), 'inbox'); + await dedupe.add([key]); expect(before.store.seenInMemory.size).toBe(0); const after = await fresh(); - expect(await after.dedupe.localDedupe('a', 'acc').has('k1')).toBe(true); - expect(await after.dedupe.localDedupe('a', 'acc').has('k2')).toBe(false); + const again = after.dedupe.localDedupe('a', 'acc'); + expect(await again.check(item('Subject: one\r\n\r\nx'), 'inbox')).toEqual({ key, seen: true }); + expect((await again.check(item('Subject: two\r\n\r\nx'), 'inbox')).seen).toBe(false); }); - it('the job is still there after a restart, and gone once forgotten', async () => { + it('the job is still there after a restart, per account, and gone once forgotten', async () => { const before = await fresh(); - await before.store.writeStoredJob({ id: 'job' }); + await before.store.writeStoredJob('a', { id: 'job-a' }); + await before.store.writeStoredJob('b', { id: 'job-b' }); const after = await fresh(); - expect(await after.store.readStoredJob()).toEqual({ id: 'job' }); - await after.store.writeStoredJob(null); - expect(await (await fresh()).store.readStoredJob()).toBeNull(); + expect(await after.store.readStoredJob('a')).toEqual({ id: 'job-a' }); + expect(await after.store.readStoredJob('b')).toEqual({ id: 'job-b' }); + await after.store.writeStoredJob('a', null); + const later = await fresh(); + expect(await later.store.readStoredJob('a')).toBeNull(); + expect(await later.store.readStoredJob('b')).toEqual({ id: 'job-b' }); }); it('a sign-out takes that account\'s checkpoints and what it imported, and leaves the others', async () => { const { store, dedupe } = await fresh(); await store.writeCheckpoint(checkpoint('ca', 'a')); await store.writeCheckpoint(checkpoint('cb', 'b')); - await dedupe.localDedupe('a', 'acc').add(['seen-a']); - await dedupe.localDedupe('b', 'acc').add(['seen-b']); + const a = dedupe.localDedupe('a', 'acc'); + const b = dedupe.localDedupe('b', 'acc'); + const mail = () => item('Subject: x\r\n\r\ny'); + await a.add([(await a.check(mail(), 'inbox')).key]); + await b.add([(await b.check(mail(), 'inbox')).key]); await store.clearCheckpointsFor('a'); expect(await store.readCheckpoint('ca')).toBeNull(); expect(await store.readCheckpoint('cb')).not.toBeNull(); - expect(await dedupe.localDedupe('a', 'acc').has('seen-a')).toBe(false); - expect(await dedupe.localDedupe('b', 'acc').has('seen-b')).toBe(true); + expect((await a.check(mail(), 'inbox')).seen).toBe(false); + expect((await b.check(mail(), 'inbox')).seen).toBe(true); }); it('signing everyone out takes the job along', async () => { const { store } = await fresh(); - await store.writeStoredJob({ id: 'job' }); + await store.writeStoredJob('a', { id: 'job' }); await store.clearAllCheckpoints(); - expect(await (await fresh()).store.readStoredJob()).toBeNull(); + expect(await (await fresh()).store.readStoredJob('a')).toBeNull(); }); it('a database from before this version keeps its checkpoints', async () => { @@ -76,8 +88,10 @@ describe('what an import keeps on disk', () => { }); const { store, dedupe } = await fresh(); expect(await store.readCheckpoint('old')).not.toBeNull(); - await dedupe.localDedupe('a', 'acc').add(['k']); - expect(await dedupe.localDedupe('a', 'acc').has('k')).toBe(true); + const remember = dedupe.localDedupe('a', 'acc'); + const { key } = await remember.check(item('Subject: x\r\n\r\ny'), 'inbox'); + await remember.add([key]); + expect((await remember.check(item('Subject: x\r\n\r\ny'), 'inbox')).seen).toBe(true); // On disk: the new store exists. Without the upgrade this lands in the memory fallback. expect(store.seenInMemory.size).toBe(0); }); diff --git a/lib/mail-import/__tests__/local-data.test.ts b/lib/mail-import/__tests__/local-data.test.ts index 7fb3a128b..b6941f40c 100644 --- a/lib/mail-import/__tests__/local-data.test.ts +++ b/lib/mail-import/__tests__/local-data.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { runImport, type ImportOptions, type ImportProgress } from '@/lib/mail-import/engine'; -import { checkpointKey, clearCheckpointsFor, deleteCheckpoint, readCheckpoint } from '@/lib/mail-import/checkpoints'; +import { checkpointKey, clearCheckpointsFor, deleteCheckpoint, readCheckpoint, seenInMemory } from '@/lib/mail-import/checkpoints'; import { localDedupe, messageIdOf } from '@/lib/mail-import/dedupe'; import { durationWords, importHours } from '@/lib/mail-import/estimate'; import { rangeBacking, registerPstFile, openSync, readSync } from '@/lib/mail-import/pst/shims/fs'; import { maildirSource, mboxSource, type ImportItem, type MaildirFile, type SourceOptions } from '@/lib/mail-import/sources'; import { isOstFile, LOCAL_FILE_UNAVAILABLE, LocalFile, toImportFile } from '@/lib/desktop/local-import'; +import { JMAPClient } from '@/lib/jmap/client'; import type { IJMAPClient, UploadedEmailImport } from '@/lib/jmap/client-interface'; import type { Mailbox } from '@/lib/jmap/types'; @@ -49,6 +50,8 @@ function fakeServer() { } function run(options: Partial & Pick): Promise { + // Time that passes when the import sleeps: a pause has to end without the test waiting for it. + let clock = Date.now(); return runImport({ accountId: 'acc', targetMailboxId: null, @@ -57,7 +60,8 @@ function run(options: Partial & Pick {}, - sleep: async () => {}, + now: () => clock, + sleep: async (ms) => { clock += ms; }, ...options, }); } @@ -143,6 +147,37 @@ describe('Maildir', () => { expect(read).toHaveBeenCalledWith(1); }); + it('a message that can not be read now is a failure the next run tries again, never one it moves past', async () => { + const source = folder([message('a'), message('held'), message('c')]); + const real = source.readEntry.bind(source); + let held = true; + source.readEntry = async (index) => { + if (index === 1 && held) throw new Error(LOCAL_FILE_UNAVAILABLE); + return real(index); + }; + const server = fakeServer(); + const scope = freshScope(); + const files = [{ file: source as unknown as File, format: 'maildir' as const, folder: ['Box'] }]; + const first = await run({ client: server.client, scope, files }); + expect(first.phase).toBe('done'); + expect(first.failed).toBe(1); + expect(first.retryable).toBe(1); + expect(first.skipped.deleted).toBeUndefined(); + expect(first.failures[0].reason).toBe(LOCAL_FILE_UNAVAILABLE); + expect(server.subjects()).toEqual(['a', 'c']); + + // The checkpoint has a gap there: the next run reads that one and nothing else. + held = false; + const second = await run({ client: server.client, scope, resume: true, files }); + expect(second.failed).toBe(0); + expect(server.subjects()).toEqual(['a', 'c', 'held']); + }); + + it('a message that has really gone is skipped, and only that', async () => { + const items = await collect(folder([null])); + expect(items[0]).toMatchObject({ kind: 'skipped', reason: 'deleted' }); + }); + it('runs through the engine as a format of its own', async () => { const server = fakeServer(); const result = await run({ client: server.client, files: [{ file: folder([message('m1'), message('m2')]) as unknown as File, format: 'maildir', folder: ['Box'] }] }); @@ -197,24 +232,71 @@ describe('dedupe across runs', () => { expect(result.skipped.duplicate).toBe(1); }); - it('a different size under the same Message-ID is another message, and no Message-ID goes by the bytes', async () => { + const asItem = (text: string) => ({ data: new Blob([text]), size: text.length }); + + it('two mails with the same Message-ID and length that differ only in Date are two mails', async () => { + const server = fakeServer(); + const scope = freshScope(); + const dated = (day: string) => `Message-ID: \r\nDate: ${day} May 2020 10:00:00 +0000\r\nSubject: Reminder\r\n\r\nPay up\r\n`; + const one = dated('05'); + const two = dated('12'); + expect(one.length).toBe(two.length); + const box = (text: string) => `From - Tue May 05 10:00:00 2020\r\n${text}\r\n`; + const result = await run({ + client: server.client, + scope, + dedupe: localDedupe(scope, 'acc'), + files: [{ file: file(box(one) + box(two), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] }], + }); + expect(result.imported).toBe(2); + expect(result.skipped.duplicate).toBeUndefined(); + expect(server.imported.map((m) => /Date: (\d+)/.exec(m.text)?.[1]).sort()).toEqual(['05', '12']); + }); + + it('a message only differing in the state headers Thunderbird rewrites is the same message', async () => { const dedupe = localDedupe(freshScope(), 'acc'); - const item = (text: string) => ({ data: new Blob([text]), size: text.length }); - const a = await dedupe.keyFor(item(message('same')), 'inbox'); - expect(await dedupe.keyFor(item(message('same')), 'inbox')).toBe(a); - expect(await dedupe.keyFor(item(`${message('same')}more`), 'inbox')).not.toBe(a); - expect(await dedupe.keyFor(item(message('same')), 'sent')).not.toBe(a); - const none = await dedupe.keyFor(item('Subject: x\r\n\r\none'), 'inbox'); - expect(await dedupe.keyFor(item('Subject: x\r\n\r\ntwo'), 'inbox')).not.toBe(none); + const a = await dedupe.check(asItem(message('same', 'X-Mozilla-Status: 0001\r\nX-Mozilla-Status2: 00000000\r\n')), 'inbox'); + const b = await dedupe.check(asItem(message('same', 'X-Mozilla-Status: 0011\r\n')), 'inbox'); + expect(b.key).toBe(a.key); + const c = await dedupe.check(asItem(`${message('same')}more`), 'inbox'); + expect(c.key).not.toBe(a.key); + const d = await dedupe.check(asItem(message('same', 'X-Mozilla-Status: 0001\r\nX-Mozilla-Status2: 00000000\r\n')), 'sent'); + expect(d.key).not.toBe(a.key); + }); + + it('what an earlier version remembered still counts: nothing is imported again, and it moves to the new key', async () => { + const scope = freshScope(); + const dedupe = localDedupe(scope, 'acc'); + const text = message('old'); + // The earlier format: a hash of the Message-ID and the size. + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`id:old@example.org:${text.length}`))); + const hash = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''); + await dedupe.add([[scope, 'acc', 'inbox', hash].join('\u0000')]); + + const first = await dedupe.check(asItem(text), 'inbox'); + expect(first.seen).toBe(true); + expect(first.key).toContain('v2:'); + // Moved: known under its own key now, whatever happens to the old one. + expect(seenInMemory.has(first.key)).toBe(true); + // And an old record isn't everything: another message is still new. + expect((await dedupe.check(asItem(message('other')), 'inbox')).seen).toBe(false); + }); + + it('a message without a Message-ID goes by its bytes', async () => { + const dedupe = localDedupe(freshScope(), 'acc'); + const none = await dedupe.check(asItem('Subject: x\r\n\r\none'), 'inbox'); + expect((await dedupe.check(asItem('Subject: x\r\n\r\ntwo'), 'inbox')).key).not.toBe(none.key); }); it('a sign-out forgets what was imported', async () => { const scope = freshScope(); const dedupe = localDedupe(scope, 'acc'); - await dedupe.add(['k']); - expect(await dedupe.has('k')).toBe(true); + const item = { data: new Blob([message('m')]), size: message('m').length }; + const { key } = await dedupe.check(item, 'inbox'); + await dedupe.add([key]); + expect((await dedupe.check(item, 'inbox')).seen).toBe(true); await clearCheckpointsFor(scope); - expect(await dedupe.has('k')).toBe(false); + expect((await dedupe.check(item, 'inbox')).seen).toBe(false); }); it('stopping mid-way leaves a checkpoint, and the next run neither repeats nor loses a message', async () => { @@ -295,6 +377,69 @@ describe('when the server can not be reached', () => { expect(server.subjects()).toEqual(['a']); }); + const dropped = () => Object.assign(new TypeError('Failed to fetch'), {}); + + it('a connection lost while making a folder pauses: it does not count towards the failures that stop an import', async () => { + const server = fakeServer(); + const real = server.client.createMailbox.bind(server.client); + let left = 12; + server.client.createMailbox = (async (...args: Parameters) => { + if (left-- > 0) throw dropped(); + return real(...args); + }) as typeof server.client.createMailbox; + let clock = Date.now(); + const seen: ImportProgress[] = []; + const result = await run({ + client: server.client, + scope: freshScope(), + now: () => clock, + sleep: async (ms) => { clock += ms; }, + onProgress: (p) => seen.push(p), + files: [{ file: file(mbox(['a', 'b', 'c']), 'p/New'), format: 'mbox', folder: ['New'] }], + }); + expect(result.phase).toBe('done'); + expect(result.failed).toBe(0); + expect(server.subjects()).toEqual(['a', 'b', 'c']); + expect(seen.some((p) => p.phase === 'paused')).toBe(true); + }); + + it('a connection lost on Email/import pauses too, and the same messages go in after it', async () => { + const server = fakeServer(); + const real = server.client.importUploadedEmails!.bind(server.client); + let left = 12; + server.client.importUploadedEmails = (async (entries: UploadedEmailImport[]) => { + if (left-- > 0) throw dropped(); + return real(entries); + }) as typeof server.client.importUploadedEmails; + let clock = Date.now(); + const result = await run({ + client: server.client, + scope: freshScope(), + now: () => clock, + sleep: async (ms) => { clock += ms; }, + files: [{ file: file(mbox(['a']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] }], + }); + expect(result.failed).toBe(0); + expect(server.subjects()).toEqual(['a']); + }); + + it('an error from the server itself still counts: that message may be the problem', async () => { + const server = fakeServer(); + server.client.createMailbox = (async () => { throw new Error('forbidden name'); }) as typeof server.client.createMailbox; + const result = await run({ client: server.client, scope: freshScope(), files: [{ file: file(mbox(['a']), 'p/New'), format: 'mbox', folder: ['New'] }] }); + expect(result.failed).toBe(1); + expect(result.failures[0].reason).toBe('forbidden name'); + }); + + it('the real Email/import call only takes the server\'s word', async () => { + const client = new JMAPClient('https://mail.example.org', 'a@example.org', 'pw'); + const request = vi.fn(async () => ({ methodResponses: [['Email/import', { created: { imp0: { id: 'e1' } } }, '0']] })); + (client as unknown as { request: typeof request }).request = request; + const results = await client.importUploadedEmails([{ blobId: 'b', mailboxIds: { inbox: true }, keywords: {} }], 'acc'); + expect(results).toEqual([{ id: 'e1' }]); + expect((request.mock.calls[0] as unknown[])[2]).toMatchObject({ networkOnly: true }); + }); + it('with the browser offline nothing is tried, not even the folder, until it is back', async () => { const server = fakeServer(); const create = vi.spyOn(server.client, 'createMailbox'); @@ -363,6 +508,24 @@ describe('a file read from disk through the shell', () => { await expect(local.slice(0, 4).arrayBuffer()).rejects.toThrow(LOCAL_FILE_UNAVAILABLE); }); + it('a file held for a moment is tried again, and only then reported', async () => { + const naps: number[] = []; + const local = new LocalFile({ ...grant, kind: 'maildir', entrySizes: [3] }, async (ms) => { naps.push(ms); }); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response('abc', { status: 200 })); + expect(new TextDecoder().decode((await local.readEntry(0))!)).toBe('abc'); + expect(naps).toHaveLength(1); + + fetchMock.mockReset(); + fetchMock.mockImplementation(async () => new Response(null, { status: 503 })); + await expect(local.readEntry(0)).rejects.toThrow(LOCAL_FILE_UNAVAILABLE); + expect(fetchMock).toHaveBeenCalledTimes(3); + + // A range too, and a fetch that throws counts the same. + fetchMock.mockReset(); + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce(new Response(new Uint8Array(3), { status: 206 })); + expect((await local.slice(0, 3).arrayBuffer()).byteLength).toBe(3); + }); + it('a Maildir message comes by position, and one that has gone is null', async () => { const local = new LocalFile({ ...grant, kind: 'maildir', entrySizes: [3] }); fetchMock.mockResolvedValueOnce(new Response('abc', { status: 200 })); diff --git a/lib/mail-import/checkpoints.ts b/lib/mail-import/checkpoints.ts index 90c7726fd..5173f4fa5 100644 --- a/lib/mail-import/checkpoints.ts +++ b/lib/mail-import/checkpoints.ts @@ -24,7 +24,7 @@ const DB_NAME = "bulwark-mail-import"; const STORE = "checkpoints"; /** What lib/mail-import/dedupe.ts remembers. Same database, so a sign-out takes it along. */ export const SEEN_STORE = "seen"; -/** What an import from this computer needs to go on after a restart (the desktop app). One record. */ +/** What an import from this computer needs to go on after a restart (the desktop app). One per account. */ const JOB_STORE = "jobs"; /** Older checkpoints are dropped: the file has likely changed or been forgotten. */ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; @@ -43,7 +43,7 @@ const memory = new Map(); /** The fallback where IndexedDB isn't available, by key, with the scope as the value. */ export const seenInMemory = new Map(); -let jobInMemory: unknown = null; +const jobsInMemory = new Map(); function forgetSeenInMemory(scope?: string): void { for (const [key, value] of seenInMemory) if (!scope || value === scope) seenInMemory.delete(key); @@ -99,32 +99,47 @@ async function withStore(mode: IDBTransactionMode, run: (store: IDBObjectStor /** * In IndexedDB rather than localStorage, which Chromium writes to disk late: an import - * killed in its first minute would come back as if it had never started. + * killed in its first minute would come back as if it had never started. One per Bulwark + * account, so one account's import doesn't overwrite another's. */ -export async function readStoredJob(): Promise { - if (!hasIdb()) return (jobInMemory as T | null); +export async function readStoredJob(scope: string): Promise { + const inMemory = () => (jobsInMemory.get(scope) as T | undefined) ?? null; + if (!hasIdb()) return inMemory(); try { const db = await openDb(); - const found = (await done(db.transaction(JOB_STORE, "readonly").objectStore(JOB_STORE).get("job"))) as { value: T } | undefined; + const found = (await done(db.transaction(JOB_STORE, "readonly").objectStore(JOB_STORE).get(scope))) as { value: T } | undefined; return found?.value ?? null; } catch { - return (jobInMemory as T | null); + return inMemory(); } } -export async function writeStoredJob(value: unknown): Promise { - jobInMemory = value; +export async function writeStoredJob(scope: string, value: unknown): Promise { + if (value === null) jobsInMemory.delete(scope); + else jobsInMemory.set(scope, value); if (!hasIdb()) return; try { const db = await openDb(); const store = db.transaction(JOB_STORE, "readwrite").objectStore(JOB_STORE); - if (value === null) await done(store.delete("job")); - else await done(store.put({ key: "job", value })); + if (value === null) await done(store.delete(scope)); + else await done(store.put({ key: scope, value })); } catch { /* kept in memory for this window */ } } +/** Every account's. */ +export async function clearStoredJobs(): Promise { + jobsInMemory.clear(); + if (!hasIdb()) return; + try { + const db = await openDb(); + await done(db.transaction(JOB_STORE, "readwrite").objectStore(JOB_STORE).clear()); + } catch { + /* nothing stored */ + } +} + export async function readCheckpoint(key: string, now = Date.now()): Promise { let found: ImportCheckpoint | null; if (!hasIdb()) { @@ -185,7 +200,7 @@ export async function clearCheckpointsFor(scope: string): Promise { /** Removes every checkpoint (everyone signed out). */ export async function clearAllCheckpoints(): Promise { memory.clear(); - jobInMemory = null; + jobsInMemory.clear(); forgetSeenInMemory(); if (!hasIdb()) return; const db = dbPromise ? await dbPromise.catch(() => null) : null; diff --git a/lib/mail-import/dedupe.ts b/lib/mail-import/dedupe.ts index 0e732031c..00366d768 100644 --- a/lib/mail-import/dedupe.ts +++ b/lib/mail-import/dedupe.ts @@ -1,4 +1,5 @@ import { done, hasIdb, openDb, seenInMemory, SEEN_STORE } from "./checkpoints"; +import { withoutStateHeaders } from "./mbox-status"; import type { ImportMessage } from "./sources"; /** @@ -8,13 +9,14 @@ import type { ImportMessage } from "./sources"; * and date, the checkpoint no longer applies, and the file starts over. The same message * into another folder is not a duplicate: Thunderbird can have a copy in both. * - * Only hashes are kept. A message is known by its Message-ID and size. Without a - * Message-ID it is known by its bytes. + * Only hashes are kept. A message is known by its bytes without the state headers, which + * are all compacting changes. Going by its Message-ID would drop a real message: mailers + * reuse one, say a reminder sent again with the same length and another Date. */ export interface ImportDedupe { - keyFor(item: Pick, mailboxId: string): Promise; - has(key: string): Promise; + /** The key to remember this message by, and whether an earlier import already did. */ + check(item: Pick, mailboxId: string): Promise<{ key: string; seen: boolean }>; add(keys: string[]): Promise; } @@ -33,37 +35,55 @@ async function sha256(data: BufferSource): Promise { } export function localDedupe(scope: string, accountId: string): ImportDedupe { + const keyOf = (mailboxId: string, hash: string) => [scope, accountId, mailboxId, hash].join("\u0000"); + + const has = async (key: string): Promise => { + if (seenInMemory.has(key)) return true; + if (!hasIdb()) return false; + try { + const db = await openDb(); + return (await done(db.transaction(SEEN_STORE, "readonly").objectStore(SEEN_STORE).getKey(key))) !== undefined; + } catch { + return false; + } + }; + + const add = async (keys: string[]): Promise => { + if (!hasIdb()) { + for (const key of keys) seenInMemory.set(key, scope); + return; + } + try { + const db = await openDb(); + const store = db.transaction(SEEN_STORE, "readwrite").objectStore(SEEN_STORE); + await Promise.all(keys.map((key) => done(store.put({ key, scope })))); + } catch { + for (const key of keys) seenInMemory.set(key, scope); + } + }; + + /** + * What a record from before this format is under, so nothing already imported goes in + * again. It can't be turned into the new form: the bytes aren't stored. A hit moves the + * message to the new key, so a real message is only at risk from a record that old. + */ + const legacyKey = async (bytes: ArrayBuffer, size: number, mailboxId: string): Promise => { + const id = messageIdOf(new TextDecoder("latin1").decode(bytes.slice(0, HEADER_BYTES))); + return keyOf(mailboxId, id ? await sha256(new TextEncoder().encode(`id:${id}:${size}`)) : await sha256(bytes)); + }; + return { - async keyFor(item, mailboxId) { - const head = new TextDecoder("latin1").decode(await item.data.slice(0, HEADER_BYTES).arrayBuffer()); - const id = messageIdOf(head); - const hash = id - ? await sha256(new TextEncoder().encode(`id:${id}:${item.size}`)) - : await sha256(await item.data.arrayBuffer()); - return [scope, accountId, mailboxId, hash].join("\u0000"); - }, - async has(key) { - if (seenInMemory.has(key)) return true; - if (!hasIdb()) return false; - try { - const db = await openDb(); - return (await done(db.transaction(SEEN_STORE, "readonly").objectStore(SEEN_STORE).getKey(key))) !== undefined; - } catch { - return false; - } - }, - async add(keys) { - if (!hasIdb()) { - for (const key of keys) seenInMemory.set(key, scope); - return; - } - try { - const db = await openDb(); - const store = db.transaction(SEEN_STORE, "readwrite").objectStore(SEEN_STORE); - await Promise.all(keys.map((key) => done(store.put({ key, scope })))); - } catch { - for (const key of keys) seenInMemory.set(key, scope); + async check(item, mailboxId) { + const bytes = await item.data.arrayBuffer(); + const stripped = await new Blob(withoutStateHeaders([new Uint8Array(bytes)]) as BlobPart[]).arrayBuffer(); + const key = keyOf(mailboxId, `v2:${await sha256(stripped)}`); + if (await has(key)) return { key, seen: true }; + if (await has(await legacyKey(bytes, item.size, mailboxId))) { + await add([key]); + return { key, seen: true }; } + return { key, seen: false }; }, + add, }; } diff --git a/lib/mail-import/engine.ts b/lib/mail-import/engine.ts index 66d002035..6c622abfc 100644 --- a/lib/mail-import/engine.ts +++ b/lib/mail-import/engine.ts @@ -2,6 +2,7 @@ import type { IJMAPClient, UploadedEmailImport, UploadedEmailImportResult } from import type { Mailbox } from "@/lib/jmap/types"; import { checkpointKey, deleteCheckpoint, readCheckpoint, writeCheckpoint } from "./checkpoints"; import type { ImportDedupe } from "./dedupe"; +import { isNetworkFailure } from "@/lib/jmap/offline-hooks"; import { holdLock } from "./locks"; import { emlSource, maildirSource, mboxSource, zipSource, type ImportFormat, type ImportItem, type ItemSource, type MaildirFile, type SkipReason, type SourceOptions } from "./sources"; @@ -414,11 +415,12 @@ export async function runImport(options: ImportOptions): Promise } } catch (error) { const name = (error as { name?: string } | null)?.name; - // The messages are uploaded and waiting. Going offline isn't a try. - if (isOffline(error)) { + // The messages are uploaded and waiting. The server being out of reach isn't a try. + if (isNetworkFailure(error)) { attempt--; - await sleep(NETWORK_PAUSE_MS, options.signal).catch(() => {}); - if (options.signal.aborted) break; + pausedUntil = Math.max(pausedUntil, now() + NETWORK_PAUSE_MS); + await waitWhilePaused().catch(() => {}); + if (signal.aborted) break; continue; } if (name === "RateLimitError" && attempt < 3) { @@ -484,20 +486,30 @@ export async function runImport(options: ImportOptions): Promise const upload = async (item: Extract, fileIndex: number) => { const u = { fileIndex, index: item.index, label: item.label }; let mailboxId: string; - // Before the folder too: created offline, it would only exist on this device. - await waitWhilePaused(); - try { - mailboxId = await folders.resolve(options.keepFolders ? item.folder : [], item.folderRole); - } catch (error) { - if (isAbort(error) || signal.aborted) throw new ImportCancelled(); - fail(u, error instanceof Error ? error.message : String(error)); - return; + // Waits before the folder too: asked for offline, a folder is refused or answered from this + // device, and neither is a folder on the server. A connection lost during the call shows below. + for (;;) { + await waitWhilePaused(); + try { + mailboxId = await folders.resolve(options.keepFolders ? item.folder : [], item.folderRole); + break; + } catch (error) { + if (isAbort(error) || signal.aborted) throw new ImportCancelled(); + // Says nothing about this message, so it doesn't count towards the failures in a row. + if (isNetworkFailure(error)) { + pausedUntil = Math.max(pausedUntil, now() + NETWORK_PAUSE_MS); + continue; + } + fail(u, error instanceof Error ? error.message : String(error)); + return; + } } let seenKey: string | undefined; if (options.dedupe) { try { - seenKey = await options.dedupe.keyFor(item, mailboxId); - if (inFlight.has(seenKey) || (await options.dedupe.has(seenKey))) { + const { key, seen } = await options.dedupe.check(item, mailboxId); + seenKey = key; + if (seen || inFlight.has(key)) { skip("duplicate"); handled(u); return; @@ -620,6 +632,10 @@ export async function runImport(options: ImportOptions): Promise try { for await (const item of source) { if (signal.aborted) throw state.stopped ?? new ImportCancelled(); + if (item.kind === "failed") { + fail({ fileIndex: f, index: item.index, label: item.label }, item.reason); + continue; + } if (item.kind === "skipped") { // Items an earlier run handled are already counted in its imported total. if (item.reason !== "resumed") skip(item.reason); diff --git a/lib/mail-import/sources.ts b/lib/mail-import/sources.ts index 3699bbf0a..beedff9e9 100644 --- a/lib/mail-import/sources.ts +++ b/lib/mail-import/sources.ts @@ -56,7 +56,15 @@ export interface ImportSkip { label?: string; } -export type ImportItem = ImportMessage | ImportSkip; +/** Couldn't be read now, which may pass: a failure, and the next run tries it again. */ +export interface ImportReadFailure { + kind: "failed"; + index: number; + label: string; + reason: string; +} + +export type ImportItem = ImportMessage | ImportSkip | ImportReadFailure; export interface SourceOptions { /** Messages larger than this are skipped as too_large without reading them in. */ @@ -290,8 +298,17 @@ export async function* maildirSource(file: MaildirFile, options: SourceOptions, } else if (size > options.maxMessageBytes) { yield { kind: "skipped", index, reason: "too_large", label: String(index + 1) }; } else { - const bytes = await file.readEntry(index); - yield bytes ? maildirItem(bytes, index, folder) : { kind: "skipped", index, reason: "deleted" }; + let bytes: Uint8Array | null = null; + let failure: string | null = null; + try { + bytes = await file.readEntry(index); + } catch (error) { + if ((error as { name?: string } | null)?.name === "AbortError") throw error; + failure = error instanceof Error ? error.message : String(error); + } + // Null is the file saying it has really gone. Anything else must not move the import past it. + if (failure !== null) yield { kind: "failed", index, label: `${file.name} #${index + 1}`, reason: failure }; + else yield bytes ? maildirItem(bytes, index, folder) : { kind: "skipped", index, reason: "deleted" }; } doneBytes += size; options.onProgress?.(doneBytes / Math.max(1, file.size)); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index 96a0369b2..19e399dcd 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -803,6 +803,36 @@ describe('what the desktop app keeps outside the page', () => { expect(shell.clearClipboardFiles).toHaveBeenCalled(); }); + it('the import from this computer goes too, and the shell hears it before the record is gone', async () => { + const order: string[] = []; + const { readStoredJob, writeStoredJob } = await import('@/lib/mail-import/checkpoints'); + await writeStoredJob(ALICE, { id: 'job', scope: ALICE }); + const localImport = { + release: vi.fn(async () => {}), + releaseAll: vi.fn(async () => { + order.push(`releaseAll, record ${JSON.stringify(await readStoredJob(ALICE))}`); + }), + }; + const window2 = window as { bulwarkDesktop?: { localImport?: unknown } }; + window2.bulwarkDesktop = { ...window2.bulwarkDesktop, localImport }; + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(order).toEqual([`releaseAll, record ${JSON.stringify({ id: 'job', scope: ALICE })}`]); + expect(await readStoredJob(ALICE)).toBeNull(); + }); + + it('a wipe of one account releases that account\'s import in the shell', async () => { + const { writeStoredJob } = await import('@/lib/mail-import/checkpoints'); + await writeStoredJob(ALICE, { id: 'job', scope: ALICE }); + const localImport = { release: vi.fn(async () => {}), releaseAll: vi.fn(async () => {}) }; + const window2 = window as { bulwarkDesktop?: { localImport?: unknown } }; + window2.bulwarkDesktop = { ...window2.bulwarkDesktop, localImport }; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(localImport.release).toHaveBeenCalledWith('job'); + }); + it('a shell without those calls wipes all the same', async () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index c63c13cc6..a72513f48 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -88,6 +88,7 @@ import { forgetEvictedSecrets } from './evicted-secrets'; import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode'; import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state'; import { runWipeCleaners } from './wipe-registry'; +import { forgetLocalImportOf } from '@/lib/desktop/local-import'; /** How long a start holds the local copy back for the instance's answer. Offline, it doesn't wait at all. */ export const BOOT_CHECK_IN_BUDGET_MS = 1_000; @@ -264,6 +265,7 @@ async function endWebPushIfNobodyLeft(): Promise { /** What's the account's own: a deletion after a refused login goes no further than this. */ async function scrubAccount(account: string): Promise { await runWipeCleaners(account); + await forgetLocalImportOf(account).catch(() => {}); await forgetPluginAccounts(account).catch(() => {}); clearPluginStorageForAccount(account); forgetMailSnapshot(account); @@ -272,6 +274,8 @@ async function scrubAccount(account: string): Promise { /** Everything accounts share, or that can't be told apart by account. */ async function scrubShared(): Promise { await clearShellFiles().catch(() => {}); + // Before clearAllCheckpoints takes the job records: they're how the shell's paths get named. + await forgetLocalImportOf('all').catch(() => {}); await deletePluginDatabases(); await forgetPluginSettings().catch(() => {}); await fileStorage.clearFiles().catch(() => {}); diff --git a/locales/ar/common.json b/locales/ar/common.json index cba29af8a..d1657586d 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "لا يمكن متابعة الاستيراد من هذا الكمبيوتر لأن ملفاته اختفت.", "pst_in_use": "لا يمكن قراءة {names} ما دام Outlook يفتحه. أغلق Outlook وابحث مرة أخرى.", "description": "ابحث عن البريد على هذا الكمبيوتر، أو اختر ملفات ‎.eml أو ‎.zip أو ‎.mbox أو Outlook ‎.pst.", - "settings_description": "استورد البريد من Thunderbird أو من ملفات ‎.eml أو ZIP أو mbox أو Outlook ‎.pst." + "settings_description": "استورد البريد من Thunderbird أو من ملفات ‎.eml أو ZIP أو mbox أو Outlook ‎.pst.", + "resume_refused": "لا يمكن قراءتها الآن: {names}. يستمر الباقي، وستُجرَّب هذه مرة أخرى عند التشغيل التالي.", + "resume_stop_trying": "التوقف عن المحاولة", + "resume_hand_picked": "اختر هذه الملفات مرة أخرى لاستيراد الباقي: {names}. يتم تخطي الرسائل المستوردة بالفعل.", + "scan_incomplete": "استغرق البحث وقتًا طويلاً وتم إيقافه. هذا ما وجده حتى ذلك الحين." }, "recover_deleted": { "tab": "استعادة العناصر المحذوفة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3d7486f70..948a559c7 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "La importació d'aquest ordinador no pot continuar perquè els fitxers ja no hi són.", "pst_in_use": "{names} no es pot llegir mentre Outlook el tingui obert. Tanca Outlook i torna a cercar.", "description": "Cerca correu en aquest ordinador, o tria fitxers .eml, .zip, .mbox o Outlook .pst.", - "settings_description": "Porta correu de Thunderbird, o de fitxers .eml, ZIP, mbox o Outlook .pst." + "settings_description": "Porta correu de Thunderbird, o de fitxers .eml, ZIP, mbox o Outlook .pst.", + "resume_refused": "Ara no es poden llegir: {names}. La resta continua, i aquests es tornen a provar al pròxim inici.", + "resume_stop_trying": "Deixa de provar-ho", + "resume_hand_picked": "Torna a triar aquests fitxers per importar la resta: {names}. Els missatges ja importats s'ometen.", + "scan_incomplete": "La cerca ha trigat massa i s'ha aturat. Això és el que ha trobat fins aleshores." }, "recover_deleted": { "tab": "Recupera elements suprimits", diff --git a/locales/cs/common.json b/locales/cs/common.json index 271be7ef7..b324bca9e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "Import z tohoto počítače nemůže pokračovat, protože jeho soubory zmizely.", "pst_in_use": "{names} nelze přečíst, dokud je soubor otevřený v Outlooku. Zavřete Outlook a hledejte znovu.", "description": "Najděte poštu v tomto počítači, nebo vyberte soubory .eml, .zip, .mbox či Outlook .pst.", - "settings_description": "Přeneste poštu z Thunderbirdu nebo ze souborů .eml, ZIP, mbox či Outlook .pst." + "settings_description": "Přeneste poštu z Thunderbirdu nebo ze souborů .eml, ZIP, mbox či Outlook .pst.", + "resume_refused": "Teď nelze přečíst: {names}. Zbytek pokračuje a tyto se zkusí znovu při dalším spuštění.", + "resume_stop_trying": "Už nezkoušet", + "resume_hand_picked": "Vyberte tyto soubory znovu, abyste importovali zbytek: {names}. Už importované zprávy se přeskočí.", + "scan_incomplete": "Hledání trvalo příliš dlouho a bylo zastaveno. Toto našlo do té doby." }, "recover_deleted": { "tab": "Obnovit smazané položky", diff --git a/locales/da/common.json b/locales/da/common.json index 509e3882a..65834dca9 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Importen fra denne computer kan ikke fortsætte, fordi dens filer er væk.", "pst_in_use": "{names} kan ikke læses, mens Outlook har filen åben. Luk Outlook, og søg igen.", "description": "Find mail på denne computer, eller vælg .eml-, .zip-, .mbox- eller Outlook .pst-filer.", - "settings_description": "Hent mail fra Thunderbird eller fra .eml-, ZIP-, mbox- eller Outlook .pst-filer." + "settings_description": "Hent mail fra Thunderbird eller fra .eml-, ZIP-, mbox- eller Outlook .pst-filer.", + "resume_refused": "Kan ikke læses lige nu: {names}. Resten fortsætter, og disse prøves igen ved næste start.", + "resume_stop_trying": "Prøv ikke mere", + "resume_hand_picked": "Vælg disse filer igen for at importere resten: {names}. Allerede importerede beskeder springes over.", + "scan_incomplete": "Søgningen tog for lang tid og blev stoppet. Dette er, hvad den fandt indtil da." }, "recover_deleted": { "tab": "Gendan slettede elementer", diff --git a/locales/de/common.json b/locales/de/common.json index ecd69dcd0..3532885e1 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "Der Import von diesem Computer kann nicht weitergehen, weil seine Dateien fehlen.", "pst_in_use": "{names} kann nicht gelesen werden, solange Outlook die Datei geöffnet hat. Schließen Sie Outlook und suchen Sie noch einmal.", "description": "Suchen Sie E-Mails auf diesem Computer, oder wählen Sie .eml-, .zip-, .mbox- oder Outlook-.pst-Dateien.", - "settings_description": "Holen Sie E-Mails aus Thunderbird oder aus .eml-, ZIP-, mbox- oder Outlook-.pst-Dateien." + "settings_description": "Holen Sie E-Mails aus Thunderbird oder aus .eml-, ZIP-, mbox- oder Outlook-.pst-Dateien.", + "resume_refused": "Gerade nicht lesbar: {names}. Der Rest läuft weiter, und diese werden beim nächsten Start noch einmal versucht.", + "resume_stop_trying": "Nicht mehr versuchen", + "resume_hand_picked": "Wählen Sie diese Dateien noch einmal aus, um den Rest zu importieren: {names}. Bereits importierte Nachrichten werden übersprungen.", + "scan_incomplete": "Die Suche hat zu lange gedauert und wurde beendet. Das hat sie bis dahin gefunden." }, "recover_deleted": { "tab": "Gelöschte E-Mails", diff --git a/locales/en/common.json b/locales/en/common.json index c15483bd9..3ee85a3c1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "The import from this computer can't go on, because its files are gone.", "pst_in_use": "{names} can't be read while Outlook has it open. Close Outlook and search again.", "description": "Find mail on this computer, or choose .eml, .zip, .mbox or Outlook .pst files.", - "settings_description": "Bring in mail from Thunderbird, or from .eml, ZIP, mbox or Outlook .pst files." + "settings_description": "Bring in mail from Thunderbird, or from .eml, ZIP, mbox or Outlook .pst files.", + "resume_refused": "Can't read right now: {names}. The rest goes on, and these are tried again at the next start.", + "resume_stop_trying": "Stop trying", + "resume_hand_picked": "Choose these files again to import the rest of them: {names}. Messages already imported are skipped.", + "scan_incomplete": "The search took too long and was stopped. This is what it found until then." }, "recover_deleted": { "tab": "Recover deleted items", diff --git a/locales/es/common.json b/locales/es/common.json index ea38bd433..684304aaa 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "La importación desde este equipo no puede continuar porque sus archivos ya no están.", "pst_in_use": "{names} no se puede leer mientras Outlook lo tenga abierto. Cierre Outlook y busque de nuevo.", "description": "Busque correo en este equipo o elija archivos .eml, .zip, .mbox u Outlook .pst.", - "settings_description": "Traiga correo de Thunderbird o de archivos .eml, ZIP, mbox u Outlook .pst." + "settings_description": "Traiga correo de Thunderbird o de archivos .eml, ZIP, mbox u Outlook .pst.", + "resume_refused": "No se pueden leer ahora: {names}. El resto continúa, y estos se intentan de nuevo en el próximo inicio.", + "resume_stop_trying": "Dejar de intentarlo", + "resume_hand_picked": "Elija de nuevo estos archivos para importar el resto: {names}. Los mensajes ya importados se omiten.", + "scan_incomplete": "La búsqueda tardó demasiado y se detuvo. Esto es lo que encontró hasta entonces." }, "recover_deleted": { "tab": "Recuperar elementos eliminados", diff --git a/locales/et/common.json b/locales/et/common.json index 0594ba259..570c14c46 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Import sellest arvutist ei saa jätkuda, sest selle failid on kadunud.", "pst_in_use": "{names} ei saa lugeda, kuni Outlook seda avatuna hoiab. Sulge Outlook ja otsi uuesti.", "description": "Otsi kirju sellest arvutist või vali .eml-, .zip-, .mbox- või Outlooki .pst-failid.", - "settings_description": "Too kirjad Thunderbirdist või .eml-, ZIP-, mbox- või Outlooki .pst-failidest." + "settings_description": "Too kirjad Thunderbirdist või .eml-, ZIP-, mbox- või Outlooki .pst-failidest.", + "resume_refused": "Praegu ei saa lugeda: {names}. Ülejäänu jätkub ja neid proovitakse järgmisel käivitamisel uuesti.", + "resume_stop_trying": "Ära enam proovi", + "resume_hand_picked": "Vali need failid uuesti, et ülejäänu importida: {names}. Juba imporditud kirjad jäetakse vahele.", + "scan_incomplete": "Otsing kestis liiga kaua ja peatati. See on see, mida ta seni leidis." }, "recover_deleted": { "tab": "Taasta kustutatud üksused", diff --git a/locales/fa/common.json b/locales/fa/common.json index fb3ac33f1..dbce54631 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "وارد کردن از این رایانه ادامه نمی‌یابد، چون فایل‌های آن از بین رفته‌اند.", "pst_in_use": "تا وقتی Outlook آن را باز نگه داشته، {names} خوانده نمی‌شود. Outlook را ببندید و دوباره جستجو کنید.", "description": "ایمیل را در این رایانه پیدا کنید، یا فایل‌های ‎.eml، ‎.zip، ‎.mbox یا Outlook ‎.pst را انتخاب کنید.", - "settings_description": "ایمیل را از Thunderbird یا از فایل‌های ‎.eml، ZIP، mbox یا Outlook ‎.pst بیاورید." + "settings_description": "ایمیل را از Thunderbird یا از فایل‌های ‎.eml، ZIP، mbox یا Outlook ‎.pst بیاورید.", + "resume_refused": "اکنون خوانده نمی‌شوند: {names}. بقیه ادامه می‌یابد و این‌ها در راه‌اندازی بعدی دوباره امتحان می‌شوند.", + "resume_stop_trying": "دیگر امتحان نشود", + "resume_hand_picked": "برای وارد کردن بقیه، این فایل‌ها را دوباره انتخاب کنید: {names}. پیام‌هایی که وارد شده‌اند رد می‌شوند.", + "scan_incomplete": "جستجو بیش از حد طول کشید و متوقف شد. این چیزی است که تا آن زمان پیدا کرد." }, "recover_deleted": { "tab": "بازیابی موارد حذف‌شده", diff --git a/locales/fr/common.json b/locales/fr/common.json index d503568de..2bf5b1698 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "L'import depuis cet ordinateur ne peut pas continuer, car ses fichiers ont disparu.", "pst_in_use": "{names} n'est pas lisible tant qu'Outlook l'a ouvert. Fermez Outlook et relancez la recherche.", "description": "Cherchez le courrier sur cet ordinateur, ou choisissez des fichiers .eml, .zip, .mbox ou Outlook .pst.", - "settings_description": "Importez le courrier de Thunderbird, ou de fichiers .eml, ZIP, mbox ou Outlook .pst." + "settings_description": "Importez le courrier de Thunderbird, ou de fichiers .eml, ZIP, mbox ou Outlook .pst.", + "resume_refused": "Illisibles pour l'instant : {names}. Le reste continue, et ceux-ci seront réessayés au prochain démarrage.", + "resume_stop_trying": "Ne plus essayer", + "resume_hand_picked": "Choisissez de nouveau ces fichiers pour importer le reste : {names}. Les messages déjà importés sont ignorés.", + "scan_incomplete": "La recherche a pris trop de temps et a été arrêtée. Voici ce qu'elle a trouvé jusque-là." }, "recover_deleted": { "tab": "Récupérer des éléments supprimés", diff --git a/locales/he/common.json b/locales/he/common.json index 120c65da1..453a66ae6 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5669,7 +5669,11 @@ "resume_gone": "הייבוא מהמחשב הזה לא יכול להמשיך כי הקבצים שלו נעלמו.", "pst_in_use": "אי אפשר לקרוא את {names} כל עוד הוא פתוח ב-Outlook. סגור את Outlook וחפש שוב.", "description": "מצא דואר במחשב הזה, או בחר קובצי ‎.eml,‏ ‎.zip,‏ ‎.mbox או Outlook ‎.pst.", - "settings_description": "הבא דואר מ-Thunderbird, או מקובצי ‎.eml,‏ ZIP,‏ mbox או Outlook ‎.pst." + "settings_description": "הבא דואר מ-Thunderbird, או מקובצי ‎.eml,‏ ZIP,‏ mbox או Outlook ‎.pst.", + "resume_refused": "לא ניתן לקרוא כרגע: {names}. השאר ממשיך, ואלה ינוסו שוב בהפעלה הבאה.", + "resume_stop_trying": "הפסק לנסות", + "resume_hand_picked": "בחר שוב בקבצים האלה כדי לייבא את השאר: {names}. הודעות שכבר יובאו ידולגו.", + "scan_incomplete": "החיפוש נמשך זמן רב מדי והופסק. זה מה שנמצא עד אז." }, "recover_deleted": { "tab": "שחזור פריטים שנמחקו", diff --git a/locales/hu/common.json b/locales/hu/common.json index c7412429a..b7efe156d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Az importálás erről a számítógépről nem folytatható, mert a fájljai eltűntek.", "pst_in_use": "{names} nem olvasható, amíg az Outlook megnyitva tartja. Zárja be az Outlookot, és keressen újra.", "description": "Keressen leveleket ezen a számítógépen, vagy válasszon .eml, .zip, .mbox vagy Outlook .pst fájlokat.", - "settings_description": "Hozzon át leveleket a Thunderbirdből, vagy .eml, ZIP, mbox vagy Outlook .pst fájlokból." + "settings_description": "Hozzon át leveleket a Thunderbirdből, vagy .eml, ZIP, mbox vagy Outlook .pst fájlokból.", + "resume_refused": "Most nem olvasható: {names}. A többi folytatódik, ezeket a következő indításkor újra megpróbálja.", + "resume_stop_trying": "Ne próbálja többet", + "resume_hand_picked": "Válassza ki újra ezeket a fájlokat a többi importálásához: {names}. A már importált üzenetek kimaradnak.", + "scan_incomplete": "A keresés túl sokáig tartott, ezért leállt. Ezt találta addig." }, "recover_deleted": { "tab": "Törölt elemek visszaállítása", diff --git a/locales/it/common.json b/locales/it/common.json index 16d478cfb..c723ab974 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "L'importazione da questo computer non può continuare perché i suoi file non ci sono più.", "pst_in_use": "{names} non è leggibile finché Outlook lo tiene aperto. Chiudi Outlook e cerca di nuovo.", "description": "Cerca la posta su questo computer, o scegli file .eml, .zip, .mbox o Outlook .pst.", - "settings_description": "Importa la posta da Thunderbird, o da file .eml, ZIP, mbox o Outlook .pst." + "settings_description": "Importa la posta da Thunderbird, o da file .eml, ZIP, mbox o Outlook .pst.", + "resume_refused": "Non leggibili ora: {names}. Il resto continua, e questi verranno riprovati al prossimo avvio.", + "resume_stop_trying": "Non riprovare", + "resume_hand_picked": "Scegli di nuovo questi file per importare il resto: {names}. I messaggi già importati vengono saltati.", + "scan_incomplete": "La ricerca ha richiesto troppo tempo ed è stata interrotta. Ecco cosa ha trovato fin lì." }, "recover_deleted": { "tab": "Recupera elementi eliminati", diff --git a/locales/ja/common.json b/locales/ja/common.json index 593358ecf..f89677148 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "ファイルがなくなったため、このコンピューターからのインポートを続行できません。", "pst_in_use": "{names} は Outlook で開かれている間は読み取れません。Outlook を閉じてもう一度検索してください。", "description": "このコンピューターのメールを探すか、.eml、.zip、.mbox、Outlook .pst ファイルを選択してください。", - "settings_description": "Thunderbird、または .eml、ZIP、mbox、Outlook .pst ファイルからメールを取り込みます。" + "settings_description": "Thunderbird、または .eml、ZIP、mbox、Outlook .pst ファイルからメールを取り込みます。", + "resume_refused": "現在読み取れません: {names}。残りは続行し、これらは次回の起動時にもう一度試します。", + "resume_stop_trying": "試行をやめる", + "resume_hand_picked": "残りをインポートするには、これらのファイルをもう一度選択してください: {names}。インポート済みのメッセージはスキップされます。", + "scan_incomplete": "検索に時間がかかりすぎたため停止しました。それまでに見つかったものです。" }, "recover_deleted": { "tab": "削除済みアイテムの復元", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6e0fc0121..3e081aefa 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "파일이 없어져 이 컴퓨터에서 가져오기를 계속할 수 없습니다.", "pst_in_use": "Outlook에서 열려 있는 동안에는 {names}을(를) 읽을 수 없습니다. Outlook을 닫고 다시 검색하세요.", "description": "이 컴퓨터에서 메일을 찾거나 .eml, .zip, .mbox 또는 Outlook .pst 파일을 선택하세요.", - "settings_description": "Thunderbird 또는 .eml, ZIP, mbox, Outlook .pst 파일에서 메일을 가져옵니다." + "settings_description": "Thunderbird 또는 .eml, ZIP, mbox, Outlook .pst 파일에서 메일을 가져옵니다.", + "resume_refused": "지금은 읽을 수 없습니다: {names}. 나머지는 계속되며, 이 항목은 다음 시작 때 다시 시도합니다.", + "resume_stop_trying": "그만 시도", + "resume_hand_picked": "나머지를 가져오려면 이 파일을 다시 선택하세요: {names}. 이미 가져온 메시지는 건너뜁니다.", + "scan_incomplete": "검색이 너무 오래 걸려 중지되었습니다. 그때까지 찾은 항목입니다." }, "recover_deleted": { "tab": "삭제된 항목 복구", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3f0fe60b6..f428e1595 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "Imports no šī datora nevar turpināties, jo tā faili ir pazuduši.", "pst_in_use": "{names} nevar nolasīt, kamēr tas ir atvērts programmā Outlook. Aizveriet Outlook un meklējiet vēlreiz.", "description": "Meklējiet pastu šajā datorā vai izvēlieties .eml, .zip, .mbox vai Outlook .pst failus.", - "settings_description": "Ievediet pastu no Thunderbird vai no .eml, ZIP, mbox vai Outlook .pst failiem." + "settings_description": "Ievediet pastu no Thunderbird vai no .eml, ZIP, mbox vai Outlook .pst failiem.", + "resume_refused": "Pašlaik nevar nolasīt: {names}. Pārējais turpinās, un šos mēģinās vēlreiz nākamajā startā.", + "resume_stop_trying": "Vairs nemēģināt", + "resume_hand_picked": "Izvēlieties šos failus vēlreiz, lai importētu pārējo: {names}. Jau importētie ziņojumi tiek izlaisti.", + "scan_incomplete": "Meklēšana aizņēma pārāk ilgu laiku un tika apturēta. Lūk, ko tā līdz tam atrada." }, "recover_deleted": { "tab": "Atjaunot dzēstos vienumus", diff --git a/locales/mn/common.json b/locales/mn/common.json index 9a7cfd2fa..8200385f0 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Файлууд нь алга болсон тул энэ компьютерээс импорт үргэлжлэх боломжгүй.", "pst_in_use": "Outlook нээлттэй байхад {names}-г унших боломжгүй. Outlook-ийг хаагаад дахин хайна уу.", "description": "Энэ компьютерээс шуудан хайх эсвэл .eml, .zip, .mbox, Outlook .pst файл сонгоно уу.", - "settings_description": "Шуудангаа Thunderbird эсвэл .eml, ZIP, mbox, Outlook .pst файлаас оруулна уу." + "settings_description": "Шуудангаа Thunderbird эсвэл .eml, ZIP, mbox, Outlook .pst файлаас оруулна уу.", + "resume_refused": "Одоогоор унших боломжгүй: {names}. Үлдсэн нь үргэлжилж, эдгээрийг дараагийн эхлэлд дахин оролдоно.", + "resume_stop_trying": "Оролдохоо болих", + "resume_hand_picked": "Үлдсэнийг импортлохын тулд эдгээр файлыг дахин сонгоно уу: {names}. Импортолсон зурвасууд алгасагдана.", + "scan_incomplete": "Хайлт хэт удаан үргэлжилсэн тул зогссон. Тэр хүртэл олдсон нь энэ." }, "recover_deleted": { "tab": "Устгасан зүйлсийг сэргээх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0f6870287..b827b6d5d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Importen fra denne datamaskinen kan ikke fortsette, fordi filene er borte.", "pst_in_use": "{names} kan ikke leses mens Outlook har filen åpen. Lukk Outlook og søk igjen.", "description": "Finn e-post på denne datamaskinen, eller velg .eml-, .zip-, .mbox- eller Outlook .pst-filer.", - "settings_description": "Hent e-post fra Thunderbird eller fra .eml-, ZIP-, mbox- eller Outlook .pst-filer." + "settings_description": "Hent e-post fra Thunderbird eller fra .eml-, ZIP-, mbox- eller Outlook .pst-filer.", + "resume_refused": "Kan ikke leses akkurat nå: {names}. Resten fortsetter, og disse prøves igjen ved neste start.", + "resume_stop_trying": "Ikke prøv mer", + "resume_hand_picked": "Velg disse filene på nytt for å importere resten: {names}. Meldinger som allerede er importert, hoppes over.", + "scan_incomplete": "Søket tok for lang tid og ble stoppet. Dette er det den fant frem til da." }, "recover_deleted": { "tab": "Gjenopprett slettede elementer", diff --git a/locales/nl/common.json b/locales/nl/common.json index fed19b92f..6903f7fd9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "De import vanaf deze computer kan niet verder, omdat de bestanden weg zijn.", "pst_in_use": "{names} kan niet worden gelezen zolang Outlook het geopend heeft. Sluit Outlook en zoek opnieuw.", "description": "Zoek e-mail op deze computer, of kies .eml-, .zip-, .mbox- of Outlook-.pst-bestanden.", - "settings_description": "Haal e-mail op uit Thunderbird, of uit .eml-, ZIP-, mbox- of Outlook-.pst-bestanden." + "settings_description": "Haal e-mail op uit Thunderbird, of uit .eml-, ZIP-, mbox- of Outlook-.pst-bestanden.", + "resume_refused": "Nu niet leesbaar: {names}. De rest gaat door, en deze worden bij de volgende start opnieuw geprobeerd.", + "resume_stop_trying": "Niet meer proberen", + "resume_hand_picked": "Kies deze bestanden opnieuw om de rest te importeren: {names}. Al geïmporteerde berichten worden overgeslagen.", + "scan_incomplete": "Het zoeken duurde te lang en is gestopt. Dit is wat er tot dan toe is gevonden." }, "recover_deleted": { "tab": "Verwijderde items herstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index f96763ba6..a0b38deec 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "Import z tego komputera nie może być kontynuowany, bo jego pliki zniknęły.", "pst_in_use": "Nie można odczytać {names}, gdy plik jest otwarty w Outlooku. Zamknij Outlooka i wyszukaj ponownie.", "description": "Znajdź pocztę na tym komputerze albo wybierz pliki .eml, .zip, .mbox lub Outlook .pst.", - "settings_description": "Przenieś pocztę z Thunderbirda albo z plików .eml, ZIP, mbox lub Outlook .pst." + "settings_description": "Przenieś pocztę z Thunderbirda albo z plików .eml, ZIP, mbox lub Outlook .pst.", + "resume_refused": "Nie można teraz odczytać: {names}. Reszta idzie dalej, a te zostaną sprawdzone ponownie przy następnym uruchomieniu.", + "resume_stop_trying": "Nie próbuj więcej", + "resume_hand_picked": "Wybierz te pliki ponownie, aby zaimportować resztę: {names}. Zaimportowane już wiadomości zostaną pominięte.", + "scan_incomplete": "Wyszukiwanie trwało zbyt długo i zostało zatrzymane. Oto co znalazło do tego czasu." }, "recover_deleted": { "tab": "Odzyskaj usunięte elementy", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0f67ac70c..53f896361 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "A importação deste computador não pode continuar porque os arquivos sumiram.", "pst_in_use": "{names} não pode ser lido enquanto o Outlook o mantiver aberto. Feche o Outlook e procure novamente.", "description": "Procure e-mails neste computador ou escolha arquivos .eml, .zip, .mbox ou Outlook .pst.", - "settings_description": "Traga e-mails do Thunderbird ou de arquivos .eml, ZIP, mbox ou Outlook .pst." + "settings_description": "Traga e-mails do Thunderbird ou de arquivos .eml, ZIP, mbox ou Outlook .pst.", + "resume_refused": "Não podem ser lidos agora: {names}. O resto continua, e estes são tentados novamente no próximo início.", + "resume_stop_trying": "Parar de tentar", + "resume_hand_picked": "Escolha estes arquivos novamente para importar o resto: {names}. As mensagens já importadas são ignoradas.", + "scan_incomplete": "A procura demorou demais e foi interrompida. Isto é o que ela encontrou até então." }, "recover_deleted": { "tab": "Recuperar itens excluídos", diff --git a/locales/ro/common.json b/locales/ro/common.json index e39d63efa..8156056ed 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Importul de pe acest computer nu poate continua, pentru că fișierele lui au dispărut.", "pst_in_use": "{names} nu poate fi citit cât timp este deschis în Outlook. Închideți Outlook și căutați din nou.", "description": "Găsiți e-mailuri pe acest computer sau alegeți fișiere .eml, .zip, .mbox ori Outlook .pst.", - "settings_description": "Aduceți e-mailuri din Thunderbird sau din fișiere .eml, ZIP, mbox ori Outlook .pst." + "settings_description": "Aduceți e-mailuri din Thunderbird sau din fișiere .eml, ZIP, mbox ori Outlook .pst.", + "resume_refused": "Nu pot fi citite acum: {names}. Restul continuă, iar acestea sunt încercate din nou la următoarea pornire.", + "resume_stop_trying": "Nu mai încerca", + "resume_hand_picked": "Alegeți din nou aceste fișiere pentru a importa restul: {names}. Mesajele deja importate sunt omise.", + "scan_incomplete": "Căutarea a durat prea mult și a fost oprită. Iată ce a găsit până atunci." }, "recover_deleted": { "tab": "Recuperați elementele șterse", diff --git a/locales/ru/common.json b/locales/ru/common.json index 140777a2e..5eb34fe19 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "Импорт с этого компьютера не может продолжаться, потому что его файлы исчезли.", "pst_in_use": "{names} нельзя прочитать, пока файл открыт в Outlook. Закройте Outlook и выполните поиск ещё раз.", "description": "Найдите почту на этом компьютере или выберите файлы .eml, .zip, .mbox или Outlook .pst.", - "settings_description": "Перенесите почту из Thunderbird или из файлов .eml, ZIP, mbox или Outlook .pst." + "settings_description": "Перенесите почту из Thunderbird или из файлов .eml, ZIP, mbox или Outlook .pst.", + "resume_refused": "Сейчас нельзя прочитать: {names}. Остальное продолжается, а эти будут проверены снова при следующем запуске.", + "resume_stop_trying": "Больше не пытаться", + "resume_hand_picked": "Выберите эти файлы ещё раз, чтобы импортировать остальное: {names}. Уже импортированные письма будут пропущены.", + "scan_incomplete": "Поиск занял слишком много времени и был остановлен. Вот что он успел найти." }, "recover_deleted": { "tab": "Восстановить удалённое", diff --git a/locales/sk/common.json b/locales/sk/common.json index bc275f666..ae87f7a86 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Import z tohto počítača nemôže pokračovať, pretože jeho súbory zmizli.", "pst_in_use": "{names} sa nedá prečítať, kým je súbor otvorený v Outlooku. Zatvorte Outlook a hľadajte znova.", "description": "Nájdite poštu v tomto počítači alebo vyberte súbory .eml, .zip, .mbox či Outlook .pst.", - "settings_description": "Preneste poštu z Thunderbirdu alebo zo súborov .eml, ZIP, mbox či Outlook .pst." + "settings_description": "Preneste poštu z Thunderbirdu alebo zo súborov .eml, ZIP, mbox či Outlook .pst.", + "resume_refused": "Teraz sa nedajú prečítať: {names}. Zvyšok pokračuje a tieto sa skúsia znova pri ďalšom spustení.", + "resume_stop_trying": "Už neskúšať", + "resume_hand_picked": "Vyberte tieto súbory znova, aby ste importovali zvyšok: {names}. Už importované správy sa preskočia.", + "scan_incomplete": "Hľadanie trvalo príliš dlho a bolo zastavené. Toto našlo dovtedy." }, "recover_deleted": { "tab": "Obnoviť odstránené položky", diff --git a/locales/th/common.json b/locales/th/common.json index 59682c569..fd79575fe 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -544,7 +544,11 @@ "resume_gone": "การนำเข้าจากคอมพิวเตอร์เครื่องนี้ดำเนินต่อไม่ได้ เพราะไฟล์หายไป", "pst_in_use": "อ่าน {names} ไม่ได้ขณะที่ Outlook เปิดไฟล์อยู่ ปิด Outlook แล้วค้นหาอีกครั้ง", "description": "ค้นหาเมลบนคอมพิวเตอร์เครื่องนี้ หรือเลือกไฟล์ .eml, .zip, .mbox หรือ Outlook .pst", - "settings_description": "นำเมลเข้าจาก Thunderbird หรือจากไฟล์ .eml, ZIP, mbox หรือ Outlook .pst" + "settings_description": "นำเมลเข้าจาก Thunderbird หรือจากไฟล์ .eml, ZIP, mbox หรือ Outlook .pst", + "resume_refused": "ยังอ่านไม่ได้ตอนนี้: {names} ส่วนที่เหลือทำต่อไป และจะลองรายการเหล่านี้อีกครั้งเมื่อเริ่มครั้งถัดไป", + "resume_stop_trying": "เลิกลอง", + "resume_hand_picked": "เลือกไฟล์เหล่านี้อีกครั้งเพื่อนำเข้าส่วนที่เหลือ: {names} ข้อความที่นำเข้าแล้วจะถูกข้าม", + "scan_incomplete": "การค้นหาใช้เวลานานเกินไปและถูกหยุด นี่คือสิ่งที่พบจนถึงตอนนั้น" }, "recover_deleted": { "tab": "กู้คืนรายการที่ลบ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 9f87c9f80..874dbf1d4 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "Bu bilgisayardan içe aktarma devam edemiyor, çünkü dosyaları yok.", "pst_in_use": "{names}, Outlook açık tuttuğu sürece okunamaz. Outlook'u kapatıp yeniden arayın.", "description": "Bu bilgisayarda posta bulun veya .eml, .zip, .mbox ya da Outlook .pst dosyaları seçin.", - "settings_description": "Postayı Thunderbird'den veya .eml, ZIP, mbox ya da Outlook .pst dosyalarından getirin." + "settings_description": "Postayı Thunderbird'den veya .eml, ZIP, mbox ya da Outlook .pst dosyalarından getirin.", + "resume_refused": "Şu anda okunamıyor: {names}. Geri kalanı sürer, bunlar bir sonraki başlatmada yeniden denenir.", + "resume_stop_trying": "Denemeyi bırak", + "resume_hand_picked": "Geri kalanını içe aktarmak için bu dosyaları yeniden seçin: {names}. Zaten aktarılan iletiler atlanır.", + "scan_incomplete": "Arama çok uzun sürdü ve durduruldu. O ana kadar bulduğu bunlar." }, "recover_deleted": { "tab": "Silinen öğeleri kurtar", diff --git a/locales/uk/common.json b/locales/uk/common.json index 4d1c083bd..224065dc2 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "Імпорт із цього комп'ютера не може продовжуватися, бо його файли зникли.", "pst_in_use": "{names} не можна прочитати, поки файл відкрито в Outlook. Закрийте Outlook і виконайте пошук ще раз.", "description": "Знайдіть пошту на цьому комп'ютері або виберіть файли .eml, .zip, .mbox чи Outlook .pst.", - "settings_description": "Перенесіть пошту з Thunderbird або з файлів .eml, ZIP, mbox чи Outlook .pst." + "settings_description": "Перенесіть пошту з Thunderbird або з файлів .eml, ZIP, mbox чи Outlook .pst.", + "resume_refused": "Зараз не можна прочитати: {names}. Решта продовжується, а ці буде спробувано знову під час наступного запуску.", + "resume_stop_trying": "Більше не намагатися", + "resume_hand_picked": "Виберіть ці файли ще раз, щоб імпортувати решту: {names}. Уже імпортовані листи буде пропущено.", + "scan_incomplete": "Пошук тривав надто довго й був зупинений. Ось що він устиг знайти." }, "recover_deleted": { "tab": "Відновити видалене", diff --git a/locales/vi/common.json b/locales/vi/common.json index b19fd05db..9445d0147 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5680,7 +5680,11 @@ "resume_gone": "Không thể tiếp tục nhập từ máy tính này vì các tệp đã mất.", "pst_in_use": "Không thể đọc {names} khi Outlook đang mở tệp. Hãy đóng Outlook và tìm lại.", "description": "Tìm thư trên máy tính này, hoặc chọn tệp .eml, .zip, .mbox hay Outlook .pst.", - "settings_description": "Lấy thư từ Thunderbird, hoặc từ tệp .eml, ZIP, mbox hay Outlook .pst." + "settings_description": "Lấy thư từ Thunderbird, hoặc từ tệp .eml, ZIP, mbox hay Outlook .pst.", + "resume_refused": "Hiện không đọc được: {names}. Phần còn lại vẫn tiếp tục, và các mục này sẽ được thử lại ở lần khởi động sau.", + "resume_stop_trying": "Ngừng thử", + "resume_hand_picked": "Hãy chọn lại các tệp này để nhập phần còn lại: {names}. Các thư đã nhập sẽ được bỏ qua.", + "scan_incomplete": "Việc tìm kiếm mất quá lâu và đã dừng. Đây là những gì đã tìm thấy đến lúc đó." }, "recover_deleted": { "tab": "Khôi phục các mục đã xóa", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index df2f49414..fa98dd23a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5668,7 +5668,11 @@ "resume_gone": "這台電腦的匯入無法繼續,因為檔案已不存在。", "pst_in_use": "Outlook 開啟 {names} 時無法讀取。請關閉 Outlook 並重新尋找。", "description": "在這台電腦上尋找郵件,或選擇 .eml、.zip、.mbox 或 Outlook .pst 檔案。", - "settings_description": "從 Thunderbird 或 .eml、ZIP、mbox、Outlook .pst 檔案匯入郵件。" + "settings_description": "從 Thunderbird 或 .eml、ZIP、mbox、Outlook .pst 檔案匯入郵件。", + "resume_refused": "目前無法讀取:{names}。其餘部分繼續進行,這些將在下次啟動時重試。", + "resume_stop_trying": "不再嘗試", + "resume_hand_picked": "請重新選擇這些檔案以匯入其餘部分:{names}。已匯入的郵件會被略過。", + "scan_incomplete": "尋找耗時過長,已停止。這是到那時為止找到的內容。" }, "recover_deleted": { "tab": "復原已刪除的項目", diff --git a/locales/zh/common.json b/locales/zh/common.json index ebac4f2b5..8f2d23482 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5493,7 +5493,11 @@ "resume_gone": "这台电脑的导入无法继续,因为文件已不存在。", "pst_in_use": "Outlook 打开 {names} 时无法读取。请关闭 Outlook 并重新查找。", "description": "在这台电脑上查找邮件,或选择 .eml、.zip、.mbox 或 Outlook .pst 文件。", - "settings_description": "从 Thunderbird 或 .eml、ZIP、mbox、Outlook .pst 文件导入邮件。" + "settings_description": "从 Thunderbird 或 .eml、ZIP、mbox、Outlook .pst 文件导入邮件。", + "resume_refused": "目前无法读取:{names}。其余部分继续进行,这些将在下次启动时重试。", + "resume_stop_trying": "不再尝试", + "resume_hand_picked": "请重新选择这些文件以导入其余部分:{names}。已导入的邮件会被跳过。", + "scan_incomplete": "查找耗时过长,已停止。这是到那时为止找到的内容。" }, "recover_deleted": { "tab": "恢复已删除项目",