Merge branch 'feat/enterprise-pack-c'

# Conflicts:
#	lib/sign-out-cleanup.ts
This commit is contained in:
Linus Rath
2026-11-01 07:39:30 +01:00
93 changed files with 10757 additions and 334 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
DEV_MOCK_JMAP: "true"
NEXT_TELEMETRY_DISABLED: "1"
GIT_COMMIT: ${{ github.sha }}
run: node scripts/build-plugin-sandbox.mjs && npx next build --webpack
run: node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs && npx next build --webpack
- name: Run perf guard
env:
+2
View File
@@ -19,6 +19,8 @@
/lite-image/
# scripts/build-plugin-sandbox.mjs
/public/plugin-sandbox-assets/
# scripts/build-pst-worker.mjs
/public/pst-worker.js
# production
/build
+1
View File
@@ -30,6 +30,7 @@ RUN rm -rf "app/api/dev-jmap" && \
find . -path ./node_modules -prune -o \
\( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \
-prune -exec rm -rf {} \; && \
node scripts/build-pst-worker.mjs && \
node scripts/build-plugin-sandbox.mjs && \
npx next build --webpack
+2
View File
@@ -20,6 +20,7 @@ import { AccessibilityQuickPanelHost } from "@/components/accessibility/accessib
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
import { MailTransferHost } from "@/components/mail-transfer/mail-transfer-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PluginAppEvents } from "@/components/providers/plugin-app-events";
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
@@ -90,6 +91,7 @@ export default async function LocaleLayout({
<TotpReauthDialog />
<PluginConnectDialog />
<QuickRuleHost />
<MailTransferHost />
<PluginConsentDialog />
<PluginOAuthCallbackListener />
<PluginAppEvents />
+5 -47
View File
@@ -3,7 +3,7 @@
import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { openMailImport } from "@/stores/mail-transfer-store";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { mailLanguage } from "@/lib/mail-language";
@@ -1309,6 +1309,7 @@ export function EmailViewer({
const archiveActionEnabled = useArchiveActionEnabled();
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tImport = useTranslations('mail_import');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
@@ -3619,52 +3620,9 @@ export function EmailViewer({
await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined });
};
// Import email from .eml file or .zip archive containing .eml files
// Import mail into the open folder: the import dialog (lib/mail-import) does the rest.
const handleImportEmail = () => {
if (!client) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = EML_IMPORT_ACCEPT;
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return;
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const mailboxId = mailbox?.originalId || selectedMailbox;
if (!mailboxId) {
toast.error(tNotifications('import_email_error'));
return;
}
let emails;
try {
emails = await expandImportableEmails(files);
} catch {
toast.error(tNotifications('import_email_error'));
return;
}
let imported = 0;
let failed = 0;
for (const { blob, receivedAt } of emails) {
try {
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true }, undefined, receivedAt);
imported++;
} catch {
failed++;
}
}
if (imported > 0) {
toast.success(tNotifications('import_email_success'));
await fetchEmails(client);
}
if (failed > 0 || emails.length === 0) {
toast.error(tNotifications('import_email_error'));
}
};
input.click();
openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined });
};
// Print only the email content in a new window. A print asked for from
@@ -4145,7 +4103,7 @@ export function EmailViewer({
...(effectiveEmailContent.isHtml
? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }]
: []),
{ kind: 'item', key: 'extra:import', icon: Upload, label: t('import_email'), run: handleImportEmail },
{ kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail },
...(onShowShortcuts && !isMobile
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
: []),
+12 -1
View File
@@ -21,6 +21,7 @@ import {
FolderX,
RefreshCw,
Upload,
Download,
Users,
EyeOff,
Bell,
@@ -28,6 +29,7 @@ import {
RotateCcw,
} from "@/components/icons";
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
interface Position {
x: number;
@@ -140,6 +142,8 @@ export function MailboxContextMenu({
notifications,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
const handleAction = (action: () => void) => {
action();
@@ -317,10 +321,17 @@ export function MailboxContextMenu({
<ContextMenuItem
icon={Upload}
label={t("import_email")}
label={tImport("menu_import")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
/>
<ContextMenuItem
icon={Download}
label={tExport("menu_export")}
onClick={() => handleAction(() => openMailExport({ mailboxId: mailbox.id }))}
disabled={mailbox.totalEmails === 0 || mailbox.myRights?.mayReadItems === false}
testId="mailbox-export"
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
import { useMailTransferStore, openMailExport } from '@/stores/mail-transfer-store';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Mailbox } from '@/lib/jmap/types';
const startExport = vi.fn();
vi.mock('@/components/mail-transfer/start-export', () => ({ startExport: (...args: unknown[]) => startExport(...args) }));
const box = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox => ({
id, name, sortOrder: 0, totalEmails: 3, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
myRights: {} as Mailbox['myRights'], ...extra,
});
beforeEach(() => {
startExport.mockReset();
useMailTransferStore.setState({ request: null, visible: false, job: null });
useEmailStore.setState({ mailboxes: [box('inbox', 'Inbox', { role: 'inbox' }), box('acc2:shared', 'Team', { originalId: 'shared', accountId: 'acc2', isShared: true })] } as never);
useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as never, username: 'alice@example.org' } as never);
});
afterEach(() => {
delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker;
});
describe('MailExportDialog', () => {
it('asks where to save first where the browser can, and exports a shared folder from its own account', async () => {
const writable = { write: vi.fn(), close: vi.fn(), abort: vi.fn() };
const picker = vi.fn(async () => ({ createWritable: async () => writable }));
(window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker;
render(<MailTransferHost />);
act(() => openMailExport({ mailboxId: 'acc2:shared' }));
expect(screen.getByText('title_folder')).toBeTruthy();
expect(screen.getByText('save_picker')).toBeTruthy();
fireEvent.click(screen.getByTestId('mail-export-start'));
await waitFor(() => expect(startExport).toHaveBeenCalled());
expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Team.mbox' }));
expect(startExport).toHaveBeenCalledWith(expect.objectContaining({ format: 'mbox', accountId: 'acc2', mailboxId: 'shared' }));
expect(startExport.mock.calls[0][0].sink.kind).toBe('picker');
});
it('does nothing when the save picker is closed', async () => {
(window as { showSaveFilePicker?: unknown }).showSaveFilePicker = vi.fn(async () => { throw new DOMException('closed', 'AbortError'); });
render(<MailTransferHost />);
act(() => openMailExport({ mailboxId: 'inbox' }));
fireEvent.click(screen.getByTestId('mail-export-start'));
await waitFor(() => expect((screen.getByTestId('mail-export-start') as HTMLButtonElement).disabled).toBe(false));
expect(startExport).not.toHaveBeenCalled();
});
it('exports the whole account as a ZIP of .eml files into memory where nothing else exists', async () => {
render(<MailTransferHost />);
act(() => openMailExport());
expect(screen.getByText('title_account')).toBeTruthy();
expect(screen.getByText('save_memory')).toBeTruthy();
fireEvent.click(screen.getByTestId('mail-export-format-eml'));
fireEvent.click(screen.getByTestId('mail-export-start'));
await waitFor(() => expect(startExport).toHaveBeenCalled());
const input = startExport.mock.calls[0][0];
expect(input).toMatchObject({ format: 'eml', accountId: 'acc1', mailboxId: undefined });
expect(input.sink.kind).toBe('memory');
});
it('offers the file again when done and frees it on Close', async () => {
const discard = vi.fn(async () => {});
const download = vi.fn();
render(<MailTransferHost />);
act(() => {
openMailExport({ mailboxId: 'inbox' });
useMailTransferStore.getState().startJob({
kind: 'export', controller: new AbortController(),
progress: { phase: 'done', exported: 3, failed: 0, failures: [], total: 3, fraction: 1, file: { name: 'Inbox.mbox', size: 1200, download, discard } },
});
});
expect(screen.getByTestId('mail-export-status').getAttribute('aria-live')).toBe('polite');
fireEvent.click(screen.getByTestId('mail-export-download'));
expect(download).toHaveBeenCalled();
const closes = screen.getAllByRole('button', { name: 'close' });
fireEvent.click(closes[closes.length - 1]);
expect(discard).toHaveBeenCalled();
expect(useMailTransferStore.getState().request).toBeNull();
});
it('signing out stops a running export, or frees a finished one\'s file', 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, progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } });
});
await act(() => runBeforeSignOut('acc1'));
expect(controller.signal.aborted).toBe(true);
const discard = vi.fn(async () => {});
act(() => {
useMailTransferStore.setState({ request: null, visible: false, job: null });
openMailExport({ mailboxId: 'inbox' });
useMailTransferStore.getState().startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'done', exported: 3, failed: 0, failures: [], total: 3, fraction: 1, file: { name: 'a.mbox', size: 1, discard } } });
});
await act(() => runBeforeSignOut('acc1'));
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 />);
act(() => {
openMailExport({ mailboxId: 'inbox' });
useMailTransferStore.getState().startJob({ kind: 'export', controller, progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } });
});
fireEvent.click(screen.getByRole('button', { name: 'background' }));
expect(screen.getByTestId('mail-transfer-chip').textContent).toContain('chip');
expect(controller.signal.aborted).toBe(false);
});
it('keeps the running count out of the live region', () => {
render(<MailTransferHost />);
act(() => {
openMailExport({ mailboxId: 'inbox' });
useMailTransferStore.getState().startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } });
});
expect(screen.getByTestId('mail-export-status').textContent).toBe('running_title');
expect(screen.getByTestId('mail-export-count').textContent).toBe('status_exported');
});
it('says the export stays in memory where the private file system can\'t write files', () => {
vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => ({}) } });
vi.stubGlobal('FileSystemFileHandle', class {});
try {
render(<MailTransferHost />);
act(() => openMailExport());
expect(screen.getByText('save_memory')).toBeTruthy();
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -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);
});
});
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
import { useMailTransferStore, openMailImport } from '@/stores/mail-transfer-store';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Mailbox } from '@/lib/jmap/types';
import type { ImportProgress } from '@/lib/mail-import/engine';
const startImport = vi.fn();
vi.mock('@/components/mail-transfer/start-import', () => ({ startImport: (...args: unknown[]) => startImport(...args) }));
const box = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox => ({
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
myRights: { mayAddItems: true } as Mailbox['myRights'], ...extra,
});
const progress = (extra: Partial<ImportProgress> = {}): ImportProgress => ({
phase: 'running', imported: 3, failed: 0, skipped: {}, failures: [], fraction: 0.4, foldersCreated: 0, ...extra,
});
beforeEach(() => {
startImport.mockReset();
useMailTransferStore.setState({ request: null, visible: false, job: null });
useEmailStore.setState({
mailboxes: [
box('inbox', 'Inbox', { role: 'inbox' }),
box('acc2:shared', 'Team', { originalId: 'shared', accountId: 'acc2', isShared: true, accountName: 'team@example.org' }),
box('ro', 'Read only', { myRights: { mayAddItems: false } as Mailbox['myRights'] }),
],
} as never);
useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as never, activeAccountId: 'bulwark-1' } as never);
});
function pick(files: File[]) {
const input = screen.getByTestId('mail-import-file') as HTMLInputElement;
Object.defineProperty(input, 'files', { value: files, configurable: true });
fireEvent.change(input);
}
describe('MailImportDialog', () => {
it('imports into a shared folder in that folder\'s own account', async () => {
render(<MailTransferHost />);
act(() => openMailImport({ mailboxId: 'acc2:shared' }));
expect(screen.getByRole('dialog')).toBeTruthy();
// Folders the user can't add to aren't offered.
expect(screen.queryByRole('option', { name: 'Read only' })).toBeNull();
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,
files: [expect.objectContaining({ format: 'mbox' })],
}));
});
it('offers to keep a ZIP\'s folders and the top level as target', async () => {
const { ZipWriter } = await import('@/lib/mail-export/zip-writer');
const parts: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
await z.addFile('Archive/a.eml', new TextEncoder().encode('Subject: a\r\n\r\nA'));
await z.finish();
render(<MailTransferHost />);
act(() => openMailImport());
pick([new File(parts as BlobPart[], 'export.zip')]);
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 }));
});
it('loads the folders when opened from Settings before the mail view did, then picks the Inbox', async () => {
const fetchMailboxes = vi.fn(async () => {});
const loaded = useEmailStore.getState().mailboxes;
useEmailStore.setState({ mailboxes: [], fetchMailboxes } as never);
render(<MailTransferHost />);
act(() => openMailImport());
expect(fetchMailboxes).toHaveBeenCalled();
act(() => useEmailStore.setState({ mailboxes: loaded } as never));
await waitFor(() => expect((screen.getByTestId('mail-import-target') as HTMLSelectElement).value).toBe('inbox'));
});
it('cannot start without files', () => {
render(<MailTransferHost />);
act(() => openMailImport());
expect((screen.getByTestId('mail-import-start') as HTMLButtonElement).disabled).toBe(true);
});
it('shows a pause in a live region, cancels on Cancel import, and hides on Escape without cancelling', async () => {
const controller = new AbortController();
render(<MailTransferHost />);
act(() => {
openMailImport();
useMailTransferStore.getState().startJob({ kind: 'import', controller, progress: progress({ phase: 'paused', pausedUntil: Date.now() + 3600_000, quota: { files: 1000, bytes: 50_000_000 } }) });
});
const status = screen.getByTestId('mail-import-status');
expect(status.getAttribute('aria-live')).toBe('polite');
expect(status.textContent).toBe('paused');
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('40');
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(controller.signal.aborted).toBe(false);
// The chip brings it back.
fireEvent.click(screen.getByTestId('mail-transfer-chip'));
expect(screen.getByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'cancel_import' }));
expect(controller.signal.aborted).toBe(true);
});
it('opening another import while one runs shows the running one', () => {
const controller = new AbortController();
render(<MailTransferHost />);
act(() => {
openMailImport();
useMailTransferStore.getState().startJob({ kind: 'import', controller, progress: progress() });
useMailTransferStore.getState().hide();
});
act(() => openMailImport({ mailboxId: 'inbox' }));
expect(screen.getByRole('heading', { name: 'running_title' })).toBeTruthy();
});
it('says when a hidden import ends, with a way back to its summary', async () => {
const { useToastStore } = await import('@/stores/toast-store');
useToastStore.setState({ toasts: [] });
render(<MailTransferHost />);
act(() => {
openMailImport();
useMailTransferStore.getState().startJob({ kind: 'import', controller: new AbortController(), progress: progress() });
useMailTransferStore.getState().hide();
});
act(() => useMailTransferStore.getState().updateProgress(progress({ phase: 'done', imported: 12, failed: 1 })));
const toastShown = useToastStore.getState().toasts.find((x) => x.title === 'toast_done');
expect(toastShown?.action?.label).toBe('toast_show');
act(() => toastShown!.action!.onClick());
expect(screen.getByRole('heading', { name: 'done_title' })).toBeTruthy();
});
it('keeps the focus in the dialog when Import turns into the progress view and then the summary', async () => {
render(<MailTransferHost />);
act(() => openMailImport());
(document.activeElement as HTMLElement | null)?.blur();
act(() => useMailTransferStore.getState().startJob({ kind: 'import', controller: new AbortController(), progress: progress() }));
await waitFor(() => expect(document.activeElement?.textContent).toBe('background'));
act(() => useMailTransferStore.getState().updateProgress(progress({ phase: 'done' })));
await waitFor(() => expect(document.activeElement?.textContent).toBe('close'));
});
it('lists what was skipped and what failed when it ends', () => {
render(<MailTransferHost />);
act(() => {
openMailImport();
useMailTransferStore.getState().startJob({
kind: 'import', controller: new AbortController(),
progress: progress({ phase: 'done', imported: 10, failed: 1, failures: [{ label: 'Hello', reason: 'invalid' }], skipped: { deleted: 2, too_large: 1 } }),
});
});
expect(screen.getByText('done_title')).toBeTruthy();
expect(screen.getByText('skip_too_large')).toBeTruthy();
expect(screen.getByText('skip_deleted')).toBeTruthy();
expect(screen.getByText('Hello: failure_invalid')).toBeTruthy();
// The footer's Close (the X in the corner is named the same).
const closes = screen.getAllByRole('button', { name: 'close' });
fireEvent.click(closes[closes.length - 1]);
expect(useMailTransferStore.getState().job).toBeNull();
});
});
@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { serverMegabytes } from '@/components/mail-transfer/transfer-progress';
describe('serverMegabytes', () => {
it('names Stalwart\'s upload quota as the server does', () => {
expect(serverMegabytes(50_000_000)).toBe('50 MB');
});
});
@@ -0,0 +1,26 @@
"use client";
import { useTranslations } from "next-intl";
import { SettingItem, SettingsSection } from "@/components/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openMailExport, openMailImport } from "@/stores/mail-transfer-store";
/** Settings > Backup & reset > Mail files: bring mail in from files, and save all of it as one file. */
export function MailBackupSettings() {
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
return (
<SettingsSection id="mail-files" title={tImport("section_title")} description={tImport("section_description")}>
<SettingItem label={tImport("settings_label")} description={tImport("settings_description")}>
<Button variant="outline" size="sm" onClick={() => openMailImport()} data-testid="settings-mail-import">
{tImport("settings_button")}
</Button>
</SettingItem>
<SettingItem label={tExport("settings_label")} description={tExport("settings_description")}>
<Button variant="outline" size="sm" onClick={() => openMailExport()} data-testid="settings-mail-export">
{tExport("settings_button")}
</Button>
</SettingItem>
</SettingsSection>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useId, useRef, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store";
import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine";
import { safeName } from "@/lib/mail-export/engine";
import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks";
import { formatFileSize } from "@/lib/utils";
import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress";
import { startExport } from "./start-export";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
/** Where the file will go in this browser, said before the export starts. */
function saveMode(): "picker" | "download" | "memory" {
if (canPickSaveFile()) return "picker";
return canWritePrivateFile() ? "download" : "memory";
}
export function MailExportDialog({ request }: { request: ExportRequest }) {
const t = useTranslations("mail_export");
const locale = useLocale();
const id = useId();
const visible = useMailTransferStore((s) => s.visible);
const job = useMailTransferStore((s) => s.job);
const close = useMailTransferStore((s) => s.close);
const hide = useMailTransferStore((s) => s.hide);
const cancelJob = useMailTransferStore((s) => s.cancelJob);
const mailboxes = useEmailStore((s) => s.mailboxes);
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const [format, setFormat] = useState<ExportFormat>("mbox");
const [starting, setStarting] = useState(false);
const mode = saveMode();
const mailbox = request.mailboxId ? mailboxes.find((m) => m.id === request.mailboxId) : undefined;
const folder = !!request.mailboxId;
const progress = job?.kind === "export" ? job.progress : null;
const active = isJobActive(job);
const stageButton = useRef<HTMLButtonElement>(null);
useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup");
if (!visible) return null;
const onStart = async () => {
if (!client || starting) return;
if (folder && !mailbox) return;
const zip = !folder || format === "eml";
const base = folder ? safeName(mailbox!.name, "mail") : `${safeName(username ?? "mail", "mail")}-${today()}`;
const name = `${base}.${zip ? "zip" : "mbox"}`;
const mime = zip ? "application/zip" : "application/mbox";
setStarting(true);
let sink: ExportSink | null = null;
try {
// The save picker must open straight from the click.
if (mode === "picker") {
try {
sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox");
} catch (error) {
if ((error as { name?: string } | null)?.name === "AbortError") return;
sink = null;
}
}
if (!sink) {
await sweepPrivateExports();
sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime);
}
startExport({
format,
accountId: mailbox?.accountId || client.getAccountId(),
mailboxId: mailbox ? mailbox.originalId || mailbox.id : undefined,
sink,
});
} finally {
setStarting(false);
}
};
const onClose = () => (active ? hide() : close());
if (progress) {
return (
<Dialog
isOpen
onClose={onClose}
size="full-on-phone"
preventOutsideClose
title={progress.phase === "done" ? t("done_title") : progress.phase === "cancelled" ? t("cancelled_title") : progress.phase === "failed" ? t("failed_title") : t("running_title")}
testId="mail-export-dialog"
footer={active ? (
<>
<Button ref={stageButton} variant="outline" onClick={hide}>{t("background")}</Button>
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`}>{t("cancel_export")}</Button>
</>
) : (
<>
{progress.phase === "done" && progress.file?.download && (
<Button variant="outline" onClick={() => progress.file?.download?.()} data-testid="mail-export-download">{t("download_again")}</Button>
)}
<Button ref={stageButton} onClick={() => close()}>{t("close")}</Button>
</>
)}
>
<ExportProgressBody progress={progress} t={t} locale={locale} statusId={`${id}-status`} />
</Dialog>
);
}
const formats: { value: ExportFormat; label: string; hint: string }[] = [
{ value: "mbox", label: folder ? t("format_mbox_folder") : t("format_mbox_account"), hint: t("hint_mbox") },
{ value: "eml", label: folder ? t("format_eml_folder") : t("format_eml_account"), hint: t("hint_eml") },
];
return (
<Dialog
isOpen
onClose={() => close()}
size="full-on-phone"
title={folder ? t("title_folder", { name: mailbox?.name ?? "" }) : t("title_account")}
description={folder ? t("description_folder") : t("description_account")}
testId="mail-export-dialog"
footer={(
<>
<Button variant="outline" onClick={() => close()}>{t("cancel")}</Button>
<Button onClick={() => void onStart()} disabled={starting || (folder && !mailbox)} data-testid="mail-export-start">{t("start")}</Button>
</>
)}
>
<div className="mt-4 space-y-5">
<fieldset>
<legend className="mb-2 text-sm font-medium text-foreground">{t("format_label")}</legend>
<div className="space-y-2">
{formats.map((f) => (
<label key={f.value} className="flex items-start gap-2 rounded-md border border-border p-3 text-sm text-foreground has-[:checked]:border-primary">
<input
type="radio"
name={`${id}-format`}
value={f.value}
checked={format === f.value}
onChange={() => setFormat(f.value)}
className="mt-0.5"
aria-describedby={`${id}-${f.value}-hint`}
data-testid={`mail-export-format-${f.value}`}
/>
<span>
{f.label}
<span id={`${id}-${f.value}-hint`} className="block text-xs text-muted-foreground">{f.hint}</span>
</span>
</label>
))}
</div>
</fieldset>
<p className="text-xs text-muted-foreground">{t(`save_${mode}`)}</p>
</div>
</Dialog>
);
}
type T = ReturnType<typeof useTranslations>;
function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) {
const running = p.phase === "running" || p.phase === "paused";
// The live region says only what changes the user's plans (paused, done);
// the count beside it would otherwise be read out several times a second.
let status: string;
if (p.phase === "paused" && p.pausedUntil) status = t("paused", { time: formatClock(p.pausedUntil, locale) });
else if (running) status = t("running_title");
else status = t("summary_exported", { count: p.exported });
const tooLarge = p.phase === "failed" && /larger than this browser can keep in memory/.test(p.error ?? "");
return (
<div className="mt-4 space-y-4">
{running && <TransferBar fraction={p.total > 0 ? p.fraction : null} label={t("progress_label")} paused={p.phase === "paused"} />}
{running && <p className="text-sm text-foreground tabular-nums" data-testid="mail-export-count">{t("status_exported", { count: p.exported, total: p.total })}</p>}
<p id={statusId} role="status" aria-live="polite" className={running ? "text-sm text-muted-foreground" : "text-sm text-foreground"} data-testid="mail-export-status">{status}</p>
{p.phase === "done" && p.file && (
<p className="text-sm text-muted-foreground break-words" data-testid="mail-export-file">{t("summary_file", { name: p.file.name, size: formatFileSize(p.file.size) })}</p>
)}
{p.failed > 0 && (
<div className="text-sm">
<p className="font-medium text-destructive">{t("summary_failed", { count: p.failed })}</p>
<ul className="mt-1 list-disc ps-5 text-muted-foreground">
{p.failures.map((f, i) => <li key={i} className="break-words">{f.label || t("untitled")}: {f.reason}</li>)}
</ul>
</div>
)}
{p.phase === "failed" && <p className="text-sm text-destructive break-words">{tooLarge ? t("too_large") : p.error}</p>}
</div>
);
}
@@ -0,0 +1,335 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { FileArchive, FileText, Upload, X } from "@/components/icons";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { isJobActive, useMailTransferStore, type ImportRequest } from "@/stores/mail-transfer-store";
import { checkpointKey, readCheckpoint } from "@/lib/mail-import/checkpoints";
import { hasFolders, sniffFormat, type ImportFormat, type SkipReason } from "@/lib/mail-import/sources";
import type { ImportProgress } from "@/lib/mail-import/engine";
import type { Mailbox } from "@/lib/jmap/types";
import { formatFileSize, getMailboxPath } from "@/lib/utils";
import { formatClock, serverMegabytes, TransferBar, useFocusOnStage } from "./transfer-progress";
import { startImport } from "./start-import";
const ACCEPT = ".eml,.zip,.mbox,.mbx,.pst,.ost,message/rfc822,application/zip,application/mbox";
const TOP = "\u0000top";
const SKIP_ORDER: SkipReason[] = ["too_large", "deleted", "embedded", "unreadable", "contact", "calendar", "other"];
interface Picked {
file: File;
format: ImportFormat;
folders: boolean;
}
function mailboxValue(m: Mailbox): string {
return m.id;
}
export function MailImportDialog({ request }: { request: ImportRequest }) {
const t = useTranslations("mail_import");
const locale = useLocale();
const id = useId();
const visible = useMailTransferStore((s) => s.visible);
const job = useMailTransferStore((s) => s.job);
const close = useMailTransferStore((s) => s.close);
const hide = useMailTransferStore((s) => s.hide);
const cancelJob = useMailTransferStore((s) => s.cancelJob);
const mailboxes = useEmailStore((s) => s.mailboxes);
const client = useAuthStore((s) => s.client);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const fileInput = useRef<HTMLInputElement>(null);
const chooseButton = useRef<HTMLButtonElement>(null);
const targets = useMemo(
() => mailboxes
.filter((m) => m.myRights?.mayAddItems !== false)
.map((m) => ({
mailbox: m,
label: (m.isShared && m.accountName ? `${m.accountName} › ` : "") + getMailboxPath(m, mailboxes),
}))
.sort((a, b) => Number(!!a.mailbox.isShared) - Number(!!b.mailbox.isShared) || a.label.localeCompare(b.label)),
[mailboxes],
);
const initialTarget = request.mailboxId && targets.some((x) => x.mailbox.id === request.mailboxId)
? request.mailboxId
: targets.find((x) => x.mailbox.role === "inbox" && !x.mailbox.isShared)?.mailbox.id ?? targets[0]?.mailbox.id ?? TOP;
const [picked, setPicked] = useState<Picked[]>([]);
const [target, setTarget] = useState<string>(initialTarget);
const [keepFolders, setKeepFolders] = useState(true);
const [resume, setResume] = useState(true);
const [resumable, setResumable] = useState(0);
const [checking, setChecking] = useState(false);
const anyFolders = picked.some((p) => p.folders);
const targetMailbox = targets.find((x) => x.mailbox.id === target)?.mailbox;
const accountId = targetMailbox?.accountId || client?.getAccountId() || "";
const targetId = target === TOP ? null : targetMailbox ? targetMailbox.originalId || targetMailbox.id : null;
const effectiveKeep = anyFolders && keepFolders;
// Opened from Settings, the mail view may not have loaded the folders yet.
useEffect(() => {
if (client && mailboxes.length === 0) void useEmailStore.getState().fetchMailboxes(client).catch(() => {});
}, [client, mailboxes.length]);
// Keep the choice valid when the folders arrive or the files change.
useEffect(() => {
const valid = target === TOP ? anyFolders : targets.some((x) => x.mailbox.id === target);
if (!valid && target !== initialTarget) setTarget(initialTarget);
}, [targets, target, anyFolders, initialTarget]);
// An earlier import of the same file into the same place that stopped part way.
useEffect(() => {
let current = true;
if (picked.length === 0 || !accountId) { setResumable(0); setChecking(false); return; }
// 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) 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]);
const addFiles = async (list: FileList | null) => {
const files = Array.from(list ?? []);
const next = await Promise.all(files.map(async (file) => {
const format = await sniffFormat(file);
return { file, format, folders: await hasFolders(file, format) };
}));
setPicked((prev) => [...prev, ...next.filter((n) => !prev.some((p) => p.file.name === n.file.name && p.file.size === n.file.size))]);
};
const formatName = (format: ImportFormat) => t(`format_${format}`);
const progress = job?.kind === "import" ? job.progress : null;
const active = isJobActive(job);
const stageButton = useRef<HTMLButtonElement>(null);
useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup");
const onStart = () => {
if (picked.length === 0 || !accountId) return;
if (target === TOP && !effectiveKeep) return;
startImport({
files: picked.map((p) => ({ file: p.file, format: p.format })),
accountId,
targetMailboxId: targetId,
keepFolders: effectiveKeep,
resume: resume && resumable > 0,
});
};
const onClose = () => (active ? hide() : close());
if (!visible) return null;
if (progress) {
return (
<Dialog
isOpen
onClose={onClose}
size="full-on-phone"
preventOutsideClose
title={progressTitle(t, progress)}
testId="mail-import-dialog"
footer={active ? (
<>
<Button ref={stageButton} variant="outline" onClick={hide}>{t("background")}</Button>
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`}>{t("cancel_import")}</Button>
</>
) : (
<Button ref={stageButton} onClick={() => close()}>{t("close")}</Button>
)}
>
<ProgressBody progress={progress} t={t} locale={locale} statusId={`${id}-status`} />
</Dialog>
);
}
const canStart = picked.length > 0 && !!accountId && !checking && !(target === TOP && !effectiveKeep);
return (
<Dialog
isOpen
onClose={() => close()}
size="full-on-phone"
title={t("title")}
description={t("description")}
testId="mail-import-dialog"
initialFocus={chooseButton}
footer={(
<>
<Button variant="outline" onClick={() => close()}>{t("cancel")}</Button>
<Button onClick={onStart} disabled={!canStart} data-testid="mail-import-start">{t("start")}</Button>
</>
)}
>
<div className="mt-4 space-y-5">
<div>
<input
ref={fileInput}
type="file"
multiple
accept={ACCEPT}
className="hidden"
tabIndex={-1}
aria-hidden="true"
data-testid="mail-import-file"
onChange={(e) => { void addFiles(e.target.files); e.target.value = ""; }}
/>
<Button ref={chooseButton} variant="outline" onClick={() => fileInput.current?.click()}>
<Upload className="me-2" aria-hidden="true" />
{t("choose_files")}
</Button>
{picked.length > 0 && (
<ul className="mt-3 divide-y divide-border rounded-md border border-border" aria-label={t("chosen_files")}>
{picked.map((p, i) => (
<li key={`${p.file.name}:${p.file.size}`} className="flex items-center gap-3 px-3 py-2 text-sm">
{p.format === "eml" ? <FileText className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> : <FileArchive className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{p.file.name}</span>
<span className="block text-xs text-muted-foreground">{formatName(p.format)} · {formatFileSize(p.file.size)}</span>
</span>
<IconButton
label={t("remove_file", { name: p.file.name })}
onClick={() => setPicked((prev) => prev.filter((_, j) => j !== i))}
>
<X aria-hidden="true" />
</IconButton>
</li>
))}
</ul>
)}
</div>
<div>
<label htmlFor={`${id}-target`} className="mb-1 block text-sm font-medium text-foreground">{t("target_label")}</label>
<select
id={`${id}-target`}
value={target}
onChange={(e) => setTarget(e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
data-testid="mail-import-target"
>
{anyFolders && <option value={TOP}>{t("target_top")}</option>}
{targets.map((x) => <option key={x.mailbox.id} value={mailboxValue(x.mailbox)}>{x.label}</option>)}
</select>
</div>
{anyFolders && (
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="checkbox"
className="mt-0.5 rounded border-border"
checked={keepFolders}
onChange={(e) => {
setKeepFolders(e.target.checked);
if (!e.target.checked && target === TOP) setTarget(initialTarget === TOP ? targets[0]?.mailbox.id ?? TOP : initialTarget);
}}
aria-describedby={`${id}-keep-hint`}
/>
<span>
{t("keep_folders")}
<span id={`${id}-keep-hint`} className="block text-xs text-muted-foreground">
{t("keep_folders_hint")}
{target === TOP && <> {t("keep_folders_top_hint")}</>}
</span>
</span>
</label>
)}
{resumable > 0 && (
<label className="flex items-start gap-2 text-sm text-foreground">
<input type="checkbox" className="mt-0.5 rounded border-border" checked={resume} onChange={(e) => setResume(e.target.checked)} />
<span>{t("resume", { count: resumable })}</span>
</label>
)}
{picked.some((p) => p.format === "pst") && <p className="text-xs text-muted-foreground">{t("pst_note")}</p>}
<p className="text-xs text-muted-foreground">{t("quota_note")}</p>
</div>
</Dialog>
);
}
type T = ReturnType<typeof useTranslations>;
const PST_ERRORS = ["encrypted", "not_pst", "unreadable", "worker"];
const IMPORT_ERRORS = ["over_quota", "forbidden", "failures", "network", "busy"];
/** Why an import stopped, in words; anything else as the server or browser said it. */
function errorText(t: T, error: string): string {
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 {
if (p.phase === "done") return t("done_title");
if (p.phase === "cancelled") return t("cancelled_title");
if (p.phase === "failed") return t("failed_title");
return t("running_title");
}
function ProgressBody({ progress: p, t, locale, statusId }: { progress: ImportProgress; t: T; locale: string; statusId: string }) {
const running = p.phase === "running" || p.phase === "paused";
const skipped = SKIP_ORDER.filter((r) => (p.skipped[r] ?? 0) > 0);
let status: string;
if (p.phase === "paused" && p.pausedUntil) {
const time = formatClock(p.pausedUntil, locale);
status = p.quota?.files && p.quota.bytes
? t("paused", { time, files: p.quota.files, size: serverMegabytes(p.quota.bytes) })
: t("paused_generic", { time });
} else if (running) {
status = p.fileName ? t("reading_file", { name: p.fileName }) : t("running_title");
} else {
status = t("summary_imported", { count: p.imported });
}
return (
<div className="mt-4 space-y-4">
{running && <TransferBar fraction={p.fraction} label={t("progress_label")} paused={p.phase === "paused"} />}
<p className="text-sm text-foreground tabular-nums" data-testid="mail-import-count">
{running ? t("status_imported", { count: p.imported }) : null}
</p>
<p id={statusId} role="status" aria-live="polite" className="text-sm text-muted-foreground" data-testid="mail-import-status">
{status}
</p>
{!running && (
<div className="space-y-3 text-sm">
{p.foldersCreated > 0 && <p>{t("summary_folders", { count: p.foldersCreated })}</p>}
{skipped.length > 0 && (
<div>
<p className="font-medium">{t("summary_skipped")}</p>
<ul className="mt-1 list-disc ps-5 text-muted-foreground">
{skipped.map((r) => <li key={r}>{t(`skip_${r}`, { count: p.skipped[r] ?? 0 })}</li>)}
</ul>
</div>
)}
{p.failed > 0 && (
<div>
<p className="font-medium text-destructive">{t("summary_failed", { count: p.failed })}</p>
<ul className="mt-1 list-disc ps-5 text-muted-foreground">
{p.failures.map((f, i) => (
<li key={i} className="break-words">{f.label || t("untitled")}: {f.reason === "invalid" ? t("failure_invalid") : f.reason}</li>
))}
</ul>
</div>
)}
{p.phase === "cancelled" && <p className="text-muted-foreground">{t("cancelled_hint")}</p>}
{p.phase === "done" && (p.retryable ?? 0) > 0 && <p className="text-muted-foreground">{t("retry_hint")}</p>}
{p.phase === "failed" && p.error && <p className="text-destructive break-words">{errorText(t, p.error)}</p>}
</div>
)}
</div>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Loader2, Pause } from "@/components/icons";
import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store";
import { onBeforeSignOut } from "@/lib/sign-out-cleanup";
import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints";
import { sweepPrivateExports } from "@/lib/mail-export/sinks";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { MailImportDialog } from "./mail-import-dialog";
import { MailExportDialog } from "./mail-export-dialog";
import { 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
* with the app, so a job outlives the menu or settings page that started it.
*/
export function MailTransferHost() {
const request = useMailTransferStore((s) => s.request);
const visible = useMailTransferStore((s) => s.visible);
const job = useMailTransferStore((s) => s.job);
// 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();
const job = store.job;
if (job && (accountId === "all" || !job.scope || job.scope === accountId)) {
if (isJobActive(job)) {
store.cancelJob("signout");
await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
}
useMailTransferStore.getState().close({ now: true });
}
if (accountId !== "all") await clearCheckpointsFor(accountId);
}), []);
// Export files a closed tab or a crash left in the browser's private
// storage go when the app starts and when someone signs in.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
useEffect(() => {
if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports();
}, [activeAccountId]);
// A job that ends while its dialog is hidden says so, with a way back to its summary.
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
const lastPhase = useRef<string | undefined>(undefined);
useEffect(() => {
const phase = job?.progress.phase;
const was = lastPhase.current;
lastPhase.current = phase;
if (!job || visible || (was !== "running" && was !== "paused")) return;
if (phase !== "done" && phase !== "failed") return;
const action = { label: (job.kind === "import" ? tImport : tExport)("toast_show"), onClick: () => useMailTransferStore.getState().show() };
if (job.kind === "import") {
if (phase === "done") toast.success(tImport("toast_done", { imported: job.progress.imported, failed: job.progress.failed }), { action });
else toast.warning(tImport("toast_stopped"), { action });
} else if (phase === "done") {
toast.success(tExport("toast_done", { name: job.progress.file?.name ?? "" }), { action });
} else {
toast.warning(tExport("toast_stopped"), { action });
}
// The translators are new functions on every render in tests; the phase is what matters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [job, visible]);
return (
<>
{request?.kind === "import" && <MailImportDialog request={request} />}
{request?.kind === "export" && <MailExportDialog request={request} />}
{!visible && isJobActive(job) && <TransferChip />}
</>
);
}
function TransferChip() {
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
const locale = useLocale();
const job = useMailTransferStore((s) => s.job);
const show = useMailTransferStore((s) => s.show);
if (!job) return null;
const t = job.kind === "export" ? tExport : tImport;
const p = job.progress;
const paused = p.phase === "paused" && p.pausedUntil;
const label = paused
? t("chip_paused", { time: formatClock(p.pausedUntil!, locale) })
: t("chip", { percent: Math.round(p.fraction * 100) });
return (
<button
type="button"
onClick={show}
className="fixed bottom-4 end-4 z-50 flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full border border-border bg-background px-4 py-2 text-sm shadow-lg hover:bg-muted focus-visible:outline-2 focus-visible:outline-primary"
data-testid="mail-transfer-chip"
>
{paused ? <Pause className="size-4 shrink-0 text-amber-600" aria-hidden="true" /> : <Loader2 className="size-4 shrink-0 animate-spin text-primary" aria-hidden="true" />}
<span className="truncate">{label}</span>
</button>
);
}
+63
View File
@@ -0,0 +1,63 @@
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";
export interface StartExportInput {
format: ExportFormat;
/** JMAP account of the folder (or of the whole export). */
accountId: string;
/** One folder (its id in its account); the whole account when absent. */
mailboxId?: string;
sink: ExportSink;
}
/** Runs an export as the dialog's job. */
export function startExport(input: StartExportInput): void {
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
? [{ mailboxId: input.mailboxId, path: [] }]
: folderTree(await client.getMailboxes(input.accountId));
} catch (error) {
await input.sink.abort().catch(() => {});
useMailTransferStore.getState().updateProgress({
phase: "failed", exported: 0, failed: 0, failures: [], total: 0, fraction: 0,
error: error instanceof Error ? error.message : String(error),
});
return;
}
const result = await runExport({
client,
accountId: input.accountId,
folders,
format: input.format,
sink: input.sink,
signal: controller.signal,
onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
});
useMailTransferStore.getState().updateProgress(result);
}
}
+68
View File
@@ -0,0 +1,68 @@
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";
export interface StartImportInput {
files: ImportFile[];
/** JMAP account of the target. */
accountId: string;
/** null = the top level (with keepFolders). */
targetMailboxId: string | null;
keepFolders: boolean;
resume: boolean;
}
/** 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();
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({
client,
accountId: input.accountId,
targetMailboxId: input.targetMailboxId,
keepFolders: input.keepFolders,
files: input.files,
scope,
resume: input.resume,
signal: controller.signal,
onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
openPst,
});
useMailTransferStore.getState().updateProgress(result);
// Show what arrived, if the same account is still the one on screen.
if (useAuthStore.getState().client !== client || result.imported === 0) return;
const email = useEmailStore.getState();
try {
await email.fetchMailboxes(client);
await email.fetchEmails(client, email.selectedMailbox || undefined, { background: true });
} catch {
/* the next refresh shows them */
}
}
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect, type RefObject } from "react";
import { cn } from "@/lib/utils";
/**
* Keeps the focus in the dialog when its buttons change under it: Import or
* Export turns into the progress view, then into the summary, and the button
* that had the focus is gone. `stage` names the view; `target` is the button
* to focus in it.
*/
export function useFocusOnStage(target: RefObject<HTMLElement | null>, stage: string): void {
useEffect(() => {
const el = target.current;
if (!el) return;
const dialog = el.closest('[role="dialog"], [role="alertdialog"]');
if (!dialog || !dialog.contains(document.activeElement)) el.focus();
}, [target, stage]);
}
/** The bar of an import or export, 0 to 1; indeterminate while nothing is known. */
export function TransferBar({ fraction, label, paused }: { fraction: number | null; label: string; paused?: boolean }) {
const percent = fraction === null ? null : Math.round(Math.max(0, Math.min(1, fraction)) * 100);
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent ?? undefined}
className="h-2 w-full rounded-full bg-primary/15 overflow-hidden"
>
<div
className={cn(
"h-full rounded-full transition-[width] duration-300",
paused ? "bg-amber-500" : "bg-primary",
percent === null && "w-1/3 animate-pulse",
)}
style={percent === null ? undefined : { width: `${percent}%` }}
/>
</div>
);
}
/** A server limit in the server's own unit: Stalwart's 50000000 bytes are "50 MB" (not the 48 MB of a 1024-based size). */
export function serverMegabytes(bytes: number): string {
return `${Math.round(bytes / 1_000_000)} MB`;
}
/** "14:00", or a date and time when the moment isn't today. */
export function formatClock(ms: number, locale: string, now = Date.now()): string {
const date = new Date(ms);
const sameDay = new Date(now).toDateString() === date.toDateString();
return new Intl.DateTimeFormat(locale, sameDay ? { hour: "2-digit", minute: "2-digit" } : { dateStyle: "medium", timeStyle: "short" }).format(date);
}
+3 -42
View File
@@ -126,7 +126,7 @@ import { isPhishingMarked } from "@/lib/phishing-links";
import { automaticLabel, classifyOutgoing, labelSourceHeaders, reopenedLabelChoice } from "@/lib/outgoing/classification";
import enMessages from "@/locales/en/common.json";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { openMailImport } from "@/stores/mail-transfer-store";
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
@@ -3775,48 +3775,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
};
// The import dialog (lib/mail-import) picks the files and runs the import.
const handleImportEmailFromContextMenu = (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const targetMailboxId = mailbox.originalId || mailbox.id;
const input = document.createElement('input');
input.type = 'file';
input.accept = EML_IMPORT_ACCEPT;
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return;
let emails;
try {
emails = await expandImportableEmails(files);
} catch {
toast.error(t('notifications.import_email_error'));
return;
}
let imported = 0;
let failed = 0;
for (const { blob, receivedAt } of emails) {
try {
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }, undefined, receivedAt);
imported++;
} catch {
failed++;
}
}
if (imported > 0) {
toast.success(t('notifications.import_email_success'));
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
}
if (failed > 0 || (imported === 0 && emails.length === 0)) {
toast.error(t('notifications.import_email_error'));
}
};
input.click();
openMailImport({ mailboxId });
};
const handleRefreshMailboxes = async () => {
@@ -16,6 +16,7 @@ import { SpamSiegeGame } from './spam-siege-game';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from '@/stores/toast-store';
import { DebugSettings } from './debug-settings';
import { MailBackupSettings } from '@/components/mail-transfer/mail-backup-settings';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -214,6 +215,8 @@ export function AboutDataSettings() {
)}
</SettingsSection>
<MailBackupSettings />
<SettingsSection id="fix" title={t('sections.fix')} description={t('sections.fix_description')}>
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')} term="cache">
<Button variant="outline" size="sm" onClick={() => setShowRefreshConfirm(true)}>
@@ -0,0 +1,448 @@
/**
* Package C: mail import (mbox, PST) and export (folder as mbox or ZIP),
* through the real UI, as ent-carol, against a VM 106 Stalwart.
*
* Run with enterprise-rig/run-spec.sh. Each run works in its own folders
* (C-<stamp>-...) and deletes them with their mail at the end; the upload
* quota (1000 messages or 50 MB an hour per user) easily holds a run.
*/
import { test, expect, type Page, type Download } from '@playwright/test';
import { mkdtempSync, readFileSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { ENT, ENT_PASSWORD, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent';
import { JMAP_URL } from './helpers/config';
import { expandSharedFolders, folderRow } from './helpers/app';
import { listZipEntries, streamZipEntry } from '../../lib/mail-import/zip-reader';
import { ZipWriter } from '../../lib/mail-export/zip-writer';
requireEntEnv();
const USER = ENT.carol;
const STAMP = `C-${Date.now().toString(36)}`;
const PST = readFileSync(path.join(__dirname, '..', '..', 'lib', 'mail-import', '__tests__', 'fixtures', 'Outlook.pst'));
const created: string[] = [];
const MBOX = [
'From alice@example.org Tue May 5 10:11:12 2020',
'Status: RO',
'From: Alice <alice@example.org>',
`Subject: ${STAMP} mbox read`,
'Date: Tue, 5 May 2020 10:11:00 +0000',
'',
'>From the start of a line',
'body one',
'',
'From bob@example.org Wed May 6 11:00:00 2020',
'Status: O',
'X-Status: F',
'From: Bob <bob@example.org>',
`Subject: ${STAMP} mbox flagged`,
'Date: Wed, 6 May 2020 10:59:00 +0000',
'',
'body two',
'',
'From carol@example.org Thu May 7 12:00:00 2020',
'X-Mozilla-Status: 0009',
`Subject: ${STAMP} mbox deleted`,
'',
'deleted in Thunderbird',
'',
'From dave@example.org Fri May 8 13:30:00 2020',
`Subject: ${STAMP} mbox plain`,
'',
'no state header',
'',
// Thunderbird writes when it copied the message into the folder; the Date header is when it was sent.
'From - Sat Nov 1 09:00:00 2025',
'X-Mozilla-Status: 0001',
`Subject: ${STAMP} mbox copied`,
'Date: Mon, 2 Jan 2017 08:30:00 +0100',
'',
'copied years later',
'',
].join('\n');
const auth = () => 'Basic ' + Buffer.from(`${USER}:${ENT_PASSWORD}`).toString('base64');
async function createFolder(name: string, parentId: string | null = null): Promise<string> {
const client = await jmapAs(USER);
const id = await client.createMailbox(name, parentId);
created.push(id);
return id;
}
interface Stored { id: string; subject: string; receivedAt: string; keywords: Record<string, boolean>; blobId: string; hasAttachment: boolean; mailboxIds: Record<string, boolean> }
async function messagesIn(mailboxId: string): Promise<Stored[]> {
const client = await jmapAs(USER);
const res = await client.request([
['Email/query', { accountId: client.accountId, filter: { inMailbox: mailboxId }, sort: [{ property: 'receivedAt', isAscending: true }], limit: 500 }, '0'],
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject', 'receivedAt', 'keywords', 'blobId', 'hasAttachment', 'mailboxIds'] }, '1'],
]);
return res.methodResponses[1][1].list as Stored[];
}
async function children(parentId: string): Promise<Record<string, string>> {
const client = await jmapAs(USER);
const all = await client.mailboxes();
return Object.fromEntries(all.filter((m) => m.parentId === parentId).map((m) => [m.name, m.id]));
}
/** Seeds a message with Email/import, as another client would have stored it. */
async function seed(mailboxId: string, subject: string, receivedAt: string, keywords: Record<string, boolean>): Promise<void> {
const client = await jmapAs(USER);
const raw = `From: Seed <seed@example.org>\r\nTo: ${USER}\r\nSubject: ${subject}\r\nDate: Mon, 1 Jan 2018 08:00:00 +0000\r\nMessage-ID: <${subject.replace(/\W/g, '')}@example.org>\r\n\r\nFrom the seed: ${subject}\r\n`;
const up = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth(), 'Content-Type': 'message/rfc822' }, body: raw });
expect(up.ok).toBe(true);
const { blobId } = await up.json();
const res = await client.request([['Email/import', { accountId: client.accountId, emails: { e: { blobId, mailboxIds: { [mailboxId]: true }, keywords, receivedAt } } }, '0']]);
expect(res.methodResponses[0][1].created?.e?.id, JSON.stringify(res.methodResponses[0][1])).toBeTruthy();
}
async function openFolderMenu(page: Page, name: string): Promise<void> {
const row = folderRow(page, { name }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click({ button: 'right' });
}
async function importFromFolderMenu(page: Page, folder: string, file: { name: string; buffer: Buffer; mimeType?: string } | string, opts: { keepFolders?: boolean } = {}) {
await openFolderMenu(page, folder);
await page.getByRole('menuitem', { name: 'Import mail…' }).click();
const dialog = page.getByTestId('mail-import-dialog');
await expect(dialog).toBeVisible();
// A path keeps the file's date, which the resume checkpoint is keyed on, like a file on the user's disk.
await page.getByTestId('mail-import-file').setInputFiles(typeof file === 'string' ? file : { name: file.name, mimeType: file.mimeType ?? 'application/octet-stream', buffer: file.buffer });
await expect(dialog.getByText(typeof file === 'string' ? path.basename(file) : file.name)).toBeVisible();
if (opts.keepFolders === false) await dialog.getByRole('checkbox', { name: /Recreate the folders/ }).uncheck();
return dialog;
}
/** Hides the save picker so the export goes through the private file and a download Playwright can read. */
async function withoutSavePicker(page: Page): Promise<void> {
await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; });
}
async function exportFolder(page: Page, folder: string, format: 'mbox' | 'eml'): Promise<Download> {
await withoutSavePicker(page);
await openFolderMenu(page, folder);
await page.getByTestId('mailbox-export').click();
const dialog = page.getByTestId('mail-export-dialog');
await expect(dialog).toBeVisible();
await page.getByTestId(`mail-export-format-${format}`).check();
const download = page.waitForEvent('download', { timeout: 60_000 });
await page.getByTestId('mail-export-start').click();
const file = await download;
await expect(dialog.getByRole('heading', { name: 'Export finished' })).toBeVisible({ timeout: 60_000 });
return file;
}
async function readDownload(download: Download): Promise<Buffer> {
return readFileSync(await download.path());
}
async function openBackupSettings(page: Page): Promise<void> {
await page.goto('/settings/backup');
// On a phone a settings link opens the list of sections first.
const button = page.getByTestId('settings-mail-import');
if (!(await button.isVisible().catch(() => false))) {
await page.getByText('Backup & reset', { exact: true }).first().click();
}
await expect(button).toBeVisible();
}
test.describe.configure({ mode: 'serial' });
test.afterAll(async () => {
const client = await jmapAs(USER);
// Children first: a folder with subfolders can't be destroyed.
const all = await client.mailboxes();
const ours = all.filter((m) => m.name.startsWith(STAMP) || created.includes(m.id));
const withDescendants = new Set(ours.map((m) => m.id));
let grew = true;
while (grew) {
grew = false;
for (const m of all) if (m.parentId && withDescendants.has(m.parentId) && !withDescendants.has(m.id)) { withDescendants.add(m.id); grew = true; }
}
const depth = (id: string): number => { const m = all.find((x) => x.id === id); return m?.parentId ? 1 + depth(m.parentId) : 0; };
const ids = [...withDescendants].sort((a, b) => depth(b) - depth(a));
for (const id of ids) {
await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [id], onDestroyRemoveEmails: true }, '0']]).catch(() => {});
}
});
test('an mbox lands in the folder with its dates, read and flagged state, and the deleted message left out', async ({ browser }) => {
const folderName = `${STAMP} mbox`;
const folderId = await createFolder(folderName);
const page = await userPage(browser, USER);
const dialog = await importFromFolderMenu(page, folderName, { name: 'thunderbird.mbox', buffer: Buffer.from(MBOX) });
await expect(dialog.getByText('mbox file')).toBeVisible();
await shot(page, 'c-import-dialog');
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
await expect(dialog.getByText('Messages imported: 4')).toBeVisible();
await expect(dialog.getByText('Deleted in Thunderbird: 1')).toBeVisible();
await shot(page, 'c-import-summary');
const stored = await messagesIn(folderId);
expect(stored.map((m) => m.subject)).toEqual([`${STAMP} mbox copied`, `${STAMP} mbox read`, `${STAMP} mbox flagged`, `${STAMP} mbox plain`]);
// The Date header first; the envelope line only where there is none.
expect(stored.map((m) => m.receivedAt)).toEqual(['2017-01-02T07:30:00Z', '2020-05-05T10:11:00Z', '2020-05-06T10:59:00Z', '2020-05-08T13:30:00Z']);
expect(stored.map((m) => !!m.keywords.$seen)).toEqual([true, true, false, true]);
expect(stored.map((m) => !!m.keywords.$flagged)).toEqual([false, false, true, false]);
// The stored message starts with its own headers (no leftover "From " envelope line) and is unquoted.
const res = await fetch(`${JMAP_URL}/jmap/download/${(await jmapAs(USER)).accountId}/${stored[1].blobId}/m.eml?accept=message/rfc822`, { headers: { Authorization: auth() } });
const raw = await res.text();
expect(raw.startsWith('From: Alice')).toBe(true);
expect(raw).toContain('\nFrom the start of a line');
expect(raw).not.toMatch(/^Status:/m);
await page.context().close();
});
test('a PST lands in its own folders under the target with dates, read state and attachments', async ({ browser }) => {
const parentName = `${STAMP} pst`;
const parentId = await createFolder(parentName);
const page = await userPage(browser, USER);
const dialog = await importFromFolderMenu(page, parentName, { name: 'Outlook.pst', buffer: PST });
await expect(dialog.getByText('Outlook data file')).toBeVisible();
await expect(dialog.getByRole('checkbox', { name: /Recreate the folders/ })).toBeChecked();
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 90_000 });
await expect(dialog.getByText('Messages imported: 14')).toBeVisible();
await expect(dialog.getByText('Folders created: 2')).toBeVisible();
await expect(dialog.getByText('Contacts: 1')).toBeVisible();
await expect(dialog.getByText('Calendar items: 1')).toBeVisible();
await expect(dialog.getByText('Messages attached to other messages: 4')).toBeVisible();
const folders = await children(parentId);
expect(Object.keys(folders).sort()).toEqual(['Inbox', 'Sent Items']);
const inbox = await messagesIn(folders.Inbox);
const sent = await messagesIn(folders['Sent Items']);
expect(inbox).toHaveLength(8);
expect(sent).toHaveLength(6);
expect(inbox.filter((m) => !m.keywords.$seen).map((m) => m.subject).sort()).toEqual(['message 1', 'message 2']);
expect(inbox.find((m) => m.subject === 'Multiple attachments')?.receivedAt).toBe('2011-06-21T23:51:07Z');
expect(inbox.find((m) => m.subject === 'Multiple attachments')?.hasAttachment).toBe(true);
// A message from the Sent folder, rebuilt without original headers.
const client = await jmapAs(USER);
const detail = await client.request([['Email/get', { accountId: client.accountId, ids: [sent.find((m) => m.subject === 'HTML body')!.id], properties: ['from', 'to', 'sentAt', 'attachments', 'htmlBody'] }, '0']]);
const html = detail.methodResponses[0][1].list[0];
expect(html.from[0].email).toBe('saqib.razzaq@xp.local');
expect(html.attachments.length).toBeGreaterThanOrEqual(12);
await page.context().close();
});
test('a folder exported as mbox from the UI imports back to the same messages, dates and read state', async ({ browser }) => {
const sourceName = `${STAMP} export`;
const sourceId = await createFolder(sourceName);
await seed(sourceId, `${STAMP} exp read`, '2019-03-01T08:00:00Z', { $seen: true });
await seed(sourceId, `${STAMP} exp flagged`, '2019-03-02T09:30:00Z', { $flagged: true });
await seed(sourceId, `${STAMP} exp answered`, '2019-03-03T10:45:00Z', { $seen: true, $answered: true });
const backName = `${STAMP} reimport`;
const backId = await createFolder(backName);
const page = await userPage(browser, USER);
const download = await exportFolder(page, sourceName, 'mbox');
expect(download.suggestedFilename()).toBe(`${sourceName}.mbox`);
const mbox = await readDownload(download);
expect(mbox.toString('latin1')).toMatch(/^From bulwark-export Fri Mar 1 08:00:00 2019\r?\n/);
expect(mbox.toString('latin1')).toContain('>From the seed');
await shot(page, 'c-export-done');
await page.getByRole('button', { name: 'Close' }).last().click();
const dialog = await importFromFolderMenu(page, backName, { name: download.suggestedFilename(), buffer: mbox });
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
const original = await messagesIn(sourceId);
const back = await messagesIn(backId);
const view = (list: Stored[]) => list.map((m) => ({ subject: m.subject, receivedAt: m.receivedAt, seen: !!m.keywords.$seen, flagged: !!m.keywords.$flagged, answered: !!m.keywords.$answered }));
expect(back).toHaveLength(3);
expect(view(back)).toEqual(view(original));
await page.context().close();
});
test('a folder exported as a ZIP of .eml files imports back to the same messages', async ({ browser }) => {
const sourceName = `${STAMP} export`;
const client = await jmapAs(USER);
const sourceId = (await client.mailboxByName(sourceName))!.id;
const backName = `${STAMP} reimport zip`;
const backId = await createFolder(backName);
const page = await userPage(browser, USER);
const download = await exportFolder(page, sourceName, 'eml');
expect(download.suggestedFilename()).toBe(`${sourceName}.zip`);
const zip = await readDownload(download);
expect(zip.subarray(0, 4).toString('latin1')).toBe('PK\u0003\u0004');
await page.getByRole('button', { name: 'Close' }).last().click();
const dialog = await importFromFolderMenu(page, backName, { name: download.suggestedFilename(), buffer: zip });
await expect(dialog.getByText('ZIP archive')).toBeVisible();
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
const original = await messagesIn(sourceId);
const back = await messagesIn(backId);
// Kept: every message, byte for byte. Lost: the state and arrival time,
// which .eml files don't hold. They come back read and dated by their Date header.
expect(back.map((m) => m.subject).sort()).toEqual(original.map((m) => m.subject).sort());
expect(original.some((m) => !m.keywords.$seen)).toBe(true);
expect(back.every((m) => m.keywords.$seen)).toBe(true);
expect(back.every((m) => m.receivedAt === '2018-01-01T08:00:00Z')).toBe(true);
await page.context().close();
});
test('the whole account exported from Settings holds a mbox file per folder', async ({ browser }) => {
const page = await userPage(browser, USER);
await openBackupSettings(page);
await withoutSavePicker(page);
await page.getByTestId('settings-mail-export').click();
const dialog = page.getByTestId('mail-export-dialog');
await expect(dialog.getByRole('heading', { name: 'Export all mail' })).toBeVisible();
const download = page.waitForEvent('download', { timeout: 120_000 });
await page.getByTestId('mail-export-start').click();
const file = await download;
await expect(dialog.getByRole('heading', { name: 'Export finished' })).toBeVisible({ timeout: 120_000 });
expect(file.suggestedFilename()).toMatch(/^ent-carol@example\.org-\d{4}-\d{2}-\d{2}\.zip$/);
const zip = new Blob([await readDownload(file)]);
const entries = await listZipEntries(zip);
const exported = entries.find((e) => e.name === `${STAMP} export.mbox`);
expect(exported, entries.map((e) => e.name).join(', ')).toBeTruthy();
let text = '';
for await (const chunk of streamZipEntry(zip, exported!)) text += Buffer.from(chunk).toString('latin1');
expect(text.match(/^From bulwark-export /gm)).toHaveLength(3);
expect(text).toContain(`Subject: ${STAMP} exp flagged`);
// A folder inside another one is written under its path.
expect(entries.some((e) => e.name === `${STAMP} pst/Sent Items.mbox`)).toBe(true);
await page.context().close();
});
test('an import into a folder shared with the user lands in the sharer\'s account', async ({ browser }) => {
const alice = await jmapAs(ENT.alice);
const sharedName = `${STAMP} shared`;
const sharedId = await alice.createSharedFolder(sharedName, USER);
try {
const page = await userPage(browser, USER);
await expandSharedFolders(page, ENT.alice);
const row = folderRow(page, { name: sharedName, shared: true }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Import mail…' }).click();
const dialog = page.getByTestId('mail-import-dialog');
await page.getByTestId('mail-import-file').setInputFiles({ name: 'shared.mbox', mimeType: 'application/mbox', buffer: Buffer.from(`From - Mon Jun 1 10:00:00 2020\nSubject: ${STAMP} into shared\nDate: Mon, 1 Jun 2020 09:00:00 +0000\n\nbody\n`) });
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
const res = await alice.request([
['Email/query', { accountId: alice.accountId, filter: { inMailbox: sharedId } }, '0'],
['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
]);
expect(res.methodResponses[1][1].list.map((m: { subject: string }) => m.subject)).toEqual([`${STAMP} into shared`]);
await page.context().close();
} finally {
await alice.request([['Mailbox/set', { accountId: alice.accountId, destroy: [sharedId], onDestroyRemoveEmails: true }, '0']]).catch(() => {});
}
});
test('a ZIP imported at the top level puts its Sent Items folder into the user\'s own Sent folder', async ({ browser }) => {
const parts: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
await z.addFile('Sent Items/1.eml', new TextEncoder().encode(`From: ${USER}\r\nTo: x@example.org\r\nSubject: ${STAMP} into sent\r\nDate: Mon, 1 Jun 2020 09:00:00 +0000\r\n\r\nbody\r\n`));
await z.finish();
const client = await jmapAs(USER);
const sent = (await client.mailboxByRole('sent'))!;
const page = await userPage(browser, USER);
await openBackupSettings(page);
await page.getByTestId('settings-mail-import').click();
const dialog = page.getByTestId('mail-import-dialog');
await page.getByTestId('mail-import-file').setInputFiles({ name: 'tree.zip', mimeType: 'application/zip', buffer: Buffer.concat(parts) });
await expect(dialog.getByRole('checkbox', { name: /Recreate the folders/ })).toBeChecked();
await page.getByTestId('mail-import-target').selectOption({ label: 'Top level' });
await expect(dialog.getByText(/At the top level, Inbox, Sent Items/)).toBeVisible();
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
await expect(dialog.getByText(/Folders created/)).toHaveCount(0);
const res = await client.request([
['Email/query', { accountId: client.accountId, filter: { inMailbox: sent.id, text: STAMP } }, '0'],
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
]);
const found = (res.methodResponses[1][1].list as { id: string; subject: string }[]).filter((m) => m.subject === `${STAMP} into sent`);
expect(found).toHaveLength(1);
// Not left in the user's real Sent folder.
await client.request([['Email/set', { accountId: client.accountId, destroy: found.map((m) => m.id) }, '0']]);
await page.context().close();
});
test('the progress dialog cancels an import, keeps what came in, and continues later without duplicates', async ({ browser }) => {
const folderName = `${STAMP} cancel`;
const folderId = await createFolder(folderName);
const count = 40;
const big = Array.from({ length: count }, (_, i) =>
`From x@example.org Mon Jun 1 10:${String(i).padStart(2, '0')}:00 2020\nSubject: ${STAMP} n${i}\n\nbody ${i}\n\n`).join('');
const onDisk = path.join(mkdtempSync(path.join(tmpdir(), 'bulwark-c-')), 'many.mbox');
writeFileSync(onDisk, big);
utimesSync(onDisk, new Date('2024-01-01T00:00:00Z'), new Date('2024-01-01T00:00:00Z'));
const page = await userPage(browser, USER);
// Slow uploads down so Cancel lands in the middle.
await page.route(/\/jmap\/upload\//, async (route) => {
await new Promise((r) => setTimeout(r, 500));
// Cancel aborts uploads that are still held here.
await route.continue().catch(() => {});
});
const dialog = await importFromFolderMenu(page, folderName, onDisk);
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Importing mail' })).toBeVisible();
await expect(dialog.getByRole('progressbar', { name: 'Import progress' })).toBeVisible();
await expect(dialog.getByTestId('mail-import-count')).toHaveText(/Messages imported: [1-9]/, { timeout: 30_000 });
await shot(page, 'c-import-progress');
await dialog.getByRole('button', { name: 'Cancel import' }).click();
await expect(dialog.getByRole('heading', { name: 'Import cancelled' })).toBeVisible({ timeout: 30_000 });
await expect(dialog.getByText('To go on later, import the same files into the same folder again.')).toBeVisible();
const partial = (await messagesIn(folderId)).length;
expect(partial).toBeGreaterThan(0);
expect(partial).toBeLessThan(count);
await page.getByRole('button', { name: 'Close' }).last().click();
await page.unroute(/\/jmap\/upload\//);
const again = await importFromFolderMenu(page, folderName, onDisk);
const resume = again.getByRole('checkbox', { name: /Continue where the last import/ });
await expect(resume).toBeChecked();
await page.getByTestId('mail-import-start').click();
await expect(again.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
const all = await messagesIn(folderId);
expect(all).toHaveLength(count);
expect(new Set(all.map((m) => m.subject)).size).toBe(count);
await page.context().close();
});
test('import and export dialogs fill the screen at 390 px and import from Settings', async ({ browser }) => {
const targetName = `${STAMP} phone`;
const target = await createFolder(targetName);
const page = await userPage(browser, USER, true);
await openBackupSettings(page);
await page.getByTestId('settings-mail-import').click();
const dialog = page.getByTestId('mail-import-dialog');
await expect(dialog).toBeVisible();
// Full screen once the opening animation is over.
await expect.poll(async () => (await dialog.boundingBox())?.width ?? 0).toBeGreaterThanOrEqual(389);
await page.getByTestId('mail-import-file').setInputFiles({ name: 'phone.mbox', mimeType: 'application/mbox', buffer: Buffer.from(MBOX.replaceAll(`${STAMP} mbox`, `${STAMP} phone`)) });
// From Settings there is no folder to start from: pick one.
await page.getByTestId('mail-import-target').selectOption({ label: targetName });
await page.waitForTimeout(300);
await shot(page, 'c-import-phone');
await page.getByTestId('mail-import-start').click();
await expect(dialog.getByRole('heading', { name: 'Import finished' })).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => (await dialog.boundingBox())?.width ?? 0).toBeGreaterThanOrEqual(389);
await shot(page, 'c-import-phone-summary');
// Four messages; the one Thunderbird marked deleted stays out.
expect(await messagesIn(target)).toHaveLength(4);
await page.getByRole('button', { name: 'Close' }).last().click();
await page.getByTestId('settings-mail-export').click();
const exportDialog = page.getByTestId('mail-export-dialog');
await expect(exportDialog.getByRole('heading', { name: 'Export all mail' })).toBeVisible();
await expect.poll(async () => (await exportDialog.boundingBox())?.width ?? 0).toBeGreaterThanOrEqual(389);
await shot(page, 'c-export-phone');
// Escape closes it and the focus goes back to the button that opened it.
await page.keyboard.press('Escape');
await expect(exportDialog).toBeHidden();
await expect(page.getByTestId('settings-mail-export')).toBeFocused();
await page.context().close();
});
+17 -52
View File
@@ -1,54 +1,18 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { expandImportableEmails, readMessageDate, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
import { readMessageDate } from '@/lib/eml-import';
import { emlSource, zipSource, type ImportItem } from '@/lib/mail-import/sources';
import { ZipWriter } from '@/lib/mail-export/zip-writer';
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
new File([content], name, { type });
describe('expandImportableEmails', () => {
it('wraps a .eml file as a message/rfc822 blob, keeping its name', async () => {
const out = await expandImportableEmails([emlFile('msg.eml')]);
expect(out).toHaveLength(1);
expect(out[0].name).toBe('msg.eml');
expect(out[0].blob.type).toBe('message/rfc822');
await expect(out[0].blob.text()).resolves.toBe('raw email');
});
const opts = { skipBefore: 0, maxMessageBytes: 50_000_000 };
it('CHARACTERISATION: wraps a non-.eml, non-zip file as rfc822 too', async () => {
const out = await expandImportableEmails([emlFile('note.txt', 'hi', 'text/plain')]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ name: 'note.txt' });
expect(out[0].blob.type).toBe('message/rfc822');
});
it('extracts only .eml entries from a .zip, stripping path prefixes', async () => {
const zip = new JSZip();
zip.file('a.eml', 'A');
zip.file('sub/b.eml', 'B');
zip.file('c.txt', 'C'); // skipped (not .eml)
zip.folder('emptydir'); // skipped (directory)
const blob = await zip.generateAsync({ type: 'blob' });
const file = new File([blob], 'archive.zip', { type: 'application/zip' });
const out = await expandImportableEmails([file]);
expect(out.map((e) => e.name).sort()).toEqual(['a.eml', 'b.eml']);
expect(out.every((e) => e.blob.type === 'message/rfc822')).toBe(true);
});
it('uses the zip path when the MIME type is application/zip even without a .zip name', async () => {
const zip = new JSZip();
zip.file('only.eml', 'X');
const blob = await zip.generateAsync({ type: 'blob' });
const file = new File([blob], 'archive-no-ext', { type: 'application/zip' });
const out = await expandImportableEmails([file]);
expect(out.map((e) => e.name)).toEqual(['only.eml']);
});
it('exposes the accept string for the file picker', () => {
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
});
});
async function all(source: AsyncGenerator<ImportItem>): Promise<ImportItem[]> {
const out: ImportItem[] = [];
for await (const item of source) out.push(item);
return out;
}
describe('Date header as receivedAt (#891)', () => {
const CRLF = String.fromCharCode(13, 10);
@@ -63,16 +27,17 @@ describe('Date header as receivedAt (#891)', () => {
].join(CRLF);
it('carries the Date header of an .eml file as a JMAP UTCDate', async () => {
const out = await expandImportableEmails([emlFile('old.eml', raw)]);
expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
const [item] = await all(emlSource(emlFile('old.eml', raw), opts));
expect(item).toMatchObject({ kind: 'message', receivedAt: '2019-03-05T08:30:00Z' });
});
it('carries the Date header of each .eml inside a .zip', async () => {
const zip = new JSZip();
zip.file('old.eml', raw);
const blob = await zip.generateAsync({ type: 'blob' });
const out = await expandImportableEmails([new File([blob], 'a.zip', { type: 'application/zip' })]);
expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
const parts: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
await z.addFile('old.eml', new TextEncoder().encode(raw));
await z.finish();
const [item] = await all(zipSource(new File(parts as BlobPart[], 'a.zip', { type: 'application/zip' }), opts));
expect(item).toMatchObject({ kind: 'message', receivedAt: '2019-03-05T08:30:00Z' });
});
it('leaves receivedAt unset when there is no usable Date header', async () => {
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
type Call = [string, Record<string, unknown>, string];
function client() {
const c = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(c, { accountId: 'me', downloadUrl: 'https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}' });
return c;
}
afterEach(() => vi.unstubAllGlobals());
describe('importUploadedEmails', () => {
it('imports several uploaded messages in one Email/import into the given account, one result each', async () => {
const c = client();
const calls: Call[][] = [];
vi.spyOn(c as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request').mockImplementation(async (req: Call[]) => {
calls.push(req);
return { methodResponses: [['Email/import', { created: { imp0: { id: 'e1' } }, notCreated: { imp1: { type: 'invalidEmail', description: 'Blob does not contain a valid RFC 5322 message.' } } }, '0']] };
});
const results = await c.importUploadedEmails([
{ blobId: 'b1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2020-01-02T03:04:05Z' },
{ blobId: 'b2', mailboxIds: { inbox: true }, keywords: {} },
], 'shared');
expect(calls).toHaveLength(1);
expect(calls[0][0][0]).toBe('Email/import');
expect(calls[0][0][1]).toEqual({
accountId: 'shared',
emails: {
imp0: { blobId: 'b1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2020-01-02T03:04:05Z' },
imp1: { blobId: 'b2', mailboxIds: { inbox: true }, keywords: {} },
},
});
expect(results).toEqual([{ id: 'e1' }, { error: 'Blob does not contain a valid RFC 5322 message.', type: 'invalidEmail' }]);
});
});
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])));
vi.stubGlobal('fetch', fetch);
const bytes = await client().fetchBlobForTransfer('blob1', 'shared');
expect(Array.from(bytes)).toEqual([1, 2, 3]);
expect(String((fetch.mock.calls[0] as unknown[])[0])).toContain('/download/shared/blob1/');
});
it('reports a 429 to the export without pausing the rest of the client', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('slow down', { status: 429, headers: { 'Retry-After': '30' } })));
const c = client();
await expect(c.fetchBlobForTransfer('blob1', undefined)).rejects.toMatchObject({ name: 'RateLimitError', retryAfterMs: 30_000 });
expect((c as unknown as { isRateLimited(): boolean }).isRateLimited()).toBe(false);
});
});
+5 -57
View File
@@ -1,26 +1,5 @@
import { MAX_FUTURE_SENT_AT_MS } from "./email-date";
export interface ImportableEmail {
name: string;
blob: Blob;
/**
* The message's `Date` header as a JMAP UTCDate, passed as `receivedAt` on
* Email/import. Without it the server stamps the import time and every
* imported message looks like it arrived today (#891).
*/
receivedAt?: string;
}
const EMAIL_MIME = "message/rfc822";
function isEmlName(name: string): boolean {
return /\.eml$/i.test(name);
}
function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
/** Only the header block is needed; don't decode a whole large message. */
const HEADER_SCAN_BYTES = 64 * 1024;
@@ -31,6 +10,11 @@ const HEADER_SCAN_BYTES = 64 * 1024;
*/
export async function readMessageDate(blob: Blob, now: number = Date.now()): Promise<string | undefined> {
const head = await blob.slice(0, HEADER_SCAN_BYTES).text();
return dateFromHeaderBlock(head, now);
}
/** `readMessageDate` for header text already read (anything after the header block is ignored). */
export function dateFromHeaderBlock(head: string, now: number = Date.now()): string | undefined {
const end = head.search(/\r?\n\r?\n/);
const headers = (end === -1 ? head : head.slice(0, end)).replace(/\r?\n[ \t]+/g, " ");
const match = /^Date:[ \t]*(.+)$/im.exec(headers);
@@ -41,39 +25,3 @@ export async function readMessageDate(blob: Blob, now: number = Date.now()): Pro
if (Number.isNaN(time) || time - now > MAX_FUTURE_SENT_AT_MS) return undefined;
return new Date(time).toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const out: ImportableEmail[] = [];
const entries = Object.values(zip.files);
for (const entry of entries) {
if (entry.dir) continue;
if (!isEmlName(entry.name)) continue;
const data = await entry.async("arraybuffer");
const blob = new Blob([data], { type: EMAIL_MIME });
out.push({
name: entry.name.split(/[\\/]/).pop() || entry.name,
blob,
receivedAt: await readMessageDate(blob),
});
}
return out;
}
export async function expandImportableEmails(
files: File[],
): Promise<ImportableEmail[]> {
const out: ImportableEmail[] = [];
for (const file of files) {
if (isZipName(file.name) || file.type === "application/zip") {
out.push(...(await extractEmlsFromZip(file)));
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
out.push({ name: file.name, blob, receivedAt: await readMessageDate(blob) });
}
return out;
}
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
+24 -1
View File
@@ -39,6 +39,16 @@ export interface KeywordDiscoveryResult {
complete: boolean;
}
/** One message for `importUploadedEmails`. */
export interface UploadedEmailImport {
blobId: string;
mailboxIds: Record<string, boolean>;
keywords: Record<string, boolean>;
receivedAt?: string;
}
export type UploadedEmailImportResult = { id: string } | { error: string; type?: string };
/** What `getBlobsAsBase64` brought back. */
export interface BlobBatch {
/** Contents as base64 by blobId, for every blob the server returned. */
@@ -336,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 ───────────────────────────────────────────
@@ -544,7 +556,13 @@ export interface IJMAPClient {
* Many blobs at once through Blob/get (RFC 9404), for exports; null when
* the session doesn't advertise the blob capability.
*/
getBlobsAsBase64?(blobIds: string[], accountId?: string): Promise<BlobBatch | null>;
getBlobsAsBase64?(blobIds: string[], accountId?: string, options?: { sized?: boolean }): Promise<BlobBatch | null>;
/**
* One blob for a mail export, outside the client-wide rate limit: a 429
* throws RateLimitError for the export to wait on, instead of pausing
* every other request.
*/
fetchBlobForTransfer?(blobId: string, accountId: string | undefined, signal?: AbortSignal): Promise<Uint8Array>;
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
// ── Identities ────────────────────────────────────────────────
@@ -690,6 +708,11 @@ export interface IJMAPClient {
* without it the server stamps the import time.
*/
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string, receivedAt?: string): Promise<string>;
/**
* Email/import of several uploaded messages in one request (the mail
* import). One result per entry, in order: the new id or the server's error.
*/
importUploadedEmails?(entries: UploadedEmailImport[], accountId?: string): Promise<UploadedEmailImportResult[]>;
submitEmail(emailId: string, identityId: string): Promise<void>;
/**
* Server-side move of one email across accounts reachable through THIS client
+64 -10
View File
@@ -4,7 +4,7 @@ import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions } from "./client-interface";
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface";
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
import { textConditions, textFilter } from "./search-utils";
import { batched, batchedBySize, itemsPerRequest } from "./request-limits";
@@ -1764,10 +1764,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);
@@ -1798,7 +1798,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);
}
@@ -2097,7 +2097,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.');
}
@@ -2117,7 +2122,7 @@ export class JMAPClient implements IJMAPClient {
'Content-Type': 'application/json',
},
body,
}),
}, opts?.transfer ? { transfer: true } : undefined),
);
const responseText = await response.text();
@@ -4318,6 +4323,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());
@@ -4339,7 +4345,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) {
@@ -6127,6 +6133,26 @@ export class JMAPClient implements IJMAPClient {
* runs as a document (toInertBlob); image/svg+xml and text/html come back
* as application/octet-stream.
*/
/**
* One blob for the mail export, outside the client-wide rate limit: a 429
* throws RateLimitError for the export to wait on instead of pausing every
* other request (authenticatedFetch would). A 401 goes through fetchBlob,
* which renews the session or token.
*/
async fetchBlobForTransfer(blobId: string, accountId: string | undefined, signal?: AbortSignal): Promise<Uint8Array> {
const url = this.getBlobDownloadUrl(blobId, 'message.eml', 'message/rfc822', accountId);
const response = await this.timedFetch(url, { signal }, { Authorization: this.authHeader }, JMAPClient.TRANSFER_TIMEOUT_MS);
if (response.status === 401) {
return new Uint8Array(await (await this.fetchBlob(blobId, 'message.eml', 'message/rfc822', accountId)).arrayBuffer());
}
if (response.status === 429) throw new RateLimitError(JMAPClient.parseRetryAfter(response));
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Failed to fetch blob: ${response.status} - ${body}`);
}
return new Uint8Array(await response.arrayBuffer());
}
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
return URL.createObjectURL(toInertBlob(blob));
@@ -6152,7 +6178,7 @@ export class JMAPClient implements IJMAPClient {
* It checks the access to each blob by itself, though, whichever account the
* request names, so the rest are asked for in the user's own account.
*/
async getBlobsAsBase64(blobIds: string[], accountId?: string): Promise<BlobBatch | null> {
async getBlobsAsBase64(blobIds: string[], accountId?: string, options?: { sized?: boolean }): Promise<BlobBatch | null> {
if (!this.hasCapability(BLOB_CAPABILITY)) return null;
let account = accountId || this.accountId;
const ids = [...new Set(blobIds)];
@@ -6160,7 +6186,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;
@@ -6173,7 +6199,8 @@ export class JMAPClient implements IJMAPClient {
};
let chunks: string[][] = ids.length > 0 ? [ids] : [];
if (ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) {
// `sized`: the caller already kept the batch within BLOB_BATCH_BYTES (the mail export knows each message's size).
if (!options?.sized && ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) {
const sizes = new Map<string, number | undefined>();
try {
for (const chunk of batched(ids, this.getMaxObjectsInGet())) {
@@ -11270,6 +11297,33 @@ export class JMAPClient implements IJMAPClient {
return emailId;
}
async importUploadedEmails(entries: UploadedEmailImport[], accountId?: string): Promise<UploadedEmailImportResult[]> {
if (entries.length === 0) return [];
const targetAccountId = accountId || this.accountId;
const emails: Record<string, Record<string, unknown>> = {};
entries.forEach((entry, i) => {
emails[`imp${i}`] = {
blobId: entry.blobId,
mailboxIds: entry.mailboxIds,
keywords: entry.keywords,
...(entry.receivedAt ? { receivedAt: entry.receivedAt } : {}),
};
});
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);
}
return entries.map((_, i) => {
const created = result?.created?.[`imp${i}`];
if (created?.id) return { id: created.id as string };
const err = result?.notCreated?.[`imp${i}`];
return { error: (err?.description || err?.type || 'Email/import returned nothing') as string, type: err?.type as string | undefined };
});
}
/** Submit an already-imported email for delivery. */
async submitEmail(emailId: string, identityId: string): Promise<void> {
const submissionAccountId = this.getSubmissionAccountId();
+201
View File
@@ -0,0 +1,201 @@
import { describe, it, expect, vi } from 'vitest';
import { runExport, folderTree, safeName, type ExportProgress } from '@/lib/mail-export/engine';
import { memorySink, ExportTooLargeError, type ExportSink } from '@/lib/mail-export/sinks';
import { runImport } from '@/lib/mail-import/engine';
import type { IJMAPClient, UploadedEmailImport } from '@/lib/jmap/client-interface';
import type { Mailbox } from '@/lib/jmap/types';
const enc = new TextEncoder();
const T0 = Date.now();
interface Msg { id: string; mailbox: string; raw: string; keywords: Record<string, boolean>; receivedAt: string; subject: string }
const msg = (i: number, mailbox: string, extra: Partial<Msg> = {}): Msg => ({
id: `m${mailbox}${i}`, mailbox, subject: `Subject ${i}`,
raw: `From: a@example.org\r\nSubject: Subject ${i}\r\nDate: Tue, 5 May 2020 10:0${i % 10}:00 +0000\r\n\r\nFrom the body ${i}\r\n`,
keywords: i % 2 ? { $seen: true } : { $flagged: true }, receivedAt: `2020-05-0${(i % 9) + 1}T10:00:00Z`, ...extra,
});
function fakeClient(messages: Msg[], opts: { blobGet?: boolean; rateLimitOnce?: boolean } = {}) {
let inFlight = 0;
let maxInFlight = 0;
const calls = { blobGet: 0, download: 0 };
let limited = false;
const track = async <T>(run: () => T): Promise<T> => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 1));
try { return run(); } finally { inFlight--; }
};
const byBlob = new Map(messages.map((m) => [`blob-${m.id}`, m]));
const client = {
getMaxConcurrentRequests: () => 4,
queryEmailFields: vi.fn(async (filter: { inMailbox: string }) => messages
.filter((m) => m.mailbox === filter.inMailbox)
.map((m) => ({ id: m.id, blobId: `blob-${m.id}`, size: m.raw.length, keywords: m.keywords, receivedAt: m.receivedAt, subject: m.subject }))
.reverse()),
getBlobsAsBase64: opts.blobGet === false ? undefined : vi.fn(async (ids: string[]) => track(() => {
calls.blobGet++;
return { data: new Map(ids.map((id) => [id, btoa(byBlob.get(id)!.raw)])), failed: [] };
})),
fetchBlobForTransfer: vi.fn(async (blobId: string) => track(() => {
calls.download++;
if (opts.rateLimitOnce && !limited) {
limited = true;
throw Object.assign(new Error('Rate limited by server'), { name: 'RateLimitError', retryAfterMs: 30_000 });
}
return enc.encode(byBlob.get(blobId)!.raw);
})),
} as unknown as IJMAPClient;
return { client, calls, get maxInFlight() { return maxInFlight; } };
}
function collectingSink() {
const parts: Uint8Array[] = [];
const sink: ExportSink = {
kind: 'memory',
write: async (c) => { parts.push(c.slice()); },
close: async () => ({ name: 'x', size: parts.reduce((n, p) => n + p.length, 0), discard: async () => {} }),
abort: async () => {},
};
return { sink, file: (name: string) => new File(parts as BlobPart[], name, { lastModified: 1 }) };
}
function importClient() {
const imported: Array<UploadedEmailImport & { text: string }> = [];
const blobs = new Map<string, string>();
const created: Array<{ name: string; parentId?: string }> = [];
let n = 0;
const client = {
getCapabilities: () => ({}),
getMaxConcurrentRequests: () => 4,
getMaxSizeUpload: () => 50_000_000,
getMailboxes: async () => [{ id: 'in', name: 'Inbox', role: 'inbox' } as Mailbox, { id: 'target', name: 'Restore' } as Mailbox],
createMailbox: async (name: string, parentId?: string) => { created.push({ name, parentId }); return { id: `c${created.length}`, name } as Mailbox; },
uploadBlob: async (f: File) => { const id = `b${n++}`; blobs.set(id, await f.text()); return { blobId: id, size: f.size, type: f.type }; },
importUploadedEmails: async (entries: UploadedEmailImport[]) => entries.map((e) => { imported.push({ ...e, text: blobs.get(e.blobId)! }); return { id: `e${imported.length}` }; }),
} as unknown as IJMAPClient;
return { client, imported, created };
}
const exportOpts = { signal: new AbortController().signal, onProgress: () => {}, sleep: async () => {}, now: () => T0, reportEveryMs: 0 };
const importOpts = { scope: 's', resume: false, signal: new AbortController().signal, onProgress: () => {}, sleep: async () => {}, reportEveryMs: 0 };
describe('runExport', () => {
it('exports a folder as mbox that imports back to the same messages, dates and state', async () => {
const messages = Array.from({ length: 12 }, (_, i) => msg(i, 'inbox'));
const fake = fakeClient(messages);
const out = collectingSink();
const result = await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders: [{ mailboxId: 'inbox', path: [] }], format: 'mbox', sink: out.sink });
expect(result.phase).toBe('done');
expect(result.exported).toBe(12);
// Small messages travel in one Blob/get, not one download each.
expect(fake.calls.blobGet).toBe(1);
expect(fake.calls.download).toBe(0);
const back = importClient();
const imported = await runImport({ ...importOpts, client: back.client, accountId: 'acc', targetMailboxId: 'target', keepFolders: false, files: [{ file: out.file('Inbox.mbox'), format: 'mbox' }] });
expect(imported.imported).toBe(12);
// Same bytes (the state headers the mbox carried are gone again), same order, state and arrival time.
expect(back.imported.map((e) => e.text)).toEqual(messages.map((m) => m.raw));
expect(back.imported.map((e) => e.keywords)).toEqual(messages.map((m) => Object.fromEntries(Object.keys(m.keywords).map((k) => [k, true]))));
expect(back.imported.map((e) => e.receivedAt)).toEqual(messages.map((m) => m.receivedAt));
});
it('exports the account as a ZIP of .eml files whose folders come back on import', async () => {
const messages = [msg(1, 'inbox'), msg(2, 'arch'), msg(3, 'y2020'), msg(4, 'y2020')];
const fake = fakeClient(messages);
const out = collectingSink();
const folders = [
{ mailboxId: 'inbox', path: ['Inbox'] }, { mailboxId: 'arch', path: ['Archive'] },
{ mailboxId: 'y2020', path: ['Archive', '2020'] }, { mailboxId: 'empty', path: ['Empty'] },
];
await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders, format: 'eml', sink: out.sink });
const { listZipEntries } = await import('@/lib/mail-import/zip-reader');
const names = (await listZipEntries(out.file('a.zip'))).map((e) => e.name);
expect(names).toEqual(['Inbox/00001 Subject 1.eml', 'Archive/00001 Subject 2.eml', 'Archive/2020/00001 Subject 3.eml', 'Archive/2020/00002 Subject 4.eml', 'Empty/']);
const back = importClient();
await runImport({ ...importOpts, client: back.client, accountId: 'acc', targetMailboxId: 'target', keepFolders: true, files: [{ file: out.file('a.zip'), format: 'zip' }] });
expect(back.created).toEqual([{ name: 'Inbox', parentId: 'target' }, { name: 'Archive', parentId: 'target' }, { name: '2020', parentId: 'c2' }]);
expect(back.imported).toHaveLength(4);
});
it('exports the account as a ZIP with one mbox per folder', async () => {
const fake = fakeClient([msg(1, 'inbox'), msg(2, 'y2020')]);
const out = collectingSink();
await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders: [{ mailboxId: 'inbox', path: ['Inbox'] }, { mailboxId: 'y2020', path: ['Archive', '2020'] }], format: 'mbox', sink: out.sink });
const back = importClient();
await runImport({ ...importOpts, client: back.client, accountId: 'acc', targetMailboxId: null, keepFolders: true, files: [{ file: out.file('a.zip'), format: 'zip' }] });
expect(back.created).toEqual([{ name: 'Archive', parentId: undefined }, { name: '2020', parentId: 'c1' }]);
expect(Object.keys(back.imported[0].mailboxIds)).toEqual(['in']);
});
it('keeps at most maxConcurrentRequests - 2 requests in flight and downloads large messages one by one', async () => {
const big = 'x'.repeat(1_100_000);
const messages = Array.from({ length: 8 }, (_, i) => msg(i, 'inbox', { raw: `Subject: ${i}\r\n\r\n${big}` }));
const fake = fakeClient(messages);
await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders: [{ mailboxId: 'inbox', path: [] }], format: 'eml', sink: collectingSink().sink });
expect(fake.calls.download).toBe(8);
expect(fake.calls.blobGet).toBe(0);
expect(fake.maxInFlight).toBeLessThanOrEqual(2);
expect(fake.maxInFlight).toBeGreaterThan(1);
});
it('downloads without Blob/get and pauses on a 429 by itself', async () => {
const fake = fakeClient([msg(1, 'inbox'), msg(2, 'inbox')], { blobGet: false, rateLimitOnce: true });
const seen: ExportProgress[] = [];
const result = await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders: [{ mailboxId: 'inbox', path: [] }], format: 'mbox', sink: collectingSink().sink, onProgress: (p) => seen.push(p) });
expect(seen.some((p) => p.phase === 'paused' && p.pausedUntil === T0 + 30_000)).toBe(true);
expect(result.phase).toBe('done');
expect(result.exported).toBe(2);
});
it('stays under the requests-per-minute it allows itself', async () => {
let clock = T0;
const fake = fakeClient(Array.from({ length: 6 }, (_, i) => msg(i, 'inbox')), { blobGet: false });
const waits: number[] = [];
await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders: [{ mailboxId: 'inbox', path: [] }], format: 'mbox', sink: collectingSink().sink,
requestsPerMinute: 3, now: () => clock, sleep: async (ms) => { waits.push(ms); clock += ms; } });
// 1 query + 6 downloads at 3 a minute: two waits of about a minute.
expect(waits.filter((w) => w > 50_000)).toHaveLength(2);
});
it('stops cleanly when cancelled and aborts the sink', async () => {
const controller = new AbortController();
const fake = fakeClient(Array.from({ length: 300 }, (_, i) => msg(i, 'inbox')));
const out = collectingSink();
const abort = vi.spyOn(out.sink, 'abort');
const result = await runExport({ ...exportOpts, client: fake.client, accountId: 'acc', folders: [{ mailboxId: 'inbox', path: [] }], format: 'eml', sink: out.sink, signal: controller.signal,
onProgress: (p) => { if (p.exported >= 100) controller.abort(); } });
expect(result.phase).toBe('cancelled');
expect(result.exported).toBeLessThan(300);
expect(abort).toHaveBeenCalled();
});
});
describe('folderTree', () => {
it('orders special folders first, nests children and makes sibling names unique', () => {
const mb = (id: string, name: string, extra: Partial<Mailbox> = {}) => ({ id, name, sortOrder: 0, ...extra }) as Mailbox;
const tree = folderTree([
mb('t', 'Trash', { role: 'trash' }), mb('a', 'Projects'), mb('a2', 'projects'), mb('i', 'Inbox', { role: 'inbox' }),
mb('c', 'Q1/Q2', { parentId: 'a' }), mb('acc:x', 'Shared', { originalId: 'x' }),
]);
expect(tree).toEqual([
{ mailboxId: 'i', path: ['Inbox'] }, { mailboxId: 't', path: ['Trash'] }, { mailboxId: 'a2', path: ['projects'] },
{ mailboxId: 'a', path: ['Projects (2)'] }, { mailboxId: 'c', path: ['Projects (2)', 'Q1 Q2'] }, { mailboxId: 'x', path: ['Shared'] },
]);
});
it('makes names safe for every file system', () => {
expect(safeName('a:b*c?.', 'x')).toBe('a b c');
expect(safeName('...', 'x')).toBe('x');
});
});
describe('memorySink', () => {
it('refuses more than its limit', async () => {
const sink = memorySink('a.mbox', 'application/mbox', 10);
await sink.write(new Uint8Array(8));
await expect(sink.write(new Uint8Array(8))).rejects.toBeInstanceOf(ExportTooLargeError);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { mboxEntry, envelopeLine } from '@/lib/mail-export/mbox-writer';
import { MboxSplitter } from '@/lib/mail-import/mbox';
import { mboxState, withoutStateHeaders } from '@/lib/mail-import/mbox-status';
import { readHeaderBlock } from '@/lib/mail-import/mbox';
const enc = new TextEncoder();
const dec = new TextDecoder();
const join = (parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
};
function reread(mbox: Uint8Array) {
const s = new MboxSplitter();
return [...s.push(mbox), ...s.end()].map((m) => ({ text: dec.decode(join(m.parts)), state: mboxState(readHeaderBlock(m.parts)), fromLine: m.fromLine }));
}
describe('mboxEntry (mboxrd)', () => {
const raw = 'Subject: hi\r\nX-Mozilla-Status: 0001\r\nStatus: RO\r\nX-Keywords:\r\n old\r\nFrom: a@example.org\r\n\r\nFrom the top\r\n>From quoted\r\nend\r\n';
it('copes with a header line of hundreds of kilobytes, on export and on import', () => {
const long = `X-${'a'.repeat(300_000)}: v\r\nStatus: RO\r\nSubject: s\r\n\r\nbody\r\n`;
const out = dec.decode(join(mboxEntry(enc.encode(long), { $seen: true }, '2020-01-01T00:00:00Z')));
expect(out).toContain('aaa: v\r\nSubject: s\r\n');
const kept = dec.decode(join(withoutStateHeaders([enc.encode(`${'X'.repeat(300_000)}: v\r\nStatus: RO\r\n\r\nb\r\n`)])));
expect(kept.endsWith(': v\r\n\r\nb\r\n')).toBe(true);
});
it('writes an envelope line in asctime UTC', () => {
expect(envelopeLine('2020-05-05T10:11:12Z', '\n')).toBe('From bulwark-export Tue May 5 10:11:12 2020\n');
});
it('quotes From lines, replaces old state headers and keeps CRLF', () => {
const out = dec.decode(join(mboxEntry(enc.encode(raw), { $flagged: true, work: true }, '2020-05-05T10:11:12Z')));
expect(out).toBe(
'From bulwark-export Tue May 5 10:11:12 2020\r\nStatus: O\r\nX-Status: F\r\nX-Keywords: work\r\n'
+ 'Subject: hi\r\nFrom: a@example.org\r\n\r\n>From the top\r\n>>From quoted\r\nend\r\n\r\n',
);
});
it('round-trips through the importer: same bytes minus state headers, same keywords', () => {
const msgs: { raw: string; keywords: Record<string, boolean> }[] = [
{ raw: 'Subject: one\n\n>>From x\nFrom y\n\nFrom MAILER-DAEMON Tue May 5 10:11:12 2020\n', keywords: { $seen: true, $answered: true } },
{ raw: 'Subject: two\r\n\r\nno newline at end', keywords: { $draft: true, $forwarded: true } },
];
const mbox = join(msgs.flatMap((m) => mboxEntry(enc.encode(m.raw), m.keywords, '2021-01-02T03:04:05Z')));
const back = reread(mbox);
expect(back).toHaveLength(2);
expect(back[0].state.keywords).toEqual({ $seen: true, $answered: true });
expect(back[0].text.endsWith('Subject: one\n\n>>From x\nFrom y\n\nFrom MAILER-DAEMON Tue May 5 10:11:12 2020\n')).toBe(true);
expect(back[1].state.keywords).toEqual({ $draft: true, $forwarded: true });
expect(back[1].text.endsWith('Subject: two\r\n\r\nno newline at end\r\n')).toBe(true);
expect(back[1].fromLine).toBe('From bulwark-export Sat Jan 2 03:04:05 2021');
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { canWritePrivateFile, downloadGraceMs, privateFileSink, sweepPrivateExports } from '@/lib/mail-export/sinks';
/** A tiny origin private file system: one directory of files. */
function fakeOpfs() {
const files = new Map<string, Uint8Array[]>();
const dir = {
getFileHandle: async (name: string) => {
if (!files.has(name)) files.set(name, []);
return {
createWritable: async () => ({
write: async (data: Uint8Array) => { files.get(name)!.push(data); },
close: async () => {},
abort: async () => {},
}),
getFile: async () => new File(files.get(name)! as BlobPart[], name),
};
},
removeEntry: async (name: string) => { files.delete(name); },
keys: async function* () { yield* [...files.keys()]; },
};
const root = {
getDirectoryHandle: async (name: string, opts?: { create?: boolean }) => {
if (name !== 'bulwark-exports') throw new Error('no');
if (!opts?.create && files.size === 0 && !created) throw new DOMException('missing', 'NotFoundError');
created = true;
return dir;
},
removeEntry: vi.fn(async () => { files.clear(); }),
};
let created = false;
return { files, root };
}
/** Web Locks where `held` names belong to another tab. */
function fakeLocks(held: Set<string>) {
return {
request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb(held.has(name) ? null : { name }),
};
}
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('sweepPrivateExports', () => {
it('deletes the files no tab holds and leaves another tab\'s running export alone', async () => {
const opfs = fakeOpfs();
opfs.files.set('1-old', [new Uint8Array(3)]);
opfs.files.set('2-busy', [new Uint8Array(3)]);
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set(['bulwark-export-file:2-busy'])) });
await sweepPrivateExports();
expect([...opfs.files.keys()]).toEqual(['2-busy']);
});
it('sweeps nothing without Web Locks unless everyone signed out', async () => {
const opfs = fakeOpfs();
opfs.files.set('1-old', [new Uint8Array(3)]);
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root } });
await sweepPrivateExports();
expect(opfs.files.size).toBe(1);
await sweepPrivateExports({ force: true });
expect(opfs.root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true });
});
});
describe('privateFileSink', () => {
it('keeps the file while its download may still read it, and deletes it after', async () => {
vi.useFakeTimers();
const opfs = fakeOpfs();
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) });
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }));
const sink = (await privateFileSink('a.mbox', 'application/mbox'))!;
await sink.write(new Uint8Array(10));
const file = await sink.close();
const gone = file.discard();
await vi.advanceTimersByTimeAsync(30_000);
expect(opfs.files.size).toBe(1);
await vi.advanceTimersByTimeAsync(downloadGraceMs(10));
await gone;
expect(opfs.files.size).toBe(0);
});
it('deletes at once on sign-out', async () => {
const opfs = fakeOpfs();
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) });
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }));
const sink = (await privateFileSink('a.mbox', 'application/mbox'))!;
await sink.write(new Uint8Array(10));
await (await sink.close()).discard(true);
expect(opfs.files.size).toBe(0);
});
});
describe('canWritePrivateFile', () => {
it('is false where the private file system has no writable files (Safari before 26)', () => {
vi.stubGlobal('navigator', { storage: { getDirectory: async () => ({}) } });
vi.stubGlobal('FileSystemFileHandle', class {});
expect(canWritePrivateFile()).toBe(false);
vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} });
expect(canWritePrivateFile()).toBe(true);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { ZipWriter } from '@/lib/mail-export/zip-writer';
import { crc32 } from '@/lib/mail-export/crc32';
import { listZipEntries, readZipEntry, streamZipEntry } from '@/lib/mail-import/zip-reader';
const enc = new TextEncoder();
const dec = new TextDecoder();
const join = (parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
};
describe('crc32', () => {
it('matches the reference value and streams', () => {
expect(crc32(enc.encode('123456789'))).toBe(0xcbf43926);
expect(crc32(enc.encode('6789'), crc32(enc.encode('12345')))).toBe(0xcbf43926);
});
});
describe('ZipWriter', () => {
async function build(fill: (z: ZipWriter) => Promise<void>) {
const chunks: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { chunks.push(c.slice()); });
await fill(z);
await z.finish();
return join(chunks);
}
it('writes files, folders and a streamed entry that other readers open', async () => {
const big = 'From MAILER-DAEMON x\n'.repeat(5000);
const zip = await build(async (z) => {
await z.addFile('Inbox/0001 Hello.eml', enc.encode('Subject: Hello\r\n\r\nbody'), new Date(2020, 4, 5, 10, 11, 12));
await z.addDirectory('Empty');
await z.addFile('Prüfung/ä.eml', enc.encode('x'.repeat(1000)));
const s = await z.openStream('Archive.mbox');
await s.write(enc.encode(big.slice(0, 1000)));
await s.write(enc.encode(big.slice(1000)));
await s.close();
});
const files = unzipSync(zip);
expect(Object.keys(files).sort()).toEqual(['Archive.mbox', 'Empty/', 'Inbox/0001 Hello.eml', 'Prüfung/ä.eml']);
expect(strFromU8(files['Inbox/0001 Hello.eml'])).toBe('Subject: Hello\r\n\r\nbody');
expect(strFromU8(files['Archive.mbox'])).toBe(big);
});
it('is read back by the importer, whole and in chunks', async () => {
const big = enc.encode('line of mail\n'.repeat(20000));
const zip = await build(async (z) => {
await z.addFile('a.eml', enc.encode('A'));
const s = await z.openStream('b.mbox');
for (let i = 0; i < big.length; i += 7000) await s.write(big.subarray(i, i + 7000));
await s.close();
});
const file = new Blob([zip]);
const entries = await listZipEntries(file);
expect(entries.map((e) => [e.name, e.size])).toEqual([['a.eml', 1], ['b.mbox', big.length]]);
expect(dec.decode(await readZipEntry(file, entries[0]))).toBe('A');
const parts: Uint8Array[] = [];
for await (const c of streamZipEntry(file, entries[1], 4096)) parts.push(c);
// Compared as text: jsdom and Node each have their own Uint8Array.
expect(dec.decode(join(parts))).toBe(dec.decode(big));
});
it('writes Zip64 records past 65,535 entries', async () => {
const zip = await build(async (z) => {
for (let i = 0; i < 65_540; i++) await z.addFile(`${i}`, new Uint8Array(0));
});
// The classic end record says "see Zip64", and the Zip64 one has the count.
const entries = await listZipEntries(new Blob([zip]));
expect(entries).toHaveLength(65_540);
expect(entries[65_539].name).toBe('65539');
}, 60_000);
it('announces Zip64 in the local header of a streamed entry and writes a 64-bit descriptor', async () => {
const zip = await build(async (z) => {
const s = await z.openStream('a.mbox');
await s.write(enc.encode('hello'));
await s.close();
});
const v = new DataView(zip.buffer);
expect(v.getUint16(4, true)).toBe(45);
expect(v.getUint32(18, true)).toBe(0xffffffff);
expect(v.getUint32(22, true)).toBe(0xffffffff);
const nameLength = v.getUint16(26, true);
expect(v.getUint16(28, true)).toBe(20);
expect(v.getUint16(30 + nameLength, true)).toBe(0x0001);
// Readers that use the central directory still open it.
expect(strFromU8(unzipSync(zip)['a.mbox'])).toBe('hello');
const entries = await listZipEntries(new Blob([zip]));
expect(dec.decode(await readZipEntry(new Blob([zip]), entries[0]))).toBe('hello');
});
it('writes the sizes of a streamed entry past 4 GB in 64 bits', async () => {
// Counts the bytes instead of keeping them; only headers and records are kept.
let offset = 0;
const small: { at: number; bytes: Uint8Array }[] = [];
const z = new ZipWriter(async (c) => {
if (c.length < 4096) small.push({ at: offset, bytes: c.slice() });
offset += c.length;
}, { compressStreams: false, checksum: () => 0x12345678 });
const chunk = new Uint8Array(64 * 1024 * 1024);
const s = await z.openStream('huge.mbox');
for (let i = 0; i < 66; i++) await s.write(chunk);
await s.close();
await z.finish();
const size = 66 * 64 * 1024 * 1024;
expect(size).toBeGreaterThan(0xffffffff);
const find = (sig: number) => small.find((r) => new DataView(r.bytes.buffer).getUint32(0, true) === sig)!;
const u64 = (dv: DataView, at: number) => dv.getUint32(at, true) + dv.getUint32(at + 4, true) * 0x1_0000_0000;
const descriptor = new DataView(find(0x08074b50).bytes.buffer);
expect(descriptor.byteLength).toBe(24);
expect(u64(descriptor, 8)).toBe(size);
expect(u64(descriptor, 16)).toBe(size);
const central = new DataView(find(0x02014b50).bytes.buffer);
expect(central.getUint32(20, true)).toBe(0xffffffff);
expect(central.getUint32(24, true)).toBe(0xffffffff);
const extraAt = 46 + central.getUint16(28, true);
expect(central.getUint16(extraAt, true)).toBe(0x0001);
expect(u64(central, extraAt + 4)).toBe(size);
expect(u64(central, extraAt + 12)).toBe(size);
// The central directory starts past 4 GB, so the Zip64 end record is there too.
expect(find(0x06064b50)).toBeTruthy();
}, 120_000);
});
describe('zip reader', () => {
async function oneFile() {
const parts: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
await z.addFile('a.eml', enc.encode('Subject: x\r\n\r\n' + 'body '.repeat(100)));
await z.finish();
return join(parts);
}
const centralAt = (zip: Uint8Array) => {
for (let i = 0; i < zip.length - 4; i++) if (new DataView(zip.buffer, zip.byteOffset).getUint32(i, true) === 0x02014b50) return i;
throw new Error('no central directory');
};
it('refuses an encrypted entry instead of importing garbage', async () => {
const zip = await oneFile();
const at = centralAt(zip);
zip[at + 8] |= 1;
const entries = await listZipEntries(new Blob([zip]));
expect(entries[0].encrypted).toBe(true);
await expect(readZipEntry(new Blob([zip]), entries[0])).rejects.toMatchObject({ name: 'ZipFormatError' });
});
it('refuses an entry whose declared size is wrong instead of cutting it short', async () => {
const zip = await oneFile();
const at = centralAt(zip);
new DataView(zip.buffer, zip.byteOffset).setUint32(at + 24, 10, true);
const entries = await listZipEntries(new Blob([zip]));
await expect(readZipEntry(new Blob([zip]), entries[0])).rejects.toMatchObject({ name: 'ZipFormatError' });
});
});
+20
View File
@@ -0,0 +1,20 @@
let table: Uint32Array | null = null;
function crcTable(): Uint32Array {
if (table) return table;
table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
}
/** CRC-32 (ZIP, PNG) of `data`, continuing from `crc` for streamed input. */
export function crc32(data: Uint8Array, crc = 0): number {
const t = crcTable();
let c = (crc ^ 0xffffffff) >>> 0;
for (let i = 0; i < data.length; i++) c = t[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
+354
View File
@@ -0,0 +1,354 @@
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Mailbox } from "@/lib/jmap/types";
import { mboxEntry } from "./mbox-writer";
import { ZipWriter, type ZipEntryStream } from "./zip-writer";
import type { ExportFile, ExportSink } from "./sinks";
/**
* Exports folders as mbox (mboxrd) or as a ZIP of .eml files, streamed into a
* sink so the mailbox never sits in memory.
*
* - One folder as mbox is a plain .mbox file; anything else is a ZIP: one
* `<path>.mbox` per folder, or `<path>/<n> <subject>.eml` per message, with
* empty folders kept as directories. A message in several folders is
* written once per folder, as IMAP programs do.
* - Messages come in batches through Blob/get (many per request) where the
* server offers it, larger ones and the rest as downloads. At most
* maxConcurrentRequests - 2 requests run at once (Stalwart 1.0 counts
* downloads against it), and the export keeps itself well under the
* server's 1000 requests a minute. A 429 pauses the export alone, never
* the rest of the app (downloads don't go through the client-wide limit).
*/
export type ExportFormat = "mbox" | "eml";
export type ExportPhase = "running" | "paused" | "done" | "cancelled" | "failed";
export interface ExportFolder {
/** The mailbox id in its account (not the store's namespaced id). */
mailboxId: string;
/** Path of names from the top, e.g. ["Archive", "2020"]. */
path: string[];
}
export interface ExportProgress {
phase: ExportPhase;
exported: number;
failed: number;
failures: { label: string; reason: string }[];
total: number;
fraction: number;
pausedUntil?: number;
file?: ExportFile;
error?: string;
}
export interface ExportOptions {
client: IJMAPClient;
accountId: string;
folders: ExportFolder[];
format: ExportFormat;
sink: ExportSink;
signal: AbortSignal;
onProgress: (progress: ExportProgress) => void;
now?: () => number;
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
reportEveryMs?: number;
/** Requests per minute the export allows itself (the server's limit is 1000 for everything). */
requestsPerMinute?: number;
}
interface MessageRef {
id: string;
blobId: string;
size: number;
keywords: Record<string, boolean>;
receivedAt?: string;
subject: string;
}
/** Messages up to this size travel inside Blob/get; bigger ones are downloaded. */
const BATCH_MESSAGE_MAX = 1024 * 1024;
/** Bytes per Blob/get, like the client's BLOB_BATCH_BYTES. */
const BATCH_BYTES = 4 * 1024 * 1024;
const BATCH_COUNT = 100;
const MAX_FAILURES_LISTED = 20;
const RETRY_DELAYS_MS = [500, 2000, 5000];
function isAbort(error: unknown): boolean {
return (error as { name?: string } | null)?.name === "AbortError";
}
function cancelled(): DOMException {
return new DOMException("Export cancelled", "AbortError");
}
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(cancelled()); return; }
const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms);
const onAbort = () => { clearTimeout(timer); reject(cancelled()); };
signal.addEventListener("abort", onAbort, { once: true });
});
}
function decodeBase64(base64: string): Uint8Array {
const fromBase64 = (Uint8Array as unknown as { fromBase64?: (s: string) => Uint8Array }).fromBase64;
if (fromBase64) return fromBase64(base64);
const binary = atob(base64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
/** A name that is safe as a file or folder name on every system. */
export function safeName(name: string, fallback: string, max = 80): string {
// eslint-disable-next-line no-control-regex
const clean = name.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, " ").replace(/\s+/g, " ").trim().replace(/^\.+|\.+$/g, "");
const cut = Array.from(clean).slice(0, max).join("").trim();
return cut || fallback;
}
/**
* The account's folders as export paths, parents first in the order the app
* shows them; two folders with the same name next to each other get " (2)".
*/
export function folderTree(mailboxes: Mailbox[]): ExportFolder[] {
const idOf = (m: Mailbox) => m.originalId || m.id;
const byParent = new Map<string, Mailbox[]>();
const ids = new Set(mailboxes.map((m) => m.id));
for (const m of mailboxes) {
const parent = m.parentId && ids.has(m.parentId) ? m.parentId : "";
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(m);
}
const ROLE_ORDER = ["inbox", "drafts", "sent", "archive", "junk", "trash"];
const rank = (m: Mailbox) => { const i = m.role ? ROLE_ORDER.indexOf(m.role) : -1; return i === -1 ? ROLE_ORDER.length : i; };
const out: ExportFolder[] = [];
const visit = (parent: string, path: string[]) => {
const children = (byParent.get(parent) ?? []).slice().sort((a, b) => rank(a) - rank(b) || (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.name.localeCompare(b.name));
const used = new Set<string>();
for (const m of children) {
const base = safeName(m.name, "Folder");
let name = base;
for (let n = 2; used.has(name.toLowerCase()); n++) name = `${base} (${n})`;
used.add(name.toLowerCase());
out.push({ mailboxId: idOf(m), path: [...path, name] });
visit(m.id, [...path, name]);
}
};
visit("", []);
return out;
}
/** Keeps the export under a number of requests per minute. */
class RateGate {
private times: number[] = [];
constructor(private readonly perMinute: number, private readonly now: () => number, private readonly sleep: (ms: number) => Promise<void>) {}
async take(): Promise<void> {
for (;;) {
const t = this.now();
this.times = this.times.filter((x) => t - x < 60_000);
if (this.times.length < this.perMinute) { this.times.push(t); return; }
await this.sleep(60_000 - (t - this.times[0]) + 10);
}
}
}
export async function runExport(options: ExportOptions): Promise<ExportProgress> {
const { client, accountId, signal, sink } = options;
const now = options.now ?? Date.now;
const sleep = (ms: number) => (options.sleep ?? defaultSleep)(ms, signal);
const gate = new RateGate(options.requestsPerMinute ?? 600, now, sleep);
const lanes = Math.max(1, (client.getMaxConcurrentRequests?.() ?? 4) - 2);
const progress: ExportProgress = { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 };
let lastReport = 0;
const report = (force = false) => {
const t = now();
if (!force && t - lastReport < (options.reportEveryMs ?? 150)) return;
lastReport = t;
progress.fraction = progress.total > 0 ? Math.min(1, (progress.exported + progress.failed) / progress.total) : 0;
options.onProgress({ ...progress, failures: [...progress.failures] });
};
const fail = (label: string, reason: string) => {
progress.failed++;
if (progress.failures.length < MAX_FAILURES_LISTED) progress.failures.push({ label, reason });
};
const pause = async (ms: number) => {
progress.phase = "paused";
progress.pausedUntil = now() + ms;
report(true);
await sleep(ms);
progress.phase = "running";
progress.pausedUntil = undefined;
report(true);
};
/** Runs a request, waiting out the server's refusals. */
const withRetry = async <T>(run: () => Promise<T>): Promise<T> => {
for (let attempt = 0; ; attempt++) {
if (signal.aborted) throw cancelled();
await gate.take();
try {
return await run();
} catch (error) {
if (isAbort(error) || signal.aborted) throw cancelled();
const name = (error as { name?: string } | null)?.name;
if (name === "RateLimitError") {
await pause(Math.max(1000, (error as { retryAfterMs?: number }).retryAfterMs ?? 60_000));
continue;
}
const message = error instanceof Error ? error.message : String(error);
const busy = /maxConcurrentRequests/.test(message);
if ((busy || /network|Failed to fetch|timed out/i.test(message)) && attempt < RETRY_DELAYS_MS.length) {
await sleep(RETRY_DELAYS_MS[attempt]);
continue;
}
throw error;
}
}
};
const download = (ref: MessageRef): Promise<Uint8Array> => withRetry(async () => {
if (client.fetchBlobForTransfer) return client.fetchBlobForTransfer(ref.blobId, accountId, signal);
return new Uint8Array(await (await client.fetchBlob(ref.blobId, "message.eml", "message/rfc822", accountId)).arrayBuffer());
});
/** The raw messages of one batch, in order; null where a message couldn't be fetched. */
const fetchBatch = async (batch: MessageRef[]): Promise<(Uint8Array | Error)[]> => {
const out = new Map<string, Uint8Array>();
if (batch.length > 1 || (batch.length === 1 && batch[0].size <= BATCH_MESSAGE_MAX)) {
try {
const result = client.getBlobsAsBase64
? await withRetry(() => client.getBlobsAsBase64!(batch.map((r) => r.blobId), accountId, { sized: true }))
: null;
if (result) for (const [blobId, base64] of result.data) out.set(blobId, decodeBase64(base64));
} catch (error) {
if (isAbort(error) || signal.aborted) throw cancelled();
// Fall back to downloads for this batch.
}
}
const results: (Uint8Array | Error)[] = [];
for (const ref of batch) {
const got = out.get(ref.blobId);
if (got) { results.push(got); continue; }
try {
results.push(await download(ref));
} catch (error) {
if (isAbort(error) || signal.aborted) throw cancelled();
results.push(error instanceof Error ? error : new Error(String(error)));
}
}
return results;
};
const batchesOf = (refs: MessageRef[]): MessageRef[][] => {
const batches: MessageRef[][] = [];
let current: MessageRef[] = [];
let bytes = 0;
for (const ref of refs) {
if (ref.size > BATCH_MESSAGE_MAX) { batches.push([ref]); continue; }
if (current.length > 0 && (bytes + ref.size > BATCH_BYTES || current.length >= BATCH_COUNT)) {
batches.push(current);
current = [];
bytes = 0;
}
current.push(ref);
bytes += ref.size;
}
if (current.length > 0) batches.push(current);
return batches;
};
try {
// Every folder's messages, oldest first, so the totals are known up front.
const lists: MessageRef[][] = [];
for (const folder of options.folders) {
const list = await withRetry(() => client.queryEmailFields(
{ inMailbox: folder.mailboxId },
["blobId", "keywords", "receivedAt", "size", "subject"],
accountId,
Number.MAX_SAFE_INTEGER,
{ transfer: true },
));
const refs = list
.filter((e) => typeof e.blobId === "string")
.map((e) => ({
id: String(e.id),
blobId: String(e.blobId),
size: typeof e.size === "number" ? e.size : 0,
keywords: (e.keywords as Record<string, boolean> | undefined) ?? {},
receivedAt: typeof e.receivedAt === "string" ? e.receivedAt : undefined,
subject: typeof e.subject === "string" ? e.subject : "",
}))
.reverse();
lists.push(refs);
progress.total += refs.length;
report();
}
const single = options.folders.length === 1 && options.format === "mbox";
const zip = single ? null : new ZipWriter((chunk) => sink.write(chunk));
for (let f = 0; f < options.folders.length; f++) {
const folder = options.folders[f];
const refs = lists[f];
const dir = folder.path.join("/");
let stream: ZipEntryStream | null = null;
if (zip && options.format === "mbox" && refs.length > 0) stream = await zip.openStream(`${dir}.mbox`);
if (zip && options.format === "eml" && refs.length === 0 && dir) await zip.addDirectory(dir);
const write = async (bytes: Uint8Array) => (stream ? stream.write(bytes) : sink.write(bytes));
const batches = batchesOf(refs);
let seq = 0;
// Fetch a few batches ahead, write them in order.
const pending: Promise<(Uint8Array | Error)[]>[] = [];
let next = 0;
const fill = () => {
while (pending.length < lanes && next < batches.length) {
const p = fetchBatch(batches[next++]);
p.catch(() => {});
pending.push(p);
}
};
fill();
for (let b = 0; b < batches.length; b++) {
const results = await pending.shift()!;
fill();
for (let i = 0; i < results.length; i++) {
const ref = batches[b][i];
const raw = results[i];
seq++;
if (raw instanceof Error) {
fail(ref.subject, raw.message);
continue;
}
if (options.format === "mbox") {
for (const part of mboxEntry(raw, ref.keywords, ref.receivedAt)) await write(part);
} else {
const name = `${String(seq).padStart(5, "0")} ${safeName(ref.subject, "message", 60)}.eml`;
await zip!.addFile(dir ? `${dir}/${name}` : name, raw, ref.receivedAt ? new Date(ref.receivedAt) : new Date());
}
progress.exported++;
report();
}
if (signal.aborted) throw cancelled();
}
if (stream) await stream.close();
}
if (zip) await zip.finish();
progress.file = await sink.close();
progress.phase = "done";
progress.fraction = 1;
} catch (error) {
await sink.abort().catch(() => {});
progress.pausedUntil = undefined;
if (isAbort(error) || signal.aborted) {
progress.phase = "cancelled";
} else {
progress.phase = "failed";
progress.error = error instanceof Error ? error.message : String(error);
}
}
report(true);
return progress;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Writes messages as mboxrd: an envelope line, the message with every
* `^>*From ` line quoted with one more `>`, and an empty line. The message's
* own line endings are kept; the lines added use the same ones.
*
* State goes into `Status:` (R read, O old), `X-Status:` (A answered,
* F flagged, T draft) and `X-Keywords:` (other keywords), the headers the
* import reads back. Those headers, and Thunderbird's X-Mozilla-* ones, are
* dropped from the message first so an old value can't contradict the new.
*/
const LF = 0x0a;
const CR = 0x0d;
const GT = 0x3e;
const FROM = [0x46, 0x72, 0x6f, 0x6d, 0x20];
const STATE_HEADERS = /^(status|x-status|x-keywords|x-mozilla-status|x-mozilla-status2|x-mozilla-keys)$/i;
/** Keywords the server works out itself, or that have their own header. */
const NOT_IN_X_KEYWORDS = new Set(["$seen", "$flagged", "$answered", "$draft", "$recent", "$hasattachment", "$hasnoattachment"]);
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/**
* The sender Bulwark writes on its envelope lines. The import trusts the
* date of a line with this sender (it is receivedAt), unlike the date on
* other programs' lines, which may be when the message was copied.
*/
export const EXPORT_ENVELOPE_SENDER = "bulwark-export";
/** `From bulwark-export Tue May 5 10:11:12 2020` for a JMAP UTCDate (UTC asctime). */
export function envelopeLine(receivedAt: string | undefined, eol: string): string {
const parsed = receivedAt ? new Date(receivedAt) : new Date(0);
const d = Number.isNaN(parsed.getTime()) ? new Date(0) : parsed;
const pad = (n: number) => String(n).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, " ");
return `From ${EXPORT_ENVELOPE_SENDER} ${DAYS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${day} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} ${d.getUTCFullYear()}${eol}`;
}
export function stateHeaders(keywords: Record<string, boolean> | undefined, eol: string): string {
const k = keywords ?? {};
const xStatus = `${k.$answered ? "A" : ""}${k.$flagged ? "F" : ""}${k.$draft ? "T" : ""}`;
const others = Object.keys(k).filter((key) => k[key] && !NOT_IN_X_KEYWORDS.has(key.toLowerCase()));
let out = `Status: ${k.$seen ? "RO" : "O"}${eol}`;
if (xStatus) out += `X-Status: ${xStatus}${eol}`;
if (others.length > 0) out += `X-Keywords: ${others.join(" ")}${eol}`;
return out;
}
function startsWithFrom(data: Uint8Array, at: number, end: number): boolean {
if (end - at < FROM.length) return false;
for (let i = 0; i < FROM.length; i++) if (data[at + i] !== FROM[i]) return false;
return true;
}
const encoder = new TextEncoder();
// Header names are ASCII; decoding (not spreading the bytes into fromCharCode) copes with a 100 KB line.
const latin1 = new TextDecoder("latin1");
const GT_BYTE = new Uint8Array([GT]);
/**
* The bytes for one message, as a list of parts (mostly subarrays of `raw`).
* `raw` is the message as the server stores it.
*/
export function mboxEntry(raw: Uint8Array, keywords: Record<string, boolean> | undefined, receivedAt: string | undefined): Uint8Array[] {
// The first line ending decides which ones added lines use.
const firstLf = raw.indexOf(LF);
const eol = firstLf > 0 && raw[firstLf - 1] === CR ? "\r\n" : "\n";
const parts: Uint8Array[] = [encoder.encode(envelopeLine(receivedAt, eol) + stateHeaders(keywords, eol))];
let inHeaders = true;
let runStart = 0;
let lineStart = 0;
let skipping = false;
const flush = (end: number) => {
if (end > runStart) parts.push(raw.subarray(runStart, end));
};
while (lineStart < raw.length) {
let lineEnd = raw.indexOf(LF, lineStart);
lineEnd = lineEnd === -1 ? raw.length : lineEnd + 1;
if (inHeaders) {
const isEmpty = lineEnd - lineStart === 1 || (lineEnd - lineStart === 2 && raw[lineStart] === CR);
if (isEmpty) {
inHeaders = false;
skipping = false;
} else if (raw[lineStart] === 0x20 || raw[lineStart] === 0x09) {
// A folded line continues the field before it.
if (skipping) { flush(lineStart); runStart = lineEnd; }
} else {
const colon = raw.indexOf(0x3a, lineStart);
const name = colon > lineStart && colon < lineEnd
? latin1.decode(raw.subarray(lineStart, colon)).trim()
: "";
skipping = STATE_HEADERS.test(name);
if (skipping) { flush(lineStart); runStart = lineEnd; }
}
}
let at = lineStart;
while (at < lineEnd && raw[at] === GT) at++;
if (startsWithFrom(raw, at, lineEnd)) {
flush(lineStart);
parts.push(GT_BYTE);
runStart = lineStart;
}
lineStart = lineEnd;
}
flush(raw.length);
// The empty line that separates messages. A message without a final line
// ending gets one first, so its last line isn't joined to the next envelope
// line; the import then sees one line ending more than the server stored.
const endsWithLf = raw.length > 0 && raw[raw.length - 1] === LF;
parts.push(encoder.encode(endsWithLf ? eol : eol + eol));
return parts;
}
+259
View File
@@ -0,0 +1,259 @@
import { hasWebLocks, holdLock } from "@/lib/mail-import/locks";
/**
* Where an export's bytes go, so a large mailbox never has to fit in memory:
*
* 1. File System Access (Chrome, Edge): the user picks the file first and the
* export writes straight into it.
* 2. The origin private file system (Firefox, Safari): written to a private
* file on disk, then downloaded. The file is deleted a while after the
* dialog closes (so the download can finish), at once on sign-out, and by
* a sweep when the app starts, someone signs in or the next export starts.
* Each file is held under a Web Lock while its tab uses it, so the sweep
* of another tab leaves it alone.
* 3. Memory, for small exports only, where neither exists.
*
* Writes are gathered into 1 MB pieces: a message arrives in many small parts.
*/
export interface ExportFile {
name: string;
size: number;
/** Saves the file (again) where the browser keeps downloads; absent when the user picked the place. */
download?: () => void;
/** Frees what the export kept in this browser: after the download had time to finish, or `now`. */
discard: (now?: boolean) => Promise<void>;
}
export interface ExportSink {
kind: "picker" | "private" | "memory";
write(chunk: Uint8Array): Promise<void>;
close(): Promise<ExportFile>;
abort(): Promise<void>;
}
export class ExportTooLargeError extends Error {
constructor(readonly limit: number) {
super(`The export is larger than this browser can keep in memory (${limit} bytes)`);
this.name = "ExportTooLargeError";
}
}
/** The most a memory-only export may hold. */
export const MEMORY_EXPORT_LIMIT = 200 * 1024 * 1024;
const PRIVATE_DIR = "bulwark-exports";
const LOCK_PREFIX = "bulwark-export-file:";
/** How long a closed export's private file stays for its download: a minute, plus a second per 50 MB. */
export function downloadGraceMs(size: number): number {
return 60_000 + Math.ceil(size / 50_000_000) * 1000;
}
const FLUSH_BYTES = 1 << 20;
interface WritableLike {
write(data: BufferSource | Blob): Promise<void>;
close(): Promise<void>;
abort(reason?: unknown): Promise<void>;
}
/** Gathers small writes and passes them on in pieces of about 1 MB. */
function buffered(write: (chunk: Uint8Array) => Promise<void>) {
let parts: Uint8Array[] = [];
let size = 0;
let total = 0;
const flush = async () => {
if (size === 0) return;
const out = new Uint8Array(size);
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
parts = [];
size = 0;
await write(out);
};
return {
async write(chunk: Uint8Array) {
if (chunk.length === 0) return;
// The caller may reuse its buffer: keep a copy until it's written.
parts.push(chunk.slice());
size += chunk.length;
total += chunk.length;
if (size >= FLUSH_BYTES) await flush();
},
flush,
get total() { return total; },
};
}
function triggerDownload(url: string, name: string): void {
const a = document.createElement("a");
a.href = url;
a.download = name;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
}
type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record<string, string[]> }[] }) => Promise<{ createWritable(): Promise<WritableLike> }>;
/** Whether this browser can write a private file from the page (Safari before 26 can't). */
export function canWritePrivateFile(): boolean {
if (typeof navigator === "undefined" || typeof (navigator.storage as { getDirectory?: unknown } | undefined)?.getDirectory !== "function") return false;
const handle = (globalThis as { FileSystemFileHandle?: { prototype: object } }).FileSystemFileHandle;
return !!handle && "createWritable" in handle.prototype;
}
export function canPickSaveFile(): boolean {
return typeof window !== "undefined" && typeof (window as unknown as { showSaveFilePicker?: unknown }).showSaveFilePicker === "function";
}
/**
* Asks where to save (call it straight from the click, before any await:
* the browser only shows the picker for a user action). Rejects with an
* AbortError when the user closes the picker.
*/
export async function pickSaveFile(name: string, mime: string, extension: string): Promise<ExportSink> {
const picker = (window as unknown as { showSaveFilePicker: SavePicker }).showSaveFilePicker;
const handle = await picker({ suggestedName: name, types: [{ accept: { [mime]: [extension] } }] });
const writable = await handle.createWritable();
const buf = buffered((chunk) => writable.write(chunk as BufferSource));
return {
kind: "picker",
write: (chunk) => buf.write(chunk),
close: async () => {
await buf.flush();
await writable.close();
return { name, size: buf.total, discard: async () => {} };
},
abort: async () => { await writable.abort().catch(() => {}); },
};
}
async function privateDir(): Promise<FileSystemDirectoryHandle | null> {
try {
const root = await navigator.storage?.getDirectory?.();
return root ? await root.getDirectoryHandle(PRIVATE_DIR, { create: true }) : null;
} catch {
return null;
}
}
/** A private file on disk, downloaded when done; null where the browser can't write one from the page. */
export async function privateFileSink(name: string, mime: string): Promise<ExportSink | null> {
const dir = await privateDir();
if (!dir) return null;
const entry = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Held until the file is gone, so no other tab's sweep deletes it meanwhile.
const release = (await holdLock(`${LOCK_PREFIX}${entry}`)) ?? (() => {});
let handle: FileSystemFileHandle;
let writable: WritableLike;
try {
handle = await dir.getFileHandle(entry, { create: true });
const create = (handle as unknown as { createWritable?: () => Promise<WritableLike> }).createWritable;
if (typeof create !== "function") {
await dir.removeEntry(entry).catch(() => {});
release();
return null;
}
writable = await create.call(handle);
} catch {
await dir.removeEntry(entry).catch(() => {});
release();
return null;
}
const buf = buffered((chunk) => writable.write(chunk as BufferSource));
const remove = async () => {
await dir.removeEntry(entry).catch(() => {});
release();
};
return {
kind: "private",
write: (chunk) => buf.write(chunk),
close: async () => {
await buf.flush();
await writable.close();
const file = await handle.getFile();
const url = URL.createObjectURL(new Blob([file], { type: mime }));
triggerDownload(url, name);
return {
name,
size: file.size,
download: () => triggerDownload(url, name),
discard: async (now = false) => {
// Deleting the file under a download that is still reading it would break the download.
if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(file.size)));
URL.revokeObjectURL(url);
await remove();
},
};
},
abort: async () => { await writable.abort().catch(() => {}); await remove(); },
};
}
/** Everything in memory, then downloaded: only for small exports. */
export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIMIT): ExportSink {
let parts: Uint8Array[] = [];
let size = 0;
return {
kind: "memory",
write: async (chunk) => {
size += chunk.length;
if (size > limit) throw new ExportTooLargeError(limit);
parts.push(chunk.slice());
},
close: async () => {
const blob = new Blob(parts as BlobPart[], { type: mime });
parts = [];
const url = URL.createObjectURL(blob);
triggerDownload(url, name);
return {
name,
size: blob.size,
download: () => triggerDownload(url, name),
discard: async (now = false) => {
if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(blob.size)));
URL.revokeObjectURL(url);
},
};
},
abort: async () => { parts = []; },
};
}
/**
* Deletes private export files no tab uses any more (a closed tab, a crash),
* so no copy of the mail stays in the browser profile. A file another tab
* holds is left alone; `force` deletes everything (everyone signed out).
* Without Web Locks only `force` sweeps, since another tab can't be asked.
*/
export async function sweepPrivateExports({ force = false }: { force?: boolean } = {}): Promise<void> {
if (!force && !hasWebLocks()) return;
try {
const root = await navigator.storage?.getDirectory?.();
if (!root) return;
if (force) {
await root.removeEntry(PRIVATE_DIR, { recursive: true }).catch(() => {});
return;
}
let dir: FileSystemDirectoryHandle;
try {
dir = await root.getDirectoryHandle(PRIVATE_DIR);
} catch {
return;
}
const names: string[] = [];
for await (const name of (dir as unknown as { keys(): AsyncIterable<string> }).keys()) names.push(name);
for (const name of names) {
const release = await holdLock(`${LOCK_PREFIX}${name}`);
if (!release) continue;
try {
await dir.removeEntry(name).catch(() => {});
} finally {
release();
}
}
} catch {
/* nothing there */
}
}
+274
View File
@@ -0,0 +1,274 @@
import { Deflate, deflateSync } from "fflate";
import { crc32 } from "./crc32";
/**
* A streaming ZIP writer: each entry goes to the sink as soon as it is added,
* so an export never holds the archive in memory. Zip64 records are written
* when the archive passes 4 GB or 65,535 entries (fflate's Zip writes none).
*
* - addFile: a whole file in memory (one message); deflated when that saves space.
* - openStream: a file written piece by piece (a folder's mbox), deflated,
* with its CRC and sizes in a data descriptor after the data. Its size
* isn't known up front, so its local header always carries a Zip64 extra
* field and the descriptor always has 64-bit sizes: readers that go
* through the archive in order (Java's ZipInputStream, macOS Archive
* Utility) learn the descriptor's size from the local header.
*/
export interface ZipWriterOptions {
/** Deflate streamed entries (default). Off only for tests that push gigabytes through. */
compressStreams?: boolean;
/** The CRC-32 function; a test that only counts bytes can pass a cheap one. */
checksum?: (data: Uint8Array, crc: number) => number;
}
export type ZipSink = (chunk: Uint8Array) => Promise<void>;
interface CentralEntry {
name: Uint8Array;
flags: number;
method: number;
time: number;
date: number;
crc: number;
compressedSize: number;
size: number;
offset: number;
directory: boolean;
}
const MAX32 = 0xffffffff;
const MAX16 = 0xffff;
/** General purpose flag 11: the name is UTF-8. */
const FLAG_UTF8 = 0x0800;
/** General purpose flag 3: CRC and sizes follow the data. */
const FLAG_DESCRIPTOR = 0x0008;
function dosDateTime(date: Date): { time: number; date: number } {
const d = Number.isNaN(date.getTime()) || date.getFullYear() < 1980 ? new Date(1980, 0, 1) : date;
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
date: ((Math.min(d.getFullYear(), 2107) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
function u64(view: DataView, at: number, value: number): void {
view.setUint32(at, value % 0x1_0000_0000, true);
view.setUint32(at + 4, Math.floor(value / 0x1_0000_0000), true);
}
const encoder = new TextEncoder();
export interface ZipEntryStream {
write(chunk: Uint8Array): Promise<void>;
close(): Promise<void>;
}
export class ZipWriter {
private offset = 0;
private readonly entries: CentralEntry[] = [];
private streaming = false;
constructor(private readonly sink: ZipSink, private readonly options: ZipWriterOptions = {}) {}
/** Bytes written so far. */
get bytesWritten(): number {
return this.offset;
}
private async emit(bytes: Uint8Array): Promise<void> {
if (bytes.length === 0) return;
this.offset += bytes.length;
await this.sink(bytes);
}
/** `zip64`: a streamed entry of unknown size, with a Zip64 extra field of zeros. */
private localHeader(entry: Omit<CentralEntry, "offset" | "directory">, zip64: boolean): Uint8Array {
const extra = zip64 ? 20 : 0;
const header = new Uint8Array(30 + entry.name.length + extra);
const v = new DataView(header.buffer);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, zip64 ? 45 : 20, true);
v.setUint16(6, entry.flags, true);
v.setUint16(8, entry.method, true);
v.setUint16(10, entry.time, true);
v.setUint16(12, entry.date, true);
v.setUint32(14, entry.crc, true);
v.setUint32(18, zip64 ? MAX32 : entry.compressedSize, true);
v.setUint32(22, zip64 ? MAX32 : entry.size, true);
v.setUint16(26, entry.name.length, true);
v.setUint16(28, extra, true);
header.set(entry.name, 30);
if (zip64) {
// Zip64 extra field: original and compressed size, both 0 (they follow in the descriptor).
v.setUint16(30 + entry.name.length, 0x0001, true);
v.setUint16(32 + entry.name.length, 16, true);
}
return header;
}
private assertIdle(): void {
if (this.streaming) throw new Error("ZipWriter: close the open entry first");
}
/** An empty folder, so the tree survives without files in it. */
async addDirectory(path: string, mtime = new Date()): Promise<void> {
this.assertIdle();
const name = encoder.encode(path.endsWith("/") ? path : `${path}/`);
const { time, date } = dosDateTime(mtime);
const entry = { name, flags: FLAG_UTF8, method: 0, time, date, crc: 0, compressedSize: 0, size: 0 };
const offset = this.offset;
await this.emit(this.localHeader(entry, false));
this.entries.push({ ...entry, offset, directory: true });
}
async addFile(path: string, data: Uint8Array, mtime = new Date()): Promise<void> {
this.assertIdle();
const name = encoder.encode(path);
const { time, date } = dosDateTime(mtime);
const crc = crc32(data);
const deflated = data.length > 64 ? deflateSync(data, { level: 1 }) : null;
const useDeflate = !!deflated && deflated.length < data.length;
const body = useDeflate ? deflated! : data;
const entry = { name, flags: FLAG_UTF8, method: useDeflate ? 8 : 0, time, date, crc, compressedSize: body.length, size: data.length };
const offset = this.offset;
await this.emit(this.localHeader(entry, false));
await this.emit(body);
this.entries.push({ ...entry, offset, directory: false });
}
async openStream(path: string, mtime = new Date()): Promise<ZipEntryStream> {
this.assertIdle();
this.streaming = true;
const name = encoder.encode(path);
const { time, date } = dosDateTime(mtime);
const flags = FLAG_UTF8 | FLAG_DESCRIPTOR;
const compress = this.options.compressStreams !== false;
const method = compress ? 8 : 0;
const checksum = this.options.checksum ?? crc32;
const offset = this.offset;
await this.emit(this.localHeader({ name, flags, method, time, date, crc: 0, compressedSize: 0, size: 0 }, true));
let crc = 0;
let size = 0;
let compressedSize = 0;
let pending: Uint8Array[] = [];
const deflate = new Deflate({ level: 1 });
deflate.ondata = (chunk) => {
pending.push(chunk);
compressedSize += chunk.length;
};
const drain = async () => {
const out = pending;
pending = [];
for (const chunk of out) await this.emit(chunk);
};
return {
write: async (chunk: Uint8Array) => {
if (chunk.length === 0) return;
crc = checksum(chunk, crc);
size += chunk.length;
if (compress) {
deflate.push(chunk, false);
await drain();
} else {
compressedSize += chunk.length;
await this.emit(chunk);
}
},
close: async () => {
if (compress) {
deflate.push(new Uint8Array(0), true);
await drain();
}
// Always 64-bit sizes: the local header announced Zip64.
const descriptor = new Uint8Array(24);
const v = new DataView(descriptor.buffer);
v.setUint32(0, 0x08074b50, true);
v.setUint32(4, crc, true);
u64(v, 8, compressedSize);
u64(v, 16, size);
await this.emit(descriptor);
this.entries.push({ name, flags, method, time, date, crc, compressedSize, size, offset, directory: false });
this.streaming = false;
},
};
}
/** Writes the central directory. Nothing may be added afterwards. */
async finish(): Promise<void> {
this.assertIdle();
const cdStart = this.offset;
for (const e of this.entries) {
const needSize = e.size >= MAX32;
const needCompressed = e.compressedSize >= MAX32;
const needOffset = e.offset >= MAX32;
const extraLength = needSize || needCompressed || needOffset
? 4 + 8 * (Number(needSize) + Number(needCompressed) + Number(needOffset))
: 0;
const record = new Uint8Array(46 + e.name.length + extraLength);
const v = new DataView(record.buffer);
const zip64 = extraLength > 0;
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, zip64 ? 45 : 20, true);
v.setUint16(6, zip64 ? 45 : 20, true);
v.setUint16(8, e.flags, true);
v.setUint16(10, e.method, true);
v.setUint16(12, e.time, true);
v.setUint16(14, e.date, true);
v.setUint32(16, e.crc, true);
v.setUint32(20, needCompressed ? MAX32 : e.compressedSize, true);
v.setUint32(24, needSize ? MAX32 : e.size, true);
v.setUint16(28, e.name.length, true);
v.setUint16(30, extraLength, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, e.directory ? 0x10 : 0, true);
v.setUint32(42, needOffset ? MAX32 : e.offset, true);
record.set(e.name, 46);
if (zip64) {
let at = 46 + e.name.length;
v.setUint16(at, 0x0001, true);
v.setUint16(at + 2, extraLength - 4, true);
at += 4;
if (needSize) { u64(v, at, e.size); at += 8; }
if (needCompressed) { u64(v, at, e.compressedSize); at += 8; }
if (needOffset) { u64(v, at, e.offset); at += 8; }
}
await this.emit(record);
}
const cdSize = this.offset - cdStart;
const count = this.entries.length;
const zip64 = count >= MAX16 || cdStart >= MAX32 || cdSize >= MAX32;
if (zip64) {
const zip64End = this.offset;
const record = new Uint8Array(56 + 20);
const v = new DataView(record.buffer);
v.setUint32(0, 0x06064b50, true);
u64(v, 4, 44);
v.setUint16(12, 45, true);
v.setUint16(14, 45, true);
v.setUint32(16, 0, true);
v.setUint32(20, 0, true);
u64(v, 24, count);
u64(v, 32, count);
u64(v, 40, cdSize);
u64(v, 48, cdStart);
v.setUint32(56, 0x07064b50, true);
v.setUint32(60, 0, true);
u64(v, 64, zip64End);
v.setUint32(72, 1, true);
await this.emit(record);
}
const end = new Uint8Array(22);
const v = new DataView(end.buffer);
v.setUint32(0, 0x06054b50, true);
v.setUint16(8, Math.min(count, MAX16), true);
v.setUint16(10, Math.min(count, MAX16), true);
v.setUint32(12, Math.min(cdSize, MAX32), true);
v.setUint32(16, Math.min(cdStart, MAX32), true);
await this.emit(end);
}
}
+386
View File
@@ -0,0 +1,386 @@
import { describe, it, expect, vi } from 'vitest';
import { runImport, uploadWindowEnd, quotaFrom, FolderResolver, type ImportProgress, type ImportOptions } from '@/lib/mail-import/engine';
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';
const mbox = (n: number, opts: { status?: (i: number) => string } = {}) =>
Array.from({ length: n }, (_, i) =>
`From a@example.org Tue May 5 10:${String(i % 60).padStart(2, '0')}:00 2020\nSubject: m${i}\n${opts.status?.(i) ?? ''}Date: Tue, 5 May 2020 10:00:00 +0000\n\nbody ${i}\n\n`,
).join('');
// Checkpoints older than 30 days are dropped, so the fake clock starts now.
const T0 = Date.now();
const file = (content: string, name = 'mail.mbox') => new File([content], name, { lastModified: 1000 });
const mailbox = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox => ({
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
myRights: {} as Mailbox['myRights'], ...extra,
});
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;
let quotaHit = false;
const imported: Array<UploadedEmailImport & { accountId?: string; text: string }> = [];
const blobs = new Map<string, string>();
const created: Array<{ name: string; parentId?: string; accountId?: string }> = [];
const mailboxes: Mailbox[] = [mailbox('inbox', 'Inbox', { role: 'inbox' }), mailbox('target', 'Target')];
const importCalls: number[] = [];
const uploadAccounts: (string | undefined)[] = [];
const client = {
getAccountId: () => 'acc-own',
getCapabilities: () => ({ 'urn:ietf:params:jmap:core': { maxConcurrentUpload: opts.uploadConcurrency ?? 4 } }),
getMaxConcurrentRequests: () => 4,
getMaxSizeUpload: () => 1000,
getMailboxes: vi.fn(async () => mailboxes.map((m) => ({ ...m }))),
createMailbox: vi.fn(async (name: string, parentId?: string, accountId?: string) => {
created.push({ name, parentId, accountId });
const box = mailbox(`new-${created.length}`, name, { parentId });
return box;
}),
uploadBlob: vi.fn(async (f: File, o: { accountId?: string; signal?: AbortSignal }) => {
const n = uploads++;
uploadAccounts.push(o.accountId);
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
try {
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."}');
}
const text = await f.text();
if (opts.failWhen?.(text)) throw new Error('Failed to upload file: 500 - oops');
const id = `blob-${n}`;
blobs.set(id, text);
return { blobId: id, size: f.size, type: f.type };
} finally {
inFlight--;
}
}),
importUploadedEmails: vi.fn(async (entries: UploadedEmailImport[], accountId?: string) => {
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}` };
});
}),
} as unknown as IJMAPClient;
return { client, imported, created, importCalls, uploadAccounts, get maxInFlight() { return maxInFlight; }, get uploads() { return uploads; } };
}
function options(client: IJMAPClient, files: File[], extra: Partial<ImportOptions> = {}): ImportOptions {
let clock = T0;
return {
client,
accountId: 'acc-shared',
targetMailboxId: 'target',
keepFolders: false,
files: files.map((f) => ({ file: f, format: f.name.endsWith('.zip') ? 'zip' : f.name.endsWith('.eml') ? 'eml' : 'mbox' })),
scope: 'bulwark-1',
resume: false,
signal: new AbortController().signal,
onProgress: () => {},
now: () => clock,
sleep: async (ms) => { clock += ms; },
reportEveryMs: 0,
...extra,
};
}
describe('runImport', () => {
it('imports every message of an mbox into the target folder of its own account, with state and date', async () => {
const fake = fakeClient();
const status = (i: number) => (i === 1 ? 'Status: O\nX-Status: F\n' : i === 2 ? 'X-Mozilla-Status: 0009\n' : '');
const result = await runImport(options(fake.client, [file(mbox(4, { status }))]));
expect(result.phase).toBe('done');
expect(result.imported).toBe(3);
expect(result.skipped).toEqual({ deleted: 1 });
expect(fake.imported.map((e) => e.accountId)).toEqual(['acc-shared', 'acc-shared', 'acc-shared']);
// Uploads go to the user's own account: one in the shared account can't be imported there.
expect(new Set(fake.uploadAccounts)).toEqual(new Set(['acc-own']));
expect(fake.imported.every((e) => e.mailboxIds.target)).toBe(true);
expect(fake.imported.map((e) => e.keywords)).toEqual([{ $seen: true }, { $flagged: true }, { $seen: true }]);
expect(fake.imported[0].receivedAt).toBe('2020-05-05T10:00:00Z');
// The envelope line is gone; Email/import would keep it as a broken header.
expect(fake.imported[0].text.startsWith('Subject: m0\n')).toBe(true);
});
it('takes the arrival time from Bulwark\'s own envelope line and leaves the state headers out of the stored message', async () => {
const fake = fakeClient();
const raw = 'From bulwark-export Sat Jan 2 03:04:05 2021\nStatus: RO\nX-Mozilla-Status: 0001\nSubject: s\nDate: Tue, 5 May 2020 10:00:00 +0000\n\nbody\n';
await runImport(options(fake.client, [file(raw)]));
expect(fake.imported[0].receivedAt).toBe('2021-01-02T03:04:05Z');
expect(fake.imported[0].text).toBe('Subject: s\nDate: Tue, 5 May 2020 10:00:00 +0000\n\nbody\n');
expect(fake.imported[0].keywords).toEqual({ $seen: true });
});
it('reads Thunderbird state headers in .eml files, and imports a plain .eml as read like before', async () => {
const fake = fakeClient();
const unread = new File(['X-Mozilla-Status: 0004\r\nSubject: u\r\n\r\nStatus: R in the body\r\n'], 'u.eml');
const plain = new File(['Subject: p\r\n\r\nbody\r\n'], 'p.eml');
await runImport(options(fake.client, [unread, plain]));
expect(fake.imported.map((e) => e.keywords)).toEqual([{ $flagged: true }, { $seen: true }]);
});
it('dates a Thunderbird message by its Date header, not by when it was copied into the folder', async () => {
const fake = fakeClient();
await runImport(options(fake.client, [file('From - Sat Nov 1 09:00:00 2025\nSubject: s\nDate: Mon, 2 Jan 2017 08:30:00 +0100\n\nb\n')]));
expect(fake.imported[0].receivedAt).toBe('2017-01-02T07:30:00Z');
});
it('trusts an envelope line with a time zone (Google Takeout)', async () => {
const fake = fakeClient();
await runImport(options(fake.client, [file('From 1234@xxx Sat Apr 18 08:29:01 +0200 2020\nSubject: s\nDate: Fri, 17 Apr 2020 22:00:00 +0000\n\nb\n')]));
expect(fake.imported[0].receivedAt).toBe('2020-04-18T06:29:01Z');
});
it('falls back to the Date header when the envelope line has no usable date', async () => {
const fake = fakeClient();
await runImport(options(fake.client, [file('From MAILER-DAEMON Thu Jan 1 00:00:00 1970\nSubject: s\nDate: Tue, 5 May 2020 10:00:00 +0000\n\nb\n')]));
expect(fake.imported[0].receivedAt).toBe('2020-05-05T10:00:00Z');
});
it('moves the progress message by message through a small mbox', async () => {
const fake = fakeClient();
const fractions: number[] = [];
await runImport(options(fake.client, [file(mbox(10))], { onProgress: (p) => { if (p.phase === 'running') fractions.push(p.fraction); } }));
const between = new Set(fractions.filter((f) => f > 0 && f < 1).map((f) => f.toFixed(2)));
expect(between.size).toBeGreaterThanOrEqual(5);
});
it('puts several messages in one Email/import and keeps uploads within the server limit', async () => {
const fake = fakeClient({ uploadConcurrency: 4 });
await runImport(options(fake.client, [file(mbox(45))]));
expect(fake.imported).toHaveLength(45);
expect(Math.max(...fake.importCalls)).toBeGreaterThan(1);
expect(fake.importCalls.length).toBeLessThan(45);
// maxConcurrentRequests 4 leaves two requests for the rest of the app.
expect(fake.maxInFlight).toBeLessThanOrEqual(2);
});
it('pauses on the hourly upload limit until the window ends, then goes on', async () => {
const fake = fakeClient({ quotaAfter: 3 });
const seen: ImportProgress[] = [];
const result = await runImport(options(fake.client, [file(mbox(6))], { onProgress: (p) => seen.push(p) }));
const paused = seen.find((p) => p.phase === 'paused');
expect(paused?.pausedUntil).toBe(uploadWindowEnd(T0) + 5000);
expect(paused?.quota).toEqual({ files: 1000, bytes: 50_000_000 });
expect(result.phase).toBe('done');
expect(result.imported).toBe(6);
expect(fake.imported.map((e) => e.text.split('\n')[0]).sort()).toEqual(['Subject: m0', 'Subject: m1', 'Subject: m2', 'Subject: m3', 'Subject: m4', 'Subject: m5']);
});
it('counts a message that keeps failing and carries on with the rest', async () => {
const fake = fakeClient({ failWhen: (text) => text.startsWith('Subject: m1\n') });
const result = await runImport(options(fake.client, [file(mbox(3))]));
expect(result.imported).toBe(2);
expect(result.failed).toBe(1);
expect(result.failures).toEqual([{ label: 'm1', reason: 'HTTP 500' }]);
// Tried three times before giving up.
expect(fake.uploads).toBe(5);
});
it('skips messages over the upload size', async () => {
const fake = fakeClient();
const big = `From a Tue May 5 10:11:12 2020\nSubject: big\n\n${'x'.repeat(2000)}\n\n`;
const result = await runImport(options(fake.client, [file(big + mbox(1))]));
expect(result.skipped).toEqual({ too_large: 1 });
expect(result.imported).toBe(1);
});
it('keeps a checkpoint when cancelled and continues there without importing twice', async () => {
const fake = fakeClient();
const controller = new AbortController();
const f = file(mbox(30));
let cancelledAt = 0;
const first = await runImport(options(fake.client, [f], {
signal: controller.signal,
onProgress: (p) => { if (p.imported >= 5 && !controller.signal.aborted) { cancelledAt = p.imported; controller.abort(); } },
}));
expect(first.phase).toBe('cancelled');
expect(cancelledAt).toBeGreaterThan(0);
const key = checkpointKey({ scope: 'bulwark-1', accountId: 'acc-shared', target: 'target', keepFolders: false, file: f });
const saved = await readCheckpoint(key);
expect(saved?.done).toBe(first.imported);
const second = await runImport(options(fake.client, [f], { resume: true }));
expect(second.phase).toBe('done');
expect(second.imported).toBe(30);
const subjects = fake.imported.map((e) => e.text.split('\n')[0]);
expect(new Set(subjects).size).toBe(30);
expect(subjects).toHaveLength(30);
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));
await runImport(options(fake.client, [f]));
await runImport(options(fake.client, [f]));
expect(fake.imported).toHaveLength(6);
});
it('recreates a file\'s folders under the target, once, reusing ones that exist', async () => {
const { ZipWriter } = await import('@/lib/mail-export/zip-writer');
const parts: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
const enc = new TextEncoder();
await z.addFile('Projects/2020/a.eml', enc.encode('Subject: a\r\n\r\nA'));
await z.addFile('Projects/2020/b.eml', enc.encode('Subject: b\r\n\r\nB'));
await z.addFile('Projects/c.eml', enc.encode('Subject: c\r\n\r\nC'));
await z.addFile('Target/d.eml', enc.encode('Subject: d\r\n\r\nD'));
await z.addFile('top.eml', enc.encode('Subject: t\r\n\r\nT'));
await z.finish();
const zip = new File(parts as BlobPart[], 'export.zip', { lastModified: 1 });
const fake = fakeClient();
const result = await runImport(options(fake.client, [zip], { keepFolders: true, targetMailboxId: null }));
expect(result.imported).toBe(5);
expect(fake.created).toEqual([
{ name: 'Projects', parentId: undefined, accountId: 'acc-shared' },
{ name: '2020', parentId: 'new-1', accountId: 'acc-shared' },
]);
const byText = Object.fromEntries(fake.imported.map((e) => [e.text.split('\r\n')[0], Object.keys(e.mailboxIds)[0]]));
// "Target" exists at the top; a message at the top of the file goes to the Inbox.
expect(byText).toEqual({ 'Subject: a': 'new-2', 'Subject: b': 'new-2', 'Subject: c': 'new-1', 'Subject: d': 'target', 'Subject: t': 'inbox' });
expect(result.foldersCreated).toBe(2);
});
it('puts everything into the target when folders are not kept', async () => {
const { ZipWriter } = await import('@/lib/mail-export/zip-writer');
const parts: Uint8Array[] = [];
const z = new ZipWriter(async (c) => { parts.push(c.slice()); });
await z.addFile('Deep/x.eml', new TextEncoder().encode('Subject: x\r\n\r\nX'));
await z.finish();
const fake = fakeClient();
await runImport(options(fake.client, [new File(parts as BlobPart[], 'e.zip')]));
expect(fake.created).toEqual([]);
expect(Object.keys(fake.imported[0].mailboxIds)).toEqual(['target']);
});
it('puts a top folder into the special folder the file names, in any language', async () => {
const mailboxes = [mailbox('in', 'Posteingang', { role: 'inbox' }), mailbox('sent', 'Gesendet', { role: 'sent' }), mailbox('bin', 'Papierkorb', { role: 'trash' })];
const client = { getMailboxes: async () => mailboxes, createMailbox: vi.fn() } as unknown as IJMAPClient;
const folders = new FolderResolver(client, 'acc', null);
// A Spanish PST whose store says this is Sent Items.
await expect(folders.resolve(['Elementos enviados'], 'sent')).resolves.toBe('sent');
// Names in the languages Outlook ships most often.
await expect(folders.resolve(['Éléments supprimés'])).resolves.toBe('bin');
await expect(folders.resolve(['Bandeja de entrada'])).resolves.toBe('in');
});
it('reads the hour window and the quota from the server', () => {
expect(uploadWindowEnd(Date.UTC(2026, 10, 1, 13, 20))).toBe(Date.UTC(2026, 10, 1, 14, 0));
expect(quotaFrom('You have exceeded the blob upload quota of 1000 files or 50000000 bytes.')).toEqual({ files: 1000, bytes: 50000000 });
});
});
Binary file not shown.
@@ -0,0 +1,27 @@
Outlook.pst is Examples/Data/MAPI/Outlook_1.pst from Aspose.Email for .NET
(https://github.com/aspose-email/Aspose.Email-for-.NET, master branch,
blob 6d0dae3b8f74d05366294736dfec3e24cc4242df), used unchanged.
The repository is published under the MIT License:
The MIT License (MIT)
Copyright (c) 2001-2016 Aspose Pty Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { mboxState, withoutStateHeaders } from '@/lib/mail-import/mbox-status';
describe('mboxState', () => {
it('imports a message without any state header as read, as before', () => {
expect(mboxState('Subject: x\nDate: Tue, 5 May 2020 10:00:00 +0000')).toEqual({ keywords: { $seen: true }, expunged: false });
});
it('reads Status R as read and Status O alone as unread', () => {
expect(mboxState('Status: RO').keywords).toEqual({ $seen: true });
expect(mboxState('Status: O').keywords).toEqual({});
});
it('reads X-Status answered, flagged and draft', () => {
expect(mboxState('Status: O\nX-Status: AFT').keywords).toEqual({ $answered: true, $flagged: true, $draft: true });
});
it('reads Thunderbird X-Mozilla-Status bits', () => {
expect(mboxState('X-Mozilla-Status: 0001').keywords).toEqual({ $seen: true });
expect(mboxState('X-Mozilla-Status: 0000').keywords).toEqual({});
expect(mboxState('X-Mozilla-Status: 1006').keywords).toEqual({ $answered: true, $flagged: true, $forwarded: true });
});
it('marks a Thunderbird-expunged message (0x8) to be skipped', () => {
expect(mboxState('X-Mozilla-Status: 0009').expunged).toBe(true);
expect(mboxState('X-Mozilla-Status: 0001').expunged).toBe(false);
});
it('adds X-Keywords and X-Mozilla-Keys, skipping invalid and server-set ones', () => {
const { keywords } = mboxState('Status: R\nX-Keywords: Work, $label1 bad(word) $HasAttachment\nX-Mozilla-Keys: todo');
expect(keywords).toEqual({ $seen: true, work: true, $label1: true, todo: true });
});
it('reads Google Takeout labels', () => {
expect(mboxState('X-Gmail-Labels: Inbox,Unread').keywords).toEqual({});
expect(mboxState('X-Gmail-Labels: Inbox,Starred').keywords).toEqual({ $seen: true, $flagged: true });
});
it('reads folded and differently cased headers', () => {
expect(mboxState('subject: a\r\nx-status:\r\n F\r\nSTATUS: O').keywords).toEqual({ $flagged: true });
});
});
describe('withoutStateHeaders', () => {
const enc = new TextEncoder();
const text = (parts: Uint8Array[]) => parts.map((p) => new TextDecoder().decode(p)).join('');
it('drops the state headers, folded lines too, and keeps the rest byte for byte', () => {
const raw = 'Status: RO\r\nSubject: hi\r\nX-Keywords: a\r\n b\r\nX-Mozilla-Status: 0001\r\nFrom: x@example.org\r\n\r\nStatus: in the body\r\n';
// Split in odd places, as the mbox splitter hands them over.
const parts = [enc.encode(raw.slice(0, 7)), enc.encode(raw.slice(7, 40)), enc.encode(raw.slice(40))];
expect(text(withoutStateHeaders(parts))).toBe('Subject: hi\r\nFrom: x@example.org\r\n\r\nStatus: in the body\r\n');
});
it('leaves a message without them as it was', () => {
const parts = [enc.encode('Subject: hi\n\nbody\n')];
expect(withoutStateHeaders(parts)).toBe(parts);
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import { MboxSplitter, envelopeDate, readHeaderBlock, type MboxMessage } from '@/lib/mail-import/mbox';
const enc = new TextEncoder();
const latin1 = (b: Uint8Array) => Array.from(b, (c) => String.fromCharCode(c)).join('');
function join(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
}
/** Splits `data` fed in chunks of `chunk` bytes (every size hits a different edge). */
function split(data: Uint8Array | string, chunk = data.length, options = {}): MboxMessage[] {
const bytes = typeof data === 'string' ? enc.encode(data) : data;
const s = new MboxSplitter(options);
const out: MboxMessage[] = [];
for (let i = 0; i < bytes.length; i += Math.max(1, chunk)) out.push(...s.push(bytes.subarray(i, i + Math.max(1, chunk))));
out.push(...s.end());
return out;
}
const texts = (msgs: MboxMessage[]) => msgs.map((m) => latin1(join(m.parts)));
const TWO = [
'From alice@example.org Tue May 5 10:11:12 2020',
'Subject: one',
'',
'Body one',
'',
'From bob@example.org Wed May 6 11:00:00 2020',
'Subject: two',
'',
'Body two',
'',
].join('\n');
describe('MboxSplitter', () => {
it('splits on From lines after an empty line and drops the envelope line and the separator', () => {
const msgs = split(TWO);
expect(texts(msgs)).toEqual(['Subject: one\n\nBody one\n', 'Subject: two\n\nBody two\n']);
expect(msgs[0].fromLine).toBe('From alice@example.org Tue May 5 10:11:12 2020');
expect(msgs[1].offset).toBe(TWO.indexOf('From bob'));
});
it('gives the same messages whatever the chunk size', () => {
const whole = texts(split(TWO));
for (const size of [1, 2, 3, 5, 7, 13, 64]) expect(texts(split(TWO, size))).toEqual(whole);
});
it('keeps a From line that follows a non-empty line in the body', () => {
const mbox = 'From a Tue May 5 10:11:12 2020\nSubject: x\n\nline\nFrom here on\n';
expect(texts(split(mbox))).toEqual(['Subject: x\n\nline\nFrom here on\n']);
});
it('does not split on a body line "From now on" after an empty line that has no date', () => {
const mbox = 'From a Tue May 5 10:11:12 2020\nSubject: x\n\nhello\n\nFrom now on we meet\n';
expect(texts(split(mbox))).toHaveLength(1);
});
it('does not split on a body line with a time but no date after it', () => {
const mbox = 'From a Tue May 5 10:11:12 2020\nSubject: x\n\nhello\n\nFrom now on we meet at 10:30 in room 2020b\n';
expect(texts(split(mbox))).toHaveLength(1);
});
it('splits on envelope lines with a zone or "remote from"', () => {
const mbox = 'From a Sat Apr 18 08:29:01 +0000 2020\nSubject: 1\n\nb\n\nFrom b Sat Apr 18 08:29:01 2020 remote from host\nSubject: 2\n\nb\n';
expect(texts(split(mbox))).toHaveLength(2);
});
it('unquotes mboxrd ">From " lines by one ">"', () => {
const mbox = 'From a Tue May 5 10:11:12 2020\nSubject: q\n\n>From the start\n>>From deeper\n> not a from\n>Fromage\n';
expect(texts(split(mbox, 4))).toEqual(['Subject: q\n\nFrom the start\n>From deeper\n> not a from\n>Fromage\n']);
});
it('handles CRLF files and keeps CRLF in the message', () => {
const msgs = split(TWO.replace(/\n/g, '\r\n'), 3);
expect(texts(msgs)).toEqual(['Subject: one\r\n\r\nBody one\r\n', 'Subject: two\r\n\r\nBody two\r\n']);
});
it('passes 8-bit bytes through untouched', () => {
const head = enc.encode('From a Tue May 5 10:11:12 2020\nSubject: x\nContent-Type: text/plain; charset=iso-8859-1\n\n');
const body = new Uint8Array([0x47, 0x72, 0xfc, 0xdf, 0x65, 0x0a, 0xff, 0xfe, 0x80, 0x0a]);
const msgs = split(join([head, body]), 5);
const out = join(msgs[0].parts);
expect(Array.from(out.subarray(out.length - body.length))).toEqual(Array.from(body));
});
it('keeps an empty line the message ends with when the separator adds another', () => {
const mbox = 'From a Tue May 5 10:11:12 2020\nSubject: x\n\nbody\n\n\nFrom b Tue May 5 10:11:12 2020\nSubject: y\n\nz\n';
expect(texts(split(mbox))).toEqual(['Subject: x\n\nbody\n\n', 'Subject: y\n\nz\n']);
});
it('ignores bytes before the first envelope line', () => {
expect(texts(split('\n\nFrom a Tue May 5 10:11:12 2020\nSubject: x\n\nb\n'))).toEqual(['Subject: x\n\nb\n']);
});
describe('Content-Length (mboxcl2)', () => {
const body = 'first\n\nFrom inside Tue May 5 10:11:12 2020\nstill body\n';
const mboxcl2 = `From a Tue May 5 10:11:12 2020\nSubject: one\nContent-Length: ${body.length}\n\n${body}\nFrom b Wed May 6 11:00:00 2020\nSubject: two\n\nz\n`;
it('keeps an unquoted From line inside a body whose Content-Length lands on the next message', () => {
for (const size of [1, 4, 9, mboxcl2.length]) {
const out = texts(split(mboxcl2, size));
expect(out).toHaveLength(2);
expect(out[0]).toBe(`Subject: one\nContent-Length: ${body.length}\n\n${body}`);
expect(out[1]).toBe('Subject: two\n\nz\n');
}
});
it('trusts a length that lands right on the next From line too', () => {
const withBlank = `${body}\n`;
const mbox = `From a Tue May 5 10:11:12 2020\nContent-Length: ${withBlank.length}\n\n${withBlank}From b Wed May 6 11:00:00 2020\n\nz\n`;
expect(texts(split(mbox, 3))).toHaveLength(2);
});
it('splits at the From lines after all when the length is wrong', () => {
const wrong = mboxcl2.replace(`Content-Length: ${body.length}`, 'Content-Length: 5');
const out = texts(split(wrong, 7));
expect(out).toHaveLength(3);
expect(out[0]).toBe('Subject: one\nContent-Length: 5\n\nfirst\n');
expect(out[1]).toBe('still body\n');
expect(out[2]).toBe('Subject: two\n\nz\n');
});
it('splits when the length runs past the end of the file', () => {
const past = mboxcl2.replace(`Content-Length: ${body.length}`, 'Content-Length: 99999');
expect(texts(split(past))).toHaveLength(3);
});
it('trusts a length that ends the file', () => {
const last = `From a Tue May 5 10:11:12 2020\nContent-Length: ${body.length}\n\n${body}`;
expect(texts(split(last, 2))).toEqual([`Content-Length: ${body.length}\n\n${body}`]);
});
});
it('reports a message over the size limit without keeping its bytes', () => {
const big = `From a Tue May 5 10:11:12 2020\nSubject: big\n\n${'x'.repeat(500)}\n\nFrom b Wed May 6 11:00:00 2020\nSubject: small\n\nok\n`;
const msgs = split(big, 50, { maxMessageBytes: 100 });
expect(msgs[0].tooLarge).toBe(true);
expect(msgs[0].parts).toEqual([]);
expect(msgs[0].size).toBeGreaterThan(100);
expect(msgs[1].tooLarge).toBe(false);
expect(latin1(join(msgs[1].parts))).toBe('Subject: small\n\nok\n');
});
});
describe('envelopeDate', () => {
it('reads the asctime date of an envelope line as UTC', () => {
expect(envelopeDate('From alice@example.org Tue May 5 10:11:12 2020')).toBe('2020-05-05T10:11:12Z');
expect(envelopeDate('From - Fri Jan 31 23:59:00 2014')).toBe('2014-01-31T23:59:00Z');
});
it('applies a zone written before the year (Gmail Takeout)', () => {
expect(envelopeDate('From 1234@xxx Sat Apr 18 08:29:01 +0200 2020')).toBe('2020-04-18T06:29:01Z');
});
it('returns undefined without a date', () => {
expect(envelopeDate('From someone')).toBeUndefined();
});
});
describe('readHeaderBlock', () => {
it('stops at the first empty line', () => {
expect(readHeaderBlock([enc.encode('A: 1\r\nB: 2\r\n\r\nbody')])).toBe('A: 1\r\nB: 2');
});
});
@@ -0,0 +1,131 @@
// @vitest-environment node
import { describe, it, expect, vi, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { PSTFile } from 'pst-extractor';
import { walkPst, type PstItem } from '@/lib/mail-import/pst/items';
import { openPstSource, pstWorkerUrl } from '@/lib/mail-import/pst-source';
import { runImport } from '@/lib/mail-import/engine';
import type { IJMAPClient, UploadedEmailImport } from '@/lib/jmap/client-interface';
import type { Mailbox } from '@/lib/jmap/types';
const FIXTURE = path.join(__dirname, 'fixtures', 'Outlook.pst');
/** Runs the worker's protocol in this thread over the real fixture. */
class FakeWorker {
static instances: FakeWorker[] = [];
static failWith: { code: string; message: string } | null = null;
onmessage: ((e: { data: unknown }) => void) | null = null;
onerror: ((e: unknown) => void) | null = null;
terminated = false;
pulls: number[] = [];
private items: Generator<PstItem> | null = null;
constructor(readonly url: string) { FakeWorker.instances.push(this); }
postMessage(msg: { type: string; skipBefore?: number; maxMessageBytes?: number; count?: number }) {
queueMicrotask(() => {
if (msg.type === 'open') {
if (FakeWorker.failWith) { this.onmessage?.({ data: { type: 'error', ...FakeWorker.failWith } }); return; }
const walk = walkPst(new PSTFile(fs.readFileSync(FIXTURE)), { skipBefore: msg.skipBefore ?? 0, maxMessageBytes: msg.maxMessageBytes ?? Infinity });
this.items = walk.items;
this.onmessage?.({ data: { type: 'opened', total: walk.total } });
} else if (msg.type === 'pull') {
this.pulls.push(msg.count ?? 1);
for (let i = 0; i < (msg.count ?? 1); i++) {
const next = this.items!.next();
if (next.done) { this.onmessage?.({ data: { type: 'done' } }); return; }
const item = next.value;
const data = item.kind === 'message' ? { ...item, mime: item.mime.slice().buffer } : item;
this.onmessage?.({ data: { type: 'item', item: data, fraction: 0.5 } });
}
}
});
}
terminate() { this.terminated = true; }
}
afterEach(() => {
FakeWorker.instances = [];
FakeWorker.failWith = null;
vi.unstubAllGlobals();
});
const box = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox => ({
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
myRights: {} as Mailbox['myRights'], ...extra,
});
function fakeClient() {
const imported: Array<UploadedEmailImport & { text: string }> = [];
const blobs = new Map<string, string>();
const created: string[] = [];
let n = 0;
const client = {
getCapabilities: () => ({ 'urn:ietf:params:jmap:core': { maxConcurrentUpload: 4 } }),
getMaxConcurrentRequests: () => 4,
getMaxSizeUpload: () => 50_000_000,
getMailboxes: async () => [box('in', 'Inbox', { role: 'inbox' }), box('sent', 'Sent Items', { role: 'sent' })],
createMailbox: async (name: string) => { created.push(name); return box(`c${created.length}`, name); },
uploadBlob: async (f: File) => { const id = `b${n++}`; blobs.set(id, await f.text()); return { blobId: id, size: f.size, type: f.type }; },
importUploadedEmails: async (entries: UploadedEmailImport[]) => entries.map((e) => {
imported.push({ ...e, text: blobs.get(e.blobId)! });
return { id: `e${imported.length}` };
}),
} as unknown as IJMAPClient;
return { client, imported, created };
}
describe('openPstSource', () => {
it('loads the worker from the public file, with the build in the URL', () => {
expect(pstWorkerUrl()).toMatch(/^\/pst-worker\.js\?v=/);
});
it('imports a PST into its own folders: Inbox and Sent Items into the account\'s, with read state', async () => {
vi.stubGlobal('Worker', FakeWorker);
const { client, imported, created } = fakeClient();
const file = new File([fs.readFileSync(FIXTURE)], 'Outlook.pst');
const result = await runImport({
client, accountId: 'acc', targetMailboxId: null, keepFolders: true,
files: [{ file, format: 'pst' }], scope: 's', resume: false, signal: new AbortController().signal,
onProgress: () => {}, openPst: openPstSource, sleep: async () => {}, reportEveryMs: 0,
});
expect(result.phase).toBe('done');
expect(result.imported).toBe(14);
expect(result.skipped).toEqual({ contact: 1, calendar: 1, embedded: 4 });
// No folder was created: both exist with their special roles.
expect(created).toEqual([]);
const byBox = imported.reduce<Record<string, number>>((acc, e) => { const k = Object.keys(e.mailboxIds)[0]; acc[k] = (acc[k] ?? 0) + 1; return acc; }, {});
expect(byBox).toEqual({ in: 8, sent: 6 });
expect(imported.filter((e) => !e.keywords.$seen)).toHaveLength(2);
expect(imported.every((e) => typeof e.receivedAt === 'string' && e.receivedAt.startsWith('2011-06-2'))).toBe(true);
// The worker is asked for a few messages at a time, never the whole file at once.
expect(Math.max(...FakeWorker.instances[0].pulls)).toBeLessThanOrEqual(4);
expect(FakeWorker.instances[0].terminated).toBe(true);
});
it('gives up on a worker that stops answering (killed for memory sends no error)', async () => {
class SilentWorker {
onmessage: unknown = null;
onerror: unknown = null;
terminated = false;
postMessage() {}
terminate() { this.terminated = true; }
}
vi.stubGlobal('Worker', SilentWorker);
const source = openPstSource(new File(['!BDN'], 'x.pst'), { skipBefore: 0, maxMessageBytes: 1e9 }, { watchdogMs: 30 });
await expect(source.next()).rejects.toMatchObject({ name: 'PstReadError', code: 'unreadable' });
});
it('turns a worker error into a coded failure', async () => {
vi.stubGlobal('Worker', FakeWorker);
FakeWorker.failWith = { code: 'encrypted', message: 'PSTFile::open PST is encrypted' };
const { client } = fakeClient();
const result = await runImport({
client, accountId: 'acc', targetMailboxId: 'in', keepFolders: false,
files: [{ file: new File(['!BDN'], 'x.pst'), format: 'pst' }], scope: 's', resume: false, signal: new AbortController().signal,
onProgress: () => {}, openPst: openPstSource, sleep: async () => {},
});
expect(result.phase).toBe('failed');
expect(result.error).toMatch(/^pst:encrypted:/);
expect(FakeWorker.instances[0].terminated).toBe(true);
});
});
+243
View File
@@ -0,0 +1,243 @@
// @vitest-environment node
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import PostalMime from 'postal-mime';
import { PSTFile } from 'pst-extractor';
import { walkPst, type PstItem } from '@/lib/mail-import/pst/items';
import { buildMime, encodeHeaderText, keptHeaders, type PstMailData } from '@/lib/mail-import/pst/mime';
import { itemKind, pstFolders, readPstMessage } from '@/lib/mail-import/pst/extract';
import { registerPstFile, openSync, readSync } from '@/lib/mail-import/pst/shims/fs';
// Aspose's Outlook.pst (MIT, see fixtures/Outlook.pst.LICENSE).
const FIXTURE = path.join(__dirname, 'fixtures', 'Outlook.pst');
function readAll(): PstItem[] {
const pst = new PSTFile(fs.readFileSync(FIXTURE));
const { items, total } = walkPst(pst, { skipBefore: 0, maxMessageBytes: 50_000_000, boundarySeed: 'test' });
const out = [...items];
expect(total).toBe(out.length);
return out;
}
const parse = (mime: Uint8Array) => PostalMime.parse(Buffer.from(mime));
describe('PST import (Outlook.pst fixture)', () => {
const items = readAll();
const messages = items.filter((i): i is Extract<PstItem, { kind: 'message' }> => i.kind === 'message');
it('reads the mail of the IPM subtree, not the search folders, and counts contacts and calendar items', () => {
expect(messages).toHaveLength(14);
const skipped = items.filter((i) => i.kind === 'skipped').map((i) => (i as { reason: string }).reason).sort();
expect(skipped).toEqual(['calendar', 'contact']);
const folders = [...new Set(messages.map((m) => m.folder.join('/')))].sort();
expect(folders).toEqual(['Inbox', 'Sent Items']);
});
it('keeps the read state and counts embedded messages it cannot read', () => {
expect(messages.filter((m) => m.keywords.$seen)).toHaveLength(12);
const unread = messages.filter((m) => !m.keywords.$seen).map((m) => m.label).sort();
expect(unread).toEqual(['message 1', 'message 2']);
expect(messages.reduce((n, m) => n + m.embeddedSkipped, 0)).toBe(4);
});
it('builds a received message from its original headers with a fresh MIME body', async () => {
const m = messages.find((x) => x.label === 'Multiple attachments' && x.folder[0] === 'Inbox')!;
const text = new TextDecoder().decode(m.mime);
expect(text).toMatch(/^Return-Path: saqib\.razzaq@xp\.local\r\n/);
expect(text).toContain('Received: from xp');
// The original multipart boundary is gone; ours names the new structure.
expect(text.match(/^Content-Type:/gim)?.[0]).toBeTruthy();
expect(text).toContain('boundary="=_bulwark_test_');
const parsed = await parse(m.mime);
expect(parsed.subject).toBe('Multiple attachments');
expect(parsed.from?.address).toBe('saqib.razzaq@xp.local');
expect(parsed.attachments.map((a) => [a.filename, a.mimeType]).sort()).toEqual([
['Blue hills.jpg', 'image/jpeg'], ['Sunset.jpg', 'image/jpeg'], ['text file.txt', 'text/plain'],
]);
const sunset = parsed.attachments.find((a) => a.filename === 'Sunset.jpg')!;
expect((sunset.content as ArrayBuffer).byteLength).toBe(71189);
expect(m.receivedAt).toBe('2011-06-21T23:51:07Z');
});
it('puts cid images in multipart/related with their Content-ID', async () => {
const m = messages.find((x) => x.label === 'HTML body' && x.folder[0] === 'Inbox')!;
const parsed = await parse(m.mime);
expect(parsed.html).toContain('cid:image001.png@01CC3097.BF0BAC70');
const inline = parsed.attachments.filter((a) => a.contentId);
expect(inline).toHaveLength(12);
expect(inline[0].contentId).toBe('<image001.png@01CC3097.BF0BAC70>');
expect(new TextDecoder().decode(m.mime)).toContain('multipart/related');
});
it('builds a sent message without headers from its properties', async () => {
const m = messages.find((x) => x.label === 'Multiple attachments' && x.folder[0] === 'Sent Items')!;
const parsed = await parse(m.mime);
expect(parsed.from?.address).toBe('saqib.razzaq@xp.local');
expect(parsed.to?.[0]?.address).toBe('saqib.razzaq@xp.local');
expect(parsed.date).toBe('2011-06-21T23:51:07.000Z');
expect(parsed.text?.trim().length).toBeGreaterThan(0);
expect(parsed.html).toBeTruthy();
// No mimeTag in the PST: the type comes from the file name.
expect(parsed.attachments.find((a) => a.filename === 'Sunset.jpg')?.mimeType).toBe('image/jpeg');
});
it('reports items before the resume point without building them', () => {
const pst = new PSTFile(fs.readFileSync(FIXTURE));
const again = [...walkPst(pst, { skipBefore: 5, maxMessageBytes: 50_000_000 }).items];
expect(again.slice(0, 5).every((i) => i.kind === 'skipped' && i.reason === 'resumed')).toBe(true);
expect(again.slice(5).map((i) => i.index)).toEqual(items.slice(5).map((i) => i.index));
});
it('skips messages larger than the server takes', () => {
const pst = new PSTFile(fs.readFileSync(FIXTURE));
const small = [...walkPst(pst, { skipBefore: 0, maxMessageBytes: 20_000 }).items];
expect(small.some((i) => i.kind === 'skipped' && i.reason === 'too_large')).toBe(true);
});
});
describe('buildMime', () => {
const base: PstMailData = { subject: 'Prüfung', to: [{ name: 'Jörg', email: 'j@example.org' }], cc: [], bcc: [{ email: 'b@example.org' }], attachments: [] };
it('encodes non-ASCII subjects, names and file names', async () => {
const mime = buildMime({ ...base, text: 'Grüße', date: new Date('2020-01-02T03:04:05Z'), attachments: [{ filename: 'Bericht ä.pdf', mimeType: 'application/pdf', data: new Uint8Array([1, 2, 3]) }] });
const text = new TextDecoder().decode(mime);
// 7-bit: every character is ASCII.
expect([...text].every((c) => c.charCodeAt(0) < 128)).toBe(true);
const parsed = await parse(mime);
expect(parsed.subject).toBe('Prüfung');
expect(parsed.to?.[0]).toMatchObject({ name: 'Jörg', address: 'j@example.org' });
expect(parsed.bcc?.[0]?.address).toBe('b@example.org');
expect(parsed.text?.trim()).toBe('Grüße');
expect(parsed.attachments[0].filename).toBe('Bericht ä.pdf');
expect(parsed.date).toBe('2020-01-02T03:04:05.000Z');
});
it('attaches an RTF-only body as message.rtf', async () => {
const parsed = await parse(buildMime({ ...base, text: '\r\n\r\n', rtf: '{\\rtf1 hi}' }));
expect(parsed.attachments.map((a) => a.filename)).toEqual(['message.rtf']);
});
it('drops the MIME fields of kept headers and keeps the rest', () => {
const { text, names } = keptHeaders('From: a@example.org\r\nContent-Type: multipart/mixed;\r\n\tboundary="old"\r\nSubject: hi\r\n there\r\nMIME-Version: 1.0\r\n');
expect(text).toBe('From: a@example.org\r\nSubject: hi\r\n there');
expect([...names]).toEqual(['from', 'subject']);
});
it('splits long encoded words between characters', () => {
const encoded = encodeHeaderText('ü'.repeat(40));
for (const word of encoded.split('\r\n ')) expect(word.length).toBeLessThanOrEqual(75);
});
});
describe('pstFolders without an IPM subtree entry (OST files)', () => {
const folder = (displayName: string, children: unknown[] = [], containerClass = '') => ({
displayName, containerClass, descriptorNodeId: 1, hasSubfolders: children.length > 0, getSubFolders: () => children,
});
it('reads IPM_SUBTREE as the top and leaves search and system folders out', () => {
const pst = {
getMessageStore: () => ({ getBinaryItem: () => null }),
getRootFolder: () => folder('', [
folder('Root - Mailbox', [
folder('IPM_SUBTREE', [folder('Inbox', [folder('Projects')]), folder('Calendar', [], 'IPF.Appointment')]),
folder('Finder', [folder('Saved search')]),
folder('Freebusy Data'),
]),
folder('Search Root', [folder('All Messages')]),
]),
};
const out = [...pstFolders(pst)].map((f) => `${f.path.join('/')}${f.mail ? '' : ' (not mail)'}`);
expect(out).toEqual(['', '', '', 'Inbox', 'Inbox/Projects', 'Calendar (not mail)']);
});
});
describe('PST special folders', () => {
it('knows Sent Items and Deleted Items from the store\'s entry IDs, whatever their names', () => {
const roles = Object.fromEntries([...pstFolders(new PSTFile(fs.readFileSync(FIXTURE)))].map((r) => [r.path.join('/'), r.topRole]));
expect(roles['Sent Items']).toBe('sent');
expect(roles['Deleted Items']).toBe('trash');
expect(roles['Inbox']).toBeUndefined();
});
it('gives every message the role of its top folder', () => {
const items = [...walkPst(new PSTFile(fs.readFileSync(FIXTURE)), { skipBefore: 0, maxMessageBytes: 50_000_000 }).items];
const sent = items.filter((i) => i.kind === 'message' && i.folder[0] === 'Sent Items');
expect(sent.length).toBeGreaterThan(0);
expect(sent.every((i) => i.kind === 'message' && i.folderRole === 'sent')).toBe(true);
});
});
describe('PST safety', () => {
const stream = (length: number) => ({ length, readCompletely: vi.fn() });
const message = (attachments: unknown[]) => ({
subject: 'big', numberOfAttachments: attachments.length, getAttachment: (i: number) => attachments[i], numberOfRecipients: 0,
});
it('skips a message whose attachments are too big before reading any of them', () => {
const big = stream(900_000_000);
const read = readPstMessage(message([{ attachMethod: 1, fileInputStream: big, filename: 'a.iso' }]), 50_000_000);
expect(read.tooLarge).toBe(true);
expect(big.readCompletely).not.toHaveBeenCalled();
});
it('counts what is left of a folder after a broken item as unreadable', () => {
let n = 0;
const folder = {
displayName: 'Inbox', containerClass: 'IPF.Note', descriptorNodeId: 9, hasSubfolders: false, contentCount: 5,
getNextChild: () => { if (++n > 2) throw new Error('broken block'); return { messageClass: 'IPM.Contact', subject: `c${n}` }; },
};
const pst = {
getMessageStore: () => ({ getBinaryItem: () => null }),
getRootFolder: () => ({ displayName: '', descriptorNodeId: 1, hasSubfolders: true, getSubFolders: () => [folder] }),
};
const items = [...walkPst(pst, { skipBefore: 0, maxMessageBytes: 1e9 }).items];
expect(items.map((i) => (i.kind === 'skipped' ? i.reason : 'message'))).toEqual(['contact', 'contact', 'unreadable', 'unreadable', 'unreadable']);
expect(items.map((i) => i.index)).toEqual([0, 1, 2, 3, 4]);
});
it('keeps line breaks and spaces out of the ids it writes into headers', () => {
const text = new TextDecoder().decode(buildMime({
subject: 's', to: [], cc: [], bcc: [], text: 'x',
messageId: '<a@b>\r\nBcc: victim@example.org', inReplyTo: 'c@d',
html: '<img src="cid:img1">',
attachments: [{ filename: 'i.png', mimeType: 'image/png', contentId: 'img1', data: new Uint8Array([1]) }],
}));
expect(text).not.toMatch(/^Bcc:/m);
expect(text).not.toMatch(/^Message-ID:/m);
expect(text).toMatch(/^In-Reply-To: <c@d>\r$/m);
const evil = new TextDecoder().decode(buildMime({
subject: 's', to: [], cc: [], bcc: [], text: 'x', html: '<img src="cid:x>\r\nX-Evil: 1">',
attachments: [{ filename: 'i.png', mimeType: 'image/png', contentId: 'x>\r\nX-Evil: 1', data: new Uint8Array([1]) }],
}));
expect(evil).not.toMatch(/^X-Evil:/m);
});
});
describe('itemKind', () => {
it('sorts Outlook message classes', () => {
expect(itemKind('IPM.Note')).toBe('mail');
expect(itemKind('IPM.Note.SMIME.MultipartSigned')).toBe('mail');
expect(itemKind('REPORT.IPM.Note.NDR')).toBe('mail');
expect(itemKind('IPM.Contact')).toBe('contact');
expect(itemKind('IPM.Schedule.Meeting.Request')).toBe('calendar');
expect(itemKind('IPM.StickyNote')).toBe('other');
});
});
describe('the worker\'s File-backed fs', () => {
it('reads any range across its 64 KB pages', () => {
const bytes = fs.readFileSync(FIXTURE);
(globalThis as { FileReaderSync?: unknown }).FileReaderSync = class {
readAsArrayBuffer(blob: { bytes: Uint8Array }) { return new Uint8Array(blob.bytes).buffer; }
};
const file = { size: bytes.length, slice: (a: number, b: number) => ({ bytes: bytes.subarray(a, b) }) };
registerPstFile('t.pst', file as unknown as Blob);
const fd = openSync('t.pst');
for (const [pos, len] of [[0, 4], [65530, 20], [131072, 1], [200000, 150000], [bytes.length - 3, 10]]) {
const out = new Uint8Array(len);
const n = readSync(fd, out, 0, len, pos);
expect(n).toBe(Math.min(len, bytes.length - pos));
expect(Buffer.from(out.subarray(0, n)).equals(bytes.subarray(pos, pos + n))).toBe(true);
}
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Where an import stopped, per file, so a cancelled, crashed or paused import
* of a large file continues instead of importing every message again
* (Email/import doesn't skip duplicates). Kept in IndexedDB, keyed by the
* Bulwark account, the target and the file's name, size and date; removed
* when the import finishes, when the account signs out and on a full
* sign-out. Falls back to memory where IndexedDB isn't available.
*/
export interface ImportCheckpoint {
key: string;
/** The Bulwark account that ran the import. */
scope: string;
fileName: string;
/** 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;
}
const DB_NAME = "bulwark-mail-import";
const STORE = "checkpoints";
/** Older checkpoints are dropped: the file has likely changed or been forgotten. */
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export function checkpointKey(parts: {
scope: string;
accountId: string;
target: string | null;
keepFolders: boolean;
file: { name: string; size: number; lastModified: number };
}): string {
return [parts.scope, parts.accountId, parts.target ?? "", parts.keepFolders ? "1" : "0", parts.file.name, parts.file.size, parts.file.lastModified].join("\u0000");
}
const memory = new Map<string, ImportCheckpoint>();
function hasIdb(): boolean {
try {
return typeof indexedDB !== "undefined" && indexedDB !== null;
} catch {
return false;
}
}
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "key" }).createIndex("scope", "scope");
}
};
request.onsuccess = () => {
const db = request.result;
// Another tab deleting the database (sign-out) must not wait on us.
db.onversionchange = () => { db.close(); dbPromise = null; };
resolve(db);
};
request.onerror = () => { dbPromise = null; reject(request.error); };
request.onblocked = () => { dbPromise = null; reject(new Error("IndexedDB blocked")); };
});
return dbPromise;
}
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const db = await openDb();
return done(run(db.transaction(STORE, mode).objectStore(STORE)));
}
export async function readCheckpoint(key: string, now = Date.now()): Promise<ImportCheckpoint | null> {
let found: ImportCheckpoint | null;
if (!hasIdb()) {
found = memory.get(key) ?? null;
} else {
try {
found = ((await withStore("readonly", (s) => s.get(key))) as ImportCheckpoint | undefined) ?? null;
} catch {
found = memory.get(key) ?? null;
}
}
if (found && now - found.updatedAt > MAX_AGE_MS) {
await deleteCheckpoint(key);
return null;
}
return found;
}
export async function writeCheckpoint(checkpoint: ImportCheckpoint): Promise<void> {
memory.set(checkpoint.key, checkpoint);
if (!hasIdb()) return;
try {
await withStore("readwrite", (s) => s.put(checkpoint));
memory.delete(checkpoint.key);
} catch {
/* kept in memory for this tab */
}
}
export async function deleteCheckpoint(key: string): Promise<void> {
memory.delete(key);
if (!hasIdb()) return;
try {
await withStore("readwrite", (s) => s.delete(key));
} catch {
/* nothing stored */
}
}
/** Removes the checkpoints of one Bulwark account (it signs out). */
export async function clearCheckpointsFor(scope: string): Promise<void> {
for (const [key, value] of memory) if (value.scope === scope) memory.delete(key);
if (!hasIdb()) return;
try {
const db = await openDb();
const tx = db.transaction(STORE, "readwrite");
const keys = await done(tx.objectStore(STORE).index("scope").getAllKeys(scope));
const store = db.transaction(STORE, "readwrite").objectStore(STORE);
await Promise.all(keys.map((k) => done(store.delete(k))));
} catch {
/* nothing stored */
}
}
/** Removes every checkpoint (everyone signed out). */
export async function clearAllCheckpoints(): Promise<void> {
memory.clear();
if (!hasIdb()) return;
const db = dbPromise ? await dbPromise.catch(() => null) : null;
db?.close();
dbPromise = null;
await new Promise<void>((resolve) => {
try {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = request.onerror = request.onblocked = () => resolve();
} catch {
resolve();
}
});
}
+603
View File
@@ -0,0 +1,603 @@
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";
/**
* One engine for every mail import (EML, ZIP, mbox, PST): it reads messages
* from the files, uploads them a few at a time, imports them several per
* Email/import, and creates the file's folders where asked.
*
* Paced to the server: at most maxConcurrentUpload uploads (and two request
* slots left for the rest of the app), through the XHR upload path so a
* refusal doesn't pause the whole client. Stalwart takes 1000 uploads or
* 50 MB per user per hour (counted in fixed hour windows, whether the blobs
* get imported or not) and answers HTTP 429 past that: the import then
* pauses until the window ends and goes on by itself. A checkpoint per file
* lets a cancelled or interrupted import continue where it stopped.
*/
export type ImportPhase = "running" | "paused" | "done" | "cancelled" | "failed";
export interface ImportFailure {
label: string;
reason: string;
}
export interface ImportProgress {
phase: ImportPhase;
imported: number;
failed: number;
skipped: Partial<Record<SkipReason, number>>;
/** The first failures, for the summary. */
failures: ImportFailure[];
/** Part of all files read, 0 to 1. */
fraction: number;
/** Messages in the files, once known for all of them. */
total?: number;
/** The file being read. */
fileName?: string;
/** While paused by the upload limit: when the import goes on (ms since epoch). */
pausedUntil?: number;
/** The limit the server named, when it did. */
quota?: { files?: number; bytes?: number };
foldersCreated: number;
/** 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 {
file: File;
format: ImportFormat;
}
/** Opens a PST source (loaded only when a PST is imported). */
export type PstOpener = (file: File, options: SourceOptions) => ItemSource;
export interface ImportOptions {
client: IJMAPClient;
/** The JMAP account the messages go into (a shared folder's own account). */
accountId: string;
/** Folder the messages go into; null is the top level (only with keepFolders). */
targetMailboxId: string | null;
/** Recreate the files' folders under the target; otherwise everything goes into it. */
keepFolders: boolean;
files: ImportFile[];
/** The Bulwark account, for the checkpoints. */
scope: string;
/** Continue after the items an earlier run of the same import handled. */
resume: boolean;
signal: AbortSignal;
onProgress: (progress: ImportProgress) => void;
openPst?: PstOpener;
/** Test seams. */
now?: () => number;
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
/** Least time between two progress reports (ms). */
reportEveryMs?: number;
}
const MAX_FAILURES_LISTED = 20;
/** Messages per Email/import call. */
const IMPORT_BATCH = 20;
/** How long a few uploaded messages wait for more before they're imported. */
const IMPORT_FLUSH_MS = 400;
/** Retries of an upload that failed for another reason than the limit. */
const UPLOAD_RETRY_DELAYS_MS = [2000, 8000];
/** Stalwart's upload window (uploadTtl, 1 h by default). */
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
const CHECKPOINT_EVERY_MS = 2000;
export class ImportCancelled extends Error {
constructor() {
super("Import cancelled");
this.name = "AbortError";
}
}
function isAbort(error: unknown): boolean {
return (error as { name?: string } | null)?.name === "AbortError";
}
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(new ImportCancelled()); return; }
const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms);
const onAbort = () => { clearTimeout(timer); reject(new ImportCancelled()); };
signal.addEventListener("abort", onAbort, { once: true });
});
}
/** What an upload error says: the client's XHR path reports "Failed to upload file: <status> - <body>". */
export function uploadErrorInfo(error: unknown): { status?: number; body: string } {
const message = error instanceof Error ? error.message : String(error ?? "");
const m = /Failed to upload file: (\d{3}) - ([\s\S]*)$/.exec(message);
return m ? { status: Number(m[1]), body: m[2] } : { body: message };
}
/** "You have exceeded the blob upload quota of 1000 files or 50000000 bytes." */
export function quotaFrom(body: string): { files?: number; bytes?: number } {
const m = /quota of (\d+) files? or (\d+) bytes/i.exec(body);
return m ? { files: Number(m[1]), bytes: Number(m[2]) } : {};
}
/**
* When the upload limit lets uploads through again. The server doesn't send
* Retry-After in a way the browser may read (no CORS exposure), so this is
* the end of the current window: Stalwart counts in fixed windows of
* uploadTtl since the epoch, full hours by default.
*/
export function uploadWindowEnd(now: number, windowMs = UPLOAD_WINDOW_MS): number {
return (Math.floor(now / windowMs) + 1) * windowMs;
}
function maxConcurrentUploads(client: IJMAPClient): number {
const core = client.getCapabilities()["urn:ietf:params:jmap:core"] as { maxConcurrentUpload?: unknown } | undefined;
const uploads = typeof core?.maxConcurrentUpload === "number" && core.maxConcurrentUpload > 0 ? core.maxConcurrentUpload : 4;
// Uploads and imports share the request slots with the rest of the app; leave it two.
return Math.max(1, Math.min(uploads, (client.getMaxConcurrentRequests?.() ?? 4) - 2));
}
/**
* 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", "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"],
};
export function roleFor(name: string): string | undefined {
const key = name.trim().toLowerCase();
return Object.keys(ROLE_NAMES).find((role) => ROLE_NAMES[role].includes(key));
}
/**
* 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 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;
private readonly cache = new Map<string, Promise<string>>();
created = 0;
constructor(
private readonly client: IJMAPClient,
private readonly accountId: string,
private readonly root: string | null,
) {}
private async list(): Promise<Mailbox[]> {
if (!this.mailboxes) this.mailboxes = await this.client.getMailboxes(this.accountId);
return this.mailboxes;
}
private static idOf(m: Mailbox): string {
return m.originalId || m.id;
}
private async inbox(): Promise<string> {
const box = (await this.list()).find((m) => m.role === "inbox");
if (!box) throw new Error("No Inbox in this account");
return FolderResolver.idOf(box);
}
/** `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, topRole);
// A failed creation may be tried again by the next message.
pending.catch(() => this.cache.delete(key));
this.cache.set(key, pending);
}
return pending;
}
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), 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);
const created = await this.client.createMailbox(name, parentId ?? undefined, this.accountId);
this.created++;
mailboxes.push({ ...created, parentId: parentId ?? undefined });
return FolderResolver.idOf(created);
}
}
interface Uploaded {
index: number;
fileIndex: number;
label: string;
entry: UploadedEmailImport;
}
/**
* 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);
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 } = 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;
const lanes = maxConcurrentUploads(client);
const uploadAccountId = client.getAccountId?.() || accountId;
let lastReport = 0;
const report = (force = false) => {
const t = now();
if (!force && t - lastReport < (options.reportEveryMs ?? 150)) return;
lastReport = t;
progress.foldersCreated = folders.created;
options.onProgress({ ...progress, skipped: { ...progress.skipped }, failures: [...progress.failures] });
};
const skip = (reason: SkipReason, n = 1) => { progress.skipped[reason] = (progress.skipped[reason] ?? 0) + n; };
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: 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.
let pausedUntil = 0;
const waitWhilePaused = async () => {
while (pausedUntil > now()) {
progress.phase = "paused";
progress.pausedUntil = pausedUntil;
report(true);
await sleep(Math.max(1000, pausedUntil - now()), signal);
}
if (progress.phase === "paused") {
progress.phase = "running";
progress.pausedUntil = undefined;
report(true);
}
};
const sizes = options.files.map((f) => f.file.size);
const totalBytes = sizes.reduce((a, b) => a + b, 0) || 1;
const fileFraction = options.files.map(() => 0);
const totals: (number | undefined)[] = options.files.map(() => undefined);
const setFraction = () => {
progress.fraction = Math.min(1, fileFraction.reduce((n, f, i) => n + f * sizes[i], 0) / totalBytes);
};
const keys = options.files.map(({ file }) => checkpointKey({
scope: options.scope, accountId, target: options.targetMailboxId, keepFolders: options.keepFolders, file,
}));
const marks: Watermark[] = [];
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 || 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()));
};
// ── Email/import in batches ──
let queue: Uploaded[] = [];
let importing: Promise<void> = Promise.resolve();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function handled(u: { fileIndex: number; index: number }): void {
marks[u.fileIndex].mark(u.index);
}
const importBatch = async (batch: Uploaded[]) => {
let results: UploadedEmailImportResult[] | null = null;
for (let attempt = 0; results === null; attempt++) {
try {
if (client.importUploadedEmails) {
results = await client.importUploadedEmails(batch.map((b) => b.entry), accountId);
} else {
// A client without batch import (the demo) can't take uploaded blobs either.
results = batch.map(() => ({ error: "Import isn't available here" }));
}
} 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), 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;
}
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]++;
failuresInARow = 0;
handled(item);
return;
}
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();
};
const flush = () => {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
if (queue.length === 0) return importing;
const batch = queue;
queue = [];
importing = importing.then(() => importBatch(batch));
return importing;
};
const enqueue = (u: Uploaded) => {
queue.push(u);
if (queue.length >= IMPORT_BATCH) void flush();
else if (!flushTimer) flushTimer = setTimeout(() => { flushTimer = null; void flush(); }, IMPORT_FLUSH_MS);
};
// ── 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 : [], item.folderRole);
} catch (error) {
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" });
for (let attempt = 0; ; attempt++) {
await waitWhilePaused();
try {
// Into the user's own account: Stalwart (0.16 and 1.0) keeps a blob uploaded to a
// shared account's upload URL where Email/import there can't find it, while one in
// the user's own account imports into any account the user may add mail to.
const { blobId } = await client.uploadBlob(file, { accountId: uploadAccountId, signal });
networkErrors = 0;
enqueue({
index: item.index,
fileIndex,
label: item.label,
entry: { blobId, mailboxIds: { [mailboxId]: true }, keywords: item.keywords, receivedAt: item.receivedAt },
});
return;
} catch (error) {
if (isAbort(error) || signal.aborted) throw new ImportCancelled();
const { status, body } = uploadErrorInfo(error);
if (status === 429) {
pausedUntil = Math.max(pausedUntil, uploadWindowEnd(now()) + 5000);
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(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(u, `HTTP ${status}`);
return;
}
}
};
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 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({ 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]);
if (checkpoint) { progress.imported += checkpoint.imported; importedPerFile[f] = checkpoint.imported; }
const mark = new Watermark(checkpoint?.done ?? 0, checkpoint?.ahead ?? []);
marks.push(mark);
progress.fileName = file.name;
const sourceOptions: SourceOptions = {
maxMessageBytes: maxSize,
skipBefore: mark.value,
skipAlso: new Set(mark.beyond),
signal,
onProgress: (fraction) => { fileFraction[f] = fraction; setFraction(); report(); },
onTotal: (count) => {
totals[f] = count;
if (totals.every((t) => t !== undefined)) progress.total = totals.reduce((a: number, b) => a + (b ?? 0), 0);
report();
},
};
let source: ItemSource;
if (format === "pst") {
if (!options.openPst) throw new Error("PST import isn't available");
source = options.openPst(file, sourceOptions);
} else if (format === "mbox") source = mboxSource(file, sourceOptions);
else if (format === "zip") source = zipSource(file, sourceOptions);
else source = emlSource(file, sourceOptions);
try {
for await (const item of source) {
if (signal.aborted) throw state.stopped ?? new ImportCancelled();
if (item.kind === "skipped") {
// Items an earlier run handled are already counted in its imported total.
if (item.reason !== "resumed") skip(item.reason);
handled({ fileIndex: f, index: item.index });
continue;
}
if (item.embeddedSkipped) skip("embedded", item.embeddedSkipped);
await startUpload(item, f);
report();
}
} finally {
await source.return?.(undefined);
}
fileFraction[f] = 1;
setFraction();
}
while (running.size > 0) await Promise.race(running);
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;
// 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) {
const signedOut = options.signal.reason === "signout";
await Promise.allSettled([...running]);
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;
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";
progress.error = error instanceof Error ? error.message : String(error);
}
} 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(() => {}));
});
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Message state from the headers mail programs write into mbox files.
*
* - `Status:` R = read (O = not recent, ignored).
* - `X-Status:` A = answered, F = flagged, T = draft.
* - `X-Mozilla-Status:` Thunderbird's flags in four hex digits: 0x1 read,
* 0x2 replied, 0x4 flagged, 0x8 expunged (deleted, the folder wasn't
* compacted yet: such a message is skipped, or the import brings back
* deleted mail), 0x1000 forwarded.
* - `X-Keywords:` and `X-Mozilla-Keys:` keywords, separated by spaces or commas.
* - `X-Gmail-Labels:` (Google Takeout) "Unread" and "Starred".
*
* A message with none of these is imported as read, as Bulwark always did.
*/
export interface MboxState {
keywords: Record<string, true>;
/** Thunderbird marked it deleted (expunged): don't import it. */
expunged: boolean;
}
/** Unfolded header fields by lowercased name (first occurrence wins). */
export function headerFields(headerBlock: string): Map<string, string> {
const fields = new Map<string, string>();
for (const line of headerBlock.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
const colon = line.indexOf(":");
if (colon <= 0) continue;
const name = line.slice(0, colon).trim().toLowerCase();
if (!fields.has(name)) fields.set(name, line.slice(colon + 1).trim());
}
return fields;
}
/** RFC 8621 keyword characters: printable ASCII except ( ) { ] % * " \ and space. */
const KEYWORD = /^[\x21-\x7e]{1,255}$/;
const BAD_KEYWORD_CHARS = /[(){\]%*"\\]/;
/** Keywords the server sets itself or that mean something else in an mbox header. */
const IGNORED_KEYWORDS = new Set(["$recent", "\\recent", "$hasattachment", "$hasnoattachment"]);
function addKeywords(into: Record<string, true>, value: string | undefined): void {
if (!value) return;
for (const raw of value.split(/[\s,]+/)) {
let keyword = raw.trim();
if (!keyword) continue;
// IMAP system flags written as keywords.
const system: Record<string, string> = { "\\seen": "$seen", "\\flagged": "$flagged", "\\answered": "$answered", "\\draft": "$draft" };
keyword = system[keyword.toLowerCase()] ?? keyword;
if (!KEYWORD.test(keyword) || BAD_KEYWORD_CHARS.test(keyword)) continue;
if (IGNORED_KEYWORDS.has(keyword.toLowerCase())) continue;
into[keyword.toLowerCase()] = true;
}
}
export function mboxState(headerBlock: string): MboxState {
const fields = headerFields(headerBlock);
const status = fields.get("status");
const xStatus = fields.get("x-status");
const mozilla = fields.get("x-mozilla-status");
const gmail = fields.get("x-gmail-labels");
const keywords: Record<string, true> = {};
let expunged = false;
const known = status !== undefined || xStatus !== undefined || mozilla !== undefined || gmail !== undefined;
if (!known) keywords.$seen = true;
if (status && /R/i.test(status)) keywords.$seen = true;
if (xStatus) {
if (/A/i.test(xStatus)) keywords.$answered = true;
if (/F/i.test(xStatus)) keywords.$flagged = true;
if (/T/i.test(xStatus)) keywords.$draft = true;
}
if (mozilla && /^[0-9a-f]{1,8}$/i.test(mozilla)) {
const flags = parseInt(mozilla, 16);
if (flags & 0x1) keywords.$seen = true;
if (flags & 0x2) keywords.$answered = true;
if (flags & 0x4) keywords.$flagged = true;
if (flags & 0x8) expunged = true;
if (flags & 0x1000) keywords.$forwarded = true;
}
if (gmail !== undefined) {
const labels = gmail.split(",").map((l) => l.trim().toLowerCase());
if (!labels.includes("unread")) keywords.$seen = true;
if (labels.includes("starred")) keywords.$flagged = true;
}
addKeywords(keywords, fields.get("x-keywords"));
addKeywords(keywords, fields.get("x-mozilla-keys"));
return { keywords, expunged };
}
const STATE_HEADER = /^(status|x-status|x-keywords|x-mozilla-status|x-mozilla-status2|x-mozilla-keys)$/i;
/** How far into a message the header block is looked for. */
const HEADER_LIMIT = 256 * 1024;
/**
* The message without the mbox state headers read above: their state is now
* in JMAP keywords, and a stale copy in the stored message would only
* contradict it (and differ from what was exported). Everything else,
* the body included, stays byte for byte.
*/
export function withoutStateHeaders(parts: Uint8Array[]): Uint8Array[] {
// Find the end of the header block (the empty line) within the first parts.
let end = -1;
let offset = 0;
let lineHasText = false;
outer: for (const p of parts) {
for (let i = 0; i < p.length; i++) {
const b = p[i];
if (b === 0x0a) {
if (!lineHasText) { end = offset + i + 1; break outer; }
lineHasText = false;
} else if (b !== 0x0d) {
lineHasText = true;
}
}
offset += p.length;
if (offset > HEADER_LIMIT) break;
}
const headerEnd = end === -1 ? Math.min(offset, parts.reduce((n, p) => n + p.length, 0)) : end;
// The header bytes, copied, and the rest as it is.
const head = new Uint8Array(headerEnd);
const rest: Uint8Array[] = [];
let at = 0;
for (const p of parts) {
if (at >= headerEnd) { rest.push(p); continue; }
const take = Math.min(p.length, headerEnd - at);
head.set(p.subarray(0, take), at);
if (take < p.length) rest.push(p.subarray(take));
at += take;
}
const kept: Uint8Array[] = [];
let skipping = false;
let found = false;
let start = 0;
for (let i = 0; i <= head.length; i++) {
if (i < head.length && head[i] !== 0x0a) continue;
const line = head.subarray(start, Math.min(head.length, i + 1));
start = i + 1;
if (line.length === 0) continue;
const folded = line[0] === 0x20 || line[0] === 0x09;
if (!folded) {
const colon = line.indexOf(0x3a);
const name = colon > 0 ? new TextDecoder("latin1").decode(line.subarray(0, colon)).trim() : "";
skipping = STATE_HEADER.test(name);
if (skipping) found = true;
}
if (!skipping) kept.push(line);
}
return found ? [...kept, ...rest] : parts;
}
+373
View File
@@ -0,0 +1,373 @@
/**
* A streaming mbox splitter that works on bytes, never on decoded text.
*
* Messages are separated by a line starting with `From ` that follows an
* empty line (or opens the file). That one rule reads mboxo, mboxrd, mboxcl
* and mboxcl2 files:
*
* - `>From ` lines lose one `>` (the mboxrd rule; on an mboxo file this also
* unquotes a line that was `>From ` originally, which is rare and harmless).
* - A `Content-Length` header (mboxcl, mboxcl2) is used only when the message
* it measures ends exactly where another message starts. Until then, `From `
* lines inside the body are held as possible separators; if the length turns
* out wrong, they split the message after all (Dovecot does the same).
* - CRLF files work: an empty line is LF or CRLF, line endings are kept.
*
* Bytes are passed through untouched (8-bit bodies, signatures): only the
* envelope line and one `>` of quoted lines are removed, and the empty line
* in front of each separator, which belongs to the mbox format.
*/
const LF = 0x0a;
const CR = 0x0d;
const GT = 0x3e;
const FROM = [0x46, 0x72, 0x6f, 0x6d, 0x20]; // "From "
export interface MboxMessage {
/** The message bytes, in order. Pass them to `new Blob(parts)`. */
parts: Uint8Array[];
/** Total bytes in `parts`. */
size: number;
/** The envelope line without its line ending, decoded as Latin-1. */
fromLine: string;
/** Byte offset of the envelope line in the file. */
offset: number;
/** Larger than `maxMessageBytes`: `parts` is empty, the message was skipped. */
tooLarge: boolean;
}
export interface MboxSplitterOptions {
/** Stop keeping the bytes of a message past this size (it is reported `tooLarge`). */
maxMessageBytes?: number;
}
interface Segment {
bytes: Uint8Array;
/** This segment is the empty line in front of a held separator. */
heldEmpty?: boolean;
/** This segment is a held separator line (its text in `fromLine`). */
heldFrom?: { line: string; offset: number };
}
function startsWithFrom(line: Uint8Array, at: number): boolean {
if (line.length - at < FROM.length) return false;
for (let i = 0; i < FROM.length; i++) if (line[at + i] !== FROM[i]) return false;
return true;
}
function isEmptyLine(line: Uint8Array): boolean {
return line.length === 1 ? line[0] === LF : line.length === 2 && line[0] === CR && line[1] === LF;
}
function latin1(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.length; i += 8192) {
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
}
return out;
}
function stripEol(line: Uint8Array): Uint8Array {
let end = line.length;
if (end > 0 && line[end - 1] === LF) end--;
if (end > 0 && line[end - 1] === CR) end--;
return line.subarray(0, end);
}
/**
* Whether a `From ` line looks like an envelope line ("From sender date"),
* which carries a time. Guards against a body line "From now on..." after an
* empty line in a file whose writer didn't quote it.
*/
export function isEnvelopeLine(text: string): boolean {
// Sender, then a date that ends in the year: "... Tue May 5 10:11:12 2020",
// optionally with a zone before the year or "remote from host" after it.
return /^From \S+\s.*\b\d{1,2}:\d{2}(?::\d{2})?\s+(?:[+-]\d{4}\s+|[A-Z]{2,5}\s+)?\d{4}(?:\s+remote from \S+)?\s*$/.test(text);
}
const CONTENT_LENGTH = /^content-length:[ \t]*(\d+)[ \t]*\r?$/im;
export class MboxSplitter {
private readonly maxBytes: number;
/** Bytes of the line not finished yet (the chunk ended inside it). */
private carry: Uint8Array[] = [];
private carryOffset = 0;
/** Offset of the next byte pushed. */
private offset = 0;
private current: {
fromLine: string;
offset: number;
segments: Segment[];
size: number;
tooLarge: boolean;
/** Header block finished (first empty line seen). */
inBody: boolean;
headerBytes: Uint8Array[];
/** End offset Content-Length says the body has, while it is unconfirmed. */
expectedEnd?: number;
holding: boolean;
} | null = null;
/** An empty line held back: it is dropped if a separator follows it. */
private pendingEmpty: { bytes: Uint8Array; offset: number } | null = null;
private out: MboxMessage[] = [];
constructor(options: MboxSplitterOptions = {}) {
this.maxBytes = options.maxMessageBytes ?? Number.POSITIVE_INFINITY;
}
/** Feed the next chunk of the file. Returns the messages it completed. */
push(chunk: Uint8Array): MboxMessage[] {
let start = 0;
for (let i = 0; i < chunk.length; i++) {
if (chunk[i] !== LF) continue;
const piece = chunk.subarray(start, i + 1);
if (this.carry.length > 0) {
this.carry.push(piece);
const line = concat(this.carry);
this.carry = [];
this.line(line, this.carryOffset);
} else {
this.line(piece, this.offset + start);
}
start = i + 1;
}
if (start < chunk.length) {
if (this.carry.length === 0) this.carryOffset = this.offset + start;
this.carry.push(chunk.subarray(start));
}
this.offset += chunk.length;
return this.take();
}
/** The file ended. Returns the last messages. */
end(): MboxMessage[] {
if (this.carry.length > 0) {
const line = concat(this.carry);
this.carry = [];
this.line(line, this.carryOffset);
}
if (this.current?.expectedEnd !== undefined) {
this.settleLength(this.offset === this.current.expectedEnd || this.pendingEmptyEndsAt(this.current.expectedEnd));
}
// The empty line closing the last message belongs to the format.
this.pendingEmpty = null;
this.finish();
return this.take();
}
private take(): MboxMessage[] {
const out = this.out;
this.out = [];
return out;
}
private pendingEmptyEndsAt(offset: number): boolean {
return !!this.pendingEmpty && this.pendingEmpty.offset === offset;
}
private line(line: Uint8Array, offset: number): void {
const cur = this.current;
// Content-Length said the body ends here: is there a separator?
if (cur?.expectedEnd !== undefined && offset >= cur.expectedEnd) {
const atEnd = offset === cur.expectedEnd;
const afterEmpty = this.pendingEmptyEndsAt(cur.expectedEnd);
if (atEnd && isEmptyLine(line)) {
// An empty line right at the end: decide on the line after it.
} else {
const confirmed = (atEnd || afterEmpty) && startsWithFrom(line, 0) && isEnvelopeLine(latin1(stripEol(line)));
this.settleLength(confirmed);
}
}
const empty = isEmptyLine(line);
if (startsWithFrom(line, 0) && (this.current === null || this.pendingEmpty !== null)) {
const text = latin1(stripEol(line));
if (this.current === null || isEnvelopeLine(text)) {
if (this.current?.holding) {
// Inside the length Content-Length claims: keep it as a possible separator.
this.flushPendingEmpty(true);
this.addSegment({ bytes: line, heldFrom: { line: text, offset } });
return;
}
this.pendingEmpty = null;
this.finish();
this.start(text, offset);
return;
}
}
if (this.current === null) return; // Bytes before the first envelope line.
if (empty) {
this.flushPendingEmpty(false);
this.pendingEmpty = { bytes: line, offset };
if (!this.current.inBody) this.endHeaders(offset + line.length);
return;
}
this.flushPendingEmpty(false);
// mboxrd: one ">" less on ">From ", ">>From ", ...
let at = 0;
while (at < line.length && line[at] === GT) at++;
const bytes = at > 0 && startsWithFrom(line, at) ? line.subarray(1) : line;
if (!this.current.inBody) this.current.headerBytes.push(bytes);
this.addSegment({ bytes });
}
private flushPendingEmpty(held: boolean): void {
if (!this.pendingEmpty) return;
this.addSegment({ bytes: this.pendingEmpty.bytes, heldEmpty: held || undefined });
this.pendingEmpty = null;
}
private addSegment(segment: Segment): void {
const cur = this.current!;
cur.size += segment.bytes.length;
if (cur.size > this.maxBytes) {
cur.tooLarge = true;
cur.segments = [];
return;
}
if (cur.tooLarge) return;
// Plain lines next to each other in one chunk become one segment.
const last = cur.segments[cur.segments.length - 1];
const b = segment.bytes;
if (last && !last.heldEmpty && !last.heldFrom && !segment.heldEmpty && !segment.heldFrom
&& last.bytes.buffer === b.buffer && last.bytes.byteOffset + last.bytes.length === b.byteOffset) {
last.bytes = new Uint8Array(b.buffer, last.bytes.byteOffset, last.bytes.length + b.length);
return;
}
cur.segments.push({ ...segment });
}
private start(fromLine: string, offset: number): void {
this.current = { fromLine, offset, segments: [], size: 0, tooLarge: false, inBody: false, headerBytes: [], holding: false };
}
private endHeaders(bodyStart: number): void {
const cur = this.current!;
cur.inBody = true;
const match = CONTENT_LENGTH.exec(latin1(concat(cur.headerBytes)));
cur.headerBytes = [];
if (match) {
cur.expectedEnd = bodyStart + Number(match[1]);
cur.holding = true;
}
}
/**
* Content-Length was checked: when it was right, the held separators were
* body lines; when it was wrong, they split the message.
*/
private settleLength(confirmed: boolean): void {
const cur = this.current!;
cur.expectedEnd = undefined;
cur.holding = false;
if (confirmed || cur.tooLarge || !cur.segments.some((s) => s.heldFrom)) {
for (const s of cur.segments) { s.heldEmpty = undefined; s.heldFrom = undefined; }
return;
}
// Split at each held separator. The last part becomes the current message.
const parts: { fromLine: string; offset: number; segments: Segment[] }[] = [
{ fromLine: cur.fromLine, offset: cur.offset, segments: [] },
];
for (const s of cur.segments) {
if (s.heldEmpty) continue;
if (s.heldFrom) {
parts.push({ fromLine: s.heldFrom.line, offset: s.heldFrom.offset, segments: [] });
continue;
}
parts[parts.length - 1].segments.push(s);
}
for (const part of parts.slice(0, -1)) {
this.out.push(toMessage(part.fromLine, part.offset, part.segments, false));
}
const last = parts[parts.length - 1];
this.current = {
fromLine: last.fromLine,
offset: last.offset,
segments: last.segments,
size: last.segments.reduce((n, s) => n + s.bytes.length, 0),
tooLarge: false,
inBody: true,
headerBytes: [],
holding: false,
};
}
private finish(): void {
const cur = this.current;
if (!cur) return;
this.current = null;
this.out.push(toMessage(cur.fromLine, cur.offset, cur.segments, cur.tooLarge, cur.size));
}
}
function toMessage(fromLine: string, offset: number, segments: Segment[], tooLarge: boolean, size?: number): MboxMessage {
const parts = tooLarge ? [] : mergeAdjacent(segments.map((s) => s.bytes));
return {
parts,
size: size ?? parts.reduce((n, p) => n + p.length, 0),
fromLine,
offset,
tooLarge,
};
}
/** Joins subarrays that sit next to each other in the same buffer, so a Blob gets few parts. */
function mergeAdjacent(parts: Uint8Array[]): Uint8Array[] {
const out: Uint8Array[] = [];
for (const p of parts) {
const last = out[out.length - 1];
if (last && last.buffer === p.buffer && last.byteOffset + last.length === p.byteOffset) {
out[out.length - 1] = new Uint8Array(p.buffer, last.byteOffset, last.length + p.length);
} else {
out.push(p);
}
}
return out;
}
function concat(parts: Uint8Array[]): Uint8Array {
if (parts.length === 1) return parts[0];
let n = 0;
for (const p of parts) n += p.length;
const out = new Uint8Array(n);
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
}
/** The header block of a message (up to the first empty line, at most 64 KB), as Latin-1 text. */
export function readHeaderBlock(parts: Uint8Array[], limit = 64 * 1024): string {
const bytes: Uint8Array[] = [];
let n = 0;
for (const p of parts) {
bytes.push(p.subarray(0, Math.max(0, limit - n)));
n += p.length;
if (n >= limit) break;
}
const text = latin1(concat(bytes));
const end = text.search(/\r?\n\r?\n/);
return end === -1 ? text : text.slice(0, end);
}
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
/**
* The date of an envelope line, "From sender Tue May 5 10:11:12 2020" (asctime,
* read as UTC, optionally with a zone before the year), as a JMAP UTCDate.
*/
export function envelopeDate(fromLine: string): string | undefined {
const m = /(?:^|\s)(?:mon|tue|wed|thu|fri|sat|sun)\w*\s+([a-z]{3})\w*\s+(\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s+(?:([+-]\d{4})\s+)?(\d{4})/i.exec(fromLine);
if (!m) return undefined;
const month = MONTHS.indexOf(m[1].toLowerCase());
if (month < 0) return undefined;
let time = Date.UTC(Number(m[7]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5] ?? 0));
if (m[6]) {
const sign = m[6][0] === "-" ? -1 : 1;
time -= sign * (Number(m[6].slice(1, 3)) * 60 + Number(m[6].slice(3))) * 60_000;
}
if (Number.isNaN(time)) return undefined;
return new Date(time).toISOString().replace(/\.\d{3}Z$/, "Z");
}
+126
View File
@@ -0,0 +1,126 @@
import { withBasePath } from "@/lib/browser-navigation";
import { LITE_BUILD_ID } from "@/lib/lite";
import type { PstItem } from "./pst/items";
import type { ImportItem, ItemSource, SourceOptions } from "./sources";
/**
* Reads an Outlook .pst/.ost file through the PST worker (public/pst-worker.js,
* built by scripts/build-pst-worker.mjs), which is only fetched now. pst-extractor
* is synchronous, so it can't run on the page; the worker reads the file in
* pieces and hands over a few messages at a time as the import asks for them.
*/
/** Errors carry a code the dialog explains: encrypted, not_pst, unreadable, worker. */
export class PstReadError extends Error {
constructor(readonly code: string, message: string) {
super(`pst:${code}: ${message}`);
this.name = "PstReadError";
}
}
/**
* The worker's URL. Its name has no hash, so the build goes in the query:
* Stalwart serves Lite's files as immutable, and an old worker must not
* meet a new page.
*/
export function pstWorkerUrl(): string {
const build = LITE_BUILD_ID || `${process.env.NEXT_PUBLIC_APP_VERSION ?? "0"}-${process.env.NEXT_PUBLIC_GIT_COMMIT ?? "dev"}`;
return withBasePath(`/pst-worker.js?v=${encodeURIComponent(build)}`);
}
/** A PstItem as posted: the MIME bytes arrive as a transferred ArrayBuffer. */
type WireItem =
| (Omit<Extract<PstItem, { kind: "message" }>, "mime"> & { mime: ArrayBuffer })
| Extract<PstItem, { kind: "skipped" }>;
type WorkerMessage =
| { type: "opened"; total: number }
| { type: "item"; item: WireItem; fraction: number }
| { type: "done" }
| { type: "error"; code: string; message: string }
| { type: "abort" };
/** Messages asked for ahead of the import, so the worker keeps busy without filling memory. */
const AHEAD = 4;
/** How long the worker may stay silent while a message is awaited before the import gives up (it may have run out of memory). */
const WATCHDOG_MS = 120_000;
export async function* openPstSource(file: File, options: SourceOptions, tuning: { watchdogMs?: number } = {}): ItemSource {
const worker = new Worker(pstWorkerUrl());
const queue: WorkerMessage[] = [];
let wake: (() => void) | null = null;
const push = (message: WorkerMessage) => {
queue.push(message);
wake?.();
};
worker.onmessage = (event: MessageEvent) => push(event.data as WorkerMessage);
worker.onerror = (event: ErrorEvent) => {
event.preventDefault?.();
push({ type: "error", code: "worker", message: event.message || "The PST reader didn't start" });
};
const onAbort = () => push({ type: "abort" });
options.signal?.addEventListener("abort", onAbort, { once: true });
// A worker killed for memory sends no error: don't wait for it forever.
const next = async (): Promise<WorkerMessage> => {
while (queue.length === 0) {
let timer: ReturnType<typeof setTimeout> | undefined;
await new Promise<void>((resolve) => {
wake = resolve;
timer = setTimeout(() => push({ type: "error", code: "unreadable", message: "The PST reader stopped answering" }), tuning.watchdogMs ?? WATCHDOG_MS);
});
clearTimeout(timer);
}
wake = null;
return queue.shift()!;
};
try {
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);
if (opened.type !== "opened") throw new PstReadError("unreadable", "unexpected reply");
options.onTotal?.(opened.total);
let asked = AHEAD;
worker.postMessage({ type: "pull", count: AHEAD });
for (;;) {
const message = await next();
if (message.type === "abort") throw new DOMException("Import cancelled", "AbortError");
if (message.type === "error") throw new PstReadError(message.code, message.message);
if (message.type === "done") break;
if (message.type !== "item") continue;
asked--;
if (asked < AHEAD / 2) {
worker.postMessage({ type: "pull", count: AHEAD });
asked += AHEAD;
}
options.onProgress?.(message.fraction);
const item = message.item;
let out: ImportItem;
if (item.kind === "message") {
const mime = item.mime;
out = {
kind: "message",
index: item.index,
folder: item.folder,
data: new Blob([mime], { type: "message/rfc822" }),
size: mime.byteLength,
keywords: item.keywords,
receivedAt: item.receivedAt,
label: item.label,
embeddedSkipped: item.embeddedSkipped,
folderRole: item.folderRole,
};
} else {
out = item;
}
yield out;
}
options.onProgress?.(1);
} finally {
options.signal?.removeEventListener("abort", onAbort);
worker.terminate();
}
}
+290
View File
@@ -0,0 +1,290 @@
import type { MailAddress, PstAttachmentData, PstMailData } from "./mime";
/**
* Reads folders and messages out of a pst-extractor PSTFile. Typed loosely
* on purpose: the objects come from the library inside the worker, and the
* protected property getters used here (getIntItem, getStringItem) are plain
* methods at run time.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type PstObject = any;
/** Folders under the root that hold no mail of the user's own (search results, views, system data). */
const SYSTEM_FOLDERS = new Set([
"search root", "spam search folder 2", "ipm_views", "ipm_common_views", "reminders", "to-do search",
"itemprocsearch", "tracked mail processing", "freebusy data", "finder", "common views", "views",
"shortcuts", "schedule", "deferred action", "spooler queue", "conversation action settings",
]);
/** Containers whose name is no folder of the user's: their contents go to the top. */
const ROOT_NAMES = new Set(["top of personal folders", "top of outlook data file", "root - mailbox", "ipm_subtree", "root - public"]);
const PR_IPM_SUBTREE_ENTRYID = 0x35e0;
const PR_IPM_WASTEBASKET_ENTRYID = 0x35e3;
const PR_IPM_SENTMAIL_ENTRYID = 0x35e4;
const PR_IPM_DRAFTS_ENTRYID = 0x36d7;
const PR_FLAG_STATUS = 0x1090;
const PR_SENDER_SMTP_ADDRESS = 0x5d01;
const PR_SENT_REPRESENTING_SMTP_ADDRESS = 0x5d02;
const ATTACH_EMBEDDED_MSG = 5;
export type ItemKind = "mail" | "contact" | "calendar" | "other";
/** What kind of item a message class is. */
export function itemKind(messageClass: string): ItemKind {
const c = (messageClass || "IPM.Note").toLowerCase();
if (c === "ipm" || c === "ipm.note" || c.startsWith("ipm.note.") || c === "ipm.post" || c.startsWith("report.ipm.note")) return "mail";
if (c.startsWith("ipm.contact") || c.startsWith("ipm.distlist")) return "contact";
if (c.startsWith("ipm.appointment") || c.startsWith("ipm.schedule") || c.startsWith("ipm.task") || c.startsWith("ipm.microsoft.scheduledata")) return "calendar";
return "other";
}
/** Whether a folder holds mail (not a calendar, contacts, tasks, notes or journal folder). */
function isMailFolder(folder: PstObject): boolean {
const cls = String(folder.containerClass || "").toLowerCase();
return cls === "" || cls === "ipf" || cls.startsWith("ipf.note");
}
export interface PstFolderRef {
folder: PstObject;
path: string[];
/** What the PST says the top folder of `path` is (sent, trash, drafts), whatever its name in the user's language. */
topRole?: string;
/** A calendar, contacts or other non-mail folder: its items are counted, not imported. */
mail: boolean;
}
/**
* The folders to read, depth first, under the mailbox's top (the IPM
* subtree): PSTs name it in the message store, OSTs call it IPM_SUBTREE.
*/
export function* pstFolders(pst: PstObject): Generator<PstFolderRef> {
const root = pst.getRootFolder();
let top: PstObject = null;
try {
const entry = pst.getMessageStore().getBinaryItem?.(PR_IPM_SUBTREE_ENTRYID);
const nid = entry && entry.length >= 24 ? entry.readUInt32LE(20) : null;
if (nid !== null) {
const find = (folder: PstObject, depth: number): PstObject => {
if (Number(folder.descriptorNodeId) === nid || folder.descriptorNodeId?.toNumber?.() === nid) return folder;
if (depth >= 3 || !folder.hasSubfolders) return null;
for (const child of folder.getSubFolders()) {
const hit = find(child, depth + 1);
if (hit) return hit;
}
return null;
};
top = find(root, 0);
}
} catch {
top = null;
}
// Special folders by their entry IDs: the message store names Sent Items and
// Deleted Items, the top folder names Drafts. The Inbox is found by name.
const roles = new Map<number, string>();
const nidOf = (entry: { length: number; readUInt32LE(at: number): number } | null | undefined) =>
entry && entry.length >= 24 ? entry.readUInt32LE(20) : null;
try {
const store = pst.getMessageStore();
for (const [id, role] of [[PR_IPM_SENTMAIL_ENTRYID, "sent"], [PR_IPM_WASTEBASKET_ENTRYID, "trash"]] as const) {
const nid = nidOf(store.getBinaryItem?.(id));
if (nid !== null) roles.set(nid, role);
}
const drafts = nidOf((top ?? root).getBinaryItem?.(PR_IPM_DRAFTS_ENTRYID));
if (drafts !== null) roles.set(drafts, "drafts");
} catch {
/* names are the fallback */
}
const nidOfFolder = (folder: PstObject): number => Number(folder.descriptorNodeId?.toNumber?.() ?? folder.descriptorNodeId);
function* walk(folder: PstObject, path: string[], depth: number, topRole?: string): Generator<PstFolderRef> {
if (!folder.hasSubfolders) return;
for (const child of folder.getSubFolders()) {
const name = String(child.displayName || "").trim();
const lower = name.toLowerCase();
// Outside the IPM subtree only what sits next to the mailbox's top counts as system data.
if (!top && path.length === 0 && SYSTEM_FOLDERS.has(lower)) continue;
const childPath = !top && ROOT_NAMES.has(lower) && path.length === 0 ? path : [...path, name || "Folder"];
const role = childPath.length === 1 && path.length === 0 ? roles.get(nidOfFolder(child)) : topRole;
yield { folder: child, path: childPath, mail: isMailFolder(child), topRole: role };
yield* walk(child, childPath, depth + 1, role);
}
}
if (top) {
yield { folder: top, path: [], mail: true };
yield* walk(top, [], 1);
} else {
yield { folder: root, path: [], mail: true };
yield* walk(root, [], 0);
}
}
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
function looksLikeEmail(value: string): boolean {
return /^[^\s@<>]+@[^\s@<>]+$/.test(value);
}
function senderOf(m: PstObject): MailAddress | undefined {
const name = str(m.senderName) || str(m.sentRepresentingName);
const candidates = [
str(m.senderAddrtype).toUpperCase() === "SMTP" ? str(m.senderEmailAddress) : "",
safeString(m, PR_SENDER_SMTP_ADDRESS),
str(m.sentRepresentingAddressType).toUpperCase() === "SMTP" ? str(m.sentRepresentingEmailAddress) : "",
safeString(m, PR_SENT_REPRESENTING_SMTP_ADDRESS),
str(m.senderEmailAddress),
];
const email = candidates.find(looksLikeEmail);
return email ? { name, email } : undefined;
}
function safeString(m: PstObject, id: number): string {
try {
return str(m.getStringItem?.(id));
} catch {
return "";
}
}
function safeInt(m: PstObject, id: number): number | undefined {
try {
const v = m.getIntItem?.(id);
return typeof v === "number" ? v : undefined;
} catch {
return undefined;
}
}
function recipients(m: PstObject): { to: MailAddress[]; cc: MailAddress[]; bcc: MailAddress[] } {
const out = { to: [] as MailAddress[], cc: [] as MailAddress[], bcc: [] as MailAddress[] };
const count = Number(m.numberOfRecipients) || 0;
for (let i = 0; i < count; i++) {
let r: PstObject;
try { r = m.getRecipient(i); } catch { continue; }
if (!r) continue;
const email = [str(r.smtpAddress), str(r.emailAddress)].find(looksLikeEmail);
if (!email) continue;
const entry = { name: str(r.displayName), email };
if (r.recipientType === 2) out.cc.push(entry);
else if (r.recipientType === 3) out.bcc.push(entry);
else out.to.push(entry);
}
return out;
}
const EXT_TYPES: Record<string, string> = {
txt: "text/plain", htm: "text/html", html: "text/html", pdf: "application/pdf", jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", gif: "image/gif", bmp: "image/bmp", zip: "application/zip", doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", ics: "text/calendar", vcf: "text/vcard",
eml: "message/rfc822", msg: "application/vnd.ms-outlook", csv: "text/csv", rtf: "application/rtf",
};
function mimeTypeFor(tag: string, filename: string): string {
const t = tag.trim().toLowerCase();
if (/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(t)) return t;
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
return EXT_TYPES[ext] ?? "application/octet-stream";
}
export interface PstMessage {
mail: PstMailData;
keywords: Record<string, true>;
receivedAt?: Date;
/** Attached messages pst-extractor can't read. */
embedded: number;
/** Bigger than the server takes: nothing was read in. */
tooLarge?: boolean;
}
function streamLength(stream: PstObject): number {
return typeof stream?.length === "number" ? stream.length : Number(stream?.length?.toNumber?.() ?? 0);
}
/**
* A mail item's properties, its attachments read in, as data for buildMime.
* A message whose attachments alone would be bigger than `maxBytes` once in
* base64 is reported `tooLarge` before anything is read: a 1 GB attachment
* would take several GB in the worker.
*/
export function readPstMessage(m: PstObject, maxBytes = Number.POSITIVE_INFINITY): PstMessage {
const attachments: PstAttachmentData[] = [];
let embedded = 0;
const count = Number(m.numberOfAttachments) || 0;
if (Number.isFinite(maxBytes)) {
let declared = 0;
for (let i = 0; i < count; i++) {
try {
const a = m.getAttachment(i);
if (a && a.attachMethod !== ATTACH_EMBEDDED_MSG) declared += streamLength(a.fileInputStream);
} catch {
/* read below, or skipped there */
}
}
if (Math.ceil(declared / 3) * 4 > maxBytes) {
return { mail: { subject: str(m.subject), to: [], cc: [], bcc: [], attachments: [] }, keywords: {}, embedded: 0, tooLarge: true };
}
}
for (let i = 0; i < count; i++) {
let a: PstObject;
try { a = m.getAttachment(i); } catch { continue; }
if (!a) continue;
if (a.attachMethod === ATTACH_EMBEDDED_MSG) { embedded++; continue; }
const stream = a.fileInputStream;
if (!stream) continue;
const length = streamLength(stream);
const buffer = new Uint8Array(length);
try {
// pst-extractor wants a Buffer; one over our bytes fills them in place.
const B = (globalThis as { Buffer?: { from(b: ArrayBuffer, o: number, l: number): Uint8Array } }).Buffer;
stream.readCompletely(B ? B.from(buffer.buffer, buffer.byteOffset, buffer.byteLength) : buffer);
} catch {
continue;
}
const filename = str(a.longFilename) || str(a.filename) || `attachment-${i + 1}`;
attachments.push({ filename, mimeType: mimeTypeFor(str(a.mimeTag), filename), contentId: str(a.contentId) || undefined, data: buffer });
}
const { to, cc, bcc } = recipients(m);
const submitted: Date | null = m.clientSubmitTime ?? null;
const delivered: Date | null = m.messageDeliveryTime ?? null;
const verb = Number(m.lastVerbExecuted) || 0;
const keywords: Record<string, true> = {};
if (m.isRead) keywords.$seen = true;
if (safeInt(m, PR_FLAG_STATUS) === 2) keywords.$flagged = true;
if (verb === 102 || verb === 103) keywords.$answered = true;
if (verb === 104) keywords.$forwarded = true;
if (m.isUnsent) keywords.$draft = true;
let rtf = "";
let text = str(m.body);
let html = str(m.bodyHTML);
if (!html && !text.trim()) {
try { rtf = str(m.bodyRTF); } catch { rtf = ""; }
}
if (!text.trim() && !html && !rtf) text = "";
return {
mail: {
headers: str(m.transportMessageHeaders) || undefined,
subject: str(m.subject),
from: senderOf(m),
to, cc, bcc,
date: submitted ?? delivered ?? undefined,
messageId: str(m.internetMessageId) || undefined,
inReplyTo: str(m.inReplyToId) || undefined,
importance: typeof m.importance === "number" ? m.importance : undefined,
text, html: html || undefined, rtf: rtf || undefined,
attachments,
},
keywords,
receivedAt: delivered ?? submitted ?? undefined,
embedded,
};
}
+103
View File
@@ -0,0 +1,103 @@
import { itemKind, pstFolders, readPstMessage, type PstFolderRef } from "./extract";
import { buildMime } from "./mime";
import type { SkipReason } from "../sources";
/** One PST item as the worker hands it over: a message's MIME, or why it was skipped. */
export type PstItem =
| {
kind: "message";
index: number;
folder: string[];
mime: Uint8Array;
keywords: Record<string, true>;
receivedAt?: string;
label: string;
embeddedSkipped: number;
folderRole?: string;
}
| { kind: "skipped"; index: number; reason: SkipReason; label?: string };
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface PstWalk {
/** Items in the folders that are read (an estimate: Outlook keeps the count). */
total: number;
items: Generator<PstItem>;
}
function utcDate(date: Date | undefined): string | undefined {
if (!date || Number.isNaN(date.getTime())) return undefined;
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
}
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);
function* items(): Generator<PstItem> {
let index = 0;
for (const ref of folders) {
if (!(Number(ref.folder.contentCount) > 0)) continue;
const count = Number(ref.folder.contentCount) || 0;
let read = 0;
for (;;) {
let child: any;
try {
child = ref.folder.getNextChild();
} catch {
// A broken item ends this folder's list in pst-extractor: count what is
// left of the folder as unreadable and go on with the next folder.
for (let left = count - read; left > 0; left--) {
const i = index++;
yield i < options.skipBefore || options.skipAlso?.has(i)
? { kind: "skipped", index: i, reason: "resumed" }
: { kind: "skipped", index: i, reason: "unreadable" };
}
break;
}
if (!child) break;
read++;
const i = index++;
if (i < options.skipBefore || options.skipAlso?.has(i)) {
yield { kind: "skipped", index: i, reason: "resumed" };
continue;
}
const kind = itemKind(String(child.messageClass || ""));
const label = String(child.subject || "");
if (kind !== "mail") {
yield { kind: "skipped", index: i, reason: kind, label };
continue;
}
let mime: Uint8Array;
let message;
try {
message = readPstMessage(child, options.maxMessageBytes);
if (message.tooLarge) {
yield { kind: "skipped", index: i, reason: "too_large", label };
continue;
}
mime = buildMime(message.mail, { boundarySeed: options.boundarySeed });
} catch {
yield { kind: "skipped", index: i, reason: "other", label };
continue;
}
if (mime.length > options.maxMessageBytes) {
yield { kind: "skipped", index: i, reason: "too_large", label };
continue;
}
yield {
kind: "message",
index: i,
folder: ref.path,
mime,
keywords: message.keywords,
receivedAt: utcDate(message.receivedAt),
label,
embeddedSkipped: message.embedded,
folderRole: ref.topRole,
};
}
}
}
return { total, items: items() };
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Builds an RFC 5322 message from what a PST stores about a message. Outlook
* keeps the parts, not the MIME: received mail has its original header block
* (PR_TRANSPORT_MESSAGE_HEADERS), sent mail and drafts have none. The header
* block is kept without its MIME fields and a new multipart is built from the
* bodies and attachments:
*
* mixed (when there are attachments)
* related (when the HTML shows cid: images)
* alternative (plain text and HTML) | the one body there is
* inline images
* attachments
*
* An RTF-only body (no plain text or HTML Outlook could give) is attached as
* message.rtf. Every part is UTF-8 and base64, so the result is 7-bit.
*/
export interface MailAddress {
name?: string;
email: string;
}
export interface PstAttachmentData {
filename: string;
mimeType: string;
contentId?: string;
data: Uint8Array;
}
export interface PstMailData {
/** The original header block of received mail, if Outlook kept it. */
headers?: string;
subject: string;
from?: MailAddress;
to: MailAddress[];
cc: MailAddress[];
bcc: MailAddress[];
date?: Date;
messageId?: string;
inReplyTo?: string;
/** 0 low, 1 normal, 2 high. */
importance?: number;
text?: string;
html?: string;
rtf?: string;
attachments: PstAttachmentData[];
}
const CRLF = "\r\n";
const encoder = new TextEncoder();
function base64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
/** base64 in lines of 76 characters. */
export function base64Lines(bytes: Uint8Array): string {
const all = base64(bytes);
const lines: string[] = [];
for (let i = 0; i < all.length; i += 76) lines.push(all.slice(i, i + 76));
return lines.join(CRLF);
}
const PRINTABLE = /^[\x20-\x7e]*$/;
/** RFC 2047 encoded words for a header text that isn't plain ASCII. */
export function encodeHeaderText(text: string): string {
const clean = text.replace(/[\r\n]+/g, " ");
if (PRINTABLE.test(clean)) return clean;
// Each word holds at most 45 bytes (60 base64 characters), split between characters.
const words: string[] = [];
let chunk = "";
for (const ch of clean) {
if (encoder.encode(chunk + ch).length > 45) {
words.push(chunk);
chunk = "";
}
chunk += ch;
}
if (chunk) words.push(chunk);
return words.map((w) => `=?UTF-8?B?${base64(encoder.encode(w))}?=`).join(`${CRLF} `);
}
function formatAddress(a: MailAddress): string {
const name = (a.name ?? "").trim().replace(/^['"]+|['"]+$/g, "");
if (!name || name.toLowerCase() === a.email.toLowerCase()) return `<${a.email}>`;
if (PRINTABLE.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${a.email}>`;
return `${encodeHeaderText(name)} <${a.email}>`;
}
function addressList(list: MailAddress[]): string {
return list.map(formatAddress).join(`,${CRLF} `);
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** RFC 5322 date in UTC. */
export function rfc5322Date(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${DAYS[date.getUTCDay()]}, ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]} ${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`;
}
/** A filename parameter both old and new mail programs read. */
function fileParams(name: string): string {
const safe = name.replace(/[\r\n"\\]/g, "_");
if (PRINTABLE.test(safe)) return `; name="${safe}"`;
const pct = Array.from(encoder.encode(safe), (b) =>
/[A-Za-z0-9!#$&+.^_`|~-]/.test(String.fromCharCode(b)) ? String.fromCharCode(b) : `%${b.toString(16).toUpperCase().padStart(2, "0")}`).join("");
return `; name="${encodeHeaderText(safe).replace(/\r\n /g, " ")}"; name*=UTF-8''${pct}`;
}
const MIME_FIELDS = /^(content-type|content-transfer-encoding|mime-version|content-disposition|content-length|content-md5)$/i;
/** The header block without its MIME fields, unfolded lines kept, CRLF. */
export function keptHeaders(block: string): { text: string; names: Set<string> } {
const lines = block.replace(/\r?\n/g, "\n").replace(/\n+$/, "").split("\n");
const fields: string[][] = [];
for (const line of lines) {
if (/^[ \t]/.test(line) && fields.length > 0) fields[fields.length - 1].push(line);
else if (line.includes(":")) fields.push([line]);
// A line without a colon outside a field isn't a header; drop it.
}
const names = new Set<string>();
const kept = fields.filter((f) => {
const name = f[0].slice(0, f[0].indexOf(":")).trim();
if (MIME_FIELDS.test(name)) return false;
names.add(name.toLowerCase());
return true;
});
return { text: kept.map((f) => f.join(CRLF)).join(CRLF), names };
}
interface Part {
headers: string[];
body: string;
}
function leaf(contentType: string, data: Uint8Array, extra: string[] = []): Part {
return { headers: [`Content-Type: ${contentType}`, "Content-Transfer-Encoding: base64", ...extra], body: base64Lines(data) };
}
function textPart(text: string, subtype: "plain" | "html"): Part {
return leaf(`text/${subtype}; charset=utf-8`, encoder.encode(text.replace(/\r?\n/g, CRLF)));
}
/** A message id from the PST as `<id>`, or undefined: no line breaks or spaces can get into the header. */
function msgId(value: string | undefined): string | undefined {
const id = (value ?? "").replace(/[\r\n]/g, "").trim().replace(/^<|>$/g, "");
return /^[^<>\s]+$/.test(id) ? `<${id}>` : undefined;
}
/** A Content-ID without anything that could end the header or the angle brackets. */
function cid(value: string): string {
return value.replace(/[<>\s]/g, "");
}
export interface MimeOptions {
/** Makes boundaries reproducible (tests). */
boundarySeed?: string;
}
export function buildMime(mail: PstMailData, options: MimeOptions = {}): Uint8Array {
const seed = options.boundarySeed ?? Math.random().toString(36).slice(2, 12);
let n = 0;
const boundary = () => `=_bulwark_${seed}_${n++}`;
const multipart = (subtype: string, parts: Part[]): Part => {
const b = boundary();
return {
headers: [`Content-Type: multipart/${subtype};${CRLF} boundary="${b}"`],
body: parts.map((p) => `--${b}${CRLF}${p.headers.join(CRLF)}${CRLF}${CRLF}${p.body}${CRLF}`).join("") + `--${b}--`,
};
};
const html = mail.html?.trim() ? mail.html : undefined;
const text = mail.text ?? "";
const rtfOnly = !html && !text.trim() && !!mail.rtf;
let body: Part = html && text.trim() ? multipart("alternative", [textPart(text, "plain"), textPart(html, "html")])
: html ? textPart(html, "html")
: textPart(text, "plain");
const isInline = (a: PstAttachmentData) => !!a.contentId && !!html && html.includes(`cid:${a.contentId}`);
const inline = mail.attachments.filter(isInline);
const attached = mail.attachments.filter((a) => !isInline(a));
if (inline.length > 0) {
body = multipart("related", [body, ...inline.map((a) => leaf(
`${a.mimeType}${fileParams(a.filename)}`,
a.data,
[`Content-ID: <${cid(a.contentId!)}>`, `Content-Disposition: inline${fileParams(a.filename).replace(/; name/g, "; filename")}`],
))]);
}
const extra: Part[] = attached.map((a) => leaf(
`${a.mimeType}${fileParams(a.filename)}`,
a.data,
[`Content-Disposition: attachment${fileParams(a.filename).replace(/; name/g, "; filename")}`],
));
if (rtfOnly) {
extra.push(leaf('application/rtf; name="message.rtf"', encoder.encode(mail.rtf!), ['Content-Disposition: attachment; filename="message.rtf"']));
}
if (extra.length > 0) body = multipart("mixed", [body, ...extra]);
const header: string[] = [];
const kept = mail.headers ? keptHeaders(mail.headers) : { text: "", names: new Set<string>() };
if (kept.text) header.push(kept.text);
const has = (name: string) => kept.names.has(name);
if (!has("date") && mail.date) header.push(`Date: ${rfc5322Date(mail.date)}`);
if (!has("from") && mail.from) header.push(`From: ${formatAddress(mail.from)}`);
if (!has("to") && mail.to.length > 0) header.push(`To: ${addressList(mail.to)}`);
if (!has("cc") && mail.cc.length > 0) header.push(`Cc: ${addressList(mail.cc)}`);
// Only sent mail and drafts have Bcc recipients; a received header block never names them.
if (!mail.headers && mail.bcc.length > 0) header.push(`Bcc: ${addressList(mail.bcc)}`);
if (!has("subject")) header.push(`Subject: ${encodeHeaderText(mail.subject)}`);
const messageId = msgId(mail.messageId);
const inReplyTo = msgId(mail.inReplyTo);
if (!has("message-id") && messageId) header.push(`Message-ID: ${messageId}`);
if (!has("in-reply-to") && inReplyTo) header.push(`In-Reply-To: ${inReplyTo}`);
if (!has("importance") && mail.importance === 2) header.push("Importance: high");
if (!has("importance") && mail.importance === 0) header.push("Importance: low");
header.push("MIME-Version: 1.0");
header.push(...body.headers);
return encoder.encode(`${header.join(CRLF)}${CRLF}${CRLF}${body.body}${CRLF}`);
}
+4
View File
@@ -0,0 +1,4 @@
// Injected into the PST worker: pst-extractor uses Node's global Buffer.
import { Buffer } from "buffer";
export { Buffer };
+67
View File
@@ -0,0 +1,67 @@
/**
* `fs` for pst-extractor inside the PST worker: random access into a File
* without loading it whole. pst-extractor only calls openSync, readSync and
* closeSync. Reads go through FileReaderSync (workers only) in 64 KB pages
* with a small cache, since the library reads a few bytes at a time.
*
* Register the file first: `registerPstFile(name, file)`.
*/
declare const FileReaderSync: { new (): { readAsArrayBuffer(blob: Blob): ArrayBuffer } };
const PAGE = 64 * 1024;
const MAX_PAGES = 128; // 8 MB
const files = new Map<string, Blob>();
const open = new Map<number, { file: Blob; pages: Map<number, Uint8Array> }>();
let nextFd = 3;
export function registerPstFile(name: string, file: Blob): void {
files.set(name, file);
}
export function openSync(name: string): number {
const file = files.get(name);
if (!file) throw new Error(`no such file: ${name}`);
const fd = nextFd++;
open.set(fd, { file, pages: new Map() });
return fd;
}
function page(entry: { file: Blob; pages: Map<number, Uint8Array> }, index: number): Uint8Array {
let bytes = entry.pages.get(index);
if (bytes) {
// Most recently used goes last.
entry.pages.delete(index);
entry.pages.set(index, bytes);
return bytes;
}
const start = index * PAGE;
bytes = new Uint8Array(new FileReaderSync().readAsArrayBuffer(entry.file.slice(start, Math.min(entry.file.size, start + PAGE))));
entry.pages.set(index, bytes);
if (entry.pages.size > MAX_PAGES) entry.pages.delete(entry.pages.keys().next().value as number);
return bytes;
}
export function readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number): number {
const entry = open.get(fd);
if (!entry) throw new Error("bad file descriptor");
const end = Math.min(entry.file.size, position + length);
let at = position;
while (at < end) {
const index = Math.floor(at / PAGE);
const bytes = page(entry, index);
const from = at - index * PAGE;
const take = Math.min(bytes.length - from, end - at);
if (take <= 0) break;
buffer.set(bytes.subarray(from, from + take), offset + (at - position));
at += take;
}
return at - position;
}
export function closeSync(fd: number): void {
open.delete(fd);
}
export default { openSync, readSync, closeSync };
+16
View File
@@ -0,0 +1,16 @@
// `iconv-lite` for pst-extractor, which only calls decode(bytes, codepage).
// TextDecoder knows the WHATWG labels (windows-125x, iso-8859-x, shift_jis,
// gbk, big5, ...) and keeps iconv-lite's 490 KB of tables out of the worker.
export function decode(data: Uint8Array, codepage: string): string {
let label = String(codepage || "utf-8").toLowerCase();
if (label === "utf8") label = "utf-8";
let decoder: TextDecoder;
try {
decoder = new TextDecoder(label);
} catch {
decoder = new TextDecoder("windows-1252");
}
return decoder.decode(data);
}
export default { decode };
+9
View File
@@ -0,0 +1,9 @@
// `zlib` for pst-extractor: it only inflates the compressed blocks of OST 2013 files.
import { decompressSync } from "fflate";
import { Buffer } from "buffer";
export function unzipSync(data: Uint8Array): Buffer {
return Buffer.from(decompressSync(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)));
}
export default { unzipSync };
+80
View File
@@ -0,0 +1,80 @@
/**
* The PST worker (built by scripts/build-pst-worker.mjs into public/). It
* opens an Outlook .pst/.ost File without loading it whole, walks its mail
* 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, skipAlso, maxMessageBytes }
* { type: "pull", count }
* worker → page { type: "opened", total }
* { type: "item", item, fraction } (item.mime is transferred)
* { type: "done" } | { type: "error", code, message }
*/
import { PSTFile } from "pst-extractor";
import { Buffer } from "buffer";
import { registerPstFile } from "./shims/fs";
import { walkPst, type PstItem } from "./items";
interface WorkerScope {
onmessage: ((event: MessageEvent) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
}
const scope = globalThis as unknown as WorkerScope;
// Attachment bytes are read into a Buffer over our own memory (extract.ts).
(globalThis as { Buffer?: unknown }).Buffer ??= Buffer;
let items: Generator<PstItem> | null = null;
let total = 0;
let done = 0;
let pst: { close(): void } | null = null;
function errorCode(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/encrypt/i.test(message)) return "encrypted";
if (/not a pst|invalid|unrecognised|unrecognized|magic|!BDN/i.test(message)) return "not_pst";
return "unreadable";
}
scope.onmessage = (event: MessageEvent) => {
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, skipAlso: new Set(data.skipAlso ?? []), maxMessageBytes: data.maxMessageBytes ?? Number.POSITIVE_INFINITY });
items = walk.items;
total = walk.total;
scope.postMessage({ type: "opened", total });
return;
}
if (data.type === "pull" && items) {
for (let n = 0; n < (data.count ?? 1); n++) {
const next = items.next();
if (next.done) {
items = null;
pst?.close();
scope.postMessage({ type: "done" });
return;
}
done++;
const item = next.value;
const fraction = total > 0 ? Math.min(1, done / total) : 0;
if (item.kind === "message") {
const buffer = item.mime.buffer.byteLength === item.mime.byteLength ? item.mime.buffer : item.mime.slice().buffer;
scope.postMessage({ type: "item", item: { ...item, mime: buffer }, fraction }, [buffer as ArrayBuffer]);
} else {
scope.postMessage({ type: "item", item, fraction });
}
}
return;
}
if (data.type === "close") {
items = null;
pst?.close();
pst = null;
}
} catch (error) {
scope.postMessage({ type: "error", code: errorCode(error), message: error instanceof Error ? error.message : String(error) });
}
};
+321
View File
@@ -0,0 +1,321 @@
import { dateFromHeaderBlock } from "@/lib/eml-import";
import { MboxSplitter, envelopeDate, readHeaderBlock, type MboxMessage } from "./mbox";
import { mboxState, withoutStateHeaders } from "./mbox-status";
import { EXPORT_ENVELOPE_SENDER } from "@/lib/mail-export/mbox-writer";
import { listZipEntries, readZipEntry, streamZipEntry } from "./zip-reader";
/**
* What the import reads from each file: messages with their folder, state and
* date, in a stable order (the index the checkpoint counts), read piece by
* piece so a large file never has to fit in memory.
*/
export type ImportFormat = "eml" | "zip" | "mbox" | "pst";
export type SkipReason =
/** Larger than the server takes (maxSizeUpload). */
| "too_large"
/** Deleted in Thunderbird, still in the file until the folder is compacted. */
| "deleted"
/** PST contacts and distribution lists. */
| "contact"
/** PST appointments, meeting requests and tasks. */
| "calendar"
/** Other PST items (notes, journal entries) and files in a ZIP that aren't mail. */
| "other"
/** Messages attached to a PST message, which pst-extractor can't read. */
| "embedded"
/** PST items after a broken one in the same folder, which pst-extractor can't reach. */
| "unreadable"
/** Imported by an earlier run of the same import. */
| "resumed";
export interface ImportMessage {
kind: "message";
index: number;
/** Folder path inside the file; [] is the top. */
folder: string[];
data: Blob;
size: number;
keywords: Record<string, true>;
receivedAt?: string;
/** Names the message in the summary when it fails: its subject or entry name. */
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 {
kind: "skipped";
index: number;
reason: SkipReason;
label?: string;
}
export type ImportItem = ImportMessage | ImportSkip;
export interface SourceOptions {
/** Messages larger than this are skipped as too_large without reading them in. */
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. */
onTotal?: (count: number) => void;
signal?: AbortSignal;
}
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";
const PST_MAGIC = [0x21, 0x42, 0x44, 0x4e]; // "!BDN"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04];
const ZIP_EMPTY = [0x50, 0x4b, 0x05, 0x06];
function startsWith(head: Uint8Array, magic: number[]): boolean {
return magic.every((b, i) => head[i] === b);
}
/** The format of a file, from its first bytes; the name only breaks ties. */
export async function sniffFormat(file: Blob & { name?: string }): Promise<ImportFormat> {
const head = new Uint8Array(await file.slice(0, 512).arrayBuffer());
if (startsWith(head, PST_MAGIC)) return "pst";
if (startsWith(head, ZIP_MAGIC) || startsWith(head, ZIP_EMPTY)) return "zip";
// mbox opens with an envelope line, maybe after a byte order mark or empty lines.
let at = head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
while (head[at] === 0x0d || head[at] === 0x0a) at++;
const first = String.fromCharCode(...head.subarray(at, at + 5));
if (first === "From ") return "mbox";
if (/\.(mbox|mbx)$/i.test(file.name ?? "")) return "mbox";
return "eml";
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("Import cancelled", "AbortError");
}
function emlLabel(name: string): string {
return name.split(/[\\/]/).pop() || name;
}
const encodedWord = /=\?([^?]+)\?([bq])\?([^?]*)\?=/gi;
/** The Subject of a header block, for the summary; encoded words decoded when possible. */
export function subjectOf(headerBlock: string): string {
const m = /^subject:[ \t]*(.*(?:\r?\n[ \t].*)*)/im.exec(headerBlock);
if (!m) return "";
const raw = m[1].replace(/\r?\n[ \t]+/g, " ").trim();
return raw.replace(/\?=\s+=\?/g, "?==?").replace(encodedWord, (all, charset: string, enc: string, text: string) => {
try {
const bytes = enc.toLowerCase() === "b"
? Uint8Array.from(atob(text), (c) => c.charCodeAt(0))
: Uint8Array.from(text.replace(/_/g, " ").replace(/=([0-9a-f]{2})/gi, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
return new TextDecoder(charset).decode(bytes);
} catch {
return all;
}
}).slice(0, 200);
}
export async function* emlSource(file: File, options: SourceOptions): ItemSource {
options.onTotal?.(1);
if (isResumed(options, 0)) {
yield { kind: "skipped", index: 0, reason: "resumed" };
options.onProgress?.(1);
return;
}
if (file.size > options.maxMessageBytes) {
yield { kind: "skipped", index: 0, reason: "too_large", label: file.name };
options.onProgress?.(1);
return;
}
const head = new TextDecoder("latin1").decode(await file.slice(0, 64 * 1024).arrayBuffer());
yield {
kind: "message",
index: 0,
folder: [],
data: file.slice(0, file.size, MIME),
size: file.size,
// Thunderbird writes X-Mozilla-Status into the .eml files it saves; none means read, as before.
keywords: mboxState(head.split(/\r?\n\r?\n/, 1)[0]).keywords,
receivedAt: dateFromHeaderBlock(head),
label: emlLabel(file.name),
};
options.onProgress?.(1);
}
/** A date between 1980 and a day from now, else undefined. */
function plausible(date: string | undefined, now = Date.now()): string | undefined {
if (!date) return undefined;
const t = Date.parse(date);
return t >= Date.UTC(1980, 0, 1) && t - now <= 24 * 60 * 60 * 1000 ? date : undefined;
}
/**
* When an mbox message arrived. The envelope line is trusted only where it
* is the arrival time for sure: Bulwark's own export (receivedAt) and lines
* with a time zone (Google Takeout). Elsewhere it may be when the message
* was copied into the folder (Thunderbird's `From - <date>`, local time),
* so the Date header comes first and the envelope line is the fallback.
*/
export function arrivalTime(fromLine: string, headers: string): string | undefined {
const envelope = plausible(envelopeDate(fromLine));
const trusted = fromLine.startsWith(`From ${EXPORT_ENVELOPE_SENDER} `) || /\s[+-]\d{4}\s+\d{4}\s*$/.test(fromLine);
return trusted ? envelope ?? dateFromHeaderBlock(headers) : dateFromHeaderBlock(headers) ?? envelope;
}
function mboxItem(message: MboxMessage, index: number, folder: string[]): ImportItem {
const label = message.tooLarge ? message.fromLine : "";
if (message.tooLarge) return { kind: "skipped", index, reason: "too_large", label };
const headers = readHeaderBlock(message.parts);
const state = mboxState(headers);
const subject = subjectOf(headers);
if (state.expunged) return { kind: "skipped", index, reason: "deleted", label: subject };
const parts = withoutStateHeaders(message.parts);
return {
kind: "message",
index,
folder,
data: new Blob(parts as BlobPart[], { type: MIME }),
size: parts.reduce((n, p) => n + p.length, 0),
keywords: state.keywords,
receivedAt: arrivalTime(message.fromLine, headers),
label: subject,
};
}
/** Splits chunks into messages; `startIndex` continues the numbering inside a ZIP. */
async function* mboxFromChunks(
chunks: AsyncIterable<Uint8Array>,
folder: string[],
options: SourceOptions,
startIndex: number,
onIndex: (next: number) => void,
/** Called with each message's offset in the stream, for a progress that moves message by message. */
onOffset?: (offset: number) => void,
): ItemSource {
const splitter = new MboxSplitter({ maxMessageBytes: options.maxMessageBytes });
let index = startIndex;
const emit = function* (messages: MboxMessage[]): Generator<ImportItem> {
for (const m of messages) {
onOffset?.(m.offset);
const i = index++;
yield isResumed(options, i) ? { kind: "skipped", index: i, reason: "resumed" } : mboxItem(m, i, folder);
}
};
for await (const chunk of chunks) {
throwIfAborted(options.signal);
yield* emit(splitter.push(chunk));
}
yield* emit(splitter.end());
onIndex(index);
}
async function* fileChunks(file: Blob, onProgress?: (fraction: number) => void): AsyncGenerator<Uint8Array> {
for (let at = 0; at < file.size; at += READ_CHUNK) {
const end = Math.min(file.size, at + READ_CHUNK);
yield new Uint8Array(await file.slice(at, end).arrayBuffer());
onProgress?.(end / Math.max(1, file.size));
}
}
export async function* mboxSource(file: File, options: SourceOptions): ItemSource {
// Reads run ahead in 1 MB pieces; the messages handed over say how far the import really is.
yield* mboxFromChunks(fileChunks(file), [], options, 0, () => {}, (offset) => options.onProgress?.(offset / Math.max(1, file.size)));
options.onProgress?.(1);
}
/** `Archive/2020.mbox` → ["Archive", "2020"]; `Inbox/a.eml` → ["Inbox"]. */
function zipFolder(name: string, isMbox: boolean): string[] {
const parts = name.split("/").filter((p) => p && p !== "." && p !== "..");
if (isMbox) {
const last = parts.pop() ?? "";
const folder = last.replace(/\.(mbox|mbx)$/i, "");
return folder ? [...parts, folder] : parts;
}
parts.pop();
return parts;
}
export async function* zipSource(file: File, options: SourceOptions): ItemSource {
const entries = (await listZipEntries(file)).filter((e) => !e.directory && !/(^|\/)(__MACOSX|\.DS_Store)/.test(e.name));
const isMbox = (name: string) => /\.(mbox|mbx)$/i.test(name);
const isEml = (name: string) => /\.eml$/i.test(name);
if (!entries.some((e) => isMbox(e.name))) options.onTotal?.(entries.filter((e) => isEml(e.name)).length);
const totalBytes = entries.reduce((n, e) => n + e.compressedSize, 0) || 1;
let doneBytes = 0;
let index = 0;
for (const entry of entries) {
throwIfAborted(options.signal);
if (entry.encrypted) {
const i = index++;
yield isResumed(options, i) ? { kind: "skipped", index: i, reason: "resumed" } : { kind: "skipped", index: i, reason: "other", label: emlLabel(entry.name) };
} else if (isMbox(entry.name)) {
const before = doneBytes;
const chunks = (async function* () {
let seen = 0;
for await (const chunk of streamZipEntry(file, entry)) {
seen += chunk.length;
options.onProgress?.((before + entry.compressedSize * Math.min(1, seen / Math.max(1, entry.size))) / totalBytes);
yield chunk;
}
})();
yield* mboxFromChunks(chunks, zipFolder(entry.name, true), options, index, (next) => { index = next; });
} else if (isEml(entry.name)) {
const i = index++;
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) };
} else {
let bytes: Uint8Array;
try {
bytes = await readZipEntry(file, entry);
} catch {
// Encrypted, or damaged: left out and named in the summary.
yield { kind: "skipped", index: i, reason: "other", label: emlLabel(entry.name) };
continue;
}
const head = new TextDecoder("latin1").decode(bytes.subarray(0, 64 * 1024));
yield {
kind: "message",
index: i,
folder: zipFolder(entry.name, false),
data: new Blob([bytes as BlobPart], { type: MIME }),
size: bytes.length,
keywords: mboxState(head.split(/\r?\n\r?\n/, 1)[0]).keywords,
receivedAt: dateFromHeaderBlock(head),
label: emlLabel(entry.name),
};
}
} else {
const i = index++;
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);
}
}
/** Whether the file has folders of its own, so the dialog offers to recreate them. */
export async function hasFolders(file: File, format: ImportFormat): Promise<boolean> {
if (format === "pst") return true;
if (format !== "zip") return false;
try {
return (await listZipEntries(file)).some((e) => e.name.includes("/") || /\.(mbox|mbx)$/i.test(e.name));
} catch {
return false;
}
}
+152
View File
@@ -0,0 +1,152 @@
import { Inflate, inflateSync } from "fflate";
/**
* Reads a ZIP through its central directory with `Blob.slice`, so a large
* archive is never loaded whole: entries are read one at a time, and a large
* entry (an mbox inside the archive) is inflated in chunks. Handles Zip64,
* entries with data descriptors, stored and deflated entries.
*/
export interface ZipEntry {
/** Path inside the archive, `/` separated. */
name: string;
directory: boolean;
/** Encrypted (general purpose flag 0): can't be read. */
encrypted: boolean;
method: number;
compressedSize: number;
size: number;
localOffset: number;
}
const EOCD = 0x06054b50;
const ZIP64_LOCATOR = 0x07064b50;
const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
const MAX32 = 0xffffffff;
export class ZipFormatError extends Error {
constructor(message: string) {
super(message);
this.name = "ZipFormatError";
}
}
async function bytes(file: Blob, start: number, end: number): Promise<Uint8Array> {
return new Uint8Array(await file.slice(start, end).arrayBuffer());
}
function u64(view: DataView, at: number): number {
return view.getUint32(at, true) + view.getUint32(at + 4, true) * 0x1_0000_0000;
}
const utf8 = new TextDecoder("utf-8");
// IBM 437 isn't a WHATWG label; old archivers' non-UTF-8 names are rare in mail exports.
const legacy = new TextDecoder("windows-1252");
export async function listZipEntries(file: Blob): Promise<ZipEntry[]> {
const tailStart = Math.max(0, file.size - (0xffff + 22));
const tail = await bytes(file, tailStart, file.size);
const tv = new DataView(tail.buffer, tail.byteOffset, tail.byteLength);
let eocd = -1;
for (let i = tail.length - 22; i >= 0; i--) {
if (tv.getUint32(i, true) === EOCD) { eocd = i; break; }
}
if (eocd < 0) throw new ZipFormatError("No end of central directory");
let count = tv.getUint16(eocd + 10, true);
let cdSize = tv.getUint32(eocd + 12, true);
let cdOffset = tv.getUint32(eocd + 16, true);
if (count === 0xffff || cdSize === MAX32 || cdOffset === MAX32) {
const locatorAt = eocd - 20;
if (locatorAt >= 0 && tv.getUint32(locatorAt, true) === ZIP64_LOCATOR) {
const zip64At = u64(tv, locatorAt + 8);
const rec = await bytes(file, zip64At, zip64At + 56);
const rv = new DataView(rec.buffer, rec.byteOffset, rec.byteLength);
if (rv.getUint32(0, true) !== ZIP64_EOCD) throw new ZipFormatError("Bad Zip64 end record");
count = u64(rv, 32);
cdSize = u64(rv, 40);
cdOffset = u64(rv, 48);
}
}
const cd = await bytes(file, cdOffset, cdOffset + cdSize);
const v = new DataView(cd.buffer, cd.byteOffset, cd.byteLength);
const entries: ZipEntry[] = [];
let at = 0;
for (let n = 0; n < count && at + 46 <= cd.length; n++) {
if (v.getUint32(at, true) !== CENTRAL) throw new ZipFormatError("Bad central directory entry");
const flags = v.getUint16(at + 8, true);
const method = v.getUint16(at + 10, true);
let compressedSize = v.getUint32(at + 20, true);
let size = v.getUint32(at + 24, true);
const nameLength = v.getUint16(at + 28, true);
const extraLength = v.getUint16(at + 30, true);
const commentLength = v.getUint16(at + 32, true);
let localOffset = v.getUint32(at + 42, true);
const nameBytes = cd.subarray(at + 46, at + 46 + nameLength);
const name = (flags & 0x0800 ? utf8 : legacy).decode(nameBytes).replace(/\\/g, "/");
// Zip64 extra field: the values that were 0xFFFFFFFF, in this order.
let extra = at + 46 + nameLength;
const extraEnd = extra + extraLength;
while (extra + 4 <= extraEnd) {
const id = v.getUint16(extra, true);
const len = v.getUint16(extra + 2, true);
if (id === 0x0001) {
let p = extra + 4;
if (size === MAX32) { size = u64(v, p); p += 8; }
if (compressedSize === MAX32) { compressedSize = u64(v, p); p += 8; }
if (localOffset === MAX32) { localOffset = u64(v, p); p += 8; }
}
extra += 4 + len;
}
entries.push({ name, directory: name.endsWith("/"), encrypted: (flags & 0x0001) !== 0, method, compressedSize, size, localOffset });
at += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
async function dataStart(file: Blob, entry: ZipEntry): Promise<number> {
const header = await bytes(file, entry.localOffset, entry.localOffset + 30);
const v = new DataView(header.buffer, header.byteOffset, header.byteLength);
if (header.length < 30 || v.getUint32(0, true) !== LOCAL) throw new ZipFormatError("Bad local header");
return entry.localOffset + 30 + v.getUint16(26, true) + v.getUint16(28, true);
}
function assertMethod(entry: ZipEntry): void {
if (entry.encrypted) throw new ZipFormatError("Encrypted entry");
if (entry.method !== 0 && entry.method !== 8) throw new ZipFormatError(`Unsupported compression ${entry.method}`);
}
/** The whole entry, for small entries (a message). */
export async function readZipEntry(file: Blob, entry: ZipEntry): Promise<Uint8Array> {
assertMethod(entry);
const start = await dataStart(file, entry);
const raw = await bytes(file, start, start + entry.compressedSize);
if (entry.method === 0) return raw;
// No fixed output buffer: a wrong declared size must fail, not cut the message short.
const data = inflateSync(raw);
if (data.length !== entry.size) throw new ZipFormatError("Entry size doesn't match");
return data;
}
/** The entry in chunks of about `chunkBytes`, inflated as they come (an mbox inside the archive). */
export async function* streamZipEntry(file: Blob, entry: ZipEntry, chunkBytes = 1 << 20): AsyncGenerator<Uint8Array> {
assertMethod(entry);
const start = await dataStart(file, entry);
const end = start + entry.compressedSize;
if (entry.method === 0) {
for (let at = start; at < end; at += chunkBytes) yield await bytes(file, at, Math.min(end, at + chunkBytes));
return;
}
let out: Uint8Array[] = [];
const inflate = new Inflate((chunk) => { out.push(chunk); });
for (let at = start; at < end; at += chunkBytes) {
const next = Math.min(end, at + chunkBytes);
inflate.push(await bytes(file, at, next), next >= end);
const ready = out;
out = [];
for (const chunk of ready) yield chunk;
}
if (start === end) inflate.push(new Uint8Array(0), true);
for (const chunk of out) yield chunk;
}
+6
View File
@@ -1,4 +1,6 @@
import { fileStorage } from '@/lib/plugin-storage';
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
import { sweepPrivateExports } from '@/lib/mail-export/sinks';
/**
* What a full sign-out removes from this browser beyond the stores that
@@ -75,6 +77,10 @@ export function purgeSignedOutData(): void {
} catch {
/* storage unavailable */
}
// Where mail imports stopped: file names and folders of the accounts.
void clearAllCheckpoints().catch(() => {});
// Mail exports written to the browser's private files and not deleted yet.
void sweepPrivateExports({ force: true }).catch(() => {});
for (const cleanup of signedOutCleanups) {
try {
cleanup();
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "عرض في الوضع الفاتح",
"export_email": "تصدير كملف ‎.eml",
"forward_as_attachment": "إعادة التوجيه كمرفق",
"import_email": "استيراد ملف ‎.eml أو ‎.zip…",
"keyboard_shortcuts": "اختصارات لوحة المفاتيح",
"email_source": "مصدر الرسالة",
"draft_banner": "هذه الرسالة مسودة",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "تعذّر استيراد بعض القوالب",
"templates_import_empty": "لم يتم العثور على قوالب في الملف",
"export_email_error": "فشل تصدير الرسالة",
"import_email_success": "تم استيراد الرسالة بنجاح",
"import_email_error": "فشل استيراد الرسالة",
"rule_created": "تم إنشاء القاعدة",
"rule_merged": "تمت الإضافة إلى القاعدة «{name}»",
"rule_already_covered": "تغطيها القاعدة «{name}» بالفعل",
@@ -3585,7 +3582,6 @@
"new_folder": "مجلد جديد…",
"rename": "إعادة تسمية…",
"share": "مشاركة…",
"import_email": "استيراد ملف ‎.eml أو ‎.zip…",
"empty_folder": "إفراغ المجلد",
"empty_folder_generic": "إفراغ المجلد",
"delete_folder": "حذف المجلد",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "البحث غير متاح في العرض الموحّد"
},
"mail_import": {
"menu_import": "استيراد البريد…",
"settings_label": "استيراد البريد",
"settings_description": "استورد البريد من ملفات ‎.eml أو ZIP أو mbox أو ملفات Outlook ‏‎.pst و‎.ost.",
"settings_button": "استيراد…",
"title": "استيراد البريد",
"description": "اختر ملفات ‎.eml أو ‎.zip أو ‎.mbox أو ملفات Outlook ‏‎.pst و‎.ost.",
"choose_files": "اختيار ملفات…",
"chosen_files": "الملفات المختارة",
"remove_file": "إزالة {name}",
"format_eml": "رسالة",
"format_zip": "أرشيف ZIP",
"format_mbox": "ملف mbox",
"format_pst": "ملف بيانات Outlook",
"target_label": "الاستيراد إلى",
"target_top": "المستوى الأعلى",
"keep_folders": "إعادة إنشاء مجلدات الملف",
"keep_folders_hint": "يُعاد استخدام المجلدات التي تحمل الاسم نفسه.",
"resume": "المتابعة من حيث توقف آخر استيراد لهذه الملفات (الرسائل المنجزة: {count})",
"quota_note": "كثير من الخوادم لا تقبل من المستخدم الواحد إلا نحو 1000 رسالة أو 50 ميغابايت في الساعة. يتوقف الاستيراد الأكبر مؤقتًا ثم يتابع وحده.",
"start": "استيراد",
"cancel": "إلغاء",
"running_title": "جارٍ استيراد البريد",
"progress_label": "تقدّم الاستيراد",
"status_imported": "الرسائل المستوردة: {count}",
"reading_file": "جارٍ قراءة {name}",
"paused": "متوقف مؤقتًا حتى {time}. يقبل الخادم {files} عملية رفع أو {size} في الساعة على الأكثر. سيتابع الاستيراد وحده.",
"paused_generic": "متوقف مؤقتًا حتى {time}. بلغ الخادم حد الرفع. سيتابع الاستيراد وحده.",
"background": "المتابعة في الخلفية",
"cancel_import": "إلغاء الاستيراد",
"chip": "جارٍ استيراد البريد: {percent}٪",
"chip_paused": "الاستيراد متوقف مؤقتًا حتى {time}",
"done_title": "انتهى الاستيراد",
"cancelled_title": "أُلغي الاستيراد",
"failed_title": "توقف الاستيراد",
"summary_imported": "الرسائل المستوردة: {count}",
"summary_folders": "المجلدات المنشأة: {count}",
"summary_skipped": "لم يُستورد:",
"skip_too_large": "أكبر مما يقبله الخادم: {count}",
"skip_deleted": "محذوفة في Thunderbird: {count}",
"skip_embedded": "رسائل مرفقة برسائل أخرى: {count}",
"skip_contact": "جهات الاتصال: {count}",
"skip_calendar": "عناصر التقويم: {count}",
"skip_other": "عناصر أخرى: {count}",
"summary_failed": "تعذّر الاستيراد: {count}",
"failure_invalid": "ليست رسالة صالحة",
"untitled": "(بلا موضوع)",
"cancelled_hint": "للمتابعة لاحقًا، استورد الملفات نفسها إلى المجلد نفسه مرة أخرى.",
"close": "إغلاق",
"pst_note": "لا يُستورد من ملفات Outlook إلا البريد. تُعدّ جهات الاتصال وعناصر التقويم والمهام وتُترك.",
"pst_error_encrypted": "يستخدم ملف Outlook هذا تشفيرًا عاليًا لا يستطيع Bulwark قراءته. صدّر البريد في Outlook إلى ملف ‎.pst جديد بلا تشفير.",
"pst_error_not_pst": "هذا الملف ليس ملف بيانات Outlook يستطيع Bulwark قراءته.",
"pst_error_unreadable": "تعذّر على Bulwark قراءة ملف Outlook هذا.",
"pst_error_worker": "لم يُحمَّل قارئ ملفات Outlook. أعد تحميل الصفحة وحاول مرة أخرى.",
"error_over_quota": "صندوق البريد ممتلئ، لذا توقف الاستيراد. أفرغ بعض المساحة واستورد الملفات نفسها إلى المجلد نفسه مرة أخرى للمتابعة.",
"error_forbidden": "لا يمكنك إضافة بريد إلى هذا المجلد، لذا توقف الاستيراد.",
"error_failures": "فشلت رسائل كثيرة متتالية، لذا توقف الاستيراد. استورد الملفات نفسها إلى المجلد نفسه لاحقًا للمتابعة.",
"error_network": "فشل الاتصال بالخادم مرارًا، لذا توقف الاستيراد. استورد الملفات نفسها إلى المجلد نفسه مرة أخرى للمتابعة.",
"error_busy": "يجري استيراد هذه الملفات في علامة تبويب أخرى بالفعل.",
"retry_hint": "استورد الملفات نفسها إلى المجلد نفسه مرة أخرى لإعادة محاولة الرسائل التي فشلت.",
"skip_unreadable": "غير مقروءة في ملف Outlook: {count}",
"keep_folders_top_hint": "في المستوى الأعلى تذهب علبة الوارد والعناصر المرسلة والعناصر المحذوفة وما شابهها إلى مجلداتك.",
"toast_done": "انتهى الاستيراد. المستورد: {imported}، الفاشل: {failed}.",
"toast_stopped": "توقف الاستيراد.",
"toast_show": "عرض",
"section_title": "ملفات البريد",
"section_description": "استورد البريد من ملفات، أو احفظ بريدك كملفات."
},
"mail_export": {
"menu_export": "تصدير المجلد…",
"settings_label": "تصدير كل البريد",
"settings_description": "احفظ كل مجلدات هذا الحساب في ملف ZIP واحد، كملفات mbox أو ‎.eml.",
"settings_button": "تصدير…",
"title_folder": "تصدير «{name}»",
"title_account": "تصدير كل البريد",
"description_folder": "احفظ رسائل هذا المجلد في ملف.",
"description_account": "احفظ كل مجلدات هذا الحساب في ملف ZIP واحد.",
"format_label": "التنسيق",
"format_mbox_folder": "ملف mbox",
"format_eml_folder": "ملف ZIP من ملفات ‎.eml",
"format_mbox_account": "ملف ZIP بملف mbox لكل مجلد",
"format_eml_account": "ملف ZIP من ملفات ‎.eml، مجلد لكل مجلد بريد",
"hint_mbox": "تفتح برامج Thunderbird وApple Mail ومعظم برامج البريد ملفات mbox.",
"hint_eml": "ملف لكل رسالة، يفتحه أي برنامج بريد.",
"save_picker": "تختار أنت مكان حفظ الملف.",
"save_download": "يُنزَّل الملف عند انتهاء التصدير.",
"save_memory": "يحتفظ هذا المتصفح بالتصدير كله في الذاكرة، لذا لا يحفظ أكثر من 200 ميغابايت. للمزيد استخدم Chrome أو Edge، أو صدّر مجلدًا مجلدًا.",
"start": "تصدير",
"cancel": "إلغاء",
"running_title": "جارٍ تصدير البريد",
"progress_label": "تقدّم التصدير",
"status_exported": "الرسائل المصدّرة: {count} من {total}",
"paused": "متوقف مؤقتًا حتى {time}: طلب الخادم استراحة. سيتابع التصدير وحده.",
"background": "المتابعة في الخلفية",
"cancel_export": "إلغاء التصدير",
"chip": "جارٍ تصدير البريد: {percent}٪",
"chip_paused": "التصدير متوقف مؤقتًا حتى {time}",
"done_title": "انتهى التصدير",
"cancelled_title": "أُلغي التصدير",
"failed_title": "توقف التصدير",
"summary_exported": "الرسائل المصدّرة: {count}",
"summary_file": "حُفظ باسم {name} ({size}).",
"summary_failed": "تعذّر التصدير: {count}",
"download_again": "التنزيل مرة أخرى",
"too_large": "التصدير أكبر مما يستطيع هذا المتصفح حفظه في الذاكرة. استخدم Chrome أو Edge، أو صدّر مجلدًا مجلدًا.",
"close": "إغلاق",
"untitled": "(بلا موضوع)",
"toast_done": "انتهى التصدير: {name}",
"toast_stopped": "توقف التصدير.",
"toast_show": "عرض"
},
"recover_deleted": {
"tab": "استعادة العناصر المحذوفة",
"title": "استعادة العناصر المحذوفة",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Veure en mode clar",
"export_email": "Exporta com a .eml",
"forward_as_attachment": "Reenvia com a fitxer adjunt",
"import_email": "Importa .eml o .zip…",
"keyboard_shortcuts": "Dreceres de teclat",
"email_source": "Codi font del correu",
"draft_banner": "Aquest missatge és un esborrany",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Algunes plantilles no s'han pogut importar",
"templates_import_empty": "No s'ha trobat cap plantilla al fitxer",
"export_email_error": "No s'ha pogut exportar el correu",
"import_email_success": "Correu importat correctament",
"import_email_error": "No s'ha pogut importar el correu",
"rule_created": "S'ha creat la regla",
"rule_merged": "S'ha afegit a la regla «{name}»",
"rule_already_covered": "Ja ho cobreix la regla «{name}»",
@@ -3585,7 +3582,6 @@
"new_folder": "Carpeta nova…",
"rename": "Canvia el nom…",
"share": "Comparteix…",
"import_email": "Importa .eml o .zip…",
"empty_folder": "Buida la carpeta",
"empty_folder_generic": "Buida la carpeta",
"delete_folder": "Suprimeix la carpeta",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "La cerca no està disponible a la vista unificada"
},
"mail_import": {
"menu_import": "Importa correu…",
"settings_label": "Importa correu",
"settings_description": "Porta correu des de fitxers .eml, ZIP, mbox o Outlook .pst i .ost.",
"settings_button": "Importa…",
"title": "Importa correu",
"description": "Trieu fitxers .eml, .zip, .mbox o Outlook .pst i .ost.",
"choose_files": "Tria fitxers…",
"chosen_files": "Fitxers triats",
"remove_file": "Treu {name}",
"format_eml": "Missatge",
"format_zip": "Arxiu ZIP",
"format_mbox": "Fitxer mbox",
"format_pst": "Fitxer de dades d'Outlook",
"target_label": "Importa a",
"target_top": "Nivell superior",
"keep_folders": "Recrea les carpetes del fitxer",
"keep_folders_hint": "Les carpetes amb el mateix nom es tornen a fer servir.",
"resume": "Continua on es va aturar l'última importació d'aquests fitxers (missatges ja fets: {count})",
"quota_note": "Molts servidors només accepten uns 1000 missatges o 50 MB per hora de cada usuari. Una importació més gran fa una pausa i després continua sola.",
"start": "Importa",
"cancel": "Cancel·la",
"running_title": "S'està important el correu",
"progress_label": "Progrés de la importació",
"status_imported": "Missatges importats: {count}",
"reading_file": "S'està llegint {name}",
"paused": "En pausa fins a les {time}. El servidor accepta com a màxim {files} pujades o {size} per hora. La importació continua sola.",
"paused_generic": "En pausa fins a les {time}. S'ha arribat al límit de pujada del servidor. La importació continua sola.",
"background": "Continua en segon pla",
"cancel_import": "Cancel·la la importació",
"chip": "S'està important el correu: {percent} %",
"chip_paused": "Importació en pausa fins a les {time}",
"done_title": "Importació acabada",
"cancelled_title": "Importació cancel·lada",
"failed_title": "Importació aturada",
"summary_imported": "Missatges importats: {count}",
"summary_folders": "Carpetes creades: {count}",
"summary_skipped": "No importats:",
"skip_too_large": "Més grans del que accepta el servidor: {count}",
"skip_deleted": "Suprimits a Thunderbird: {count}",
"skip_embedded": "Missatges adjunts a altres missatges: {count}",
"skip_contact": "Contactes: {count}",
"skip_calendar": "Elements del calendari: {count}",
"skip_other": "Altres elements: {count}",
"summary_failed": "No s'han pogut importar: {count}",
"failure_invalid": "no és un missatge vàlid",
"untitled": "(sense assumpte)",
"cancelled_hint": "Per continuar més tard, torneu a importar els mateixos fitxers a la mateixa carpeta.",
"close": "Tanca",
"pst_note": "Dels fitxers d'Outlook només s'importa el correu. Els contactes, elements del calendari i tasques es compten i es deixen fora.",
"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.",
"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.",
"skip_unreadable": "Il·legibles al fitxer d'Outlook: {count}",
"keep_folders_top_hint": "Al nivell superior, Safata d'entrada, Elements enviats, Elements suprimits i similars van a les vostres.",
"toast_done": "Importació acabada. Importats: {imported}, fallats: {failed}.",
"toast_stopped": "La importació s'ha aturat.",
"toast_show": "Mostra",
"section_title": "Fitxers de correu",
"section_description": "Porteu correu des de fitxers, o deseu el vostre correu com a fitxers."
},
"mail_export": {
"menu_export": "Exporta la carpeta…",
"settings_label": "Exporta tot el correu",
"settings_description": "Desa totes les carpetes d'aquest compte en un fitxer ZIP, com a fitxers mbox o .eml.",
"settings_button": "Exporta…",
"title_folder": "Exporta «{name}»",
"title_account": "Exporta tot el correu",
"description_folder": "Desa els missatges d'aquesta carpeta en un fitxer.",
"description_account": "Desa totes les carpetes d'aquest compte en un fitxer ZIP.",
"format_label": "Format",
"format_mbox_folder": "Fitxer mbox",
"format_eml_folder": "ZIP de fitxers .eml",
"format_mbox_account": "ZIP amb un fitxer mbox per carpeta",
"format_eml_account": "ZIP de fitxers .eml, una carpeta per cada carpeta de correu",
"hint_mbox": "Thunderbird, Apple Mail i la majoria de programes de correu obren fitxers mbox.",
"hint_eml": "Un fitxer per missatge, que qualsevol programa de correu pot obrir.",
"save_picker": "Trieu on es desa el fitxer.",
"save_download": "El fitxer es baixa quan acaba l'exportació.",
"save_memory": "Aquest navegador guarda tota l'exportació a la memòria i només pot desar fins a 200 MB. Per a més, feu servir Chrome o Edge, o exporteu carpeta per carpeta.",
"start": "Exporta",
"cancel": "Cancel·la",
"running_title": "S'està exportant el correu",
"progress_label": "Progrés de l'exportació",
"status_exported": "Missatges exportats: {count} de {total}",
"paused": "En pausa fins a les {time}: el servidor ha demanat una pausa. L'exportació continua sola.",
"background": "Continua en segon pla",
"cancel_export": "Cancel·la l'exportació",
"chip": "S'està exportant el correu: {percent} %",
"chip_paused": "Exportació en pausa fins a les {time}",
"done_title": "Exportació acabada",
"cancelled_title": "Exportació cancel·lada",
"failed_title": "Exportació aturada",
"summary_exported": "Missatges exportats: {count}",
"summary_file": "Desat com a {name} ({size}).",
"summary_failed": "No s'han pogut exportar: {count}",
"download_again": "Torna a baixar",
"too_large": "L'exportació és més gran del que aquest navegador pot guardar a la memòria. Feu servir Chrome o Edge, o exporteu carpeta per carpeta.",
"close": "Tanca",
"untitled": "(sense assumpte)",
"toast_done": "Exportació acabada: {name}",
"toast_stopped": "L'exportació s'ha aturat.",
"toast_show": "Mostra"
},
"recover_deleted": {
"tab": "Recupera elements suprimits",
"title": "Recupera elements suprimits",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Zobrazit v světlém režimu",
"export_email": "Exportovat jako .eml",
"forward_as_attachment": "Přeposlat jako přílohu",
"import_email": "Importovat .eml nebo .zip…",
"keyboard_shortcuts": "Klávesové zkratky",
"email_source": "Zdrojový kód zprávy",
"draft_banner": "Tato zpráva je koncept",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Některé šablony se nepodařilo importovat",
"templates_import_empty": "V souboru nebyly nalezeny žádné šablony",
"export_email_error": "Export zprávy selhal",
"import_email_success": "Zpráva byla úspěšně importována",
"import_email_error": "Import zprávy selhal",
"rule_created": "Pravidlo vytvořeno",
"rule_merged": "Přidáno do pravidla „{name}“",
"rule_already_covered": "Už to pokrývá pravidlo „{name}“",
@@ -3585,7 +3582,6 @@
"new_folder": "Nová složka…",
"rename": "Přejmenovat…",
"share": "Sdílet…",
"import_email": "Importovat .eml nebo .zip…",
"empty_folder": "Vyprázdnit složku",
"empty_folder_generic": "Vyprázdnit složku",
"delete_folder": "Smazat složku",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici"
},
"mail_import": {
"menu_import": "Importovat poštu…",
"settings_label": "Importovat poštu",
"settings_description": "Přeneste poštu ze souborů .eml, ZIP, mbox nebo Outlook .pst a .ost.",
"settings_button": "Importovat…",
"title": "Importovat poštu",
"description": "Vyberte soubory .eml, .zip, .mbox nebo Outlook .pst a .ost.",
"choose_files": "Vybrat soubory…",
"chosen_files": "Vybrané soubory",
"remove_file": "Odebrat {name}",
"format_eml": "Zpráva",
"format_zip": "Archiv ZIP",
"format_mbox": "Soubor mbox",
"format_pst": "Datový soubor Outlooku",
"target_label": "Importovat do",
"target_top": "Nejvyšší úroveň",
"keep_folders": "Znovu vytvořit složky ze souboru",
"keep_folders_hint": "Složky se stejným názvem se použijí znovu.",
"resume": "Pokračovat tam, kde se poslední import těchto souborů zastavil (již hotové zprávy: {count})",
"quota_note": "Mnoho serverů přijímá od jednoho uživatele jen asi 1000 zpráv nebo 50 MB za hodinu. Větší import se pozastaví a pak pokračuje sám.",
"start": "Importovat",
"cancel": "Zrušit",
"running_title": "Importuje se pošta",
"progress_label": "Průběh importu",
"status_imported": "Importované zprávy: {count}",
"reading_file": "Čte se {name}",
"paused": "Pozastaveno do {time}. Server přijímá nejvýše {files} nahrání nebo {size} za hodinu. Import pokračuje sám.",
"paused_generic": "Pozastaveno do {time}. Byl dosažen limit nahrávání serveru. Import pokračuje sám.",
"background": "Běžet na pozadí",
"cancel_import": "Zrušit import",
"chip": "Importuje se pošta: {percent} %",
"chip_paused": "Import pozastaven do {time}",
"done_title": "Import dokončen",
"cancelled_title": "Import zrušen",
"failed_title": "Import zastaven",
"summary_imported": "Importované zprávy: {count}",
"summary_folders": "Vytvořené složky: {count}",
"summary_skipped": "Neimportováno:",
"skip_too_large": "Větší, než server přijme: {count}",
"skip_deleted": "Smazané v Thunderbirdu: {count}",
"skip_embedded": "Zprávy připojené k jiným zprávám: {count}",
"skip_contact": "Kontakty: {count}",
"skip_calendar": "Položky kalendáře: {count}",
"skip_other": "Jiné položky: {count}",
"summary_failed": "Nelze importovat: {count}",
"failure_invalid": "není platná zpráva",
"untitled": "(bez předmětu)",
"cancelled_hint": "Chcete-li pokračovat později, importujte stejné soubory znovu do stejné složky.",
"close": "Zavřít",
"pst_note": "Z outlookových souborů se přenáší jen pošta. Kontakty, položky kalendáře a úkoly se spočítají a vynechají.",
"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.",
"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.",
"skip_unreadable": "V souboru Outlooku nečitelné: {count}",
"keep_folders_top_hint": "Na nejvyšší úrovni skončí Doručená pošta, Odeslaná pošta, Odstraněná pošta a podobné ve vašich vlastních.",
"toast_done": "Import dokončen. Importováno: {imported}, selhalo: {failed}.",
"toast_stopped": "Import se zastavil.",
"toast_show": "Zobrazit",
"section_title": "Soubory s poštou",
"section_description": "Přeneste poštu ze souborů, nebo uložte svou poštu jako soubory."
},
"mail_export": {
"menu_export": "Exportovat složku…",
"settings_label": "Exportovat všechnu poštu",
"settings_description": "Uložit všechny složky tohoto účtu do jednoho souboru ZIP jako soubory mbox nebo .eml.",
"settings_button": "Exportovat…",
"title_folder": "Exportovat „{name}“",
"title_account": "Exportovat všechnu poštu",
"description_folder": "Uložit zprávy v této složce jako soubor.",
"description_account": "Uložit všechny složky tohoto účtu do jednoho souboru ZIP.",
"format_label": "Formát",
"format_mbox_folder": "Soubor mbox",
"format_eml_folder": "ZIP se soubory .eml",
"format_mbox_account": "ZIP s jedním souborem mbox na složku",
"format_eml_account": "ZIP se soubory .eml, jedna složka na poštovní složku",
"hint_mbox": "Thunderbird, Apple Mail a většina poštovních programů otevře soubory mbox.",
"hint_eml": "Jeden soubor na zprávu, který otevře každý poštovní program.",
"save_picker": "Sami vyberete, kam se soubor uloží.",
"save_download": "Soubor se stáhne, až bude export hotový.",
"save_memory": "Tento prohlížeč drží celý export v paměti, takže uloží nejvýše 200 MB. Pro větší export použijte Chrome nebo Edge, nebo exportujte složku po složce.",
"start": "Exportovat",
"cancel": "Zrušit",
"running_title": "Exportuje se pošta",
"progress_label": "Průběh exportu",
"status_exported": "Exportované zprávy: {count} z {total}",
"paused": "Pozastaveno do {time}: server požádal o přestávku. Export pokračuje sám.",
"background": "Běžet na pozadí",
"cancel_export": "Zrušit export",
"chip": "Exportuje se pošta: {percent} %",
"chip_paused": "Export pozastaven do {time}",
"done_title": "Export dokončen",
"cancelled_title": "Export zrušen",
"failed_title": "Export zastaven",
"summary_exported": "Exportované zprávy: {count}",
"summary_file": "Uloženo jako {name} ({size}).",
"summary_failed": "Nelze exportovat: {count}",
"download_again": "Stáhnout znovu",
"too_large": "Export je větší, než tento prohlížeč udrží v paměti. Použijte Chrome nebo Edge, nebo exportujte složku po složce.",
"close": "Zavřít",
"untitled": "(bez předmětu)",
"toast_done": "Export dokončen: {name}",
"toast_stopped": "Export se zastavil.",
"toast_show": "Zobrazit"
},
"recover_deleted": {
"tab": "Obnovit smazané položky",
"title": "Obnovit smazané položky",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Vis i lys tilstand",
"export_email": "Eksportér som .eml",
"forward_as_attachment": "Videresend som vedhæftet fil",
"import_email": "Importér .eml eller .zip…",
"keyboard_shortcuts": "Tastaturgenveje",
"email_source": "E-mail-kilde",
"draft_banner": "Denne besked er en kladde",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Nogle skabeloner kunne ikke importeres",
"templates_import_empty": "Ingen skabeloner fundet i filen",
"export_email_error": "Kunne ikke eksportere e-mail",
"import_email_success": "E-mail importeret succesfuldt",
"import_email_error": "Kunne ikke importere e-mail",
"rule_created": "Regel oprettet",
"rule_merged": "Føjet til reglen “{name}”",
"rule_already_covered": "Er allerede dækket af reglen “{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "Ny mappe…",
"rename": "Omdøb…",
"share": "Del…",
"import_email": "Importér .eml eller .zip…",
"empty_folder": "Tøm mappe",
"empty_folder_generic": "Tøm mappe",
"delete_folder": "Slet mappe",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Søgning er ikke tilgængelig i den samlede visning"
},
"mail_import": {
"menu_import": "Importér mail…",
"settings_label": "Importér mail",
"settings_description": "Hent mail ind fra .eml-, ZIP-, mbox- eller Outlook-.pst- og -.ost-filer.",
"settings_button": "Importér…",
"title": "Importér mail",
"description": "Vælg .eml-, .zip-, .mbox- eller Outlook-.pst- og -.ost-filer.",
"choose_files": "Vælg filer…",
"chosen_files": "Valgte filer",
"remove_file": "Fjern {name}",
"format_eml": "Besked",
"format_zip": "ZIP-arkiv",
"format_mbox": "mbox-fil",
"format_pst": "Outlook-datafil",
"target_label": "Importér til",
"target_top": "Øverste niveau",
"keep_folders": "Genskab mapperne fra filen",
"keep_folders_hint": "Mapper med samme navn bruges igen.",
"resume": "Fortsæt, hvor den sidste import af disse filer stoppede (beskeder allerede klaret: {count})",
"quota_note": "Mange servere tager kun imod omkring 1000 beskeder eller 50 MB i timen fra én bruger. En større import holder pause og fortsætter derefter af sig selv.",
"start": "Importér",
"cancel": "Annullér",
"running_title": "Importerer mail",
"progress_label": "Importens forløb",
"status_imported": "Importerede beskeder: {count}",
"reading_file": "Læser {name}",
"paused": "Sat på pause til {time}. Serveren tager højst imod {files} uploads eller {size} i timen. Importen fortsætter af sig selv.",
"paused_generic": "Sat på pause til {time}. Serverens uploadgrænse er nået. Importen fortsætter af sig selv.",
"background": "Kør i baggrunden",
"cancel_import": "Annullér import",
"chip": "Importerer mail: {percent} %",
"chip_paused": "Import sat på pause til {time}",
"done_title": "Import færdig",
"cancelled_title": "Import annulleret",
"failed_title": "Import stoppet",
"summary_imported": "Importerede beskeder: {count}",
"summary_folders": "Oprettede mapper: {count}",
"summary_skipped": "Ikke importeret:",
"skip_too_large": "Større end serveren tillader: {count}",
"skip_deleted": "Slettet i Thunderbird: {count}",
"skip_embedded": "Beskeder vedhæftet andre beskeder: {count}",
"skip_contact": "Kontakter: {count}",
"skip_calendar": "Kalenderelementer: {count}",
"skip_other": "Andre elementer: {count}",
"summary_failed": "Kunne ikke importeres: {count}",
"failure_invalid": "ikke en gyldig besked",
"untitled": "(intet emne)",
"cancelled_hint": "Importér de samme filer til samme mappe igen for at fortsætte senere.",
"close": "Luk",
"pst_note": "Fra Outlook-filer kommer kun mail med. Kontakter, kalenderelementer og opgaver tælles og springes over.",
"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.",
"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.",
"skip_unreadable": "Ulæselige i Outlook-filen: {count}",
"keep_folders_top_hint": "På øverste niveau havner Indbakke, Sendt post, Slettet post og lignende i dine egne.",
"toast_done": "Import færdig. Importeret: {imported}, mislykkedes: {failed}.",
"toast_stopped": "Importen stoppede.",
"toast_show": "Vis",
"section_title": "Mailfiler",
"section_description": "Hent mail ind fra filer, eller gem din mail som filer."
},
"mail_export": {
"menu_export": "Eksportér mappe…",
"settings_label": "Eksportér al mail",
"settings_description": "Gem alle mapper i denne konto i én ZIP-fil som mbox- eller .eml-filer.",
"settings_button": "Eksportér…",
"title_folder": "Eksportér “{name}”",
"title_account": "Eksportér al mail",
"description_folder": "Gem beskederne i denne mappe som en fil.",
"description_account": "Gem alle mapper i denne konto i én ZIP-fil.",
"format_label": "Format",
"format_mbox_folder": "mbox-fil",
"format_eml_folder": "ZIP med .eml-filer",
"format_mbox_account": "ZIP med én mbox-fil pr. mappe",
"format_eml_account": "ZIP med .eml-filer, én mappe pr. mailmappe",
"hint_mbox": "Thunderbird, Apple Mail og de fleste mailprogrammer kan åbne mbox-filer.",
"hint_eml": "Én fil pr. besked, som alle mailprogrammer kan åbne.",
"save_picker": "Du vælger, hvor filen gemmes.",
"save_download": "Filen downloades, når eksporten er færdig.",
"save_memory": "Denne browser holder hele eksporten i hukommelsen og kan derfor højst gemme 200 MB. Brug Chrome eller Edge til mere, eller eksportér mappe for mappe.",
"start": "Eksportér",
"cancel": "Annullér",
"running_title": "Eksporterer mail",
"progress_label": "Eksportens forløb",
"status_exported": "Eksporterede beskeder: {count} af {total}",
"paused": "Sat på pause til {time}: serveren bad om en pause. Eksporten fortsætter af sig selv.",
"background": "Kør i baggrunden",
"cancel_export": "Annullér eksport",
"chip": "Eksporterer mail: {percent} %",
"chip_paused": "Eksport sat på pause til {time}",
"done_title": "Eksport færdig",
"cancelled_title": "Eksport annulleret",
"failed_title": "Eksport stoppet",
"summary_exported": "Eksporterede beskeder: {count}",
"summary_file": "Gemt som {name} ({size}).",
"summary_failed": "Kunne ikke eksporteres: {count}",
"download_again": "Download igen",
"too_large": "Eksporten er større, end denne browser kan holde i hukommelsen. Brug Chrome eller Edge, eller eksportér mappe for mappe.",
"close": "Luk",
"untitled": "(intet emne)",
"toast_done": "Eksport færdig: {name}",
"toast_stopped": "Eksporten stoppede.",
"toast_show": "Vis"
},
"recover_deleted": {
"tab": "Gendan slettede elementer",
"title": "Gendan slettede elementer",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Im hellen Modus anzeigen",
"export_email": "Als .eml exportieren",
"forward_as_attachment": "Als Anhang weiterleiten",
"import_email": ".eml oder .zip importieren…",
"keyboard_shortcuts": "Tastaturkürzel",
"email_source": "E-Mail-Quelltext",
"draft_banner": "Diese Nachricht ist ein Entwurf",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Einige Vorlagen konnten nicht importiert werden",
"templates_import_empty": "Keine Vorlagen in der Datei gefunden",
"export_email_error": "Fehler beim Exportieren der E-Mail",
"import_email_success": "E-Mail erfolgreich importiert",
"import_email_error": "Fehler beim Importieren der E-Mail",
"rule_created": "Regel erstellt",
"rule_merged": "Zur Regel „{name}“ hinzugefügt",
"rule_already_covered": "Bereits durch die Regel „{name}“ abgedeckt",
@@ -3585,7 +3582,6 @@
"new_folder": "Neuer Ordner…",
"rename": "Umbenennen…",
"share": "Freigeben…",
"import_email": ".eml oder .zip importieren…",
"empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
},
"mail_import": {
"menu_import": "E-Mails importieren…",
"settings_label": "E-Mails importieren",
"settings_description": "E-Mails aus .eml-, ZIP-, mbox- oder Outlook-.pst- und -.ost-Dateien übernehmen.",
"settings_button": "Importieren…",
"title": "E-Mails importieren",
"description": "Wählen Sie .eml-, .zip-, .mbox- oder Outlook-.pst- und -.ost-Dateien.",
"choose_files": "Dateien auswählen…",
"chosen_files": "Ausgewählte Dateien",
"remove_file": "{name} entfernen",
"format_eml": "Nachricht",
"format_zip": "ZIP-Archiv",
"format_mbox": "mbox-Datei",
"format_pst": "Outlook-Datendatei",
"target_label": "Importieren in",
"target_top": "Oberste Ebene",
"keep_folders": "Ordner aus der Datei übernehmen",
"keep_folders_hint": "Ordner mit gleichem Namen werden wiederverwendet.",
"resume": "Dort weitermachen, wo der letzte Import dieser Dateien aufgehört hat (bereits erledigte Nachrichten: {count})",
"quota_note": "Viele Server nehmen pro Benutzer nur etwa 1000 Nachrichten oder 50 MB pro Stunde an. Ein größerer Import pausiert und läuft dann von selbst weiter.",
"start": "Importieren",
"cancel": "Abbrechen",
"running_title": "E-Mails werden importiert",
"progress_label": "Fortschritt des Imports",
"status_imported": "Importierte Nachrichten: {count}",
"reading_file": "{name} wird gelesen",
"paused": "Pausiert bis {time}. Der Server nimmt höchstens {files} Uploads oder {size} pro Stunde an. Der Import läuft von selbst weiter.",
"paused_generic": "Pausiert bis {time}. Das Upload-Limit des Servers ist erreicht. Der Import läuft von selbst weiter.",
"background": "Im Hintergrund weiterlaufen lassen",
"cancel_import": "Import abbrechen",
"chip": "E-Mails werden importiert: {percent} %",
"chip_paused": "Import pausiert bis {time}",
"done_title": "Import abgeschlossen",
"cancelled_title": "Import abgebrochen",
"failed_title": "Import angehalten",
"summary_imported": "Importierte Nachrichten: {count}",
"summary_folders": "Angelegte Ordner: {count}",
"summary_skipped": "Nicht importiert:",
"skip_too_large": "Größer als vom Server erlaubt: {count}",
"skip_deleted": "In Thunderbird gelöscht: {count}",
"skip_embedded": "An andere Nachrichten angehängte Nachrichten: {count}",
"skip_contact": "Kontakte: {count}",
"skip_calendar": "Kalendereinträge: {count}",
"skip_other": "Andere Elemente: {count}",
"summary_failed": "Nicht importierbar: {count}",
"failure_invalid": "keine gültige Nachricht",
"untitled": "(kein Betreff)",
"cancelled_hint": "Um später weiterzumachen, importieren Sie dieselben Dateien erneut in denselben Ordner.",
"close": "Schließen",
"pst_note": "Aus Outlook-Dateien werden nur E-Mails übernommen. Kontakte, Kalendereinträge und Aufgaben werden gezählt und ausgelassen.",
"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.",
"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.",
"skip_unreadable": "In der Outlook-Datei nicht lesbar: {count}",
"keep_folders_top_hint": "Auf oberster Ebene landen Posteingang, Gesendete Elemente, Gelöschte Elemente und Ähnliche in Ihren eigenen Ordnern.",
"toast_done": "Import abgeschlossen. Importiert: {imported}, fehlgeschlagen: {failed}.",
"toast_stopped": "Der Import wurde angehalten.",
"toast_show": "Anzeigen",
"section_title": "E-Mail-Dateien",
"section_description": "E-Mails aus Dateien übernehmen oder Ihre E-Mails als Dateien sichern."
},
"mail_export": {
"menu_export": "Ordner exportieren…",
"settings_label": "Alle E-Mails exportieren",
"settings_description": "Alle Ordner dieses Kontos in einer ZIP-Datei sichern, als mbox- oder .eml-Dateien.",
"settings_button": "Exportieren…",
"title_folder": "„{name}“ exportieren",
"title_account": "Alle E-Mails exportieren",
"description_folder": "Die Nachrichten in diesem Ordner als Datei sichern.",
"description_account": "Alle Ordner dieses Kontos in einer ZIP-Datei sichern.",
"format_label": "Format",
"format_mbox_folder": "mbox-Datei",
"format_eml_folder": "ZIP mit .eml-Dateien",
"format_mbox_account": "ZIP mit einer mbox-Datei pro Ordner",
"format_eml_account": "ZIP mit .eml-Dateien, ein Ordner pro E-Mail-Ordner",
"hint_mbox": "Thunderbird, Apple Mail und die meisten E-Mail-Programme öffnen mbox-Dateien.",
"hint_eml": "Eine Datei pro Nachricht, die jedes E-Mail-Programm öffnen kann.",
"save_picker": "Sie wählen, wo die Datei gespeichert wird.",
"save_download": "Die Datei wird heruntergeladen, wenn der Export fertig ist.",
"save_memory": "Dieser Browser hält den ganzen Export im Speicher und kann daher höchstens 200 MB sichern. Für mehr nutzen Sie Chrome oder Edge, oder exportieren Sie Ordner für Ordner.",
"start": "Exportieren",
"cancel": "Abbrechen",
"running_title": "E-Mails werden exportiert",
"progress_label": "Fortschritt des Exports",
"status_exported": "Exportierte Nachrichten: {count} von {total}",
"paused": "Pausiert bis {time}: Der Server hat um eine Pause gebeten. Der Export läuft von selbst weiter.",
"background": "Im Hintergrund weiterlaufen lassen",
"cancel_export": "Export abbrechen",
"chip": "E-Mails werden exportiert: {percent} %",
"chip_paused": "Export pausiert bis {time}",
"done_title": "Export abgeschlossen",
"cancelled_title": "Export abgebrochen",
"failed_title": "Export angehalten",
"summary_exported": "Exportierte Nachrichten: {count}",
"summary_file": "Gespeichert als {name} ({size}).",
"summary_failed": "Nicht exportierbar: {count}",
"download_again": "Erneut herunterladen",
"too_large": "Der Export ist größer, als dieser Browser im Speicher halten kann. Nutzen Sie Chrome oder Edge, oder exportieren Sie Ordner für Ordner.",
"close": "Schließen",
"untitled": "(kein Betreff)",
"toast_done": "Export abgeschlossen: {name}",
"toast_stopped": "Der Export wurde angehalten.",
"toast_show": "Anzeigen"
},
"recover_deleted": {
"tab": "Gelöschte E-Mails",
"title": "Gelöschte Elemente wiederherstellen",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "View in light mode",
"export_email": "Export as .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Import .eml or .zip…",
"keyboard_shortcuts": "Keyboard shortcuts",
"email_source": "Email source",
"draft_banner": "This message is a draft",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Some templates could not be imported",
"templates_import_empty": "No templates found in the file",
"export_email_error": "Failed to export email",
"import_email_success": "Email imported successfully",
"import_email_error": "Failed to import email",
"rule_created": "Rule created",
"rule_merged": "Added to rule “{name}”",
"rule_already_covered": "Already covered by rule “{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "New folder…",
"rename": "Rename…",
"share": "Share…",
"import_email": "Import .eml or .zip…",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Search is not available in the unified view"
},
"mail_import": {
"menu_import": "Import mail…",
"settings_label": "Import mail",
"settings_description": "Bring in mail from .eml, ZIP, mbox or Outlook .pst and .ost files.",
"settings_button": "Import…",
"title": "Import mail",
"description": "Choose .eml, .zip, .mbox or Outlook .pst and .ost files.",
"choose_files": "Choose files…",
"chosen_files": "Chosen files",
"remove_file": "Remove {name}",
"format_eml": "Message",
"format_zip": "ZIP archive",
"format_mbox": "mbox file",
"format_pst": "Outlook data file",
"target_label": "Import into",
"target_top": "Top level",
"keep_folders": "Recreate the folders from the file",
"keep_folders_hint": "Folders with the same name are used again.",
"resume": "Continue where the last import of these files stopped (messages already done: {count})",
"quota_note": "Many servers take only about 1000 messages or 50 MB an hour from one user. A larger import pauses and then goes on by itself.",
"start": "Import",
"cancel": "Cancel",
"running_title": "Importing mail",
"progress_label": "Import progress",
"status_imported": "Messages imported: {count}",
"reading_file": "Reading {name}",
"paused": "Paused until {time}. The server takes at most {files} uploads or {size} an hour. The import goes on by itself.",
"paused_generic": "Paused until {time}. The server's upload limit was reached. The import goes on by itself.",
"background": "Run in background",
"cancel_import": "Cancel import",
"chip": "Importing mail: {percent}%",
"chip_paused": "Import paused until {time}",
"done_title": "Import finished",
"cancelled_title": "Import cancelled",
"failed_title": "Import stopped",
"summary_imported": "Messages imported: {count}",
"summary_folders": "Folders created: {count}",
"summary_skipped": "Not imported:",
"skip_too_large": "Larger than the server accepts: {count}",
"skip_deleted": "Deleted in Thunderbird: {count}",
"skip_embedded": "Messages attached to other messages: {count}",
"skip_contact": "Contacts: {count}",
"skip_calendar": "Calendar items: {count}",
"skip_other": "Other items: {count}",
"summary_failed": "Couldn't be imported: {count}",
"failure_invalid": "not a valid message",
"untitled": "(no subject)",
"cancelled_hint": "To go on later, import the same files into the same folder again.",
"close": "Close",
"pst_note": "Only mail comes in from Outlook files. Contacts, calendar items and tasks are counted and left out.",
"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.",
"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.",
"skip_unreadable": "Unreadable in the Outlook file: {count}",
"keep_folders_top_hint": "At the top level, Inbox, Sent Items, Deleted Items and the like go into your own.",
"toast_done": "Import finished. Imported: {imported}, failed: {failed}.",
"toast_stopped": "The import stopped.",
"toast_show": "Show",
"section_title": "Mail files",
"section_description": "Bring mail in from files, or save your mail as files."
},
"mail_export": {
"menu_export": "Export folder…",
"settings_label": "Export all mail",
"settings_description": "Save every folder of this account in one ZIP file, as mbox or .eml files.",
"settings_button": "Export…",
"title_folder": "Export “{name}”",
"title_account": "Export all mail",
"description_folder": "Save the messages in this folder as a file.",
"description_account": "Save every folder of this account in one ZIP file.",
"format_label": "Format",
"format_mbox_folder": "mbox file",
"format_eml_folder": "ZIP of .eml files",
"format_mbox_account": "ZIP with one mbox file per folder",
"format_eml_account": "ZIP of .eml files, one folder per mail folder",
"hint_mbox": "Thunderbird, Apple Mail and most mail programs open mbox files.",
"hint_eml": "One file per message, which any mail program can open.",
"save_picker": "You choose where to save the file.",
"save_download": "The file downloads when the export is done.",
"save_memory": "This browser keeps the whole export in memory, so it can save at most 200 MB. For more, use Chrome or Edge, or export folder by folder.",
"start": "Export",
"cancel": "Cancel",
"running_title": "Exporting mail",
"progress_label": "Export progress",
"status_exported": "Messages exported: {count} of {total}",
"paused": "Paused until {time}: the server asked for a break. The export goes on by itself.",
"background": "Run in background",
"cancel_export": "Cancel export",
"chip": "Exporting mail: {percent}%",
"chip_paused": "Export paused until {time}",
"done_title": "Export finished",
"cancelled_title": "Export cancelled",
"failed_title": "Export stopped",
"summary_exported": "Messages exported: {count}",
"summary_file": "Saved as {name} ({size}).",
"summary_failed": "Couldn't be exported: {count}",
"download_again": "Download again",
"too_large": "The export is larger than this browser can keep in memory. Use Chrome or Edge, or export folder by folder.",
"close": "Close",
"untitled": "(no subject)",
"toast_done": "Export finished: {name}",
"toast_stopped": "The export stopped.",
"toast_show": "Show"
},
"recover_deleted": {
"tab": "Recover deleted items",
"title": "Recover deleted items",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Ver en modo claro",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Reenviar como archivo adjunto",
"import_email": "Importar .eml o .zip…",
"keyboard_shortcuts": "Atajos de teclado",
"email_source": "Código Fuente del Correo",
"draft_banner": "Este mensaje es un borrador",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Algunas plantillas no se pudieron importar",
"templates_import_empty": "No se encontraron plantillas en el archivo",
"export_email_error": "Error al exportar el correo electrónico",
"import_email_success": "Correo electrónico importado correctamente",
"import_email_error": "Error al importar el correo electrónico",
"rule_created": "Regla creada",
"rule_merged": "Añadido a la regla «{name}»",
"rule_already_covered": "Ya lo cubre la regla «{name}»",
@@ -3585,7 +3582,6 @@
"new_folder": "Nueva carpeta…",
"rename": "Renombrar…",
"share": "Compartir…",
"import_email": "Importar .eml o .zip…",
"empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
},
"mail_import": {
"menu_import": "Importar correo…",
"settings_label": "Importar correo",
"settings_description": "Traer correo desde archivos .eml, ZIP, mbox u Outlook .pst y .ost.",
"settings_button": "Importar…",
"title": "Importar correo",
"description": "Elija archivos .eml, .zip, .mbox u Outlook .pst y .ost.",
"choose_files": "Elegir archivos…",
"chosen_files": "Archivos elegidos",
"remove_file": "Quitar {name}",
"format_eml": "Mensaje",
"format_zip": "Archivo ZIP",
"format_mbox": "Archivo mbox",
"format_pst": "Archivo de datos de Outlook",
"target_label": "Importar en",
"target_top": "Nivel superior",
"keep_folders": "Recrear las carpetas del archivo",
"keep_folders_hint": "Las carpetas con el mismo nombre se reutilizan.",
"resume": "Seguir donde se detuvo la última importación de estos archivos (mensajes ya procesados: {count})",
"quota_note": "Muchos servidores solo aceptan unos 1000 mensajes o 50 MB por hora de cada usuario. Una importación mayor se pausa y luego sigue sola.",
"start": "Importar",
"cancel": "Cancelar",
"running_title": "Importando correo",
"progress_label": "Progreso de la importación",
"status_imported": "Mensajes importados: {count}",
"reading_file": "Leyendo {name}",
"paused": "En pausa hasta las {time}. El servidor acepta como máximo {files} subidas o {size} por hora. La importación sigue sola.",
"paused_generic": "En pausa hasta las {time}. Se alcanzó el límite de subida del servidor. La importación sigue sola.",
"background": "Seguir en segundo plano",
"cancel_import": "Cancelar importación",
"chip": "Importando correo: {percent} %",
"chip_paused": "Importación en pausa hasta las {time}",
"done_title": "Importación terminada",
"cancelled_title": "Importación cancelada",
"failed_title": "Importación detenida",
"summary_imported": "Mensajes importados: {count}",
"summary_folders": "Carpetas creadas: {count}",
"summary_skipped": "No importados:",
"skip_too_large": "Más grandes de lo que acepta el servidor: {count}",
"skip_deleted": "Eliminados en Thunderbird: {count}",
"skip_embedded": "Mensajes adjuntos a otros mensajes: {count}",
"skip_contact": "Contactos: {count}",
"skip_calendar": "Elementos de calendario: {count}",
"skip_other": "Otros elementos: {count}",
"summary_failed": "No se pudieron importar: {count}",
"failure_invalid": "no es un mensaje válido",
"untitled": "(sin asunto)",
"cancelled_hint": "Para seguir más tarde, importe los mismos archivos en la misma carpeta otra vez.",
"close": "Cerrar",
"pst_note": "De los archivos de Outlook solo se importa el correo. Los contactos, elementos de calendario y tareas se cuentan y se dejan fuera.",
"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.",
"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.",
"skip_unreadable": "Ilegibles en el archivo de Outlook: {count}",
"keep_folders_top_hint": "En el nivel superior, Bandeja de entrada, Elementos enviados, Elementos eliminados y similares van a las suyas.",
"toast_done": "Importación terminada. Importados: {imported}, fallidos: {failed}.",
"toast_stopped": "La importación se detuvo.",
"toast_show": "Mostrar",
"section_title": "Archivos de correo",
"section_description": "Traiga correo desde archivos o guarde su correo como archivos."
},
"mail_export": {
"menu_export": "Exportar carpeta…",
"settings_label": "Exportar todo el correo",
"settings_description": "Guardar todas las carpetas de esta cuenta en un archivo ZIP, como archivos mbox o .eml.",
"settings_button": "Exportar…",
"title_folder": "Exportar «{name}»",
"title_account": "Exportar todo el correo",
"description_folder": "Guardar los mensajes de esta carpeta en un archivo.",
"description_account": "Guardar todas las carpetas de esta cuenta en un archivo ZIP.",
"format_label": "Formato",
"format_mbox_folder": "Archivo mbox",
"format_eml_folder": "ZIP de archivos .eml",
"format_mbox_account": "ZIP con un archivo mbox por carpeta",
"format_eml_account": "ZIP de archivos .eml, una carpeta por carpeta de correo",
"hint_mbox": "Thunderbird, Apple Mail y la mayoría de los programas de correo abren archivos mbox.",
"hint_eml": "Un archivo por mensaje, que cualquier programa de correo puede abrir.",
"save_picker": "Usted elige dónde guardar el archivo.",
"save_download": "El archivo se descarga cuando termina la exportación.",
"save_memory": "Este navegador guarda toda la exportación en memoria, así que solo puede guardar hasta 200 MB. Para más, use Chrome o Edge, o exporte carpeta por carpeta.",
"start": "Exportar",
"cancel": "Cancelar",
"running_title": "Exportando correo",
"progress_label": "Progreso de la exportación",
"status_exported": "Mensajes exportados: {count} de {total}",
"paused": "En pausa hasta las {time}: el servidor pidió una pausa. La exportación sigue sola.",
"background": "Seguir en segundo plano",
"cancel_export": "Cancelar exportación",
"chip": "Exportando correo: {percent} %",
"chip_paused": "Exportación en pausa hasta las {time}",
"done_title": "Exportación terminada",
"cancelled_title": "Exportación cancelada",
"failed_title": "Exportación detenida",
"summary_exported": "Mensajes exportados: {count}",
"summary_file": "Guardado como {name} ({size}).",
"summary_failed": "No se pudieron exportar: {count}",
"download_again": "Descargar de nuevo",
"too_large": "La exportación es mayor de lo que este navegador puede guardar en memoria. Use Chrome o Edge, o exporte carpeta por carpeta.",
"close": "Cerrar",
"untitled": "(sin asunto)",
"toast_done": "Exportación terminada: {name}",
"toast_stopped": "La exportación se detuvo.",
"toast_show": "Mostrar"
},
"recover_deleted": {
"tab": "Recuperar elementos eliminados",
"title": "Recuperar elementos eliminados",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Vaata heledas režiimis",
"export_email": "Ekspordi .eml failina",
"forward_as_attachment": "Saada edasi manusena",
"import_email": "Impordi .eml või .zip…",
"keyboard_shortcuts": "Kiirklahvid",
"email_source": "Kirja lähtekood",
"draft_banner": "See kiri on mustand",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Mõnda malli ei õnnestunud importida",
"templates_import_empty": "Failist ei leitud ühtegi malli",
"export_email_error": "E-kirja eksportimine ebaõnnestus",
"import_email_success": "E-kiri imporditi edukalt",
"import_email_error": "E-kirja importimine ebaõnnestus",
"rule_created": "Reegel loodud",
"rule_merged": "Lisatud reeglisse „{name}”",
"rule_already_covered": "Juba kaetud reegliga „{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "Uus kaust…",
"rename": "Nimeta ümber…",
"share": "Jaga…",
"import_email": "Impordi .eml või .zip…",
"empty_folder": "Tühjenda kaust",
"empty_folder_generic": "Tühjenda kaust",
"delete_folder": "Kustuta kaust",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Otsing pole ühendatud vaates saadaval"
},
"mail_import": {
"menu_import": "Impordi kirju…",
"settings_label": "Impordi kirju",
"settings_description": "Too kirjad sisse .eml-, ZIP-, mbox- või Outlooki .pst- ja .ost-failidest.",
"settings_button": "Impordi…",
"title": "Impordi kirju",
"description": "Valige .eml-, .zip-, .mbox- või Outlooki .pst- ja .ost-failid.",
"choose_files": "Vali failid…",
"chosen_files": "Valitud failid",
"remove_file": "Eemalda {name}",
"format_eml": "Kiri",
"format_zip": "ZIP-arhiiv",
"format_mbox": "mbox-fail",
"format_pst": "Outlooki andmefail",
"target_label": "Impordi kausta",
"target_top": "Ülemine tase",
"keep_folders": "Loo failist kaustad uuesti",
"keep_folders_hint": "Sama nimega kaustu kasutatakse uuesti.",
"resume": "Jätka sealt, kus nende failide viimane import peatus (juba tehtud kirju: {count})",
"quota_note": "Paljud serverid võtavad ühelt kasutajalt vastu ainult umbes 1000 kirja või 50 MB tunnis. Suurem import teeb pausi ja jätkab siis ise.",
"start": "Impordi",
"cancel": "Loobu",
"running_title": "Kirjade importimine",
"progress_label": "Impordi edenemine",
"status_imported": "Imporditud kirju: {count}",
"reading_file": "Loetakse faili {name}",
"paused": "Peatatud kuni {time}. Server võtab tunnis vastu kuni {files} üleslaadimist või {size}. Import jätkab ise.",
"paused_generic": "Peatatud kuni {time}. Serveri üleslaadimise piir on täis. Import jätkab ise.",
"background": "Jätka taustal",
"cancel_import": "Katkesta import",
"chip": "Kirjade importimine: {percent}%",
"chip_paused": "Import peatatud kuni {time}",
"done_title": "Import lõpetatud",
"cancelled_title": "Import katkestatud",
"failed_title": "Import peatus",
"summary_imported": "Imporditud kirju: {count}",
"summary_folders": "Loodud kaustu: {count}",
"summary_skipped": "Importimata:",
"skip_too_large": "Suuremad, kui server lubab: {count}",
"skip_deleted": "Thunderbirdis kustutatud: {count}",
"skip_embedded": "Teiste kirjade külge lisatud kirjad: {count}",
"skip_contact": "Kontaktid: {count}",
"skip_calendar": "Kalendrikirjed: {count}",
"skip_other": "Muud üksused: {count}",
"summary_failed": "Ei saanud importida: {count}",
"failure_invalid": "pole kehtiv kiri",
"untitled": "(teemata)",
"cancelled_hint": "Hiljem jätkamiseks importige samad failid uuesti samasse kausta.",
"close": "Sulge",
"pst_note": "Outlooki failidest tulevad ainult kirjad. Kontaktid, kalendrikirjed ja ülesanded loendatakse ja jäetakse välja.",
"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.",
"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.",
"skip_unreadable": "Outlooki failis loetamatud: {count}",
"keep_folders_top_hint": "Ülemisel tasemel lähevad Sisendkaust, Saadetud, Kustutatud ja muud sellised teie omadesse.",
"toast_done": "Import lõpetatud. Imporditud: {imported}, ebaõnnestunud: {failed}.",
"toast_stopped": "Import peatus.",
"toast_show": "Näita",
"section_title": "Kirjafailid",
"section_description": "Tooge kirjad failidest sisse või salvestage oma kirjad failidena."
},
"mail_export": {
"menu_export": "Ekspordi kaust…",
"settings_label": "Ekspordi kõik kirjad",
"settings_description": "Salvestage selle konto kõik kaustad ühte ZIP-faili mbox- või .eml-failidena.",
"settings_button": "Ekspordi…",
"title_folder": "Ekspordi „{name}“",
"title_account": "Ekspordi kõik kirjad",
"description_folder": "Salvestage selle kausta kirjad faili.",
"description_account": "Salvestage selle konto kõik kaustad ühte ZIP-faili.",
"format_label": "Vorming",
"format_mbox_folder": "mbox-fail",
"format_eml_folder": "ZIP .eml-failidega",
"format_mbox_account": "ZIP, iga kausta kohta üks mbox-fail",
"format_eml_account": "ZIP .eml-failidega, iga kirjakausta kohta üks kaust",
"hint_mbox": "Thunderbird, Apple Mail ja enamik e-posti programme avavad mbox-faile.",
"hint_eml": "Iga kirja kohta üks fail, mida avab iga e-posti programm.",
"save_picker": "Teie valite, kuhu fail salvestatakse.",
"save_download": "Fail laaditakse alla, kui eksport on valmis.",
"save_memory": "See brauser hoiab kogu eksporti mälus, seega saab salvestada kuni 200 MB. Suurema jaoks kasutage Chrome'i või Edge'i või eksportige kaust kaupa.",
"start": "Ekspordi",
"cancel": "Loobu",
"running_title": "Kirjade eksportimine",
"progress_label": "Ekspordi edenemine",
"status_exported": "Eksporditud kirju: {count} / {total}",
"paused": "Peatatud kuni {time}: server palus pausi. Eksport jätkab ise.",
"background": "Jätka taustal",
"cancel_export": "Katkesta eksport",
"chip": "Kirjade eksportimine: {percent}%",
"chip_paused": "Eksport peatatud kuni {time}",
"done_title": "Eksport lõpetatud",
"cancelled_title": "Eksport katkestatud",
"failed_title": "Eksport peatus",
"summary_exported": "Eksporditud kirju: {count}",
"summary_file": "Salvestatud nimega {name} ({size}).",
"summary_failed": "Ei saanud eksportida: {count}",
"download_again": "Laadi uuesti alla",
"too_large": "Eksport on suurem, kui see brauser mälus hoida suudab. Kasutage Chrome'i või Edge'i või eksportige kaust kaupa.",
"close": "Sulge",
"untitled": "(teemata)",
"toast_done": "Eksport lõpetatud: {name}",
"toast_stopped": "Eksport peatus.",
"toast_show": "Näita"
},
"recover_deleted": {
"tab": "Taasta kustutatud üksused",
"title": "Taasta kustutatud üksused",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "نمایش در حالت روشن",
"export_email": "خروجی .eml",
"forward_as_attachment": "ارسال به‌عنوان پیوست",
"import_email": "وارد کردن .eml یا .zip…",
"keyboard_shortcuts": "میانبرهای صفحه کلید",
"email_source": "منبع ایمیل",
"draft_banner": "این پیام یک پیش‌نویس است",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "برخی قالب‌ها وارد نشدند",
"templates_import_empty": "قالبی در فایل یافت نشد",
"export_email_error": "خروجی ایمیل ناموفق بود",
"import_email_success": "ایمیل با موفقیت وارد شد",
"import_email_error": "وارد کردن ایمیل ناموفق بود",
"rule_created": "قانون ایجاد شد",
"rule_merged": "به قانون «{name}» افزوده شد",
"rule_already_covered": "قانون «{name}» از قبل این را پوشش می‌دهد",
@@ -3585,7 +3582,6 @@
"new_folder": "پوشه جدید…",
"rename": "تغییر نام…",
"share": "اشتراک‌گذاری…",
"import_email": "وارد کردن .eml/.zip…",
"empty_folder": "خالی کردن پوشه",
"empty_folder_generic": "خالی کردن پوشه",
"delete_folder": "حذف پوشه",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "جستجو در نمای یکپارچه در دسترس نیست"
},
"mail_import": {
"menu_import": "درون‌ریزی نامه…",
"settings_label": "درون‌ریزی نامه",
"settings_description": "نامه‌ها را از فایل‌های ‎.eml، ZIP، mbox یا ‎.pst و ‎.ost اوت‌لوک بیاورید.",
"settings_button": "درون‌ریزی…",
"title": "درون‌ریزی نامه",
"description": "فایل‌های ‎.eml، ‎.zip، ‎.mbox یا ‎.pst و ‎.ost اوت‌لوک را انتخاب کنید.",
"choose_files": "انتخاب فایل‌ها…",
"chosen_files": "فایل‌های انتخاب‌شده",
"remove_file": "برداشتن {name}",
"format_eml": "پیام",
"format_zip": "بایگانی ZIP",
"format_mbox": "فایل mbox",
"format_pst": "فایل داده اوت‌لوک",
"target_label": "درون‌ریزی به",
"target_top": "بالاترین سطح",
"keep_folders": "ساختن دوباره پوشه‌های فایل",
"keep_folders_hint": "پوشه‌های هم‌نام دوباره به کار می‌روند.",
"resume": "ادامه از جایی که آخرین درون‌ریزی این فایل‌ها متوقف شد (پیام‌های انجام‌شده: {count})",
"quota_note": "بسیاری از سرورها از هر کاربر فقط حدود ۱۰۰۰ پیام یا ۵۰ مگابایت در ساعت می‌پذیرند. درون‌ریزی بزرگ‌تر مکث می‌کند و سپس خودش ادامه می‌دهد.",
"start": "درون‌ریزی",
"cancel": "لغو",
"running_title": "در حال درون‌ریزی نامه",
"progress_label": "پیشرفت درون‌ریزی",
"status_imported": "پیام‌های درون‌ریزی‌شده: {count}",
"reading_file": "در حال خواندن {name}",
"paused": "تا {time} متوقف شد. سرور حداکثر {files} بارگذاری یا {size} در ساعت می‌پذیرد. درون‌ریزی خودش ادامه می‌دهد.",
"paused_generic": "تا {time} متوقف شد. محدودیت بارگذاری سرور پر شده است. درون‌ریزی خودش ادامه می‌دهد.",
"background": "اجرا در پس‌زمینه",
"cancel_import": "لغو درون‌ریزی",
"chip": "در حال درون‌ریزی نامه: {percent}٪",
"chip_paused": "درون‌ریزی تا {time} متوقف است",
"done_title": "درون‌ریزی تمام شد",
"cancelled_title": "درون‌ریزی لغو شد",
"failed_title": "درون‌ریزی متوقف شد",
"summary_imported": "پیام‌های درون‌ریزی‌شده: {count}",
"summary_folders": "پوشه‌های ساخته‌شده: {count}",
"summary_skipped": "درون‌ریزی نشد:",
"skip_too_large": "بزرگ‌تر از حد مجاز سرور: {count}",
"skip_deleted": "حذف‌شده در Thunderbird: {count}",
"skip_embedded": "پیام‌های پیوست‌شده به پیام‌های دیگر: {count}",
"skip_contact": "مخاطبان: {count}",
"skip_calendar": "موارد تقویم: {count}",
"skip_other": "موارد دیگر: {count}",
"summary_failed": "درون‌ریزی نشد: {count}",
"failure_invalid": "پیام معتبری نیست",
"untitled": "(بدون موضوع)",
"cancelled_hint": "برای ادامه در زمانی دیگر، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"close": "بستن",
"pst_note": "از فایل‌های اوت‌لوک فقط نامه‌ها می‌آیند. مخاطبان، موارد تقویم و کارها شمرده و کنار گذاشته می‌شوند.",
"pst_error_encrypted": "این فایل اوت‌لوک از رمزگذاری قوی استفاده می‌کند که Bulwark نمی‌تواند بخواند. در اوت‌لوک، نامه‌ها را به یک فایل ‎.pst تازه بدون رمزگذاری صادر کنید.",
"pst_error_not_pst": "این فایل، فایل داده اوت‌لوکی نیست که Bulwark بتواند بخواند.",
"pst_error_unreadable": "Bulwark نتوانست این فایل اوت‌لوک را بخواند.",
"pst_error_worker": "خواننده فایل‌های اوت‌لوک بارگیری نشد. صفحه را دوباره بارگیری کنید و دوباره امتحان کنید.",
"error_over_quota": "صندوق پستی پر است، بنابراین درون‌ریزی متوقف شد. جا باز کنید و برای ادامه، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"error_forbidden": "اجازه ندارید به این پوشه نامه اضافه کنید، بنابراین درون‌ریزی متوقف شد.",
"error_failures": "پیام‌های زیادی پشت سر هم ناموفق بودند، بنابراین درون‌ریزی متوقف شد. برای ادامه، بعداً همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"error_network": "اتصال به سرور چند بار قطع شد، بنابراین درون‌ریزی متوقف شد. برای ادامه، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"error_busy": "این فایل‌ها هم‌اکنون در زبانه دیگری درون‌ریزی می‌شوند.",
"retry_hint": "برای امتحان دوباره پیام‌های ناموفق، همان فایل‌ها را دوباره در همان پوشه درون‌ریزی کنید.",
"skip_unreadable": "خوانا نیست در فایل اوت‌لوک: {count}",
"keep_folders_top_hint": "در بالاترین سطح، صندوق ورودی، موارد ارسال‌شده، موارد حذف‌شده و مانند آن‌ها به پوشه‌های خودتان می‌روند.",
"toast_done": "درون‌ریزی تمام شد. درون‌ریزی‌شده: {imported}، ناموفق: {failed}.",
"toast_stopped": "درون‌ریزی متوقف شد.",
"toast_show": "نمایش",
"section_title": "فایل‌های نامه",
"section_description": "نامه‌ها را از فایل‌ها بیاورید، یا نامه‌های خود را در فایل ذخیره کنید."
},
"mail_export": {
"menu_export": "برون‌بری پوشه…",
"settings_label": "برون‌بری همه نامه‌ها",
"settings_description": "همه پوشه‌های این حساب را در یک فایل ZIP، به شکل فایل‌های mbox یا ‎.eml ذخیره کنید.",
"settings_button": "برون‌بری…",
"title_folder": "برون‌بری «{name}»",
"title_account": "برون‌بری همه نامه‌ها",
"description_folder": "پیام‌های این پوشه را در یک فایل ذخیره کنید.",
"description_account": "همه پوشه‌های این حساب را در یک فایل ZIP ذخیره کنید.",
"format_label": "قالب",
"format_mbox_folder": "فایل mbox",
"format_eml_folder": "ZIP از فایل‌های ‎.eml",
"format_mbox_account": "ZIP با یک فایل mbox برای هر پوشه",
"format_eml_account": "ZIP از فایل‌های ‎.eml، یک پوشه برای هر پوشه نامه",
"hint_mbox": "Thunderbird، Apple Mail و بیشتر برنامه‌های نامه فایل‌های mbox را باز می‌کنند.",
"hint_eml": "یک فایل برای هر پیام که هر برنامه نامه‌ای باز می‌کند.",
"save_picker": "خودتان انتخاب می‌کنید فایل کجا ذخیره شود.",
"save_download": "فایل پس از پایان برون‌بری دانلود می‌شود.",
"save_memory": "این مرورگر کل برون‌بری را در حافظه نگه می‌دارد و بنابراین حداکثر ۲۰۰ مگابایت ذخیره می‌کند. برای بیشتر از Chrome یا Edge استفاده کنید یا پوشه به پوشه برون‌بری کنید.",
"start": "برون‌بری",
"cancel": "لغو",
"running_title": "در حال برون‌بری نامه",
"progress_label": "پیشرفت برون‌بری",
"status_exported": "پیام‌های برون‌بری‌شده: {count} از {total}",
"paused": "تا {time} متوقف شد: سرور درخواست مکث کرد. برون‌بری خودش ادامه می‌دهد.",
"background": "اجرا در پس‌زمینه",
"cancel_export": "لغو برون‌بری",
"chip": "در حال برون‌بری نامه: {percent}٪",
"chip_paused": "برون‌بری تا {time} متوقف است",
"done_title": "برون‌بری تمام شد",
"cancelled_title": "برون‌بری لغو شد",
"failed_title": "برون‌بری متوقف شد",
"summary_exported": "پیام‌های برون‌بری‌شده: {count}",
"summary_file": "با نام {name} ذخیره شد ({size}).",
"summary_failed": "برون‌بری نشد: {count}",
"download_again": "دانلود دوباره",
"too_large": "برون‌بری بزرگ‌تر از آن است که این مرورگر در حافظه نگه دارد. از Chrome یا Edge استفاده کنید یا پوشه به پوشه برون‌بری کنید.",
"close": "بستن",
"untitled": "(بدون موضوع)",
"toast_done": "برون‌بری تمام شد: {name}",
"toast_stopped": "برون‌بری متوقف شد.",
"toast_show": "نمایش"
},
"recover_deleted": {
"tab": "بازیابی موارد حذف‌شده",
"title": "بازیابی موارد حذف‌شده",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Afficher en mode clair",
"export_email": "Exporter en .eml",
"forward_as_attachment": "Transférer en pièce jointe",
"import_email": "Importer .eml ou .zip…",
"keyboard_shortcuts": "Raccourcis clavier",
"email_source": "Source de l'email",
"draft_banner": "Ce message est un brouillon",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Certains modèles n'ont pas pu être importés",
"templates_import_empty": "Aucun modèle trouvé dans le fichier",
"export_email_error": "Échec de l'exportation de l'e-mail",
"import_email_success": "E-mail importé avec succès",
"import_email_error": "Échec de l'importation de l'e-mail",
"rule_created": "Règle créée",
"rule_merged": "Ajouté à la règle « {name} »",
"rule_already_covered": "Déjà couvert par la règle « {name} »",
@@ -3585,7 +3582,6 @@
"new_folder": "Nouveau dossier…",
"rename": "Renommer…",
"share": "Partager…",
"import_email": "Importer .eml ou .zip…",
"empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
},
"mail_import": {
"menu_import": "Importer des e-mails…",
"settings_label": "Importer des e-mails",
"settings_description": "Importer des e-mails depuis des fichiers .eml, ZIP, mbox ou Outlook .pst et .ost.",
"settings_button": "Importer…",
"title": "Importer des e-mails",
"description": "Choisissez des fichiers .eml, .zip, .mbox ou Outlook .pst et .ost.",
"choose_files": "Choisir des fichiers…",
"chosen_files": "Fichiers choisis",
"remove_file": "Retirer {name}",
"format_eml": "Message",
"format_zip": "Archive ZIP",
"format_mbox": "Fichier mbox",
"format_pst": "Fichier de données Outlook",
"target_label": "Importer dans",
"target_top": "Niveau supérieur",
"keep_folders": "Recréer les dossiers du fichier",
"keep_folders_hint": "Les dossiers du même nom sont réutilisés.",
"resume": "Reprendre là où le dernier import de ces fichiers s'est arrêté (messages déjà traités : {count})",
"quota_note": "Beaucoup de serveurs n'acceptent qu'environ 1000 messages ou 50 Mo par heure et par utilisateur. Un import plus grand se met en pause puis reprend tout seul.",
"start": "Importer",
"cancel": "Annuler",
"running_title": "Import des e-mails",
"progress_label": "Progression de l'import",
"status_imported": "Messages importés : {count}",
"reading_file": "Lecture de {name}",
"paused": "En pause jusqu'à {time}. Le serveur accepte au plus {files} envois ou {size} par heure. L'import reprend tout seul.",
"paused_generic": "En pause jusqu'à {time}. La limite d'envoi du serveur est atteinte. L'import reprend tout seul.",
"background": "Continuer en arrière-plan",
"cancel_import": "Annuler l'import",
"chip": "Import des e-mails : {percent} %",
"chip_paused": "Import en pause jusqu'à {time}",
"done_title": "Import terminé",
"cancelled_title": "Import annulé",
"failed_title": "Import interrompu",
"summary_imported": "Messages importés : {count}",
"summary_folders": "Dossiers créés : {count}",
"summary_skipped": "Non importés :",
"skip_too_large": "Plus grands que ce qu'accepte le serveur : {count}",
"skip_deleted": "Supprimés dans Thunderbird : {count}",
"skip_embedded": "Messages joints à d'autres messages : {count}",
"skip_contact": "Contacts : {count}",
"skip_calendar": "Éléments d'agenda : {count}",
"skip_other": "Autres éléments : {count}",
"summary_failed": "Import impossible : {count}",
"failure_invalid": "message non valide",
"untitled": "(sans objet)",
"cancelled_hint": "Pour reprendre plus tard, importez à nouveau les mêmes fichiers dans le même dossier.",
"close": "Fermer",
"pst_note": "Seuls les e-mails sont importés des fichiers Outlook. Les contacts, éléments d'agenda et tâches sont comptés et laissés de côté.",
"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.",
"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é.",
"skip_unreadable": "Illisibles dans le fichier Outlook : {count}",
"keep_folders_top_hint": "Au niveau supérieur, Boîte de réception, Éléments envoyés, Éléments supprimés et les autres vont dans les vôtres.",
"toast_done": "Import terminé. Importés : {imported}, échecs : {failed}.",
"toast_stopped": "L'import s'est arrêté.",
"toast_show": "Afficher",
"section_title": "Fichiers de messagerie",
"section_description": "Importez des e-mails depuis des fichiers, ou enregistrez vos e-mails dans des fichiers."
},
"mail_export": {
"menu_export": "Exporter le dossier…",
"settings_label": "Exporter tous les e-mails",
"settings_description": "Enregistrer tous les dossiers de ce compte dans un fichier ZIP, en fichiers mbox ou .eml.",
"settings_button": "Exporter…",
"title_folder": "Exporter « {name} »",
"title_account": "Exporter tous les e-mails",
"description_folder": "Enregistrer les messages de ce dossier dans un fichier.",
"description_account": "Enregistrer tous les dossiers de ce compte dans un fichier ZIP.",
"format_label": "Format",
"format_mbox_folder": "Fichier mbox",
"format_eml_folder": "ZIP de fichiers .eml",
"format_mbox_account": "ZIP avec un fichier mbox par dossier",
"format_eml_account": "ZIP de fichiers .eml, un dossier par dossier de messagerie",
"hint_mbox": "Thunderbird, Apple Mail et la plupart des logiciels de messagerie ouvrent les fichiers mbox.",
"hint_eml": "Un fichier par message, que tout logiciel de messagerie peut ouvrir.",
"save_picker": "Vous choisissez où enregistrer le fichier.",
"save_download": "Le fichier est téléchargé à la fin de l'export.",
"save_memory": "Ce navigateur garde tout l'export en mémoire et ne peut donc enregistrer que 200 Mo au plus. Au-delà, utilisez Chrome ou Edge, ou exportez dossier par dossier.",
"start": "Exporter",
"cancel": "Annuler",
"running_title": "Export des e-mails",
"progress_label": "Progression de l'export",
"status_exported": "Messages exportés : {count} sur {total}",
"paused": "En pause jusqu'à {time} : le serveur a demandé une pause. L'export reprend tout seul.",
"background": "Continuer en arrière-plan",
"cancel_export": "Annuler l'export",
"chip": "Export des e-mails : {percent} %",
"chip_paused": "Export en pause jusqu'à {time}",
"done_title": "Export terminé",
"cancelled_title": "Export annulé",
"failed_title": "Export interrompu",
"summary_exported": "Messages exportés : {count}",
"summary_file": "Enregistré sous {name} ({size}).",
"summary_failed": "Export impossible : {count}",
"download_again": "Télécharger à nouveau",
"too_large": "L'export est plus grand que ce que ce navigateur peut garder en mémoire. Utilisez Chrome ou Edge, ou exportez dossier par dossier.",
"close": "Fermer",
"untitled": "(sans objet)",
"toast_done": "Export terminé : {name}",
"toast_stopped": "L'export s'est arrêté.",
"toast_show": "Afficher"
},
"recover_deleted": {
"tab": "Récupérer des éléments supprimés",
"title": "Récupérer des éléments supprimés",
+111 -4
View File
@@ -410,7 +410,6 @@
"view_in_light_mode": "הצגה במצב בהיר",
"export_email": "ייצא כ-.eml",
"forward_as_attachment": "העבר כקובץ מצורף",
"import_email": "ייבא .eml או .zip…",
"keyboard_shortcuts": "קיצורי מקשים",
"email_source": "מקור דוא\"ל",
"draft_banner": "הודעה זו היא טיוטה",
@@ -1245,8 +1244,6 @@
"templates_import_errors": "לא ניתן לייבא חלק מהתבניות",
"templates_import_empty": "לא נמצאו תבניות בקובץ",
"export_email_error": "ייצוא האימייל נכשל",
"import_email_success": "אימייל יובא בהצלחה",
"import_email_error": "ייבוא האימייל נכשל",
"rule_created": "הכלל נוצר",
"rule_merged": "נוסף לכלל „{name}”",
"rule_already_covered": "כבר מכוסה על ידי הכלל „{name}”",
@@ -5237,7 +5234,6 @@
"new_folder": "תיקייה חדשה…",
"rename": "שנה שם…",
"share": "שיתוף…",
"import_email": "ייבא .eml או .zip…",
"empty_folder": "תיקייה ריקה",
"empty_folder_generic": "תיקייה ריקה",
"delete_folder": "מחק תיקייה",
@@ -5455,6 +5451,117 @@
"unified_mailbox": {
"search_unavailable": "חיפוש אינו זמין בתצוגה המאוחדת"
},
"mail_import": {
"menu_import": "ייבוא דואר…",
"settings_label": "ייבוא דואר",
"settings_description": "הבאת דואר מקובצי ‎.eml‏, ZIP‏, mbox או ‎.pst ו-‎.ost של Outlook.",
"settings_button": "ייבוא…",
"title": "ייבוא דואר",
"description": "בחרו קובצי ‎.eml‏, ‎.zip‏, ‎.mbox או ‎.pst ו-‎.ost של Outlook.",
"choose_files": "בחירת קבצים…",
"chosen_files": "קבצים שנבחרו",
"remove_file": "הסרת {name}",
"format_eml": "הודעה",
"format_zip": "ארכיון ZIP",
"format_mbox": "קובץ mbox",
"format_pst": "קובץ נתונים של Outlook",
"target_label": "ייבוא אל",
"target_top": "רמה עליונה",
"keep_folders": "יצירה מחדש של התיקיות מהקובץ",
"keep_folders_hint": "תיקיות באותו שם ישמשו שוב.",
"resume": "להמשיך מהמקום שבו נעצר הייבוא האחרון של הקבצים האלה (הודעות שכבר טופלו: {count})",
"quota_note": "שרתים רבים מקבלים ממשתמש אחד רק כ-1000 הודעות או 50 MB בשעה. ייבוא גדול יותר נעצר לזמן מה ואז ממשיך מעצמו.",
"start": "ייבוא",
"cancel": "ביטול",
"running_title": "מייבא דואר",
"progress_label": "התקדמות הייבוא",
"status_imported": "הודעות שיובאו: {count}",
"reading_file": "קורא את {name}",
"paused": "מושהה עד {time}. השרת מקבל לכל היותר {files} העלאות או {size} בשעה. הייבוא ימשיך מעצמו.",
"paused_generic": "מושהה עד {time}. הגעתם למגבלת ההעלאה של השרת. הייבוא ימשיך מעצמו.",
"background": "המשך ברקע",
"cancel_import": "ביטול הייבוא",
"chip": "מייבא דואר: {percent}%",
"chip_paused": "הייבוא מושהה עד {time}",
"done_title": "הייבוא הסתיים",
"cancelled_title": "הייבוא בוטל",
"failed_title": "הייבוא נעצר",
"summary_imported": "הודעות שיובאו: {count}",
"summary_folders": "תיקיות שנוצרו: {count}",
"summary_skipped": "לא יובאו:",
"skip_too_large": "גדולות ממה שהשרת מקבל: {count}",
"skip_deleted": "נמחקו ב-Thunderbird: {count}",
"skip_embedded": "הודעות שצורפו להודעות אחרות: {count}",
"skip_contact": "אנשי קשר: {count}",
"skip_calendar": "פריטי לוח שנה: {count}",
"skip_other": "פריטים אחרים: {count}",
"summary_failed": "לא ניתן לייבא: {count}",
"failure_invalid": "זו אינה הודעה תקינה",
"untitled": "(ללא נושא)",
"cancelled_hint": "כדי להמשיך מאוחר יותר, ייבאו שוב את אותם קבצים לאותה תיקייה.",
"close": "סגירה",
"pst_note": "מקובצי Outlook מגיע רק דואר. אנשי קשר, פריטי לוח שנה ומשימות נספרים ומושמטים.",
"pst_error_encrypted": "קובץ Outlook זה משתמש בהצפנה גבוהה ש-Bulwark אינו יכול לקרוא. ב-Outlook, ייצאו את הדואר לקובץ ‎.pst חדש ללא הצפנה.",
"pst_error_not_pst": "קובץ זה אינו קובץ נתונים של Outlook ש-Bulwark יכול לקרוא.",
"pst_error_unreadable": "Bulwark לא הצליח לקרוא את קובץ ה-Outlook הזה.",
"pst_error_worker": "קורא קובצי Outlook לא נטען. טענו את הדף מחדש ונסו שוב.",
"error_over_quota": "תיבת הדואר מלאה, ולכן הייבוא נעצר. פנו מקום וייבאו שוב את אותם קבצים לאותה תיקייה כדי להמשיך.",
"error_forbidden": "אינכם רשאים להוסיף דואר לתיקייה זו, ולכן הייבוא נעצר.",
"error_failures": "יותר מדי הודעות ברצף נכשלו, ולכן הייבוא נעצר. ייבאו שוב מאוחר יותר את אותם קבצים לאותה תיקייה כדי להמשיך.",
"error_network": "החיבור לשרת נכשל שוב ושוב, ולכן הייבוא נעצר. ייבאו שוב את אותם קבצים לאותה תיקייה כדי להמשיך.",
"error_busy": "הקבצים האלה כבר מיובאים בכרטיסייה אחרת.",
"retry_hint": "ייבאו שוב את אותם קבצים לאותה תיקייה כדי לנסות שוב את אלה שנכשלו.",
"skip_unreadable": "לא קריאות בקובץ ה-Outlook: {count}",
"keep_folders_top_hint": "ברמה העליונה, דואר נכנס, פריטים שנשלחו, פריטים שנמחקו ודומיהם נכנסים לתיקיות שלכם.",
"toast_done": "הייבוא הסתיים. יובאו: {imported}, נכשלו: {failed}.",
"toast_stopped": "הייבוא נעצר.",
"toast_show": "הצגה",
"section_title": "קובצי דואר",
"section_description": "הביאו דואר מקבצים, או שמרו את הדואר שלכם כקבצים."
},
"mail_export": {
"menu_export": "ייצוא תיקייה…",
"settings_label": "ייצוא כל הדואר",
"settings_description": "שמירת כל התיקיות בחשבון זה בקובץ ZIP אחד, כקובצי mbox או ‎.eml.",
"settings_button": "ייצוא…",
"title_folder": "ייצוא „{name}”",
"title_account": "ייצוא כל הדואר",
"description_folder": "שמירת ההודעות בתיקייה זו כקובץ.",
"description_account": "שמירת כל התיקיות בחשבון זה בקובץ ZIP אחד.",
"format_label": "תבנית",
"format_mbox_folder": "קובץ mbox",
"format_eml_folder": "ZIP של קובצי ‎.eml",
"format_mbox_account": "ZIP עם קובץ mbox אחד לכל תיקייה",
"format_eml_account": "ZIP של קובצי ‎.eml, תיקייה לכל תיקיית דואר",
"hint_mbox": "Thunderbird‏, Apple Mail ורוב תוכנות הדואר פותחות קובצי mbox.",
"hint_eml": "קובץ לכל הודעה, שכל תוכנת דואר יכולה לפתוח.",
"save_picker": "אתם בוחרים היכן לשמור את הקובץ.",
"save_download": "הקובץ יורד בסיום הייצוא.",
"save_memory": "דפדפן זה שומר את כל הייצוא בזיכרון, ולכן יכול לשמור עד 200 MB. ליותר מזה השתמשו ב-Chrome או ב-Edge, או ייצאו תיקייה אחר תיקייה.",
"start": "ייצוא",
"cancel": "ביטול",
"running_title": "מייצא דואר",
"progress_label": "התקדמות הייצוא",
"status_exported": "הודעות שיוצאו: {count} מתוך {total}",
"paused": "מושהה עד {time}: השרת ביקש הפסקה. הייצוא ימשיך מעצמו.",
"background": "המשך ברקע",
"cancel_export": "ביטול הייצוא",
"chip": "מייצא דואר: {percent}%",
"chip_paused": "הייצוא מושהה עד {time}",
"done_title": "הייצוא הסתיים",
"cancelled_title": "הייצוא בוטל",
"failed_title": "הייצוא נעצר",
"summary_exported": "הודעות שיוצאו: {count}",
"summary_file": "נשמר בשם {name} ({size}).",
"summary_failed": "לא ניתן לייצא: {count}",
"download_again": "הורדה מחדש",
"too_large": "הייצוא גדול ממה שדפדפן זה יכול להחזיק בזיכרון. השתמשו ב-Chrome או ב-Edge, או ייצאו תיקייה אחר תיקייה.",
"close": "סגירה",
"untitled": "(ללא נושא)",
"toast_done": "הייצוא הסתיים: {name}",
"toast_stopped": "הייצוא נעצר.",
"toast_show": "הצגה"
},
"recover_deleted": {
"tab": "שחזור פריטים שנמחקו",
"title": "שחזור פריטים שנמחקו",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Megjelenítés világos módban",
"export_email": "Exportálás .eml-ként",
"forward_as_attachment": "Továbbítás mellékletként",
"import_email": "Importálás .eml vagy .zip fájlból…",
"keyboard_shortcuts": "Billentyűparancsok",
"email_source": "E-mail forrás",
"draft_banner": "Ez az üzenet egy piszkozat",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Néhány sablon importálása sikertelen",
"templates_import_empty": "Nem található sablon a fájlban",
"export_email_error": "Nem sikerült exportálni az e-mailt",
"import_email_success": "E-mail sikeresen importálva",
"import_email_error": "Nem sikerült importálni az e-mailt",
"rule_created": "Szabály létrehozva",
"rule_merged": "Hozzáadva a(z) „{name}” szabályhoz",
"rule_already_covered": "Ezt már lefedi a(z) „{name}” szabály",
@@ -3585,7 +3582,6 @@
"new_folder": "Új mappa…",
"rename": "Átnevezés…",
"share": "Megosztás…",
"import_email": "Importálás .eml vagy .zip fájlból…",
"empty_folder": "Mappa ürítése",
"empty_folder_generic": "Mappa ürítése",
"delete_folder": "Mappa törlése",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "A keresés nem érhető el az egységes nézetben"
},
"mail_import": {
"menu_import": "Levelek importálása…",
"settings_label": "Levelek importálása",
"settings_description": "Levelek behozása .eml, ZIP, mbox vagy Outlook .pst és .ost fájlokból.",
"settings_button": "Importálás…",
"title": "Levelek importálása",
"description": "Válasszon .eml, .zip, .mbox vagy Outlook .pst és .ost fájlokat.",
"choose_files": "Fájlok kiválasztása…",
"chosen_files": "Kiválasztott fájlok",
"remove_file": "{name} eltávolítása",
"format_eml": "Üzenet",
"format_zip": "ZIP-archívum",
"format_mbox": "mbox-fájl",
"format_pst": "Outlook-adatfájl",
"target_label": "Importálás ide",
"target_top": "Legfelső szint",
"keep_folders": "A fájl mappáinak újbóli létrehozása",
"keep_folders_hint": "Az azonos nevű mappák újra felhasználódnak.",
"resume": "Folytatás ott, ahol e fájlok legutóbbi importálása megállt (már kész üzenetek: {count})",
"quota_note": "Sok szerver felhasználónként óránként csak körülbelül 1000 üzenetet vagy 50 MB-ot fogad. A nagyobb importálás szünetel, majd magától folytatódik.",
"start": "Importálás",
"cancel": "Mégse",
"running_title": "Levelek importálása folyamatban",
"progress_label": "Az importálás állapota",
"status_imported": "Importált üzenetek: {count}",
"reading_file": "{name} olvasása",
"paused": "Szüneteltetve eddig: {time}. A szerver óránként legfeljebb {files} feltöltést vagy {size} adatot fogad. Az importálás magától folytatódik.",
"paused_generic": "Szüneteltetve eddig: {time}. Elérte a szerver feltöltési korlátját. Az importálás magától folytatódik.",
"background": "Futtatás a háttérben",
"cancel_import": "Importálás megszakítása",
"chip": "Levelek importálása: {percent}%",
"chip_paused": "Az importálás szünetel eddig: {time}",
"done_title": "Az importálás befejeződött",
"cancelled_title": "Az importálás megszakítva",
"failed_title": "Az importálás leállt",
"summary_imported": "Importált üzenetek: {count}",
"summary_folders": "Létrehozott mappák: {count}",
"summary_skipped": "Nem importált:",
"skip_too_large": "Nagyobb, mint amit a szerver elfogad: {count}",
"skip_deleted": "Thunderbirdben törölve: {count}",
"skip_embedded": "Más üzenetekhez csatolt üzenetek: {count}",
"skip_contact": "Névjegyek: {count}",
"skip_calendar": "Naptárelemek: {count}",
"skip_other": "Egyéb elemek: {count}",
"summary_failed": "Nem importálható: {count}",
"failure_invalid": "nem érvényes üzenet",
"untitled": "(nincs tárgy)",
"cancelled_hint": "A későbbi folytatáshoz importálja újra ugyanazokat a fájlokat ugyanabba a mappába.",
"close": "Bezárás",
"pst_note": "Az Outlook-fájlokból csak a levelek kerülnek át. A névjegyeket, naptárelemeket és feladatokat megszámolja és kihagyja.",
"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.",
"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.",
"skip_unreadable": "Olvashatatlan az Outlook-fájlban: {count}",
"keep_folders_top_hint": "A legfelső szinten a Beérkezett üzenetek, az Elküldött elemek, a Törölt elemek és a hasonlók a saját mappáiba kerülnek.",
"toast_done": "Az importálás befejeződött. Importált: {imported}, sikertelen: {failed}.",
"toast_stopped": "Az importálás leállt.",
"toast_show": "Megjelenítés",
"section_title": "Levélfájlok",
"section_description": "Hozzon be leveleket fájlokból, vagy mentse a leveleit fájlokba."
},
"mail_export": {
"menu_export": "Mappa exportálása…",
"settings_label": "Az összes levél exportálása",
"settings_description": "A fiók összes mappájának mentése egy ZIP-fájlba, mbox vagy .eml fájlokként.",
"settings_button": "Exportálás…",
"title_folder": "„{name}” exportálása",
"title_account": "Az összes levél exportálása",
"description_folder": "A mappa üzeneteinek mentése fájlba.",
"description_account": "A fiók összes mappájának mentése egy ZIP-fájlba.",
"format_label": "Formátum",
"format_mbox_folder": "mbox-fájl",
"format_eml_folder": "ZIP .eml fájlokkal",
"format_mbox_account": "ZIP mappánként egy mbox-fájllal",
"format_eml_account": "ZIP .eml fájlokkal, levélmappánként egy mappával",
"hint_mbox": "A Thunderbird, az Apple Mail és a legtöbb levelezőprogram megnyitja az mbox-fájlokat.",
"hint_eml": "Üzenetenként egy fájl, amelyet bármely levelezőprogram megnyit.",
"save_picker": "Ön választja ki, hová kerüljön a fájl.",
"save_download": "A fájl az exportálás végén letöltődik.",
"save_memory": "Ez a böngésző a teljes exportot a memóriában tartja, ezért legfeljebb 200 MB-ot tud menteni. Nagyobbhoz használja a Chrome-ot vagy az Edge-et, vagy exportáljon mappánként.",
"start": "Exportálás",
"cancel": "Mégse",
"running_title": "Levelek exportálása folyamatban",
"progress_label": "Az exportálás állapota",
"status_exported": "Exportált üzenetek: {count} / {total}",
"paused": "Szüneteltetve eddig: {time}: a szerver szünetet kért. Az exportálás magától folytatódik.",
"background": "Futtatás a háttérben",
"cancel_export": "Exportálás megszakítása",
"chip": "Levelek exportálása: {percent}%",
"chip_paused": "Az exportálás szünetel eddig: {time}",
"done_title": "Az exportálás befejeződött",
"cancelled_title": "Az exportálás megszakítva",
"failed_title": "Az exportálás leállt",
"summary_exported": "Exportált üzenetek: {count}",
"summary_file": "Mentve: {name} ({size}).",
"summary_failed": "Nem exportálható: {count}",
"download_again": "Letöltés újra",
"too_large": "Az export nagyobb, mint amit ez a böngésző a memóriában tarthat. Használja a Chrome-ot vagy az Edge-et, vagy exportáljon mappánként.",
"close": "Bezárás",
"untitled": "(nincs tárgy)",
"toast_done": "Az exportálás befejeződött: {name}",
"toast_stopped": "Az exportálás leállt.",
"toast_show": "Megjelenítés"
},
"recover_deleted": {
"tab": "Törölt elemek visszaállítása",
"title": "Törölt elemek visszaállítása",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Visualizza in modalità chiara",
"export_email": "Esporta come .eml",
"forward_as_attachment": "Inoltra come allegato",
"import_email": "Importa .eml o .zip…",
"keyboard_shortcuts": "Scorciatoie da tastiera",
"email_source": "Sorgente del messaggio",
"draft_banner": "Questo messaggio è una bozza",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Alcuni modelli non sono stati importati",
"templates_import_empty": "Nessun modello trovato nel file",
"export_email_error": "Impossibile esportare l'e-mail",
"import_email_success": "E-mail importata con successo",
"import_email_error": "Impossibile importare l'e-mail",
"rule_created": "Regola creata",
"rule_merged": "Aggiunto alla regola «{name}»",
"rule_already_covered": "Già coperto dalla regola «{name}»",
@@ -3585,7 +3582,6 @@
"new_folder": "Nuova cartella…",
"rename": "Rinomina…",
"share": "Condividi…",
"import_email": "Importa .eml o .zip…",
"empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
},
"mail_import": {
"menu_import": "Importa posta…",
"settings_label": "Importa posta",
"settings_description": "Importa la posta da file .eml, ZIP, mbox o Outlook .pst e .ost.",
"settings_button": "Importa…",
"title": "Importa posta",
"description": "Scegli file .eml, .zip, .mbox o Outlook .pst e .ost.",
"choose_files": "Scegli file…",
"chosen_files": "File scelti",
"remove_file": "Rimuovi {name}",
"format_eml": "Messaggio",
"format_zip": "Archivio ZIP",
"format_mbox": "File mbox",
"format_pst": "File di dati di Outlook",
"target_label": "Importa in",
"target_top": "Livello principale",
"keep_folders": "Ricrea le cartelle del file",
"keep_folders_hint": "Le cartelle con lo stesso nome vengono riutilizzate.",
"resume": "Riprendi da dove si è fermata l'ultima importazione di questi file (messaggi già elaborati: {count})",
"quota_note": "Molti server accettano solo circa 1000 messaggi o 50 MB all'ora per utente. Un'importazione più grande va in pausa e poi riprende da sola.",
"start": "Importa",
"cancel": "Annulla",
"running_title": "Importazione della posta",
"progress_label": "Avanzamento dell'importazione",
"status_imported": "Messaggi importati: {count}",
"reading_file": "Lettura di {name}",
"paused": "In pausa fino alle {time}. Il server accetta al massimo {files} caricamenti o {size} all'ora. L'importazione riprende da sola.",
"paused_generic": "In pausa fino alle {time}. È stato raggiunto il limite di caricamento del server. L'importazione riprende da sola.",
"background": "Continua in background",
"cancel_import": "Annulla importazione",
"chip": "Importazione della posta: {percent}%",
"chip_paused": "Importazione in pausa fino alle {time}",
"done_title": "Importazione completata",
"cancelled_title": "Importazione annullata",
"failed_title": "Importazione interrotta",
"summary_imported": "Messaggi importati: {count}",
"summary_folders": "Cartelle create: {count}",
"summary_skipped": "Non importati:",
"skip_too_large": "Più grandi di quanto accetta il server: {count}",
"skip_deleted": "Eliminati in Thunderbird: {count}",
"skip_embedded": "Messaggi allegati ad altri messaggi: {count}",
"skip_contact": "Contatti: {count}",
"skip_calendar": "Elementi del calendario: {count}",
"skip_other": "Altri elementi: {count}",
"summary_failed": "Impossibile importare: {count}",
"failure_invalid": "non è un messaggio valido",
"untitled": "(nessun oggetto)",
"cancelled_hint": "Per riprendere più tardi, importa di nuovo gli stessi file nella stessa cartella.",
"close": "Chiudi",
"pst_note": "Dai file di Outlook viene importata solo la posta. Contatti, elementi del calendario e attività vengono contati e lasciati fuori.",
"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.",
"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.",
"skip_unreadable": "Illeggibili nel file di Outlook: {count}",
"keep_folders_top_hint": "Al livello principale, Posta in arrivo, Posta inviata, Elementi eliminati e simili finiscono nelle tue.",
"toast_done": "Importazione completata. Importati: {imported}, non riusciti: {failed}.",
"toast_stopped": "L'importazione si è fermata.",
"toast_show": "Mostra",
"section_title": "File di posta",
"section_description": "Importa la posta da file o salva la tua posta come file."
},
"mail_export": {
"menu_export": "Esporta cartella…",
"settings_label": "Esporta tutta la posta",
"settings_description": "Salva tutte le cartelle di questo account in un file ZIP, come file mbox o .eml.",
"settings_button": "Esporta…",
"title_folder": "Esporta «{name}»",
"title_account": "Esporta tutta la posta",
"description_folder": "Salva i messaggi di questa cartella in un file.",
"description_account": "Salva tutte le cartelle di questo account in un file ZIP.",
"format_label": "Formato",
"format_mbox_folder": "File mbox",
"format_eml_folder": "ZIP di file .eml",
"format_mbox_account": "ZIP con un file mbox per cartella",
"format_eml_account": "ZIP di file .eml, una cartella per ogni cartella di posta",
"hint_mbox": "Thunderbird, Apple Mail e la maggior parte dei programmi di posta aprono i file mbox.",
"hint_eml": "Un file per messaggio, che qualsiasi programma di posta può aprire.",
"save_picker": "Scegli tu dove salvare il file.",
"save_download": "Il file viene scaricato al termine dell'esportazione.",
"save_memory": "Questo browser tiene in memoria tutta l'esportazione, quindi può salvare al massimo 200 MB. Per di più usa Chrome o Edge, oppure esporta una cartella alla volta.",
"start": "Esporta",
"cancel": "Annulla",
"running_title": "Esportazione della posta",
"progress_label": "Avanzamento dell'esportazione",
"status_exported": "Messaggi esportati: {count} di {total}",
"paused": "In pausa fino alle {time}: il server ha chiesto una pausa. L'esportazione riprende da sola.",
"background": "Continua in background",
"cancel_export": "Annulla esportazione",
"chip": "Esportazione della posta: {percent}%",
"chip_paused": "Esportazione in pausa fino alle {time}",
"done_title": "Esportazione completata",
"cancelled_title": "Esportazione annullata",
"failed_title": "Esportazione interrotta",
"summary_exported": "Messaggi esportati: {count}",
"summary_file": "Salvato come {name} ({size}).",
"summary_failed": "Impossibile esportare: {count}",
"download_again": "Scarica di nuovo",
"too_large": "L'esportazione è più grande di quanto questo browser possa tenere in memoria. Usa Chrome o Edge, oppure esporta una cartella alla volta.",
"close": "Chiudi",
"untitled": "(nessun oggetto)",
"toast_done": "Esportazione completata: {name}",
"toast_stopped": "L'esportazione si è fermata.",
"toast_show": "Mostra"
},
"recover_deleted": {
"tab": "Recupera elementi eliminati",
"title": "Recupera elementi eliminati",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "ライトモードで表示",
"export_email": ".emlとしてエクスポート",
"forward_as_attachment": "添付ファイルとして転送",
"import_email": ".eml または .zip をインポート…",
"keyboard_shortcuts": "キーボードショートカット",
"email_source": "メールソース",
"draft_banner": "このメッセージは下書きです",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "一部のテンプレートをインポートできませんでした",
"templates_import_empty": "ファイルにテンプレートが見つかりません",
"export_email_error": "メールのエクスポートに失敗しました",
"import_email_success": "メールを正常にインポートしました",
"import_email_error": "メールのインポートに失敗しました",
"rule_created": "ルールを作成しました",
"rule_merged": "ルール「{name}」に追加しました",
"rule_already_covered": "ルール「{name}」ですでに対象になっています",
@@ -3585,7 +3582,6 @@
"new_folder": "新しいフォルダー…",
"rename": "名前を変更…",
"share": "共有…",
"import_email": ".eml または .zip をインポート…",
"empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "統合ビューでは検索を利用できません"
},
"mail_import": {
"menu_import": "メールをインポート…",
"settings_label": "メールをインポート",
"settings_description": ".eml、ZIP、mbox、Outlook の .pst・.ost ファイルからメールを取り込みます。",
"settings_button": "インポート…",
"title": "メールをインポート",
"description": ".eml、.zip、.mbox、Outlook の .pst・.ost ファイルを選んでください。",
"choose_files": "ファイルを選択…",
"chosen_files": "選択したファイル",
"remove_file": "{name} を外す",
"format_eml": "メッセージ",
"format_zip": "ZIP アーカイブ",
"format_mbox": "mbox ファイル",
"format_pst": "Outlook データファイル",
"target_label": "インポート先",
"target_top": "最上位",
"keep_folders": "ファイルのフォルダーを作り直す",
"keep_folders_hint": "同じ名前のフォルダーはそのまま使います。",
"resume": "これらのファイルの前回のインポートが止まったところから続ける(処理済みのメッセージ: {count})",
"quota_note": "多くのサーバーは 1 人のユーザーから 1 時間に約 1000 通または 50 MB までしか受け付けません。それより大きいインポートは一時停止し、その後自動で再開します。",
"start": "インポート",
"cancel": "キャンセル",
"running_title": "メールをインポートしています",
"progress_label": "インポートの進み具合",
"status_imported": "インポートしたメッセージ: {count}",
"reading_file": "{name} を読み込み中",
"paused": "{time} まで一時停止中。サーバーが受け付けるのは 1 時間に最大 {files} 件のアップロードまたは {size} です。インポートは自動で再開します。",
"paused_generic": "{time} まで一時停止中。サーバーのアップロード上限に達しました。インポートは自動で再開します。",
"background": "バックグラウンドで続ける",
"cancel_import": "インポートをキャンセル",
"chip": "メールをインポート中: {percent}%",
"chip_paused": "インポートは {time} まで一時停止中",
"done_title": "インポートが完了しました",
"cancelled_title": "インポートをキャンセルしました",
"failed_title": "インポートが止まりました",
"summary_imported": "インポートしたメッセージ: {count}",
"summary_folders": "作成したフォルダー: {count}",
"summary_skipped": "インポートしなかったもの:",
"skip_too_large": "サーバーの上限より大きい: {count}",
"skip_deleted": "Thunderbird で削除済み: {count}",
"skip_embedded": "他のメッセージに添付されたメッセージ: {count}",
"skip_contact": "連絡先: {count}",
"skip_calendar": "カレンダーの項目: {count}",
"skip_other": "その他の項目: {count}",
"summary_failed": "インポートできなかったもの: {count}",
"failure_invalid": "有効なメッセージではありません",
"untitled": "(件名なし)",
"cancelled_hint": "後で続けるには、同じファイルを同じフォルダーにもう一度インポートしてください。",
"close": "閉じる",
"pst_note": "Outlook ファイルから取り込むのはメールだけです。連絡先、カレンダーの項目、タスクは数えて除外します。",
"pst_error_encrypted": "この Outlook ファイルは Bulwark が読めない高度な暗号化を使っています。Outlook でメールを暗号化なしの新しい .pst ファイルにエクスポートしてください。",
"pst_error_not_pst": "このファイルは Bulwark が読める Outlook データファイルではありません。",
"pst_error_unreadable": "Bulwark はこの Outlook ファイルを読めませんでした。",
"pst_error_worker": "Outlook ファイルの読み込み機能を読み込めませんでした。ページを再読み込みして、もう一度お試しください。",
"error_over_quota": "メールボックスがいっぱいのため、インポートを停止しました。空きを作ってから、同じファイルを同じフォルダーにもう一度インポートすると続きから再開します。",
"error_forbidden": "このフォルダーにはメールを追加できないため、インポートを停止しました。",
"error_failures": "続けて失敗したメッセージが多すぎるため、インポートを停止しました。後で同じファイルを同じフォルダーにもう一度インポートすると続きから再開します。",
"error_network": "サーバーへの接続が何度も失敗したため、インポートを停止しました。同じファイルを同じフォルダーにもう一度インポートすると続きから再開します。",
"error_busy": "これらのファイルは別のタブですでにインポート中です。",
"retry_hint": "失敗したものをもう一度試すには、同じファイルを同じフォルダーにもう一度インポートしてください。",
"skip_unreadable": "Outlook ファイル内で読めないもの: {count}",
"keep_folders_top_hint": "最上位では、受信トレイ、送信済みアイテム、削除済みアイテムなどはご自身のフォルダーに入ります。",
"toast_done": "インポートが完了しました。インポート: {imported}、失敗: {failed}。",
"toast_stopped": "インポートが止まりました。",
"toast_show": "表示",
"section_title": "メールのファイル",
"section_description": "ファイルからメールを取り込んだり、メールをファイルとして保存したりします。"
},
"mail_export": {
"menu_export": "フォルダーをエクスポート…",
"settings_label": "すべてのメールをエクスポート",
"settings_description": "このアカウントのすべてのフォルダーを、mbox または .eml ファイルとして 1 つの ZIP ファイルに保存します。",
"settings_button": "エクスポート…",
"title_folder": "「{name}」をエクスポート",
"title_account": "すべてのメールをエクスポート",
"description_folder": "このフォルダーのメッセージをファイルに保存します。",
"description_account": "このアカウントのすべてのフォルダーを 1 つの ZIP ファイルに保存します。",
"format_label": "形式",
"format_mbox_folder": "mbox ファイル",
"format_eml_folder": ".eml ファイルの ZIP",
"format_mbox_account": "フォルダーごとに mbox ファイル 1 つの ZIP",
"format_eml_account": "メールフォルダーごとにフォルダーを分けた .eml ファイルの ZIP",
"hint_mbox": "mbox ファイルは Thunderbird、Apple Mail など多くのメールソフトで開けます。",
"hint_eml": "メッセージごとに 1 ファイル。どのメールソフトでも開けます。",
"save_picker": "保存先はご自身で選びます。",
"save_download": "エクスポートが終わるとファイルがダウンロードされます。",
"save_memory": "このブラウザーはエクスポート全体をメモリーに保持するため、保存できるのは最大 200 MB です。それ以上は Chrome か Edge を使うか、フォルダーごとにエクスポートしてください。",
"start": "エクスポート",
"cancel": "キャンセル",
"running_title": "メールをエクスポートしています",
"progress_label": "エクスポートの進み具合",
"status_exported": "エクスポートしたメッセージ: {count} / {total}",
"paused": "{time} まで一時停止中: サーバーが休止を求めました。エクスポートは自動で再開します。",
"background": "バックグラウンドで続ける",
"cancel_export": "エクスポートをキャンセル",
"chip": "メールをエクスポート中: {percent}%",
"chip_paused": "エクスポートは {time} まで一時停止中",
"done_title": "エクスポートが完了しました",
"cancelled_title": "エクスポートをキャンセルしました",
"failed_title": "エクスポートが止まりました",
"summary_exported": "エクスポートしたメッセージ: {count}",
"summary_file": "{name} として保存しました({size})。",
"summary_failed": "エクスポートできなかったもの: {count}",
"download_again": "もう一度ダウンロード",
"too_large": "エクスポートがこのブラウザーでメモリーに保持できる量を超えています。Chrome か Edge を使うか、フォルダーごとにエクスポートしてください。",
"close": "閉じる",
"untitled": "(件名なし)",
"toast_done": "エクスポートが完了しました: {name}",
"toast_stopped": "エクスポートが止まりました。",
"toast_show": "表示"
},
"recover_deleted": {
"tab": "削除済みアイテムの復元",
"title": "削除済みアイテムの復元",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "라이트 모드로 보기",
"export_email": ".eml 파일로 내보내기",
"forward_as_attachment": "첨부 파일로 전달",
"import_email": ".eml 또는 .zip 가져오기…",
"keyboard_shortcuts": "단축키",
"email_source": "메일 원본",
"draft_banner": "작성 중인 임시보관 메일이에요",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "일부 템플릿을 가져오지 못했어요",
"templates_import_empty": "파일에서 템플릿을 찾을 수 없어요",
"export_email_error": "메일 내보내기에 실패했어요",
"import_email_success": "메일을 성공적으로 가져왔어요",
"import_email_error": "메일 가져오기에 실패했어요",
"rule_created": "규칙을 만들었어요",
"rule_merged": "“{name}” 규칙에 추가했어요",
"rule_already_covered": "이미 “{name}” 규칙에 포함되어 있어요",
@@ -3585,7 +3582,6 @@
"new_folder": "새 폴더…",
"rename": "이름 바꾸기…",
"share": "공유…",
"import_email": ".eml 또는 .zip 가져오기…",
"empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
},
"mail_import": {
"menu_import": "메일 가져오기…",
"settings_label": "메일 가져오기",
"settings_description": ".eml, ZIP, mbox 또는 Outlook .pst 및 .ost 파일에서 메일을 가져옵니다.",
"settings_button": "가져오기…",
"title": "메일 가져오기",
"description": ".eml, .zip, .mbox 또는 Outlook .pst 및 .ost 파일을 선택하세요.",
"choose_files": "파일 선택…",
"chosen_files": "선택한 파일",
"remove_file": "{name} 제거",
"format_eml": "메시지",
"format_zip": "ZIP 압축 파일",
"format_mbox": "mbox 파일",
"format_pst": "Outlook 데이터 파일",
"target_label": "가져올 위치",
"target_top": "최상위",
"keep_folders": "파일의 폴더를 다시 만들기",
"keep_folders_hint": "이름이 같은 폴더는 다시 사용합니다.",
"resume": "이 파일들의 마지막 가져오기가 멈춘 곳부터 계속 (이미 처리한 메시지: {count})",
"quota_note": "많은 서버는 사용자 한 명에게서 시간당 약 1000개의 메시지 또는 50MB만 받습니다. 더 큰 가져오기는 잠시 멈췄다가 저절로 계속됩니다.",
"start": "가져오기",
"cancel": "취소",
"running_title": "메일을 가져오는 중",
"progress_label": "가져오기 진행 상황",
"status_imported": "가져온 메시지: {count}",
"reading_file": "{name} 읽는 중",
"paused": "{time}까지 일시 중지됨. 서버는 시간당 최대 {files}건의 업로드 또는 {size}를 받습니다. 가져오기는 저절로 계속됩니다.",
"paused_generic": "{time}까지 일시 중지됨. 서버의 업로드 한도에 도달했습니다. 가져오기는 저절로 계속됩니다.",
"background": "백그라운드에서 실행",
"cancel_import": "가져오기 취소",
"chip": "메일을 가져오는 중: {percent}%",
"chip_paused": "가져오기가 {time}까지 일시 중지됨",
"done_title": "가져오기 완료",
"cancelled_title": "가져오기 취소됨",
"failed_title": "가져오기 중단됨",
"summary_imported": "가져온 메시지: {count}",
"summary_folders": "만든 폴더: {count}",
"summary_skipped": "가져오지 않음:",
"skip_too_large": "서버가 받는 크기보다 큼: {count}",
"skip_deleted": "Thunderbird에서 삭제됨: {count}",
"skip_embedded": "다른 메시지에 첨부된 메시지: {count}",
"skip_contact": "연락처: {count}",
"skip_calendar": "캘린더 항목: {count}",
"skip_other": "기타 항목: {count}",
"summary_failed": "가져올 수 없음: {count}",
"failure_invalid": "올바른 메시지가 아님",
"untitled": "(제목 없음)",
"cancelled_hint": "나중에 계속하려면 같은 파일을 같은 폴더로 다시 가져오세요.",
"close": "닫기",
"pst_note": "Outlook 파일에서는 메일만 가져옵니다. 연락처, 캘린더 항목, 작업은 개수만 세고 건너뜁니다.",
"pst_error_encrypted": "이 Outlook 파일은 Bulwark가 읽을 수 없는 강력한 암호화를 사용합니다. Outlook에서 메일을 암호화하지 않은 새 .pst 파일로 내보내세요.",
"pst_error_not_pst": "이 파일은 Bulwark가 읽을 수 있는 Outlook 데이터 파일이 아닙니다.",
"pst_error_unreadable": "Bulwark가 이 Outlook 파일을 읽지 못했습니다.",
"pst_error_worker": "Outlook 파일 읽기 도구를 불러오지 못했습니다. 페이지를 새로 고친 뒤 다시 시도하세요.",
"error_over_quota": "사서함이 가득 차서 가져오기를 멈췄습니다. 공간을 확보한 뒤 같은 파일을 같은 폴더로 다시 가져오면 이어서 진행합니다.",
"error_forbidden": "이 폴더에는 메일을 추가할 수 없어서 가져오기를 멈췄습니다.",
"error_failures": "연속으로 실패한 메시지가 너무 많아 가져오기를 멈췄습니다. 나중에 같은 파일을 같은 폴더로 다시 가져오면 이어서 진행합니다.",
"error_network": "서버 연결이 계속 실패해 가져오기를 멈췄습니다. 같은 파일을 같은 폴더로 다시 가져오면 이어서 진행합니다.",
"error_busy": "이 파일은 이미 다른 탭에서 가져오는 중입니다.",
"retry_hint": "실패한 메시지를 다시 시도하려면 같은 파일을 같은 폴더로 다시 가져오세요.",
"skip_unreadable": "Outlook 파일에서 읽을 수 없음: {count}",
"keep_folders_top_hint": "최상위에서는 받은 편지함, 보낸 편지함, 지운 편지함 등이 사용자의 폴더로 들어갑니다.",
"toast_done": "가져오기 완료. 가져옴: {imported}, 실패: {failed}.",
"toast_stopped": "가져오기가 멈췄습니다.",
"toast_show": "보기",
"section_title": "메일 파일",
"section_description": "파일에서 메일을 가져오거나 메일을 파일로 저장합니다."
},
"mail_export": {
"menu_export": "폴더 내보내기…",
"settings_label": "모든 메일 내보내기",
"settings_description": "이 계정의 모든 폴더를 mbox 또는 .eml 파일로 하나의 ZIP 파일에 저장합니다.",
"settings_button": "내보내기…",
"title_folder": "‘{name}’ 내보내기",
"title_account": "모든 메일 내보내기",
"description_folder": "이 폴더의 메시지를 파일로 저장합니다.",
"description_account": "이 계정의 모든 폴더를 하나의 ZIP 파일에 저장합니다.",
"format_label": "형식",
"format_mbox_folder": "mbox 파일",
"format_eml_folder": ".eml 파일 ZIP",
"format_mbox_account": "폴더마다 mbox 파일 하나가 든 ZIP",
"format_eml_account": "메일 폴더마다 폴더 하나로 정리한 .eml 파일 ZIP",
"hint_mbox": "Thunderbird, Apple Mail 등 대부분의 메일 프로그램이 mbox 파일을 엽니다.",
"hint_eml": "메시지마다 파일 하나. 어떤 메일 프로그램으로도 열 수 있습니다.",
"save_picker": "파일을 저장할 위치를 직접 고릅니다.",
"save_download": "내보내기가 끝나면 파일이 다운로드됩니다.",
"save_memory": "이 브라우저는 내보내기 전체를 메모리에 두므로 최대 200MB까지만 저장할 수 있습니다. 더 크면 Chrome이나 Edge를 쓰거나 폴더별로 내보내세요.",
"start": "내보내기",
"cancel": "취소",
"running_title": "메일을 내보내는 중",
"progress_label": "내보내기 진행 상황",
"status_exported": "내보낸 메시지: {count}/{total}",
"paused": "{time}까지 일시 중지됨: 서버가 잠시 쉬기를 요청했습니다. 내보내기는 저절로 계속됩니다.",
"background": "백그라운드에서 실행",
"cancel_export": "내보내기 취소",
"chip": "메일을 내보내는 중: {percent}%",
"chip_paused": "내보내기가 {time}까지 일시 중지됨",
"done_title": "내보내기 완료",
"cancelled_title": "내보내기 취소됨",
"failed_title": "내보내기 중단됨",
"summary_exported": "내보낸 메시지: {count}",
"summary_file": "{name}(으)로 저장했습니다({size}).",
"summary_failed": "내보낼 수 없음: {count}",
"download_again": "다시 다운로드",
"too_large": "내보내기가 이 브라우저가 메모리에 담을 수 있는 크기보다 큽니다. Chrome이나 Edge를 쓰거나 폴더별로 내보내세요.",
"close": "닫기",
"untitled": "(제목 없음)",
"toast_done": "내보내기 완료: {name}",
"toast_stopped": "내보내기가 멈췄습니다.",
"toast_show": "보기"
},
"recover_deleted": {
"tab": "삭제된 항목 복구",
"title": "삭제된 항목 복구",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Skatīt gaišajā režīmā",
"export_email": "Eksportēt kā .eml",
"forward_as_attachment": "Pārsūtīt kā pielikumu",
"import_email": "Importēt .eml vai .zip…",
"keyboard_shortcuts": "Īsinājumtaustiņi",
"email_source": "Vēstules avota kods",
"draft_banner": "Šī vēstule ir melnraksts",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Dažas veidnes neizdevās importēt",
"templates_import_empty": "Failā netika atrastas veidnes",
"export_email_error": "Neizdevās eksportēt vēstuli",
"import_email_success": "Vēstule veiksmīgi importēta",
"import_email_error": "Neizdevās importēt vēstuli",
"rule_created": "Noteikums izveidots",
"rule_merged": "Pievienots noteikumam “{name}”",
"rule_already_covered": "To jau aptver noteikums “{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "Jauna mape…",
"rename": "Pārsaukt…",
"share": "Kopīgot…",
"import_email": "Importēt .eml vai .zip…",
"empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
},
"mail_import": {
"menu_import": "Importēt pastu…",
"settings_label": "Importēt pastu",
"settings_description": "Ienesiet pastu no .eml, ZIP, mbox vai Outlook .pst un .ost failiem.",
"settings_button": "Importēt…",
"title": "Importēt pastu",
"description": "Izvēlieties .eml, .zip, .mbox vai Outlook .pst un .ost failus.",
"choose_files": "Izvēlēties failus…",
"chosen_files": "Izvēlētie faili",
"remove_file": "Noņemt {name}",
"format_eml": "Ziņojums",
"format_zip": "ZIP arhīvs",
"format_mbox": "mbox fails",
"format_pst": "Outlook datu fails",
"target_label": "Importēt mapē",
"target_top": "Augšējais līmenis",
"keep_folders": "Atjaunot mapes no faila",
"keep_folders_hint": "Mapes ar tādu pašu nosaukumu tiek izmantotas atkārtoti.",
"resume": "Turpināt no vietas, kur šo failu pēdējā importēšana apstājās (jau apstrādāti ziņojumi: {count})",
"quota_note": "Daudzi serveri no viena lietotāja pieņem tikai aptuveni 1000 ziņojumu vai 50 MB stundā. Lielāka importēšana apstājas un pēc tam turpinās pati.",
"start": "Importēt",
"cancel": "Atcelt",
"running_title": "Notiek pasta importēšana",
"progress_label": "Importēšanas gaita",
"status_imported": "Importētie ziņojumi: {count}",
"reading_file": "Lasa {name}",
"paused": "Apturēts līdz {time}. Serveris stundā pieņem ne vairāk kā {files} augšupielādes vai {size}. Importēšana turpināsies pati.",
"paused_generic": "Apturēts līdz {time}. Sasniegts servera augšupielādes ierobežojums. Importēšana turpināsies pati.",
"background": "Turpināt fonā",
"cancel_import": "Atcelt importēšanu",
"chip": "Notiek pasta importēšana: {percent}%",
"chip_paused": "Importēšana apturēta līdz {time}",
"done_title": "Importēšana pabeigta",
"cancelled_title": "Importēšana atcelta",
"failed_title": "Importēšana apturēta",
"summary_imported": "Importētie ziņojumi: {count}",
"summary_folders": "Izveidotās mapes: {count}",
"summary_skipped": "Nav importēti:",
"skip_too_large": "Lielāki, nekā serveris pieņem: {count}",
"skip_deleted": "Dzēsti programmā Thunderbird: {count}",
"skip_embedded": "Ziņojumi, kas pievienoti citiem ziņojumiem: {count}",
"skip_contact": "Kontakti: {count}",
"skip_calendar": "Kalendāra vienumi: {count}",
"skip_other": "Citi vienumi: {count}",
"summary_failed": "Neizdevās importēt: {count}",
"failure_invalid": "nav derīgs ziņojums",
"untitled": "(bez temata)",
"cancelled_hint": "Lai turpinātu vēlāk, vēlreiz importējiet tos pašus failus tajā pašā mapē.",
"close": "Aizvērt",
"pst_note": "No Outlook failiem tiek pārnests tikai pasts. Kontakti, kalendāra vienumi un uzdevumi tiek saskaitīti un izlaisti.",
"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.",
"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.",
"skip_unreadable": "Outlook failā nenolasāmi: {count}",
"keep_folders_top_hint": "Augšējā līmenī Iesūtne, Nosūtītie, Dzēstie un līdzīgi nonāk jūsu pašu mapēs.",
"toast_done": "Importēšana pabeigta. Importēti: {imported}, neizdevās: {failed}.",
"toast_stopped": "Importēšana apstājās.",
"toast_show": "Rādīt",
"section_title": "Pasta faili",
"section_description": "Ienesiet pastu no failiem vai saglabājiet savu pastu kā failus."
},
"mail_export": {
"menu_export": "Eksportēt mapi…",
"settings_label": "Eksportēt visu pastu",
"settings_description": "Saglabāt visas šī konta mapes vienā ZIP failā kā mbox vai .eml failus.",
"settings_button": "Eksportēt…",
"title_folder": "Eksportēt “{name}”",
"title_account": "Eksportēt visu pastu",
"description_folder": "Saglabāt šīs mapes ziņojumus failā.",
"description_account": "Saglabāt visas šī konta mapes vienā ZIP failā.",
"format_label": "Formāts",
"format_mbox_folder": "mbox fails",
"format_eml_folder": "ZIP ar .eml failiem",
"format_mbox_account": "ZIP ar vienu mbox failu katrai mapei",
"format_eml_account": "ZIP ar .eml failiem, viena mape katrai pasta mapei",
"hint_mbox": "Thunderbird, Apple Mail un lielākā daļa pasta programmu atver mbox failus.",
"hint_eml": "Viens fails katram ziņojumam, ko atver jebkura pasta programma.",
"save_picker": "Jūs izvēlaties, kur saglabāt failu.",
"save_download": "Fails tiks lejupielādēts, kad eksportēšana būs pabeigta.",
"save_memory": "Šī pārlūkprogramma tur visu eksportu atmiņā, tāpēc var saglabāt ne vairāk kā 200 MB. Lielākam eksportam izmantojiet Chrome vai Edge vai eksportējiet pa mapei.",
"start": "Eksportēt",
"cancel": "Atcelt",
"running_title": "Notiek pasta eksportēšana",
"progress_label": "Eksportēšanas gaita",
"status_exported": "Eksportētie ziņojumi: {count} no {total}",
"paused": "Apturēts līdz {time}: serveris lūdza pauzi. Eksportēšana turpināsies pati.",
"background": "Turpināt fonā",
"cancel_export": "Atcelt eksportēšanu",
"chip": "Notiek pasta eksportēšana: {percent}%",
"chip_paused": "Eksportēšana apturēta līdz {time}",
"done_title": "Eksportēšana pabeigta",
"cancelled_title": "Eksportēšana atcelta",
"failed_title": "Eksportēšana apturēta",
"summary_exported": "Eksportētie ziņojumi: {count}",
"summary_file": "Saglabāts kā {name} ({size}).",
"summary_failed": "Neizdevās eksportēt: {count}",
"download_again": "Lejupielādēt vēlreiz",
"too_large": "Eksports ir lielāks, nekā šī pārlūkprogramma var noturēt atmiņā. Izmantojiet Chrome vai Edge vai eksportējiet pa mapei.",
"close": "Aizvērt",
"untitled": "(bez temata)",
"toast_done": "Eksportēšana pabeigta: {name}",
"toast_stopped": "Eksportēšana apstājās.",
"toast_show": "Rādīt"
},
"recover_deleted": {
"tab": "Atjaunot dzēstos vienumus",
"title": "Atjaunot dzēstos vienumus",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Цайвар горимд харах",
"export_email": ".eml",
"forward_as_attachment": "Хавсралт болгон дамжуулах",
"import_email": "Импорт .eml эсвэл .zip…",
"keyboard_shortcuts": "Гарын товчлол",
"email_source": "Имэйл эх сурвалж",
"draft_banner": "Энэ зурвас нь ноорог байна",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Зарим загварыг импортлох боломжгүй байна",
"templates_import_empty": "Файлд ямар ч загвар олдсонгүй",
"export_email_error": "Имэйл экспорт хийж чадсангүй",
"import_email_success": "Имэйл амжилттай импортлогдсон",
"import_email_error": "Имэйл импортлож чадсангүй",
"rule_created": "Дүрэм үүслээ",
"rule_merged": "“{name}” дүрэмд нэмлээ",
"rule_already_covered": "“{name}” дүрэм үүнийг аль хэдийн хамарсан",
@@ -3585,7 +3582,6 @@
"new_folder": "Шинэ хавтас…",
"rename": "Нэр өөрчлөх…",
"share": "Хуваалцах…",
"import_email": "Импорт .eml эсвэл .zip…",
"empty_folder": "Хоосон хавтас",
"empty_folder_generic": "Хоосон хавтас",
"delete_folder": "Хавтас устгах",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Нэгдсэн харагдац дээр хайлт ашиглах боломжгүй"
},
"mail_import": {
"menu_import": "Шуудан импортлох…",
"settings_label": "Шуудан импортлох",
"settings_description": ".eml, ZIP, mbox эсвэл Outlook-ийн .pst болон .ost файлаас шуудан оруулна.",
"settings_button": "Импортлох…",
"title": "Шуудан импортлох",
"description": ".eml, .zip, .mbox эсвэл Outlook-ийн .pst болон .ost файл сонгоно уу.",
"choose_files": "Файл сонгох…",
"chosen_files": "Сонгосон файлууд",
"remove_file": "{name}-г хасах",
"format_eml": "Захидал",
"format_zip": "ZIP архив",
"format_mbox": "mbox файл",
"format_pst": "Outlook-ийн өгөгдлийн файл",
"target_label": "Импортлох газар",
"target_top": "Дээд түвшин",
"keep_folders": "Файлын хавтаснуудыг дахин үүсгэх",
"keep_folders_hint": "Ижил нэртэй хавтсыг дахин ашиглана.",
"resume": "Эдгээр файлын сүүлийн импорт зогссон газраас үргэлжлүүлэх (аль хэдийн хийсэн захидал: {count})",
"quota_note": "Олон сервер нэг хэрэглэгчээс цагт ойролцоогоор 1000 захидал эсвэл 50 MB л хүлээн авдаг. Үүнээс том импорт түр зогсоод дараа нь өөрөө үргэлжилнэ.",
"start": "Импортлох",
"cancel": "Цуцлах",
"running_title": "Шуудан импортлож байна",
"progress_label": "Импортын явц",
"status_imported": "Импортолсон захидал: {count}",
"reading_file": "{name}-г уншиж байна",
"paused": "{time} хүртэл түр зогссон. Сервер цагт хамгийн ихдээ {files} байршуулалт эсвэл {size} хүлээн авна. Импорт өөрөө үргэлжилнэ.",
"paused_generic": "{time} хүртэл түр зогссон. Серверийн байршуулах хязгаарт хүрсэн. Импорт өөрөө үргэлжилнэ.",
"background": "Ард ажиллуулах",
"cancel_import": "Импортыг цуцлах",
"chip": "Шуудан импортлож байна: {percent}%",
"chip_paused": "Импорт {time} хүртэл түр зогссон",
"done_title": "Импорт дууслаа",
"cancelled_title": "Импорт цуцлагдлаа",
"failed_title": "Импорт зогслоо",
"summary_imported": "Импортолсон захидал: {count}",
"summary_folders": "Үүсгэсэн хавтас: {count}",
"summary_skipped": "Импортлоогүй:",
"skip_too_large": "Серверийн хүлээн авахаас том: {count}",
"skip_deleted": "Thunderbird-д устгасан: {count}",
"skip_embedded": "Өөр захидалд хавсаргасан захидал: {count}",
"skip_contact": "Харилцагчид: {count}",
"skip_calendar": "Хуанлийн зүйлс: {count}",
"skip_other": "Бусад зүйлс: {count}",
"summary_failed": "Импортлож чадсангүй: {count}",
"failure_invalid": "хүчинтэй захидал биш",
"untitled": "(гарчиггүй)",
"cancelled_hint": "Дараа үргэлжлүүлэхийн тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"close": "Хаах",
"pst_note": "Outlook-ийн файлаас зөвхөн шуудан орно. Харилцагч, хуанлийн зүйл, даалгаврыг тоолоод алгасна.",
"pst_error_encrypted": "Энэ Outlook файл Bulwark уншиж чадахгүй хүчтэй шифрлэлт ашигладаг. Outlook-д шуудангаа шифрлэлтгүй шинэ .pst файл руу экспортлоно уу.",
"pst_error_not_pst": "Энэ файл Bulwark уншиж чадах Outlook-ийн өгөгдлийн файл биш байна.",
"pst_error_unreadable": "Bulwark энэ Outlook файлыг уншиж чадсангүй.",
"pst_error_worker": "Outlook файл уншигч ачаалагдсангүй. Хуудсыг дахин ачаалаад дахин оролдоно уу.",
"error_over_quota": "Шуудангийн хайрцаг дүүрсэн тул импорт зогслоо. Зай гаргаад үргэлжлүүлэхийн тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"error_forbidden": "Та энэ хавтсанд шуудан нэмэх эрхгүй тул импорт зогслоо.",
"error_failures": "Дараалан хэт олон захидал амжилтгүй болсон тул импорт зогслоо. Үргэлжлүүлэхийн тулд дараа нь ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"error_network": "Сервертэй холболт удаа дараа тасарсан тул импорт зогслоо. Үргэлжлүүлэхийн тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"error_busy": "Эдгээр файлыг өөр табд аль хэдийн импортлож байна.",
"retry_hint": "Амжилтгүй болсон захидлуудыг дахин оролдохын тулд ижил файлуудыг ижил хавтас руу дахин импортлоно уу.",
"skip_unreadable": "Outlook файлд уншигдахгүй: {count}",
"keep_folders_top_hint": "Дээд түвшинд Ирсэн, Илгээсэн, Устгасан болон түүнтэй адил хавтаснууд таны өөрийнх рүү орно.",
"toast_done": "Импорт дууслаа. Импортолсон: {imported}, амжилтгүй: {failed}.",
"toast_stopped": "Импорт зогслоо.",
"toast_show": "Харуулах",
"section_title": "Шуудангийн файлууд",
"section_description": "Файлаас шуудан оруулах, эсвэл шуудангаа файл болгон хадгална."
},
"mail_export": {
"menu_export": "Хавтас экспортлох…",
"settings_label": "Бүх шуудан экспортлох",
"settings_description": "Энэ бүртгэлийн бүх хавтсыг mbox эсвэл .eml файл хэлбэрээр нэг ZIP файлд хадгална.",
"settings_button": "Экспортлох…",
"title_folder": "“{name}” экспортлох",
"title_account": "Бүх шуудан экспортлох",
"description_folder": "Энэ хавтасны захидлуудыг файлд хадгална.",
"description_account": "Энэ бүртгэлийн бүх хавтсыг нэг ZIP файлд хадгална.",
"format_label": "Формат",
"format_mbox_folder": "mbox файл",
"format_eml_folder": ".eml файлуудын ZIP",
"format_mbox_account": "Хавтас бүрт нэг mbox файлтай ZIP",
"format_eml_account": "Шуудангийн хавтас бүрт нэг хавтастай .eml файлуудын ZIP",
"hint_mbox": "Thunderbird, Apple Mail болон ихэнх шуудангийн программ mbox файл нээдэг.",
"hint_eml": "Захидал бүрт нэг файл, ямар ч шуудангийн программ нээнэ.",
"save_picker": "Файлыг хаана хадгалахыг та өөрөө сонгоно.",
"save_download": "Экспорт дуусахад файл татагдана.",
"save_memory": "Энэ хөтөч экспортыг бүхэлд нь санах ойд хадгалдаг тул хамгийн ихдээ 200 MB хадгална. Үүнээс их бол Chrome эсвэл Edge ашиглах, эсвэл хавтас тус бүрээр экспортлоно уу.",
"start": "Экспортлох",
"cancel": "Цуцлах",
"running_title": "Шуудан экспортлож байна",
"progress_label": "Экспортын явц",
"status_exported": "Экспортолсон захидал: {count} / {total}",
"paused": "{time} хүртэл түр зогссон: сервер завсарлага хүссэн. Экспорт өөрөө үргэлжилнэ.",
"background": "Ард ажиллуулах",
"cancel_export": "Экспортыг цуцлах",
"chip": "Шуудан экспортлож байна: {percent}%",
"chip_paused": "Экспорт {time} хүртэл түр зогссон",
"done_title": "Экспорт дууслаа",
"cancelled_title": "Экспорт цуцлагдлаа",
"failed_title": "Экспорт зогслоо",
"summary_exported": "Экспортолсон захидал: {count}",
"summary_file": "{name} нэрээр хадгалсан ({size}).",
"summary_failed": "Экспортлож чадсангүй: {count}",
"download_again": "Дахин татах",
"too_large": "Экспорт энэ хөтчийн санах ойд багтахаас том байна. Chrome эсвэл Edge ашиглах, эсвэл хавтас тус бүрээр экспортлоно уу.",
"close": "Хаах",
"untitled": "(гарчиггүй)",
"toast_done": "Экспорт дууслаа: {name}",
"toast_stopped": "Экспорт зогслоо.",
"toast_show": "Харуулах"
},
"recover_deleted": {
"tab": "Устгасан зүйлсийг сэргээх",
"title": "Устгасан зүйлсийг сэргээх",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Vis i lys modus",
"export_email": "Eksporter som .eml",
"forward_as_attachment": "Videresend som vedlegg",
"import_email": "Importer .eml eller .zip…",
"keyboard_shortcuts": "Tastatursnarveier",
"email_source": "Meldingskilde",
"draft_banner": "Denne meldingen er et utkast",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Noen maler kunne ikke importeres",
"templates_import_empty": "Fant ingen maler i filen",
"export_email_error": "Kunne ikke eksportere e-post",
"import_email_success": "E-posten er importert",
"import_email_error": "Kunne ikke importere e-post",
"rule_created": "Regel opprettet",
"rule_merged": "Lagt til i regelen «{name}»",
"rule_already_covered": "Dekkes allerede av regelen «{name}»",
@@ -3585,7 +3582,6 @@
"new_folder": "Ny mappe…",
"rename": "Gi nytt navn…",
"share": "Del…",
"import_email": "Importer .eml eller .zip…",
"empty_folder": "Tøm mappe",
"empty_folder_generic": "Tøm mappe",
"delete_folder": "Slett mappe",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Søk er ikke tilgjengelig i den samlede visningen"
},
"mail_import": {
"menu_import": "Importer e-post…",
"settings_label": "Importer e-post",
"settings_description": "Hent inn e-post fra .eml-, ZIP-, mbox- eller Outlook-.pst- og -.ost-filer.",
"settings_button": "Importer…",
"title": "Importer e-post",
"description": "Velg .eml-, .zip-, .mbox- eller Outlook-.pst- og -.ost-filer.",
"choose_files": "Velg filer…",
"chosen_files": "Valgte filer",
"remove_file": "Fjern {name}",
"format_eml": "Melding",
"format_zip": "ZIP-arkiv",
"format_mbox": "mbox-fil",
"format_pst": "Outlook-datafil",
"target_label": "Importer til",
"target_top": "Øverste nivå",
"keep_folders": "Gjenskap mappene fra filen",
"keep_folders_hint": "Mapper med samme navn brukes på nytt.",
"resume": "Fortsett der forrige import av disse filene stoppet (meldinger allerede ferdige: {count})",
"quota_note": "Mange servere tar bare imot rundt 1000 meldinger eller 50 MB i timen fra én bruker. En større import tar en pause og fortsetter så av seg selv.",
"start": "Importer",
"cancel": "Avbryt",
"running_title": "Importerer e-post",
"progress_label": "Fremdrift for importen",
"status_imported": "Importerte meldinger: {count}",
"reading_file": "Leser {name}",
"paused": "Satt på pause til {time}. Serveren tar imot høyst {files} opplastinger eller {size} i timen. Importen fortsetter av seg selv.",
"paused_generic": "Satt på pause til {time}. Serverens opplastingsgrense er nådd. Importen fortsetter av seg selv.",
"background": "Kjør i bakgrunnen",
"cancel_import": "Avbryt import",
"chip": "Importerer e-post: {percent} %",
"chip_paused": "Import satt på pause til {time}",
"done_title": "Import fullført",
"cancelled_title": "Import avbrutt",
"failed_title": "Import stoppet",
"summary_imported": "Importerte meldinger: {count}",
"summary_folders": "Opprettede mapper: {count}",
"summary_skipped": "Ikke importert:",
"skip_too_large": "Større enn serveren tillater: {count}",
"skip_deleted": "Slettet i Thunderbird: {count}",
"skip_embedded": "Meldinger lagt ved andre meldinger: {count}",
"skip_contact": "Kontakter: {count}",
"skip_calendar": "Kalenderelementer: {count}",
"skip_other": "Andre elementer: {count}",
"summary_failed": "Kunne ikke importeres: {count}",
"failure_invalid": "ikke en gyldig melding",
"untitled": "(uten emne)",
"cancelled_hint": "Importer de samme filene til samme mappe igjen for å fortsette senere.",
"close": "Lukk",
"pst_note": "Fra Outlook-filer kommer bare e-post med. Kontakter, kalenderelementer og oppgaver telles og hoppes over.",
"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.",
"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.",
"skip_unreadable": "Uleselige i Outlook-filen: {count}",
"keep_folders_top_hint": "På øverste nivå havner Innboks, Sendte elementer, Slettede elementer og lignende i dine egne.",
"toast_done": "Import fullført. Importert: {imported}, mislyktes: {failed}.",
"toast_stopped": "Importen stoppet.",
"toast_show": "Vis",
"section_title": "E-postfiler",
"section_description": "Hent inn e-post fra filer, eller lagre e-posten din som filer."
},
"mail_export": {
"menu_export": "Eksporter mappe…",
"settings_label": "Eksporter all e-post",
"settings_description": "Lagre alle mappene i denne kontoen i én ZIP-fil, som mbox- eller .eml-filer.",
"settings_button": "Eksporter…",
"title_folder": "Eksporter «{name}»",
"title_account": "Eksporter all e-post",
"description_folder": "Lagre meldingene i denne mappen som en fil.",
"description_account": "Lagre alle mappene i denne kontoen i én ZIP-fil.",
"format_label": "Format",
"format_mbox_folder": "mbox-fil",
"format_eml_folder": "ZIP med .eml-filer",
"format_mbox_account": "ZIP med én mbox-fil per mappe",
"format_eml_account": "ZIP med .eml-filer, én mappe per e-postmappe",
"hint_mbox": "Thunderbird, Apple Mail og de fleste e-postprogrammer åpner mbox-filer.",
"hint_eml": "Én fil per melding, som alle e-postprogrammer kan åpne.",
"save_picker": "Du velger hvor filen lagres.",
"save_download": "Filen lastes ned når eksporten er ferdig.",
"save_memory": "Denne nettleseren holder hele eksporten i minnet og kan derfor lagre høyst 200 MB. Bruk Chrome eller Edge for mer, eller eksporter mappe for mappe.",
"start": "Eksporter",
"cancel": "Avbryt",
"running_title": "Eksporterer e-post",
"progress_label": "Fremdrift for eksporten",
"status_exported": "Eksporterte meldinger: {count} av {total}",
"paused": "Satt på pause til {time}: serveren ba om en pause. Eksporten fortsetter av seg selv.",
"background": "Kjør i bakgrunnen",
"cancel_export": "Avbryt eksport",
"chip": "Eksporterer e-post: {percent} %",
"chip_paused": "Eksport satt på pause til {time}",
"done_title": "Eksport fullført",
"cancelled_title": "Eksport avbrutt",
"failed_title": "Eksport stoppet",
"summary_exported": "Eksporterte meldinger: {count}",
"summary_file": "Lagret som {name} ({size}).",
"summary_failed": "Kunne ikke eksporteres: {count}",
"download_again": "Last ned på nytt",
"too_large": "Eksporten er større enn denne nettleseren kan holde i minnet. Bruk Chrome eller Edge, eller eksporter mappe for mappe.",
"close": "Lukk",
"untitled": "(uten emne)",
"toast_done": "Eksport fullført: {name}",
"toast_stopped": "Eksporten stoppet.",
"toast_show": "Vis"
},
"recover_deleted": {
"tab": "Gjenopprett slettede elementer",
"title": "Gjenopprett slettede elementer",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Weergeven in lichte modus",
"export_email": "Exporteren als .eml",
"forward_as_attachment": "Doorsturen als bijlage",
"import_email": ".eml of .zip importeren…",
"keyboard_shortcuts": "Sneltoetsen",
"email_source": "E-mailbron",
"draft_banner": "Dit bericht is een concept",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Sommige sjablonen konden niet worden geïmporteerd",
"templates_import_empty": "Geen sjablonen gevonden in het bestand",
"export_email_error": "Kan e-mail niet exporteren",
"import_email_success": "E-mail succesvol geïmporteerd",
"import_email_error": "Kan e-mail niet importeren",
"rule_created": "Regel gemaakt",
"rule_merged": "Toegevoegd aan regel ‘{name}’",
"rule_already_covered": "Valt al onder regel ‘{name}’",
@@ -3585,7 +3582,6 @@
"new_folder": "Nieuwe map…",
"rename": "Hernoemen…",
"share": "Delen…",
"import_email": ".eml of .zip importeren…",
"empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
},
"mail_import": {
"menu_import": "E-mail importeren…",
"settings_label": "E-mail importeren",
"settings_description": "Haal e-mail binnen uit .eml-, ZIP-, mbox- of Outlook-.pst- en -.ost-bestanden.",
"settings_button": "Importeren…",
"title": "E-mail importeren",
"description": "Kies .eml-, .zip-, .mbox- of Outlook-.pst- en -.ost-bestanden.",
"choose_files": "Bestanden kiezen…",
"chosen_files": "Gekozen bestanden",
"remove_file": "{name} verwijderen",
"format_eml": "Bericht",
"format_zip": "ZIP-archief",
"format_mbox": "mbox-bestand",
"format_pst": "Outlook-gegevensbestand",
"target_label": "Importeren in",
"target_top": "Hoogste niveau",
"keep_folders": "De mappen uit het bestand overnemen",
"keep_folders_hint": "Mappen met dezelfde naam worden opnieuw gebruikt.",
"resume": "Doorgaan waar de laatste import van deze bestanden stopte (al verwerkte berichten: {count})",
"quota_note": "Veel servers nemen per gebruiker maar ongeveer 1000 berichten of 50 MB per uur aan. Een grotere import pauzeert en gaat daarna vanzelf verder.",
"start": "Importeren",
"cancel": "Annuleren",
"running_title": "E-mail wordt geïmporteerd",
"progress_label": "Voortgang van de import",
"status_imported": "Geïmporteerde berichten: {count}",
"reading_file": "{name} wordt gelezen",
"paused": "Gepauzeerd tot {time}. De server neemt hoogstens {files} uploads of {size} per uur aan. De import gaat vanzelf verder.",
"paused_generic": "Gepauzeerd tot {time}. De uploadlimiet van de server is bereikt. De import gaat vanzelf verder.",
"background": "Op de achtergrond verder",
"cancel_import": "Import annuleren",
"chip": "E-mail wordt geïmporteerd: {percent}%",
"chip_paused": "Import gepauzeerd tot {time}",
"done_title": "Import voltooid",
"cancelled_title": "Import geannuleerd",
"failed_title": "Import gestopt",
"summary_imported": "Geïmporteerde berichten: {count}",
"summary_folders": "Aangemaakte mappen: {count}",
"summary_skipped": "Niet geïmporteerd:",
"skip_too_large": "Groter dan de server toestaat: {count}",
"skip_deleted": "Verwijderd in Thunderbird: {count}",
"skip_embedded": "Berichten die aan andere berichten hangen: {count}",
"skip_contact": "Contacten: {count}",
"skip_calendar": "Agenda-items: {count}",
"skip_other": "Andere items: {count}",
"summary_failed": "Kon niet worden geïmporteerd: {count}",
"failure_invalid": "geen geldig bericht",
"untitled": "(geen onderwerp)",
"cancelled_hint": "Importeer later dezelfde bestanden opnieuw in dezelfde map om verder te gaan.",
"close": "Sluiten",
"pst_note": "Uit Outlook-bestanden komt alleen e-mail mee. Contacten, agenda-items en taken worden geteld en overgeslagen.",
"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.",
"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.",
"skip_unreadable": "Onleesbaar in het Outlook-bestand: {count}",
"keep_folders_top_hint": "Op het hoogste niveau gaan Postvak IN, Verzonden items, Verwijderde items en dergelijke naar die van u.",
"toast_done": "Import voltooid. Geïmporteerd: {imported}, mislukt: {failed}.",
"toast_stopped": "De import is gestopt.",
"toast_show": "Tonen",
"section_title": "E-mailbestanden",
"section_description": "Haal e-mail binnen uit bestanden, of bewaar uw e-mail als bestanden."
},
"mail_export": {
"menu_export": "Map exporteren…",
"settings_label": "Alle e-mail exporteren",
"settings_description": "Bewaar alle mappen van dit account in één ZIP-bestand, als mbox- of .eml-bestanden.",
"settings_button": "Exporteren…",
"title_folder": "‘{name}’ exporteren",
"title_account": "Alle e-mail exporteren",
"description_folder": "Bewaar de berichten in deze map als bestand.",
"description_account": "Bewaar alle mappen van dit account in één ZIP-bestand.",
"format_label": "Indeling",
"format_mbox_folder": "mbox-bestand",
"format_eml_folder": "ZIP met .eml-bestanden",
"format_mbox_account": "ZIP met één mbox-bestand per map",
"format_eml_account": "ZIP met .eml-bestanden, één map per e-mailmap",
"hint_mbox": "Thunderbird, Apple Mail en de meeste e-mailprogramma's openen mbox-bestanden.",
"hint_eml": "Eén bestand per bericht, dat elk e-mailprogramma kan openen.",
"save_picker": "U kiest waar het bestand wordt opgeslagen.",
"save_download": "Het bestand wordt gedownload als de export klaar is.",
"save_memory": "Deze browser houdt de hele export in het geheugen en kan dus hoogstens 200 MB opslaan. Gebruik voor meer Chrome of Edge, of exporteer map voor map.",
"start": "Exporteren",
"cancel": "Annuleren",
"running_title": "E-mail wordt geëxporteerd",
"progress_label": "Voortgang van de export",
"status_exported": "Geëxporteerde berichten: {count} van {total}",
"paused": "Gepauzeerd tot {time}: de server vroeg om een pauze. De export gaat vanzelf verder.",
"background": "Op de achtergrond verder",
"cancel_export": "Export annuleren",
"chip": "E-mail wordt geëxporteerd: {percent}%",
"chip_paused": "Export gepauzeerd tot {time}",
"done_title": "Export voltooid",
"cancelled_title": "Export geannuleerd",
"failed_title": "Export gestopt",
"summary_exported": "Geëxporteerde berichten: {count}",
"summary_file": "Opgeslagen als {name} ({size}).",
"summary_failed": "Kon niet worden geëxporteerd: {count}",
"download_again": "Opnieuw downloaden",
"too_large": "De export is groter dan deze browser in het geheugen kan houden. Gebruik Chrome of Edge, of exporteer map voor map.",
"close": "Sluiten",
"untitled": "(geen onderwerp)",
"toast_done": "Export voltooid: {name}",
"toast_stopped": "De export is gestopt.",
"toast_show": "Tonen"
},
"recover_deleted": {
"tab": "Verwijderde items herstellen",
"title": "Verwijderde items herstellen",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Wyświetl w jasnym trybie",
"export_email": "Eksportuj jako .eml",
"forward_as_attachment": "Prześlij dalej jako załącznik",
"import_email": "Importuj .eml lub .zip…",
"keyboard_shortcuts": "Skróty klawiszowe",
"email_source": "Źródło wiadomości",
"draft_banner": "Ta wiadomość jest szkicem",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Niektóre szablony nie mogły zostać zaimportowane",
"templates_import_empty": "Nie znaleziono szablonów w pliku",
"export_email_error": "Nie udało się wyeksportować wiadomości",
"import_email_success": "Wiadomość zaimportowana pomyślnie",
"import_email_error": "Nie udało się zaimportować wiadomości",
"rule_created": "Utworzono regułę",
"rule_merged": "Dodano do reguły „{name}”",
"rule_already_covered": "Już obejmuje to reguła „{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "Nowy folder…",
"rename": "Zmień nazwę…",
"share": "Udostępnij…",
"import_email": "Importuj .eml lub .zip…",
"empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
},
"mail_import": {
"menu_import": "Importuj pocztę…",
"settings_label": "Importuj pocztę",
"settings_description": "Przenieś pocztę z plików .eml, ZIP, mbox lub Outlook .pst i .ost.",
"settings_button": "Importuj…",
"title": "Importuj pocztę",
"description": "Wybierz pliki .eml, .zip, .mbox lub Outlook .pst i .ost.",
"choose_files": "Wybierz pliki…",
"chosen_files": "Wybrane pliki",
"remove_file": "Usuń {name}",
"format_eml": "Wiadomość",
"format_zip": "Archiwum ZIP",
"format_mbox": "Plik mbox",
"format_pst": "Plik danych programu Outlook",
"target_label": "Importuj do",
"target_top": "Najwyższy poziom",
"keep_folders": "Odtwórz foldery z pliku",
"keep_folders_hint": "Foldery o tej samej nazwie są używane ponownie.",
"resume": "Kontynuuj tam, gdzie zatrzymał się ostatni import tych plików (wiadomości już zrobione: {count})",
"quota_note": "Wiele serwerów przyjmuje od jednego użytkownika tylko około 1000 wiadomości lub 50 MB na godzinę. Większy import robi przerwę, a potem sam działa dalej.",
"start": "Importuj",
"cancel": "Anuluj",
"running_title": "Importowanie poczty",
"progress_label": "Postęp importu",
"status_imported": "Zaimportowane wiadomości: {count}",
"reading_file": "Odczytywanie {name}",
"paused": "Wstrzymano do {time}. Serwer przyjmuje najwyżej {files} przesłań lub {size} na godzinę. Import sam działa dalej.",
"paused_generic": "Wstrzymano do {time}. Osiągnięto limit przesyłania serwera. Import sam działa dalej.",
"background": "Działaj w tle",
"cancel_import": "Anuluj import",
"chip": "Importowanie poczty: {percent}%",
"chip_paused": "Import wstrzymany do {time}",
"done_title": "Import zakończony",
"cancelled_title": "Import anulowany",
"failed_title": "Import zatrzymany",
"summary_imported": "Zaimportowane wiadomości: {count}",
"summary_folders": "Utworzone foldery: {count}",
"summary_skipped": "Nie zaimportowano:",
"skip_too_large": "Większe, niż przyjmuje serwer: {count}",
"skip_deleted": "Usunięte w Thunderbirdzie: {count}",
"skip_embedded": "Wiadomości dołączone do innych wiadomości: {count}",
"skip_contact": "Kontakty: {count}",
"skip_calendar": "Elementy kalendarza: {count}",
"skip_other": "Inne elementy: {count}",
"summary_failed": "Nie udało się zaimportować: {count}",
"failure_invalid": "to nie jest poprawna wiadomość",
"untitled": "(bez tematu)",
"cancelled_hint": "Aby kontynuować później, zaimportuj te same pliki ponownie do tego samego folderu.",
"close": "Zamknij",
"pst_note": "Z plików programu Outlook przenoszona jest tylko poczta. Kontakty, elementy kalendarza i zadania są liczone i pomijane.",
"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.",
"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.",
"skip_unreadable": "Nieczytelne w pliku programu Outlook: {count}",
"keep_folders_top_hint": "Na najwyższym poziomie Skrzynka odbiorcza, Elementy wysłane, Elementy usunięte i podobne trafiają do Twoich własnych.",
"toast_done": "Import zakończony. Zaimportowano: {imported}, nie udało się: {failed}.",
"toast_stopped": "Import się zatrzymał.",
"toast_show": "Pokaż",
"section_title": "Pliki poczty",
"section_description": "Przenieś pocztę z plików lub zapisz swoją pocztę jako pliki."
},
"mail_export": {
"menu_export": "Eksportuj folder…",
"settings_label": "Eksportuj całą pocztę",
"settings_description": "Zapisz wszystkie foldery tego konta w jednym pliku ZIP, jako pliki mbox lub .eml.",
"settings_button": "Eksportuj…",
"title_folder": "Eksportuj „{name}”",
"title_account": "Eksportuj całą pocztę",
"description_folder": "Zapisz wiadomości z tego folderu w pliku.",
"description_account": "Zapisz wszystkie foldery tego konta w jednym pliku ZIP.",
"format_label": "Format",
"format_mbox_folder": "Plik mbox",
"format_eml_folder": "ZIP z plikami .eml",
"format_mbox_account": "ZIP z jednym plikiem mbox na folder",
"format_eml_account": "ZIP z plikami .eml, jeden folder na folder poczty",
"hint_mbox": "Thunderbird, Apple Mail i większość programów pocztowych otwiera pliki mbox.",
"hint_eml": "Jeden plik na wiadomość, który otworzy każdy program pocztowy.",
"save_picker": "Wybierasz, gdzie zapisać plik.",
"save_download": "Plik zostanie pobrany po zakończeniu eksportu.",
"save_memory": "Ta przeglądarka trzyma cały eksport w pamięci, więc zapisze najwyżej 200 MB. Przy większym eksporcie użyj Chrome lub Edge albo eksportuj folder po folderze.",
"start": "Eksportuj",
"cancel": "Anuluj",
"running_title": "Eksportowanie poczty",
"progress_label": "Postęp eksportu",
"status_exported": "Wyeksportowane wiadomości: {count} z {total}",
"paused": "Wstrzymano do {time}: serwer poprosił o przerwę. Eksport sam działa dalej.",
"background": "Działaj w tle",
"cancel_export": "Anuluj eksport",
"chip": "Eksportowanie poczty: {percent}%",
"chip_paused": "Eksport wstrzymany do {time}",
"done_title": "Eksport zakończony",
"cancelled_title": "Eksport anulowany",
"failed_title": "Eksport zatrzymany",
"summary_exported": "Wyeksportowane wiadomości: {count}",
"summary_file": "Zapisano jako {name} ({size}).",
"summary_failed": "Nie udało się wyeksportować: {count}",
"download_again": "Pobierz ponownie",
"too_large": "Eksport jest większy, niż ta przeglądarka może trzymać w pamięci. Użyj Chrome lub Edge albo eksportuj folder po folderze.",
"close": "Zamknij",
"untitled": "(bez tematu)",
"toast_done": "Eksport zakończony: {name}",
"toast_stopped": "Eksport się zatrzymał.",
"toast_show": "Pokaż"
},
"recover_deleted": {
"tab": "Odzyskaj usunięte elementy",
"title": "Odzyskaj usunięte elementy",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Ver em modo claro",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Encaminhar como anexo",
"import_email": "Importar .eml ou .zip…",
"keyboard_shortcuts": "Atalhos de teclado",
"email_source": "Código-fonte do E-mail",
"draft_banner": "Esta mensagem é um rascunho",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Alguns modelos não puderam ser importados",
"templates_import_empty": "Nenhum modelo encontrado no arquivo",
"export_email_error": "Falha ao exportar o e-mail",
"import_email_success": "E-mail importado com sucesso",
"import_email_error": "Falha ao importar o e-mail",
"rule_created": "Regra criada",
"rule_merged": "Adicionado à regra “{name}”",
"rule_already_covered": "Já coberto pela regra “{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "Nova pasta…",
"rename": "Renomear…",
"share": "Partilhar…",
"import_email": "Importar .eml ou .zip…",
"empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "A pesquisa não está disponível na vista unificada"
},
"mail_import": {
"menu_import": "Importar e-mails…",
"settings_label": "Importar e-mails",
"settings_description": "Trazer e-mails de arquivos .eml, ZIP, mbox ou Outlook .pst e .ost.",
"settings_button": "Importar…",
"title": "Importar e-mails",
"description": "Escolha arquivos .eml, .zip, .mbox ou Outlook .pst e .ost.",
"choose_files": "Escolher arquivos…",
"chosen_files": "Arquivos escolhidos",
"remove_file": "Remover {name}",
"format_eml": "Mensagem",
"format_zip": "Arquivo ZIP",
"format_mbox": "Arquivo mbox",
"format_pst": "Arquivo de dados do Outlook",
"target_label": "Importar para",
"target_top": "Nível superior",
"keep_folders": "Recriar as pastas do arquivo",
"keep_folders_hint": "Pastas com o mesmo nome são reutilizadas.",
"resume": "Continuar de onde a última importação destes arquivos parou (mensagens já processadas: {count})",
"quota_note": "Muitos servidores aceitam só cerca de 1000 mensagens ou 50 MB por hora de cada usuário. Uma importação maior faz uma pausa e depois continua sozinha.",
"start": "Importar",
"cancel": "Cancelar",
"running_title": "Importando e-mails",
"progress_label": "Progresso da importação",
"status_imported": "Mensagens importadas: {count}",
"reading_file": "Lendo {name}",
"paused": "Pausado até {time}. O servidor aceita no máximo {files} envios ou {size} por hora. A importação continua sozinha.",
"paused_generic": "Pausado até {time}. O limite de envio do servidor foi atingido. A importação continua sozinha.",
"background": "Continuar em segundo plano",
"cancel_import": "Cancelar importação",
"chip": "Importando e-mails: {percent}%",
"chip_paused": "Importação pausada até {time}",
"done_title": "Importação concluída",
"cancelled_title": "Importação cancelada",
"failed_title": "Importação interrompida",
"summary_imported": "Mensagens importadas: {count}",
"summary_folders": "Pastas criadas: {count}",
"summary_skipped": "Não importados:",
"skip_too_large": "Maiores do que o servidor aceita: {count}",
"skip_deleted": "Excluídos no Thunderbird: {count}",
"skip_embedded": "Mensagens anexadas a outras mensagens: {count}",
"skip_contact": "Contatos: {count}",
"skip_calendar": "Itens de calendário: {count}",
"skip_other": "Outros itens: {count}",
"summary_failed": "Não foi possível importar: {count}",
"failure_invalid": "não é uma mensagem válida",
"untitled": "(sem assunto)",
"cancelled_hint": "Para continuar mais tarde, importe os mesmos arquivos na mesma pasta outra vez.",
"close": "Fechar",
"pst_note": "Dos arquivos do Outlook só vêm os e-mails. Contatos, itens de calendário e tarefas são contados e deixados de fora.",
"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.",
"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.",
"skip_unreadable": "Ilegíveis no arquivo do Outlook: {count}",
"keep_folders_top_hint": "No nível superior, Caixa de entrada, Itens enviados, Itens excluídos e similares vão para as suas.",
"toast_done": "Importação concluída. Importados: {imported}, com falha: {failed}.",
"toast_stopped": "A importação parou.",
"toast_show": "Mostrar",
"section_title": "Arquivos de e-mail",
"section_description": "Traga e-mails de arquivos ou salve seus e-mails como arquivos."
},
"mail_export": {
"menu_export": "Exportar pasta…",
"settings_label": "Exportar todos os e-mails",
"settings_description": "Salvar todas as pastas desta conta em um arquivo ZIP, como arquivos mbox ou .eml.",
"settings_button": "Exportar…",
"title_folder": "Exportar “{name}”",
"title_account": "Exportar todos os e-mails",
"description_folder": "Salvar as mensagens desta pasta em um arquivo.",
"description_account": "Salvar todas as pastas desta conta em um arquivo ZIP.",
"format_label": "Formato",
"format_mbox_folder": "Arquivo mbox",
"format_eml_folder": "ZIP de arquivos .eml",
"format_mbox_account": "ZIP com um arquivo mbox por pasta",
"format_eml_account": "ZIP de arquivos .eml, uma pasta por pasta de e-mail",
"hint_mbox": "Thunderbird, Apple Mail e a maioria dos programas de e-mail abrem arquivos mbox.",
"hint_eml": "Um arquivo por mensagem, que qualquer programa de e-mail abre.",
"save_picker": "Você escolhe onde salvar o arquivo.",
"save_download": "O arquivo é baixado quando a exportação termina.",
"save_memory": "Este navegador guarda toda a exportação na memória e por isso só salva até 200 MB. Para mais, use o Chrome ou o Edge, ou exporte pasta por pasta.",
"start": "Exportar",
"cancel": "Cancelar",
"running_title": "Exportando e-mails",
"progress_label": "Progresso da exportação",
"status_exported": "Mensagens exportadas: {count} de {total}",
"paused": "Pausado até {time}: o servidor pediu uma pausa. A exportação continua sozinha.",
"background": "Continuar em segundo plano",
"cancel_export": "Cancelar exportação",
"chip": "Exportando e-mails: {percent}%",
"chip_paused": "Exportação pausada até {time}",
"done_title": "Exportação concluída",
"cancelled_title": "Exportação cancelada",
"failed_title": "Exportação interrompida",
"summary_exported": "Mensagens exportadas: {count}",
"summary_file": "Salvo como {name} ({size}).",
"summary_failed": "Não foi possível exportar: {count}",
"download_again": "Baixar de novo",
"too_large": "A exportação é maior do que este navegador consegue guardar na memória. Use o Chrome ou o Edge, ou exporte pasta por pasta.",
"close": "Fechar",
"untitled": "(sem assunto)",
"toast_done": "Exportação concluída: {name}",
"toast_stopped": "A exportação parou.",
"toast_show": "Mostrar"
},
"recover_deleted": {
"tab": "Recuperar itens excluídos",
"title": "Recuperar itens excluídos",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Vizualizare în modul luminos",
"export_email": "Exportați ca fișier .eml",
"forward_as_attachment": "Redirecționează ca atașament",
"import_email": "Importați fișiere .eml sau .zip…",
"keyboard_shortcuts": "Comenzi rapide de la tastatură",
"email_source": "Sursa e-mailului",
"draft_banner": "Acest mesaj este o schiță",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Unele șabloane nu au putut fi importate",
"templates_import_empty": "Nu s-au găsit șabloane în fișier",
"export_email_error": "Nu s-a putut exporta e-mailul",
"import_email_success": "E-mailul a fost importat cu succes",
"import_email_error": "Importul e-mailului a eșuat",
"rule_created": "Regula a fost creată",
"rule_merged": "Adăugat la regula „{name}”",
"rule_already_covered": "Deja acoperit de regula „{name}”",
@@ -3585,7 +3582,6 @@
"new_folder": "Dosar nou…",
"rename": "Redenumire…",
"share": "Partajează…",
"import_email": "Importați fișiere .eml sau .zip…",
"empty_folder": "Folder gol",
"empty_folder_generic": "Folder gol",
"delete_folder": "Șterge dosarul",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Funcția de căutare nu este disponibilă în vizualizarea unificată"
},
"mail_import": {
"menu_import": "Importă e-mailuri…",
"settings_label": "Importă e-mailuri",
"settings_description": "Aduceți e-mailuri din fișiere .eml, ZIP, mbox sau Outlook .pst și .ost.",
"settings_button": "Importă…",
"title": "Importă e-mailuri",
"description": "Alegeți fișiere .eml, .zip, .mbox sau Outlook .pst și .ost.",
"choose_files": "Alege fișiere…",
"chosen_files": "Fișiere alese",
"remove_file": "Elimină {name}",
"format_eml": "Mesaj",
"format_zip": "Arhivă ZIP",
"format_mbox": "Fișier mbox",
"format_pst": "Fișier de date Outlook",
"target_label": "Importă în",
"target_top": "Nivelul de sus",
"keep_folders": "Recreează dosarele din fișier",
"keep_folders_hint": "Dosarele cu același nume sunt refolosite.",
"resume": "Continuă de unde s-a oprit ultimul import al acestor fișiere (mesaje deja procesate: {count})",
"quota_note": "Multe servere acceptă de la un utilizator doar circa 1000 de mesaje sau 50 MB pe oră. Un import mai mare face o pauză și apoi continuă singur.",
"start": "Importă",
"cancel": "Anulează",
"running_title": "Se importă e-mailurile",
"progress_label": "Progresul importului",
"status_imported": "Mesaje importate: {count}",
"reading_file": "Se citește {name}",
"paused": "În pauză până la {time}. Serverul acceptă cel mult {files} încărcări sau {size} pe oră. Importul continuă singur.",
"paused_generic": "În pauză până la {time}. S-a atins limita de încărcare a serverului. Importul continuă singur.",
"background": "Rulează în fundal",
"cancel_import": "Anulează importul",
"chip": "Se importă e-mailurile: {percent}%",
"chip_paused": "Import în pauză până la {time}",
"done_title": "Import încheiat",
"cancelled_title": "Import anulat",
"failed_title": "Import oprit",
"summary_imported": "Mesaje importate: {count}",
"summary_folders": "Dosare create: {count}",
"summary_skipped": "Neimportate:",
"skip_too_large": "Mai mari decât acceptă serverul: {count}",
"skip_deleted": "Șterse în Thunderbird: {count}",
"skip_embedded": "Mesaje atașate la alte mesaje: {count}",
"skip_contact": "Contacte: {count}",
"skip_calendar": "Elemente de calendar: {count}",
"skip_other": "Alte elemente: {count}",
"summary_failed": "Nu s-au putut importa: {count}",
"failure_invalid": "nu este un mesaj valid",
"untitled": "(fără subiect)",
"cancelled_hint": "Pentru a continua mai târziu, importați din nou aceleași fișiere în același dosar.",
"close": "Închide",
"pst_note": "Din fișierele Outlook vin doar e-mailurile. Contactele, elementele de calendar și sarcinile sunt numărate și lăsate deoparte.",
"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.",
"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.",
"skip_unreadable": "Ilizibile în fișierul Outlook: {count}",
"keep_folders_top_hint": "La nivelul de sus, Inbox, Elemente trimise, Elemente șterse și altele asemenea ajung în ale dumneavoastră.",
"toast_done": "Import încheiat. Importate: {imported}, eșuate: {failed}.",
"toast_stopped": "Importul s-a oprit.",
"toast_show": "Afișează",
"section_title": "Fișiere de e-mail",
"section_description": "Aduceți e-mailuri din fișiere sau salvați e-mailurile ca fișiere."
},
"mail_export": {
"menu_export": "Exportă dosarul…",
"settings_label": "Exportă toate e-mailurile",
"settings_description": "Salvați toate dosarele acestui cont într-un fișier ZIP, ca fișiere mbox sau .eml.",
"settings_button": "Exportă…",
"title_folder": "Exportă „{name}”",
"title_account": "Exportă toate e-mailurile",
"description_folder": "Salvați mesajele din acest dosar într-un fișier.",
"description_account": "Salvați toate dosarele acestui cont într-un fișier ZIP.",
"format_label": "Format",
"format_mbox_folder": "Fișier mbox",
"format_eml_folder": "ZIP cu fișiere .eml",
"format_mbox_account": "ZIP cu un fișier mbox pentru fiecare dosar",
"format_eml_account": "ZIP cu fișiere .eml, câte un dosar pentru fiecare dosar de e-mail",
"hint_mbox": "Thunderbird, Apple Mail și majoritatea programelor de e-mail deschid fișiere mbox.",
"hint_eml": "Câte un fișier pentru fiecare mesaj, pe care îl deschide orice program de e-mail.",
"save_picker": "Alegeți unde se salvează fișierul.",
"save_download": "Fișierul se descarcă la sfârșitul exportului.",
"save_memory": "Acest browser ține tot exportul în memorie, deci poate salva cel mult 200 MB. Pentru mai mult, folosiți Chrome sau Edge sau exportați dosar cu dosar.",
"start": "Exportă",
"cancel": "Anulează",
"running_title": "Se exportă e-mailurile",
"progress_label": "Progresul exportului",
"status_exported": "Mesaje exportate: {count} din {total}",
"paused": "În pauză până la {time}: serverul a cerut o pauză. Exportul continuă singur.",
"background": "Rulează în fundal",
"cancel_export": "Anulează exportul",
"chip": "Se exportă e-mailurile: {percent}%",
"chip_paused": "Export în pauză până la {time}",
"done_title": "Export încheiat",
"cancelled_title": "Export anulat",
"failed_title": "Export oprit",
"summary_exported": "Mesaje exportate: {count}",
"summary_file": "Salvat ca {name} ({size}).",
"summary_failed": "Nu s-au putut exporta: {count}",
"download_again": "Descarcă din nou",
"too_large": "Exportul este mai mare decât poate ține acest browser în memorie. Folosiți Chrome sau Edge sau exportați dosar cu dosar.",
"close": "Închide",
"untitled": "(fără subiect)",
"toast_done": "Export încheiat: {name}",
"toast_stopped": "Exportul s-a oprit.",
"toast_show": "Afișează"
},
"recover_deleted": {
"tab": "Recuperați elementele șterse",
"title": "Recuperați elementele șterse",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Просмотр в светлом режиме",
"export_email": "Экспортировать как .eml",
"forward_as_attachment": "Переслать как вложение",
"import_email": "Импортировать .eml или .zip…",
"keyboard_shortcuts": "Сочетания клавиш",
"email_source": "Исходный код письма",
"draft_banner": "Это письмо является черновиком",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Некоторые шаблоны не удалось импортировать",
"templates_import_empty": "В файле не найдено шаблонов",
"export_email_error": "Не удалось экспортировать письмо",
"import_email_success": "Письмо успешно импортировано",
"import_email_error": "Не удалось импортировать письмо",
"rule_created": "Правило создано",
"rule_merged": "Добавлено в правило «{name}»",
"rule_already_covered": "Уже покрывается правилом «{name}»",
@@ -3585,7 +3582,6 @@
"new_folder": "Новая папка…",
"rename": "Переименовать…",
"share": "Поделиться…",
"import_email": "Импортировать .eml или .zip…",
"empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Поиск недоступен в объединённом представлении"
},
"mail_import": {
"menu_import": "Импортировать почту…",
"settings_label": "Импортировать почту",
"settings_description": "Перенесите почту из файлов .eml, ZIP, mbox или Outlook .pst и .ost.",
"settings_button": "Импортировать…",
"title": "Импорт почты",
"description": "Выберите файлы .eml, .zip, .mbox или Outlook .pst и .ost.",
"choose_files": "Выбрать файлы…",
"chosen_files": "Выбранные файлы",
"remove_file": "Убрать {name}",
"format_eml": "Сообщение",
"format_zip": "Архив ZIP",
"format_mbox": "Файл mbox",
"format_pst": "Файл данных Outlook",
"target_label": "Импортировать в",
"target_top": "Верхний уровень",
"keep_folders": "Воссоздать папки из файла",
"keep_folders_hint": "Папки с тем же именем используются повторно.",
"resume": "Продолжить с места, где остановился прошлый импорт этих файлов (уже обработано сообщений: {count})",
"quota_note": "Многие серверы принимают от одного пользователя лишь около 1000 сообщений или 50 МБ в час. Большой импорт приостанавливается и затем продолжается сам.",
"start": "Импортировать",
"cancel": "Отмена",
"running_title": "Импорт почты",
"progress_label": "Ход импорта",
"status_imported": "Импортировано сообщений: {count}",
"reading_file": "Чтение {name}",
"paused": "Пауза до {time}. Сервер принимает не более {files} загрузок или {size} в час. Импорт продолжится сам.",
"paused_generic": "Пауза до {time}. Достигнут лимит загрузок сервера. Импорт продолжится сам.",
"background": "Продолжить в фоне",
"cancel_import": "Отменить импорт",
"chip": "Импорт почты: {percent} %",
"chip_paused": "Импорт приостановлен до {time}",
"done_title": "Импорт завершён",
"cancelled_title": "Импорт отменён",
"failed_title": "Импорт остановлен",
"summary_imported": "Импортировано сообщений: {count}",
"summary_folders": "Создано папок: {count}",
"summary_skipped": "Не импортировано:",
"skip_too_large": "Больше, чем принимает сервер: {count}",
"skip_deleted": "Удалено в Thunderbird: {count}",
"skip_embedded": "Сообщения, вложенные в другие сообщения: {count}",
"skip_contact": "Контакты: {count}",
"skip_calendar": "Элементы календаря: {count}",
"skip_other": "Другие элементы: {count}",
"summary_failed": "Не удалось импортировать: {count}",
"failure_invalid": "недопустимое сообщение",
"untitled": "(без темы)",
"cancelled_hint": "Чтобы продолжить позже, снова импортируйте те же файлы в ту же папку.",
"close": "Закрыть",
"pst_note": "Из файлов Outlook переносится только почта. Контакты, элементы календаря и задачи подсчитываются и пропускаются.",
"pst_error_encrypted": "В этом файле Outlook используется сильное шифрование, которое Bulwark не может прочитать. Экспортируйте почту в Outlook в новый файл .pst без шифрования.",
"pst_error_not_pst": "Этот файл не является файлом данных Outlook, который Bulwark может прочитать.",
"pst_error_unreadable": "Bulwark не смог прочитать этот файл Outlook.",
"pst_error_worker": "Модуль чтения файлов Outlook не загрузился. Перезагрузите страницу и попробуйте снова.",
"error_over_quota": "Почтовый ящик заполнен, поэтому импорт остановлен. Освободите место и снова импортируйте те же файлы в ту же папку, чтобы продолжить.",
"error_forbidden": "Вы не можете добавлять почту в эту папку, поэтому импорт остановлен.",
"error_failures": "Слишком много сообщений подряд не удалось импортировать, поэтому импорт остановлен. Позже снова импортируйте те же файлы в ту же папку, чтобы продолжить.",
"error_network": "Соединение с сервером несколько раз прерывалось, поэтому импорт остановлен. Снова импортируйте те же файлы в ту же папку, чтобы продолжить.",
"error_busy": "Эти файлы уже импортируются в другой вкладке.",
"retry_hint": "Снова импортируйте те же файлы в ту же папку, чтобы ещё раз попробовать неудавшиеся.",
"skip_unreadable": "Не читаются в файле Outlook: {count}",
"keep_folders_top_hint": "На верхнем уровне Входящие, Отправленные, Удалённые и подобные попадают в ваши собственные.",
"toast_done": "Импорт завершён. Импортировано: {imported}, не удалось: {failed}.",
"toast_stopped": "Импорт остановлен.",
"toast_show": "Показать",
"section_title": "Почтовые файлы",
"section_description": "Перенесите почту из файлов или сохраните свою почту в файлы."
},
"mail_export": {
"menu_export": "Экспортировать папку…",
"settings_label": "Экспортировать всю почту",
"settings_description": "Сохранить все папки этой учётной записи в один ZIP-файл в виде файлов mbox или .eml.",
"settings_button": "Экспортировать…",
"title_folder": "Экспорт «{name}»",
"title_account": "Экспорт всей почты",
"description_folder": "Сохранить сообщения этой папки в файл.",
"description_account": "Сохранить все папки этой учётной записи в один ZIP-файл.",
"format_label": "Формат",
"format_mbox_folder": "Файл mbox",
"format_eml_folder": "ZIP с файлами .eml",
"format_mbox_account": "ZIP с одним файлом mbox на папку",
"format_eml_account": "ZIP с файлами .eml, по папке на каждую почтовую папку",
"hint_mbox": "Thunderbird, Apple Mail и большинство почтовых программ открывают файлы mbox.",
"hint_eml": "По файлу на сообщение, их откроет любая почтовая программа.",
"save_picker": "Вы сами выбираете, куда сохранить файл.",
"save_download": "Файл скачается, когда экспорт завершится.",
"save_memory": "Этот браузер держит весь экспорт в памяти, поэтому сохранит не больше 200 МБ. Для большего используйте Chrome или Edge либо экспортируйте по одной папке.",
"start": "Экспортировать",
"cancel": "Отмена",
"running_title": "Экспорт почты",
"progress_label": "Ход экспорта",
"status_exported": "Экспортировано сообщений: {count} из {total}",
"paused": "Пауза до {time}: сервер попросил перерыв. Экспорт продолжится сам.",
"background": "Продолжить в фоне",
"cancel_export": "Отменить экспорт",
"chip": "Экспорт почты: {percent} %",
"chip_paused": "Экспорт приостановлен до {time}",
"done_title": "Экспорт завершён",
"cancelled_title": "Экспорт отменён",
"failed_title": "Экспорт остановлен",
"summary_exported": "Экспортировано сообщений: {count}",
"summary_file": "Сохранено как {name} ({size}).",
"summary_failed": "Не удалось экспортировать: {count}",
"download_again": "Скачать снова",
"too_large": "Экспорт больше, чем этот браузер может держать в памяти. Используйте Chrome или Edge либо экспортируйте по одной папке.",
"close": "Закрыть",
"untitled": "(без темы)",
"toast_done": "Экспорт завершён: {name}",
"toast_stopped": "Экспорт остановлен.",
"toast_show": "Показать"
},
"recover_deleted": {
"tab": "Восстановить удалённое",
"title": "Восстановить удалённое",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Zobraziť v svetlom režime",
"export_email": "Exportovať ako .eml",
"forward_as_attachment": "Preposlať ako prílohu",
"import_email": "Importovať .eml alebo .zip…",
"keyboard_shortcuts": "Klávesové skratky",
"email_source": "Zdrojový kód e-mailu",
"draft_banner": "Táto správa je koncept",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Niektoré šablóny sa nepodarilo importovať",
"templates_import_empty": "V súbore neboli nájdené žiadne šablóny",
"export_email_error": "Export e-mailu zlyhal",
"import_email_success": "E-mail bol úspešne importovaný",
"import_email_error": "Import e-mailu zlyhal",
"rule_created": "Pravidlo bolo vytvorené",
"rule_merged": "Pridané do pravidla „{name}“",
"rule_already_covered": "Už to pokrýva pravidlo „{name}“",
@@ -3585,7 +3582,6 @@
"new_folder": "Nový priečinok…",
"rename": "Premenovať…",
"share": "Zdieľať…",
"import_email": "Importovať .eml alebo .zip…",
"empty_folder": "Vyprázdniť priečinok",
"empty_folder_generic": "Vyprázdniť priečinok",
"delete_folder": "Zmazať priečinok",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Vyhľadávanie nie je v zjednotenom zobrazení k dispozícii"
},
"mail_import": {
"menu_import": "Importovať poštu…",
"settings_label": "Importovať poštu",
"settings_description": "Preneste poštu zo súborov .eml, ZIP, mbox alebo Outlook .pst a .ost.",
"settings_button": "Importovať…",
"title": "Importovať poštu",
"description": "Vyberte súbory .eml, .zip, .mbox alebo Outlook .pst a .ost.",
"choose_files": "Vybrať súbory…",
"chosen_files": "Vybrané súbory",
"remove_file": "Odstrániť {name}",
"format_eml": "Správa",
"format_zip": "Archív ZIP",
"format_mbox": "Súbor mbox",
"format_pst": "Dátový súbor Outlooku",
"target_label": "Importovať do",
"target_top": "Najvyššia úroveň",
"keep_folders": "Znovu vytvoriť priečinky zo súboru",
"keep_folders_hint": "Priečinky s rovnakým názvom sa použijú znova.",
"resume": "Pokračovať tam, kde sa posledný import týchto súborov zastavil (už hotové správy: {count})",
"quota_note": "Mnohé servery prijímajú od jedného používateľa len asi 1000 správ alebo 50 MB za hodinu. Väčší import sa pozastaví a potom pokračuje sám.",
"start": "Importovať",
"cancel": "Zrušiť",
"running_title": "Importuje sa pošta",
"progress_label": "Priebeh importu",
"status_imported": "Importované správy: {count}",
"reading_file": "Číta sa {name}",
"paused": "Pozastavené do {time}. Server prijíma najviac {files} nahraní alebo {size} za hodinu. Import pokračuje sám.",
"paused_generic": "Pozastavené do {time}. Dosiahol sa limit nahrávania servera. Import pokračuje sám.",
"background": "Spustiť na pozadí",
"cancel_import": "Zrušiť import",
"chip": "Importuje sa pošta: {percent} %",
"chip_paused": "Import pozastavený do {time}",
"done_title": "Import dokončený",
"cancelled_title": "Import zrušený",
"failed_title": "Import zastavený",
"summary_imported": "Importované správy: {count}",
"summary_folders": "Vytvorené priečinky: {count}",
"summary_skipped": "Neimportované:",
"skip_too_large": "Väčšie, než server prijme: {count}",
"skip_deleted": "Odstránené v Thunderbirde: {count}",
"skip_embedded": "Správy priložené k iným správam: {count}",
"skip_contact": "Kontakty: {count}",
"skip_calendar": "Položky kalendára: {count}",
"skip_other": "Iné položky: {count}",
"summary_failed": "Nedá sa importovať: {count}",
"failure_invalid": "nie je platná správa",
"untitled": "(bez predmetu)",
"cancelled_hint": "Ak chcete pokračovať neskôr, importujte rovnaké súbory znova do rovnakého priečinka.",
"close": "Zavrieť",
"pst_note": "Zo súborov Outlooku sa prenáša len pošta. Kontakty, položky kalendára a úlohy sa spočítajú a vynechajú.",
"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.",
"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.",
"skip_unreadable": "V súbore Outlooku nečitateľné: {count}",
"keep_folders_top_hint": "Na najvyššej úrovni skončia Doručená pošta, Odoslaná pošta, Odstránená pošta a podobné vo vašich vlastných.",
"toast_done": "Import dokončený. Importované: {imported}, zlyhalo: {failed}.",
"toast_stopped": "Import sa zastavil.",
"toast_show": "Zobraziť",
"section_title": "Súbory s poštou",
"section_description": "Preneste poštu zo súborov alebo uložte svoju poštu ako súbory."
},
"mail_export": {
"menu_export": "Exportovať priečinok…",
"settings_label": "Exportovať všetku poštu",
"settings_description": "Uložiť všetky priečinky tohto účtu do jedného súboru ZIP ako súbory mbox alebo .eml.",
"settings_button": "Exportovať…",
"title_folder": "Exportovať „{name}“",
"title_account": "Exportovať všetku poštu",
"description_folder": "Uložiť správy v tomto priečinku ako súbor.",
"description_account": "Uložiť všetky priečinky tohto účtu do jedného súboru ZIP.",
"format_label": "Formát",
"format_mbox_folder": "Súbor mbox",
"format_eml_folder": "ZIP so súbormi .eml",
"format_mbox_account": "ZIP s jedným súborom mbox na priečinok",
"format_eml_account": "ZIP so súbormi .eml, jeden priečinok na poštový priečinok",
"hint_mbox": "Thunderbird, Apple Mail a väčšina poštových programov otvorí súbory mbox.",
"hint_eml": "Jeden súbor na správu, ktorý otvorí každý poštový program.",
"save_picker": "Sami vyberiete, kam sa súbor uloží.",
"save_download": "Súbor sa stiahne, keď bude export hotový.",
"save_memory": "Tento prehliadač drží celý export v pamäti, takže uloží najviac 200 MB. Pri väčšom exporte použite Chrome alebo Edge, alebo exportujte priečinok po priečinku.",
"start": "Exportovať",
"cancel": "Zrušiť",
"running_title": "Exportuje sa pošta",
"progress_label": "Priebeh exportu",
"status_exported": "Exportované správy: {count} z {total}",
"paused": "Pozastavené do {time}: server požiadal o prestávku. Export pokračuje sám.",
"background": "Spustiť na pozadí",
"cancel_export": "Zrušiť export",
"chip": "Exportuje sa pošta: {percent} %",
"chip_paused": "Export pozastavený do {time}",
"done_title": "Export dokončený",
"cancelled_title": "Export zrušený",
"failed_title": "Export zastavený",
"summary_exported": "Exportované správy: {count}",
"summary_file": "Uložené ako {name} ({size}).",
"summary_failed": "Nedá sa exportovať: {count}",
"download_again": "Stiahnuť znova",
"too_large": "Export je väčší, než tento prehliadač udrží v pamäti. Použite Chrome alebo Edge, alebo exportujte priečinok po priečinku.",
"close": "Zavrieť",
"untitled": "(bez predmetu)",
"toast_done": "Export dokončený: {name}",
"toast_stopped": "Export sa zastavil.",
"toast_show": "Zobraziť"
},
"recover_deleted": {
"tab": "Obnoviť odstránené položky",
"title": "Obnoviť odstránené položky",
+111 -4
View File
@@ -397,6 +397,117 @@
"unified_mailbox": {
"search_unavailable": "ไม่สามารถค้นหาในมุมมองรวมศูนย์ได้"
},
"mail_import": {
"menu_import": "นำเข้าอีเมล…",
"settings_label": "นำเข้าอีเมล",
"settings_description": "นำอีเมลเข้าจากไฟล์ .eml, ZIP, mbox หรือ .pst และ .ost ของ Outlook",
"settings_button": "นำเข้า…",
"title": "นำเข้าอีเมล",
"description": "เลือกไฟล์ .eml, .zip, .mbox หรือ .pst และ .ost ของ Outlook",
"choose_files": "เลือกไฟล์…",
"chosen_files": "ไฟล์ที่เลือก",
"remove_file": "เอา {name} ออก",
"format_eml": "ข้อความ",
"format_zip": "ไฟล์บีบอัด ZIP",
"format_mbox": "ไฟล์ mbox",
"format_pst": "ไฟล์ข้อมูล Outlook",
"target_label": "นำเข้าไปที่",
"target_top": "ระดับบนสุด",
"keep_folders": "สร้างโฟลเดอร์จากไฟล์ขึ้นใหม่",
"keep_folders_hint": "โฟลเดอร์ที่ชื่อเดียวกันจะถูกใช้ซ้ำ",
"resume": "ทำต่อจากจุดที่การนำเข้าไฟล์เหล่านี้ครั้งล่าสุดหยุดไว้ (ข้อความที่ทำแล้ว: {count})",
"quota_note": "เซิร์ฟเวอร์หลายแห่งรับจากผู้ใช้หนึ่งคนได้เพียงประมาณ 1000 ข้อความหรือ 50 MB ต่อชั่วโมง การนำเข้าที่ใหญ่กว่านี้จะหยุดพักแล้วทำต่อเอง",
"start": "นำเข้า",
"cancel": "ยกเลิก",
"running_title": "กำลังนำเข้าอีเมล",
"progress_label": "ความคืบหน้าการนำเข้า",
"status_imported": "ข้อความที่นำเข้าแล้ว: {count}",
"reading_file": "กำลังอ่าน {name}",
"paused": "หยุดพักถึง {time} เซิร์ฟเวอร์รับการอัปโหลดได้สูงสุด {files} ครั้งหรือ {size} ต่อชั่วโมง การนำเข้าจะทำต่อเอง",
"paused_generic": "หยุดพักถึง {time} ถึงขีดจำกัดการอัปโหลดของเซิร์ฟเวอร์แล้ว การนำเข้าจะทำต่อเอง",
"background": "ทำงานเบื้องหลัง",
"cancel_import": "ยกเลิกการนำเข้า",
"chip": "กำลังนำเข้าอีเมล: {percent}%",
"chip_paused": "การนำเข้าหยุดพักถึง {time}",
"done_title": "นำเข้าเสร็จแล้ว",
"cancelled_title": "ยกเลิกการนำเข้าแล้ว",
"failed_title": "การนำเข้าหยุดลง",
"summary_imported": "ข้อความที่นำเข้าแล้ว: {count}",
"summary_folders": "โฟลเดอร์ที่สร้าง: {count}",
"summary_skipped": "ไม่ได้นำเข้า:",
"skip_too_large": "ใหญ่กว่าที่เซิร์ฟเวอร์รับได้: {count}",
"skip_deleted": "ถูกลบใน Thunderbird: {count}",
"skip_embedded": "ข้อความที่แนบอยู่ในข้อความอื่น: {count}",
"skip_contact": "รายชื่อติดต่อ: {count}",
"skip_calendar": "รายการปฏิทิน: {count}",
"skip_other": "รายการอื่น: {count}",
"summary_failed": "นำเข้าไม่ได้: {count}",
"failure_invalid": "ไม่ใช่ข้อความที่ถูกต้อง",
"untitled": "(ไม่มีหัวเรื่อง)",
"cancelled_hint": "หากต้องการทำต่อภายหลัง ให้นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้ง",
"close": "ปิด",
"pst_note": "จากไฟล์ Outlook จะนำเข้าเฉพาะอีเมล รายชื่อติดต่อ รายการปฏิทิน และงานจะถูกนับแล้วข้ามไป",
"pst_error_encrypted": "ไฟล์ Outlook นี้ใช้การเข้ารหัสระดับสูงที่ Bulwark อ่านไม่ได้ ใน Outlook ให้ส่งออกอีเมลไปยังไฟล์ .pst ใหม่ที่ไม่เข้ารหัส",
"pst_error_not_pst": "ไฟล์นี้ไม่ใช่ไฟล์ข้อมูล Outlook ที่ Bulwark อ่านได้",
"pst_error_unreadable": "Bulwark อ่านไฟล์ Outlook นี้ไม่ได้",
"pst_error_worker": "ตัวอ่านไฟล์ Outlook โหลดไม่สำเร็จ โหลดหน้าใหม่แล้วลองอีกครั้ง",
"error_over_quota": "กล่องจดหมายเต็ม การนำเข้าจึงหยุดลง เคลียร์พื้นที่แล้วนำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งเพื่อทำต่อ",
"error_forbidden": "คุณไม่มีสิทธิ์เพิ่มอีเมลในโฟลเดอร์นี้ การนำเข้าจึงหยุดลง",
"error_failures": "ข้อความล้มเหลวติดต่อกันมากเกินไป การนำเข้าจึงหยุดลง นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งภายหลังเพื่อทำต่อ",
"error_network": "การเชื่อมต่อกับเซิร์ฟเวอร์ล้มเหลวซ้ำๆ การนำเข้าจึงหยุดลง นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งเพื่อทำต่อ",
"error_busy": "ไฟล์เหล่านี้กำลังถูกนำเข้าในอีกแท็บหนึ่งอยู่แล้ว",
"retry_hint": "นำเข้าไฟล์เดิมไปยังโฟลเดอร์เดิมอีกครั้งเพื่อลองข้อความที่ล้มเหลวอีกรอบ",
"skip_unreadable": "อ่านไม่ได้ในไฟล์ Outlook: {count}",
"keep_folders_top_hint": "ในระดับบนสุด กล่องจดหมายเข้า รายการที่ส่ง รายการที่ลบ และที่คล้ายกันจะเข้าไปในโฟลเดอร์ของคุณเอง",
"toast_done": "นำเข้าเสร็จแล้ว นำเข้าแล้ว: {imported} ล้มเหลว: {failed}",
"toast_stopped": "การนำเข้าหยุดลง",
"toast_show": "แสดง",
"section_title": "ไฟล์อีเมล",
"section_description": "นำอีเมลเข้าจากไฟล์ หรือบันทึกอีเมลของคุณเป็นไฟล์"
},
"mail_export": {
"menu_export": "ส่งออกโฟลเดอร์…",
"settings_label": "ส่งออกอีเมลทั้งหมด",
"settings_description": "บันทึกทุกโฟลเดอร์ของบัญชีนี้ลงในไฟล์ ZIP เดียว เป็นไฟล์ mbox หรือ .eml",
"settings_button": "ส่งออก…",
"title_folder": "ส่งออก “{name}”",
"title_account": "ส่งออกอีเมลทั้งหมด",
"description_folder": "บันทึกข้อความในโฟลเดอร์นี้เป็นไฟล์",
"description_account": "บันทึกทุกโฟลเดอร์ของบัญชีนี้ลงในไฟล์ ZIP เดียว",
"format_label": "รูปแบบ",
"format_mbox_folder": "ไฟล์ mbox",
"format_eml_folder": "ZIP ของไฟล์ .eml",
"format_mbox_account": "ZIP ที่มีไฟล์ mbox หนึ่งไฟล์ต่อโฟลเดอร์",
"format_eml_account": "ZIP ของไฟล์ .eml หนึ่งโฟลเดอร์ต่อโฟลเดอร์อีเมล",
"hint_mbox": "Thunderbird, Apple Mail และโปรแกรมอีเมลส่วนใหญ่เปิดไฟล์ mbox ได้",
"hint_eml": "หนึ่งไฟล์ต่อหนึ่งข้อความ ซึ่งโปรแกรมอีเมลใดก็เปิดได้",
"save_picker": "คุณเลือกเองว่าจะบันทึกไฟล์ไว้ที่ใด",
"save_download": "ไฟล์จะถูกดาวน์โหลดเมื่อส่งออกเสร็จ",
"save_memory": "เบราว์เซอร์นี้เก็บการส่งออกทั้งหมดไว้ในหน่วยความจำ จึงบันทึกได้สูงสุด 200 MB หากมากกว่านั้นให้ใช้ Chrome หรือ Edge หรือส่งออกทีละโฟลเดอร์",
"start": "ส่งออก",
"cancel": "ยกเลิก",
"running_title": "กำลังส่งออกอีเมล",
"progress_label": "ความคืบหน้าการส่งออก",
"status_exported": "ข้อความที่ส่งออกแล้ว: {count} จาก {total}",
"paused": "หยุดพักถึง {time}: เซิร์ฟเวอร์ขอให้พัก การส่งออกจะทำต่อเอง",
"background": "ทำงานเบื้องหลัง",
"cancel_export": "ยกเลิกการส่งออก",
"chip": "กำลังส่งออกอีเมล: {percent}%",
"chip_paused": "การส่งออกหยุดพักถึง {time}",
"done_title": "ส่งออกเสร็จแล้ว",
"cancelled_title": "ยกเลิกการส่งออกแล้ว",
"failed_title": "การส่งออกหยุดลง",
"summary_exported": "ข้อความที่ส่งออกแล้ว: {count}",
"summary_file": "บันทึกเป็น {name} ({size}) แล้ว",
"summary_failed": "ส่งออกไม่ได้: {count}",
"download_again": "ดาวน์โหลดอีกครั้ง",
"too_large": "การส่งออกใหญ่เกินกว่าที่เบราว์เซอร์นี้เก็บไว้ในหน่วยความจำได้ ให้ใช้ Chrome หรือ Edge หรือส่งออกทีละโฟลเดอร์",
"close": "ปิด",
"untitled": "(ไม่มีหัวเรื่อง)",
"toast_done": "ส่งออกเสร็จแล้ว: {name}",
"toast_stopped": "การส่งออกหยุดลง",
"toast_show": "แสดง"
},
"recover_deleted": {
"tab": "กู้คืนรายการที่ลบ",
"title": "กู้คืนรายการที่ลบ",
@@ -516,8 +627,6 @@
"templates_import_errors": "ไม่สามารถนำเข้าแม่แบบบางรายการได้",
"templates_import_empty": "ไม่พบแม่แบบข้อความในไฟล์",
"export_email_error": "ส่งออกอีเมลไม่สำเร็จ",
"import_email_success": "นำเข้าอีเมลเรียบร้อยแล้ว",
"import_email_error": "นำเข้าอีเมลไม่สำเร็จ",
"rule_created": "สร้างกฎตัวกรองแล้ว",
"rule_merged": "เพิ่มลงในกฎ “{name}” แล้ว",
"rule_already_covered": "ครอบคลุมอยู่ในกฎ “{name}” แล้ว",
@@ -741,7 +850,6 @@
"new_folder": "สร้างโฟลเดอร์ใหม่…",
"rename": "เปลี่ยนชื่อ…",
"share": "แชร์…",
"import_email": "นำเข้าไฟล์ .eml หรือ .zip…",
"empty_folder": "ล้างข้อมูลในโฟลเดอร์",
"empty_folder_generic": "ล้างข้อมูลในโฟลเดอร์",
"delete_folder": "ลบโฟลเดอร์",
@@ -1398,7 +1506,6 @@
"view_in_light_mode": "แสดงผลในโหมดสว่าง",
"export_email": "ส่งออกเป็นไฟล์ .eml",
"forward_as_attachment": "ส่งต่อเป็นไฟล์แนบ",
"import_email": "นำเข้าไฟล์ .eml หรือ .zip…",
"keyboard_shortcuts": "แป้นพิมพ์ลัด",
"email_source": "ต้นฉบับอีเมล",
"draft_banner": "ข้อความนี้เป็นร่างจดหมาย",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Açık modda görüntüle",
"export_email": ".eml olarak dışa aktar",
"forward_as_attachment": "Ek olarak ilet",
"import_email": ".eml veya .zip içe aktar…",
"keyboard_shortcuts": "Klavye kısayolları",
"email_source": "E-posta Kaynağı",
"draft_banner": "Bu ileti bir taslaktır",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Bazı şablonlar içe aktarılamadı",
"templates_import_empty": "Dosyada şablon bulunamadı",
"export_email_error": "E-posta dışa aktarılamadı",
"import_email_success": "E-posta başarıyla içe aktarıldı",
"import_email_error": "E-posta içe aktarılamadı",
"rule_created": "Kural oluşturuldu",
"rule_merged": "“{name}” kuralına eklendi",
"rule_already_covered": "Zaten “{name}” kuralı kapsıyor",
@@ -3585,7 +3582,6 @@
"new_folder": "Yeni klasör…",
"rename": "Yeniden adlandır…",
"share": "Paylaş…",
"import_email": ".eml veya .zip içe aktar…",
"empty_folder": "Klasörü boşalt",
"empty_folder_generic": "Klasörü boşalt",
"delete_folder": "Klasörü sil",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "Birleşik görünümde arama kullanılamaz"
},
"mail_import": {
"menu_import": "E-posta içe aktar…",
"settings_label": "E-posta içe aktar",
"settings_description": ".eml, ZIP, mbox veya Outlook .pst ve .ost dosyalarından e-posta alın.",
"settings_button": "İçe aktar…",
"title": "E-posta içe aktar",
"description": ".eml, .zip, .mbox veya Outlook .pst ve .ost dosyalarını seçin.",
"choose_files": "Dosya seç…",
"chosen_files": "Seçilen dosyalar",
"remove_file": "{name} öğesini kaldır",
"format_eml": "İleti",
"format_zip": "ZIP arşivi",
"format_mbox": "mbox dosyası",
"format_pst": "Outlook veri dosyası",
"target_label": "Şuraya aktar",
"target_top": "En üst düzey",
"keep_folders": "Dosyadaki klasörleri yeniden oluştur",
"keep_folders_hint": "Aynı adlı klasörler yeniden kullanılır.",
"resume": "Bu dosyaların son içe aktarımının durduğu yerden devam et (zaten işlenen iletiler: {count})",
"quota_note": "Birçok sunucu bir kullanıcıdan saatte yalnızca yaklaşık 1000 ileti veya 50 MB kabul eder. Daha büyük bir içe aktarım duraklar ve sonra kendiliğinden devam eder.",
"start": "İçe aktar",
"cancel": "İptal",
"running_title": "E-posta içe aktarılıyor",
"progress_label": "İçe aktarma ilerlemesi",
"status_imported": "İçe aktarılan iletiler: {count}",
"reading_file": "{name} okunuyor",
"paused": "{time} saatine kadar duraklatıldı. Sunucu saatte en fazla {files} yükleme veya {size} kabul ediyor. İçe aktarım kendiliğinden devam eder.",
"paused_generic": "{time} saatine kadar duraklatıldı. Sunucunun yükleme sınırına ulaşıldı. İçe aktarım kendiliğinden devam eder.",
"background": "Arka planda çalıştır",
"cancel_import": "İçe aktarımı iptal et",
"chip": "E-posta içe aktarılıyor: %{percent}",
"chip_paused": "İçe aktarım {time} saatine kadar duraklatıldı",
"done_title": "İçe aktarım tamamlandı",
"cancelled_title": "İçe aktarım iptal edildi",
"failed_title": "İçe aktarım durdu",
"summary_imported": "İçe aktarılan iletiler: {count}",
"summary_folders": "Oluşturulan klasörler: {count}",
"summary_skipped": "İçe aktarılmayanlar:",
"skip_too_large": "Sunucunun kabul ettiğinden büyük: {count}",
"skip_deleted": "Thunderbird'de silinmiş: {count}",
"skip_embedded": "Başka iletilere ekli iletiler: {count}",
"skip_contact": "Kişiler: {count}",
"skip_calendar": "Takvim öğeleri: {count}",
"skip_other": "Diğer öğeler: {count}",
"summary_failed": "İçe aktarılamadı: {count}",
"failure_invalid": "geçerli bir ileti değil",
"untitled": "(konu yok)",
"cancelled_hint": "Daha sonra devam etmek için aynı dosyaları aynı klasöre yeniden içe aktarın.",
"close": "Kapat",
"pst_note": "Outlook dosyalarından yalnızca e-postalar alınır. Kişiler, takvim öğeleri ve görevler sayılır ve atlanır.",
"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.",
"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.",
"skip_unreadable": "Outlook dosyasında okunamayan: {count}",
"keep_folders_top_hint": "En üst düzeyde Gelen Kutusu, Gönderilmiş Öğeler, Silinmiş Öğeler ve benzerleri sizinkilere gider.",
"toast_done": "İçe aktarım tamamlandı. İçe aktarılan: {imported}, başarısız: {failed}.",
"toast_stopped": "İçe aktarım durdu.",
"toast_show": "Göster",
"section_title": "E-posta dosyaları",
"section_description": "Dosyalardan e-posta alın veya e-postalarınızı dosya olarak kaydedin."
},
"mail_export": {
"menu_export": "Klasörü dışa aktar…",
"settings_label": "Tüm e-postaları dışa aktar",
"settings_description": "Bu hesabın tüm klasörlerini mbox veya .eml dosyaları olarak tek bir ZIP dosyasına kaydedin.",
"settings_button": "Dışa aktar…",
"title_folder": "“{name}” klasörünü dışa aktar",
"title_account": "Tüm e-postaları dışa aktar",
"description_folder": "Bu klasördeki iletileri bir dosyaya kaydedin.",
"description_account": "Bu hesabın tüm klasörlerini tek bir ZIP dosyasına kaydedin.",
"format_label": "Biçim",
"format_mbox_folder": "mbox dosyası",
"format_eml_folder": ".eml dosyalarından ZIP",
"format_mbox_account": "Her klasör için bir mbox dosyalı ZIP",
"format_eml_account": "Her e-posta klasörü için bir klasörle .eml dosyalarından ZIP",
"hint_mbox": "Thunderbird, Apple Mail ve çoğu e-posta programı mbox dosyalarını açar.",
"hint_eml": "Her ileti için bir dosya; her e-posta programı açabilir.",
"save_picker": "Dosyanın nereye kaydedileceğini siz seçersiniz.",
"save_download": "Dışa aktarım bitince dosya indirilir.",
"save_memory": "Bu tarayıcı tüm dışa aktarımı bellekte tutar, bu yüzden en fazla 200 MB kaydedebilir. Daha fazlası için Chrome veya Edge kullanın ya da klasör klasör dışa aktarın.",
"start": "Dışa aktar",
"cancel": "İptal",
"running_title": "E-posta dışa aktarılıyor",
"progress_label": "Dışa aktarma ilerlemesi",
"status_exported": "Dışa aktarılan iletiler: {count} / {total}",
"paused": "{time} saatine kadar duraklatıldı: sunucu ara istedi. Dışa aktarım kendiliğinden devam eder.",
"background": "Arka planda çalıştır",
"cancel_export": "Dışa aktarımı iptal et",
"chip": "E-posta dışa aktarılıyor: %{percent}",
"chip_paused": "Dışa aktarım {time} saatine kadar duraklatıldı",
"done_title": "Dışa aktarım tamamlandı",
"cancelled_title": "Dışa aktarım iptal edildi",
"failed_title": "Dışa aktarım durdu",
"summary_exported": "Dışa aktarılan iletiler: {count}",
"summary_file": "{name} olarak kaydedildi ({size}).",
"summary_failed": "Dışa aktarılamadı: {count}",
"download_again": "Yeniden indir",
"too_large": "Dışa aktarım, bu tarayıcının bellekte tutabileceğinden büyük. Chrome veya Edge kullanın ya da klasör klasör dışa aktarın.",
"close": "Kapat",
"untitled": "(konu yok)",
"toast_done": "Dışa aktarım tamamlandı: {name}",
"toast_stopped": "Dışa aktarım durdu.",
"toast_show": "Göster"
},
"recover_deleted": {
"tab": "Silinen öğeleri kurtar",
"title": "Silinen öğeleri kurtar",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Перегляд у світлому режимі",
"export_email": "Експортувати як .eml",
"forward_as_attachment": "Переслати як вкладення",
"import_email": "Імпорт .eml або .zip…",
"keyboard_shortcuts": "Комбінації клавіш",
"email_source": "Джерело електронної пошти",
"draft_banner": "Це повідомлення є чернеткою",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "Деякі шаблони не вдалося імпортувати",
"templates_import_empty": "У файлі не знайдено шаблонів",
"export_email_error": "Не вдалося експортувати електронну пошту",
"import_email_success": "Електронну пошту успішно імпортовано",
"import_email_error": "Не вдалося імпортувати електронну пошту",
"rule_created": "Правило створено",
"rule_merged": "Додано до правила «{name}»",
"rule_already_covered": "Уже охоплено правилом «{name}»",
@@ -3585,7 +3582,6 @@
"new_folder": "Нова папка…",
"rename": "Перейменувати…",
"share": "Поділитися…",
"import_email": "Імпортувати .eml або .zip…",
"empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
},
"mail_import": {
"menu_import": "Імпортувати пошту…",
"settings_label": "Імпортувати пошту",
"settings_description": "Перенесіть пошту з файлів .eml, ZIP, mbox або Outlook .pst і .ost.",
"settings_button": "Імпортувати…",
"title": "Імпорт пошти",
"description": "Виберіть файли .eml, .zip, .mbox або Outlook .pst і .ost.",
"choose_files": "Вибрати файли…",
"chosen_files": "Вибрані файли",
"remove_file": "Прибрати {name}",
"format_eml": "Повідомлення",
"format_zip": "Архів ZIP",
"format_mbox": "Файл mbox",
"format_pst": "Файл даних Outlook",
"target_label": "Імпортувати до",
"target_top": "Верхній рівень",
"keep_folders": "Відтворити теки з файлу",
"keep_folders_hint": "Теки з тією самою назвою використовуються повторно.",
"resume": "Продовжити з місця, де зупинився останній імпорт цих файлів (уже оброблено повідомлень: {count})",
"quota_note": "Багато серверів приймають від одного користувача лише близько 1000 повідомлень або 50 МБ на годину. Більший імпорт призупиняється, а потім продовжується сам.",
"start": "Імпортувати",
"cancel": "Скасувати",
"running_title": "Імпорт пошти",
"progress_label": "Перебіг імпорту",
"status_imported": "Імпортовано повідомлень: {count}",
"reading_file": "Читання {name}",
"paused": "Пауза до {time}. Сервер приймає щонайбільше {files} завантажень або {size} на годину. Імпорт продовжиться сам.",
"paused_generic": "Пауза до {time}. Досягнуто ліміту завантажень сервера. Імпорт продовжиться сам.",
"background": "Продовжити у фоні",
"cancel_import": "Скасувати імпорт",
"chip": "Імпорт пошти: {percent} %",
"chip_paused": "Імпорт призупинено до {time}",
"done_title": "Імпорт завершено",
"cancelled_title": "Імпорт скасовано",
"failed_title": "Імпорт зупинено",
"summary_imported": "Імпортовано повідомлень: {count}",
"summary_folders": "Створено тек: {count}",
"summary_skipped": "Не імпортовано:",
"skip_too_large": "Більші, ніж приймає сервер: {count}",
"skip_deleted": "Видалені в Thunderbird: {count}",
"skip_embedded": "Повідомлення, вкладені в інші повідомлення: {count}",
"skip_contact": "Контакти: {count}",
"skip_calendar": "Елементи календаря: {count}",
"skip_other": "Інші елементи: {count}",
"summary_failed": "Не вдалося імпортувати: {count}",
"failure_invalid": "недійсне повідомлення",
"untitled": "(без теми)",
"cancelled_hint": "Щоб продовжити пізніше, знову імпортуйте ті самі файли до тієї самої теки.",
"close": "Закрити",
"pst_note": "З файлів Outlook переноситься лише пошта. Контакти, елементи календаря та завдання підраховуються й пропускаються.",
"pst_error_encrypted": "У цьому файлі Outlook використано сильне шифрування, яке Bulwark не може прочитати. Експортуйте пошту в Outlook до нового файлу .pst без шифрування.",
"pst_error_not_pst": "Цей файл не є файлом даних Outlook, який Bulwark може прочитати.",
"pst_error_unreadable": "Bulwark не зміг прочитати цей файл Outlook.",
"pst_error_worker": "Модуль читання файлів Outlook не завантажився. Перезавантажте сторінку й спробуйте ще раз.",
"error_over_quota": "Поштову скриньку заповнено, тому імпорт зупинено. Звільніть місце й знову імпортуйте ті самі файли до тієї самої теки, щоб продовжити.",
"error_forbidden": "Ви не можете додавати пошту до цієї теки, тому імпорт зупинено.",
"error_failures": "Забагато повідомлень поспіль не вдалося імпортувати, тому імпорт зупинено. Пізніше знову імпортуйте ті самі файли до тієї самої теки, щоб продовжити.",
"error_network": "З'єднання із сервером кілька разів переривалося, тому імпорт зупинено. Знову імпортуйте ті самі файли до тієї самої теки, щоб продовжити.",
"error_busy": "Ці файли вже імпортуються в іншій вкладці.",
"retry_hint": "Знову імпортуйте ті самі файли до тієї самої теки, щоб ще раз спробувати невдалі.",
"skip_unreadable": "Не читаються у файлі Outlook: {count}",
"keep_folders_top_hint": "На верхньому рівні Вхідні, Надіслані, Видалені й подібні потрапляють до ваших власних.",
"toast_done": "Імпорт завершено. Імпортовано: {imported}, не вдалося: {failed}.",
"toast_stopped": "Імпорт зупинено.",
"toast_show": "Показати",
"section_title": "Поштові файли",
"section_description": "Перенесіть пошту з файлів або збережіть свою пошту у файли."
},
"mail_export": {
"menu_export": "Експортувати теку…",
"settings_label": "Експортувати всю пошту",
"settings_description": "Зберегти всі теки цього облікового запису в один ZIP-файл як файли mbox або .eml.",
"settings_button": "Експортувати…",
"title_folder": "Експорт «{name}»",
"title_account": "Експорт усієї пошти",
"description_folder": "Зберегти повідомлення цієї теки у файл.",
"description_account": "Зберегти всі теки цього облікового запису в один ZIP-файл.",
"format_label": "Формат",
"format_mbox_folder": "Файл mbox",
"format_eml_folder": "ZIP із файлами .eml",
"format_mbox_account": "ZIP з одним файлом mbox на теку",
"format_eml_account": "ZIP із файлами .eml, по теці на кожну поштову теку",
"hint_mbox": "Thunderbird, Apple Mail і більшість поштових програм відкривають файли mbox.",
"hint_eml": "По файлу на повідомлення, їх відкриє будь-яка поштова програма.",
"save_picker": "Ви самі вибираєте, куди зберегти файл.",
"save_download": "Файл завантажиться, коли експорт завершиться.",
"save_memory": "Цей браузер тримає весь експорт у пам'яті, тож збереже не більше 200 МБ. Для більшого використовуйте Chrome або Edge чи експортуйте по одній теці.",
"start": "Експортувати",
"cancel": "Скасувати",
"running_title": "Експорт пошти",
"progress_label": "Перебіг експорту",
"status_exported": "Експортовано повідомлень: {count} з {total}",
"paused": "Пауза до {time}: сервер попросив перерву. Експорт продовжиться сам.",
"background": "Продовжити у фоні",
"cancel_export": "Скасувати експорт",
"chip": "Експорт пошти: {percent} %",
"chip_paused": "Експорт призупинено до {time}",
"done_title": "Експорт завершено",
"cancelled_title": "Експорт скасовано",
"failed_title": "Експорт зупинено",
"summary_exported": "Експортовано повідомлень: {count}",
"summary_file": "Збережено як {name} ({size}).",
"summary_failed": "Не вдалося експортувати: {count}",
"download_again": "Завантажити знову",
"too_large": "Експорт більший, ніж цей браузер може тримати в пам'яті. Використовуйте Chrome або Edge чи експортуйте по одній теці.",
"close": "Закрити",
"untitled": "(без теми)",
"toast_done": "Експорт завершено: {name}",
"toast_stopped": "Експорт зупинено.",
"toast_show": "Показати"
},
"recover_deleted": {
"tab": "Відновити видалене",
"title": "Відновити видалене",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "Xem ở chế độ sáng",
"export_email": "Xuất dưới dạng .eml",
"forward_as_attachment": "Chuyển tiếp dưới dạng tệp đính kèm",
"import_email": "Nhập .eml hoặc .zip…",
"keyboard_shortcuts": "Phím tắt",
"email_source": "Nguồn email",
"draft_banner": "Thư này là một bản nháp",
@@ -1292,8 +1291,6 @@
"templates_import_errors": "Không thể nhập một số mẫu",
"templates_import_empty": "Không tìm thấy mẫu nào trong tệp",
"export_email_error": "Không xuất được email",
"import_email_success": "Email được nhập thành công",
"import_email_error": "Không thể nhập email",
"rule_created": "Đã tạo quy tắc",
"rule_merged": "Đã thêm vào quy tắc “{name}”",
"rule_already_covered": "Đã được áp dụng theo quy tắc “{name}”",
@@ -3597,7 +3594,6 @@
"new_folder": "Thư mục mới…",
"rename": "Đổi tên…",
"share": "Chia sẻ…",
"import_email": "Nhập .eml hoặc .zip…",
"empty_folder": "Làm trống thư mục",
"empty_folder_generic": "Làm trống thư mục",
"delete_folder": "Xóa thư mục",
@@ -5466,6 +5462,117 @@
"unified_mailbox": {
"search_unavailable": "Tìm kiếm không có sẵn trong chế độ xem hợp nhất"
},
"mail_import": {
"menu_import": "Nhập thư…",
"settings_label": "Nhập thư",
"settings_description": "Đưa thư vào từ tệp .eml, ZIP, mbox hoặc .pst và .ost của Outlook.",
"settings_button": "Nhập…",
"title": "Nhập thư",
"description": "Chọn tệp .eml, .zip, .mbox hoặc .pst và .ost của Outlook.",
"choose_files": "Chọn tệp…",
"chosen_files": "Tệp đã chọn",
"remove_file": "Bỏ {name}",
"format_eml": "Thư",
"format_zip": "Tệp nén ZIP",
"format_mbox": "Tệp mbox",
"format_pst": "Tệp dữ liệu Outlook",
"target_label": "Nhập vào",
"target_top": "Cấp cao nhất",
"keep_folders": "Tạo lại các thư mục từ tệp",
"keep_folders_hint": "Thư mục cùng tên sẽ được dùng lại.",
"resume": "Tiếp tục từ chỗ lần nhập trước của các tệp này dừng lại (thư đã xử lý: {count})",
"quota_note": "Nhiều máy chủ chỉ nhận khoảng 1000 thư hoặc 50 MB mỗi giờ từ một người dùng. Lần nhập lớn hơn sẽ tạm dừng rồi tự tiếp tục.",
"start": "Nhập",
"cancel": "Hủy",
"running_title": "Đang nhập thư",
"progress_label": "Tiến độ nhập",
"status_imported": "Thư đã nhập: {count}",
"reading_file": "Đang đọc {name}",
"paused": "Tạm dừng đến {time}. Máy chủ nhận tối đa {files} lượt tải lên hoặc {size} mỗi giờ. Việc nhập sẽ tự tiếp tục.",
"paused_generic": "Tạm dừng đến {time}. Đã chạm giới hạn tải lên của máy chủ. Việc nhập sẽ tự tiếp tục.",
"background": "Chạy trong nền",
"cancel_import": "Hủy nhập",
"chip": "Đang nhập thư: {percent}%",
"chip_paused": "Việc nhập tạm dừng đến {time}",
"done_title": "Đã nhập xong",
"cancelled_title": "Đã hủy nhập",
"failed_title": "Việc nhập đã dừng",
"summary_imported": "Thư đã nhập: {count}",
"summary_folders": "Thư mục đã tạo: {count}",
"summary_skipped": "Không nhập:",
"skip_too_large": "Lớn hơn mức máy chủ nhận: {count}",
"skip_deleted": "Đã xóa trong Thunderbird: {count}",
"skip_embedded": "Thư đính kèm trong thư khác: {count}",
"skip_contact": "Danh bạ: {count}",
"skip_calendar": "Mục lịch: {count}",
"skip_other": "Mục khác: {count}",
"summary_failed": "Không nhập được: {count}",
"failure_invalid": "không phải thư hợp lệ",
"untitled": "(không có tiêu đề)",
"cancelled_hint": "Để tiếp tục sau, hãy nhập lại cùng các tệp vào cùng thư mục.",
"close": "Đóng",
"pst_note": "Từ tệp Outlook chỉ nhập thư. Danh bạ, mục lịch và công việc được đếm rồi bỏ qua.",
"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.",
"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.",
"skip_unreadable": "Không đọc được trong tệp Outlook: {count}",
"keep_folders_top_hint": "Ở cấp cao nhất, Hộp thư đến, Thư đã gửi, Thư đã xóa và các thư mục tương tự sẽ vào thư mục của bạn.",
"toast_done": "Đã nhập xong. Đã nhập: {imported}, thất bại: {failed}.",
"toast_stopped": "Việc nhập đã dừng.",
"toast_show": "Hiện",
"section_title": "Tệp thư",
"section_description": "Đưa thư vào từ tệp, hoặc lưu thư của bạn thành tệp."
},
"mail_export": {
"menu_export": "Xuất thư mục…",
"settings_label": "Xuất toàn bộ thư",
"settings_description": "Lưu mọi thư mục của tài khoản này vào một tệp ZIP, dưới dạng tệp mbox hoặc .eml.",
"settings_button": "Xuất…",
"title_folder": "Xuất “{name}”",
"title_account": "Xuất toàn bộ thư",
"description_folder": "Lưu các thư trong thư mục này thành một tệp.",
"description_account": "Lưu mọi thư mục của tài khoản này vào một tệp ZIP.",
"format_label": "Định dạng",
"format_mbox_folder": "Tệp mbox",
"format_eml_folder": "ZIP gồm các tệp .eml",
"format_mbox_account": "ZIP với một tệp mbox cho mỗi thư mục",
"format_eml_account": "ZIP gồm các tệp .eml, mỗi thư mục thư một thư mục",
"hint_mbox": "Thunderbird, Apple Mail và hầu hết các chương trình thư đều mở được tệp mbox.",
"hint_eml": "Mỗi thư một tệp, chương trình thư nào cũng mở được.",
"save_picker": "Bạn chọn nơi lưu tệp.",
"save_download": "Tệp sẽ được tải xuống khi xuất xong.",
"save_memory": "Trình duyệt này giữ toàn bộ bản xuất trong bộ nhớ nên chỉ lưu được tối đa 200 MB. Với dung lượng lớn hơn, hãy dùng Chrome hoặc Edge, hoặc xuất từng thư mục.",
"start": "Xuất",
"cancel": "Hủy",
"running_title": "Đang xuất thư",
"progress_label": "Tiến độ xuất",
"status_exported": "Thư đã xuất: {count} / {total}",
"paused": "Tạm dừng đến {time}: máy chủ yêu cầu nghỉ. Việc xuất sẽ tự tiếp tục.",
"background": "Chạy trong nền",
"cancel_export": "Hủy xuất",
"chip": "Đang xuất thư: {percent}%",
"chip_paused": "Việc xuất tạm dừng đến {time}",
"done_title": "Đã xuất xong",
"cancelled_title": "Đã hủy xuất",
"failed_title": "Việc xuất đã dừng",
"summary_exported": "Thư đã xuất: {count}",
"summary_file": "Đã lưu thành {name} ({size}).",
"summary_failed": "Không xuất được: {count}",
"download_again": "Tải xuống lại",
"too_large": "Bản xuất lớn hơn mức trình duyệt này giữ được trong bộ nhớ. Hãy dùng Chrome hoặc Edge, hoặc xuất từng thư mục.",
"close": "Đóng",
"untitled": "(không có tiêu đề)",
"toast_done": "Đã xuất xong: {name}",
"toast_stopped": "Việc xuất đã dừng.",
"toast_show": "Hiện"
},
"recover_deleted": {
"tab": "Khôi phục các mục đã xóa",
"title": "Khôi phục các mục đã xóa",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "淺色模式檢視",
"export_email": "匯出為 .eml",
"forward_as_attachment": "以附件形式轉寄",
"import_email": "匯入 .eml 或 .zip…",
"keyboard_shortcuts": "鍵盤快速鍵",
"email_source": "郵件原始碼",
"draft_banner": "這是一封草稿郵件",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "部分範本無法匯入",
"templates_import_empty": "檔案中找不到範本",
"export_email_error": "無法匯出郵件",
"import_email_success": "郵件已成功匯入",
"import_email_error": "無法匯入郵件",
"rule_created": "已建立規則",
"rule_merged": "已加入規則「{name}」",
"rule_already_covered": "規則「{name}」已涵蓋此項",
@@ -3585,7 +3582,6 @@
"new_folder": "新增信件匣…",
"rename": "重新命名…",
"share": "共用…",
"import_email": "匯入 .eml 或 .zip…",
"empty_folder": "清空信件匣",
"empty_folder_generic": "清空信件匣",
"delete_folder": "刪除信件匣",
@@ -5454,6 +5450,117 @@
"unified_mailbox": {
"search_unavailable": "整合檢視中無法使用搜尋"
},
"mail_import": {
"menu_import": "匯入郵件…",
"settings_label": "匯入郵件",
"settings_description": "從 .eml、ZIP、mbox 或 Outlook .pst 和 .ost 檔案匯入郵件。",
"settings_button": "匯入…",
"title": "匯入郵件",
"description": "請選擇 .eml、.zip、.mbox 或 Outlook .pst 和 .ost 檔案。",
"choose_files": "選擇檔案…",
"chosen_files": "已選檔案",
"remove_file": "移除 {name}",
"format_eml": "郵件",
"format_zip": "ZIP 壓縮檔",
"format_mbox": "mbox 檔案",
"format_pst": "Outlook 資料檔",
"target_label": "匯入到",
"target_top": "最上層",
"keep_folders": "依檔案重建資料夾",
"keep_folders_hint": "同名資料夾會重複使用。",
"resume": "從這些檔案上次匯入停止的地方繼續(已處理的郵件:{count})",
"quota_note": "許多伺服器每小時只接受單一使用者約 1000 封郵件或 50 MB。較大的匯入會暫停,然後自動繼續。",
"start": "匯入",
"cancel": "取消",
"running_title": "正在匯入郵件",
"progress_label": "匯入進度",
"status_imported": "已匯入郵件:{count}",
"reading_file": "正在讀取 {name}",
"paused": "已暫停至 {time}。伺服器每小時最多接受 {files} 次上傳或 {size}。匯入會自動繼續。",
"paused_generic": "已暫停至 {time}。已達伺服器的上傳上限。匯入會自動繼續。",
"background": "在背景執行",
"cancel_import": "取消匯入",
"chip": "正在匯入郵件:{percent}%",
"chip_paused": "匯入已暫停至 {time}",
"done_title": "匯入完成",
"cancelled_title": "匯入已取消",
"failed_title": "匯入已停止",
"summary_imported": "已匯入郵件:{count}",
"summary_folders": "已建立資料夾:{count}",
"summary_skipped": "未匯入:",
"skip_too_large": "超過伺服器允許的大小:{count}",
"skip_deleted": "已在 Thunderbird 中刪除:{count}",
"skip_embedded": "附在其他郵件中的郵件:{count}",
"skip_contact": "聯絡人:{count}",
"skip_calendar": "行事曆項目:{count}",
"skip_other": "其他項目:{count}",
"summary_failed": "無法匯入:{count}",
"failure_invalid": "不是有效的郵件",
"untitled": "(無主旨)",
"cancelled_hint": "如需稍後繼續,請再次把相同的檔案匯入到同一個資料夾。",
"close": "關閉",
"pst_note": "Outlook 檔案中只匯入郵件。聯絡人、行事曆項目和工作會被計數並略過。",
"pst_error_encrypted": "此 Outlook 檔案使用了 Bulwark 無法讀取的高強度加密。請在 Outlook 中把郵件匯出到一個不加密的新 .pst 檔案。",
"pst_error_not_pst": "此檔案不是 Bulwark 能讀取的 Outlook 資料檔。",
"pst_error_unreadable": "Bulwark 無法讀取此 Outlook 檔案。",
"pst_error_worker": "Outlook 檔案讀取元件未能載入。請重新載入頁面後再試。",
"error_over_quota": "信箱已滿,匯入已停止。請騰出空間,然後把相同的檔案再次匯入同一個資料夾即可繼續。",
"error_forbidden": "您無權在此資料夾新增郵件,匯入已停止。",
"error_failures": "連續失敗的郵件太多,匯入已停止。請稍後把相同的檔案再次匯入同一個資料夾以繼續。",
"error_network": "與伺服器的連線多次失敗,匯入已停止。請把相同的檔案再次匯入同一個資料夾以繼續。",
"error_busy": "這些檔案已在另一個分頁中匯入。",
"retry_hint": "把相同的檔案再次匯入同一個資料夾,即可重試失敗的郵件。",
"skip_unreadable": "Outlook 檔案中無法讀取:{count}",
"keep_folders_top_hint": "在最上層,收件匣、寄件備份、刪除的郵件等會進入您自己的資料夾。",
"toast_done": "匯入完成。已匯入:{imported},失敗:{failed}。",
"toast_stopped": "匯入已停止。",
"toast_show": "顯示",
"section_title": "郵件檔案",
"section_description": "從檔案匯入郵件,或把郵件儲存為檔案。"
},
"mail_export": {
"menu_export": "匯出資料夾…",
"settings_label": "匯出所有郵件",
"settings_description": "把此帳戶的所有資料夾以 mbox 或 .eml 檔案儲存到一個 ZIP 檔案中。",
"settings_button": "匯出…",
"title_folder": "匯出「{name}」",
"title_account": "匯出所有郵件",
"description_folder": "把此資料夾中的郵件儲存為檔案。",
"description_account": "把此帳戶的所有資料夾儲存到一個 ZIP 檔案中。",
"format_label": "格式",
"format_mbox_folder": "mbox 檔案",
"format_eml_folder": ".eml 檔案的 ZIP",
"format_mbox_account": "每個資料夾一個 mbox 檔案的 ZIP",
"format_eml_account": ".eml 檔案的 ZIP,每個郵件資料夾一個資料夾",
"hint_mbox": "Thunderbird、Apple Mail 和大多數郵件程式都能開啟 mbox 檔案。",
"hint_eml": "每封郵件一個檔案,任何郵件程式都能開啟。",
"save_picker": "由您選擇檔案儲存位置。",
"save_download": "匯出完成後會下載檔案。",
"save_memory": "此瀏覽器會把整個匯出保存在記憶體中,因此最多只能儲存 200 MB。更大的匯出請使用 Chrome 或 Edge,或逐一匯出資料夾。",
"start": "匯出",
"cancel": "取消",
"running_title": "正在匯出郵件",
"progress_label": "匯出進度",
"status_exported": "已匯出郵件:{count} / {total}",
"paused": "已暫停至 {time}:伺服器要求暫停。匯出會自動繼續。",
"background": "在背景執行",
"cancel_export": "取消匯出",
"chip": "正在匯出郵件:{percent}%",
"chip_paused": "匯出已暫停至 {time}",
"done_title": "匯出完成",
"cancelled_title": "匯出已取消",
"failed_title": "匯出已停止",
"summary_exported": "已匯出郵件:{count}",
"summary_file": "已儲存為 {name}({size})。",
"summary_failed": "無法匯出:{count}",
"download_again": "重新下載",
"too_large": "匯出內容超過此瀏覽器能放在記憶體中的大小。請使用 Chrome 或 Edge,或逐一匯出資料夾。",
"close": "關閉",
"untitled": "(無主旨)",
"toast_done": "匯出完成:{name}",
"toast_stopped": "匯出已停止。",
"toast_show": "顯示"
},
"recover_deleted": {
"tab": "復原已刪除的項目",
"title": "復原已刪除的項目",
+111 -4
View File
@@ -461,7 +461,6 @@
"view_in_light_mode": "浅色模式查看",
"export_email": "导出为 .eml",
"forward_as_attachment": "作为附件转发",
"import_email": "导入 .eml 或 .zip…",
"keyboard_shortcuts": "键盘快捷键",
"email_source": "邮件源码",
"draft_banner": "这是一封草稿邮件",
@@ -1280,8 +1279,6 @@
"templates_import_errors": "部分模板无法导入",
"templates_import_empty": "文件中未找到模板",
"export_email_error": "导出邮件失败",
"import_email_success": "邮件导入成功",
"import_email_error": "导入邮件失败",
"rule_created": "规则已创建",
"rule_merged": "已添加到规则“{name}”",
"rule_already_covered": "规则“{name}”已涵盖此项",
@@ -3585,7 +3582,6 @@
"new_folder": "新建文件夹…",
"rename": "重命名…",
"share": "共享…",
"import_email": "导入 .eml 或 .zip…",
"empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹",
@@ -5279,6 +5275,117 @@
"unified_mailbox": {
"search_unavailable": "统一视图中无法使用搜索"
},
"mail_import": {
"menu_import": "导入邮件…",
"settings_label": "导入邮件",
"settings_description": "从 .eml、ZIP、mbox 或 Outlook .pst 和 .ost 文件导入邮件。",
"settings_button": "导入…",
"title": "导入邮件",
"description": "请选择 .eml、.zip、.mbox 或 Outlook .pst 和 .ost 文件。",
"choose_files": "选择文件…",
"chosen_files": "已选文件",
"remove_file": "移除 {name}",
"format_eml": "邮件",
"format_zip": "ZIP 压缩包",
"format_mbox": "mbox 文件",
"format_pst": "Outlook 数据文件",
"target_label": "导入到",
"target_top": "顶层",
"keep_folders": "按文件重建文件夹",
"keep_folders_hint": "同名文件夹会被重复使用。",
"resume": "从这些文件上次导入停止的地方继续(已处理的邮件:{count})",
"quota_note": "很多服务器每小时只接受单个用户约 1000 封邮件或 50 MB。更大的导入会暂停,然后自动继续。",
"start": "导入",
"cancel": "取消",
"running_title": "正在导入邮件",
"progress_label": "导入进度",
"status_imported": "已导入邮件:{count}",
"reading_file": "正在读取 {name}",
"paused": "已暂停至 {time}。服务器每小时最多接受 {files} 次上传或 {size}。导入会自动继续。",
"paused_generic": "已暂停至 {time}。已达到服务器的上传限制。导入会自动继续。",
"background": "在后台运行",
"cancel_import": "取消导入",
"chip": "正在导入邮件:{percent}%",
"chip_paused": "导入已暂停至 {time}",
"done_title": "导入完成",
"cancelled_title": "导入已取消",
"failed_title": "导入已停止",
"summary_imported": "已导入邮件:{count}",
"summary_folders": "已创建文件夹:{count}",
"summary_skipped": "未导入:",
"skip_too_large": "超过服务器允许的大小:{count}",
"skip_deleted": "已在 Thunderbird 中删除:{count}",
"skip_embedded": "附在其他邮件中的邮件:{count}",
"skip_contact": "联系人:{count}",
"skip_calendar": "日历项目:{count}",
"skip_other": "其他项目:{count}",
"summary_failed": "无法导入:{count}",
"failure_invalid": "不是有效的邮件",
"untitled": "(无主题)",
"cancelled_hint": "如需稍后继续,请再次把相同的文件导入到同一个文件夹。",
"close": "关闭",
"pst_note": "Outlook 文件中只导入邮件。联系人、日历项目和任务会被计数并跳过。",
"pst_error_encrypted": "此 Outlook 文件使用了 Bulwark 无法读取的高强度加密。请在 Outlook 中把邮件导出到一个不加密的新 .pst 文件。",
"pst_error_not_pst": "此文件不是 Bulwark 能读取的 Outlook 数据文件。",
"pst_error_unreadable": "Bulwark 无法读取此 Outlook 文件。",
"pst_error_worker": "Outlook 文件读取组件未能加载。请重新加载页面后再试。",
"error_over_quota": "邮箱已满,导入已停止。请腾出空间,然后把相同的文件再次导入同一个文件夹即可继续。",
"error_forbidden": "您无权向此文件夹添加邮件,导入已停止。",
"error_failures": "连续失败的邮件太多,导入已停止。请稍后把相同的文件再次导入同一个文件夹以继续。",
"error_network": "与服务器的连接多次失败,导入已停止。请把相同的文件再次导入同一个文件夹以继续。",
"error_busy": "这些文件已在另一个标签页中导入。",
"retry_hint": "把相同的文件再次导入同一个文件夹,即可重试失败的邮件。",
"skip_unreadable": "Outlook 文件中无法读取:{count}",
"keep_folders_top_hint": "在顶层,收件箱、已发送邮件、已删除邮件等会进入您自己的文件夹。",
"toast_done": "导入完成。已导入:{imported},失败:{failed}。",
"toast_stopped": "导入已停止。",
"toast_show": "显示",
"section_title": "邮件文件",
"section_description": "从文件导入邮件,或把邮件保存为文件。"
},
"mail_export": {
"menu_export": "导出文件夹…",
"settings_label": "导出所有邮件",
"settings_description": "把此账户的所有文件夹以 mbox 或 .eml 文件保存到一个 ZIP 文件中。",
"settings_button": "导出…",
"title_folder": "导出“{name}”",
"title_account": "导出所有邮件",
"description_folder": "把此文件夹中的邮件保存为文件。",
"description_account": "把此账户的所有文件夹保存到一个 ZIP 文件中。",
"format_label": "格式",
"format_mbox_folder": "mbox 文件",
"format_eml_folder": ".eml 文件的 ZIP",
"format_mbox_account": "每个文件夹一个 mbox 文件的 ZIP",
"format_eml_account": ".eml 文件的 ZIP,每个邮件文件夹一个文件夹",
"hint_mbox": "Thunderbird、Apple Mail 和大多数邮件程序都能打开 mbox 文件。",
"hint_eml": "每封邮件一个文件,任何邮件程序都能打开。",
"save_picker": "由您选择文件保存位置。",
"save_download": "导出完成后会下载文件。",
"save_memory": "此浏览器会把整个导出保存在内存中,因此最多只能保存 200 MB。更大的导出请使用 Chrome 或 Edge,或逐个文件夹导出。",
"start": "导出",
"cancel": "取消",
"running_title": "正在导出邮件",
"progress_label": "导出进度",
"status_exported": "已导出邮件:{count} / {total}",
"paused": "已暂停至 {time}:服务器要求暂停。导出会自动继续。",
"background": "在后台运行",
"cancel_export": "取消导出",
"chip": "正在导出邮件:{percent}%",
"chip_paused": "导出已暂停至 {time}",
"done_title": "导出完成",
"cancelled_title": "导出已取消",
"failed_title": "导出已停止",
"summary_exported": "已导出邮件:{count}",
"summary_file": "已保存为 {name}({size})。",
"summary_failed": "无法导出:{count}",
"download_again": "重新下载",
"too_large": "导出内容超过了此浏览器能放在内存中的大小。请使用 Chrome 或 Edge,或逐个文件夹导出。",
"close": "关闭",
"untitled": "(无主题)",
"toast_done": "导出完成:{name}",
"toast_stopped": "导出已停止。",
"toast_show": "显示"
},
"recover_deleted": {
"tab": "恢复已删除项目",
"title": "恢复已删除项目",
+115 -1
View File
@@ -32,9 +32,11 @@
"@tiptap/react": "^3.32.1",
"@tiptap/starter-kit": "^3.32.1",
"@tiptap/suggestion": "^3.32.1",
"buffer": "6.0.3",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dompurify": "^3.4.12",
"fflate": "0.8.3",
"ioredis": "^5.11.1",
"jalaali-js": "^2.0.1",
"jszip": "^3.10.2",
@@ -43,6 +45,7 @@
"otpauth": "^9.5.2",
"pdfjs-dist": "^6.4.299",
"postal-mime": "^4.1.4",
"pst-extractor": "1.12.0",
"qrcode": "^1.5.4",
"react": "^19.3.1",
"react-dom": "^19.3.1",
@@ -4921,6 +4924,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz",
@@ -5000,6 +5023,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6142,7 +6189,6 @@
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
@@ -6542,6 +6588,22 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/icu-minify": {
"version": "4.14.9",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.14.9.tgz",
@@ -6557,6 +6619,26 @@
"@formatjs/icu-messageformat-parser": "^3.4.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -7619,6 +7701,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -8533,6 +8621,20 @@
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/pst-extractor": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/pst-extractor/-/pst-extractor-1.12.0.tgz",
"integrity": "sha512-mO87iT2FGXc3MZVQ8YVIHn8GghM3WI8Dvqn872JEYXo0Bi5EFewS6hlmdPTasfFzrAJgJgnuA8EXSYSxIrCcyQ==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.7.1",
"long": "^5.3.2",
"uuid-parse": "^1.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8825,6 +8927,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -9728,6 +9836,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz",
"integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+5 -2
View File
@@ -22,8 +22,8 @@
"typescript"
],
"scripts": {
"dev": "node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack",
"build": "node scripts/build-plugin-sandbox.mjs && next build --turbopack",
"dev": "node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack",
"build": "node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack",
"build:lite": "node scripts/lite/build.mjs",
"lite:serve": "node scripts/lite/serve.mjs out 4173",
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
@@ -62,9 +62,11 @@
"@tiptap/react": "^3.32.1",
"@tiptap/starter-kit": "^3.32.1",
"@tiptap/suggestion": "^3.32.1",
"buffer": "6.0.3",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dompurify": "^3.4.12",
"fflate": "0.8.3",
"ioredis": "^5.11.1",
"jalaali-js": "^2.0.1",
"jszip": "^3.10.2",
@@ -73,6 +75,7 @@
"otpauth": "^9.5.2",
"pdfjs-dist": "^6.4.299",
"postal-mime": "^4.1.4",
"pst-extractor": "1.12.0",
"qrcode": "^1.5.4",
"react": "^19.3.1",
"react-dom": "^19.3.1",
+49
View File
@@ -0,0 +1,49 @@
// Builds the Outlook PST import worker (lib/mail-import/pst/worker.ts) into
// public/pst-worker.js. The page loads it only when someone imports a .pst
// or .ost file (lib/mail-import/pst-source.ts).
//
// pst-extractor is written for Node: it needs Buffer, `fs` (only to read the
// file in pieces), `zlib` (only to inflate OST 2013 blocks) and iconv-lite
// (only to decode text in a codepage). In the worker those become the
// `buffer` package, a File-backed fs, fflate and TextDecoder; iconv-lite's
// tables alone would be three times the rest of the worker.
//
// node scripts/build-pst-worker.mjs (npm run build, npm run dev, npm run build:lite)
import { build } from 'esbuild';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const PST_WORKER_OUTFILE = path.join(root, 'public', 'pst-worker.js');
export async function buildPstWorker({ outfile = PST_WORKER_OUTFILE } = {}) {
const shims = path.join(root, 'lib/mail-import/pst/shims');
const result = await build({
entryPoints: [path.join(root, 'lib/mail-import/pst/worker.ts')],
bundle: true,
format: 'iife',
platform: 'browser',
target: 'es2020',
outfile,
minify: true,
// The licences of pst-extractor, buffer, fflate and long stay with their code.
legalComments: 'eof',
alias: {
'@': root,
fs: path.join(shims, 'fs.ts'),
zlib: path.join(shims, 'zlib.ts'),
'iconv-lite': path.join(shims, 'iconv.ts'),
},
inject: [path.join(shims, 'buffer.ts')],
define: { 'process.env.NODE_ENV': '"production"', global: 'globalThis' },
metafile: true,
logLevel: 'warning',
});
return { bytes: Object.values(result.metafile.outputs).reduce((n, o) => n + o.bytes, 0), inputs: Object.keys(result.metafile.inputs) };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const { bytes } = await buildPstWorker();
console.log(`[build-pst-worker] ${path.relative(root, PST_WORKER_OUTFILE)} ${bytes}`);
}
+5
View File
@@ -22,6 +22,7 @@ import { makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot } from
import { runPrepare } from "./prepare.mjs";
import { runPostbuild } from "./postbuild.mjs";
import { verifyExport } from "./verify.mjs";
import { buildPstWorker } from "../build-pst-worker.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const argv = process.argv.slice(2);
@@ -72,6 +73,10 @@ if (!argv.includes("--skip-prepare")) {
runPrepare({ argv: argv.filter((a) => a === "--in-place"), env });
}
// The Outlook PST import worker is a file in public/, which the export copies.
const pstWorker = await buildPstWorker();
console.log(`[lite] PST import worker ${pstWorker.bytes} bytes`);
const require = createRequire(import.meta.url);
const nextBin = require.resolve("next/dist/bin/next", { paths: [repoRoot] });
console.log(`[lite] next build --turbopack (static export, ${target} target, build ${env.NEXT_PUBLIC_LITE_BUILD_ID})`);
+105
View File
@@ -0,0 +1,105 @@
import { create } from "zustand";
import type { ImportProgress } from "@/lib/mail-import/engine";
import type { ExportProgress } from "@/lib/mail-export/engine";
/**
* The mail import and export dialog: what it was opened for, whether it is
* on screen, and the job it runs. A job keeps running while the dialog is
* hidden ("Run in background"); a small chip then shows its progress and
* brings the dialog back. One job at a time.
*/
export interface ImportRequest {
kind: "import";
/** The folder it was opened from (its id in the mail store), if any. */
mailboxId?: string;
}
export interface ExportRequest {
kind: "export";
/** A folder (its id in the mail store), or the whole account when absent. */
mailboxId?: string;
}
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 =
| (JobBase & { kind: "import"; progress: ImportProgress })
| (JobBase & { kind: "export"; progress: ExportProgress });
interface MailTransferState {
request: TransferRequest | null;
visible: boolean;
job: TransferJob | null;
/** Opens the dialog; false when a job is running already (it is shown instead). */
open: (request: TransferRequest) => boolean;
/** Hides the dialog; a running job goes on. */
hide: () => void;
show: () => void;
/**
* Closes the dialog and forgets a finished job. An export's private file
* is deleted once its download had time to finish, or at once with `now`.
*/
close: (options?: { now?: boolean }) => void;
startJob: (job: TransferJob) => void;
updateProgress: (progress: ImportProgress | ExportProgress) => 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 {
return !!job && (job.progress.phase === "running" || job.progress.phase === "paused");
}
function discardExport(job: TransferJob | null, now = false): void {
if (job?.kind === "export") void job.progress.file?.discard(now).catch(() => {});
}
export const useMailTransferStore = create<MailTransferState>()((set, get) => ({
request: null,
visible: false,
job: null,
open: (request) => {
if (isJobActive(get().job)) {
set({ visible: true });
return false;
}
discardExport(get().job);
set({ request, visible: true, job: null });
return true;
},
hide: () => set({ visible: false }),
show: () => set({ visible: true }),
close: (options) => {
if (isJobActive(get().job)) {
set({ visible: false });
return;
}
discardExport(get().job, options?.now);
set({ request: null, visible: false, job: null });
},
startJob: (job) => set({ job }),
updateProgress: (progress) => {
const job = get().job;
if (job) set({ job: { ...job, progress } as TransferJob });
},
cancelJob: (reason) => get().job?.controller.abort(reason),
}));
/** Opens the import dialog, for a folder or (from Settings) for none. */
export function openMailImport(request: Omit<ImportRequest, "kind"> = {}): void {
useMailTransferStore.getState().open({ kind: "import", ...request });
}
/** Opens the export dialog for a folder, or for the whole account. */
export function openMailExport(request: Omit<ExportRequest, "kind"> = {}): void {
useMailTransferStore.getState().open({ kind: "export", ...request });
}