fix: keep failed messages for the next import run and stop an import that can't go on

A message that failed for a passing reason (a dropped connection, a
server error, an expired token) used to count as done, so continuing
the import skipped it for good, and a slow upload followed by a cancel
imported the messages after it twice.

- The checkpoint keeps every message that is done, also past a gap,
  and the next run of the same import tries only the rest again.
- Ten failures in a row, a full mailbox (overQuota) or a refusal
  (forbidden) stop the import with a message instead of going through
  the whole file.
- An upload without an answer pauses the import instead of failing the
  message: 30 seconds, then until the upload window ends.
- Email/import, the export's Blob/get and folder listing pass the
  client's own 429 pause by, and a 429 on them doesn't pause the rest
  of the app.
- Signing out stops the job of that account only, writes no checkpoint
  and imports nothing more afterwards; another account's sign-out
  leaves the job alone.
- One tab imports a file at a time (Web Locks), and Import waits until
  the dialog knows whether an earlier run can be continued.

The import dialog tests now wait for the Import button to be enabled
before clicking it, since it stays off during that lookup.
This commit is contained in:
Linus Rath
2026-11-01 06:37:08 +01:00
parent 75fbd18bb9
commit 8d48f98261
51 changed files with 708 additions and 134 deletions
@@ -104,6 +104,20 @@ describe('MailExportDialog', () => {
expect(discard).toHaveBeenCalled();
});
it('another account signing out leaves the job alone; its own sign-out stops it with the sign-out reason', async () => {
const { runBeforeSignOut } = await import('@/lib/sign-out-cleanup');
const controller = new AbortController();
render(<MailTransferHost />);
act(() => {
openMailExport({ mailboxId: 'inbox' });
useMailTransferStore.getState().startJob({ kind: 'export', controller, scope: 'bulwark-a', finished: Promise.resolve(), progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } });
});
await act(() => runBeforeSignOut('bulwark-b'));
expect(controller.signal.aborted).toBe(false);
await act(() => runBeforeSignOut('bulwark-a'));
expect(controller.signal.reason).toBe('signout');
});
it('runs on in the background and the chip names the export', () => {
const controller = new AbortController();
render(<MailTransferHost />);
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
import { openMailImport } from '@/stores/mail-transfer-store';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Mailbox } from '@/lib/jmap/types';
// The lookup of an earlier run never answers here.
vi.mock('@/lib/mail-import/checkpoints', () => ({
checkpointKey: () => 'k',
readCheckpoint: () => new Promise(() => {}),
clearCheckpointsFor: async () => {},
}));
describe('MailImportDialog and an earlier run', () => {
it('keeps Import off until it knows whether the file can be continued', async () => {
useEmailStore.setState({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } } as Mailbox] } as never);
useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as never, activeAccountId: 'b1' } as never);
render(<MailTransferHost />);
act(() => openMailImport());
const input = screen.getByTestId('mail-import-file') as HTMLInputElement;
Object.defineProperty(input, 'files', { value: [new File(['From a Tue May 5 10:11:12 2020\n\nb\n'], 'a.mbox')], configurable: true });
fireEvent.change(input);
await screen.findByText('a.mbox');
const start = screen.getByTestId('mail-import-start') as HTMLButtonElement;
await waitFor(() => expect(start.disabled).toBe(true));
await new Promise((r) => setTimeout(r, 100));
expect(start.disabled).toBe(true);
});
});
@@ -48,6 +48,8 @@ describe('MailImportDialog', () => {
pick([new File(['From a Tue May 5 10:11:12 2020\nSubject: x\n\nb\n'], 'old.mbox')]);
await screen.findByText('old.mbox');
expect(screen.getByText(/format_mbox/)).toBeTruthy();
// Import waits until it knows whether an earlier run can be continued.
await waitFor(() => expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('mail-import-start'));
expect(startImport).toHaveBeenCalledWith(expect.objectContaining({
accountId: 'acc2', targetMailboxId: 'shared', keepFolders: false, resume: false,
@@ -67,6 +69,8 @@ describe('MailImportDialog', () => {
const keep = await screen.findByRole('checkbox', { name: /keep_folders/ });
expect((keep as HTMLInputElement).checked).toBe(true);
fireEvent.change(screen.getByTestId('mail-import-target'), { target: { value: '\u0000top' } });
// Import waits until it knows whether an earlier run can be continued.
await waitFor(() => expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByTestId('mail-import-start'));
expect(startImport).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'acc1', targetMailboxId: null, keepFolders: true }));
});
@@ -96,7 +96,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
footer={active ? (
<>
<Button variant="outline" onClick={hide}>{t("background")}</Button>
<Button variant="destructive" onClick={cancelJob} aria-describedby={`${id}-status`}>{t("cancel_export")}</Button>
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`}>{t("cancel_export")}</Button>
</>
) : (
<>
@@ -65,6 +65,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
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 targetMailbox = targets.find((x) => x.mailbox.id === target)?.mailbox;
@@ -86,12 +87,16 @@ 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); return; }
if (picked.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,
})))).then((found) => {
if (current) setResumable(found.reduce((n, c) => n + (c?.done ?? 0), 0));
});
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]);
@@ -136,7 +141,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
footer={active ? (
<>
<Button variant="outline" onClick={hide}>{t("background")}</Button>
<Button variant="destructive" onClick={cancelJob} aria-describedby={`${id}-status`}>{t("cancel_import")}</Button>
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`}>{t("cancel_import")}</Button>
</>
) : (
<Button onClick={close}>{t("close")}</Button>
@@ -147,7 +152,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
);
}
const canStart = picked.length > 0 && !!accountId && !(target === TOP && !effectiveKeep);
const canStart = picked.length > 0 && !!accountId && !checking && !(target === TOP && !effectiveKeep);
return (
<Dialog
@@ -253,11 +258,15 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
type T = ReturnType<typeof useTranslations>;
const PST_ERRORS = ["encrypted", "not_pst", "unreadable", "worker"];
const IMPORT_ERRORS = ["over_quota", "forbidden", "failures", "network", "busy"];
/** The PST reader's errors in words; anything else as the server or browser said it. */
/** Why an import stopped, in words; anything else as the server or browser said it. */
function errorText(t: T, error: string): string {
const code = /^pst:(\w+):/.exec(error)?.[1];
return code && PST_ERRORS.includes(code) ? t(`pst_error_${code}`) : error;
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];
if (own && IMPORT_ERRORS.includes(own)) return t(`error_${own}`);
return error;
}
function progressTitle(t: T, p: ImportProgress): string {
@@ -312,6 +321,7 @@ function ProgressBody({ progress: p, t, locale, statusId }: { progress: ImportPr
</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>}
</div>
)}
@@ -10,6 +10,9 @@ import { MailImportDialog } from "./mail-import-dialog";
import { MailExportDialog } from "./mail-export-dialog";
import { formatClock } from "./transfer-progress";
/** How long sign-out waits for a cancelled job to stop (its budget is 2 s). */
const SIGN_OUT_WAIT_MS = 1500;
/**
* Draws the mail import and export dialogs, and while it is hidden with a job running,
* a chip in the corner that shows the progress and brings it back. Mounted
@@ -22,10 +25,18 @@ export function MailTransferHost() {
// An account that signs out takes its running job, a finished export's
// file in the browser's private storage, and its checkpoints with it.
// Another account signing out leaves the job alone. The job's checkpoint
// goes after the job has stopped, so a late write can't bring it back.
useEffect(() => onBeforeSignOut(async (accountId) => {
const store = useMailTransferStore.getState();
if (isJobActive(store.job)) store.cancelJob();
else store.close();
const job = store.job;
if (job && (accountId === "all" || !job.scope || job.scope === accountId)) {
if (isJobActive(job)) {
store.cancelJob("signout");
await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
}
useMailTransferStore.getState().close();
}
if (accountId !== "all") await clearCheckpointsFor(accountId);
}), []);
+16 -3
View File
@@ -1,5 +1,6 @@
import { runExport, folderTree, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine";
import type { ExportSink } from "@/lib/mail-export/sinks";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useAuthStore } from "@/stores/auth-store";
import { useMailTransferStore } from "@/stores/mail-transfer-store";
@@ -14,15 +15,27 @@ export interface StartExportInput {
/** Runs an export as the dialog's job. */
export function startExport(input: StartExportInput): void {
const client = useAuthStore.getState().client;
if (!client) return;
const auth = useAuthStore.getState();
if (!auth.client) return;
const client: IJMAPClient = auth.client;
const controller = new AbortController();
let ended: () => void = () => {};
const finished = new Promise<void>((resolve) => { ended = resolve; });
useMailTransferStore.getState().startJob({
kind: "export",
controller,
scope: auth.activeAccountId ?? "default",
finished,
progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 },
});
void (async () => {
try {
await run();
} finally {
ended();
}
})();
async function run() {
let folders: ExportFolder[];
try {
folders = input.mailboxId
@@ -46,5 +59,5 @@ export function startExport(input: StartExportInput): void {
onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
});
useMailTransferStore.getState().updateProgress(result);
})();
}
}
+17 -4
View File
@@ -1,4 +1,5 @@
import { runImport, type ImportFile } 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";
import { useMailTransferStore } from "@/stores/mail-transfer-store";
@@ -16,17 +17,29 @@ export interface StartImportInput {
/** Runs an import as the dialog's job and refreshes the mail lists when it ends. */
export function startImport(input: StartImportInput): void {
const auth = useAuthStore.getState();
const client = auth.client;
if (!client) return;
if (!auth.client) return;
const client: IJMAPClient = auth.client;
const store = useMailTransferStore.getState();
const controller = new AbortController();
const scope = auth.activeAccountId ?? "default";
let ended: () => void = () => {};
const finished = new Promise<void>((resolve) => { ended = resolve; });
store.startJob({
kind: "import",
controller,
scope,
finished,
progress: { phase: "running", imported: 0, failed: 0, skipped: {}, failures: [], fraction: 0, foldersCreated: 0 },
});
const hasPst = input.files.some((f) => f.format === "pst");
void (async () => {
try {
await run();
} finally {
ended();
}
})();
async function run() {
// The PST reader (and its worker) load only for an Outlook file.
const openPst = hasPst ? (await import("@/lib/mail-import/pst-source")).openPstSource : undefined;
const result = await runImport({
@@ -35,7 +48,7 @@ export function startImport(input: StartImportInput): void {
targetMailboxId: input.targetMailboxId,
keepFolders: input.keepFolders,
files: input.files,
scope: auth.activeAccountId ?? "default",
scope,
resume: input.resume,
signal: controller.signal,
onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
@@ -51,5 +64,5 @@ export function startImport(input: StartImportInput): void {
} catch {
/* the next refresh shows them */
}
})();
}
}
+22
View File
@@ -36,6 +36,28 @@ describe('importUploadedEmails', () => {
});
});
describe('the transfer\'s own JMAP requests', () => {
const entry = { blobId: 'b1', mailboxIds: { inbox: true }, keywords: {} };
it('throw RateLimitError on a 429 without pausing the rest of the client', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('slow down', { status: 429, headers: { 'Retry-After': '20' } })));
const c = client();
Object.assign(c, { apiUrl: 'https://jmap.example.com/jmap/' });
await expect(c.importUploadedEmails([entry])).rejects.toMatchObject({ name: 'RateLimitError', retryAfterMs: 20_000 });
expect((c as unknown as { isRateLimited(): boolean }).isRateLimited()).toBe(false);
});
it('still go out while the client waits out a 429 of its own', async () => {
const fetch = vi.fn(async () => new Response(JSON.stringify({ methodResponses: [['Email/import', { created: { imp0: { id: 'e1' } } }, '0']] })));
vi.stubGlobal('fetch', fetch);
const c = client();
Object.assign(c, { apiUrl: 'https://jmap.example.com/jmap/' });
(c as unknown as { setRateLimited(ms: number): void }).setRateLimited(60_000);
await expect(c.importUploadedEmails([entry])).resolves.toEqual([{ id: 'e1' }]);
expect(fetch).toHaveBeenCalledTimes(1);
});
});
describe('fetchBlobForTransfer', () => {
it('downloads with the account in the URL', async () => {
const fetch = vi.fn(async () => new Response(new Uint8Array([1, 2, 3])));
+2
View File
@@ -346,6 +346,8 @@ export interface IJMAPClient {
properties: string[],
accountId?: string,
limit?: number,
/** `transfer`: a mail export's own request (a 429 doesn't pause the rest of the client). */
opts?: { transfer?: boolean },
): Promise<Array<Record<string, unknown>>>;
// ── Email mutations ───────────────────────────────────────────
+14 -8
View File
@@ -1752,10 +1752,10 @@ export class JMAPClient implements IJMAPClient {
private async authenticatedFetch(
url: string,
init?: Parameters<typeof fetch>[1],
opts?: { timeoutMs?: number },
opts?: { timeoutMs?: number; transfer?: boolean },
): Promise<Response> {
// Short-circuit: if rate-limited, reject immediately without sending a request
if (this.isRateLimited()) {
if (!opts?.transfer && this.isRateLimited()) {
const remaining = this.rateLimitedUntil - Date.now();
this.notifyRateLimitBlocked(remaining);
throw new RateLimitError(remaining);
@@ -1786,7 +1786,7 @@ export class JMAPClient implements IJMAPClient {
// Handle 429 rate limiting - stop immediately, do not retry
if (response.status === 429) {
const retryAfterMs = JMAPClient.parseRetryAfter(response);
this.setRateLimited(retryAfterMs);
if (!opts?.transfer) this.setRateLimited(retryAfterMs);
throw new RateLimitError(retryAfterMs);
}
@@ -2085,7 +2085,12 @@ export class JMAPClient implements IJMAPClient {
}
}
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
/**
* `transfer`: a mail import or export's own request. A 429 throws
* RateLimitError for the transfer to wait on, without pausing the rest of
* the client, and a pause the client is in doesn't hold the transfer up.
*/
private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise<JMAPResponse> {
if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.');
}
@@ -2105,7 +2110,7 @@ export class JMAPClient implements IJMAPClient {
'Content-Type': 'application/json',
},
body,
}),
}, opts?.transfer ? { transfer: true } : undefined),
);
const responseText = await response.text();
@@ -4303,6 +4308,7 @@ export class JMAPClient implements IJMAPClient {
properties: string[],
accountId?: string,
limit: number = 10000,
opts?: { transfer?: boolean },
): Promise<Array<Record<string, unknown>>> {
const targetAccountId = accountId || this.accountId;
const pageSize = Math.min(500, this.getMaxObjectsInGet());
@@ -4324,7 +4330,7 @@ export class JMAPClient implements IJMAPClient {
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["id", ...properties],
}, "1"],
]);
], undefined, opts?.transfer ? { transfer: true } : undefined);
assertQuerySucceeded(response, 'Email query');
const [getName, getResult] = response.methodResponses?.[1] ?? [];
if (getName !== "Email/get" || !getResult) {
@@ -6158,7 +6164,7 @@ export class JMAPClient implements IJMAPClient {
const blobGet = async (chunk: string[], property: "size" | "data:asBase64") => {
const call = () => this.request([
["Blob/get", { accountId: account, ids: chunk, properties: [property] }, "0"],
], ["urn:ietf:params:jmap:core", BLOB_CAPABILITY]);
], ["urn:ietf:params:jmap:core", BLOB_CAPABILITY], { transfer: options?.sized });
let [name, result] = (await call()).methodResponses?.[0] ?? [];
if (name === "error" && result?.type === "forbidden" && account !== this.accountId) {
account = this.accountId;
@@ -11259,7 +11265,7 @@ export class JMAPClient implements IJMAPClient {
});
const response = await this.request([
['Email/import', { accountId: targetAccountId, emails }, '0'],
]);
], undefined, { transfer: true });
const [name, result] = response.methodResponses?.[0] ?? [];
if (name !== 'Email/import') {
throw jmapError(`Email/import failed: ${result?.description || result?.type || 'unknown'}`, result);
+1
View File
@@ -268,6 +268,7 @@ export async function runExport(options: ExportOptions): Promise<ExportProgress>
["blobId", "keywords", "receivedAt", "size", "subject"],
accountId,
Number.MAX_SAFE_INTEGER,
{ transfer: true },
));
const refs = list
.filter((e) => typeof e.blobId === "string")
+107 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { runImport, uploadWindowEnd, quotaFrom, type ImportProgress, type ImportOptions } from '@/lib/mail-import/engine';
import { checkpointKey, readCheckpoint } from '@/lib/mail-import/checkpoints';
import { checkpointKey, deleteCheckpoint, readCheckpoint } from '@/lib/mail-import/checkpoints';
import type { IJMAPClient, UploadedEmailImport } from '@/lib/jmap/client-interface';
import type { Mailbox } from '@/lib/jmap/types';
@@ -19,7 +19,17 @@ const mailbox = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbo
myRights: {} as Mailbox['myRights'], ...extra,
});
function fakeClient(opts: { failWhen?: (text: string) => boolean; quotaAfter?: number; uploadConcurrency?: number } = {}) {
function fakeClient(opts: {
failWhen?: (text: string) => boolean;
quotaAfter?: number;
uploadConcurrency?: number;
/** Upload errors with no HTTP status (a dropped connection), for the first n uploads. */
networkErrors?: number;
/** Milliseconds an upload of a message takes. */
delay?: (text: string) => number;
/** An Email/import error for a message. */
importError?: (text: string) => { type: string; error: string } | undefined;
} = {}) {
let uploads = 0;
let inFlight = 0;
let maxInFlight = 0;
@@ -44,8 +54,10 @@ function fakeClient(opts: { failWhen?: (text: string) => boolean; quotaAfter?: n
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
try {
await new Promise((r) => setTimeout(r, 1));
const body = await f.text();
await new Promise((r) => setTimeout(r, opts.delay?.(body) ?? 1));
if (o.signal?.aborted) throw new DOMException('Upload aborted', 'AbortError');
if (opts.networkErrors && n < opts.networkErrors) throw new Error('Upload network error');
if (opts.quotaAfter !== undefined && n >= opts.quotaAfter && !quotaHit) {
quotaHit = true;
throw new Error('Failed to upload file: 429 - {"title":"Quota exceeded","detail":"You have exceeded the blob upload quota of 1000 files or 50000000 bytes."}');
@@ -63,6 +75,8 @@ function fakeClient(opts: { failWhen?: (text: string) => boolean; quotaAfter?: n
importCalls.push(entries.length);
return entries.map((e) => {
const text = blobs.get(e.blobId)!;
const refused = opts.importError?.(text);
if (refused) return refused;
imported.push({ ...e, accountId, text });
return { id: `email-${imported.length}` };
});
@@ -201,6 +215,96 @@ describe('runImport', () => {
await expect(readCheckpoint(key)).resolves.toBeNull();
});
it('tries a message that failed again on the next run, and only that one', async () => {
const fake = fakeClient({ failWhen: (text) => text.startsWith('Subject: m1\n') });
const f = file(mbox(4));
const first = await runImport(options(fake.client, [f]));
expect(first).toMatchObject({ phase: 'done', imported: 3, failed: 1, retryable: 1 });
const again = fakeClient();
const second = await runImport(options(again.client, [f], { resume: true }));
expect(again.imported.map((e) => e.text.split('\n')[0])).toEqual(['Subject: m1']);
expect(second).toMatchObject({ phase: 'done', imported: 4, failed: 0 });
});
it('stops after ten failures in a row instead of burning through the file, and keeps the checkpoint', async () => {
const fake = fakeClient({ failWhen: () => true });
const f = file(mbox(40));
const result = await runImport(options(fake.client, [f]));
expect(result).toMatchObject({ phase: 'failed', error: 'import:failures' });
expect(fake.uploads).toBeLessThan(40 * 3);
const key = checkpointKey({ scope: 'bulwark-1', accountId: 'acc-shared', target: 'target', keepFolders: false, file: f });
// Nothing was done, so nothing is skipped next time: the failures are tried again.
expect((await readCheckpoint(key))?.done ?? 0).toBe(0);
});
it('stops at once when the mailbox is over quota', async () => {
const fake = fakeClient({ importError: (text) => (text.includes('Subject: m3\n') ? { type: 'overQuota', error: 'over quota' } : undefined) });
const result = await runImport(options(fake.client, [file(mbox(60))]));
expect(result).toMatchObject({ phase: 'failed', error: 'import:over_quota' });
expect(fake.uploads).toBeLessThan(60);
});
it('pauses on an upload with no answer and goes on, instead of failing the message', async () => {
const fake = fakeClient({ networkErrors: 1 });
const seen: ImportProgress[] = [];
const result = await runImport(options(fake.client, [file(mbox(3))], { onProgress: (p) => seen.push(p) }));
expect(seen.some((p) => p.phase === 'paused' && p.pausedUntil === T0 + 30_000)).toBe(true);
expect(result).toMatchObject({ phase: 'done', imported: 3, failed: 0 });
});
it('resumes without importing again what came in after a slow message', async () => {
// m2 is a slow upload; the messages after it are imported first.
const fake = fakeClient({ delay: (text) => (text.startsWith('Subject: m2\n') ? 300 : 1) });
const controller = new AbortController();
const f = file(mbox(12));
await runImport(options(fake.client, [f], {
signal: controller.signal,
onProgress: () => { if (fake.uploads >= 8 && !controller.signal.aborted) controller.abort(); },
}));
const firstRun = fake.imported.map((e) => e.text.split('\n')[0]);
expect(firstRun).not.toContain('Subject: m2');
expect(firstRun.some((s) => Number(s.slice(10)) > 2)).toBe(true);
await runImport(options(fake.client, [f], { resume: true }));
const subjects = fake.imported.map((e) => e.text.split('\n')[0]);
expect(subjects).toHaveLength(12);
expect(new Set(subjects).size).toBe(12);
});
it('writes no checkpoint and imports nothing more once the account signs out', async () => {
const fake = fakeClient({ delay: () => 20 });
const controller = new AbortController();
const f = file(mbox(30));
const key = checkpointKey({ scope: 'bulwark-1', accountId: 'acc-shared', target: 'target', keepFolders: false, file: f });
let callsAtSignOut = -1;
const result = await runImport(options(fake.client, [f], {
signal: controller.signal,
onProgress: (p) => {
if (p.imported >= 2 && !controller.signal.aborted) {
callsAtSignOut = fake.importCalls.length;
controller.abort('signout');
// What the sign-out does next: clear the account's checkpoints.
void deleteCheckpoint(key);
}
},
}));
expect(result.phase).toBe('cancelled');
expect(fake.importCalls.length).toBe(callsAtSignOut);
await expect(readCheckpoint(key)).resolves.toBeNull();
});
it('refuses a file another tab is importing', async () => {
const locks = { request: vi.fn(async (_n: string, _o: unknown, cb: (lock: unknown) => unknown) => cb(null)) };
vi.stubGlobal('navigator', { ...navigator, locks });
try {
const fake = fakeClient();
const result = await runImport(options(fake.client, [file(mbox(2))]));
expect(result).toMatchObject({ phase: 'failed', error: 'import:busy' });
expect(fake.uploads).toBe(0);
} finally {
vi.unstubAllGlobals();
}
});
it('starts again from the top when not resuming', async () => {
const fake = fakeClient();
const f = file(mbox(3));
+3 -1
View File
@@ -12,8 +12,10 @@ export interface ImportCheckpoint {
/** The Bulwark account that ran the import. */
scope: string;
fileName: string;
/** Items handled (imported, skipped or failed) from the start of the file. */
/** Items done for good (imported or skipped) from the start of the file, without a gap. */
done: number;
/** Items done after the first gap (a message that failed and is tried again). */
ahead?: number[];
imported: number;
updatedAt: number;
}
+167 -60
View File
@@ -1,6 +1,7 @@
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 { holdLock } from "./locks";
import { emlSource, mboxSource, zipSource, type ImportFormat, type ImportItem, type ItemSource, type SkipReason, type SourceOptions } from "./sources";
/**
@@ -42,8 +43,10 @@ export interface ImportProgress {
/** The limit the server named, when it did. */
quota?: { files?: number; bytes?: number };
foldersCreated: number;
/** Why the whole import stopped (phase failed). */
/** Why the whole import stopped (phase failed): `import:<code>`, `pst:<code>: ...` or the error's text. */
error?: string;
/** Messages that failed for a passing reason; importing the same files again tries them again. */
retryable?: number;
}
export interface ImportFile {
@@ -138,17 +141,22 @@ function maxConcurrentUploads(client: IJMAPClient): number {
return Math.max(1, Math.min(uploads, (client.getMaxConcurrentRequests?.() ?? 4) - 2));
}
/** Top-level folder names that mean a special folder, as Outlook and others name them. */
/**
* Top-level folder names that mean a special folder, as Outlook, Thunderbird
* and others name them in the languages Outlook ships most often. Outlook
* files say which folder is which themselves (`folderRole`); for mbox and
* ZIP files the name is all there is.
*/
const ROLE_NAMES: Record<string, string[]> = {
inbox: ["inbox", "posteingang"],
sent: ["sent", "sent items", "sent mail", "sent messages", "gesendet", "gesendete elemente", "gesendete objekte"],
drafts: ["drafts", "entwürfe"],
trash: ["trash", "deleted items", "deleted messages", "papierkorb", "gelöschte elemente", "gelöschte objekte"],
junk: ["junk", "junk e-mail", "junk email", "junk mail", "spam", "junk-e-mail"],
archive: ["archive", "archiv"],
inbox: ["inbox", "posteingang", "boîte de réception", "bandeja de entrada", "posta in arrivo", "postvak in", "caixa de entrada", "skrzynka odbiorcza", "входящие", "doručená pošta", "inkorgen", "indbakke", "innboks", "saapuneet"],
sent: ["sent", "sent items", "sent mail", "sent messages", "gesendet", "gesendete elemente", "gesendete objekte", "éléments envoyés", "elementos enviados", "posta inviata", "verzonden items", "itens enviados", "elementy wysłane", "отправленные", "odeslaná pošta", "skickat", "sendt post", "sendte elementer", "lähetetyt"],
drafts: ["drafts", "entwürfe", "brouillons", "borradores", "bozze", "concepten", "rascunhos", "wersje robocze", "черновики", "koncepty", "utkast", "kladder", "luonnokset"],
trash: ["trash", "deleted items", "deleted messages", "papierkorb", "gelöschte elemente", "gelöschte objekte", "éléments supprimés", "elementos eliminados", "posta eliminata", "verwijderde items", "itens excluídos", "elementy usunięte", "удаленные", "удалённые", "odstraněná pošta", "borttaget", "slettet post", "slettede elementer", "poistetut"],
junk: ["junk", "junk e-mail", "junk email", "junk mail", "spam", "junk-e-mail", "courrier indésirable", "correo no deseado", "posta indesiderata", "ongewenste e-mail", "lixo eletrônico", "wiadomości-śmieci", "нежелательная почта", "nevyžádaná pošta", "skräppost", "uønsket e-mail", "søppelpost", "roskaposti"],
archive: ["archive", "archiv", "archives", "archivo", "archivio", "archief", "arquivo", "archiwum", "архив", "arkiv", "arkisto"],
};
function roleFor(name: string): string | undefined {
export function roleFor(name: string): string | undefined {
const key = name.trim().toLowerCase();
return Object.keys(ROLE_NAMES).find((role) => ROLE_NAMES[role].includes(key));
}
@@ -156,8 +164,9 @@ function roleFor(name: string): string | undefined {
/**
* Maps a file's folder paths to mailboxes in the account, creating what's
* missing once. A folder with the same name under the same parent is used
* again, so a resumed import doesn't make a second copy of the tree; at the
* top level an "Inbox" or "Sent Items" means the account's own.
* again, so a resumed import doesn't make a second copy of the tree. At the
* top level a special folder (the file says so, or its name does) means the
* account's own Inbox, Sent, Drafts, Trash, Junk or Archive.
*/
export class FolderResolver {
private mailboxes: Mailbox[] | null = null;
@@ -185,11 +194,12 @@ export class FolderResolver {
return FolderResolver.idOf(box);
}
resolve(path: string[]): Promise<string> {
/** `topRole`: what the file says the first folder of `path` is. */
resolve(path: string[], topRole?: string): Promise<string> {
const key = path.join("\u0000");
let pending = this.cache.get(key);
if (!pending) {
pending = this.resolveUncached(path);
pending = this.resolveUncached(path, topRole);
// A failed creation may be tried again by the next message.
pending.catch(() => this.cache.delete(key));
this.cache.set(key, pending);
@@ -197,20 +207,20 @@ export class FolderResolver {
return pending;
}
private async resolveUncached(path: string[]): Promise<string> {
private async resolveUncached(path: string[], topRole?: string): Promise<string> {
if (path.length === 0) return this.root ?? this.inbox();
const parentId = path.length === 1 ? this.root : await this.resolve(path.slice(0, -1));
const parentId = path.length === 1 ? this.root : await this.resolve(path.slice(0, -1), topRole);
const name = path[path.length - 1].trim().slice(0, 255) || "Imported";
const mailboxes = await this.list();
if (parentId === null) {
const role = (path.length === 1 && topRole) || roleFor(name);
const special = role && mailboxes.find((m) => m.role === role);
if (special) return FolderResolver.idOf(special);
}
const same = (m: Mailbox) => m.name.trim().toLowerCase() === name.toLowerCase();
const parentMatches = (m: Mailbox) => (parentId === null ? !m.parentId : m.parentId === parentId || m.parentId === mailboxes.find((p) => FolderResolver.idOf(p) === parentId)?.id);
const existing = mailboxes.find((m) => parentMatches(m) && same(m));
if (existing) return FolderResolver.idOf(existing);
if (parentId === null) {
const role = roleFor(name);
const special = role && mailboxes.find((m) => m.role === role);
if (special) return FolderResolver.idOf(special);
}
const created = await this.client.createMailbox(name, parentId ?? undefined, this.accountId);
this.created++;
mailboxes.push({ ...created, parentId: parentId ?? undefined });
@@ -225,23 +235,66 @@ interface Uploaded {
entry: UploadedEmailImport;
}
/** Counts handled items per file, so the checkpoint is the first index not handled yet. */
class Watermark {
private next = 0;
private readonly ahead = new Set<number>();
constructor(start: number) { this.next = start; }
/**
* Which items of a file are done for good (imported, or skipped for a reason
* that won't change): everything before `next`, and the ones in `ahead`
* past a gap. A message that failed for a passing reason leaves a gap, so
* the next run of the same import tries it again; nothing done is redone.
*/
export class Watermark {
private next: number;
private readonly ahead: Set<number>;
constructor(start: number, ahead: Iterable<number> = []) {
this.next = start;
this.ahead = new Set(ahead);
this.advance();
}
private advance(): void {
while (this.ahead.has(this.next)) { this.ahead.delete(this.next); this.next++; }
}
mark(index: number): void {
if (index < this.next) return;
this.ahead.add(index);
while (this.ahead.has(this.next)) { this.ahead.delete(this.next); this.next++; }
this.advance();
}
has(index: number): boolean {
return index < this.next || this.ahead.has(index);
}
get value(): number { return this.next; }
get beyond(): number[] { return [...this.ahead].sort((a, b) => a - b); }
}
/** The whole import stops (phase failed); the checkpoint keeps what was done. */
export class ImportStopped extends Error {
constructor(readonly code: "over_quota" | "forbidden" | "failures" | "network" | "busy") {
super(`import:${code}`);
this.name = "ImportStopped";
}
}
/** This many failures in a row stop the import instead of burning through the file. */
const MAX_FAILURES_IN_A_ROW = 10;
/** Pauses after uploads fail without an answer: a short one, then until the upload window ends. */
const NETWORK_PAUSE_MS = 30_000;
/** Email/import errors that stop the whole import: nothing more would go in. */
const STOPPING_IMPORT_ERRORS: Record<string, ImportStopped["code"]> = { overQuota: "over_quota", forbidden: "forbidden" };
export async function runImport(options: ImportOptions): Promise<ImportProgress> {
const { client, accountId, signal } = options;
const { client, accountId } = options;
const now = options.now ?? Date.now;
const sleep = options.sleep ?? defaultSleep;
// Our own signal: a cancel from outside, or a stop from inside.
const inner = new AbortController();
const outerAbort = () => inner.abort(options.signal.reason);
if (options.signal.aborted) outerAbort();
else options.signal.addEventListener("abort", outerAbort, { once: true });
const signal = inner.signal;
const state: { stopped: ImportStopped | null } = { stopped: null };
const stop = (error: ImportStopped) => {
if (!state.stopped) state.stopped = error;
inner.abort(error);
};
const progress: ImportProgress = { phase: "running", imported: 0, failed: 0, skipped: {}, failures: [], fraction: 0, foldersCreated: 0 };
const folders = new FolderResolver(client, accountId, options.targetMailboxId);
const maxSize = client.getMaxSizeUpload() || Number.POSITIVE_INFINITY;
@@ -256,9 +309,15 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
options.onProgress({ ...progress, skipped: { ...progress.skipped }, failures: [...progress.failures] });
};
const skip = (reason: SkipReason, n = 1) => { progress.skipped[reason] = (progress.skipped[reason] ?? 0) + n; };
const fail = (label: string, reason: string) => {
let failuresInARow = 0;
let retryable = 0;
/** A failure; `permanent` ones (not a message at all) aren't tried again on the next run. */
const fail = (u: { fileIndex: number; index: number; label: string }, reason: string, permanent = false) => {
progress.failed++;
if (progress.failures.length < MAX_FAILURES_LISTED) progress.failures.push({ label, reason });
if (progress.failures.length < MAX_FAILURES_LISTED) progress.failures.push({ label: u.label, reason });
if (permanent) { handled(u); return; }
progress.retryable = ++retryable;
if (++failuresInARow >= MAX_FAILURES_IN_A_ROW) stop(new ImportStopped("failures"));
};
// Shared pause: when the server refuses uploads, every lane waits.
@@ -292,10 +351,13 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
const importedPerFile: number[] = options.files.map(() => 0);
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 ? writeCheckpoint({
key: keys[i], scope: options.scope, fileName: options.files[i].file.name, done: m.value, imported: importedPerFile[i], updatedAt: 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()));
};
@@ -303,7 +365,9 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
let queue: Uploaded[] = [];
let importing: Promise<void> = Promise.resolve();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const handled = (u: { fileIndex: number; index: number }) => marks[u.fileIndex].mark(u.index);
function handled(u: { fileIndex: number; index: number }): void {
marks[u.fileIndex].mark(u.index);
}
const importBatch = async (batch: Uploaded[]) => {
let results: UploadedEmailImportResult[] | null = null;
@@ -318,22 +382,36 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
} catch (error) {
const name = (error as { name?: string } | null)?.name;
if (name === "RateLimitError" && attempt < 3) {
await sleep(Math.max(1000, (error as { retryAfterMs?: number }).retryAfterMs ?? 60_000), signal);
await sleep(Math.max(1000, (error as { retryAfterMs?: number }).retryAfterMs ?? 60_000), options.signal).catch(() => {});
if (options.signal.aborted) break;
continue;
}
if (attempt < 2 && !isAbort(error)) {
await sleep(2000 * (attempt + 1), options.signal).catch(() => {});
if (options.signal.aborted) break;
continue;
}
if (attempt < 1 && !isAbort(error)) { await sleep(2000, signal); continue; }
results = batch.map(() => ({ error: error instanceof Error ? error.message : String(error) }));
}
}
if (!results) return;
results.forEach((result, i) => {
const item = batch[i];
if ("id" in result) {
progress.imported++;
importedPerFile[item.fileIndex]++;
} else {
fail(item.label, result.type === "invalidEmail" ? "invalid" : result.error);
failuresInARow = 0;
handled(item);
return;
}
handled(item);
const stopping = result.type ? STOPPING_IMPORT_ERRORS[result.type] : undefined;
if (stopping) {
progress.failed++;
stop(new ImportStopped(stopping));
return;
}
// A blob that isn't a message won't become one: don't try it again.
fail(item, result.type === "invalidEmail" ? "invalid" : result.error, result.type === "invalidEmail");
});
report();
await saveCheckpoints();
@@ -355,14 +433,15 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
// ── Uploads, a few at a time ──
const running = new Set<Promise<void>>();
let networkErrors = 0;
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 : []);
mailboxId = await folders.resolve(options.keepFolders ? item.folder : [], item.folderRole);
} catch (error) {
if (isAbort(error)) throw error;
fail(item.label, error instanceof Error ? error.message : String(error));
handled({ fileIndex, index: item.index });
if (isAbort(error) || signal.aborted) throw new ImportCancelled();
fail(u, error instanceof Error ? error.message : String(error));
return;
}
const file = new File([item.data], "message.eml", { type: "message/rfc822" });
@@ -370,6 +449,7 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
await waitWhilePaused();
try {
const { blobId } = await client.uploadBlob(file, { accountId, signal });
networkErrors = 0;
enqueue({
index: item.index,
fileIndex,
@@ -385,17 +465,25 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
progress.quota = quotaFrom(body);
continue;
}
if (status === undefined) {
// No answer at all: the connection dropped, or a refusal the browser
// couldn't read. Pause and try again rather than losing messages.
networkErrors++;
if (networkErrors > 2) { stop(new ImportStopped("network")); throw new ImportCancelled(); }
pausedUntil = Math.max(pausedUntil, networkErrors === 1 ? now() + NETWORK_PAUSE_MS : uploadWindowEnd(now()) + 5000);
continue;
}
if (status === 413 || (status === 400 && /maxSizeUpload/.test(body))) {
skip("too_large");
handled({ fileIndex, index: item.index });
handled(u);
return;
}
if (status === 403) { stop(new ImportStopped("forbidden")); throw new ImportCancelled(); }
if (attempt < UPLOAD_RETRY_DELAYS_MS.length) {
await sleep(UPLOAD_RETRY_DELAYS_MS[attempt], signal);
continue;
}
fail(item.label, status ? `HTTP ${status}` : body);
handled({ fileIndex, index: item.index });
fail(u, `HTTP ${status}`);
return;
}
}
@@ -404,30 +492,36 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
const startUpload = async (item: Extract<ImportItem, { kind: "message" }>, fileIndex: number) => {
while (running.size >= lanes) await Promise.race(running);
if (signal.aborted) throw new ImportCancelled();
// A lane never rejects: a cancel shows in `signal`, anything else is a failure of this message.
// A lane never rejects: a cancel or a stop shows in `signal`, anything else is a failure of this message.
const task: Promise<void> = upload(item, fileIndex)
.catch((error) => {
if (!isAbort(error) && !signal.aborted) {
fail(item.label, error instanceof Error ? error.message : String(error));
handled({ fileIndex, index: item.index });
}
if (!isAbort(error) && !signal.aborted) fail({ fileIndex, index: item.index, label: item.label }, error instanceof Error ? error.message : String(error));
})
.finally(() => { running.delete(task); });
running.add(task);
};
// One import of a file at a time, across the tabs of this browser.
const releases: (() => void)[] = [];
let keepCheckpoints = false;
try {
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 checkpoint = options.resume ? await readCheckpoint(keys[f]) : null;
if (!options.resume) await deleteCheckpoint(keys[f]);
const skipBefore = checkpoint?.done ?? 0;
if (checkpoint) { progress.imported += checkpoint.imported; importedPerFile[f] = checkpoint.imported; }
marks.push(new Watermark(skipBefore));
const mark = new Watermark(checkpoint?.done ?? 0, checkpoint?.ahead ?? []);
marks.push(mark);
progress.fileName = file.name;
const sourceOptions: SourceOptions = {
maxMessageBytes: maxSize,
skipBefore,
skipBefore: mark.value,
skipAlso: new Set(mark.beyond),
signal,
onProgress: (fraction) => { fileFraction[f] = fraction; setFraction(); report(); },
onTotal: (count) => {
@@ -446,7 +540,7 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
try {
for await (const item of source) {
if (signal.aborted) throw new ImportCancelled();
if (signal.aborted) throw state.stopped ?? new ImportCancelled();
if (item.kind === "skipped") {
// Items an earlier run handled are already counted in its imported total.
if (item.reason !== "resumed") skip(item.reason);
@@ -464,21 +558,32 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
setFraction();
}
while (running.size > 0) await Promise.race(running);
if (signal.aborted) throw new ImportCancelled();
if (signal.aborted) throw state.stopped ?? new ImportCancelled();
await flush();
await importing;
if (state.stopped) throw state.stopped;
progress.phase = "done";
progress.fraction = 1;
progress.pausedUntil = undefined;
await Promise.all(keys.map((k) => deleteCheckpoint(k)));
// Messages that failed for a passing reason are tried again by the next run.
keepCheckpoints = retryable > 0;
if (keepCheckpoints) await saveCheckpoints(true);
else await Promise.all(keys.map((k) => deleteCheckpoint(k)));
} catch (error) {
// Let the uploads in flight end (they were aborted) and keep what got imported.
const signedOut = options.signal.reason === "signout";
await Promise.allSettled([...running]);
await flush().catch(() => {});
await importing.catch(() => {});
await saveCheckpoints(true).catch(() => {});
if (!signedOut) {
// Keep what was uploaded and where the import got to.
await flush().catch(() => {});
await importing.catch(() => {});
await saveCheckpoints(true).catch(() => {});
}
progress.pausedUntil = undefined;
if (isAbort(error) || signal.aborted) {
const reason = state.stopped ?? error;
if ((reason as { name?: string } | null)?.name === "ImportStopped") {
progress.phase = "failed";
progress.error = (reason as ImportStopped).message;
} else if (isAbort(error) || options.signal.aborted) {
progress.phase = "cancelled";
} else {
progress.phase = "failed";
@@ -486,6 +591,8 @@ export async function runImport(options: ImportOptions): Promise<ImportProgress>
}
} finally {
if (flushTimer) clearTimeout(flushTimer);
options.signal.removeEventListener("abort", outerAbort);
for (const release of releases) release();
}
report(true);
return progress;
+36
View File
@@ -0,0 +1,36 @@
/**
* Web Locks across the tabs of this browser, held until released: one tab
* imports a file at a time, and an export's private file isn't swept while
* the tab that wrote it still uses it. Where the browser has no Web Locks,
* every request succeeds (one tab can't know about another then).
*/
interface LockManagerLike {
request(name: string, options: { ifAvailable: boolean }, callback: (lock: unknown) => Promise<void> | void): Promise<unknown>;
}
function lockManager(): LockManagerLike | null {
const locks = typeof navigator !== "undefined" ? (navigator as { locks?: LockManagerLike }).locks : undefined;
return locks && typeof locks.request === "function" ? locks : null;
}
export function hasWebLocks(): boolean {
return lockManager() !== null;
}
/** Takes the lock if nobody holds it: a release function, or null when another tab has it. */
export function holdLock(name: string): Promise<(() => void) | null> {
const locks = lockManager();
if (!locks) return Promise.resolve(() => {});
return new Promise((resolve) => {
locks
.request(name, { ifAvailable: true }, (lock) => {
if (!lock) {
resolve(null);
return;
}
return new Promise<void>((release) => resolve(release));
})
.catch(() => resolve(() => {}));
});
}
+1 -1
View File
@@ -65,7 +65,7 @@ export async function* openPstSource(file: File, options: SourceOptions): ItemSo
};
try {
worker.postMessage({ type: "open", file, skipBefore: options.skipBefore, maxMessageBytes: options.maxMessageBytes });
worker.postMessage({ type: "open", file, 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);
+2 -2
View File
@@ -29,7 +29,7 @@ function utcDate(date: Date | undefined): string | undefined {
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function walkPst(pst: any, options: { skipBefore: number; maxMessageBytes: number; boundarySeed?: string }): PstWalk {
export function walkPst(pst: any, options: { skipBefore: number; skipAlso?: Set<number>; maxMessageBytes: number; boundarySeed?: string }): PstWalk {
const folders: PstFolderRef[] = [...pstFolders(pst)];
const total = folders.reduce((n, f) => n + (Number(f.folder.contentCount) || 0), 0);
@@ -47,7 +47,7 @@ export function walkPst(pst: any, options: { skipBefore: number; maxMessageBytes
}
if (!child) break;
const i = index++;
if (i < options.skipBefore) {
if (i < options.skipBefore || options.skipAlso?.has(i)) {
yield { kind: "skipped", index: i, reason: "resumed" };
continue;
}
+3 -3
View File
@@ -4,7 +4,7 @@
* folders and hands over one message at a time as MIME, only as many as the
* page asked for, so a large file never piles up in memory.
*
* page → worker { type: "open", file, skipBefore, maxMessageBytes }
* page → worker { type: "open", file, skipBefore, skipAlso, maxMessageBytes }
* { type: "pull", count }
* worker → page { type: "opened", total }
* { type: "item", item, fraction } (item.mime is transferred)
@@ -37,12 +37,12 @@ function errorCode(error: unknown): string {
}
scope.onmessage = (event: MessageEvent) => {
const data = event.data as { type: string; file?: File; skipBefore?: number; maxMessageBytes?: number; count?: number };
const data = event.data as { type: string; file?: File; skipBefore?: number; skipAlso?: number[]; maxMessageBytes?: number; count?: number };
try {
if (data.type === "open" && data.file) {
registerPstFile("import.pst", data.file);
pst = new PSTFile("import.pst") as unknown as { close(): void };
const walk = walkPst(pst, { skipBefore: data.skipBefore ?? 0, maxMessageBytes: data.maxMessageBytes ?? Number.POSITIVE_INFINITY });
const walk = walkPst(pst, { skipBefore: data.skipBefore ?? 0, skipAlso: new Set(data.skipAlso ?? []), maxMessageBytes: data.maxMessageBytes ?? Number.POSITIVE_INFINITY });
items = walk.items;
total = walk.total;
scope.postMessage({ type: "opened", total });
+13 -4
View File
@@ -40,6 +40,8 @@ export interface ImportMessage {
label: string;
/** Messages attached to this one that couldn't be read. */
embeddedSkipped?: number;
/** The special folder (inbox, sent, drafts, trash, junk) the top folder of `folder` is, when the file says so. */
folderRole?: string;
}
export interface ImportSkip {
@@ -56,6 +58,8 @@ export interface SourceOptions {
maxMessageBytes: number;
/** Items before this index were handled by an earlier run: report them as `resumed`. */
skipBefore: number;
/** Items after `skipBefore` an earlier run handled too (it stopped with gaps). */
skipAlso?: Set<number>;
/** Called as the file is read: the part done, 0 to 1. */
onProgress?: (fraction: number) => void;
/** Called once the number of messages in the file is known. */
@@ -65,6 +69,11 @@ export interface SourceOptions {
export type ItemSource = AsyncGenerator<ImportItem, void, undefined>;
/** Whether an earlier run of the same import handled item `index`. */
export function isResumed(options: Pick<SourceOptions, "skipBefore" | "skipAlso">, index: number): boolean {
return index < options.skipBefore || !!options.skipAlso?.has(index);
}
const READ_CHUNK = 1 << 20;
const MIME = "message/rfc822";
@@ -119,7 +128,7 @@ export function subjectOf(headerBlock: string): string {
export async function* emlSource(file: File, options: SourceOptions): ItemSource {
options.onTotal?.(1);
if (options.skipBefore > 0) {
if (isResumed(options, 0)) {
yield { kind: "skipped", index: 0, reason: "resumed" };
options.onProgress?.(1);
return;
@@ -189,7 +198,7 @@ async function* mboxFromChunks(
for (const m of messages) {
onOffset?.(m.offset);
const i = index++;
yield i < options.skipBefore ? { kind: "skipped", index: i, reason: "resumed" } : mboxItem(m, i, folder);
yield isResumed(options, i) ? { kind: "skipped", index: i, reason: "resumed" } : mboxItem(m, i, folder);
}
};
for await (const chunk of chunks) {
@@ -249,7 +258,7 @@ export async function* zipSource(file: File, options: SourceOptions): ItemSource
yield* mboxFromChunks(chunks, zipFolder(entry.name, true), options, index, (next) => { index = next; });
} else if (isEml(entry.name)) {
const i = index++;
if (i < options.skipBefore) {
if (isResumed(options, i)) {
yield { kind: "skipped", index: i, reason: "resumed" };
} else if (entry.size > options.maxMessageBytes) {
yield { kind: "skipped", index: i, reason: "too_large", label: emlLabel(entry.name) };
@@ -269,7 +278,7 @@ export async function* zipSource(file: File, options: SourceOptions): ItemSource
}
} else {
const i = index++;
yield i < options.skipBefore ? { kind: "skipped", index: i, reason: "resumed" } : { kind: "skipped", index: i, reason: "other", label: emlLabel(entry.name) };
yield isResumed(options, i) ? { kind: "skipped", index: i, reason: "resumed" } : { kind: "skipped", index: i, reason: "other", label: emlLabel(entry.name) };
}
doneBytes += entry.compressedSize;
options.onProgress?.(doneBytes / totalBytes);
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "يستخدم ملف Outlook هذا تشفيرًا عاليًا لا يستطيع Bulwark قراءته. صدّر البريد في Outlook إلى ملف ‎.pst جديد بلا تشفير.",
"pst_error_not_pst": "هذا الملف ليس ملف بيانات Outlook يستطيع Bulwark قراءته.",
"pst_error_unreadable": "تعذّر على Bulwark قراءة ملف Outlook هذا.",
"pst_error_worker": "لم يُحمَّل قارئ ملفات Outlook. أعد تحميل الصفحة وحاول مرة أخرى."
"pst_error_worker": "لم يُحمَّل قارئ ملفات Outlook. أعد تحميل الصفحة وحاول مرة أخرى.",
"error_over_quota": "صندوق البريد ممتلئ، لذا توقف الاستيراد. أفرغ بعض المساحة واستورد الملفات نفسها إلى المجلد نفسه مرة أخرى للمتابعة.",
"error_forbidden": "لا يمكنك إضافة بريد إلى هذا المجلد، لذا توقف الاستيراد.",
"error_failures": "فشلت رسائل كثيرة متتالية، لذا توقف الاستيراد. استورد الملفات نفسها إلى المجلد نفسه لاحقًا للمتابعة.",
"error_network": "فشل الاتصال بالخادم مرارًا، لذا توقف الاستيراد. استورد الملفات نفسها إلى المجلد نفسه مرة أخرى للمتابعة.",
"error_busy": "يجري استيراد هذه الملفات في علامة تبويب أخرى بالفعل.",
"retry_hint": "استورد الملفات نفسها إلى المجلد نفسه مرة أخرى لإعادة محاولة الرسائل التي فشلت."
},
"mail_export": {
"menu_export": "تصدير المجلد…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Aquest fitxer d'Outlook fa servir xifratge alt, que Bulwark no pot llegir. A Outlook, exporteu el correu a un fitxer .pst nou sense xifratge.",
"pst_error_not_pst": "Aquest fitxer no és un fitxer de dades d'Outlook que Bulwark pugui llegir.",
"pst_error_unreadable": "Bulwark no ha pogut llegir aquest fitxer d'Outlook.",
"pst_error_worker": "El lector de fitxers d'Outlook no s'ha carregat. Torneu a carregar la pàgina i proveu-ho de nou."
"pst_error_worker": "El lector de fitxers d'Outlook no s'ha carregat. Torneu a carregar la pàgina i proveu-ho de nou.",
"error_over_quota": "La bústia és plena, així que la importació s'ha aturat. Feu espai i torneu a importar els mateixos fitxers a la mateixa carpeta per continuar.",
"error_forbidden": "No podeu afegir correu a aquesta carpeta, així que la importació s'ha aturat.",
"error_failures": "Han fallat massa missatges seguits, així que la importació s'ha aturat. Torneu a importar els mateixos fitxers a la mateixa carpeta més tard per continuar.",
"error_network": "La connexió amb el servidor ha fallat diverses vegades, així que la importació s'ha aturat. Torneu a importar els mateixos fitxers a la mateixa carpeta per continuar.",
"error_busy": "Aquests fitxers ja s'estan important en una altra pestanya.",
"retry_hint": "Torneu a importar els mateixos fitxers a la mateixa carpeta per tornar a provar els que han fallat."
},
"mail_export": {
"menu_export": "Exporta la carpeta…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Tento soubor Outlooku používá vysoké šifrování, které Bulwark neumí přečíst. V Outlooku exportujte poštu do nového souboru .pst bez šifrování.",
"pst_error_not_pst": "Tento soubor není datový soubor Outlooku, který by Bulwark uměl přečíst.",
"pst_error_unreadable": "Bulwark tento soubor Outlooku nedokázal přečíst.",
"pst_error_worker": "Čtečka souborů Outlooku se nenačetla. Načtěte stránku znovu a zkuste to ještě jednou."
"pst_error_worker": "Čtečka souborů Outlooku se nenačetla. Načtěte stránku znovu a zkuste to ještě jednou.",
"error_over_quota": "Schránka je plná, proto se import zastavil. Uvolněte místo a importujte stejné soubory znovu do stejné složky, abyste pokračovali.",
"error_forbidden": "Do této složky nesmíte přidávat poštu, proto se import zastavil.",
"error_failures": "Příliš mnoho zpráv za sebou selhalo, proto se import zastavil. Importujte stejné soubory později znovu do stejné složky, abyste pokračovali.",
"error_network": "Spojení se serverem opakovaně selhalo, proto se import zastavil. Importujte stejné soubory znovu do stejné složky, abyste pokračovali.",
"error_busy": "Tyto soubory se už importují v jiné kartě.",
"retry_hint": "Importujte stejné soubory znovu do stejné složky, abyste zkusili neúspěšné ještě jednou."
},
"mail_export": {
"menu_export": "Exportovat složku…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Denne Outlook-fil bruger høj kryptering, som Bulwark ikke kan læse. Eksportér mailen i Outlook til en ny .pst-fil uden kryptering.",
"pst_error_not_pst": "Denne fil er ikke en Outlook-datafil, som Bulwark kan læse.",
"pst_error_unreadable": "Bulwark kunne ikke læse denne Outlook-fil.",
"pst_error_worker": "Læseren til Outlook-filer blev ikke indlæst. Genindlæs siden, og prøv igen."
"pst_error_worker": "Læseren til Outlook-filer blev ikke indlæst. Genindlæs siden, og prøv igen.",
"error_over_quota": "Postkassen er fuld, så importen stoppede. Skaf plads, og importér de samme filer til samme mappe igen for at fortsætte.",
"error_forbidden": "Du må ikke tilføje mail til denne mappe, så importen stoppede.",
"error_failures": "For mange beskeder i træk mislykkedes, så importen stoppede. Importér de samme filer til samme mappe igen senere for at fortsætte.",
"error_network": "Forbindelsen til serveren svigtede gentagne gange, så importen stoppede. Importér de samme filer til samme mappe igen for at fortsætte.",
"error_busy": "Disse filer importeres allerede i en anden fane.",
"retry_hint": "Importér de samme filer til samme mappe igen for at prøve dem, der mislykkedes, en gang til."
},
"mail_export": {
"menu_export": "Eksportér mappe…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Diese Outlook-Datei nutzt eine hohe Verschlüsselung, die Bulwark nicht lesen kann. Exportieren Sie die E-Mails in Outlook in eine neue .pst-Datei ohne Verschlüsselung.",
"pst_error_not_pst": "Diese Datei ist keine Outlook-Datendatei, die Bulwark lesen kann.",
"pst_error_unreadable": "Bulwark konnte diese Outlook-Datei nicht lesen.",
"pst_error_worker": "Das Lesemodul für Outlook-Dateien wurde nicht geladen. Laden Sie die Seite neu und versuchen Sie es noch einmal."
"pst_error_worker": "Das Lesemodul für Outlook-Dateien wurde nicht geladen. Laden Sie die Seite neu und versuchen Sie es noch einmal.",
"error_over_quota": "Das Postfach ist voll, daher wurde der Import angehalten. Schaffen Sie Platz und importieren Sie dieselben Dateien erneut in denselben Ordner, um weiterzumachen.",
"error_forbidden": "Sie dürfen diesem Ordner keine E-Mails hinzufügen, daher wurde der Import angehalten.",
"error_failures": "Zu viele Nachrichten sind nacheinander fehlgeschlagen, daher wurde der Import angehalten. Importieren Sie dieselben Dateien später erneut in denselben Ordner, um weiterzumachen.",
"error_network": "Die Verbindung zum Server ist wiederholt fehlgeschlagen, daher wurde der Import angehalten. Importieren Sie dieselben Dateien erneut in denselben Ordner, um weiterzumachen.",
"error_busy": "Diese Dateien werden bereits in einem anderen Tab importiert.",
"retry_hint": "Importieren Sie dieselben Dateien erneut in denselben Ordner, um die fehlgeschlagenen noch einmal zu versuchen."
},
"mail_export": {
"menu_export": "Ordner exportieren…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "This Outlook file uses high encryption, which Bulwark can't read. In Outlook, export the mail to a new .pst file without encryption.",
"pst_error_not_pst": "This file isn't an Outlook data file Bulwark can read.",
"pst_error_unreadable": "Bulwark couldn't read this Outlook file.",
"pst_error_worker": "The Outlook file reader didn't load. Reload the page and try again."
"pst_error_worker": "The Outlook file reader didn't load. Reload the page and try again.",
"error_over_quota": "The mailbox is full, so the import stopped. Make room and import the same files into the same folder again to go on.",
"error_forbidden": "You may not add mail to this folder, so the import stopped.",
"error_failures": "Too many messages failed in a row, so the import stopped. Import the same files into the same folder again later to go on.",
"error_network": "The connection to the server kept failing, so the import stopped. Import the same files into the same folder again to go on.",
"error_busy": "These files are being imported in another tab already.",
"retry_hint": "Import the same files into the same folder again to try the ones that failed once more."
},
"mail_export": {
"menu_export": "Export folder…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Este archivo de Outlook usa cifrado alto, que Bulwark no puede leer. En Outlook, exporte el correo a un archivo .pst nuevo sin cifrado.",
"pst_error_not_pst": "Este archivo no es un archivo de datos de Outlook que Bulwark pueda leer.",
"pst_error_unreadable": "Bulwark no pudo leer este archivo de Outlook.",
"pst_error_worker": "El lector de archivos de Outlook no se cargó. Vuelva a cargar la página e inténtelo de nuevo."
"pst_error_worker": "El lector de archivos de Outlook no se cargó. Vuelva a cargar la página e inténtelo de nuevo.",
"error_over_quota": "El buzón está lleno, así que la importación se detuvo. Libere espacio e importe los mismos archivos en la misma carpeta otra vez para seguir.",
"error_forbidden": "No puede añadir correo a esta carpeta, así que la importación se detuvo.",
"error_failures": "Fallaron demasiados mensajes seguidos, así que la importación se detuvo. Importe los mismos archivos en la misma carpeta más tarde para seguir.",
"error_network": "La conexión con el servidor falló varias veces, así que la importación se detuvo. Importe los mismos archivos en la misma carpeta otra vez para seguir.",
"error_busy": "Estos archivos ya se están importando en otra pestaña.",
"retry_hint": "Importe los mismos archivos en la misma carpeta otra vez para reintentar los que fallaron."
},
"mail_export": {
"menu_export": "Exportar carpeta…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "See Outlooki fail kasutab tugevat krüpteerimist, mida Bulwark lugeda ei saa. Eksportige kirjad Outlookis uude krüpteerimata .pst-faili.",
"pst_error_not_pst": "See fail pole Outlooki andmefail, mida Bulwark lugeda oskab.",
"pst_error_unreadable": "Bulwark ei saanud seda Outlooki faili lugeda.",
"pst_error_worker": "Outlooki failide lugeja ei laadinud. Laadige leht uuesti ja proovige veel kord."
"pst_error_worker": "Outlooki failide lugeja ei laadinud. Laadige leht uuesti ja proovige veel kord.",
"error_over_quota": "Postkast on täis, seega import peatus. Tehke ruumi ja importige samad failid uuesti samasse kausta, et jätkata.",
"error_forbidden": "Sellesse kausta ei tohi te kirju lisada, seega import peatus.",
"error_failures": "Liiga palju kirju järjest ebaõnnestus, seega import peatus. Importige samad failid hiljem uuesti samasse kausta, et jätkata.",
"error_network": "Ühendus serveriga katkes korduvalt, seega import peatus. Importige samad failid uuesti samasse kausta, et jätkata.",
"error_busy": "Neid faile imporditakse juba teisel vahelehel.",
"retry_hint": "Importige samad failid uuesti samasse kausta, et ebaõnnestunud kirju veel kord proovida."
},
"mail_export": {
"menu_export": "Ekspordi kaust…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "این فایل اوت‌لوک از رمزگذاری قوی استفاده می‌کند که Bulwark نمی‌تواند بخواند. در اوت‌لوک، نامه‌ها را به یک فایل ‎.pst تازه بدون رمزگذاری صادر کنید.",
"pst_error_not_pst": "این فایل، فایل داده اوت‌لوکی نیست که Bulwark بتواند بخواند.",
"pst_error_unreadable": "Bulwark نتوانست این فایل اوت‌لوک را بخواند.",
"pst_error_worker": "خواننده فایل‌های اوت‌لوک بارگیری نشد. صفحه را دوباره بارگیری کنید و دوباره امتحان کنید."
"pst_error_worker": "خواننده فایل‌های اوت‌لوک بارگیری نشد. صفحه را دوباره بارگیری کنید و دوباره امتحان کنید.",
"error_over_quota": "صندوق پستی پر است، بنابراین درون‌ریزی متوقف شد. جا باز کنید و برای ادامه، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"error_forbidden": "اجازه ندارید به این پوشه نامه اضافه کنید، بنابراین درون‌ریزی متوقف شد.",
"error_failures": "پیام‌های زیادی پشت سر هم ناموفق بودند، بنابراین درون‌ریزی متوقف شد. برای ادامه، بعداً همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"error_network": "اتصال به سرور چند بار قطع شد، بنابراین درون‌ریزی متوقف شد. برای ادامه، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"error_busy": "این فایل‌ها هم‌اکنون در زبانه دیگری درون‌ریزی می‌شوند.",
"retry_hint": "برای امتحان دوباره پیام‌های ناموفق، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید."
},
"mail_export": {
"menu_export": "برون‌بری پوشه…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Ce fichier Outlook utilise un chiffrement élevé que Bulwark ne sait pas lire. Dans Outlook, exportez les e-mails dans un nouveau fichier .pst sans chiffrement.",
"pst_error_not_pst": "Ce fichier n'est pas un fichier de données Outlook que Bulwark sait lire.",
"pst_error_unreadable": "Bulwark n'a pas pu lire ce fichier Outlook.",
"pst_error_worker": "Le lecteur de fichiers Outlook ne s'est pas chargé. Rechargez la page et réessayez."
"pst_error_worker": "Le lecteur de fichiers Outlook ne s'est pas chargé. Rechargez la page et réessayez.",
"error_over_quota": "La boîte aux lettres est pleine, l'import s'est donc arrêté. Libérez de la place et importez à nouveau les mêmes fichiers dans le même dossier pour reprendre.",
"error_forbidden": "Vous ne pouvez pas ajouter de messages à ce dossier, l'import s'est donc arrêté.",
"error_failures": "Trop de messages ont échoué à la suite, l'import s'est donc arrêté. Importez à nouveau les mêmes fichiers dans le même dossier plus tard pour reprendre.",
"error_network": "La connexion au serveur a échoué plusieurs fois, l'import s'est donc arrêté. Importez à nouveau les mêmes fichiers dans le même dossier pour reprendre.",
"error_busy": "Ces fichiers sont déjà en cours d'import dans un autre onglet.",
"retry_hint": "Importez à nouveau les mêmes fichiers dans le même dossier pour réessayer ceux qui ont échoué."
},
"mail_export": {
"menu_export": "Exporter le dossier…",
+7 -1
View File
@@ -5434,7 +5434,13 @@
"pst_error_encrypted": "קובץ Outlook זה משתמש בהצפנה גבוהה ש-Bulwark אינו יכול לקרוא. ב-Outlook, ייצאו את הדואר לקובץ ‎.pst חדש ללא הצפנה.",
"pst_error_not_pst": "קובץ זה אינו קובץ נתונים של Outlook ש-Bulwark יכול לקרוא.",
"pst_error_unreadable": "Bulwark לא הצליח לקרוא את קובץ ה-Outlook הזה.",
"pst_error_worker": "קורא קובצי Outlook לא נטען. טענו את הדף מחדש ונסו שוב."
"pst_error_worker": "קורא קובצי Outlook לא נטען. טענו את הדף מחדש ונסו שוב.",
"error_over_quota": "תיבת הדואר מלאה, ולכן הייבוא נעצר. פנו מקום וייבאו שוב את אותם קבצים לאותה תיקייה כדי להמשיך.",
"error_forbidden": "אינכם רשאים להוסיף דואר לתיקייה זו, ולכן הייבוא נעצר.",
"error_failures": "יותר מדי הודעות ברצף נכשלו, ולכן הייבוא נעצר. ייבאו שוב מאוחר יותר את אותם קבצים לאותה תיקייה כדי להמשיך.",
"error_network": "החיבור לשרת נכשל שוב ושוב, ולכן הייבוא נעצר. ייבאו שוב את אותם קבצים לאותה תיקייה כדי להמשיך.",
"error_busy": "הקבצים האלה כבר מיובאים בכרטיסייה אחרת.",
"retry_hint": "ייבאו שוב את אותם קבצים לאותה תיקייה כדי לנסות שוב את אלה שנכשלו."
},
"mail_export": {
"menu_export": "ייצוא תיקייה…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Ez az Outlook-fájl erős titkosítást használ, amelyet a Bulwark nem tud olvasni. Az Outlookban exportálja a leveleket egy új, titkosítás nélküli .pst fájlba.",
"pst_error_not_pst": "Ez a fájl nem olyan Outlook-adatfájl, amelyet a Bulwark olvasni tud.",
"pst_error_unreadable": "A Bulwark nem tudta beolvasni ezt az Outlook-fájlt.",
"pst_error_worker": "Az Outlook-fájlok olvasója nem töltődött be. Töltse újra az oldalt, és próbálja meg ismét."
"pst_error_worker": "Az Outlook-fájlok olvasója nem töltődött be. Töltse újra az oldalt, és próbálja meg ismét.",
"error_over_quota": "A postafiók megtelt, ezért az importálás leállt. Szabadítson fel helyet, és importálja újra ugyanazokat a fájlokat ugyanabba a mappába a folytatáshoz.",
"error_forbidden": "Ebbe a mappába nem adhat hozzá leveleket, ezért az importálás leállt.",
"error_failures": "Túl sok üzenet hiúsult meg egymás után, ezért az importálás leállt. Később importálja újra ugyanazokat a fájlokat ugyanabba a mappába a folytatáshoz.",
"error_network": "A kapcsolat a szerverrel többször megszakadt, ezért az importálás leállt. Importálja újra ugyanazokat a fájlokat ugyanabba a mappába a folytatáshoz.",
"error_busy": "Ezeket a fájlokat már importálja egy másik lap.",
"retry_hint": "Importálja újra ugyanazokat a fájlokat ugyanabba a mappába, hogy a sikertelenekkel újra próbálkozzon."
},
"mail_export": {
"menu_export": "Mappa exportálása…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Questo file di Outlook usa la crittografia elevata, che Bulwark non sa leggere. In Outlook, esporta la posta in un nuovo file .pst senza crittografia.",
"pst_error_not_pst": "Questo file non è un file di dati di Outlook che Bulwark sa leggere.",
"pst_error_unreadable": "Bulwark non è riuscito a leggere questo file di Outlook.",
"pst_error_worker": "Il lettore dei file di Outlook non si è caricato. Ricarica la pagina e riprova."
"pst_error_worker": "Il lettore dei file di Outlook non si è caricato. Ricarica la pagina e riprova.",
"error_over_quota": "La casella è piena, quindi l'importazione si è fermata. Libera spazio e importa di nuovo gli stessi file nella stessa cartella per continuare.",
"error_forbidden": "Non puoi aggiungere posta a questa cartella, quindi l'importazione si è fermata.",
"error_failures": "Troppi messaggi di fila non sono riusciti, quindi l'importazione si è fermata. Importa di nuovo gli stessi file nella stessa cartella più tardi per continuare.",
"error_network": "La connessione al server non è riuscita più volte, quindi l'importazione si è fermata. Importa di nuovo gli stessi file nella stessa cartella per continuare.",
"error_busy": "Questi file sono già in importazione in un'altra scheda.",
"retry_hint": "Importa di nuovo gli stessi file nella stessa cartella per riprovare quelli non riusciti."
},
"mail_export": {
"menu_export": "Esporta cartella…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "この Outlook ファイルは Bulwark が読めない高度な暗号化を使っています。Outlook でメールを暗号化なしの新しい .pst ファイルにエクスポートしてください。",
"pst_error_not_pst": "このファイルは Bulwark が読める Outlook データファイルではありません。",
"pst_error_unreadable": "Bulwark はこの Outlook ファイルを読めませんでした。",
"pst_error_worker": "Outlook ファイルの読み込み機能を読み込めませんでした。ページを再読み込みして、もう一度お試しください。"
"pst_error_worker": "Outlook ファイルの読み込み機能を読み込めませんでした。ページを再読み込みして、もう一度お試しください。",
"error_over_quota": "メールボックスがいっぱいのため、インポートを停止しました。空きを作ってから、同じファイルを同じフォルダーにもう一度インポートすると続きから再開します。",
"error_forbidden": "このフォルダーにはメールを追加できないため、インポートを停止しました。",
"error_failures": "続けて失敗したメッセージが多すぎるため、インポートを停止しました。後で同じファイルを同じフォルダーにもう一度インポートすると続きから再開します。",
"error_network": "サーバーへの接続が何度も失敗したため、インポートを停止しました。同じファイルを同じフォルダーにもう一度インポートすると続きから再開します。",
"error_busy": "これらのファイルは別のタブですでにインポート中です。",
"retry_hint": "失敗したものをもう一度試すには、同じファイルを同じフォルダーにもう一度インポートしてください。"
},
"mail_export": {
"menu_export": "フォルダーをエクスポート…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "이 Outlook 파일은 Bulwark가 읽을 수 없는 강력한 암호화를 사용합니다. Outlook에서 메일을 암호화하지 않은 새 .pst 파일로 내보내세요.",
"pst_error_not_pst": "이 파일은 Bulwark가 읽을 수 있는 Outlook 데이터 파일이 아닙니다.",
"pst_error_unreadable": "Bulwark가 이 Outlook 파일을 읽지 못했습니다.",
"pst_error_worker": "Outlook 파일 읽기 도구를 불러오지 못했습니다. 페이지를 새로 고친 뒤 다시 시도하세요."
"pst_error_worker": "Outlook 파일 읽기 도구를 불러오지 못했습니다. 페이지를 새로 고친 뒤 다시 시도하세요.",
"error_over_quota": "사서함이 가득 차서 가져오기를 멈췄습니다. 공간을 확보한 뒤 같은 파일을 같은 폴더로 다시 가져오면 이어서 진행합니다.",
"error_forbidden": "이 폴더에는 메일을 추가할 수 없어서 가져오기를 멈췄습니다.",
"error_failures": "연속으로 실패한 메시지가 너무 많아 가져오기를 멈췄습니다. 나중에 같은 파일을 같은 폴더로 다시 가져오면 이어서 진행합니다.",
"error_network": "서버 연결이 계속 실패해 가져오기를 멈췄습니다. 같은 파일을 같은 폴더로 다시 가져오면 이어서 진행합니다.",
"error_busy": "이 파일은 이미 다른 탭에서 가져오는 중입니다.",
"retry_hint": "실패한 메시지를 다시 시도하려면 같은 파일을 같은 폴더로 다시 가져오세요."
},
"mail_export": {
"menu_export": "폴더 내보내기…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Šis Outlook fails izmanto augstu šifrēšanu, ko Bulwark nevar nolasīt. Programmā Outlook eksportējiet pastu jaunā .pst failā bez šifrēšanas.",
"pst_error_not_pst": "Šis fails nav Outlook datu fails, ko Bulwark var nolasīt.",
"pst_error_unreadable": "Bulwark nevarēja nolasīt šo Outlook failu.",
"pst_error_worker": "Outlook failu lasītājs netika ielādēts. Pārlādējiet lapu un mēģiniet vēlreiz."
"pst_error_worker": "Outlook failu lasītājs netika ielādēts. Pārlādējiet lapu un mēģiniet vēlreiz.",
"error_over_quota": "Pastkaste ir pilna, tāpēc importēšana apstājās. Atbrīvojiet vietu un vēlreiz importējiet tos pašus failus tajā pašā mapē, lai turpinātu.",
"error_forbidden": "Šajā mapē nedrīkstat pievienot pastu, tāpēc importēšana apstājās.",
"error_failures": "Pārāk daudz ziņojumu pēc kārtas neizdevās, tāpēc importēšana apstājās. Vēlāk vēlreiz importējiet tos pašus failus tajā pašā mapē, lai turpinātu.",
"error_network": "Savienojums ar serveri atkārtoti neizdevās, tāpēc importēšana apstājās. Vēlreiz importējiet tos pašus failus tajā pašā mapē, lai turpinātu.",
"error_busy": "Šie faili jau tiek importēti citā cilnē.",
"retry_hint": "Vēlreiz importējiet tos pašus failus tajā pašā mapē, lai vēlreiz mēģinātu neizdevušos."
},
"mail_export": {
"menu_export": "Eksportēt mapi…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Энэ Outlook файл Bulwark уншиж чадахгүй хүчтэй шифрлэлт ашигладаг. Outlook-д шуудангаа шифрлэлтгүй шинэ .pst файл руу экспортлоно уу.",
"pst_error_not_pst": "Энэ файл Bulwark уншиж чадах Outlook-ийн өгөгдлийн файл биш байна.",
"pst_error_unreadable": "Bulwark энэ Outlook файлыг уншиж чадсангүй.",
"pst_error_worker": "Outlook файл уншигч ачаалагдсангүй. Хуудсыг дахин ачаалаад дахин оролдоно уу."
"pst_error_worker": "Outlook файл уншигч ачаалагдсангүй. Хуудсыг дахин ачаалаад дахин оролдоно уу.",
"error_over_quota": "Шуудангийн хайрцаг дүүрсэн тул импорт зогслоо. Зай гаргаад үргэлжлүүлэхийн тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"error_forbidden": "Та энэ хавтсанд шуудан нэмэх эрхгүй тул импорт зогслоо.",
"error_failures": "Дараалан хэт олон захидал амжилтгүй болсон тул импорт зогслоо. Үргэлжлүүлэхийн тулд дараа нь ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"error_network": "Сервертэй холболт удаа дараа тасарсан тул импорт зогслоо. Үргэлжлүүлэхийн тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"error_busy": "Эдгээр файлыг өөр табд аль хэдийн импортлож байна.",
"retry_hint": "Амжилтгүй болсон захидлуудыг дахин оролдохын тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу."
},
"mail_export": {
"menu_export": "Хавтас экспортлох…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Denne Outlook-filen bruker høy kryptering, som Bulwark ikke kan lese. Eksporter e-posten i Outlook til en ny .pst-fil uten kryptering.",
"pst_error_not_pst": "Denne filen er ikke en Outlook-datafil som Bulwark kan lese.",
"pst_error_unreadable": "Bulwark kunne ikke lese denne Outlook-filen.",
"pst_error_worker": "Leseren for Outlook-filer ble ikke lastet. Last inn siden på nytt og prøv igjen."
"pst_error_worker": "Leseren for Outlook-filer ble ikke lastet. Last inn siden på nytt og prøv igjen.",
"error_over_quota": "Postkassen er full, så importen stoppet. Frigjør plass og importer de samme filene til samme mappe igjen for å fortsette.",
"error_forbidden": "Du kan ikke legge e-post i denne mappen, så importen stoppet.",
"error_failures": "For mange meldinger på rad mislyktes, så importen stoppet. Importer de samme filene til samme mappe igjen senere for å fortsette.",
"error_network": "Tilkoblingen til serveren mislyktes gang på gang, så importen stoppet. Importer de samme filene til samme mappe igjen for å fortsette.",
"error_busy": "Disse filene importeres allerede i en annen fane.",
"retry_hint": "Importer de samme filene til samme mappe igjen for å prøve de som mislyktes, på nytt."
},
"mail_export": {
"menu_export": "Eksporter mappe…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Dit Outlook-bestand gebruikt sterke versleuteling, die Bulwark niet kan lezen. Exporteer de e-mail in Outlook naar een nieuw .pst-bestand zonder versleuteling.",
"pst_error_not_pst": "Dit bestand is geen Outlook-gegevensbestand dat Bulwark kan lezen.",
"pst_error_unreadable": "Bulwark kon dit Outlook-bestand niet lezen.",
"pst_error_worker": "De lezer voor Outlook-bestanden is niet geladen. Laad de pagina opnieuw en probeer het nog eens."
"pst_error_worker": "De lezer voor Outlook-bestanden is niet geladen. Laad de pagina opnieuw en probeer het nog eens.",
"error_over_quota": "De mailbox is vol, dus de import is gestopt. Maak ruimte en importeer dezelfde bestanden opnieuw in dezelfde map om verder te gaan.",
"error_forbidden": "U mag geen e-mail aan deze map toevoegen, dus de import is gestopt.",
"error_failures": "Te veel berichten na elkaar zijn mislukt, dus de import is gestopt. Importeer dezelfde bestanden later opnieuw in dezelfde map om verder te gaan.",
"error_network": "De verbinding met de server mislukte steeds, dus de import is gestopt. Importeer dezelfde bestanden opnieuw in dezelfde map om verder te gaan.",
"error_busy": "Deze bestanden worden al in een ander tabblad geïmporteerd.",
"retry_hint": "Importeer dezelfde bestanden opnieuw in dezelfde map om de mislukte nog eens te proberen."
},
"mail_export": {
"menu_export": "Map exporteren…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Ten plik programu Outlook używa silnego szyfrowania, którego Bulwark nie potrafi odczytać. W programie Outlook wyeksportuj pocztę do nowego pliku .pst bez szyfrowania.",
"pst_error_not_pst": "Ten plik nie jest plikiem danych programu Outlook, który Bulwark potrafi odczytać.",
"pst_error_unreadable": "Bulwark nie mógł odczytać tego pliku programu Outlook.",
"pst_error_worker": "Czytnik plików programu Outlook się nie wczytał. Odśwież stronę i spróbuj ponownie."
"pst_error_worker": "Czytnik plików programu Outlook się nie wczytał. Odśwież stronę i spróbuj ponownie.",
"error_over_quota": "Skrzynka jest pełna, więc import się zatrzymał. Zwolnij miejsce i zaimportuj te same pliki ponownie do tego samego folderu, aby kontynuować.",
"error_forbidden": "Nie możesz dodawać poczty do tego folderu, więc import się zatrzymał.",
"error_failures": "Zbyt wiele wiadomości z rzędu się nie udało, więc import się zatrzymał. Zaimportuj te same pliki później ponownie do tego samego folderu, aby kontynuować.",
"error_network": "Połączenie z serwerem wielokrotnie zawiodło, więc import się zatrzymał. Zaimportuj te same pliki ponownie do tego samego folderu, aby kontynuować.",
"error_busy": "Te pliki są już importowane w innej karcie.",
"retry_hint": "Zaimportuj te same pliki ponownie do tego samego folderu, aby jeszcze raz spróbować tych, które się nie udały."
},
"mail_export": {
"menu_export": "Eksportuj folder…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "Este arquivo do Outlook usa criptografia alta, que o Bulwark não consegue ler. No Outlook, exporte os e-mails para um novo arquivo .pst sem criptografia.",
"pst_error_not_pst": "Este arquivo não é um arquivo de dados do Outlook que o Bulwark consiga ler.",
"pst_error_unreadable": "O Bulwark não conseguiu ler este arquivo do Outlook.",
"pst_error_worker": "O leitor de arquivos do Outlook não carregou. Recarregue a página e tente de novo."
"pst_error_worker": "O leitor de arquivos do Outlook não carregou. Recarregue a página e tente de novo.",
"error_over_quota": "A caixa de correio está cheia, por isso a importação parou. Libere espaço e importe os mesmos arquivos na mesma pasta outra vez para continuar.",
"error_forbidden": "Você não pode adicionar e-mails a esta pasta, por isso a importação parou.",
"error_failures": "Muitas mensagens falharam seguidas, por isso a importação parou. Importe os mesmos arquivos na mesma pasta mais tarde para continuar.",
"error_network": "A conexão com o servidor falhou várias vezes, por isso a importação parou. Importe os mesmos arquivos na mesma pasta outra vez para continuar.",
"error_busy": "Estes arquivos já estão sendo importados em outra aba.",
"retry_hint": "Importe os mesmos arquivos na mesma pasta outra vez para tentar de novo os que falharam."
},
"mail_export": {
"menu_export": "Exportar pasta…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Acest fișier Outlook folosește criptare puternică, pe care Bulwark nu o poate citi. În Outlook, exportați e-mailurile într-un fișier .pst nou, fără criptare.",
"pst_error_not_pst": "Acest fișier nu este un fișier de date Outlook pe care Bulwark îl poate citi.",
"pst_error_unreadable": "Bulwark nu a putut citi acest fișier Outlook.",
"pst_error_worker": "Cititorul de fișiere Outlook nu s-a încărcat. Reîncărcați pagina și încercați din nou."
"pst_error_worker": "Cititorul de fișiere Outlook nu s-a încărcat. Reîncărcați pagina și încercați din nou.",
"error_over_quota": "Căsuța poștală este plină, așa că importul s-a oprit. Faceți loc și importați din nou aceleași fișiere în același dosar pentru a continua.",
"error_forbidden": "Nu puteți adăuga e-mailuri în acest dosar, așa că importul s-a oprit.",
"error_failures": "Prea multe mesaje la rând au eșuat, așa că importul s-a oprit. Importați mai târziu aceleași fișiere în același dosar pentru a continua.",
"error_network": "Conexiunea la server a eșuat de mai multe ori, așa că importul s-a oprit. Importați din nou aceleași fișiere în același dosar pentru a continua.",
"error_busy": "Aceste fișiere se importă deja în altă filă.",
"retry_hint": "Importați din nou aceleași fișiere în același dosar pentru a reîncerca mesajele care au eșuat."
},
"mail_export": {
"menu_export": "Exportă dosarul…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "В этом файле Outlook используется сильное шифрование, которое Bulwark не может прочитать. Экспортируйте почту в Outlook в новый файл .pst без шифрования.",
"pst_error_not_pst": "Этот файл не является файлом данных Outlook, который Bulwark может прочитать.",
"pst_error_unreadable": "Bulwark не смог прочитать этот файл Outlook.",
"pst_error_worker": "Модуль чтения файлов Outlook не загрузился. Перезагрузите страницу и попробуйте снова."
"pst_error_worker": "Модуль чтения файлов Outlook не загрузился. Перезагрузите страницу и попробуйте снова.",
"error_over_quota": "Почтовый ящик заполнен, поэтому импорт остановлен. Освободите место и снова импортируйте те же файлы в ту же папку, чтобы продолжить.",
"error_forbidden": "Вы не можете добавлять почту в эту папку, поэтому импорт остановлен.",
"error_failures": "Слишком много сообщений подряд не удалось импортировать, поэтому импорт остановлен. Позже снова импортируйте те же файлы в ту же папку, чтобы продолжить.",
"error_network": "Соединение с сервером несколько раз прерывалось, поэтому импорт остановлен. Снова импортируйте те же файлы в ту же папку, чтобы продолжить.",
"error_busy": "Эти файлы уже импортируются в другой вкладке.",
"retry_hint": "Снова импортируйте те же файлы в ту же папку, чтобы ещё раз попробовать неудавшиеся."
},
"mail_export": {
"menu_export": "Экспортировать папку…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Tento súbor Outlooku používa vysoké šifrovanie, ktoré Bulwark nevie prečítať. V Outlooku exportujte poštu do nového súboru .pst bez šifrovania.",
"pst_error_not_pst": "Tento súbor nie je dátový súbor Outlooku, ktorý by Bulwark vedel prečítať.",
"pst_error_unreadable": "Bulwark nedokázal prečítať tento súbor Outlooku.",
"pst_error_worker": "Čítačka súborov Outlooku sa nenačítala. Načítajte stránku znova a skúste to ešte raz."
"pst_error_worker": "Čítačka súborov Outlooku sa nenačítala. Načítajte stránku znova a skúste to ešte raz.",
"error_over_quota": "Schránka je plná, preto sa import zastavil. Uvoľnite miesto a importujte rovnaké súbory znova do rovnakého priečinka, aby ste pokračovali.",
"error_forbidden": "Do tohto priečinka nesmiete pridávať poštu, preto sa import zastavil.",
"error_failures": "Príliš veľa správ za sebou zlyhalo, preto sa import zastavil. Importujte rovnaké súbory neskôr znova do rovnakého priečinka, aby ste pokračovali.",
"error_network": "Spojenie so serverom opakovane zlyhalo, preto sa import zastavil. Importujte rovnaké súbory znova do rovnakého priečinka, aby ste pokračovali.",
"error_busy": "Tieto súbory sa už importujú na inej karte.",
"retry_hint": "Importujte rovnaké súbory znova do rovnakého priečinka, aby ste skúsili neúspešné ešte raz."
},
"mail_export": {
"menu_export": "Exportovať priečinok…",
+7 -1
View File
@@ -450,7 +450,13 @@
"pst_error_encrypted": "ไฟล์ Outlook นี้ใช้การเข้ารหัสระดับสูงที่ Bulwark อ่านไม่ได้ ใน Outlook ให้ส่งออกอีเมลไปยังไฟล์ .pst ใหม่ที่ไม่เข้ารหัส",
"pst_error_not_pst": "ไฟล์นี้ไม่ใช่ไฟล์ข้อมูล Outlook ที่ Bulwark อ่านได้",
"pst_error_unreadable": "Bulwark อ่านไฟล์ Outlook นี้ไม่ได้",
"pst_error_worker": "ตัวอ่านไฟล์ Outlook โหลดไม่สำเร็จ โหลดหน้าใหม่แล้วลองอีกครั้ง"
"pst_error_worker": "ตัวอ่านไฟล์ Outlook โหลดไม่สำเร็จ โหลดหน้าใหม่แล้วลองอีกครั้ง",
"error_over_quota": "กล่องจดหมายเต็ม การนำเข้าจึงหยุดลง เคลียร์พื้นที่แล้วนำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งเพื่อทำต่อ",
"error_forbidden": "คุณไม่มีสิทธิ์เพิ่มอีเมลในโฟลเดอร์นี้ การนำเข้าจึงหยุดลง",
"error_failures": "ข้อความล้มเหลวติดต่อกันมากเกินไป การนำเข้าจึงหยุดลง นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งภายหลังเพื่อทำต่อ",
"error_network": "การเชื่อมต่อกับเซิร์ฟเวอร์ล้มเหลวซ้ำๆ การนำเข้าจึงหยุดลง นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งเพื่อทำต่อ",
"error_busy": "ไฟล์เหล่านี้กำลังถูกนำเข้าในอีกแท็บหนึ่งอยู่แล้ว",
"retry_hint": "นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งเพื่อลองข้อความที่ล้มเหลวอีกรอบ"
},
"mail_export": {
"menu_export": "ส่งออกโฟลเดอร์…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "Bu Outlook dosyası Bulwark'ın okuyamadığı yüksek şifreleme kullanıyor. Outlook'ta e-postaları şifrelemesiz yeni bir .pst dosyasına aktarın.",
"pst_error_not_pst": "Bu dosya Bulwark'ın okuyabildiği bir Outlook veri dosyası değil.",
"pst_error_unreadable": "Bulwark bu Outlook dosyasını okuyamadı.",
"pst_error_worker": "Outlook dosya okuyucusu yüklenmedi. Sayfayı yeniden yükleyip tekrar deneyin."
"pst_error_worker": "Outlook dosya okuyucusu yüklenmedi. Sayfayı yeniden yükleyip tekrar deneyin.",
"error_over_quota": "Posta kutusu dolu olduğundan içe aktarım durdu. Yer açın ve devam etmek için aynı dosyaları aynı klasöre yeniden içe aktarın.",
"error_forbidden": "Bu klasöre e-posta ekleyemezsiniz, bu yüzden içe aktarım durdu.",
"error_failures": "Arka arkaya çok fazla ileti başarısız olduğundan içe aktarım durdu. Devam etmek için aynı dosyaları daha sonra aynı klasöre yeniden içe aktarın.",
"error_network": "Sunucuya bağlantı tekrar tekrar başarısız olduğundan içe aktarım durdu. Devam etmek için aynı dosyaları aynı klasöre yeniden içe aktarın.",
"error_busy": "Bu dosyalar zaten başka bir sekmede içe aktarılıyor.",
"retry_hint": "Başarısız olanları bir kez daha denemek için aynı dosyaları aynı klasöre yeniden içe aktarın."
},
"mail_export": {
"menu_export": "Klasörü dışa aktar…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "У цьому файлі Outlook використано сильне шифрування, яке Bulwark не може прочитати. Експортуйте пошту в Outlook до нового файлу .pst без шифрування.",
"pst_error_not_pst": "Цей файл не є файлом даних Outlook, який Bulwark може прочитати.",
"pst_error_unreadable": "Bulwark не зміг прочитати цей файл Outlook.",
"pst_error_worker": "Модуль читання файлів Outlook не завантажився. Перезавантажте сторінку й спробуйте ще раз."
"pst_error_worker": "Модуль читання файлів Outlook не завантажився. Перезавантажте сторінку й спробуйте ще раз.",
"error_over_quota": "Поштову скриньку заповнено, тому імпорт зупинено. Звільніть місце й знову імпортуйте ті самі файли до тієї самої теки, щоб продовжити.",
"error_forbidden": "Ви не можете додавати пошту до цієї теки, тому імпорт зупинено.",
"error_failures": "Забагато повідомлень поспіль не вдалося імпортувати, тому імпорт зупинено. Пізніше знову імпортуйте ті самі файли до тієї самої теки, щоб продовжити.",
"error_network": "З'єднання із сервером кілька разів переривалося, тому імпорт зупинено. Знову імпортуйте ті самі файли до тієї самої теки, щоб продовжити.",
"error_busy": "Ці файли вже імпортуються в іншій вкладці.",
"retry_hint": "Знову імпортуйте ті самі файли до тієї самої теки, щоб ще раз спробувати невдалі."
},
"mail_export": {
"menu_export": "Експортувати теку…",
+7 -1
View File
@@ -5445,7 +5445,13 @@
"pst_error_encrypted": "Tệp Outlook này dùng mã hóa mạnh mà Bulwark không đọc được. Trong Outlook, hãy xuất thư sang một tệp .pst mới không mã hóa.",
"pst_error_not_pst": "Tệp này không phải tệp dữ liệu Outlook mà Bulwark đọc được.",
"pst_error_unreadable": "Bulwark không đọc được tệp Outlook này.",
"pst_error_worker": "Không tải được trình đọc tệp Outlook. Hãy tải lại trang rồi thử lại."
"pst_error_worker": "Không tải được trình đọc tệp Outlook. Hãy tải lại trang rồi thử lại.",
"error_over_quota": "Hộp thư đã đầy nên việc nhập đã dừng. Hãy giải phóng dung lượng rồi nhập lại cùng các tệp vào cùng thư mục để tiếp tục.",
"error_forbidden": "Bạn không được thêm thư vào thư mục này nên việc nhập đã dừng.",
"error_failures": "Quá nhiều thư liên tiếp không nhập được nên việc nhập đã dừng. Hãy nhập lại cùng các tệp vào cùng thư mục sau để tiếp tục.",
"error_network": "Kết nối tới máy chủ liên tục thất bại nên việc nhập đã dừng. Hãy nhập lại cùng các tệp vào cùng thư mục để tiếp tục.",
"error_busy": "Các tệp này đang được nhập ở một thẻ khác.",
"retry_hint": "Nhập lại cùng các tệp vào cùng thư mục để thử lại những thư không nhập được."
},
"mail_export": {
"menu_export": "Xuất thư mục…",
+7 -1
View File
@@ -5433,7 +5433,13 @@
"pst_error_encrypted": "此 Outlook 檔案使用了 Bulwark 無法讀取的高強度加密。請在 Outlook 中把郵件匯出到一個不加密的新 .pst 檔案。",
"pst_error_not_pst": "此檔案不是 Bulwark 能讀取的 Outlook 資料檔。",
"pst_error_unreadable": "Bulwark 無法讀取此 Outlook 檔案。",
"pst_error_worker": "Outlook 檔案讀取元件未能載入。請重新載入頁面後再試。"
"pst_error_worker": "Outlook 檔案讀取元件未能載入。請重新載入頁面後再試。",
"error_over_quota": "信箱已滿,匯入已停止。請騰出空間,然後把相同的檔案再次匯入同一個資料夾即可繼續。",
"error_forbidden": "您無權在此資料夾新增郵件,匯入已停止。",
"error_failures": "連續失敗的郵件太多,匯入已停止。請稍後把相同的檔案再次匯入同一個資料夾以繼續。",
"error_network": "與伺服器的連線多次失敗,匯入已停止。請把相同的檔案再次匯入同一個資料夾以繼續。",
"error_busy": "這些檔案已在另一個分頁中匯入。",
"retry_hint": "把相同的檔案再次匯入同一個資料夾,即可重試失敗的郵件。"
},
"mail_export": {
"menu_export": "匯出資料夾…",
+7 -1
View File
@@ -5303,7 +5303,13 @@
"pst_error_encrypted": "此 Outlook 文件使用了 Bulwark 无法读取的高强度加密。请在 Outlook 中把邮件导出到一个不加密的新 .pst 文件。",
"pst_error_not_pst": "此文件不是 Bulwark 能读取的 Outlook 数据文件。",
"pst_error_unreadable": "Bulwark 无法读取此 Outlook 文件。",
"pst_error_worker": "Outlook 文件读取组件未能加载。请重新加载页面后再试。"
"pst_error_worker": "Outlook 文件读取组件未能加载。请重新加载页面后再试。",
"error_over_quota": "邮箱已满,导入已停止。请腾出空间,然后把相同的文件再次导入同一个文件夹即可继续。",
"error_forbidden": "您无权向此文件夹添加邮件,导入已停止。",
"error_failures": "连续失败的邮件太多,导入已停止。请稍后把相同的文件再次导入同一个文件夹以继续。",
"error_network": "与服务器的连接多次失败,导入已停止。请把相同的文件再次导入同一个文件夹以继续。",
"error_busy": "这些文件已在另一个标签页中导入。",
"retry_hint": "把相同的文件再次导入同一个文件夹,即可重试失败的邮件。"
},
"mail_export": {
"menu_export": "导出文件夹…",
+13 -4
View File
@@ -23,9 +23,17 @@ export interface ExportRequest {
export type TransferRequest = ImportRequest | ExportRequest;
interface JobBase {
controller: AbortController;
/** The Bulwark account that runs it: its sign-out stops it. */
scope?: string;
/** Settles when the job has ended. */
finished?: Promise<unknown>;
}
export type TransferJob =
| { kind: "import"; progress: ImportProgress; controller: AbortController }
| { kind: "export"; progress: ExportProgress; controller: AbortController };
| (JobBase & { kind: "import"; progress: ImportProgress })
| (JobBase & { kind: "export"; progress: ExportProgress });
interface MailTransferState {
request: TransferRequest | null;
@@ -40,7 +48,8 @@ interface MailTransferState {
close: () => void;
startJob: (job: TransferJob) => void;
updateProgress: (progress: ImportProgress | ExportProgress) => void;
cancelJob: () => void;
/** `reason` "signout": the account signs out, so nothing more is written for it. */
cancelJob: (reason?: string) => void;
}
export function isJobActive(job: TransferJob | null): boolean {
@@ -79,7 +88,7 @@ export const useMailTransferStore = create<MailTransferState>()((set, get) => ({
const job = get().job;
if (job) set({ job: { ...job, progress } as TransferJob });
},
cancelJob: () => get().job?.controller.abort(),
cancelJob: (reason) => get().job?.controller.abort(reason),
}));
/** Opens the import dialog, for a folder or (from Settings) for none. */