Merge branch 'feat/do-import' into feat/desktop-offline

# Conflicts:
#	lib/desktop/types.ts
This commit is contained in:
Linus Rath
2026-11-03 19:44:43 +01:00
54 changed files with 3909 additions and 71 deletions
@@ -0,0 +1,494 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
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, 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', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key} ${JSON.stringify(values)}` : key),
useLocale: () => 'en',
}));
vi.mock('../start-import', () => ({ startImport: vi.fn() }));
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>>; 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 };
}
function bridge(over: Partial<DesktopLocalImportBridge> = {}): DesktopLocalImportBridge {
const localImport: DesktopLocalImportBridge = {
scan: vi.fn(),
grant: vi.fn(),
resume: vi.fn(),
release: vi.fn(async () => {}),
releaseAll: vi.fn(async () => {}),
...over,
};
(window as unknown as { bulwarkDesktop: BulwarkDesktopBridge }).bulwarkDesktop = { version: 1, platform: 'win32', localImport };
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(SCOPE);
await forgetLocalImportJob('someone-else');
vi.mocked(startImport).mockReset();
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={() => {}} />);
expect(container.firstChild).toBeNull();
});
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 () => granted()),
});
const onPick = vi.fn();
render(<LocalImportPanel onPick={onPick} />);
expect(localImport.scan).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('local-import-find'));
await screen.findByTestId('local-import-list');
expect(screen.getByText('Archives / 2020')).toBeTruthy();
expect(localImport.grant).not.toHaveBeenCalled();
expect(onPick).not.toHaveBeenCalled();
// Unticking one leaves it out of what's asked for.
fireEvent.click(screen.getAllByTestId('local-import-item')[1]);
fireEvent.click(screen.getByTestId('local-import-confirm'));
await waitFor(() => expect(onPick).toHaveBeenCalled());
expect(localImport.grant).toHaveBeenCalledWith(['a']);
expect(onPick.mock.calls[0][0]).toMatchObject({ jobId: 'job', files: [grantedFile] });
});
it('says so when the user declined at either question, and reads nothing', async () => {
const localImport = bridge({ scan: vi.fn(async () => ({ ok: false as const, reason: 'declined' as const })) });
const onPick = vi.fn();
render(<LocalImportPanel onPick={onPick} />);
fireEvent.click(screen.getByTestId('local-import-find'));
expect((await screen.findByTestId('local-import-note')).textContent).toBe('scan_declined');
expect(localImport.grant).not.toHaveBeenCalled();
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={() => {}} />);
fireEvent.click(screen.getByTestId('local-import-find'));
expect((await screen.findByTestId('local-import-ost')).textContent).toContain('ost_refused');
expect(screen.getByTestId('local-import-ost').textContent).toContain('me.ost');
expect(screen.queryAllByTestId('local-import-item')).toHaveLength(0);
expect(screen.getByTestId('local-import-nothing')).toBeTruthy();
expect(screen.queryByTestId('local-import-confirm')).toBeNull();
});
it('says a PST held by Outlook needs Outlook closed, apart from the .ost note', async () => {
bridge({ scan: vi.fn(async () => makeScan({ candidates: [], refused: [{ name: 'Open.pst', reason: 'in-use' }] })) as never });
render(<LocalImportPanel onPick={() => {}} />);
fireEvent.click(screen.getByTestId('local-import-find'));
expect((await screen.findByTestId('local-import-in-use')).textContent).toContain('pst_in_use');
expect(screen.getByTestId('local-import-in-use').textContent).toContain('Open.pst');
expect(screen.queryByTestId('local-import-ost')).toBeNull();
});
it('warns when Thunderbird is running, and says calendars stay out', async () => {
bridge({ scan: vi.fn(async () => makeScan({ candidates: [candidate({ handle: 'a' })], thunderbirdRunning: true, calendars: 1 })) as never });
render(<LocalImportPanel onPick={() => {}} />);
fireEvent.click(screen.getByTestId('local-import-find'));
expect((await screen.findByTestId('local-import-running')).textContent).toContain('thunderbird_running');
expect(screen.getByTestId('local-import-calendars')).toBeTruthy();
});
it('says nothing about hours when a selection is well under the limit', async () => {
bridge({ scan: vi.fn(async () => makeScan({ candidates: [candidate({ handle: 'a', size: 1_000_000 })] })) as never });
render(<LocalImportPanel onPick={() => {}} />);
fireEvent.click(screen.getByTestId('local-import-find'));
await screen.findByTestId('local-import-list');
expect(screen.queryByTestId('local-import-estimate')).toBeNull();
});
it('gives an honest estimate of the selection, and Trash and Junk start unticked', async () => {
bridge({
scan: vi.fn(async () => makeScan({
candidates: [
candidate({ handle: 'a', size: 5_000_000_000 }),
candidate({ handle: 'trash', folder: ['Trash'], name: 'x/Trash', size: 9_000_000_000 }),
candidate({ handle: 'junk', folder: ['Junk'], name: 'x/Junk', size: 9_000_000_000 }),
],
})) as never,
});
render(<LocalImportPanel onPick={() => {}} />);
fireEvent.click(screen.getByTestId('local-import-find'));
const estimate = await screen.findByTestId('local-import-estimate');
// 5 GB at 50 MB an hour is 100 hours: 5 days, and only the ticked one counts.
expect(estimate.textContent).toContain('duration_days');
expect(estimate.textContent).toContain('{\\"count\\":5}');
const boxes = screen.getAllByTestId('local-import-item') as HTMLInputElement[];
expect(boxes.map((b) => b.checked)).toEqual([true, false, false]);
});
});
describe('an import that goes on after a restart', () => {
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();
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);
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(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 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 () => 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 });
expect(input.files[0]).toMatchObject({ format: 'mbox', folder: ['Inbox'] });
// A dialog to come back to from the chip, hidden.
expect(useMailTransferStore.getState().request).toEqual({ kind: 'import' });
expect(useMailTransferStore.getState().visible).toBe(false);
ended('done');
});
it('"not now" keeps the job, "stop" and missing files drop it, and another account leaves it alone', async () => {
await saveLocalImportJob(job);
const resume = vi.fn<DesktopLocalImportBridge['resume']>();
bridge({ resume });
resume.mockResolvedValueOnce({ ok: false, reason: 'later' });
expect((await resumeLocalImport()).outcome).toBe('later');
expect(await readLocalImportJob(SCOPE)).not.toBeNull();
useAuthStore.setState({ activeAccountId: 'someone-else' });
resume.mockClear();
expect((await resumeLocalImport()).outcome).toBe('none');
expect(resume).not.toHaveBeenCalled();
useAuthStore.setState({ activeAccountId: SCOPE });
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()).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() {
const held = new Map<string, Array<() => void>>();
const request = (name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => unknown) => {
const queue = held.get(name) ?? [];
held.set(name, queue);
const run = () => Promise.resolve(callback({ name })).finally(() => {
queue.shift();
queue[0]?.();
});
if (queue.length && options.ifAvailable) return Promise.resolve(callback(null));
return new Promise((resolve, reject) => {
queue.push(() => { run().then(resolve, reject); });
if (queue.length === 1) queue[0]();
});
};
vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { locks: { request } }));
return request;
}
afterEach(() => vi.unstubAllGlobals());
it('waits, and takes over when that window goes away', async () => {
const request = fakeLocks();
await saveLocalImportJob(job);
const localImport = bridge({ resume: vi.fn(async () => granted()) });
let goAway: () => void = () => {};
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).outcome).toBe('resumed');
expect(localImport.resume).toHaveBeenCalledWith('job');
ended('done');
});
it('does nothing once it has the lock if the job ended meanwhile', async () => {
const request = fakeLocks();
await saveLocalImportJob(job);
const localImport = bridge();
let finish: () => void = () => {};
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(SCOPE);
finish();
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 () => 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(SCOPE)).not.toBeNull();
// A job on it would stay on it for good.
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());
ended('done');
});
it('nothing happens in a browser, or with a job already running', async () => {
await saveLocalImportJob(job);
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()).outcome).toBe('none');
expect(localImport.resume).not.toHaveBeenCalled();
});
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(SCOPE)).not.toBeNull();
await forgetLocalImportFor(SCOPE);
expect(await readLocalImportJob(SCOPE)).toBeNull();
expect(await readLocalImportJob('someone-else')).not.toBeNull();
expect(localImport.release).toHaveBeenCalledWith('job');
await forgetLocalImportFor('all');
expect(await readLocalImportJob('someone-else')).toBeNull();
expect(localImport.releaseAll).toHaveBeenCalled();
});
});
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 };
running({ phase: 'running', fraction: 0.5 });
expect(setProgress).toHaveBeenLastCalledWith(0.5, 'normal');
running({ phase: 'paused', fraction: 0.6 });
expect(setProgress).toHaveBeenLastCalledWith(0.6, 'paused');
running({ phase: 'done', fraction: 1 });
expect(setProgress.mock.lastCall?.[0]).toBeNull();
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
import { useMailTransferStore, openMailImport } from '@/stores/mail-transfer-store';
@@ -39,6 +39,123 @@ function pick(files: File[]) {
}
describe('MailImportDialog', () => {
it('in a browser an .ost is still taken, as before', async () => {
render(<MailTransferHost />);
act(() => openMailImport());
expect((screen.getByTestId('mail-import-file') as HTMLInputElement).accept).toContain('.ost');
pick([new File(['!BDN'], 'mine.ost')]);
await screen.findByText('mine.ost');
expect(screen.queryByTestId('local-import-ost-chosen')).toBeNull();
expect(screen.queryByTestId('local-import-panel')).toBeNull();
});
describe('in the desktop app', () => {
const appBridge = (localImport: Record<string, unknown>) => {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', localImport };
};
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
it('turns an .ost down with the reason, by its name or its header, and keeps the others', async () => {
appBridge({ scan: vi.fn() });
render(<MailTransferHost />);
act(() => openMailImport());
expect((screen.getByTestId('mail-import-file') as HTMLInputElement).accept).not.toContain('.ost');
pick([
new File(['!BDN'], 'mine.ost'),
new File(['!BDN\u0000\u0000\u0000\u0000SOrest'], 'renamed.pst'),
new File(['From a Tue May 5 10:11:12 2020\nSubject: x\n\nb\n'], 'keep.mbox'),
]);
await screen.findByText('keep.mbox');
// The mock gives the key: the names themselves show in the screenshot and the e2e run.
expect(screen.getByTestId('local-import-ost-chosen').textContent).toBe('ost_refused');
expect(screen.queryByText('mine.ost')).toBeNull();
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({
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, contacts: [], refused: [], thunderbirdRunning: false })),
release: vi.fn(async () => {}),
});
render(<MailTransferHost />);
act(() => openMailImport());
fireEvent.click(screen.getByTestId('local-import-find'));
await screen.findByTestId('local-import-list');
expect(screen.getByTestId('mail-import-target').closest('.hidden')).not.toBeNull();
expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByTestId('local-import-confirm'));
await screen.findByTestId('local-import-picked');
expect(screen.getByTestId('mail-import-target').closest('.hidden')).toBeNull();
// Thunderbird's own folders keep their place: the top level, recreating them.
expect((screen.getByTestId('mail-import-target') as HTMLSelectElement).value).toBe('\u0000top');
await waitFor(() => expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('mail-import-start'));
await waitFor(() => expect(startImport).toHaveBeenCalled());
expect(startImport).toHaveBeenCalledWith(expect.objectContaining({
accountId: 'acc1', targetMailboxId: null, keepFolders: true, dedupe: true,
files: [expect.objectContaining({ format: 'mbox', folder: ['Inbox'] })],
}));
});
});
it('imports into a shared folder in that folder\'s own account', async () => {
render(<MailTransferHost />);
act(() => openMailImport({ mailboxId: 'acc2:shared' }));
@@ -0,0 +1,185 @@
import { getDesktopBridge } from "@/lib/desktop/bridge";
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";
import { useContactStore } from "@/stores/contact-store";
import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store";
import { detectDuplicates, parseVCard } from "@/lib/vcard";
import { startImport } from "./start-import";
/**
* A desktop import of local data, which can take days under a server's upload limit: what
* it needs to go on after a restart stays with the checkpoints, and the shell holds the
* 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 account's job. */
const jobLock = (scope: string) => `bulwark-local-import-job:${scope}`;
let releaseLock: (() => void) | null = null;
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;
// 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> {
// Before the first message: what's imported from here on can come back.
await saveLocalImportJob(job);
startImport({
files,
accountId: job.accountId,
targetMailboxId: job.targetMailboxId,
keepFolders: job.keepFolders,
resume,
dedupe: job.dedupe,
onEnd: (result) => void ended(job, result),
});
}
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;
resume: boolean;
dedupe: boolean;
}
/** 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 = lock;
await run(
{
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. */
export function isDeviceOnlyClient(client: unknown): boolean {
return !!(client as { isOfflineOnly?: () => boolean } | null)?.isOfflineOnly?.();
}
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
* in again. The shell's dialog decides: continue, not now, or stop. Not in pop-out
* windows, which come and go.
*
* It waits for the job's lock rather than giving up on it. The window that has the job
* can go at any moment: the shell rebuilds a window once while it starts, and a settings
* 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<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;
const scope = auth.activeAccountId ?? "default";
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(scope);
const now = useAuthStore.getState();
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) {
// 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(next, files.map((file) => toImportFile(file)), true);
return { outcome: "resumed", thunderbirdRunning: result.thunderbirdRunning, refused, handPicked };
} finally {
if (!keepLock) lock();
}
}
/** "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;
}
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;
const store = useContactStore.getState();
if (!client || !client.supportsContacts() || !store.supportsSync || vcards.length === 0) return 0;
const parsed = parseVCard(vcards.join("\r\n"));
const duplicates = detectDuplicates(store.contacts, parsed);
return store.importContacts(client, parsed.filter((_, index) => !duplicates.has(index)));
}
@@ -0,0 +1,245 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Search } from "@/components/icons";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopLocalImportBook, DesktopLocalImportCandidate, DesktopLocalImportFile, DesktopLocalImportScanResult } from "@/lib/desktop/types";
import { roleFor } from "@/lib/mail-import/engine";
import { durationWords, importHours, WORTH_SAYING_HOURS, type DurationWords } from "@/lib/mail-import/estimate";
import { formatFileSize } from "@/lib/utils";
type Scan = Extract<DesktopLocalImportScanResult, { ok: true }>;
export interface LocalPick {
jobId: string;
files: DesktopLocalImportFile[];
contacts: string[];
refused: string[];
thunderbirdRunning: boolean;
}
type T = ReturnType<typeof useTranslations>;
export function durationText(t: T, words: DurationWords): string {
return t(`duration_${words.unit}`, { count: words.count });
}
/** Trash and Junk start unticked: the upload limit is better spent on mail someone kept. */
function startsTicked(candidate: DesktopLocalImportCandidate): boolean {
const role = candidate.folder.length === 1 ? roleFor(candidate.folder[0]) : undefined;
return role !== "trash" && role !== "junk";
}
/** Inbox first, then by path, so a profile reads the way Thunderbird shows it. */
function byFolder(a: DesktopLocalImportCandidate, b: DesktopLocalImportCandidate): number {
const rank = (c: DesktopLocalImportCandidate) => (c.folder.length === 1 && roleFor(c.folder[0]) === "inbox" ? 0 : 1);
return rank(a) - rank(b) || a.folder.join("/").localeCompare(b.folder.join("/")) || a.name.localeCompare(b.name);
}
function ThunderbirdWarning({ t }: { t: T }) {
return (
<p role="alert" className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-foreground" data-testid="local-import-running">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden="true" />
<span>{t("thunderbird_running")}</span>
</p>
);
}
function GroupList({
title, candidates, ticked, onToggle, onToggleAll, t,
}: {
title: string;
candidates: DesktopLocalImportCandidate[];
ticked: Set<string>;
onToggle: (handle: string) => void;
onToggleAll: (handles: string[], on: boolean) => void;
t: T;
}) {
const handles = candidates.map((c) => c.handle);
const on = handles.filter((h) => ticked.has(h)).length;
return (
<div>
<label className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-foreground">
<input
type="checkbox"
className="rounded border-border"
checked={on === handles.length}
ref={(el) => { if (el) el.indeterminate = on > 0 && on < handles.length; }}
onChange={(e) => onToggleAll(handles, e.target.checked)}
/>
<span className="min-w-0 flex-1 truncate">{title}</span>
</label>
<ul className="border-t border-border">
{candidates.map((c) => {
const label = c.kind === "pst" ? c.name.replace(/^Outlook\//, "") : c.folder.join(" / ");
return (
<li key={c.handle}>
<label className="flex items-center gap-2 py-1.5 pe-3 ps-8 text-sm text-foreground hover:bg-muted/50">
<input type="checkbox" className="rounded border-border" checked={ticked.has(c.handle)} onChange={() => onToggle(c.handle)} data-testid="local-import-item" />
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{c.messages !== undefined && <>{t("message_count", { count: c.messages })} · </>}
{formatFileSize(c.size)}
</span>
</label>
</li>
);
})}
</ul>
</div>
);
}
/**
* The desktop app's "find it for me": the shell asks natively before it looks, the user
* ticks what to bring, and the shell asks again with the real paths before anything is
* read. Handed up as files for the import dialog's ordinary run.
*/
export function LocalImportPanel({ onPick, onListing }: {
onPick: (pick: LocalPick) => void;
/** Whether the list is open: the rest of the dialog waits until it's answered. */
onListing?: (listing: boolean) => void;
}) {
const t = useTranslations("desktopImport");
const bridge = getDesktopBridge()?.localImport;
const [scan, setScan] = useState<Scan | null>(null);
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
const groups = useMemo(() => {
const out = new Map<string, DesktopLocalImportCandidate[]>();
for (const c of scan?.candidates ?? []) {
const key = c.app === "outlook" ? t("outlook_files") : `${t("thunderbird_profile", { name: c.group })}${c.account ? ` · ${c.account}` : ""}`;
out.set(key, [...(out.get(key) ?? []), c]);
}
for (const list of out.values()) list.sort(byFolder);
return [...out];
}, [scan, t]);
const selected = (scan?.candidates ?? []).filter((c) => ticked.has(c.handle));
const selectedBooks = (scan?.books ?? []).filter((b) => ticked.has(b.handle));
const selectedBytes = selected.reduce((n, c) => n + c.size, 0);
const knownMessages = selected.reduce((n, c) => n + (c.messages ?? 0), 0);
if (!bridge) return null;
const find = async () => {
setBusy(true);
setNote(null);
try {
const result = await bridge.scan();
if (!result.ok) {
setNote(result.reason === "declined" ? t("scan_declined") : result.reason === "failed" ? t("scan_failed") : null);
return;
}
setScan(result);
onListing?.(true);
setTicked(new Set([...result.candidates.filter(startsTicked).map((c) => c.handle), ...result.books.map((b) => b.handle)]));
} catch {
setNote(t("scan_failed"));
} finally {
setBusy(false);
}
};
const confirm = async () => {
setBusy(true);
setNote(null);
try {
const result = await bridge.grant([...ticked]);
if (!result.ok) {
setNote(result.reason === "declined" ? t("read_declined") : result.reason === "invalid" ? t("read_failed") : null);
return;
}
setScan(null);
onListing?.(false);
onPick({ jobId: result.jobId, files: result.files, contacts: result.contacts, refused: result.refused, thunderbirdRunning: result.thunderbirdRunning });
} catch {
setNote(t("read_failed"));
} finally {
setBusy(false);
}
};
const toggle = (handle: string) => setTicked((prev) => {
const next = new Set(prev);
if (!next.delete(handle)) next.add(handle);
return next;
});
const toggleAll = (handles: string[], on: boolean) => setTicked((prev) => {
const next = new Set(prev);
for (const h of handles) if (on) next.add(h); else next.delete(h);
return next;
});
if (!scan) {
return (
<div data-testid="local-import-panel">
<Button variant="outline" onClick={() => void find()} disabled={busy} data-testid="local-import-find">
<Search className="me-2" aria-hidden="true" />
{t("find")}
</Button>
<p className="mt-1 text-xs text-muted-foreground">{t("find_hint")}</p>
{note && <p role="status" className="mt-2 text-sm text-muted-foreground" data-testid="local-import-note">{note}</p>}
</div>
);
}
const nothing = scan.candidates.length === 0 && scan.books.length === 0;
const ost = scan.refused.filter((r) => r.reason === "ost").map((r) => r.name);
const inUse = scan.refused.filter((r) => r.reason === "in-use").map((r) => r.name);
const hours = importHours({ bytes: selectedBytes, messages: knownMessages });
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">
{groups.map(([title, candidates]) => (
<GroupList key={title} title={title} candidates={candidates} ticked={ticked} onToggle={toggle} onToggleAll={toggleAll} t={t} />
))}
{scan.books.map((book: DesktopLocalImportBook) => (
<label key={book.handle} className="flex items-center gap-2 px-3 py-2 text-sm text-foreground">
<input type="checkbox" className="rounded border-border" checked={ticked.has(book.handle)} onChange={() => toggle(book.handle)} data-testid="local-import-book" />
<span className="min-w-0 flex-1 truncate">{t("address_book", { name: book.group })}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{t("contact_count", { count: book.contacts })}</span>
</label>
))}
</div>
)}
{selected.length > 0 && hours >= WORTH_SAYING_HOURS && (
<p className="text-sm text-foreground" data-testid="local-import-estimate">
{t("estimate", { size: formatFileSize(selectedBytes), time: durationText(t, durationWords(hours)) })}
</p>
)}
{ost.length > 0 && (
<p className="text-sm text-muted-foreground" data-testid="local-import-ost">
{t("ost_refused", { names: ost.join(", ") })}
</p>
)}
{inUse.length > 0 && (
<p className="text-sm text-muted-foreground" data-testid="local-import-in-use">
{t("pst_in_use", { names: inUse.join(", ") })}
</p>
)}
{scan.calendars > 0 && <p className="text-xs text-muted-foreground" data-testid="local-import-calendars">{t("calendars_left_out")}</p>}
{scan.candidates.some((c) => c.kind === "pst") && <p className="text-xs text-muted-foreground">{t("pst_items_left_out")}</p>}
{note && <p role="status" className="text-sm text-muted-foreground" data-testid="local-import-note">{note}</p>}
<div className="flex gap-2">
{!nothing && (
<Button onClick={() => void confirm()} disabled={busy || ticked.size === 0} data-testid="local-import-confirm">
{t("confirm", { count: selected.length + selectedBooks.length })}
</Button>
)}
<Button variant="outline" onClick={() => { setScan(null); setNote(null); onListing?.(false); }} disabled={busy}>{t("back")}</Button>
</div>
</div>
);
}
export { ThunderbirdWarning };
@@ -1,20 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { SettingItem, SettingsSection } from "@/components/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openMailExport, openMailImport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { getDesktopBridge } from "@/lib/desktop/bridge";
/** Settings > Backup & reset > Mail files: bring mail in from files, and save all of it as one file. */
export function MailBackupSettings() {
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
const tLocal = useTranslations("desktopImport");
// After mount: the server's render can't know about the app.
const [inDesktopApp, setInDesktopApp] = useState(false);
useEffect(() => setInDesktopApp(!!getDesktopBridge()?.localImport), []);
// Organisation > Devices > No export: shown as set by the organisation.
const exportOff = useDevicePolicy().noExport;
return (
<SettingsSection id="mail-files" title={tImport("section_title")} description={tImport("section_description")}>
<SettingItem label={tImport("settings_label")} description={tImport("settings_description")}>
<SettingItem label={tImport("settings_label")} description={inDesktopApp ? tLocal("settings_description") : tImport("settings_description")}>
<Button variant="outline" size="sm" onClick={() => openMailImport()} data-testid="settings-mail-import">
{tImport("settings_button")}
</Button>
+135 -21
View File
@@ -5,19 +5,27 @@ import { useLocale, useTranslations } from "next-intl";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { FileArchive, FileText, Upload, X } from "@/components/icons";
import { FileArchive, FileText, Upload, X, FolderOpen } from "@/components/icons";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { isJobActive, useMailTransferStore, type ImportRequest } from "@/stores/mail-transfer-store";
import { checkpointKey, readCheckpoint } from "@/lib/mail-import/checkpoints";
import { hasFolders, sniffFormat, type ImportFormat, type SkipReason } from "@/lib/mail-import/sources";
import type { ImportProgress } from "@/lib/mail-import/engine";
import type { ImportFile, ImportProgress } from "@/lib/mail-import/engine";
import { durationWords, importHours, WORTH_SAYING_HOURS } from "@/lib/mail-import/estimate";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { isOstFile, LOCAL_FILE_UNAVAILABLE, toImportFile } from "@/lib/desktop/local-import";
import type { Mailbox } from "@/lib/jmap/types";
import { formatFileSize, getMailboxPath } from "@/lib/utils";
import { cn, formatFileSize, getMailboxPath } from "@/lib/utils";
import { formatClock, serverMegabytes, TransferBar, useFocusOnStage } from "./transfer-progress";
import { startImport } from "./start-import";
import { importLocalContacts, startLocalImport } from "./local-import-job";
import { durationText, LocalImportPanel, ThunderbirdWarning, type LocalPick } from "./local-import-panel";
import { toast } from "@/stores/toast-store";
const ACCEPT = ".eml,.zip,.mbox,.mbx,.pst,.ost,message/rfc822,application/zip,application/mbox";
/** The desktop app doesn't take .ost files: no real one was ever tested (decision 7). */
const ACCEPT_DESKTOP = ".eml,.zip,.mbox,.mbx,.pst,message/rfc822,application/zip,application/mbox";
const TOP = "\u0000top";
const SKIP_ORDER: SkipReason[] = ["too_large", "deleted", "embedded", "unreadable", "contact", "calendar", "other"];
@@ -33,6 +41,7 @@ function mailboxValue(m: Mailbox): string {
export function MailImportDialog({ request }: { request: ImportRequest }) {
const t = useTranslations("mail_import");
const tLocal = useTranslations("desktopImport");
const locale = useLocale();
const id = useId();
const visible = useMailTransferStore((s) => s.visible);
@@ -61,13 +70,21 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
: targets.find((x) => x.mailbox.role === "inbox" && !x.mailbox.isShared)?.mailbox.id ?? targets[0]?.mailbox.id ?? TOP;
const [picked, setPicked] = useState<Picked[]>([]);
// What the desktop app's search brought: its files are in the checkpoint check through this list too.
const [local, setLocal] = useState<LocalPick | null>(null);
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);
const [keepFolders, setKeepFolders] = useState(true);
const [resume, setResume] = useState(true);
const [resumable, setResumable] = useState(0);
const [checking, setChecking] = useState(false);
const anyFolders = picked.some((p) => p.folders);
const anyFolders = picked.some((p) => p.folders) || localFiles.length > 0;
const targetMailbox = targets.find((x) => x.mailbox.id === target)?.mailbox;
const accountId = targetMailbox?.accountId || client?.getAccountId() || "";
const targetId = target === TOP ? null : targetMailbox ? targetMailbox.originalId || targetMailbox.id : null;
@@ -87,21 +104,27 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
// An earlier import of the same file into the same place that stopped part way.
useEffect(() => {
let current = true;
if (picked.length === 0 || !accountId) { setResumable(0); setChecking(false); return; }
const all = [...picked.map((p) => p.file), ...localFiles.map((f) => f.file)];
if (all.length === 0 || !accountId) { setResumable(0); setChecking(false); return; }
// Import waits for this: starting before it knows would throw the checkpoint away.
setChecking(true);
void Promise.all(picked.map((p) => readCheckpoint(checkpointKey({
scope: activeAccountId ?? "default", accountId, target: targetId, keepFolders: effectiveKeep, file: p.file,
void Promise.all(all.map((file) => readCheckpoint(checkpointKey({
scope: activeAccountId ?? "default", accountId, target: targetId, keepFolders: effectiveKeep, file,
})))).then((found) => {
if (!current) return;
setResumable(found.reduce((n, c) => n + (c?.done ?? 0) + (c?.ahead?.length ?? 0), 0));
setChecking(false);
}, () => { if (current) setChecking(false); });
return () => { current = false; };
}, [picked, accountId, targetId, effectiveKeep, activeAccountId]);
}, [picked, localFiles, accountId, targetId, effectiveKeep, activeAccountId]);
const addFiles = async (list: FileList | null) => {
const files = Array.from(list ?? []);
let files = Array.from(list ?? []);
if (inDesktopApp) {
const ost = await Promise.all(files.map((file) => isOstFile(file)));
setOstChosen(files.filter((_, i) => ost[i]).map((file) => file.name));
files = files.filter((_, i) => !ost[i]);
}
const next = await Promise.all(files.map(async (file) => {
const format = await sniffFormat(file);
return { file, format, folders: await hasFolders(file, format) };
@@ -116,8 +139,29 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup");
const onStart = () => {
if (picked.length === 0 || !accountId) return;
if ((picked.length === 0 && !local) || !accountId) return;
if (target === TOP && !effectiveKeep) return;
if (local) {
if (local.contacts.length > 0) {
void importLocalContacts(local.contacts).then((count) => {
if (count > 0) toast.success(tLocal("contacts_added", { count }));
}).catch(() => toast.warning(tLocal("contacts_failed")));
}
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({
files: picked.map((p) => ({ file: p.file, format: p.format })),
accountId,
@@ -128,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;
@@ -149,36 +198,84 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
<Button ref={stageButton} onClick={() => close()}>{t("close")}</Button>
)}
>
<ProgressBody progress={progress} t={t} locale={locale} statusId={`${id}-status`} />
<ProgressBody progress={progress} t={t} tLocal={tLocal} locale={locale} statusId={`${id}-status`} />
</Dialog>
);
}
const canStart = picked.length > 0 && !!accountId && !checking && !(target === TOP && !effectiveKeep);
const canStart = (picked.length > 0 || !!local) && !listing && !!accountId && !checking && !(target === TOP && !effectiveKeep);
const localBytes = localFiles.reduce((n, f) => n + f.file.size, 0);
const localHours = importHours({ bytes: localBytes, messages: local?.files.reduce((n, f) => n + (f.entrySizes?.length ?? 0), 0) ?? 0 });
return (
<Dialog
isOpen
onClose={() => close()}
onClose={leaveSetup}
size="full-on-phone"
title={t("title")}
description={t("description")}
description={inDesktopApp ? tLocal("description") : t("description")}
testId="mail-import-dialog"
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}
onPick={(pick) => {
setLocal(pick);
// Thunderbird's Inbox and Sent belong in the account's own, so the top level fits.
if (pick.files.length > 0) setTarget(TOP);
}}
/>
)}
{local && (
<div className="space-y-3" data-testid="local-import-picked">
{local.files.length > 0 && (
<div className="flex items-center gap-3 rounded-md border border-border px-3 py-2 text-sm">
<FolderOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{tLocal("picked", { count: local.files.length })}</span>
<span className="block text-xs text-muted-foreground">{formatFileSize(localBytes)}</span>
</span>
<IconButton
label={tLocal("remove_picked")}
onClick={() => {
void getDesktopBridge()?.localImport?.release(local.jobId).catch(() => {});
setLocal(null);
}}
>
<X aria-hidden="true" />
</IconButton>
</div>
)}
{local.contacts.length > 0 && <p className="text-sm text-foreground">{tLocal("contacts_picked", { count: local.contacts.length })}</p>}
{local.refused.length > 0 && <p className="text-sm text-muted-foreground">{tLocal("files_refused", { names: local.refused.join(", ") })}</p>}
{local.thunderbirdRunning && <ThunderbirdWarning t={tLocal} />}
{localHours >= WORTH_SAYING_HOURS && (
<p className="text-sm text-foreground" data-testid="local-import-estimate">
{tLocal("estimate", { size: formatFileSize(localBytes), time: durationText(tLocal, durationWords(localHours)) })}
</p>
)}
<label className="flex items-start gap-2 text-sm text-foreground">
<input type="checkbox" className="mt-0.5 rounded border-border" checked={skipSeen} onChange={(e) => setSkipSeen(e.target.checked)} data-testid="local-import-skip-seen" />
<span>{tLocal("skip_seen")}</span>
</label>
</div>
)}
<div className={cn("space-y-5", listing && "hidden")}>
<div>
<input
ref={fileInput}
type="file"
multiple
accept={ACCEPT}
accept={inDesktopApp ? ACCEPT_DESKTOP : ACCEPT}
className="hidden"
tabIndex={-1}
aria-hidden="true"
@@ -189,6 +286,11 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
<Upload className="me-2" aria-hidden="true" />
{t("choose_files")}
</Button>
{ostChosen.length > 0 && (
<p className="mt-2 text-sm text-muted-foreground" data-testid="local-import-ost-chosen">
{tLocal("ost_refused", { names: ostChosen.join(", ") })}
</p>
)}
{picked.length > 0 && (
<ul className="mt-3 divide-y divide-border rounded-md border border-border" aria-label={t("chosen_files")}>
{picked.map((p, i) => (
@@ -253,8 +355,9 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
</label>
)}
{picked.some((p) => p.format === "pst") && <p className="text-xs text-muted-foreground">{t("pst_note")}</p>}
{(picked.some((p) => p.format === "pst") || local?.files.some((f) => f.kind === "pst")) && <p className="text-xs text-muted-foreground">{t("pst_note")}</p>}
<p className="text-xs text-muted-foreground">{t("quota_note")}</p>
</div>
</div>
</Dialog>
);
@@ -266,7 +369,8 @@ const PST_ERRORS = ["encrypted", "not_pst", "unreadable", "worker"];
const IMPORT_ERRORS = ["over_quota", "forbidden", "failures", "network", "busy"];
/** Why an import stopped, in words; anything else as the server or browser said it. */
function errorText(t: T, error: string): string {
function errorText(t: T, tLocal: T, error: string): string {
if (error === LOCAL_FILE_UNAVAILABLE) return tLocal("file_unavailable");
const pst = /^pst:(\w+):/.exec(error)?.[1];
if (pst && PST_ERRORS.includes(pst)) return t(`pst_error_${pst}`);
const own = /^import:(\w+)$/.exec(error)?.[1];
@@ -281,9 +385,13 @@ function progressTitle(t: T, p: ImportProgress): string {
return t("running_title");
}
function ProgressBody({ progress: p, t, locale, statusId }: { progress: ImportProgress; t: T; locale: string; statusId: string }) {
function ProgressBody({ progress: p, t, tLocal, locale, statusId }: { progress: ImportProgress; t: T; tLocal: T; locale: string; statusId: string }) {
const running = p.phase === "running" || p.phase === "paused";
const skipped = SKIP_ORDER.filter((r) => (p.skipped[r] ?? 0) > 0);
// Only the desktop app imports what can take days, and says so: browsers stay as they were.
const hoursLeft = running && p.totalBytes && getDesktopBridge()
? importHours({ bytes: p.totalBytes * (1 - p.fraction) }, p.quota)
: 0;
let status: string;
if (p.phase === "paused" && p.pausedUntil) {
const time = formatClock(p.pausedUntil, locale);
@@ -304,6 +412,11 @@ function ProgressBody({ progress: p, t, locale, statusId }: { progress: ImportPr
<p id={statusId} role="status" aria-live="polite" className="text-sm text-muted-foreground" data-testid="mail-import-status">
{status}
</p>
{hoursLeft >= WORTH_SAYING_HOURS && (
<p className="text-sm text-muted-foreground" data-testid="local-import-time-left">
{tLocal("time_left", { time: durationText(tLocal, durationWords(hoursLeft)) })}
</p>
)}
{!running && (
<div className="space-y-3 text-sm">
{p.foldersCreated > 0 && <p>{t("summary_folders", { count: p.foldersCreated })}</p>}
@@ -315,19 +428,20 @@ function ProgressBody({ progress: p, t, locale, statusId }: { progress: ImportPr
</ul>
</div>
)}
{(p.skipped.duplicate ?? 0) > 0 && <p className="text-muted-foreground" data-testid="local-import-duplicates">{tLocal("skip_duplicate", { count: p.skipped.duplicate ?? 0 })}</p>}
{p.failed > 0 && (
<div>
<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>
)}
{p.phase === "cancelled" && <p className="text-muted-foreground">{t("cancelled_hint")}</p>}
{p.phase === "done" && (p.retryable ?? 0) > 0 && <p className="text-muted-foreground">{t("retry_hint")}</p>}
{p.phase === "failed" && p.error && <p className="text-destructive break-words">{errorText(t, p.error)}</p>}
{p.phase === "failed" && p.error && <p className="text-destructive break-words">{errorText(t, tLocal, p.error)}</p>}
</div>
)}
</div>
@@ -15,6 +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, 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;
@@ -61,12 +62,14 @@ export function MailTransferHost() {
// goes after the job has stopped, so a late write can't bring it back.
useEffect(() => onBeforeSignOut(async (accountId) => {
await stopJobOf(accountId);
await forgetLocalImportFor(accountId);
if (accountId !== "all") await clearCheckpointsFor(accountId);
}), []);
// A wipe takes the same, and every spool no tab holds. `null` is everything on this device.
useEffect(() => onDeviceWipe(async (account) => {
await stopJobOf(account ?? "all");
await forgetLocalImportFor(account ?? "all");
await sweepPrivateExports({ force: account === null });
}), []);
@@ -78,22 +81,49 @@ 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");
const tExport = useTranslations("mail_export");
const tLocal = useTranslations("desktopImport");
// The desktop app: an import from this computer that a quit, a crash or a restart cut off
// goes on, after the shell asked. Once per account per run: "Not now" means not now.
const client = useAuthStore((s) => s.client);
const asked = useRef<string | null>(null);
useEffect(() => {
// The real client replaces a device-only one after a start, and this runs again then.
if (!client || isDeviceOnlyClient(client)) return;
const scope = activeAccountId ?? "default";
if (asked.current === scope) return;
asked.current = scope;
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]);
const lastPhase = useRef<string | undefined>(undefined);
useEffect(() => {
const phase = job?.progress.phase;
+8 -1
View File
@@ -1,4 +1,5 @@
import { runImport, type ImportFile } from "@/lib/mail-import/engine";
import { localDedupe } from "@/lib/mail-import/dedupe";
import { runImport, type ImportFile, type ImportProgress } from "@/lib/mail-import/engine";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
@@ -12,6 +13,10 @@ export interface StartImportInput {
targetMailboxId: string | null;
keepFolders: boolean;
resume: boolean;
/** Skip what an earlier import put in the same folder (lib/mail-import/dedupe.ts). */
dedupe?: boolean;
/** Called with how the job ended. */
onEnd?: (result: ImportProgress) => void;
}
/** Runs an import as the dialog's job and refreshes the mail lists when it ends. */
@@ -53,8 +58,10 @@ export function startImport(input: StartImportInput): void {
signal: controller.signal,
onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
openPst,
dedupe: input.dedupe ? localDedupe(scope, input.accountId) : undefined,
});
useMailTransferStore.getState().updateProgress(result);
input.onEnd?.(result);
// Show what arrived, if the same account is still the one on screen.
if (useAuthStore.getState().client !== client || result.imported === 0) return;
const email = useEmailStore.getState();
+148
View File
@@ -0,0 +1,148 @@
/**
* What the import needs from a file the desktop app reads from disk. The engine only uses
* name, size, lastModified and slice().arrayBuffer() of an mbox file, so these act as a
* File for it without being one, and no copy of the file ever lands in the browser's
* storage. The bytes come from the shell's own scheme, by range, for files the user
* 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(
private readonly url: string,
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 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);
return bytes;
}
}
export class LocalFile {
readonly name: string;
readonly size: number;
readonly lastModified: number;
readonly type = '';
/** pst-source.ts hands this to the PST worker, which can't take this object. */
readonly rangeUrl: string;
readonly entrySizes: number[];
constructor(
file: DesktopLocalImportFile,
private readonly sleep: Sleep = realSleep,
) {
this.name = file.name;
this.size = file.size;
this.lastModified = file.lastModified;
this.rangeUrl = file.url;
this.entrySizes = file.entrySizes ?? [];
}
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, this.sleep);
}
/** 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 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());
}
}
/** An Outlook .ost, by name or by the header's client magic (SO; a PST has SM), whatever it is called. */
export async function isOstFile(file: Blob & { name?: string }): Promise<boolean> {
if (/\.ost$/i.test(file.name ?? '')) return true;
const head = new Uint8Array(await file.slice(0, 10).arrayBuffer());
const magic = String.fromCharCode(...head);
return magic.startsWith('!BDN') && magic.slice(8, 10) === 'SO';
}
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(() => {});
}
+83
View File
@@ -325,6 +325,88 @@ export interface DesktopAppLockBridge {
onLockRequest: (listener: () => void) => () => void;
}
export type DesktopLocalImportKind = 'mbox' | 'maildir' | 'pst';
/** Something a scan found. The handle is all the page can ask for it by. */
export interface DesktopLocalImportCandidate {
handle: string;
app: 'thunderbird' | 'outlook';
kind: DesktopLocalImportKind;
/** The Thunderbird profile's name, or "Outlook". */
group: string;
account?: string;
/** Where the mail goes: `Archives.sbd/2020` is ["Archives", "2020"]. Empty for a PST, which has its own. */
folder: string[];
name: string;
size: number;
lastModified: number;
/** Maildir only. */
messages?: number;
}
export interface DesktopLocalImportBook {
handle: string;
group: string;
name: string;
contacts: number;
}
export type DesktopLocalImportScanResult =
| {
ok: true;
candidates: DesktopLocalImportCandidate[];
books: DesktopLocalImportBook[];
/** Seen and not offered. */
refused: { name: string; reason: 'ost' | 'in-use' }[];
/** 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' | 'failed' };
/** A file the user confirmed. `url` takes Range requests; a Maildir's messages are at `url/<position>`. */
export interface DesktopLocalImportFile {
url: string;
name: string;
kind: DesktopLocalImportKind;
folder: string[];
size: number;
lastModified: number;
entrySizes?: number[];
}
export type DesktopLocalImportGrantResult =
| {
ok: true;
jobId: string;
files: DesktopLocalImportFile[];
/** Contacts of the confirmed address books, as vCard text. */
contacts: string[];
/** Names that couldn't be read after all. */
refused: string[];
thunderbirdRunning: boolean;
}
| { ok: false; reason: 'declined' | 'busy' | 'invalid'; refused?: string[] };
export type DesktopLocalImportResumeResult =
| Extract<DesktopLocalImportGrantResult, { ok: true }>
/** `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
* user natively in main: looking, reading the confirmed files, going on after a restart.
*/
export interface DesktopLocalImportBridge {
scan: () => Promise<DesktopLocalImportScanResult>;
grant: (handles: string[]) => Promise<DesktopLocalImportGrantResult>;
resume: (jobId: string) => Promise<DesktopLocalImportResumeResult>;
release: (jobId: string) => Promise<void>;
releaseAll: () => Promise<void>;
}
export interface DesktopOsSettings {
/** Closing the main window leaves the app running in the tray. */
closeToTray: boolean;
@@ -504,6 +586,7 @@ export interface BulwarkDesktopBridge {
plugins?: DesktopPluginsBridge;
keystore?: DesktopKeystoreBridge;
appLock?: DesktopAppLockBridge;
localImport?: DesktopLocalImportBridge;
privacy?: DesktopPrivacyBridge;
}
+2 -1
View File
@@ -11974,9 +11974,10 @@ export class JMAPClient implements IJMAPClient {
...(entry.receivedAt ? { receivedAt: entry.receivedAt } : {}),
};
});
// networkOnly: an import has to reach the server. Nothing on this device can answer for it.
const response = await this.request([
['Email/import', { accountId: targetAccountId, emails }, '0'],
], undefined, { transfer: true });
], undefined, { transfer: true, networkOnly: true });
const [name, result] = response.methodResponses?.[0] ?? [];
if (name !== 'Email/import') {
throw jmapError(`Email/import failed: ${result?.description || result?.type || 'unknown'}`, result);
@@ -0,0 +1,98 @@
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
/**
* What survives a kill lives in IndexedDB, which the other tests don't have: they run on
* the fallback in memory. A fresh module here is what a restart of the app sees.
*/
const fresh = async () => {
vi.resetModules();
return { store: await import('@/lib/mail-import/checkpoints'), dedupe: await import('@/lib/mail-import/dedupe') };
};
const checkpoint = (key: string, scope = 'a') => ({ key, scope, fileName: 'f', done: 3, imported: 3, updatedAt: Date.now() });
beforeEach(async () => {
const { store } = await fresh();
await store.clearAllCheckpoints();
});
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();
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();
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, per account, and gone once forgotten', async () => {
const before = await fresh();
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('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'));
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 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('a', { id: 'job' });
await store.clearAllCheckpoints();
expect(await (await fresh()).store.readStoredJob('a')).toBeNull();
});
it('a database from before this version keeps its checkpoints', async () => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('bulwark-mail-import', 1);
request.onupgradeneeded = () => {
request.result.createObjectStore('checkpoints', { keyPath: 'key' }).createIndex('scope', 'scope');
};
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('checkpoints', 'readwrite');
tx.objectStore('checkpoints').put(checkpoint('old'));
tx.oncomplete = () => {
db.close();
resolve();
};
};
request.onerror = () => reject(request.error);
});
const { store, dedupe } = await fresh();
expect(await store.readCheckpoint('old')).not.toBeNull();
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);
});
});
@@ -0,0 +1,615 @@
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, 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';
const message = (name: string, extra = '') =>
`Message-ID: <${name}@example.org>\r\nDate: Tue, 05 May 2020 10:00:00 +0000\r\nSubject: ${name}\r\n${extra}\r\nbody of ${name}\r\n`;
const mbox = (names: string[]) => names.map((name) => `From - Tue May 05 10:00:00 2020\r\n${message(name)}\r\n`).join('');
const file = (content: string, name: string, lastModified = 1000) => new File([content], name, { lastModified });
const box = (id: string, name: string, role?: string): Mailbox => ({
id, name, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
myRights: {} as Mailbox['myRights'],
} as Mailbox);
/** A server that keeps what it was given, where it was told to put it. */
function fakeServer() {
const blobs: string[] = [];
const imported: { text: string; mailboxId: string }[] = [];
const mailboxes = [box('inbox', 'Inbox', 'inbox'), box('sent', 'Sent', 'sent')];
const client = {
getMaxSizeUpload: () => 0,
getCapabilities: () => ({}),
getMaxConcurrentRequests: () => 4,
getAccountId: () => 'acc',
getMailboxes: async () => mailboxes,
createMailbox: async (name: string, parentId?: string) => {
const created = { ...box(`m${mailboxes.length}`, name), parentId };
mailboxes.push(created);
return created;
},
uploadBlob: async (data: Blob) => {
blobs.push(await data.text());
return { blobId: String(blobs.length - 1) };
},
importUploadedEmails: async (entries: UploadedEmailImport[]) => entries.map((entry) => {
imported.push({ text: blobs[Number(entry.blobId)], mailboxId: Object.keys(entry.mailboxIds)[0] });
return { id: `e${imported.length}` };
}),
} as unknown as IJMAPClient;
const subjects = () => imported.map((m) => /Subject: (.*)/.exec(m.text)?.[1]).sort();
return { client, imported, mailboxes, subjects };
}
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,
keepFolders: true,
scope: 'scope-a',
resume: false,
signal: new AbortController().signal,
onProgress: () => {},
now: () => clock,
sleep: async (ms) => { clock += ms; },
...options,
});
}
let scopeCounter = 0;
const freshScope = () => `scope-${++scopeCounter}`;
describe('a file with its folder given from outside', () => {
it('mboxSource keeps the folder it was given, and stays at the top without one', async () => {
const options: SourceOptions = { maxMessageBytes: Infinity, skipBefore: 0 };
const collect = async (folder?: string[]) => {
const out: ImportItem[] = [];
for await (const item of mboxSource(file(mbox(['a']), 'x'), options, folder)) out.push(item);
return out;
};
expect((await collect(['Archives', '2020']))[0]).toMatchObject({ kind: 'message', folder: ['Archives', '2020'] });
expect((await collect())[0]).toMatchObject({ kind: 'message', folder: [] });
});
it('maps folders onto the account: Inbox to its own, a nested one created under its parent', async () => {
const server = fakeServer();
const result = await run({
client: server.client,
files: [
{ file: file(mbox(['one']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] },
{ file: file(mbox(['two']), 'p/Sent'), format: 'mbox', folder: ['Sent'] },
{ file: file(mbox(['three']), 'p/Projects/Alpha'), format: 'mbox', folder: ['Projects', 'Alpha'] },
],
});
expect(result.phase).toBe('done');
const where = Object.fromEntries(server.imported.map((m) => [/Subject: (.*)/.exec(m.text)![1], m.mailboxId]));
expect(where.one).toBe('inbox');
expect(where.two).toBe('sent');
const projects = server.mailboxes.find((m) => m.name === 'Projects')!;
const alpha = server.mailboxes.find((m) => m.name === 'Alpha')!;
expect(where.three).toBe(alpha.id);
expect(alpha.parentId).toBe(projects.id);
});
});
describe('Maildir', () => {
const folder = (messages: (string | null)[]): MaildirFile => {
const bytes = messages.map((m) => (m === null ? null : new TextEncoder().encode(m)));
return {
name: 'p/Box',
size: bytes.reduce((n, b) => n + (b?.length ?? 1), 0),
lastModified: 5,
entrySizes: bytes.map((b) => b?.length ?? 1),
readEntry: async (index) => bytes[index],
};
};
const options: SourceOptions = { maxMessageBytes: Infinity, skipBefore: 0 };
const collect = async (source: MaildirFile, opts = options) => {
const out: ImportItem[] = [];
for await (const item of maildirSource(source, opts, ['Box'])) out.push(item);
return out;
};
it('reads a message per position, drops the envelope line and the state headers, and skips what was deleted', async () => {
const items = await collect(folder([
message('plain', 'X-Mozilla-Status: 0001\r\n'),
`From - Tue May 05 10:00:00 2020\r\n${message('with-envelope', 'X-Mozilla-Status: 0001\r\n')}`,
message('deleted', 'X-Mozilla-Status: 0009\r\n'),
null,
]));
expect(items.map((i) => i.kind)).toEqual(['message', 'message', 'skipped', 'skipped']);
const second = items[1] as Extract<ImportItem, { kind: 'message' }>;
const text = await second.data.text();
expect(text.startsWith('Message-ID:')).toBe(true);
expect(text).not.toContain('X-Mozilla-Status');
expect(second.folder).toEqual(['Box']);
expect(second.receivedAt).toBeDefined();
expect(items[2]).toMatchObject({ reason: 'deleted' });
expect(items[3]).toMatchObject({ reason: 'deleted' });
});
it('resumes by position and skips what is over the limit without reading it', async () => {
const source = folder([message('a'), message('b'), message('c', 'X-Pad: pad\r\n')]);
const read = vi.spyOn(source, 'readEntry');
const items = await collect(source, { maxMessageBytes: source.entrySizes[2] - 1, skipBefore: 1 });
expect(items.map((i) => (i.kind === 'skipped' ? i.reason : 'message'))).toEqual(['resumed', 'message', 'too_large']);
expect(read).toHaveBeenCalledTimes(1);
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'] }] });
expect(result.imported).toBe(2);
expect(server.mailboxes.find((m) => m.name === 'Box')).toBeDefined();
});
});
describe('dedupe across runs', () => {
it('reads the Message-ID, folded or not, and only from the headers', () => {
expect(messageIdOf('Subject: x\r\nMessage-ID: <a@b>\r\n\r\nbody')).toBe('a@b');
expect(messageIdOf('message-id:\r\n <folded@b>\r\n\r\nbody')).toBe('folded@b');
expect(messageIdOf('Subject: x\r\n\r\nMessage-ID: <in-the-body@b>')).toBeNull();
});
it('a compacted file has a new key and starts over: nothing goes in twice', async () => {
const server = fakeServer();
const scope = freshScope();
const dedupe = localDedupe(scope, 'acc');
const before = mbox(['a', 'b', 'c']);
await run({ client: server.client, scope, dedupe, files: [{ file: file(before, 'p/Inbox', 1000), format: 'mbox', folder: ['Inbox'] }] });
expect(server.subjects()).toEqual(['a', 'b', 'c']);
// Thunderbird compacted away b and fetched d: other size, other date, other positions.
const after = mbox(['a', 'c', 'd']);
const second = await run({ client: server.client, scope, dedupe, resume: true, files: [{ file: file(after, 'p/Inbox', 2000), format: 'mbox', folder: ['Inbox'] }] });
expect(server.subjects()).toEqual(['a', 'b', 'c', 'd']);
expect(second.imported).toBe(1);
expect(second.skipped.duplicate).toBe(2);
});
it('without dedupe the same run does import twice, which is why it exists', async () => {
const server = fakeServer();
const scope = freshScope();
await run({ client: server.client, scope, files: [{ file: file(mbox(['a']), 'p/Inbox', 1000), format: 'mbox', folder: ['Inbox'] }] });
await run({ client: server.client, scope, resume: true, files: [{ file: file(mbox(['a']), 'p/Inbox', 2000), format: 'mbox', folder: ['Inbox'] }] });
expect(server.subjects()).toEqual(['a', 'a']);
});
it('the same message in two folders goes into both, and twice in one folder only once', async () => {
const server = fakeServer();
const scope = freshScope();
const result = await run({
client: server.client,
scope,
dedupe: localDedupe(scope, 'acc'),
files: [
{ file: file(mbox(['a', 'a']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] },
{ file: file(mbox(['a']), 'p/Sent'), format: 'mbox', folder: ['Sent'] },
],
});
expect(server.imported.map((m) => m.mailboxId).sort()).toEqual(['inbox', 'sent']);
expect(result.skipped.duplicate).toBe(1);
});
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 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');
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.check(item, 'inbox')).seen).toBe(false);
});
it('stopping mid-way leaves a checkpoint, and the next run neither repeats nor loses a message', async () => {
const server = fakeServer();
const scope = freshScope();
const dedupe = localDedupe(scope, 'acc');
const big = mbox(Array.from({ length: 60 }, (_, i) => `m${i}`));
const theFile = file(big, 'p/Inbox');
const controller = new AbortController();
let uploads = 0;
const upload = server.client.uploadBlob.bind(server.client);
server.client.uploadBlob = (async (data: Blob, options: { signal?: AbortSignal }) => {
if (++uploads === 25) controller.abort();
return upload(data as File, options as never);
}) as typeof server.client.uploadBlob;
const first = await run({ client: server.client, scope, dedupe, signal: controller.signal, files: [{ file: theFile, format: 'mbox', folder: ['Inbox'] }] });
expect(first.phase).toBe('cancelled');
const done = server.imported.length;
expect(done).toBeGreaterThan(0);
expect(done).toBeLessThan(60);
const key = checkpointKey({ scope, accountId: 'acc', target: null, keepFolders: true, file: theFile });
expect(await readCheckpoint(key)).not.toBeNull();
const second = await run({ client: server.client, scope, dedupe, resume: true, files: [{ file: theFile, format: 'mbox', folder: ['Inbox'] }] });
expect(second.phase).toBe('done');
const subjects = server.subjects();
expect(subjects).toHaveLength(60);
expect(new Set(subjects).size).toBe(60);
await deleteCheckpoint(key);
});
});
describe('when the server can not be reached', () => {
const offlineError = () => Object.assign(new Error('Not available offline'), { name: 'OfflineUnavailableError' });
it('never keeps the mail on this device: every upload says so', async () => {
const server = fakeServer();
const upload = vi.spyOn(server.client, 'uploadBlob');
await run({ client: server.client, scope: freshScope(), files: [{ file: file(mbox(['a']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] }] });
expect(upload).toHaveBeenCalledTimes(1);
expect(upload.mock.calls[0][1]).toMatchObject({ noHold: true });
});
it('waits out an offline spell on uploads, without losing a message or using up its tries', async () => {
const server = fakeServer();
const real = server.client.uploadBlob.bind(server.client);
let left = 15;
server.client.uploadBlob = (async (data: File, options: never) => {
if (left-- > 0) throw offlineError();
return real(data, options);
}) as typeof server.client.uploadBlob;
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']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] }],
});
expect(result.phase).toBe('done');
expect(result.failed).toBe(0);
expect(server.subjects()).toEqual(['a', 'b']);
expect(seen.some((p) => p.phase === 'paused')).toBe(true);
});
it('waits out an offline spell on Email/import too: the uploaded messages still go in', async () => {
const server = fakeServer();
const real = server.client.importUploadedEmails!.bind(server.client);
let left = 8;
server.client.importUploadedEmails = (async (entries: UploadedEmailImport[]) => {
if (left-- > 0) throw offlineError();
return real(entries);
}) as typeof server.client.importUploadedEmails;
const result = await run({ client: server.client, scope: freshScope(), files: [{ file: file(mbox(['a']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] }] });
expect(result.failed).toBe(0);
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');
const onLine = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
let naps = 0;
let clock = Date.now();
const result = await run({
client: server.client,
scope: freshScope(),
now: () => clock,
sleep: async (ms) => {
clock += ms;
if (++naps <= 3) expect(create).not.toHaveBeenCalled();
if (naps === 3) onLine.mockReturnValue(true);
},
files: [{ file: file(mbox(['a']), 'p/Folder'), format: 'mbox', folder: ['Folder'] }],
});
onLine.mockRestore();
expect(naps).toBeGreaterThanOrEqual(3);
expect(result.phase).toBe('done');
expect(create).toHaveBeenCalled();
expect(server.subjects()).toEqual(['a']);
});
it('stops at once on a client that can only answer from this device', async () => {
const server = fakeServer();
(server.client as unknown as { isOfflineOnly: () => boolean }).isOfflineOnly = () => true;
const upload = vi.spyOn(server.client, 'uploadBlob');
const result = await run({ client: server.client, scope: freshScope(), files: [{ file: file(mbox(['a']), 'p/Inbox'), format: 'mbox', folder: ['Inbox'] }] });
expect(result).toMatchObject({ phase: 'failed', error: 'import:network' });
expect(upload).not.toHaveBeenCalled();
});
});
describe('a file read from disk through the shell', () => {
const grant = { url: 'bulwark-import://f/token', name: 'p/Inbox', kind: 'mbox' as const, folder: ['Inbox'], size: 10, lastModified: 7 };
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
it('reads like a File to the engine, by range', async () => {
fetchMock.mockResolvedValue(new Response(new Uint8Array(4), { status: 206 }));
const local = new LocalFile(grant);
expect([local.name, local.size, local.lastModified]).toEqual(['p/Inbox', 10, 7]);
const buffer = await local.slice(2, 6).arrayBuffer();
expect(buffer.byteLength).toBe(4);
expect(fetchMock).toHaveBeenCalledWith(grant.url, { headers: { Range: 'bytes=2-5' } });
// An empty slice needs no request.
fetchMock.mockClear();
expect((await local.slice(10, 10).arrayBuffer()).byteLength).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(local.slice(8, 99).size).toBe(2);
});
it('a file that changed, or a short answer, stops with the code the dialog explains', async () => {
const local = new LocalFile(grant);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 }));
await expect(local.slice(0, 4).arrayBuffer()).rejects.toThrow(LOCAL_FILE_UNAVAILABLE);
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(2), { status: 206 }));
await expect(local.slice(0, 4).arrayBuffer()).rejects.toThrow(LOCAL_FILE_UNAVAILABLE);
// The right length with the wrong status is no range of the file either.
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(4), { status: 200 }));
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 }));
expect(new TextDecoder().decode((await local.readEntry(0))!)).toBe('abc');
expect(fetchMock).toHaveBeenCalledWith(`${grant.url}/0`);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }));
expect(await local.readEntry(1)).toBeNull();
fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }));
await expect(local.readEntry(1)).rejects.toThrow(LOCAL_FILE_UNAVAILABLE);
});
it('knows an .ost by its name and by its header, and a PST for what it is', async () => {
const header = (client: string) => new Blob([`!BDN\u0000\u0000\u0000\u0000${client}rest`]);
expect(await isOstFile(new File(['x'], 'mine.OST'))).toBe(true);
expect(await isOstFile(Object.assign(header('SO'), { name: 'renamed.pst' }))).toBe(true);
expect(await isOstFile(Object.assign(header('SM'), { name: 'real.pst' }))).toBe(false);
expect(await isOstFile(new File(['From - x'], 'Inbox'))).toBe(false);
});
it('toImportFile carries the kind and the folder', () => {
expect(toImportFile(grant)).toMatchObject({ format: 'mbox', folder: ['Inbox'] });
});
});
describe('the PST reader on a file behind a range URL', () => {
const data = Uint8Array.from({ length: 200_000 }, (_, i) => i % 251);
function stubXhr(status: (range: string) => number = () => 206) {
const seen: string[] = [];
class FakeXhr {
status = 0;
response: ArrayBuffer | null = null;
responseType = '';
opened: unknown[] = [];
private range = '';
open(...args: unknown[]) { this.opened = args; }
setRequestHeader(name: string, value: string) { if (name === 'Range') this.range = value; }
send() {
expect(this.opened[2]).toBe(false);
seen.push(this.range);
this.status = status(this.range);
const m = /bytes=(\d+)-(\d+)/.exec(this.range)!;
const bytes = data.slice(Number(m[1]), Number(m[2]) + 1);
this.response = this.status === 206 ? bytes.buffer : null;
}
}
vi.stubGlobal('XMLHttpRequest', FakeXhr);
return seen;
}
afterEach(() => vi.unstubAllGlobals());
it('reads through synchronous ranges, a page at a time with its cache', () => {
const seen = stubXhr();
registerPstFile('range.pst', rangeBacking('bulwark-import://f/token', data.length));
const fd = openSync('range.pst');
const out = new Uint8Array(10);
expect(readSync(fd, out, 0, 10, 70_000)).toBe(10);
expect([...out]).toEqual([...data.subarray(70_000, 70_010)]);
// One 64 KB page, and the second read of it comes from the cache.
expect(seen).toEqual(['bytes=65536-131071']);
readSync(fd, out, 0, 10, 70_100);
expect(seen).toHaveLength(1);
});
it('refuses a failed or short answer, so it is never read as data', () => {
stubXhr(() => 409);
registerPstFile('bad.pst', rangeBacking('bulwark-import://f/token', data.length));
expect(() => readSync(openSync('bad.pst'), new Uint8Array(4), 0, 4, 0)).toThrow(/read failed/);
const short = rangeBacking('bulwark-import://f/token', data.length * 2);
stubXhr();
expect(() => short.read(data.length - 10, data.length + 10)).toThrow(/read failed/);
});
});
describe('how long it takes', () => {
it('goes by whichever limit comes first', () => {
expect(importHours({ bytes: 100_000_000 })).toBe(2);
expect(importHours({ bytes: 1_000_000, messages: 3000 })).toBe(3);
expect(importHours({ bytes: 100_000_000 }, { files: 10_000, bytes: 200_000_000 })).toBe(0.5);
});
it('says it in hours, then days, rounded up', () => {
expect(durationWords(2.2)).toEqual({ unit: 'hours', count: 3 });
expect(durationWords(47)).toEqual({ unit: 'hours', count: 47 });
expect(durationWords(49)).toEqual({ unit: 'days', count: 3 });
});
});
+72 -8
View File
@@ -22,6 +22,10 @@ export interface ImportCheckpoint {
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 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;
@@ -37,7 +41,15 @@ export function checkpointKey(parts: {
const memory = new Map<string, ImportCheckpoint>();
function hasIdb(): boolean {
/** The fallback where IndexedDB isn't available, by key, with the scope as the value. */
export const seenInMemory = new Map<string, string>();
const jobsInMemory = new Map<string, unknown>();
function forgetSeenInMemory(scope?: string): void {
for (const [key, value] of seenInMemory) if (!scope || value === scope) seenInMemory.delete(key);
}
export function hasIdb(): boolean {
try {
return typeof indexedDB !== "undefined" && indexedDB !== null;
} catch {
@@ -47,15 +59,19 @@ function hasIdb(): boolean {
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
export function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
const request = indexedDB.open(DB_NAME, 2);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "key" }).createIndex("scope", "scope");
}
if (!db.objectStoreNames.contains(SEEN_STORE)) {
db.createObjectStore(SEEN_STORE, { keyPath: "key" }).createIndex("scope", "scope");
}
if (!db.objectStoreNames.contains(JOB_STORE)) db.createObjectStore(JOB_STORE, { keyPath: "key" });
};
request.onsuccess = () => {
const db = request.result;
@@ -69,7 +85,7 @@ function openDb(): Promise<IDBDatabase> {
return dbPromise;
}
function done<T>(request: IDBRequest<T>): Promise<T> {
export function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
@@ -81,6 +97,49 @@ async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStor
return done(run(db.transaction(STORE, mode).objectStore(STORE)));
}
/**
* 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. One per Bulwark
* account, so one account's import doesn't overwrite another's.
*/
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(scope))) as { value: T } | undefined;
return found?.value ?? null;
} catch {
return inMemory();
}
}
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(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()) {
@@ -123,13 +182,16 @@ export async function deleteCheckpoint(key: string): Promise<void> {
/** Removes the checkpoints of one Bulwark account (it signs out). */
export async function clearCheckpointsFor(scope: string): Promise<void> {
for (const [key, value] of memory) if (value.scope === scope) memory.delete(key);
forgetSeenInMemory(scope);
if (!hasIdb()) return;
try {
const db = await openDb();
const tx = db.transaction(STORE, "readwrite");
const keys = await done(tx.objectStore(STORE).index("scope").getAllKeys(scope));
const store = db.transaction(STORE, "readwrite").objectStore(STORE);
await Promise.all(keys.map((k) => done(store.delete(k))));
for (const name of [STORE, SEEN_STORE]) {
const tx = db.transaction(name, "readwrite");
const keys = await done(tx.objectStore(name).index("scope").getAllKeys(scope));
const store = db.transaction(name, "readwrite").objectStore(name);
await Promise.all(keys.map((k) => done(store.delete(k))));
}
} catch {
/* nothing stored */
}
@@ -138,6 +200,8 @@ export async function clearCheckpointsFor(scope: string): Promise<void> {
/** Removes every checkpoint (everyone signed out). */
export async function clearAllCheckpoints(): Promise<void> {
memory.clear();
jobsInMemory.clear();
forgetSeenInMemory();
if (!hasIdb()) return;
const db = dbPromise ? await dbPromise.catch(() => null) : null;
db?.close();
+89
View File
@@ -0,0 +1,89 @@
import { done, hasIdb, openDb, seenInMemory, SEEN_STORE } from "./checkpoints";
import { withoutStateHeaders } from "./mbox-status";
import type { ImportMessage } from "./sources";
/**
* Remembers what an import put on the server, per folder, so a second run skips it.
* Email/import doesn't skip duplicates and a checkpoint counts positions in one file as
* it was: a running Thunderbird compacts a folder or adds to it, the file gets a new size
* 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 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 {
/** 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>;
}
const HEADER_BYTES = 64 * 1024;
/** The Message-ID of a message's header block, without the brackets, or null. */
export function messageIdOf(headerBlock: string): string | null {
const headers = headerBlock.split(/\r?\n\r?\n/, 1)[0];
const match = /^message-id:[ \t]*(?:\r?\n[ \t]+)?<([^>\r\n]+)>/im.exec(headers);
return match ? match[1].trim() : null;
}
async function sha256(data: BufferSource): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
}
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 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,
};
}
+106 -15
View File
@@ -1,8 +1,10 @@
import type { IJMAPClient, UploadedEmailImport, UploadedEmailImportResult } from "@/lib/jmap/client-interface";
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, mboxSource, zipSource, type ImportFormat, type ImportItem, type ItemSource, type SkipReason, type SourceOptions } from "./sources";
import { emlSource, maildirSource, mboxSource, zipSource, type ImportFormat, type ImportItem, type ItemSource, type MaildirFile, type SkipReason, type SourceOptions } from "./sources";
/**
* One engine for every mail import (EML, ZIP, mbox, PST): it reads messages
@@ -47,11 +49,16 @@ export interface ImportProgress {
error?: string;
/** Messages that failed for a passing reason; importing the same files again tries them again. */
retryable?: number;
/** The size of all files, for what's left of a long import. */
totalBytes?: number;
}
export interface ImportFile {
/** A File, or something that reads like one (lib/desktop/local-import.ts). */
file: File;
format: ImportFormat;
/** Where the file's messages belong, for formats with no folders of their own (a Thunderbird folder). */
folder?: string[];
}
/** Opens a PST source (loaded only when a PST is imported). */
@@ -73,6 +80,11 @@ export interface ImportOptions {
signal: AbortSignal;
onProgress: (progress: ImportProgress) => void;
openPst?: PstOpener;
/**
* Skips what was imported before, whatever the file looks like now. Compacting a
* Thunderbird folder changes its size, so its checkpoint no longer applies.
*/
dedupe?: ImportDedupe;
/** Test seams. */
now?: () => number;
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
@@ -102,6 +114,15 @@ function isAbort(error: unknown): boolean {
return (error as { name?: string } | null)?.name === "AbortError";
}
/** The client knows it can't reach the server right now. That passes: it says nothing about the message. */
function isOffline(error: unknown): boolean {
return (error as { name?: string } | null)?.name === "OfflineUnavailableError";
}
function browserOffline(): boolean {
return typeof navigator !== "undefined" && navigator.onLine === false;
}
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(new ImportCancelled()); return; }
@@ -233,6 +254,7 @@ interface Uploaded {
fileIndex: number;
label: string;
entry: UploadedEmailImport;
seenKey?: string;
}
/**
@@ -324,7 +346,9 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
// Shared pause: when the server refuses uploads, every lane waits.
let pausedUntil = 0;
const waitWhilePaused = async () => {
while (pausedUntil > now()) {
while (pausedUntil > now() || browserOffline()) {
// A laptop closed overnight: look again soon, it costs nothing.
if (pausedUntil <= now()) pausedUntil = now() + NETWORK_PAUSE_MS;
progress.phase = "paused";
progress.pausedUntil = pausedUntil;
report(true);
@@ -339,6 +363,7 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
const sizes = options.files.map((f) => f.file.size);
const totalBytes = sizes.reduce((a, b) => a + b, 0) || 1;
progress.totalBytes = totalBytes;
const fileFraction = options.files.map(() => 0);
const totals: (number | undefined)[] = options.files.map(() => undefined);
const setFraction = () => {
@@ -350,16 +375,24 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
}));
const marks: Watermark[] = [];
const importedPerFile: number[] = options.files.map(() => 0);
// A profile has hundreds of files, and most of them stand still at any time.
const savedAs: string[] = [];
let lastSaved = 0;
const saveCheckpoints = async (force = false) => {
// After sign-out nothing about the account may be written again.
if (options.signal.reason === "signout") return;
if (!force && now() - lastSaved < CHECKPOINT_EVERY_MS) return;
lastSaved = now();
await Promise.all(marks.map((m, i) => m.value > 0 || m.beyond.length > 0 ? writeCheckpoint({
key: keys[i], scope: options.scope, fileName: options.files[i].file.name,
done: m.value, ahead: m.beyond, imported: importedPerFile[i], updatedAt: now(),
}) : Promise.resolve()));
await Promise.all(marks.map((m, i) => {
if (m.value === 0 && m.beyond.length === 0) return Promise.resolve();
const state = `${m.value}:${m.beyond.length}:${importedPerFile[i]}`;
if (savedAs[i] === state) return Promise.resolve();
savedAs[i] = state;
return writeCheckpoint({
key: keys[i], scope: options.scope, fileName: options.files[i].file.name,
done: m.value, ahead: m.beyond, imported: importedPerFile[i], updatedAt: now(),
});
}));
};
// ── Email/import in batches ──
@@ -382,6 +415,14 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
}
} catch (error) {
const name = (error as { name?: string } | null)?.name;
// The messages are uploaded and waiting. The server being out of reach isn't a try.
if (isNetworkFailure(error)) {
attempt--;
pausedUntil = Math.max(pausedUntil, now() + NETWORK_PAUSE_MS);
await waitWhilePaused().catch(() => {});
if (signal.aborted) break;
continue;
}
if (name === "RateLimitError" && attempt < 3) {
await sleep(Math.max(1000, (error as { retryAfterMs?: number }).retryAfterMs ?? 60_000), options.signal).catch(() => {});
if (options.signal.aborted) break;
@@ -396,6 +437,11 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
}
}
if (!results) return;
// Right after the answer, before the checkpoint: this is what a crash can't take back.
if (options.dedupe && options.signal.reason !== "signout") {
const imported = batch.flatMap((b, i) => (results![i] && "id" in results![i] && b.seenKey ? [b.seenKey] : []));
if (imported.length > 0) await options.dedupe.add(imported).catch(() => {});
}
results.forEach((result, i) => {
const item = batch[i];
if ("id" in result) {
@@ -435,15 +481,44 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
// ── Uploads, a few at a time ──
const running = new Set<Promise<void>>();
let networkErrors = 0;
// The same message twice in one run, before the first has been answered.
const inFlight = new Set<string>();
const upload = async (item: Extract<ImportItem, { kind: "message" }>, fileIndex: number) => {
const u = { fileIndex, index: item.index, label: item.label };
let mailboxId: string;
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 {
const { key, seen } = await options.dedupe.check(item, mailboxId);
seenKey = key;
if (seen || inFlight.has(key)) {
skip("duplicate");
handled(u);
return;
}
inFlight.add(seenKey);
} catch {
// Can't tell: a duplicate is the smaller harm, a lost message the larger.
seenKey = undefined;
}
}
const file = new File([item.data], "message.eml", { type: "message/rfc822" });
for (let attempt = 0; ; attempt++) {
@@ -452,17 +527,25 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
// Into the user's own account: Stalwart (0.16 and 1.0) keeps a blob uploaded to a
// shared account's upload URL where Email/import there can't find it, while one in
// the user's own account imports into any account the user may add mail to.
const { blobId } = await client.uploadBlob(file, { accountId: uploadAccountId, signal });
// noHold: offline, uploadBlob would keep the file on this device and answer with a stand-in
// id. That's for an attachment on its way out with a draft. Here it would fill the store with
// the very mail being imported and count it as uploaded.
const { blobId } = await client.uploadBlob(file, { accountId: uploadAccountId, signal, noHold: true });
networkErrors = 0;
enqueue({
index: item.index,
fileIndex,
label: item.label,
seenKey,
entry: { blobId, mailboxIds: { [mailboxId]: true }, keywords: item.keywords, receivedAt: item.receivedAt },
});
return;
} catch (error) {
if (isAbort(error) || signal.aborted) throw new ImportCancelled();
if (isOffline(error)) {
pausedUntil = Math.max(pausedUntil, now() + NETWORK_PAUSE_MS);
continue;
}
const { status, body } = uploadErrorInfo(error);
if (status === 429) {
pausedUntil = Math.max(pausedUntil, uploadWindowEnd(now()) + 5000);
@@ -509,13 +592,16 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
const releases: (() => void)[] = [];
let keepCheckpoints = false;
try {
// A client started from the device's copy of the session never reaches the server. The real
// one replaces it in the app, not in here, so waiting would be waiting for nothing.
if ((client as { isOfflineOnly?: () => boolean }).isOfflineOnly?.()) throw new ImportStopped("network");
for (const key of keys) {
const release = await holdLock(`bulwark-mail-import:${key}`);
if (!release) throw new ImportStopped("busy");
releases.push(release);
}
for (let f = 0; f < options.files.length; f++) {
const { file, format } = options.files[f];
const { file, format, folder } = options.files[f];
const checkpoint = options.resume ? await readCheckpoint(keys[f]) : null;
if (!options.resume) await deleteCheckpoint(keys[f]);
if (checkpoint) { progress.imported += checkpoint.imported; importedPerFile[f] = checkpoint.imported; }
@@ -538,13 +624,18 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
if (format === "pst") {
if (!options.openPst) throw new Error("PST import isn't available");
source = options.openPst(file, sourceOptions);
} else if (format === "mbox") source = mboxSource(file, sourceOptions);
} else if (format === "mbox") source = mboxSource(file, sourceOptions, folder);
else if (format === "maildir") source = maildirSource(file as unknown as MaildirFile, sourceOptions, folder);
else if (format === "zip") source = zipSource(file, sourceOptions);
else source = emlSource(file, sourceOptions);
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);
+27
View File
@@ -0,0 +1,27 @@
/**
* How long an import takes at a server's upload limit. Stalwart's default is 1000
* uploads or 50 MB per user per hour, and each message is an upload. Without a count of
* the messages this goes by bytes, so it is the least the import can take.
*/
export const DEFAULT_UPLOAD_LIMIT = { files: 1000, bytes: 50_000_000 };
export function importHours(
amount: { bytes: number; messages?: number },
limit: { files?: number; bytes?: number } = DEFAULT_UPLOAD_LIMIT,
): number {
const byBytes = amount.bytes / (limit.bytes || DEFAULT_UPLOAD_LIMIT.bytes);
const byCount = (amount.messages ?? 0) / (limit.files || DEFAULT_UPLOAD_LIMIT.files);
return Math.max(byBytes, byCount);
}
export type DurationWords = { unit: "hours" | "days"; count: number };
/** Below this the limit doesn't matter enough to mention: the dialog's general note covers it. */
export const WORTH_SAYING_HOURS = 1;
/** Whole hours up to two days, whole days after that, rounded up. */
export function durationWords(hours: number): DurationWords {
if (hours < 48) return { unit: "hours", count: Math.ceil(hours) };
return { unit: "days", count: Math.ceil(hours / 24) };
}
+16 -1
View File
@@ -6,7 +6,7 @@
*/
interface LockManagerLike {
request(name: string, options: { ifAvailable: boolean }, callback: (lock: unknown) => Promise<void> | void): Promise<unknown>;
request(name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => Promise<void> | void): Promise<unknown>;
}
function lockManager(): LockManagerLike | null {
@@ -14,6 +14,21 @@ function lockManager(): LockManagerLike | null {
return locks && typeof locks.request === "function" ? locks : null;
}
/**
* Waits for the lock for as long as it takes, then holds it until released. The browser
* frees a lock when the page holding it goes, so a window can wait to take over from one
* that closes, reloads or is rebuilt.
*/
export function waitForLock(name: string): Promise<() => void> {
const locks = lockManager();
if (!locks) return Promise.resolve(() => {});
return new Promise((resolve) => {
locks
.request(name, {}, () => new Promise<void>((release) => resolve(release)))
.catch(() => resolve(() => {}));
});
}
export function hasWebLocks(): boolean {
return lockManager() !== null;
}
+4 -1
View File
@@ -76,7 +76,10 @@ export async function* openPstSource(file: File, options: SourceOptions, tuning:
};
try {
worker.postMessage({ type: "open", file, skipBefore: options.skipBefore, skipAlso: [...(options.skipAlso ?? [])], maxMessageBytes: options.maxMessageBytes });
// A file the desktop app reads from disk has no Blob: the worker reads it by range.
const rangeUrl = (file as File & { rangeUrl?: string }).rangeUrl;
const source = rangeUrl ? { range: { url: rangeUrl, size: file.size } } : { file };
worker.postMessage({ type: "open", ...source, skipBefore: options.skipBefore, skipAlso: [...(options.skipAlso ?? [])], maxMessageBytes: options.maxMessageBytes });
const opened = await next();
if (opened.type === "abort") throw new DOMException("Import cancelled", "AbortError");
if (opened.type === "error") throw new PstReadError(opened.code, opened.message);
+40 -7
View File
@@ -2,7 +2,9 @@
* `fs` for pst-extractor inside the PST worker: random access into a File
* without loading it whole. pst-extractor only calls openSync, readSync and
* closeSync. Reads go through FileReaderSync (workers only) in 64 KB pages
* with a small cache, since the library reads a few bytes at a time.
* with a small cache, since the library reads a few bytes at a time. A file
* the desktop app reads from disk has no Blob: there the pages come through a
* synchronous XHR with a Range header, which a worker may still do.
*
* Register the file first: `registerPstFile(name, file)`.
*/
@@ -12,12 +14,43 @@ declare const FileReaderSync: { new (): { readAsArrayBuffer(blob: Blob): ArrayBu
const PAGE = 64 * 1024;
const MAX_PAGES = 128; // 8 MB
const files = new Map<string, Blob>();
const open = new Map<number, { file: Blob; pages: Map<number, Uint8Array> }>();
/** Where the pages come from. */
export interface PstBacking {
size: number;
read(start: number, end: number): Uint8Array;
}
const files = new Map<string, PstBacking>();
const open = new Map<number, { file: PstBacking; pages: Map<number, Uint8Array> }>();
let nextFd = 3;
export function registerPstFile(name: string, file: Blob): void {
files.set(name, file);
function blobBacking(blob: Blob): PstBacking {
return {
size: blob.size,
read: (start, end) => new Uint8Array(new FileReaderSync().readAsArrayBuffer(blob.slice(start, end))),
};
}
/** A file behind a URL that answers Range requests. */
export function rangeBacking(url: string, size: number): PstBacking {
return {
size,
read(start, end) {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, false);
xhr.responseType = "arraybuffer";
xhr.setRequestHeader("Range", `bytes=${start}-${end - 1}`);
xhr.send();
const bytes = xhr.status === 206 ? new Uint8Array(xhr.response as ArrayBuffer) : null;
// A short answer would be read as data further on, so it counts as a failure here.
if (!bytes || bytes.length !== end - start) throw new Error(`read failed: ${xhr.status}`);
return bytes;
},
};
}
export function registerPstFile(name: string, file: Blob | PstBacking): void {
files.set(name, "read" in file ? file : blobBacking(file));
}
export function openSync(name: string): number {
@@ -28,7 +61,7 @@ export function openSync(name: string): number {
return fd;
}
function page(entry: { file: Blob; pages: Map<number, Uint8Array> }, index: number): Uint8Array {
function page(entry: { file: PstBacking; pages: Map<number, Uint8Array> }, index: number): Uint8Array {
let bytes = entry.pages.get(index);
if (bytes) {
// Most recently used goes last.
@@ -37,7 +70,7 @@ function page(entry: { file: Blob; pages: Map<number, Uint8Array> }, index: numb
return bytes;
}
const start = index * PAGE;
bytes = new Uint8Array(new FileReaderSync().readAsArrayBuffer(entry.file.slice(start, Math.min(entry.file.size, start + PAGE))));
bytes = entry.file.read(start, Math.min(entry.file.size, start + PAGE));
entry.pages.set(index, bytes);
if (entry.pages.size > MAX_PAGES) entry.pages.delete(entry.pages.keys().next().value as number);
return bytes;
+4 -4
View File
@@ -13,7 +13,7 @@
import { PSTFile } from "pst-extractor";
import { Buffer } from "buffer";
import { registerPstFile } from "./shims/fs";
import { rangeBacking, registerPstFile } from "./shims/fs";
import { walkPst, type PstItem } from "./items";
interface WorkerScope {
@@ -37,10 +37,10 @@ function errorCode(error: unknown): string {
}
scope.onmessage = (event: MessageEvent) => {
const data = event.data as { type: string; file?: File; skipBefore?: number; skipAlso?: number[]; maxMessageBytes?: number; count?: number };
const data = event.data as { type: string; file?: File; range?: { url: string; size: number }; skipBefore?: number; skipAlso?: number[]; maxMessageBytes?: number; count?: number };
try {
if (data.type === "open" && data.file) {
registerPstFile("import.pst", data.file);
if (data.type === "open" && (data.file || data.range)) {
registerPstFile("import.pst", data.range ? rangeBacking(data.range.url, data.range.size) : data.file!);
pst = new PSTFile("import.pst") as unknown as { close(): void };
const walk = walkPst(pst, { skipBefore: data.skipBefore ?? 0, skipAlso: new Set(data.skipAlso ?? []), maxMessageBytes: data.maxMessageBytes ?? Number.POSITIVE_INFINITY });
items = walk.items;
+84 -5
View File
@@ -10,7 +10,7 @@ import { listZipEntries, readZipEntry, streamZipEntry } from "./zip-reader";
* piece so a large file never has to fit in memory.
*/
export type ImportFormat = "eml" | "zip" | "mbox" | "pst";
export type ImportFormat = "eml" | "zip" | "mbox" | "pst" | "maildir";
export type SkipReason =
/** Larger than the server takes (maxSizeUpload). */
@@ -28,7 +28,9 @@ export type SkipReason =
/** PST items after a broken one in the same folder, which pst-extractor can't reach. */
| "unreadable"
/** Imported by an earlier run of the same import. */
| "resumed";
| "resumed"
/** The same message into the same folder was imported before, by any run. */
| "duplicate";
export interface ImportMessage {
kind: "message";
@@ -54,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. */
@@ -231,9 +241,78 @@ async function* fileChunks(file: Blob, onProgress?: (fraction: number) => void):
}
}
export async function* mboxSource(file: File, options: SourceOptions): ItemSource {
/** `folder`: where a file with no folders of its own belongs, as a Thunderbird folder does. */
export async function* mboxSource(file: File, options: SourceOptions, folder: string[] = []): ItemSource {
// Reads run ahead in 1 MB pieces; the messages handed over say how far the import really is.
yield* mboxFromChunks(fileChunks(file), [], options, 0, () => {}, (offset) => options.onProgress?.(offset / Math.max(1, file.size)));
yield* mboxFromChunks(fileChunks(file), folder, options, 0, () => {}, (offset) => options.onProgress?.(offset / Math.max(1, file.size)));
options.onProgress?.(1);
}
/** A Maildir folder: messages by position, in an order that stays the same between runs. */
export interface MaildirFile {
name: string;
size: number;
lastModified: number;
entrySizes: number[];
/** Null when the message has gone since the folder was listed. */
readEntry(index: number): Promise<Uint8Array | null>;
}
function maildirItem(bytes: Uint8Array, index: number, folder: string[]): ImportItem {
// Thunderbird writes its `From - <date>` line into Maildir messages too.
let body = bytes;
let fromLine = "";
if (String.fromCharCode(...bytes.subarray(0, 5)) === "From ") {
const end = bytes.indexOf(0x0a);
if (end >= 0) {
fromLine = new TextDecoder("latin1").decode(bytes.subarray(0, end)).trim();
body = bytes.subarray(end + 1);
}
}
const headers = readHeaderBlock([body]);
const state = mboxState(headers);
const subject = subjectOf(headers);
if (state.expunged) return { kind: "skipped", index, reason: "deleted", label: subject };
const parts = withoutStateHeaders([body]);
return {
kind: "message",
index,
folder,
data: new Blob(parts as BlobPart[], { type: MIME }),
size: parts.reduce((n, p) => n + p.length, 0),
keywords: state.keywords,
receivedAt: arrivalTime(fromLine, headers),
label: subject,
};
}
export async function* maildirSource(file: MaildirFile, options: SourceOptions, folder: string[] = []): ItemSource {
const count = file.entrySizes.length;
options.onTotal?.(count);
let doneBytes = 0;
for (let index = 0; index < count; index++) {
throwIfAborted(options.signal);
const size = file.entrySizes[index];
if (isResumed(options, index)) {
yield { kind: "skipped", index, reason: "resumed" };
} else if (size > options.maxMessageBytes) {
yield { kind: "skipped", index, reason: "too_large", label: String(index + 1) };
} else {
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));
}
options.onProgress?.(1);
}
+30
View File
@@ -886,6 +886,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());
+4
View File
@@ -91,6 +91,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;
@@ -275,6 +276,7 @@ async function forgetLockIfNobodyStays(leaving: string | null): 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);
@@ -283,6 +285,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(() => {});
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "توقف التصدير.",
"toast_show": "عرض"
},
"desktopImport": {
"find": "البحث عن بيانات Thunderbird وOutlook…",
"find_hint": "يبحث في الأماكن المعتادة على هذا الكمبيوتر. يسألك Bulwark أولاً.",
"scan_declined": "لم يتم البحث لأنك لم تسمح بذلك.",
"scan_failed": "لم ينجح البحث. حاول مرة أخرى.",
"read_declined": "لم تتم قراءة أي ملفات لأنك لم تسمح بذلك.",
"read_failed": "لم يعد من الممكن قراءة هذه الملفات. ابحث مرة أخرى.",
"nothing_found": "لا يوجد ملف تعريف Thunderbird أو ملف بيانات Outlook في الأماكن المعتادة. لاستيراد ملف من مكان آخر، استخدم اختيار الملفات.",
"found_list": "بيانات البريد الموجودة على هذا الكمبيوتر",
"outlook_files": "ملفات بيانات Outlook",
"thunderbird_profile": "ملف تعريف Thunderbird {name}",
"address_book": "دفتر عناوين {name}",
"message_count": "{count, plural, one {رسالة واحدة} two {رسالتان} few {# رسائل} many {# رسالة} other {# رسالة}}",
"contact_count": "{count, plural, one {جهة اتصال واحدة} two {جهتا اتصال} few {# جهات اتصال} many {# جهة اتصال} other {# جهة اتصال}}",
"confirm": "{count, plural, one {استخدام هذا العنصر…} two {استخدام هذين العنصرين…} few {استخدام هذه العناصر (#)…} many {استخدام هذه العناصر (#)…} other {استخدام هذه العناصر (#)…}}",
"back": "رجوع",
"estimate": "تم تحديد {size}. تقبل خوادم كثيرة، منها Stalwart، نحو 1000 رسالة أو 50 ميغابايت فقط في الساعة من مستخدم واحد، لذا يستغرق هذا {time} على الأقل. عند الحد يتوقف الاستيراد مؤقتًا ثم يستمر وحده. يعمل في الخلفية ويستمر بعد إعادة التشغيل.",
"duration_hours": "{count, plural, one {ساعة واحدة} two {ساعتان} few {# ساعات} many {# ساعة} other {# ساعة}}",
"duration_days": "{count, plural, one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}",
"time_left": "يتبقى {time} على الأقل وفق حد الرفع في الخادم. يمكنك مواصلة العمل في هذه الأثناء.",
"thunderbird_running": "Thunderbird قيد التشغيل. أغلقه أولاً. إذا غيّر مجلدًا أثناء الاستيراد، يبدأ ذلك المجلد من جديد ويتم تخطي الرسائل المستوردة بالفعل.",
"ost_refused": "غير معروضة: {names}. ملفات Outlook بصيغة ‎.ost هي مجرد نسخة محلية من صندوق البريد ولا يمكن استيرادها هنا. في Outlook، صدّر البريد إلى ملف ‎.pst بدلاً من ذلك.",
"calendars_left_out": "لا يتم استيراد تقاويم Thunderbird. صدّرها من Thunderbird كملفات ‎.ics واستوردها في التقويم.",
"pst_items_left_out": "يتم استيراد البريد فقط من ملفات Outlook، وليس جهات الاتصال أو التقاويم.",
"picked": "{count, plural, one {مجلد أو ملف واحد من هذا الكمبيوتر} two {مجلدان أو ملفان من هذا الكمبيوتر} few {# مجلدات وملفات من هذا الكمبيوتر} many {# مجلدًا وملفًا من هذا الكمبيوتر} other {# مجلد وملف من هذا الكمبيوتر}}",
"remove_picked": "إزالة هذه الملفات",
"contacts_picked": "{count, plural, one {ستُضاف جهة اتصال واحدة من Thunderbird إلى جهات اتصالك.} two {ستُضاف جهتا اتصال من Thunderbird إلى جهات اتصالك.} few {ستُضاف # جهات اتصال من Thunderbird إلى جهات اتصالك.} many {ستُضاف # جهة اتصال من Thunderbird إلى جهات اتصالك.} other {ستُضاف # جهة اتصال من Thunderbird إلى جهات اتصالك.}}",
"contacts_added": "{count, plural, one {تمت إضافة جهة اتصال واحدة من Thunderbird.} two {تمت إضافة جهتي اتصال من Thunderbird.} few {تمت إضافة # جهات اتصال من Thunderbird.} many {تمت إضافة # جهة اتصال من Thunderbird.} other {تمت إضافة # جهة اتصال من Thunderbird.}}",
"contacts_failed": "تعذّرت إضافة بعض جهات الاتصال من Thunderbird.",
"files_refused": "تم تركها لأنه لم يعد من الممكن قراءتها: {names}",
"skip_seen": "تخطي الرسائل التي وضعها استيراد سابق في المجلد نفسه",
"skip_duplicate": "{count, plural, one {تم استيراد رسالة واحدة من قبل} two {تم استيراد رسالتين من قبل} few {تم استيراد # رسائل من قبل} many {تم استيراد # رسالة من قبل} other {تم استيراد # رسالة من قبل}}",
"file_unavailable": "تغيّر ملف أو لم يعد من الممكن قراءته. أغلق Thunderbird واستورد مرة أخرى. يتم تخطي الرسائل المستوردة بالفعل.",
"resume_gone": "لا يمكن متابعة الاستيراد من هذا الكمبيوتر لأن ملفاته اختفت.",
"pst_in_use": "لا يمكن قراءة {names} ما دام Outlook يفتحه. أغلق Outlook وابحث مرة أخرى.",
"description": "ابحث عن البريد على هذا الكمبيوتر، أو اختر ملفات ‎.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": "استعادة العناصر المحذوفة",
"title": "استعادة العناصر المحذوفة",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "L'exportació s'ha aturat.",
"toast_show": "Mostra"
},
"desktopImport": {
"find": "Cerca dades de Thunderbird i Outlook…",
"find_hint": "Cerca als llocs habituals d'aquest ordinador. Bulwark t'ho pregunta abans.",
"scan_declined": "No s'ha cercat res perquè no ho has permès.",
"scan_failed": "La cerca no ha funcionat. Torna-ho a provar.",
"read_declined": "No s'ha llegit cap fitxer perquè no ho has permès.",
"read_failed": "Aquests fitxers ja no es poden llegir. Torna a cercar.",
"nothing_found": "No hi ha cap perfil de Thunderbird ni fitxer de dades d'Outlook als llocs habituals. Per importar un fitxer d'un altre lloc, fes servir Tria fitxers.",
"found_list": "Dades de correu trobades en aquest ordinador",
"outlook_files": "Fitxers de dades d'Outlook",
"thunderbird_profile": "Perfil de Thunderbird {name}",
"address_book": "Llibreta d'adreces de {name}",
"message_count": "{count, plural, one {# missatge} other {# missatges}}",
"contact_count": "{count, plural, one {# contacte} other {# contactes}}",
"confirm": "{count, plural, one {Fes servir aquest element…} other {Fes servir aquests # elements…}}",
"back": "Enrere",
"estimate": "{size} seleccionats. Molts servidors, entre ells Stalwart, només accepten uns 1000 missatges o 50 MB per hora d'un usuari, així que això triga almenys {time}. Al límit, la importació s'atura i continua sola. Funciona en segon pla i continua després de reiniciar.",
"duration_hours": "{count, plural, one {# hora} other {# hores}}",
"duration_days": "{count, plural, one {# dia} other {# dies}}",
"time_left": "Queden almenys {time} amb el límit de pujada del servidor. Mentrestant pots continuar treballant.",
"thunderbird_running": "Thunderbird està obert. Tanca'l primer. Si canvia una carpeta durant la importació, aquesta carpeta torna a començar i s'ometen els missatges ja importats.",
"ost_refused": "No s'ofereixen: {names}. Els fitxers .ost d'Outlook només són una còpia local d'una bústia i aquí no es poden importar. A Outlook, exporta el correu a un fitxer .pst.",
"calendars_left_out": "Els calendaris de Thunderbird no s'importen. Exporta'ls des de Thunderbird com a fitxers .ics i importa'ls al calendari.",
"pst_items_left_out": "Dels fitxers d'Outlook només s'importa el correu, no els contactes ni els calendaris.",
"picked": "{count, plural, one {# carpeta o fitxer d'aquest ordinador} other {# carpetes i fitxers d'aquest ordinador}}",
"remove_picked": "Elimina aquests fitxers",
"contacts_picked": "{count, plural, one {# contacte de Thunderbird va als teus contactes.} other {# contactes de Thunderbird van als teus contactes.}}",
"contacts_added": "{count, plural, one {S'ha afegit # contacte de Thunderbird.} other {S'han afegit # contactes de Thunderbird.}}",
"contacts_failed": "Alguns contactes de Thunderbird no s'han pogut afegir.",
"files_refused": "Omesos perquè ja no es poden llegir: {names}",
"skip_seen": "Omet els missatges que una importació anterior ja va posar a la mateixa carpeta",
"skip_duplicate": "{count, plural, one {# missatge ja es va importar abans} other {# missatges ja es van importar abans}}",
"file_unavailable": "Un fitxer ha canviat o ja no es pot llegir. Tanca Thunderbird i torna a importar. Els missatges ja importats s'ometen.",
"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.",
"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",
"title": "Recupera elements suprimits",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "Export se zastavil.",
"toast_show": "Zobrazit"
},
"desktopImport": {
"find": "Najít data Thunderbirdu a Outlooku…",
"find_hint": "Hledá na obvyklých místech v tomto počítači. Bulwark se nejdřív zeptá.",
"scan_declined": "Nic se nehledalo, protože jste to nepovolili.",
"scan_failed": "Hledání se nezdařilo. Zkuste to znovu.",
"read_declined": "Žádné soubory se nečetly, protože jste to nepovolili.",
"read_failed": "Tyto soubory už nelze přečíst. Hledejte znovu.",
"nothing_found": "Na obvyklých místech není žádný profil Thunderbirdu ani datový soubor Outlooku. Chcete-li importovat soubor odjinud, použijte Vybrat soubory.",
"found_list": "Poštovní data nalezená v tomto počítači",
"outlook_files": "Datové soubory Outlooku",
"thunderbird_profile": "Profil Thunderbirdu {name}",
"address_book": "Adresář profilu {name}",
"message_count": "{count, plural, one {# zpráva} few {# zprávy} many {# zprávy} other {# zpráv}}",
"contact_count": "{count, plural, one {# kontakt} few {# kontakty} many {# kontaktu} other {# kontaktů}}",
"confirm": "{count, plural, one {Použít tuto položku…} few {Použít # položky…} many {Použít # položky…} other {Použít # položek…}}",
"back": "Zpět",
"estimate": "Vybráno {size}. Mnoho serverů, včetně Stalwartu, přijímá od jednoho uživatele jen asi 1000 zpráv nebo 50 MB za hodinu, takže to potrvá nejméně {time}. Na limitu se import pozastaví a sám pokračuje. Běží na pozadí a po restartu pokračuje.",
"duration_hours": "{count, plural, one {# hodinu} few {# hodiny} many {# hodiny} other {# hodin}}",
"duration_days": "{count, plural, one {# den} few {# dny} many {# dne} other {# dní}}",
"time_left": "Zbývá nejméně {time} při limitu nahrávání serveru. Mezitím můžete dál pracovat.",
"thunderbird_running": "Thunderbird běží. Nejprve ho zavřete. Pokud během importu změní složku, tato složka začne znovu a už importované zprávy se přeskočí.",
"ost_refused": "Nenabízeno: {names}. Soubory .ost Outlooku jsou jen místní kopie schránky a zde je nelze importovat. V Outlooku raději exportujte poštu do souboru .pst.",
"calendars_left_out": "Kalendáře Thunderbirdu se neimportují. Exportujte je z Thunderbirdu jako soubory .ics a ty importujte do kalendáře.",
"pst_items_left_out": "Ze souborů Outlooku se importuje jen pošta, ne kontakty ani kalendáře.",
"picked": "{count, plural, one {# složka nebo soubor z tohoto počítače} few {# složky a soubory z tohoto počítače} many {# složky a souboru z tohoto počítače} other {# složek a souborů z tohoto počítače}}",
"remove_picked": "Odebrat tyto soubory",
"contacts_picked": "{count, plural, one {# kontakt z Thunderbirdu přejde do vašich kontaktů.} few {# kontakty z Thunderbirdu přejdou do vašich kontaktů.} many {# kontaktu z Thunderbirdu přejde do vašich kontaktů.} other {# kontaktů z Thunderbirdu přejde do vašich kontaktů.}}",
"contacts_added": "{count, plural, one {Přidán # kontakt z Thunderbirdu.} few {Přidány # kontakty z Thunderbirdu.} many {Přidáno # kontaktu z Thunderbirdu.} other {Přidáno # kontaktů z Thunderbirdu.}}",
"contacts_failed": "Některé kontakty z Thunderbirdu se nepodařilo přidat.",
"files_refused": "Vynecháno, protože je už nelze přečíst: {names}",
"skip_seen": "Přeskočit zprávy, které dřívější import už uložil do stejné složky",
"skip_duplicate": "{count, plural, one {# zpráva už byla importována dříve} few {# zprávy už byly importovány dříve} many {# zprávy už bylo importováno dříve} other {# zpráv už bylo importováno dříve}}",
"file_unavailable": "Soubor se změnil nebo ho už nelze přečíst. Zavřete Thunderbird a importujte znovu. Už importované zprávy se přeskočí.",
"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.",
"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",
"title": "Obnovit smazané položky",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Eksporten stoppede.",
"toast_show": "Vis"
},
"desktopImport": {
"find": "Find Thunderbird- og Outlook-data…",
"find_hint": "Søger de sædvanlige steder på denne computer. Bulwark spørger først.",
"scan_declined": "Der blev ikke søgt, fordi du ikke tillod det.",
"scan_failed": "Søgningen virkede ikke. Prøv igen.",
"read_declined": "Ingen filer blev læst, fordi du ikke tillod det.",
"read_failed": "Disse filer kan ikke længere læses. Søg igen.",
"nothing_found": "Ingen Thunderbird-profil eller Outlook-datafil de sædvanlige steder. Brug Vælg filer for at importere en fil fra et andet sted.",
"found_list": "Maildata fundet på denne computer",
"outlook_files": "Outlook-datafiler",
"thunderbird_profile": "Thunderbird-profil {name}",
"address_book": "Adressebog for {name}",
"message_count": "{count, plural, one {# besked} other {# beskeder}}",
"contact_count": "{count, plural, one {# kontakt} other {# kontakter}}",
"confirm": "{count, plural, one {Brug dette element…} other {Brug disse # elementer…}}",
"back": "Tilbage",
"estimate": "{size} valgt. Mange servere, også Stalwart, tager kun imod omkring 1000 beskeder eller 50 MB i timen fra én bruger, så det tager mindst {time}. Ved grænsen holder importen pause og fortsætter af sig selv. Den kører i baggrunden og fortsætter efter en genstart.",
"duration_hours": "{count, plural, one {# time} other {# timer}}",
"duration_days": "{count, plural, one {# dag} other {# dage}}",
"time_left": "Mindst {time} tilbage ved serverens uploadgrænse. Du kan arbejde videre imens.",
"thunderbird_running": "Thunderbird kører. Luk det først. Hvis det ændrer en mappe under importen, starter mappen forfra, og allerede importerede beskeder springes over.",
"ost_refused": "Ikke tilbudt: {names}. Outlooks .ost-filer er kun en lokal kopi af en postkasse og kan ikke importeres her. Eksportér i stedet mailen til en .pst-fil i Outlook.",
"calendars_left_out": "Thunderbird-kalendere importeres ikke. Eksportér dem fra Thunderbird som .ics-filer, og importér dem i kalenderen.",
"pst_items_left_out": "Fra Outlook-filer importeres kun mail, ikke kontakter eller kalendere.",
"picked": "{count, plural, one {# mappe eller fil fra denne computer} other {# mapper og filer fra denne computer}}",
"remove_picked": "Fjern disse filer",
"contacts_picked": "{count, plural, one {# kontakt fra Thunderbird kommer i dine kontakter.} other {# kontakter fra Thunderbird kommer i dine kontakter.}}",
"contacts_added": "{count, plural, one {# kontakt fra Thunderbird tilføjet.} other {# kontakter fra Thunderbird tilføjet.}}",
"contacts_failed": "Nogle kontakter fra Thunderbird kunne ikke tilføjes.",
"files_refused": "Udeladt, fordi de ikke længere kan læses: {names}",
"skip_seen": "Spring beskeder over, som en tidligere import allerede har lagt i samme mappe",
"skip_duplicate": "{count, plural, one {# besked er allerede importeret tidligere} other {# beskeder er allerede importeret tidligere}}",
"file_unavailable": "En fil er ændret eller kan ikke længere læses. Luk Thunderbird, og importér igen. Allerede importerede beskeder springes over.",
"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.",
"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",
"title": "Gendan slettede elementer",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "Der Export wurde angehalten.",
"toast_show": "Anzeigen"
},
"desktopImport": {
"find": "Thunderbird- und Outlook-Daten suchen…",
"find_hint": "Sucht an den üblichen Orten auf diesem Computer. Bulwark fragt Sie vorher.",
"scan_declined": "Es wurde nichts gesucht, weil Sie es nicht zugelassen haben.",
"scan_failed": "Die Suche hat nicht funktioniert. Versuchen Sie es noch einmal.",
"read_declined": "Es wurden keine Dateien gelesen, weil Sie es nicht zugelassen haben.",
"read_failed": "Diese Dateien können nicht mehr gelesen werden. Suchen Sie noch einmal.",
"nothing_found": "Kein Thunderbird-Profil und keine Outlook-Datendatei an den üblichen Orten. Um eine Datei von anderswo zu importieren, verwenden Sie „Dateien auswählen“.",
"found_list": "Auf diesem Computer gefundene Mail-Daten",
"outlook_files": "Outlook-Datendateien",
"thunderbird_profile": "Thunderbird-Profil {name}",
"address_book": "Adressbuch von {name}",
"message_count": "{count, plural, one {# Nachricht} other {# Nachrichten}}",
"contact_count": "{count, plural, one {# Kontakt} other {# Kontakte}}",
"confirm": "{count, plural, one {Diesen Eintrag verwenden…} other {Diese # Einträge verwenden…}}",
"back": "Zurück",
"estimate": "{size} ausgewählt. Viele Server, auch Stalwart, nehmen von einem Benutzer nur etwa 1000 Nachrichten oder 50 MB pro Stunde an. Das dauert also mindestens {time}. Am Limit pausiert der Import und läuft von selbst weiter. Er läuft im Hintergrund und geht nach einem Neustart weiter.",
"duration_hours": "{count, plural, one {# Stunde} other {# Stunden}}",
"duration_days": "{count, plural, one {# Tag} other {# Tage}}",
"time_left": "Noch mindestens {time} beim Upload-Limit des Servers. Sie können währenddessen weiterarbeiten.",
"thunderbird_running": "Thunderbird läuft. Schließen Sie es zuerst. Ändert es während des Imports einen Ordner, beginnt dieser Ordner von vorn, und bereits importierte Nachrichten werden übersprungen.",
"ost_refused": "Nicht angeboten: {names}. Outlook-.ost-Dateien sind nur eine lokale Kopie eines Postfachs und können hier nicht importiert werden. Exportieren Sie die E-Mails in Outlook stattdessen in eine .pst-Datei.",
"calendars_left_out": "Thunderbird-Kalender werden nicht importiert. Exportieren Sie sie in Thunderbird als .ics-Dateien und importieren Sie diese im Kalender.",
"pst_items_left_out": "Aus Outlook-Dateien werden nur E-Mails importiert, keine Kontakte oder Kalender.",
"picked": "{count, plural, one {# Ordner oder Datei von diesem Computer} other {# Ordner und Dateien von diesem Computer}}",
"remove_picked": "Diese Dateien entfernen",
"contacts_picked": "{count, plural, one {# Kontakt aus Thunderbird kommt in Ihre Kontakte.} other {# Kontakte aus Thunderbird kommen in Ihre Kontakte.}}",
"contacts_added": "{count, plural, one {# Kontakt aus Thunderbird hinzugefügt.} other {# Kontakte aus Thunderbird hinzugefügt.}}",
"contacts_failed": "Einige Kontakte aus Thunderbird konnten nicht hinzugefügt werden.",
"files_refused": "Ausgelassen, weil sie nicht mehr gelesen werden können: {names}",
"skip_seen": "Nachrichten überspringen, die ein früherer Import schon in denselben Ordner gelegt hat",
"skip_duplicate": "{count, plural, one {# Nachricht wurde schon früher importiert} other {# Nachrichten wurden schon früher importiert}}",
"file_unavailable": "Eine Datei hat sich geändert oder kann nicht mehr gelesen werden. Schließen Sie Thunderbird und importieren Sie noch einmal. Bereits importierte Nachrichten werden übersprungen.",
"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.",
"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",
"title": "Gelöschte Elemente wiederherstellen",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "The export stopped.",
"toast_show": "Show"
},
"desktopImport": {
"find": "Find Thunderbird and Outlook data…",
"find_hint": "Looks in the usual places on this computer. Bulwark asks you first.",
"scan_declined": "Nothing was searched, because you didn't allow it.",
"scan_failed": "The search didn't work. Try again.",
"read_declined": "No files were read, because you didn't allow it.",
"read_failed": "These files can't be read any more. Search again.",
"nothing_found": "No Thunderbird profile or Outlook data file in the usual places. To import a file from somewhere else, use Choose files.",
"found_list": "Mail data found on this computer",
"outlook_files": "Outlook data files",
"thunderbird_profile": "Thunderbird profile {name}",
"address_book": "Address book of {name}",
"message_count": "{count, plural, one {# message} other {# messages}}",
"contact_count": "{count, plural, one {# contact} other {# contacts}}",
"confirm": "{count, plural, one {Use this item…} other {Use these # items…}}",
"back": "Back",
"estimate": "{size} selected. Many servers, Stalwart among them, take only about 1000 messages or 50 MB an hour from one user, so this takes at least {time}. At the limit the import pauses and goes on by itself. It runs in the background and carries on after a restart.",
"duration_hours": "{count, plural, one {# hour} other {# hours}}",
"duration_days": "{count, plural, one {# day} other {# days}}",
"time_left": "At least {time} left at the server's upload limit. You can keep working meanwhile.",
"thunderbird_running": "Thunderbird is running. Close it first. If it changes a folder during the import, that folder starts over, and messages already imported are skipped.",
"ost_refused": "Not offered: {names}. Outlook .ost files are only a local copy of a mailbox and can't be imported here. In Outlook, export the mail to a .pst file instead.",
"calendars_left_out": "Thunderbird calendars aren't imported. Export them from Thunderbird as .ics files and import those in the calendar.",
"pst_items_left_out": "From Outlook files only mail is imported, not contacts or calendars.",
"picked": "{count, plural, one {# folder or file from this computer} other {# folders and files from this computer}}",
"remove_picked": "Remove these files",
"contacts_picked": "{count, plural, one {# contact from Thunderbird goes into your contacts.} other {# contacts from Thunderbird go into your contacts.}}",
"contacts_added": "{count, plural, one {Added # contact from Thunderbird.} other {Added # contacts from Thunderbird.}}",
"contacts_failed": "Some contacts from Thunderbird couldn't be added.",
"files_refused": "Left out, because they can't be read any more: {names}",
"skip_seen": "Skip messages an earlier import already put in the same folder",
"skip_duplicate": "{count, plural, one {# message was already imported earlier} other {# messages were already imported earlier}}",
"file_unavailable": "A file changed or can't be read any more. Close Thunderbird and import again. Messages already imported are skipped.",
"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.",
"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",
"title": "Recover deleted items",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "La exportación se detuvo.",
"toast_show": "Mostrar"
},
"desktopImport": {
"find": "Buscar datos de Thunderbird y Outlook…",
"find_hint": "Busca en los lugares habituales de este equipo. Bulwark le pregunta antes.",
"scan_declined": "No se buscó nada porque no lo permitió.",
"scan_failed": "La búsqueda no funcionó. Inténtelo de nuevo.",
"read_declined": "No se leyó ningún archivo porque no lo permitió.",
"read_failed": "Estos archivos ya no se pueden leer. Busque de nuevo.",
"nothing_found": "No hay ningún perfil de Thunderbird ni archivo de datos de Outlook en los lugares habituales. Para importar un archivo de otro lugar, use Elegir archivos.",
"found_list": "Datos de correo encontrados en este equipo",
"outlook_files": "Archivos de datos de Outlook",
"thunderbird_profile": "Perfil de Thunderbird {name}",
"address_book": "Libreta de direcciones de {name}",
"message_count": "{count, plural, one {# mensaje} other {# mensajes}}",
"contact_count": "{count, plural, one {# contacto} other {# contactos}}",
"confirm": "{count, plural, one {Usar este elemento…} other {Usar estos # elementos…}}",
"back": "Atrás",
"estimate": "{size} seleccionados. Muchos servidores, entre ellos Stalwart, solo aceptan unos 1000 mensajes o 50 MB por hora de un usuario, así que esto tarda al menos {time}. Al llegar al límite, la importación se detiene y sigue sola. Funciona en segundo plano y continúa tras reiniciar.",
"duration_hours": "{count, plural, one {# hora} other {# horas}}",
"duration_days": "{count, plural, one {# día} other {# días}}",
"time_left": "Quedan al menos {time} con el límite de subida del servidor. Mientras tanto puede seguir trabajando.",
"thunderbird_running": "Thunderbird está abierto. Ciérrelo primero. Si cambia una carpeta durante la importación, esa carpeta empieza de nuevo y se omiten los mensajes ya importados.",
"ost_refused": "No se ofrecen: {names}. Los archivos .ost de Outlook son solo una copia local de un buzón y no se pueden importar aquí. En Outlook, exporte el correo a un archivo .pst.",
"calendars_left_out": "Los calendarios de Thunderbird no se importan. Expórtelos desde Thunderbird como archivos .ics e impórtelos en el calendario.",
"pst_items_left_out": "De los archivos de Outlook solo se importa el correo, no los contactos ni los calendarios.",
"picked": "{count, plural, one {# carpeta o archivo de este equipo} other {# carpetas y archivos de este equipo}}",
"remove_picked": "Quitar estos archivos",
"contacts_picked": "{count, plural, one {# contacto de Thunderbird pasa a sus contactos.} other {# contactos de Thunderbird pasan a sus contactos.}}",
"contacts_added": "{count, plural, one {Se añadió # contacto de Thunderbird.} other {Se añadieron # contactos de Thunderbird.}}",
"contacts_failed": "Algunos contactos de Thunderbird no se pudieron añadir.",
"files_refused": "Omitidos porque ya no se pueden leer: {names}",
"skip_seen": "Omitir mensajes que una importación anterior ya puso en la misma carpeta",
"skip_duplicate": "{count, plural, one {# mensaje ya se importó antes} other {# mensajes ya se importaron antes}}",
"file_unavailable": "Un archivo cambió o ya no se puede leer. Cierre Thunderbird e importe de nuevo. Los mensajes ya importados se omiten.",
"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.",
"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",
"title": "Recuperar elementos eliminados",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Eksport peatus.",
"toast_show": "Näita"
},
"desktopImport": {
"find": "Otsi Thunderbirdi ja Outlooki andmeid…",
"find_hint": "Otsib selle arvuti tavalistest kohtadest. Bulwark küsib enne luba.",
"scan_declined": "Midagi ei otsitud, sest sa ei lubanud seda.",
"scan_failed": "Otsing ei õnnestunud. Proovi uuesti.",
"read_declined": "Faile ei loetud, sest sa ei lubanud seda.",
"read_failed": "Neid faile ei saa enam lugeda. Otsi uuesti.",
"nothing_found": "Tavalistes kohtades pole Thunderbirdi profiili ega Outlooki andmefaili. Mujalt faili importimiseks kasuta nuppu Vali failid.",
"found_list": "Sellest arvutist leitud e-posti andmed",
"outlook_files": "Outlooki andmefailid",
"thunderbird_profile": "Thunderbirdi profiil {name}",
"address_book": "Profiili {name} aadressiraamat",
"message_count": "{count, plural, one {# kiri} other {# kirja}}",
"contact_count": "{count, plural, one {# kontakt} other {# kontakti}}",
"confirm": "{count, plural, one {Kasuta seda üksust…} other {Kasuta neid # üksust…}}",
"back": "Tagasi",
"estimate": "Valitud {size}. Paljud serverid, sealhulgas Stalwart, võtavad ühelt kasutajalt vastu vaid umbes 1000 kirja või 50 MB tunnis, seega kulub selleks vähemalt {time}. Piirini jõudes jääb import pausile ja jätkub ise. See töötab taustal ja jätkub pärast taaskäivitust.",
"duration_hours": "{count, plural, one {# tund} other {# tundi}}",
"duration_days": "{count, plural, one {# päev} other {# päeva}}",
"time_left": "Serveri üleslaadimispiiri juures on jäänud vähemalt {time}. Seni võid edasi töötada.",
"thunderbird_running": "Thunderbird töötab. Sulge see enne. Kui see muudab impordi ajal kausta, algab see kaust otsast ja juba imporditud kirjad jäetakse vahele.",
"ost_refused": "Ei pakuta: {names}. Outlooki .ost-failid on vaid postkasti kohalik koopia ja neid ei saa siin importida. Ekspordi kirjad Outlookis hoopis .pst-faili.",
"calendars_left_out": "Thunderbirdi kalendreid ei impordita. Ekspordi need Thunderbirdist .ics-failidena ja impordi kalendrisse.",
"pst_items_left_out": "Outlooki failidest imporditakse ainult kirjad, mitte kontakte ega kalendreid.",
"picked": "{count, plural, one {# kaust või fail sellest arvutist} other {# kausta ja faili sellest arvutist}}",
"remove_picked": "Eemalda need failid",
"contacts_picked": "{count, plural, one {# kontakt Thunderbirdist läheb sinu kontaktidesse.} other {# kontakti Thunderbirdist läheb sinu kontaktidesse.}}",
"contacts_added": "{count, plural, one {Lisatud # kontakt Thunderbirdist.} other {Lisatud # kontakti Thunderbirdist.}}",
"contacts_failed": "Mõnda Thunderbirdi kontakti ei saanud lisada.",
"files_refused": "Välja jäetud, sest neid ei saa enam lugeda: {names}",
"skip_seen": "Jäta vahele kirjad, mille varasem import on samasse kausta juba pannud",
"skip_duplicate": "{count, plural, one {# kiri on juba varem imporditud} other {# kirja on juba varem imporditud}}",
"file_unavailable": "Fail on muutunud või seda ei saa enam lugeda. Sulge Thunderbird ja impordi uuesti. Juba imporditud kirjad jäetakse vahele.",
"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.",
"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",
"title": "Taasta kustutatud üksused",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "برون‌بری متوقف شد.",
"toast_show": "نمایش"
},
"desktopImport": {
"find": "یافتن داده‌های Thunderbird و Outlook…",
"find_hint": "در جاهای معمول این رایانه جستجو می‌کند. Bulwark اول از شما می‌پرسد.",
"scan_declined": "چیزی جستجو نشد، چون اجازه ندادید.",
"scan_failed": "جستجو انجام نشد. دوباره امتحان کنید.",
"read_declined": "هیچ فایلی خوانده نشد، چون اجازه ندادید.",
"read_failed": "این فایل‌ها دیگر قابل خواندن نیستند. دوباره جستجو کنید.",
"nothing_found": "در جاهای معمول هیچ نمایه Thunderbird یا فایل داده Outlook نیست. برای وارد کردن فایل از جای دیگر، از «انتخاب فایل‌ها» استفاده کنید.",
"found_list": "داده‌های ایمیل یافت‌شده در این رایانه",
"outlook_files": "فایل‌های داده Outlook",
"thunderbird_profile": "نمایه Thunderbird {name}",
"address_book": "دفترچه نشانی {name}",
"message_count": "{count, plural, one {# پیام} other {# پیام}}",
"contact_count": "{count, plural, one {# مخاطب} other {# مخاطب}}",
"confirm": "{count, plural, one {استفاده از این مورد…} other {استفاده از این # مورد…}}",
"back": "بازگشت",
"estimate": "{size} انتخاب شد. بسیاری از سرورها، از جمله Stalwart، از هر کاربر فقط حدود ۱۰۰۰ پیام یا ۵۰ مگابایت در ساعت می‌پذیرند، پس این کار دست‌کم {time} طول می‌کشد. در حد مجاز، وارد کردن مکث می‌کند و خودش ادامه می‌دهد. در پس‌زمینه اجرا می‌شود و پس از راه‌اندازی دوباره ادامه می‌یابد.",
"duration_hours": "{count, plural, one {# ساعت} other {# ساعت}}",
"duration_days": "{count, plural, one {# روز} other {# روز}}",
"time_left": "با حد بارگذاری سرور، دست‌کم {time} مانده است. در این مدت می‌توانید به کار ادامه دهید.",
"thunderbird_running": "Thunderbird در حال اجراست. اول آن را ببندید. اگر هنگام وارد کردن پوشه‌ای را تغییر دهد، آن پوشه از نو شروع می‌شود و پیام‌هایی که وارد شده‌اند رد می‌شوند.",
"ost_refused": "پیشنهاد نشد: {names}. فایل‌های ‎.ost در Outlook فقط نسخه محلی یک صندوق پستی هستند و اینجا وارد نمی‌شوند. به جای آن در Outlook ایمیل را به فایل ‎.pst صادر کنید.",
"calendars_left_out": "تقویم‌های Thunderbird وارد نمی‌شوند. آن‌ها را از Thunderbird به صورت فایل ‎.ics صادر کنید و در تقویم وارد کنید.",
"pst_items_left_out": "از فایل‌های Outlook فقط ایمیل وارد می‌شود، نه مخاطبان یا تقویم‌ها.",
"picked": "{count, plural, one {# پوشه یا فایل از این رایانه} other {# پوشه و فایل از این رایانه}}",
"remove_picked": "حذف این فایل‌ها",
"contacts_picked": "{count, plural, one {# مخاطب از Thunderbird به مخاطبان شما اضافه می‌شود.} other {# مخاطب از Thunderbird به مخاطبان شما اضافه می‌شوند.}}",
"contacts_added": "{count, plural, one {# مخاطب از Thunderbird اضافه شد.} other {# مخاطب از Thunderbird اضافه شدند.}}",
"contacts_failed": "برخی مخاطبان Thunderbird اضافه نشدند.",
"files_refused": "کنار گذاشته شد، چون دیگر قابل خواندن نیستند: {names}",
"skip_seen": "رد کردن پیام‌هایی که وارد کردن قبلی در همان پوشه گذاشته است",
"skip_duplicate": "{count, plural, one {# پیام قبلاً وارد شده است} other {# پیام قبلاً وارد شده‌اند}}",
"file_unavailable": "فایلی تغییر کرده یا دیگر قابل خواندن نیست. Thunderbird را ببندید و دوباره وارد کنید. پیام‌هایی که وارد شده‌اند رد می‌شوند.",
"resume_gone": "وارد کردن از این رایانه ادامه نمی‌یابد، چون فایل‌های آن از بین رفته‌اند.",
"pst_in_use": "تا وقتی Outlook آن را باز نگه داشته، {names} خوانده نمی‌شود. Outlook را ببندید و دوباره جستجو کنید.",
"description": "ایمیل را در این رایانه پیدا کنید، یا فایل‌های ‎.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": "بازیابی موارد حذف‌شده",
"title": "بازیابی موارد حذف‌شده",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "L'export s'est arrêté.",
"toast_show": "Afficher"
},
"desktopImport": {
"find": "Rechercher les données Thunderbird et Outlook…",
"find_hint": "Cherche aux emplacements habituels de cet ordinateur. Bulwark vous le demande d'abord.",
"scan_declined": "Aucune recherche effectuée, car vous ne l'avez pas autorisée.",
"scan_failed": "La recherche n'a pas fonctionné. Réessayez.",
"read_declined": "Aucun fichier lu, car vous ne l'avez pas autorisé.",
"read_failed": "Ces fichiers ne sont plus lisibles. Relancez la recherche.",
"nothing_found": "Aucun profil Thunderbird ni fichier de données Outlook aux emplacements habituels. Pour importer un fichier situé ailleurs, utilisez Choisir des fichiers.",
"found_list": "Données de messagerie trouvées sur cet ordinateur",
"outlook_files": "Fichiers de données Outlook",
"thunderbird_profile": "Profil Thunderbird {name}",
"address_book": "Carnet d'adresses de {name}",
"message_count": "{count, plural, one {# message} other {# messages}}",
"contact_count": "{count, plural, one {# contact} other {# contacts}}",
"confirm": "{count, plural, one {Utiliser cet élément…} other {Utiliser ces # éléments…}}",
"back": "Retour",
"estimate": "{size} sélectionnés. Beaucoup de serveurs, dont Stalwart, n'acceptent qu'environ 1000 messages ou 50 Mo par heure d'un même utilisateur : cela prendra donc au moins {time}. À la limite, l'import se met en pause puis reprend tout seul. Il tourne en arrière-plan et continue après un redémarrage.",
"duration_hours": "{count, plural, one {# heure} other {# heures}}",
"duration_days": "{count, plural, one {# jour} other {# jours}}",
"time_left": "Encore au moins {time} à la limite d'envoi du serveur. Vous pouvez continuer à travailler.",
"thunderbird_running": "Thunderbird est ouvert. Fermez-le d'abord. S'il modifie un dossier pendant l'import, ce dossier repart de zéro et les messages déjà importés sont ignorés.",
"ost_refused": "Non proposés : {names}. Les fichiers .ost d'Outlook ne sont qu'une copie locale d'une boîte aux lettres et ne peuvent pas être importés ici. Dans Outlook, exportez plutôt le courrier vers un fichier .pst.",
"calendars_left_out": "Les agendas Thunderbird ne sont pas importés. Exportez-les depuis Thunderbird en fichiers .ics et importez-les dans l'agenda.",
"pst_items_left_out": "Des fichiers Outlook, seul le courrier est importé, pas les contacts ni les agendas.",
"picked": "{count, plural, one {# dossier ou fichier de cet ordinateur} other {# dossiers et fichiers de cet ordinateur}}",
"remove_picked": "Retirer ces fichiers",
"contacts_picked": "{count, plural, one {# contact de Thunderbird rejoint vos contacts.} other {# contacts de Thunderbird rejoignent vos contacts.}}",
"contacts_added": "{count, plural, one {# contact de Thunderbird ajouté.} other {# contacts de Thunderbird ajoutés.}}",
"contacts_failed": "Certains contacts de Thunderbird n'ont pas pu être ajoutés.",
"files_refused": "Laissés de côté, car ils ne sont plus lisibles : {names}",
"skip_seen": "Ignorer les messages qu'un import précédent a déjà placés dans le même dossier",
"skip_duplicate": "{count, plural, one {# message déjà importé auparavant} other {# messages déjà importés auparavant}}",
"file_unavailable": "Un fichier a changé ou n'est plus lisible. Fermez Thunderbird et relancez l'import. Les messages déjà importés sont ignorés.",
"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.",
"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",
"title": "Récupérer des éléments supprimés",
+42
View File
@@ -5633,6 +5633,48 @@
"toast_stopped": "הייצוא נעצר.",
"toast_show": "הצגה"
},
"desktopImport": {
"find": "חיפוש נתוני Thunderbird ו-Outlook…",
"find_hint": "מחפש במקומות הרגילים במחשב הזה. Bulwark ישאל אותך קודם.",
"scan_declined": "לא בוצע חיפוש, כי לא אישרת.",
"scan_failed": "החיפוש לא הצליח. נסה שוב.",
"read_declined": "לא נקראו קבצים, כי לא אישרת.",
"read_failed": "אי אפשר לקרוא עוד את הקבצים האלה. חפש שוב.",
"nothing_found": "אין פרופיל Thunderbird או קובץ נתונים של Outlook במקומות הרגילים. כדי לייבא קובץ ממקום אחר, השתמש בבחירת קבצים.",
"found_list": "נתוני דואר שנמצאו במחשב הזה",
"outlook_files": "קובצי נתונים של Outlook",
"thunderbird_profile": "פרופיל Thunderbird {name}",
"address_book": "פנקס הכתובות של {name}",
"message_count": "{count, plural, one {הודעה אחת} two {# הודעות} other {# הודעות}}",
"contact_count": "{count, plural, one {איש קשר אחד} two {# אנשי קשר} other {# אנשי קשר}}",
"confirm": "{count, plural, one {שימוש בפריט הזה…} two {שימוש ב-# הפריטים האלה…} other {שימוש ב-# הפריטים האלה…}}",
"back": "חזרה",
"estimate": "נבחרו {size}. שרתים רבים, ביניהם Stalwart, מקבלים ממשתמש אחד רק כ-1000 הודעות או 50 MB בשעה, ולכן זה ייקח לפחות {time}. במגבלה הייבוא נעצר וממשיך מעצמו. הוא פועל ברקע וממשיך אחרי הפעלה מחדש.",
"duration_hours": "{count, plural, one {שעה אחת} two {שעתיים} other {# שעות}}",
"duration_days": "{count, plural, one {יום אחד} two {יומיים} other {# ימים}}",
"time_left": "נותרו לפחות {time} לפי מגבלת ההעלאה של השרת. בינתיים אפשר להמשיך לעבוד.",
"thunderbird_running": "Thunderbird פועל. סגור אותו קודם. אם הוא ישנה תיקייה במהלך הייבוא, התיקייה תתחיל מחדש והודעות שכבר יובאו ידולגו.",
"ost_refused": "לא מוצעים: {names}. קובצי ‎.ost של Outlook הם רק עותק מקומי של תיבת דואר ואי אפשר לייבא אותם כאן. ב-Outlook ייצא את הדואר לקובץ ‎.pst במקום.",
"calendars_left_out": "לוחות שנה של Thunderbird אינם מיובאים. ייצא אותם מ-Thunderbird כקובצי ‎.ics וייבא אותם ללוח השנה.",
"pst_items_left_out": "מקובצי Outlook מיובא רק דואר, לא אנשי קשר או לוחות שנה.",
"picked": "{count, plural, one {תיקייה או קובץ אחד מהמחשב הזה} two {# תיקיות וקבצים מהמחשב הזה} other {# תיקיות וקבצים מהמחשב הזה}}",
"remove_picked": "הסרת הקבצים האלה",
"contacts_picked": "{count, plural, one {איש קשר אחד מ-Thunderbird יתווסף לאנשי הקשר שלך.} two {# אנשי קשר מ-Thunderbird יתווספו לאנשי הקשר שלך.} other {# אנשי קשר מ-Thunderbird יתווספו לאנשי הקשר שלך.}}",
"contacts_added": "{count, plural, one {נוסף איש קשר אחד מ-Thunderbird.} two {נוספו # אנשי קשר מ-Thunderbird.} other {נוספו # אנשי קשר מ-Thunderbird.}}",
"contacts_failed": "לא ניתן היה להוסיף חלק מאנשי הקשר מ-Thunderbird.",
"files_refused": "הושמטו כי אי אפשר לקרוא אותם עוד: {names}",
"skip_seen": "דילוג על הודעות שייבוא קודם כבר שם באותה תיקייה",
"skip_duplicate": "{count, plural, one {הודעה אחת כבר יובאה קודם} two {# הודעות כבר יובאו קודם} other {# הודעות כבר יובאו קודם}}",
"file_unavailable": "קובץ השתנה או שאי אפשר לקרוא אותו עוד. סגור את Thunderbird וייבא שוב. הודעות שכבר יובאו ידולגו.",
"resume_gone": "הייבוא מהמחשב הזה לא יכול להמשיך כי הקבצים שלו נעלמו.",
"pst_in_use": "אי אפשר לקרוא את {names} כל עוד הוא פתוח ב-Outlook. סגור את Outlook וחפש שוב.",
"description": "מצא דואר במחשב הזה, או בחר קובצי ‎.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": "שחזור פריטים שנמחקו",
"title": "שחזור פריטים שנמחקו",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Az exportálás leállt.",
"toast_show": "Megjelenítés"
},
"desktopImport": {
"find": "Thunderbird- és Outlook-adatok keresése…",
"find_hint": "A számítógép szokásos helyein keres. A Bulwark előbb megkérdezi.",
"scan_declined": "Nem történt keresés, mert nem engedélyezte.",
"scan_failed": "A keresés nem sikerült. Próbálja újra.",
"read_declined": "Egy fájl sem lett beolvasva, mert nem engedélyezte.",
"read_failed": "Ezek a fájlok már nem olvashatók. Keressen újra.",
"nothing_found": "A szokásos helyeken nincs Thunderbird-profil vagy Outlook-adatfájl. Máshonnan való importáláshoz használja a Fájlok kiválasztása gombot.",
"found_list": "A számítógépen talált levelezési adatok",
"outlook_files": "Outlook-adatfájlok",
"thunderbird_profile": "Thunderbird-profil: {name}",
"address_book": "{name} címjegyzéke",
"message_count": "{count, plural, one {# üzenet} other {# üzenet}}",
"contact_count": "{count, plural, one {# névjegy} other {# névjegy}}",
"confirm": "{count, plural, one {Ezen elem használata…} other {Ezen # elem használata…}}",
"back": "Vissza",
"estimate": "{size} kiválasztva. Sok szerver, köztük a Stalwart, egy felhasználótól óránként csak körülbelül 1000 üzenetet vagy 50 MB-ot fogad, ezért ez legalább {time}. A korlátnál az importálás szünetel, majd magától folytatódik. A háttérben fut, és újraindítás után is folytatódik.",
"duration_hours": "{count, plural, one {# óra} other {# óra}}",
"duration_days": "{count, plural, one {# nap} other {# nap}}",
"time_left": "Még legalább {time} a szerver feltöltési korlátjával. Közben tovább dolgozhat.",
"thunderbird_running": "A Thunderbird fut. Előbb zárja be. Ha importálás közben módosít egy mappát, az a mappa elölről kezdődik, a már importált üzenetek pedig kimaradnak.",
"ost_refused": "Nem ajánlott: {names}. Az Outlook .ost fájljai csak egy postafiók helyi másolatai, és itt nem importálhatók. Az Outlookban inkább exportálja a leveleket .pst fájlba.",
"calendars_left_out": "A Thunderbird-naptárak nem kerülnek importálásra. Exportálja őket a Thunderbirdből .ics fájlként, és importálja a naptárba.",
"pst_items_left_out": "Az Outlook-fájlokból csak a levelek kerülnek át, a névjegyek és naptárak nem.",
"picked": "{count, plural, one {# mappa vagy fájl erről a számítógépről} other {# mappa és fájl erről a számítógépről}}",
"remove_picked": "Ezek a fájlok eltávolítása",
"contacts_picked": "{count, plural, one {# névjegy kerül át a Thunderbirdből a névjegyei közé.} other {# névjegy kerül át a Thunderbirdből a névjegyei közé.}}",
"contacts_added": "{count, plural, one {# névjegy hozzáadva a Thunderbirdből.} other {# névjegy hozzáadva a Thunderbirdből.}}",
"contacts_failed": "Néhány Thunderbird-névjegyet nem sikerült hozzáadni.",
"files_refused": "Kihagyva, mert már nem olvashatók: {names}",
"skip_seen": "Azon üzenetek kihagyása, amelyeket egy korábbi importálás már ugyanabba a mappába tett",
"skip_duplicate": "{count, plural, one {# üzenet már korábban importálva} other {# üzenet már korábban importálva}}",
"file_unavailable": "Egy fájl megváltozott vagy már nem olvasható. Zárja be a Thunderbirdöt, és importáljon újra. A már importált üzenetek kimaradnak.",
"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.",
"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",
"title": "Törölt elemek visszaállítása",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "L'esportazione si è fermata.",
"toast_show": "Mostra"
},
"desktopImport": {
"find": "Cerca dati di Thunderbird e Outlook…",
"find_hint": "Cerca nei percorsi abituali di questo computer. Bulwark te lo chiede prima.",
"scan_declined": "Non è stato cercato nulla perché non l'hai consentito.",
"scan_failed": "La ricerca non ha funzionato. Riprova.",
"read_declined": "Nessun file letto perché non l'hai consentito.",
"read_failed": "Questi file non sono più leggibili. Cerca di nuovo.",
"nothing_found": "Nessun profilo Thunderbird né file di dati Outlook nei percorsi abituali. Per importare un file da un altro percorso, usa Scegli file.",
"found_list": "Dati di posta trovati su questo computer",
"outlook_files": "File di dati di Outlook",
"thunderbird_profile": "Profilo Thunderbird {name}",
"address_book": "Rubrica di {name}",
"message_count": "{count, plural, one {# messaggio} other {# messaggi}}",
"contact_count": "{count, plural, one {# contatto} other {# contatti}}",
"confirm": "{count, plural, one {Usa questo elemento…} other {Usa questi # elementi…}}",
"back": "Indietro",
"estimate": "{size} selezionati. Molti server, tra cui Stalwart, accettano solo circa 1000 messaggi o 50 MB all'ora da un utente, quindi servono almeno {time}. Al limite l'importazione si ferma e riprende da sola. Funziona in background e continua dopo un riavvio.",
"duration_hours": "{count, plural, one {# ora} other {# ore}}",
"duration_days": "{count, plural, one {# giorno} other {# giorni}}",
"time_left": "Mancano almeno {time} al limite di caricamento del server. Nel frattempo puoi continuare a lavorare.",
"thunderbird_running": "Thunderbird è aperto. Chiudilo prima. Se modifica una cartella durante l'importazione, quella cartella riparte da capo e i messaggi già importati vengono saltati.",
"ost_refused": "Non proposti: {names}. I file .ost di Outlook sono solo una copia locale di una casella e qui non si possono importare. In Outlook, esporta invece la posta in un file .pst.",
"calendars_left_out": "I calendari di Thunderbird non vengono importati. Esportali da Thunderbird come file .ics e importali nel calendario.",
"pst_items_left_out": "Dai file di Outlook viene importata solo la posta, non contatti o calendari.",
"picked": "{count, plural, one {# cartella o file da questo computer} other {# cartelle e file da questo computer}}",
"remove_picked": "Rimuovi questi file",
"contacts_picked": "{count, plural, one {# contatto di Thunderbird va nei tuoi contatti.} other {# contatti di Thunderbird vanno nei tuoi contatti.}}",
"contacts_added": "{count, plural, one {Aggiunto # contatto da Thunderbird.} other {Aggiunti # contatti da Thunderbird.}}",
"contacts_failed": "Alcuni contatti di Thunderbird non sono stati aggiunti.",
"files_refused": "Esclusi perché non più leggibili: {names}",
"skip_seen": "Salta i messaggi che un'importazione precedente ha già messo nella stessa cartella",
"skip_duplicate": "{count, plural, one {# messaggio già importato in precedenza} other {# messaggi già importati in precedenza}}",
"file_unavailable": "Un file è cambiato o non è più leggibile. Chiudi Thunderbird e importa di nuovo. I messaggi già importati vengono saltati.",
"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.",
"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",
"title": "Recupera elementi eliminati",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "エクスポートが止まりました。",
"toast_show": "表示"
},
"desktopImport": {
"find": "Thunderbird と Outlook のデータを探す…",
"find_hint": "このコンピューターの通常の場所を探します。先に Bulwark が確認します。",
"scan_declined": "許可されなかったため、検索しませんでした。",
"scan_failed": "検索できませんでした。もう一度お試しください。",
"read_declined": "許可されなかったため、ファイルを読み取りませんでした。",
"read_failed": "これらのファイルはもう読み取れません。もう一度検索してください。",
"nothing_found": "通常の場所に Thunderbird プロファイルや Outlook データファイルはありません。別の場所のファイルをインポートするには「ファイルを選択」を使ってください。",
"found_list": "このコンピューターで見つかったメールデータ",
"outlook_files": "Outlook データファイル",
"thunderbird_profile": "Thunderbird プロファイル {name}",
"address_book": "{name} のアドレス帳",
"message_count": "{count, plural, other {# 件のメッセージ}}",
"contact_count": "{count, plural, other {# 件の連絡先}}",
"confirm": "{count, plural, other {この # 項目を使う…}}",
"back": "戻る",
"estimate": "{size} を選択しました。Stalwart を含む多くのサーバーは、1 人のユーザーから 1 時間に約 1000 件または 50 MB しか受け付けないため、少なくとも {time}かかります。上限に達するとインポートは一時停止し、自動で再開します。バックグラウンドで動作し、再起動後も続きます。",
"duration_hours": "{count, plural, other {# 時間}}",
"duration_days": "{count, plural, other {# 日}}",
"time_left": "サーバーのアップロード上限で、残り少なくとも {time}です。その間も作業を続けられます。",
"thunderbird_running": "Thunderbird が起動しています。先に閉じてください。インポート中にフォルダーが変更されると、そのフォルダーは最初からやり直しになり、インポート済みのメッセージはスキップされます。",
"ost_refused": "対象外: {names}。Outlook の .ost ファイルはメールボックスのローカルコピーにすぎず、ここではインポートできません。Outlook でメールを .pst ファイルにエクスポートしてください。",
"calendars_left_out": "Thunderbird のカレンダーはインポートされません。Thunderbird から .ics ファイルとしてエクスポートし、カレンダーにインポートしてください。",
"pst_items_left_out": "Outlook ファイルからはメールのみインポートされ、連絡先やカレンダーはインポートされません。",
"picked": "{count, plural, other {このコンピューターの # 個のフォルダーとファイル}}",
"remove_picked": "これらのファイルを削除",
"contacts_picked": "{count, plural, other {Thunderbird の連絡先 # 件を連絡先に追加します。}}",
"contacts_added": "{count, plural, other {Thunderbird から連絡先を # 件追加しました。}}",
"contacts_failed": "Thunderbird の連絡先の一部を追加できませんでした。",
"files_refused": "読み取れなくなったため除外: {names}",
"skip_seen": "以前のインポートで同じフォルダーに入れたメッセージをスキップする",
"skip_duplicate": "{count, plural, other {# 件のメッセージは以前にインポート済みです}}",
"file_unavailable": "ファイルが変更されたか、読み取れなくなりました。Thunderbird を閉じてもう一度インポートしてください。インポート済みのメッセージはスキップされます。",
"resume_gone": "ファイルがなくなったため、このコンピューターからのインポートを続行できません。",
"pst_in_use": "{names} は Outlook で開かれている間は読み取れません。Outlook を閉じてもう一度検索してください。",
"description": "このコンピューターのメールを探すか、.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": "削除済みアイテムの復元",
"title": "削除済みアイテムの復元",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "내보내기가 멈췄습니다.",
"toast_show": "보기"
},
"desktopImport": {
"find": "Thunderbird 및 Outlook 데이터 찾기…",
"find_hint": "이 컴퓨터의 일반적인 위치를 찾습니다. Bulwark가 먼저 묻습니다.",
"scan_declined": "허용하지 않아서 검색하지 않았습니다.",
"scan_failed": "검색하지 못했습니다. 다시 시도하세요.",
"read_declined": "허용하지 않아서 파일을 읽지 않았습니다.",
"read_failed": "이 파일들은 더 이상 읽을 수 없습니다. 다시 검색하세요.",
"nothing_found": "일반적인 위치에 Thunderbird 프로필이나 Outlook 데이터 파일이 없습니다. 다른 위치의 파일을 가져오려면 파일 선택을 사용하세요.",
"found_list": "이 컴퓨터에서 찾은 메일 데이터",
"outlook_files": "Outlook 데이터 파일",
"thunderbird_profile": "Thunderbird 프로필 {name}",
"address_book": "{name}의 주소록",
"message_count": "{count, plural, other {메시지 #개}}",
"contact_count": "{count, plural, other {연락처 #개}}",
"confirm": "{count, plural, other {이 항목 #개 사용…}}",
"back": "뒤로",
"estimate": "{size} 선택됨. Stalwart를 포함한 많은 서버는 사용자 한 명에게서 시간당 약 1000개의 메시지 또는 50MB만 받으므로 최소 {time} 걸립니다. 한도에 도달하면 가져오기가 일시 중지되었다가 자동으로 계속됩니다. 백그라운드에서 실행되며 다시 시작한 후에도 계속됩니다.",
"duration_hours": "{count, plural, other {#시간}}",
"duration_days": "{count, plural, other {#일}}",
"time_left": "서버 업로드 한도 기준으로 최소 {time} 남았습니다. 그동안 계속 작업할 수 있습니다.",
"thunderbird_running": "Thunderbird가 실행 중입니다. 먼저 닫으세요. 가져오는 동안 폴더가 변경되면 그 폴더는 처음부터 다시 시작하고, 이미 가져온 메시지는 건너뜁니다.",
"ost_refused": "제공되지 않음: {names}. Outlook .ost 파일은 사서함의 로컬 복사본일 뿐이라 여기서 가져올 수 없습니다. Outlook에서 메일을 .pst 파일로 내보내세요.",
"calendars_left_out": "Thunderbird 캘린더는 가져오지 않습니다. Thunderbird에서 .ics 파일로 내보낸 다음 캘린더에서 가져오세요.",
"pst_items_left_out": "Outlook 파일에서는 메일만 가져오고, 연락처나 캘린더는 가져오지 않습니다.",
"picked": "{count, plural, other {이 컴퓨터의 폴더 및 파일 #개}}",
"remove_picked": "이 파일 제거",
"contacts_picked": "{count, plural, other {Thunderbird 연락처 #개가 내 연락처에 추가됩니다.}}",
"contacts_added": "{count, plural, other {Thunderbird에서 연락처 #개를 추가했습니다.}}",
"contacts_failed": "일부 Thunderbird 연락처를 추가하지 못했습니다.",
"files_refused": "더 이상 읽을 수 없어 제외됨: {names}",
"skip_seen": "이전 가져오기에서 같은 폴더에 넣은 메시지 건너뛰기",
"skip_duplicate": "{count, plural, other {메시지 #개는 이전에 이미 가져왔습니다}}",
"file_unavailable": "파일이 변경되었거나 더 이상 읽을 수 없습니다. Thunderbird를 닫고 다시 가져오세요. 이미 가져온 메시지는 건너뜁니다.",
"resume_gone": "파일이 없어져 이 컴퓨터에서 가져오기를 계속할 수 없습니다.",
"pst_in_use": "Outlook에서 열려 있는 동안에는 {names}을(를) 읽을 수 없습니다. Outlook을 닫고 다시 검색하세요.",
"description": "이 컴퓨터에서 메일을 찾거나 .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": "삭제된 항목 복구",
"title": "삭제된 항목 복구",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "Eksportēšana apstājās.",
"toast_show": "Rādīt"
},
"desktopImport": {
"find": "Meklēt Thunderbird un Outlook datus…",
"find_hint": "Meklē ierastajās vietās šajā datorā. Bulwark vispirms pajautā.",
"scan_declined": "Nekas netika meklēts, jo jūs to neatļāvāt.",
"scan_failed": "Meklēšana neizdevās. Mēģiniet vēlreiz.",
"read_declined": "Neviens fails netika nolasīts, jo jūs to neatļāvāt.",
"read_failed": "Šos failus vairs nevar nolasīt. Meklējiet vēlreiz.",
"nothing_found": "Ierastajās vietās nav ne Thunderbird profila, ne Outlook datu faila. Lai importētu failu no citurienes, izmantojiet Izvēlēties failus.",
"found_list": "Šajā datorā atrastie pasta dati",
"outlook_files": "Outlook datu faili",
"thunderbird_profile": "Thunderbird profils {name}",
"address_book": "{name} adrešu grāmata",
"message_count": "{count, plural, zero {# ziņojumu} one {# ziņojums} other {# ziņojumi}}",
"contact_count": "{count, plural, zero {# kontaktu} one {# kontakts} other {# kontakti}}",
"confirm": "{count, plural, zero {Izmantot # vienumu…} one {Izmantot šo vienumu…} other {Izmantot šos # vienumus…}}",
"back": "Atpakaļ",
"estimate": "Atlasīts {size}. Daudzi serveri, arī Stalwart, no viena lietotāja pieņem tikai aptuveni 1000 ziņojumu vai 50 MB stundā, tāpēc tas aizņems vismaz {time}. Sasniedzot ierobežojumu, imports nopauzējas un turpinās pats. Tas darbojas fonā un turpinās pēc restartēšanas.",
"duration_hours": "{count, plural, zero {# stundu} one {# stunda} other {# stundas}}",
"duration_days": "{count, plural, zero {# dienu} one {# diena} other {# dienas}}",
"time_left": "Atlikušas vismaz {time} pie servera augšupielādes ierobežojuma. Pa to laiku varat turpināt strādāt.",
"thunderbird_running": "Thunderbird darbojas. Vispirms aizveriet to. Ja importa laikā tas maina mapi, šī mape sākas no jauna, un jau importētie ziņojumi tiek izlaisti.",
"ost_refused": "Nav piedāvāti: {names}. Outlook .ost faili ir tikai pastkastes lokāla kopija, un šeit tos nevar importēt. Outlook programmā eksportējiet pastu uz .pst failu.",
"calendars_left_out": "Thunderbird kalendāri netiek importēti. Eksportējiet tos no Thunderbird kā .ics failus un importējiet kalendārā.",
"pst_items_left_out": "No Outlook failiem tiek importēts tikai pasts, nevis kontakti vai kalendāri.",
"picked": "{count, plural, zero {# mapju un failu no šī datora} one {# mape vai fails no šī datora} other {# mapes un faili no šī datora}}",
"remove_picked": "Noņemt šos failus",
"contacts_picked": "{count, plural, zero {# kontaktu no Thunderbird nonāk jūsu kontaktos.} one {# kontakts no Thunderbird nonāk jūsu kontaktos.} other {# kontakti no Thunderbird nonāk jūsu kontaktos.}}",
"contacts_added": "{count, plural, zero {Pievienots # kontaktu no Thunderbird.} one {Pievienots # kontakts no Thunderbird.} other {Pievienoti # kontakti no Thunderbird.}}",
"contacts_failed": "Dažus Thunderbird kontaktus neizdevās pievienot.",
"files_refused": "Izlaisti, jo tos vairs nevar nolasīt: {names}",
"skip_seen": "Izlaist ziņojumus, ko iepriekšējs imports jau ielicis tajā pašā mapē",
"skip_duplicate": "{count, plural, zero {# ziņojumu jau importēts iepriekš} one {# ziņojums jau importēts iepriekš} other {# ziņojumi jau importēti iepriekš}}",
"file_unavailable": "Fails ir mainījies vai vairs nav nolasāms. Aizveriet Thunderbird un importējiet vēlreiz. Jau importētie ziņojumi tiek izlaisti.",
"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.",
"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",
"title": "Atjaunot dzēstos vienumus",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Экспорт зогслоо.",
"toast_show": "Харуулах"
},
"desktopImport": {
"find": "Thunderbird болон Outlook-ийн өгөгдөл хайх…",
"find_hint": "Энэ компьютерийн ердийн байршлуудад хайна. Bulwark эхлээд асууна.",
"scan_declined": "Та зөвшөөрөөгүй тул юу ч хайгаагүй.",
"scan_failed": "Хайлт амжилтгүй боллоо. Дахин оролдоно уу.",
"read_declined": "Та зөвшөөрөөгүй тул ямар ч файл уншаагүй.",
"read_failed": "Эдгээр файлыг цаашид унших боломжгүй. Дахин хайна уу.",
"nothing_found": "Ердийн байршлуудад Thunderbird профайл эсвэл Outlook өгөгдлийн файл алга. Өөр газраас файл импортлохын тулд Файл сонгох-ыг ашиглана уу.",
"found_list": "Энэ компьютерээс олдсон шуудангийн өгөгдөл",
"outlook_files": "Outlook өгөгдлийн файлууд",
"thunderbird_profile": "Thunderbird профайл {name}",
"address_book": "{name}-ийн хаягийн дэвтэр",
"message_count": "{count, plural, one {# зурвас} other {# зурвас}}",
"contact_count": "{count, plural, one {# харилцагч} other {# харилцагч}}",
"confirm": "{count, plural, one {Энэ зүйлийг ашиглах…} other {Эдгээр # зүйлийг ашиглах…}}",
"back": "Буцах",
"estimate": "{size} сонгосон. Stalwart зэрэг олон сервер нэг хэрэглэгчээс цагт ойролцоогоор 1000 зурвас эсвэл 50 MB л хүлээн авдаг тул дор хаяж {time} шаардана. Хязгаарт хүрэхэд импорт түр зогсоод өөрөө үргэлжилнэ. Энэ нь ард ажиллах ба дахин эхлүүлсний дараа үргэлжилнэ.",
"duration_hours": "{count, plural, one {# цаг} other {# цаг}}",
"duration_days": "{count, plural, one {# өдөр} other {# өдөр}}",
"time_left": "Серверийн байршуулах хязгаараар дор хаяж {time} үлдлээ. Энэ хооронд та ажлаа үргэлжлүүлж болно.",
"thunderbird_running": "Thunderbird ажиллаж байна. Эхлээд хаана уу. Импортын үеэр хавтас өөрчилбөл тэр хавтас дахин эхэлж, аль хэдийн импортолсон зурвасууд алгасагдана.",
"ost_refused": "Санал болгоогүй: {names}. Outlook .ost файл нь зөвхөн шуудангийн хайрцгийн дотоод хуулбар бөгөөд энд импортлох боломжгүй. Outlook дээр шуудангаа .pst файл руу экспортлоно уу.",
"calendars_left_out": "Thunderbird хуанлийг импортлохгүй. Thunderbird-ээс .ics файлаар экспортлоод хуанлид импортлоно уу.",
"pst_items_left_out": "Outlook файлаас зөвхөн шууданг импортолно, харилцагч болон хуанли ороогүй.",
"picked": "{count, plural, one {Энэ компьютерээс # хавтас эсвэл файл} other {Энэ компьютерээс # хавтас ба файл}}",
"remove_picked": "Эдгээр файлыг хасах",
"contacts_picked": "{count, plural, one {Thunderbird-ийн # харилцагч таны харилцагчид орно.} other {Thunderbird-ийн # харилцагч таны харилцагчид орно.}}",
"contacts_added": "{count, plural, one {Thunderbird-ээс # харилцагч нэмлээ.} other {Thunderbird-ээс # харилцагч нэмлээ.}}",
"contacts_failed": "Thunderbird-ийн зарим харилцагчийг нэмж чадсангүй.",
"files_refused": "Цаашид унших боломжгүй тул алгассан: {names}",
"skip_seen": "Өмнөх импортын ижил хавтсанд хийсэн зурвасуудыг алгасах",
"skip_duplicate": "{count, plural, one {# зурвас өмнө нь импортлогдсон} other {# зурвас өмнө нь импортлогдсон}}",
"file_unavailable": "Файл өөрчлөгдсөн эсвэл цаашид уншигдахгүй байна. Thunderbird-ийг хаагаад дахин импортлоно уу. Импортолсон зурвасууд алгасагдана.",
"resume_gone": "Файлууд нь алга болсон тул энэ компьютерээс импорт үргэлжлэх боломжгүй.",
"pst_in_use": "Outlook нээлттэй байхад {names}-г унших боломжгүй. Outlook-ийг хаагаад дахин хайна уу.",
"description": "Энэ компьютерээс шуудан хайх эсвэл .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": "Устгасан зүйлсийг сэргээх",
"title": "Устгасан зүйлсийг сэргээх",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Eksporten stoppet.",
"toast_show": "Vis"
},
"desktopImport": {
"find": "Finn Thunderbird- og Outlook-data…",
"find_hint": "Søker på de vanlige stedene på denne datamaskinen. Bulwark spør først.",
"scan_declined": "Det ble ikke søkt, fordi du ikke tillot det.",
"scan_failed": "Søket virket ikke. Prøv igjen.",
"read_declined": "Ingen filer ble lest, fordi du ikke tillot det.",
"read_failed": "Disse filene kan ikke lenger leses. Søk igjen.",
"nothing_found": "Ingen Thunderbird-profil eller Outlook-datafil på de vanlige stedene. Bruk Velg filer for å importere en fil fra et annet sted.",
"found_list": "E-postdata funnet på denne datamaskinen",
"outlook_files": "Outlook-datafiler",
"thunderbird_profile": "Thunderbird-profil {name}",
"address_book": "Adressebok for {name}",
"message_count": "{count, plural, one {# melding} other {# meldinger}}",
"contact_count": "{count, plural, one {# kontakt} other {# kontakter}}",
"confirm": "{count, plural, one {Bruk dette elementet…} other {Bruk disse # elementene…}}",
"back": "Tilbake",
"estimate": "{size} valgt. Mange servere, også Stalwart, tar bare imot rundt 1000 meldinger eller 50 MB i timen fra én bruker, så dette tar minst {time}. Ved grensen tar importen pause og fortsetter av seg selv. Den kjører i bakgrunnen og fortsetter etter en omstart.",
"duration_hours": "{count, plural, one {# time} other {# timer}}",
"duration_days": "{count, plural, one {# dag} other {# dager}}",
"time_left": "Minst {time} igjen ved serverens opplastingsgrense. Du kan jobbe videre imens.",
"thunderbird_running": "Thunderbird kjører. Lukk det først. Hvis det endrer en mappe under importen, starter mappen på nytt, og meldinger som allerede er importert, hoppes over.",
"ost_refused": "Ikke tilbudt: {names}. Outlooks .ost-filer er bare en lokal kopi av en postkasse og kan ikke importeres her. Eksporter heller e-posten til en .pst-fil i Outlook.",
"calendars_left_out": "Thunderbird-kalendere importeres ikke. Eksporter dem fra Thunderbird som .ics-filer, og importer dem i kalenderen.",
"pst_items_left_out": "Fra Outlook-filer importeres bare e-post, ikke kontakter eller kalendere.",
"picked": "{count, plural, one {# mappe eller fil fra denne datamaskinen} other {# mapper og filer fra denne datamaskinen}}",
"remove_picked": "Fjern disse filene",
"contacts_picked": "{count, plural, one {# kontakt fra Thunderbird kommer i kontaktene dine.} other {# kontakter fra Thunderbird kommer i kontaktene dine.}}",
"contacts_added": "{count, plural, one {# kontakt fra Thunderbird lagt til.} other {# kontakter fra Thunderbird lagt til.}}",
"contacts_failed": "Noen kontakter fra Thunderbird kunne ikke legges til.",
"files_refused": "Utelatt, fordi de ikke lenger kan leses: {names}",
"skip_seen": "Hopp over meldinger som en tidligere import allerede har lagt i samme mappe",
"skip_duplicate": "{count, plural, one {# melding er allerede importert tidligere} other {# meldinger er allerede importert tidligere}}",
"file_unavailable": "En fil er endret eller kan ikke lenger leses. Lukk Thunderbird og importer på nytt. Meldinger som allerede er importert, hoppes over.",
"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.",
"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",
"title": "Gjenopprett slettede elementer",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "De export is gestopt.",
"toast_show": "Tonen"
},
"desktopImport": {
"find": "Thunderbird- en Outlook-gegevens zoeken…",
"find_hint": "Zoekt op de gebruikelijke plekken op deze computer. Bulwark vraagt het eerst.",
"scan_declined": "Er is niets gezocht, omdat je het niet hebt toegestaan.",
"scan_failed": "Het zoeken is mislukt. Probeer het opnieuw.",
"read_declined": "Er zijn geen bestanden gelezen, omdat je het niet hebt toegestaan.",
"read_failed": "Deze bestanden kunnen niet meer worden gelezen. Zoek opnieuw.",
"nothing_found": "Geen Thunderbird-profiel of Outlook-gegevensbestand op de gebruikelijke plekken. Gebruik Bestanden kiezen om een bestand van elders te importeren.",
"found_list": "E-mailgegevens gevonden op deze computer",
"outlook_files": "Outlook-gegevensbestanden",
"thunderbird_profile": "Thunderbird-profiel {name}",
"address_book": "Adresboek van {name}",
"message_count": "{count, plural, one {# bericht} other {# berichten}}",
"contact_count": "{count, plural, one {# contact} other {# contacten}}",
"confirm": "{count, plural, one {Dit item gebruiken…} other {Deze # items gebruiken…}}",
"back": "Terug",
"estimate": "{size} geselecteerd. Veel servers, waaronder Stalwart, nemen maar zo'n 1000 berichten of 50 MB per uur van één gebruiker aan, dus dit duurt minstens {time}. Bij de limiet pauzeert de import en gaat daarna vanzelf verder. Hij loopt op de achtergrond en gaat verder na een herstart.",
"duration_hours": "{count, plural, one {# uur} other {# uur}}",
"duration_days": "{count, plural, one {# dag} other {# dagen}}",
"time_left": "Nog minstens {time} bij de uploadlimiet van de server. Je kunt intussen gewoon doorwerken.",
"thunderbird_running": "Thunderbird is geopend. Sluit het eerst. Als het tijdens de import een map wijzigt, begint die map opnieuw en worden al geïmporteerde berichten overgeslagen.",
"ost_refused": "Niet aangeboden: {names}. .ost-bestanden van Outlook zijn alleen een lokale kopie van een postvak en kunnen hier niet worden geïmporteerd. Exporteer de e-mail in Outlook liever naar een .pst-bestand.",
"calendars_left_out": "Thunderbird-agenda's worden niet geïmporteerd. Exporteer ze in Thunderbird als .ics-bestanden en importeer die in de agenda.",
"pst_items_left_out": "Uit Outlook-bestanden wordt alleen e-mail geïmporteerd, geen contacten of agenda's.",
"picked": "{count, plural, one {# map of bestand van deze computer} other {# mappen en bestanden van deze computer}}",
"remove_picked": "Deze bestanden verwijderen",
"contacts_picked": "{count, plural, one {# contact uit Thunderbird gaat naar je contacten.} other {# contacten uit Thunderbird gaan naar je contacten.}}",
"contacts_added": "{count, plural, one {# contact uit Thunderbird toegevoegd.} other {# contacten uit Thunderbird toegevoegd.}}",
"contacts_failed": "Sommige contacten uit Thunderbird konden niet worden toegevoegd.",
"files_refused": "Weggelaten, omdat ze niet meer kunnen worden gelezen: {names}",
"skip_seen": "Berichten overslaan die een eerdere import al in dezelfde map heeft gezet",
"skip_duplicate": "{count, plural, one {# bericht is al eerder geïmporteerd} other {# berichten zijn al eerder geïmporteerd}}",
"file_unavailable": "Een bestand is gewijzigd of kan niet meer worden gelezen. Sluit Thunderbird en importeer opnieuw. Al geïmporteerde berichten worden overgeslagen.",
"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.",
"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",
"title": "Verwijderde items herstellen",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "Eksport się zatrzymał.",
"toast_show": "Pokaż"
},
"desktopImport": {
"find": "Znajdź dane Thunderbirda i Outlooka…",
"find_hint": "Szuka w typowych miejscach na tym komputerze. Bulwark najpierw zapyta.",
"scan_declined": "Nic nie wyszukano, bo na to nie zezwolono.",
"scan_failed": "Wyszukiwanie się nie udało. Spróbuj ponownie.",
"read_declined": "Nie odczytano żadnych plików, bo na to nie zezwolono.",
"read_failed": "Tych plików nie można już odczytać. Wyszukaj ponownie.",
"nothing_found": "W typowych miejscach nie ma profilu Thunderbirda ani pliku danych Outlooka. Aby zaimportować plik z innego miejsca, użyj Wybierz pliki.",
"found_list": "Dane poczty znalezione na tym komputerze",
"outlook_files": "Pliki danych Outlooka",
"thunderbird_profile": "Profil Thunderbirda {name}",
"address_book": "Książka adresowa: {name}",
"message_count": "{count, plural, one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}}",
"contact_count": "{count, plural, one {# kontakt} few {# kontakty} many {# kontaktów} other {# kontaktów}}",
"confirm": "{count, plural, one {Użyj # elementu…} few {Użyj # elementów…} many {Użyj # elementów…} other {Użyj # elementów…}}",
"back": "Wstecz",
"estimate": "Wybrano {size}. Wiele serwerów, w tym Stalwart, przyjmuje od jednego użytkownika tylko około 1000 wiadomości lub 50 MB na godzinę, więc zajmie to co najmniej {time}. Przy limicie import robi przerwę i sam rusza dalej. Działa w tle i wznawia się po ponownym uruchomieniu.",
"duration_hours": "{count, plural, one {# godzina} few {# godziny} many {# godzin} other {# godzin}}",
"duration_days": "{count, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}",
"time_left": "Zostało co najmniej {time} przy limicie wysyłania serwera. W tym czasie możesz dalej pracować.",
"thunderbird_running": "Thunderbird jest uruchomiony. Najpierw go zamknij. Jeśli podczas importu zmieni folder, ten folder zacznie się od nowa, a zaimportowane już wiadomości zostaną pominięte.",
"ost_refused": "Niedostępne: {names}. Pliki .ost Outlooka to tylko lokalna kopia skrzynki i nie można ich tu zaimportować. W Outlooku wyeksportuj pocztę do pliku .pst.",
"calendars_left_out": "Kalendarze Thunderbirda nie są importowane. Wyeksportuj je z Thunderbirda jako pliki .ics i zaimportuj w kalendarzu.",
"pst_items_left_out": "Z plików Outlooka importowana jest tylko poczta, bez kontaktów i kalendarzy.",
"picked": "{count, plural, one {# folder lub plik z tego komputera} few {# foldery i pliki z tego komputera} many {# folderów i plików z tego komputera} other {# folderów i plików z tego komputera}}",
"remove_picked": "Usuń te pliki",
"contacts_picked": "{count, plural, one {# kontakt z Thunderbirda trafi do kontaktów.} few {# kontakty z Thunderbirda trafią do kontaktów.} many {# kontaktów z Thunderbirda trafi do kontaktów.} other {# kontaktów z Thunderbirda trafi do kontaktów.}}",
"contacts_added": "{count, plural, one {Dodano # kontakt z Thunderbirda.} few {Dodano # kontakty z Thunderbirda.} many {Dodano # kontaktów z Thunderbirda.} other {Dodano # kontaktów z Thunderbirda.}}",
"contacts_failed": "Nie udało się dodać niektórych kontaktów z Thunderbirda.",
"files_refused": "Pominięto, bo nie można ich już odczytać: {names}",
"skip_seen": "Pomiń wiadomości, które wcześniejszy import umieścił już w tym samym folderze",
"skip_duplicate": "{count, plural, one {# wiadomość zaimportowano już wcześniej} few {# wiadomości zaimportowano już wcześniej} many {# wiadomości zaimportowano już wcześniej} other {# wiadomości zaimportowano już wcześniej}}",
"file_unavailable": "Plik się zmienił lub nie można go już odczytać. Zamknij Thunderbirda i zaimportuj ponownie. Zaimportowane już wiadomości zostaną pominięte.",
"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.",
"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",
"title": "Odzyskaj usunięte elementy",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "A exportação parou.",
"toast_show": "Mostrar"
},
"desktopImport": {
"find": "Procurar dados do Thunderbird e do Outlook…",
"find_hint": "Procura nos locais habituais deste computador. O Bulwark pergunta primeiro.",
"scan_declined": "Nada foi procurado porque você não permitiu.",
"scan_failed": "A procura não funcionou. Tente novamente.",
"read_declined": "Nenhum arquivo foi lido porque você não permitiu.",
"read_failed": "Estes arquivos já não podem ser lidos. Procure novamente.",
"nothing_found": "Nenhum perfil do Thunderbird nem arquivo de dados do Outlook nos locais habituais. Para importar um arquivo de outro local, use Escolher arquivos.",
"found_list": "Dados de e-mail encontrados neste computador",
"outlook_files": "Arquivos de dados do Outlook",
"thunderbird_profile": "Perfil do Thunderbird {name}",
"address_book": "Catálogo de endereços de {name}",
"message_count": "{count, plural, one {# mensagem} other {# mensagens}}",
"contact_count": "{count, plural, one {# contato} other {# contatos}}",
"confirm": "{count, plural, one {Usar este item…} other {Usar estes # itens…}}",
"back": "Voltar",
"estimate": "{size} selecionados. Muitos servidores, entre eles o Stalwart, aceitam só cerca de 1000 mensagens ou 50 MB por hora de um usuário, então isso leva pelo menos {time}. No limite a importação pausa e continua sozinha. Ela roda em segundo plano e continua depois de reiniciar.",
"duration_hours": "{count, plural, one {# hora} other {# horas}}",
"duration_days": "{count, plural, one {# dia} other {# dias}}",
"time_left": "Faltam pelo menos {time} no limite de envio do servidor. Enquanto isso, você pode continuar trabalhando.",
"thunderbird_running": "O Thunderbird está aberto. Feche-o primeiro. Se ele alterar uma pasta durante a importação, essa pasta recomeça, e as mensagens já importadas são ignoradas.",
"ost_refused": "Não oferecidos: {names}. Os arquivos .ost do Outlook são apenas uma cópia local de uma caixa de correio e não podem ser importados aqui. No Outlook, exporte o e-mail para um arquivo .pst.",
"calendars_left_out": "Os calendários do Thunderbird não são importados. Exporte-os do Thunderbird como arquivos .ics e importe-os no calendário.",
"pst_items_left_out": "Dos arquivos do Outlook só o e-mail é importado, não contatos nem calendários.",
"picked": "{count, plural, one {# pasta ou arquivo deste computador} other {# pastas e arquivos deste computador}}",
"remove_picked": "Remover estes arquivos",
"contacts_picked": "{count, plural, one {# contato do Thunderbird vai para seus contatos.} other {# contatos do Thunderbird vão para seus contatos.}}",
"contacts_added": "{count, plural, one {# contato do Thunderbird adicionado.} other {# contatos do Thunderbird adicionados.}}",
"contacts_failed": "Alguns contatos do Thunderbird não puderam ser adicionados.",
"files_refused": "Deixados de fora, pois já não podem ser lidos: {names}",
"skip_seen": "Ignorar mensagens que uma importação anterior já colocou na mesma pasta",
"skip_duplicate": "{count, plural, one {# mensagem já foi importada antes} other {# mensagens já foram importadas antes}}",
"file_unavailable": "Um arquivo mudou ou já não pode ser lido. Feche o Thunderbird e importe novamente. As mensagens já importadas são ignoradas.",
"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.",
"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",
"title": "Recuperar itens excluídos",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Exportul s-a oprit.",
"toast_show": "Afișează"
},
"desktopImport": {
"find": "Găsește datele Thunderbird și Outlook…",
"find_hint": "Caută în locurile obișnuite de pe acest computer. Bulwark întreabă mai întâi.",
"scan_declined": "Nu s-a căutat nimic, pentru că nu ați permis.",
"scan_failed": "Căutarea nu a funcționat. Încercați din nou.",
"read_declined": "Nu s-a citit niciun fișier, pentru că nu ați permis.",
"read_failed": "Aceste fișiere nu mai pot fi citite. Căutați din nou.",
"nothing_found": "Niciun profil Thunderbird sau fișier de date Outlook în locurile obișnuite. Pentru a importa un fișier din altă parte, folosiți Alegeți fișiere.",
"found_list": "Date de e-mail găsite pe acest computer",
"outlook_files": "Fișiere de date Outlook",
"thunderbird_profile": "Profil Thunderbird {name}",
"address_book": "Agenda lui {name}",
"message_count": "{count, plural, one {# mesaj} few {# mesaje} other {# de mesaje}}",
"contact_count": "{count, plural, one {# contact} few {# contacte} other {# de contacte}}",
"confirm": "{count, plural, one {Folosește acest element…} few {Folosește aceste # elemente…} other {Folosește aceste # de elemente…}}",
"back": "Înapoi",
"estimate": "{size} selectați. Multe servere, printre care Stalwart, acceptă doar aproximativ 1000 de mesaje sau 50 MB pe oră de la un utilizator, deci durează cel puțin {time}. La limită, importul se oprește și continuă singur. Rulează în fundal și continuă după o repornire.",
"duration_hours": "{count, plural, one {# oră} few {# ore} other {# de ore}}",
"duration_days": "{count, plural, one {# zi} few {# zile} other {# de zile}}",
"time_left": "Mai rămân cel puțin {time} la limita de încărcare a serverului. Între timp puteți lucra în continuare.",
"thunderbird_running": "Thunderbird rulează. Închideți-l mai întâi. Dacă schimbă un dosar în timpul importului, acel dosar o ia de la capăt, iar mesajele deja importate sunt omise.",
"ost_refused": "Nu sunt oferite: {names}. Fișierele .ost din Outlook sunt doar o copie locală a unei căsuțe poștale și nu pot fi importate aici. În Outlook, exportați e-mailurile într-un fișier .pst.",
"calendars_left_out": "Calendarele Thunderbird nu sunt importate. Exportați-le din Thunderbird ca fișiere .ics și importați-le în calendar.",
"pst_items_left_out": "Din fișierele Outlook se importă doar e-mailurile, nu contactele sau calendarele.",
"picked": "{count, plural, one {# dosar sau fișier de pe acest computer} few {# dosare și fișiere de pe acest computer} other {# de dosare și fișiere de pe acest computer}}",
"remove_picked": "Eliminați aceste fișiere",
"contacts_picked": "{count, plural, one {# contact din Thunderbird ajunge în contactele dvs.} few {# contacte din Thunderbird ajung în contactele dvs.} other {# de contacte din Thunderbird ajung în contactele dvs.}}",
"contacts_added": "{count, plural, one {S-a adăugat # contact din Thunderbird.} few {S-au adăugat # contacte din Thunderbird.} other {S-au adăugat # de contacte din Thunderbird.}}",
"contacts_failed": "Unele contacte din Thunderbird nu au putut fi adăugate.",
"files_refused": "Omise, pentru că nu mai pot fi citite: {names}",
"skip_seen": "Omiteți mesajele pe care un import anterior le-a pus deja în același dosar",
"skip_duplicate": "{count, plural, one {# mesaj a fost deja importat} few {# mesaje au fost deja importate} other {# de mesaje au fost deja importate}}",
"file_unavailable": "Un fișier s-a schimbat sau nu mai poate fi citit. Închideți Thunderbird și importați din nou. Mesajele deja importate sunt omise.",
"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.",
"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",
"title": "Recuperați elementele șterse",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "Экспорт остановлен.",
"toast_show": "Показать"
},
"desktopImport": {
"find": "Найти данные Thunderbird и Outlook…",
"find_hint": "Поиск в обычных местах на этом компьютере. Сначала Bulwark спросит вас.",
"scan_declined": "Поиск не выполнялся, потому что вы его не разрешили.",
"scan_failed": "Поиск не удался. Попробуйте ещё раз.",
"read_declined": "Файлы не прочитаны, потому что вы это не разрешили.",
"read_failed": "Эти файлы больше нельзя прочитать. Выполните поиск ещё раз.",
"nothing_found": "В обычных местах нет профиля Thunderbird и файлов данных Outlook. Чтобы импортировать файл из другого места, используйте «Выбрать файлы».",
"found_list": "Почтовые данные, найденные на этом компьютере",
"outlook_files": "Файлы данных Outlook",
"thunderbird_profile": "Профиль Thunderbird {name}",
"address_book": "Адресная книга {name}",
"message_count": "{count, plural, one {# письмо} few {# письма} many {# писем} other {# писем}}",
"contact_count": "{count, plural, one {# контакт} few {# контакта} many {# контактов} other {# контактов}}",
"confirm": "{count, plural, one {Использовать # элемент…} few {Использовать # элемента…} many {Использовать # элементов…} other {Использовать # элементов…}}",
"back": "Назад",
"estimate": "Выбрано {size}. Многие серверы, в том числе Stalwart, принимают от одного пользователя только около 1000 писем или 50 МБ в час, поэтому это займёт не менее {time}. При достижении лимита импорт приостанавливается и продолжается сам. Он идёт в фоне и продолжается после перезапуска.",
"duration_hours": "{count, plural, one {# час} few {# часа} many {# часов} other {# часов}}",
"duration_days": "{count, plural, one {# день} few {# дня} many {# дней} other {# дней}}",
"time_left": "Осталось не менее {time} при лимите загрузки сервера. Пока можно продолжать работать.",
"thunderbird_running": "Thunderbird запущен. Сначала закройте его. Если он изменит папку во время импорта, эта папка начнётся заново, а уже импортированные письма будут пропущены.",
"ost_refused": "Не предлагаются: {names}. Файлы Outlook .ost — это только локальная копия почтового ящика, и здесь их импортировать нельзя. Экспортируйте почту в Outlook в файл .pst.",
"calendars_left_out": "Календари Thunderbird не импортируются. Экспортируйте их из Thunderbird в файлы .ics и импортируйте в календарь.",
"pst_items_left_out": "Из файлов Outlook импортируется только почта, без контактов и календарей.",
"picked": "{count, plural, one {# папка или файл с этого компьютера} few {# папки и файла с этого компьютера} many {# папок и файлов с этого компьютера} other {# папок и файлов с этого компьютера}}",
"remove_picked": "Убрать эти файлы",
"contacts_picked": "{count, plural, one {# контакт из Thunderbird попадёт в ваши контакты.} few {# контакта из Thunderbird попадут в ваши контакты.} many {# контактов из Thunderbird попадут в ваши контакты.} other {# контактов из Thunderbird попадут в ваши контакты.}}",
"contacts_added": "{count, plural, one {Добавлен # контакт из Thunderbird.} few {Добавлено # контакта из Thunderbird.} many {Добавлено # контактов из Thunderbird.} other {Добавлено # контактов из Thunderbird.}}",
"contacts_failed": "Некоторые контакты из Thunderbird добавить не удалось.",
"files_refused": "Пропущены, потому что их больше нельзя прочитать: {names}",
"skip_seen": "Пропускать письма, которые предыдущий импорт уже поместил в ту же папку",
"skip_duplicate": "{count, plural, one {# письмо уже импортировано ранее} few {# письма уже импортированы ранее} many {# писем уже импортировано ранее} other {# писем уже импортировано ранее}}",
"file_unavailable": "Файл изменился или его больше нельзя прочитать. Закройте Thunderbird и импортируйте ещё раз. Уже импортированные письма будут пропущены.",
"resume_gone": "Импорт с этого компьютера не может продолжаться, потому что его файлы исчезли.",
"pst_in_use": "{names} нельзя прочитать, пока файл открыт в Outlook. Закройте Outlook и выполните поиск ещё раз.",
"description": "Найдите почту на этом компьютере или выберите файлы .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": "Восстановить удалённое",
"title": "Восстановить удалённое",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Export sa zastavil.",
"toast_show": "Zobraziť"
},
"desktopImport": {
"find": "Nájsť údaje Thunderbirdu a Outlooku…",
"find_hint": "Hľadá na obvyklých miestach v tomto počítači. Bulwark sa najprv opýta.",
"scan_declined": "Nič sa nehľadalo, pretože ste to nepovolili.",
"scan_failed": "Hľadanie sa nepodarilo. Skúste to znova.",
"read_declined": "Žiadne súbory sa nečítali, pretože ste to nepovolili.",
"read_failed": "Tieto súbory sa už nedajú prečítať. Hľadajte znova.",
"nothing_found": "Na obvyklých miestach nie je žiadny profil Thunderbirdu ani dátový súbor Outlooku. Ak chcete importovať súbor odinakiaľ, použite Vybrať súbory.",
"found_list": "Poštové údaje nájdené v tomto počítači",
"outlook_files": "Dátové súbory Outlooku",
"thunderbird_profile": "Profil Thunderbirdu {name}",
"address_book": "Adresár profilu {name}",
"message_count": "{count, plural, one {# správa} few {# správy} many {# správy} other {# správ}}",
"contact_count": "{count, plural, one {# kontakt} few {# kontakty} many {# kontaktu} other {# kontaktov}}",
"confirm": "{count, plural, one {Použiť túto položku…} few {Použiť # položky…} many {Použiť # položky…} other {Použiť # položiek…}}",
"back": "Späť",
"estimate": "Vybrané {size}. Mnohé servery vrátane Stalwartu prijímajú od jedného používateľa len asi 1000 správ alebo 50 MB za hodinu, takže to potrvá najmenej {time}. Na limite sa import pozastaví a sám pokračuje. Beží na pozadí a po reštarte pokračuje.",
"duration_hours": "{count, plural, one {# hodinu} few {# hodiny} many {# hodiny} other {# hodín}}",
"duration_days": "{count, plural, one {# deň} few {# dni} many {# dňa} other {# dní}}",
"time_left": "Zostáva najmenej {time} pri limite nahrávania servera. Medzitým môžete ďalej pracovať.",
"thunderbird_running": "Thunderbird beží. Najprv ho zatvorte. Ak počas importu zmení priečinok, tento priečinok začne odznova a už importované správy sa preskočia.",
"ost_refused": "Neponúkané: {names}. Súbory .ost Outlooku sú len lokálna kópia schránky a tu sa nedajú importovať. V Outlooku radšej exportujte poštu do súboru .pst.",
"calendars_left_out": "Kalendáre Thunderbirdu sa neimportujú. Exportujte ich z Thunderbirdu ako súbory .ics a tie importujte do kalendára.",
"pst_items_left_out": "Zo súborov Outlooku sa importuje len pošta, nie kontakty ani kalendáre.",
"picked": "{count, plural, one {# priečinok alebo súbor z tohto počítača} few {# priečinky a súbory z tohto počítača} many {# priečinka a súboru z tohto počítača} other {# priečinkov a súborov z tohto počítača}}",
"remove_picked": "Odstrániť tieto súbory",
"contacts_picked": "{count, plural, one {# kontakt z Thunderbirdu prejde do vašich kontaktov.} few {# kontakty z Thunderbirdu prejdú do vašich kontaktov.} many {# kontaktu z Thunderbirdu prejde do vašich kontaktov.} other {# kontaktov z Thunderbirdu prejde do vašich kontaktov.}}",
"contacts_added": "{count, plural, one {Pridaný # kontakt z Thunderbirdu.} few {Pridané # kontakty z Thunderbirdu.} many {Pridaných # kontaktu z Thunderbirdu.} other {Pridaných # kontaktov z Thunderbirdu.}}",
"contacts_failed": "Niektoré kontakty z Thunderbirdu sa nepodarilo pridať.",
"files_refused": "Vynechané, pretože sa už nedajú prečítať: {names}",
"skip_seen": "Preskočiť správy, ktoré skorší import už uložil do rovnakého priečinka",
"skip_duplicate": "{count, plural, one {# správa už bola importovaná skôr} few {# správy už boli importované skôr} many {# správy už bolo importovanej skôr} other {# správ už bolo importovaných skôr}}",
"file_unavailable": "Súbor sa zmenil alebo sa už nedá prečítať. Zatvorte Thunderbird a importujte znova. Už importované správy sa preskočia.",
"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.",
"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",
"title": "Obnoviť odstránené položky",
+42
View File
@@ -508,6 +508,48 @@
"toast_stopped": "การส่งออกหยุดลง",
"toast_show": "แสดง"
},
"desktopImport": {
"find": "ค้นหาข้อมูล Thunderbird และ Outlook…",
"find_hint": "ค้นหาในตำแหน่งปกติบนคอมพิวเตอร์เครื่องนี้ Bulwark จะถามคุณก่อน",
"scan_declined": "ไม่ได้ค้นหา เพราะคุณไม่อนุญาต",
"scan_failed": "ค้นหาไม่สำเร็จ ลองอีกครั้ง",
"read_declined": "ไม่ได้อ่านไฟล์ใด เพราะคุณไม่อนุญาต",
"read_failed": "ไม่สามารถอ่านไฟล์เหล่านี้ได้อีก ค้นหาอีกครั้ง",
"nothing_found": "ไม่พบโปรไฟล์ Thunderbird หรือไฟล์ข้อมูล Outlook ในตำแหน่งปกติ หากต้องการนำเข้าไฟล์จากที่อื่น ให้ใช้ เลือกไฟล์",
"found_list": "ข้อมูลเมลที่พบบนคอมพิวเตอร์เครื่องนี้",
"outlook_files": "ไฟล์ข้อมูล Outlook",
"thunderbird_profile": "โปรไฟล์ Thunderbird {name}",
"address_book": "สมุดที่อยู่ของ {name}",
"message_count": "{count, plural, other {# ข้อความ}}",
"contact_count": "{count, plural, other {# รายชื่อ}}",
"confirm": "{count, plural, other {ใช้ # รายการนี้…}}",
"back": "กลับ",
"estimate": "เลือกแล้ว {size} เซิร์ฟเวอร์จำนวนมาก รวมถึง Stalwart รับได้เพียงประมาณ 1000 ข้อความหรือ 50 MB ต่อชั่วโมงจากผู้ใช้หนึ่งคน จึงใช้เวลาอย่างน้อย {time} เมื่อถึงขีดจำกัด การนำเข้าจะหยุดชั่วคราวแล้วทำต่อเอง โดยทำงานในพื้นหลังและทำต่อหลังรีสตาร์ท",
"duration_hours": "{count, plural, other {# ชั่วโมง}}",
"duration_days": "{count, plural, other {# วัน}}",
"time_left": "เหลืออย่างน้อย {time} ตามขีดจำกัดการอัปโหลดของเซิร์ฟเวอร์ ระหว่างนี้คุณทำงานต่อได้",
"thunderbird_running": "Thunderbird กำลังทำงาน ปิดก่อน หากโปรแกรมเปลี่ยนโฟลเดอร์ระหว่างการนำเข้า โฟลเดอร์นั้นจะเริ่มใหม่ และข้อความที่นำเข้าแล้วจะถูกข้าม",
"ost_refused": "ไม่แสดง: {names} ไฟล์ .ost ของ Outlook เป็นเพียงสำเนาในเครื่องของกล่องจดหมายและนำเข้าที่นี่ไม่ได้ ให้ส่งออกเมลใน Outlook เป็นไฟล์ .pst แทน",
"calendars_left_out": "ปฏิทิน Thunderbird จะไม่ถูกนำเข้า ส่งออกจาก Thunderbird เป็นไฟล์ .ics แล้วนำเข้าในปฏิทิน",
"pst_items_left_out": "จากไฟล์ Outlook จะนำเข้าเฉพาะเมล ไม่รวมรายชื่อหรือปฏิทิน",
"picked": "{count, plural, other {# โฟลเดอร์และไฟล์จากคอมพิวเตอร์เครื่องนี้}}",
"remove_picked": "เอาไฟล์เหล่านี้ออก",
"contacts_picked": "{count, plural, other {# รายชื่อจาก Thunderbird จะเข้าสู่รายชื่อของคุณ}}",
"contacts_added": "{count, plural, other {เพิ่ม # รายชื่อจาก Thunderbird แล้ว}}",
"contacts_failed": "ไม่สามารถเพิ่มรายชื่อบางรายการจาก Thunderbird ได้",
"files_refused": "ข้ามไป เพราะอ่านไม่ได้อีก: {names}",
"skip_seen": "ข้ามข้อความที่การนำเข้าครั้งก่อนใส่ไว้ในโฟลเดอร์เดียวกันแล้ว",
"skip_duplicate": "{count, plural, other {# ข้อความเคยนำเข้าแล้ว}}",
"file_unavailable": "ไฟล์มีการเปลี่ยนแปลงหรืออ่านไม่ได้อีก ปิด Thunderbird แล้วนำเข้าอีกครั้ง ข้อความที่นำเข้าแล้วจะถูกข้าม",
"resume_gone": "การนำเข้าจากคอมพิวเตอร์เครื่องนี้ดำเนินต่อไม่ได้ เพราะไฟล์หายไป",
"pst_in_use": "อ่าน {names} ไม่ได้ขณะที่ Outlook เปิดไฟล์อยู่ ปิด Outlook แล้วค้นหาอีกครั้ง",
"description": "ค้นหาเมลบนคอมพิวเตอร์เครื่องนี้ หรือเลือกไฟล์ .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": "กู้คืนรายการที่ลบ",
"title": "กู้คืนรายการที่ลบ",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "Dışa aktarım durdu.",
"toast_show": "Göster"
},
"desktopImport": {
"find": "Thunderbird ve Outlook verilerini bul…",
"find_hint": "Bu bilgisayarda olağan yerlerde arar. Bulwark önce size sorar.",
"scan_declined": "İzin vermediğiniz için arama yapılmadı.",
"scan_failed": "Arama çalışmadı. Yeniden deneyin.",
"read_declined": "İzin vermediğiniz için hiçbir dosya okunmadı.",
"read_failed": "Bu dosyalar artık okunamıyor. Yeniden arayın.",
"nothing_found": "Olağan yerlerde Thunderbird profili veya Outlook veri dosyası yok. Başka bir yerden dosya içe aktarmak için Dosya seç'i kullanın.",
"found_list": "Bu bilgisayarda bulunan posta verileri",
"outlook_files": "Outlook veri dosyaları",
"thunderbird_profile": "Thunderbird profili {name}",
"address_book": "{name} adres defteri",
"message_count": "{count, plural, one {# ileti} other {# ileti}}",
"contact_count": "{count, plural, one {# kişi} other {# kişi}}",
"confirm": "{count, plural, one {Bu öğeyi kullan…} other {Bu # öğeyi kullan…}}",
"back": "Geri",
"estimate": "{size} seçildi. Stalwart dahil birçok sunucu bir kullanıcıdan saatte yalnızca yaklaşık 1000 ileti veya 50 MB kabul eder, bu yüzden bu en az {time} sürer. Sınırda içe aktarma durur ve kendiliğinden devam eder. Arka planda çalışır ve yeniden başlatmadan sonra sürer.",
"duration_hours": "{count, plural, one {# saat} other {# saat}}",
"duration_days": "{count, plural, one {# gün} other {# gün}}",
"time_left": "Sunucunun yükleme sınırıyla en az {time} kaldı. Bu sırada çalışmaya devam edebilirsiniz.",
"thunderbird_running": "Thunderbird çalışıyor. Önce kapatın. İçe aktarma sırasında bir klasörü değiştirirse o klasör baştan başlar ve zaten aktarılan iletiler atlanır.",
"ost_refused": "Sunulmuyor: {names}. Outlook .ost dosyaları yalnızca bir posta kutusunun yerel kopyasıdır ve burada içe aktarılamaz. Bunun yerine Outlook'ta postayı bir .pst dosyasına aktarın.",
"calendars_left_out": "Thunderbird takvimleri içe aktarılmaz. Thunderbird'den .ics dosyası olarak dışa aktarıp takvime aktarın.",
"pst_items_left_out": "Outlook dosyalarından yalnızca posta aktarılır, kişiler ve takvimler aktarılmaz.",
"picked": "{count, plural, one {Bu bilgisayardan # klasör veya dosya} other {Bu bilgisayardan # klasör ve dosya}}",
"remove_picked": "Bu dosyaları kaldır",
"contacts_picked": "{count, plural, one {Thunderbird'den # kişi kişilerinize eklenecek.} other {Thunderbird'den # kişi kişilerinize eklenecek.}}",
"contacts_added": "{count, plural, one {Thunderbird'den # kişi eklendi.} other {Thunderbird'den # kişi eklendi.}}",
"contacts_failed": "Thunderbird'den bazı kişiler eklenemedi.",
"files_refused": "Artık okunamadıkları için atlandı: {names}",
"skip_seen": "Önceki bir içe aktarmanın aynı klasöre koyduğu iletileri atla",
"skip_duplicate": "{count, plural, one {# ileti daha önce içe aktarılmış} other {# ileti daha önce içe aktarılmış}}",
"file_unavailable": "Bir dosya değişti veya artık okunamıyor. Thunderbird'ü kapatıp yeniden içe aktarın. Zaten aktarılan iletiler atlanır.",
"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.",
"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",
"title": "Silinen öğeleri kurtar",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "Експорт зупинено.",
"toast_show": "Показати"
},
"desktopImport": {
"find": "Знайти дані Thunderbird та Outlook…",
"find_hint": "Пошук у звичних місцях на цьому комп'ютері. Спершу Bulwark запитає вас.",
"scan_declined": "Пошук не виконувався, бо ви його не дозволили.",
"scan_failed": "Пошук не вдався. Спробуйте ще раз.",
"read_declined": "Файли не прочитано, бо ви цього не дозволили.",
"read_failed": "Ці файли більше не можна прочитати. Виконайте пошук ще раз.",
"nothing_found": "У звичних місцях немає профілю Thunderbird і файлів даних Outlook. Щоб імпортувати файл з іншого місця, скористайтеся «Вибрати файли».",
"found_list": "Поштові дані, знайдені на цьому комп'ютері",
"outlook_files": "Файли даних Outlook",
"thunderbird_profile": "Профіль Thunderbird {name}",
"address_book": "Адресна книга {name}",
"message_count": "{count, plural, one {# лист} few {# листи} many {# листів} other {# листів}}",
"contact_count": "{count, plural, one {# контакт} few {# контакти} many {# контактів} other {# контактів}}",
"confirm": "{count, plural, one {Використати # елемент…} few {Використати # елементи…} many {Використати # елементів…} other {Використати # елементів…}}",
"back": "Назад",
"estimate": "Вибрано {size}. Багато серверів, зокрема Stalwart, приймають від одного користувача лише близько 1000 листів або 50 МБ на годину, тому це займе щонайменше {time}. На межі імпорт призупиняється і продовжується сам. Він працює у фоні й продовжується після перезапуску.",
"duration_hours": "{count, plural, one {# година} few {# години} many {# годин} other {# годин}}",
"duration_days": "{count, plural, one {# день} few {# дні} many {# днів} other {# днів}}",
"time_left": "Залишилося щонайменше {time} за межі завантаження сервера. Тим часом можна працювати далі.",
"thunderbird_running": "Thunderbird запущено. Спершу закрийте його. Якщо він змінить теку під час імпорту, ця тека почнеться спочатку, а вже імпортовані листи буде пропущено.",
"ost_refused": "Не пропонуються: {names}. Файли Outlook .ost — це лише локальна копія поштової скриньки, і тут їх не можна імпортувати. Експортуйте пошту в Outlook у файл .pst.",
"calendars_left_out": "Календарі Thunderbird не імпортуються. Експортуйте їх із Thunderbird у файли .ics та імпортуйте в календар.",
"pst_items_left_out": "З файлів Outlook імпортується лише пошта, без контактів і календарів.",
"picked": "{count, plural, one {# тека або файл із цього комп'ютера} few {# теки та файли з цього комп'ютера} many {# тек і файлів із цього комп'ютера} other {# тек і файлів із цього комп'ютера}}",
"remove_picked": "Прибрати ці файли",
"contacts_picked": "{count, plural, one {# контакт із Thunderbird потрапить до ваших контактів.} few {# контакти з Thunderbird потраплять до ваших контактів.} many {# контактів із Thunderbird потраплять до ваших контактів.} other {# контактів із Thunderbird потраплять до ваших контактів.}}",
"contacts_added": "{count, plural, one {Додано # контакт із Thunderbird.} few {Додано # контакти з Thunderbird.} many {Додано # контактів із Thunderbird.} other {Додано # контактів із Thunderbird.}}",
"contacts_failed": "Деякі контакти з Thunderbird не вдалося додати.",
"files_refused": "Пропущено, бо їх більше не можна прочитати: {names}",
"skip_seen": "Пропускати листи, які попередній імпорт уже поклав у ту саму теку",
"skip_duplicate": "{count, plural, one {# лист уже імпортовано раніше} few {# листи вже імпортовано раніше} many {# листів уже імпортовано раніше} other {# листів уже імпортовано раніше}}",
"file_unavailable": "Файл змінився або його більше не можна прочитати. Закрийте Thunderbird та імпортуйте ще раз. Уже імпортовані листи буде пропущено.",
"resume_gone": "Імпорт із цього комп'ютера не може продовжуватися, бо його файли зникли.",
"pst_in_use": "{names} не можна прочитати, поки файл відкрито в Outlook. Закрийте Outlook і виконайте пошук ще раз.",
"description": "Знайдіть пошту на цьому комп'ютері або виберіть файли .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": "Відновити видалене",
"title": "Відновити видалене",
+42
View File
@@ -5644,6 +5644,48 @@
"toast_stopped": "Việc xuất đã dừng.",
"toast_show": "Hiện"
},
"desktopImport": {
"find": "Tìm dữ liệu Thunderbird và Outlook…",
"find_hint": "Tìm ở những nơi thường dùng trên máy tính này. Bulwark sẽ hỏi bạn trước.",
"scan_declined": "Không tìm gì vì bạn không cho phép.",
"scan_failed": "Không tìm được. Hãy thử lại.",
"read_declined": "Không đọc tệp nào vì bạn không cho phép.",
"read_failed": "Không thể đọc các tệp này nữa. Hãy tìm lại.",
"nothing_found": "Không có hồ sơ Thunderbird hay tệp dữ liệu Outlook ở những nơi thường dùng. Để nhập tệp từ nơi khác, hãy dùng Chọn tệp.",
"found_list": "Dữ liệu thư tìm thấy trên máy tính này",
"outlook_files": "Tệp dữ liệu Outlook",
"thunderbird_profile": "Hồ sơ Thunderbird {name}",
"address_book": "Sổ địa chỉ của {name}",
"message_count": "{count, plural, other {# thư}}",
"contact_count": "{count, plural, other {# liên hệ}}",
"confirm": "{count, plural, other {Dùng # mục này…}}",
"back": "Quay lại",
"estimate": "Đã chọn {size}. Nhiều máy chủ, trong đó có Stalwart, chỉ nhận khoảng 1000 thư hoặc 50 MB mỗi giờ từ một người dùng, nên việc này mất ít nhất {time}. Đến giới hạn, việc nhập tạm dừng rồi tự tiếp tục. Nó chạy ở chế độ nền và tiếp tục sau khi khởi động lại.",
"duration_hours": "{count, plural, other {# giờ}}",
"duration_days": "{count, plural, other {# ngày}}",
"time_left": "Còn ít nhất {time} theo giới hạn tải lên của máy chủ. Trong lúc đó bạn vẫn có thể làm việc.",
"thunderbird_running": "Thunderbird đang chạy. Hãy đóng nó trước. Nếu nó thay đổi một thư mục trong khi nhập, thư mục đó sẽ bắt đầu lại, và các thư đã nhập sẽ được bỏ qua.",
"ost_refused": "Không cung cấp: {names}. Tệp .ost của Outlook chỉ là bản sao cục bộ của hộp thư và không thể nhập ở đây. Trong Outlook, hãy xuất thư ra tệp .pst.",
"calendars_left_out": "Lịch Thunderbird không được nhập. Hãy xuất chúng từ Thunderbird thành tệp .ics rồi nhập vào lịch.",
"pst_items_left_out": "Từ tệp Outlook chỉ nhập thư, không nhập liên hệ hay lịch.",
"picked": "{count, plural, other {# thư mục và tệp từ máy tính này}}",
"remove_picked": "Xóa các tệp này",
"contacts_picked": "{count, plural, other {# liên hệ từ Thunderbird sẽ vào danh bạ của bạn.}}",
"contacts_added": "{count, plural, other {Đã thêm # liên hệ từ Thunderbird.}}",
"contacts_failed": "Không thể thêm một số liên hệ từ Thunderbird.",
"files_refused": "Bỏ qua vì không thể đọc nữa: {names}",
"skip_seen": "Bỏ qua các thư mà lần nhập trước đã đưa vào cùng thư mục",
"skip_duplicate": "{count, plural, other {# thư đã được nhập trước đó}}",
"file_unavailable": "Một tệp đã thay đổi hoặc không thể đọc nữa. Hãy đóng Thunderbird và nhập lại. Các thư đã nhập sẽ được bỏ qua.",
"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.",
"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",
"title": "Khôi phục các mục đã xóa",
+42
View File
@@ -5632,6 +5632,48 @@
"toast_stopped": "匯出已停止。",
"toast_show": "顯示"
},
"desktopImport": {
"find": "尋找 Thunderbird 和 Outlook 資料…",
"find_hint": "在這台電腦的常用位置尋找。Bulwark 會先詢問你。",
"scan_declined": "你沒有允許,所以沒有尋找。",
"scan_failed": "尋找失敗。請再試一次。",
"read_declined": "你沒有允許,所以沒有讀取任何檔案。",
"read_failed": "這些檔案已無法讀取。請重新尋找。",
"nothing_found": "常用位置沒有 Thunderbird 設定檔或 Outlook 資料檔。要匯入其他位置的檔案,請使用「選擇檔案」。",
"found_list": "在這台電腦上找到的郵件資料",
"outlook_files": "Outlook 資料檔",
"thunderbird_profile": "Thunderbird 設定檔 {name}",
"address_book": "{name} 的通訊錄",
"message_count": "{count, plural, other {# 封郵件}}",
"contact_count": "{count, plural, other {# 位聯絡人}}",
"confirm": "{count, plural, other {使用這 # 項…}}",
"back": "返回",
"estimate": "已選擇 {size}。包括 Stalwart 在內的許多伺服器每小時只接收一位使用者約 1000 封郵件或 50 MB,因此至少需要 {time}。達到上限時匯入會暫停,然後自動繼續。它在背景執行,重新啟動後也會繼續。",
"duration_hours": "{count, plural, other {# 小時}}",
"duration_days": "{count, plural, other {# 天}}",
"time_left": "依伺服器的上傳限制,至少還需要 {time}。在此期間你可以繼續工作。",
"thunderbird_running": "Thunderbird 正在執行。請先關閉它。如果它在匯入期間變更了資料夾,該資料夾會重新開始,已匯入的郵件會被略過。",
"ost_refused": "未提供:{names}。Outlook .ost 檔案只是信箱的本機副本,無法在這裡匯入。請在 Outlook 中將郵件匯出為 .pst 檔案。",
"calendars_left_out": "不會匯入 Thunderbird 行事曆。請從 Thunderbird 匯出為 .ics 檔案,再匯入行事曆。",
"pst_items_left_out": "從 Outlook 檔案只匯入郵件,不匯入聯絡人和行事曆。",
"picked": "{count, plural, other {來自這台電腦的 # 個資料夾和檔案}}",
"remove_picked": "移除這些檔案",
"contacts_picked": "{count, plural, other {來自 Thunderbird 的 # 位聯絡人將加入你的聯絡人。}}",
"contacts_added": "{count, plural, other {已新增來自 Thunderbird 的 # 位聯絡人。}}",
"contacts_failed": "部分 Thunderbird 聯絡人無法新增。",
"files_refused": "已略過,因為它們已無法讀取:{names}",
"skip_seen": "略過之前的匯入已放入同一資料夾的郵件",
"skip_duplicate": "{count, plural, other {# 封郵件之前已匯入}}",
"file_unavailable": "檔案已變更或已無法讀取。請關閉 Thunderbird 並重新匯入。已匯入的郵件會被略過。",
"resume_gone": "這台電腦的匯入無法繼續,因為檔案已不存在。",
"pst_in_use": "Outlook 開啟 {names} 時無法讀取。請關閉 Outlook 並重新尋找。",
"description": "在這台電腦上尋找郵件,或選擇 .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": "復原已刪除的項目",
"title": "復原已刪除的項目",
+42
View File
@@ -5457,6 +5457,48 @@
"toast_stopped": "导出已停止。",
"toast_show": "显示"
},
"desktopImport": {
"find": "查找 Thunderbird 和 Outlook 数据…",
"find_hint": "在这台电脑的常用位置查找。Bulwark 会先询问你。",
"scan_declined": "你没有允许,所以没有查找。",
"scan_failed": "查找失败。请重试。",
"read_declined": "你没有允许,所以没有读取任何文件。",
"read_failed": "这些文件已无法读取。请重新查找。",
"nothing_found": "常用位置没有 Thunderbird 配置文件或 Outlook 数据文件。要导入其他位置的文件,请使用“选择文件”。",
"found_list": "在这台电脑上找到的邮件数据",
"outlook_files": "Outlook 数据文件",
"thunderbird_profile": "Thunderbird 配置文件 {name}",
"address_book": "{name} 的通讯录",
"message_count": "{count, plural, other {# 封邮件}}",
"contact_count": "{count, plural, other {# 个联系人}}",
"confirm": "{count, plural, other {使用这 # 项…}}",
"back": "返回",
"estimate": "已选择 {size}。包括 Stalwart 在内的许多服务器每小时只接收一个用户约 1000 封邮件或 50 MB,因此至少需要 {time}。达到上限时导入会暂停,然后自动继续。它在后台运行,重启后也会继续。",
"duration_hours": "{count, plural, other {# 小时}}",
"duration_days": "{count, plural, other {# 天}}",
"time_left": "按服务器的上传限制,至少还需要 {time}。在此期间你可以继续工作。",
"thunderbird_running": "Thunderbird 正在运行。请先关闭它。如果它在导入期间更改了文件夹,该文件夹会重新开始,已导入的邮件会被跳过。",
"ost_refused": "未提供:{names}。Outlook .ost 文件只是邮箱的本地副本,无法在这里导入。请在 Outlook 中将邮件导出为 .pst 文件。",
"calendars_left_out": "不会导入 Thunderbird 日历。请从 Thunderbird 导出为 .ics 文件,再导入日历。",
"pst_items_left_out": "从 Outlook 文件只导入邮件,不导入联系人和日历。",
"picked": "{count, plural, other {来自这台电脑的 # 个文件夹和文件}}",
"remove_picked": "移除这些文件",
"contacts_picked": "{count, plural, other {来自 Thunderbird 的 # 个联系人将进入你的联系人。}}",
"contacts_added": "{count, plural, other {已添加来自 Thunderbird 的 # 个联系人。}}",
"contacts_failed": "部分 Thunderbird 联系人无法添加。",
"files_refused": "已跳过,因为它们已无法读取:{names}",
"skip_seen": "跳过之前的导入已放入同一文件夹的邮件",
"skip_duplicate": "{count, plural, other {# 封邮件之前已导入}}",
"file_unavailable": "文件已更改或已无法读取。请关闭 Thunderbird 并重新导入。已导入的邮件会被跳过。",
"resume_gone": "这台电脑的导入无法继续,因为文件已不存在。",
"pst_in_use": "Outlook 打开 {names} 时无法读取。请关闭 Outlook 并重新查找。",
"description": "在这台电脑上查找邮件,或选择 .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": "恢复已删除项目",
"title": "恢复已删除项目",