mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: stop a local import losing messages on a held file and name what a resume leaves out
Fixes from the review of the desktop import. - A message another program holds is no longer recorded as deleted. Only a real absence moves the import past it; anything else is tried again and then reported as a failure, and the next run reads it. - A resume that can't read some folders names them in a toast with a choice to stop trying, keeps the job open and retries only those at the next start. Files chosen by hand in the same dialog are named, since they can't come back by themselves. - A wipe or sign-out releases the job in the shell before the record goes. Leaving the dialog without starting releases the confirmed files. Job records are per account, and a start is refused while another window holds that account's job. - Dedupe goes by the bytes without state headers: a reused Message-ID of the same length dropped a real message. Older records still count, so nothing goes in twice. - A lost connection on a folder or Email/import pauses instead of counting towards the failures that stop an import. - The taskbar goes through `lib/desktop/taskbar-progress.ts`. Test changes: the taskbar test now renders the host, because its helper in `local-import-job.ts` is gone, and the dedupe tests use `check`, which replaced `keyFor` and `has`.
This commit is contained in:
@@ -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<Parameters<typeof makeScan>[0]['candidates'][number]>) => ({
|
||||
const candidate = (over: Record<string, unknown>) => ({
|
||||
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<Record<string, unknown> & { 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<Record<string, unknown>>; 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<DesktopLocalImportBridge> = {}): 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<string, unknown> = {}) => ({ 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(<LocalImportPanel onPick={() => {}} />);
|
||||
@@ -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(<LocalImportPanel onPick={onPick} />);
|
||||
@@ -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<DesktopLocalImportBridge['scan']>();
|
||||
bridge({ scan });
|
||||
render(<LocalImportPanel onPick={() => {}} />);
|
||||
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(<LocalImportPanel onPick={() => {}} />);
|
||||
@@ -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<Parameters<typeof startLocalImport>[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<DesktopLocalImportBridge['resume']>().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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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<void>((resolve) => { goAway = resolve; }));
|
||||
void request(`bulwark-local-import-job:${SCOPE}`, {}, () => new Promise<void>((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<void>((resolve) => { finish = resolve; }));
|
||||
void request(`bulwark-local-import-job:${SCOPE}`, {}, () => new Promise<void>((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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
const running = (progress: Partial<ImportProgress>) =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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(<MailTransferHost />);
|
||||
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({
|
||||
|
||||
@@ -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<LocalImportJobRecord | null> {
|
||||
return readStoredJob<LocalImportJobRecord>();
|
||||
}
|
||||
|
||||
export function saveLocalImportJob(job: LocalImportJobRecord): Promise<void> {
|
||||
return writeStoredJob(job);
|
||||
}
|
||||
|
||||
export function forgetLocalImportJob(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -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<void> {
|
||||
/** False when another window of the app is importing for this account already: nothing started. */
|
||||
export async function startLocalImport(input: StartLocalImport): Promise<boolean> {
|
||||
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<ResumeOutcome> {
|
||||
export async function resumeLocalImport(): Promise<ResumeResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
const client = useAuthStore.getState().client;
|
||||
@@ -160,11 +183,3 @@ export async function importLocalContacts(vcards: string[]): Promise<number> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3" data-testid="local-import-panel">
|
||||
{nothing && <p role="status" className="text-sm text-muted-foreground" data-testid="local-import-nothing">{t("nothing_found")}</p>}
|
||||
{scan.incomplete && <p role="status" className="text-sm text-foreground" data-testid="local-import-incomplete">{t("scan_incomplete")}</p>}
|
||||
{scan.thunderbirdRunning && <ThunderbirdWarning t={t} />}
|
||||
{!nothing && (
|
||||
<div className="max-h-72 divide-y divide-border overflow-y-auto rounded-md border border-border" aria-label={t("found_list")} data-testid="local-import-list">
|
||||
|
||||
@@ -75,6 +75,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
|
||||
const [skipSeen, setSkipSeen] = useState(true);
|
||||
const [listing, setListing] = useState(false);
|
||||
const [ostChosen, setOstChosen] = useState<string[]>([]);
|
||||
const [lockHeld, setLockHeld] = useState(false);
|
||||
const inDesktopApp = !!getDesktopBridge()?.localImport;
|
||||
const localFiles = useMemo(() => (local?.files ?? []).map(toImportFile), [local]);
|
||||
const [target, setTarget] = useState<string>(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 (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={() => 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={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => close()}>{t("cancel")}</Button>
|
||||
<Button variant="outline" onClick={leaveSetup}>{t("cancel")}</Button>
|
||||
<Button onClick={onStart} disabled={!canStart} data-testid="mail-import-start">{t("start")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="mt-4 space-y-5">
|
||||
{lockHeld && <p role="status" className="text-sm text-destructive" data-testid="local-import-busy">{t("error_busy")}</p>}
|
||||
{inDesktopApp && !local && (
|
||||
<LocalImportPanel
|
||||
onListing={setListing}
|
||||
@@ -425,7 +434,7 @@ function ProgressBody({ progress: p, t, tLocal, locale, statusId }: { progress:
|
||||
<p className="font-medium text-destructive">{t("summary_failed", { count: p.failed })}</p>
|
||||
<ul className="mt-1 list-disc ps-5 text-muted-foreground">
|
||||
{p.failures.map((f, i) => (
|
||||
<li key={i} className="break-words">{f.label || t("untitled")}: {f.reason === "invalid" ? t("failure_invalid") : f.reason}</li>
|
||||
<li key={i} className="break-words">{f.label || t("untitled")}: {f.reason === "invalid" ? t("failure_invalid") : f.reason === LOCAL_FILE_UNAVAILABLE ? tLocal("file_unavailable") : f.reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const phase = job?.progress.phase;
|
||||
|
||||
@@ -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<void>;
|
||||
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<Response> {
|
||||
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<ArrayBuffer> {
|
||||
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<Uint8Array | null> {
|
||||
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<boolean
|
||||
export function toImportFile(file: DesktopLocalImportFile): ImportFile {
|
||||
return { file: new LocalFile(file) as unknown as File, format: file.kind, folder: file.folder };
|
||||
}
|
||||
|
||||
/** What's needed to go on with a job after a restart, one per Bulwark account. The shell holds the paths. */
|
||||
export interface LocalImportJobRecord {
|
||||
id: string;
|
||||
scope: string;
|
||||
accountId: string;
|
||||
targetMailboxId: string | null;
|
||||
keepFolders: boolean;
|
||||
dedupe: boolean;
|
||||
/** Files chosen by hand in the same dialog: no way back to them after a restart, so they get named. */
|
||||
handPicked?: string[];
|
||||
/** The folders still to do after a resume left some out: they're tried at the next start. */
|
||||
retryOnly?: string[];
|
||||
}
|
||||
|
||||
export function readLocalImportJob(scope: string): Promise<LocalImportJobRecord | null> {
|
||||
return readStoredJob<LocalImportJobRecord>(scope);
|
||||
}
|
||||
|
||||
export function saveLocalImportJob(job: LocalImportJobRecord): Promise<void> {
|
||||
return writeStoredJob(job.scope, job);
|
||||
}
|
||||
|
||||
export function forgetLocalImportJob(scope: string): Promise<void> {
|
||||
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<void> {
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
@@ -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/<position>`. */
|
||||
export interface DesktopLocalImportFile {
|
||||
@@ -357,7 +359,8 @@ export type DesktopLocalImportGrantResult =
|
||||
|
||||
export type DesktopLocalImportResumeResult =
|
||||
| Extract<DesktopLocalImportGrantResult, { ok: true }>
|
||||
| { 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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<ImportOptions> & Pick<ImportOptions, 'client' | 'files'>): Promise<ImportProgress> {
|
||||
// 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<ImportOptions> & Pick<ImportOptions, 'client' | 'f
|
||||
resume: false,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => {},
|
||||
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: <fixed@mailer.example.org>\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<typeof real>) => {
|
||||
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 }));
|
||||
|
||||
@@ -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<string, ImportCheckpoint>();
|
||||
|
||||
/** The fallback where IndexedDB isn't available, by key, with the scope as the value. */
|
||||
export const seenInMemory = new Map<string, string>();
|
||||
let jobInMemory: unknown = null;
|
||||
const jobsInMemory = new Map<string, unknown>();
|
||||
|
||||
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<T>(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<T>(): Promise<T | null> {
|
||||
if (!hasIdb()) return (jobInMemory as T | null);
|
||||
export async function readStoredJob<T>(scope: string): Promise<T | null> {
|
||||
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<void> {
|
||||
jobInMemory = value;
|
||||
export async function writeStoredJob(scope: string, value: unknown): Promise<void> {
|
||||
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<void> {
|
||||
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<ImportCheckpoint | null> {
|
||||
let found: ImportCheckpoint | null;
|
||||
if (!hasIdb()) {
|
||||
@@ -185,7 +200,7 @@ export async function clearCheckpointsFor(scope: string): Promise<void> {
|
||||
/** Removes every checkpoint (everyone signed out). */
|
||||
export async function clearAllCheckpoints(): Promise<void> {
|
||||
memory.clear();
|
||||
jobInMemory = null;
|
||||
jobsInMemory.clear();
|
||||
forgetSeenInMemory();
|
||||
if (!hasIdb()) return;
|
||||
const db = dbPromise ? await dbPromise.catch(() => null) : null;
|
||||
|
||||
+53
-33
@@ -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<ImportMessage, "data" | "size">, mailboxId: string): Promise<string>;
|
||||
has(key: string): Promise<boolean>;
|
||||
/** The key to remember this message by, and whether an earlier import already did. */
|
||||
check(item: Pick<ImportMessage, "data" | "size">, mailboxId: string): Promise<{ key: string; seen: boolean }>;
|
||||
add(keys: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -33,37 +35,55 @@ async function sha256(data: BufferSource): Promise<string> {
|
||||
}
|
||||
|
||||
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<boolean> => {
|
||||
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<void> => {
|
||||
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<string> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+30
-14
@@ -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<ImportProgress>
|
||||
}
|
||||
} 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<ImportProgress>
|
||||
const upload = async (item: Extract<ImportItem, { kind: "message" }>, 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<ImportProgress>
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<void> {
|
||||
/** What's the account's own: a deletion after a refused login goes no further than this. */
|
||||
async function scrubAccount(account: string): Promise<void> {
|
||||
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<void> {
|
||||
/** Everything accounts share, or that can't be told apart by account. */
|
||||
async function scrubShared(): Promise<void> {
|
||||
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(() => {});
|
||||
|
||||
@@ -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": "استعادة العناصر المحذوفة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "بازیابی موارد حذفشده",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "שחזור פריטים שנמחקו",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "削除済みアイテムの復元",
|
||||
|
||||
@@ -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": "삭제된 항목 복구",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Устгасан зүйлсийг сэргээх",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Восстановить удалённое",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "กู้คืนรายการที่ลบ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Відновити видалене",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "復原已刪除的項目",
|
||||
|
||||
@@ -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": "恢复已删除项目",
|
||||
|
||||
Reference in New Issue
Block a user