fix: report partial and failed attachment ZIP downloads

This commit is contained in:
VasilisNtovolos
2026-10-29 14:02:24 +02:00
parent 99b6857d90
commit 995262bde2
31 changed files with 352 additions and 2 deletions
@@ -0,0 +1,313 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import JSZip from 'jszip';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { JMAPClient } from '@/lib/jmap/client';
import { useAuthStore } from '@/stores/auth-store';
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { ToastContainer } from '@/components/ui/toast';
import en from '@/locales/en/common.json';
// Keep the actual viewer handler, translations, JMAP routing/auth/retries and
// JSZip. Stub unrelated viewer hosts, as in the other viewer component tests.
vi.unmock('next-intl');
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/components/email/external-sender-chip', () => ({ ExternalSenderChip: () => null }));
const { EmailViewer } = await import('../email-viewer');
const initialAuth = useAuthStore.getState();
const initialEmail = useEmailStore.getState();
const initialSettings = useSettingsStore.getState();
const clients: JMAPClient[] = [];
const alpha = new TextEncoder().encode('synthetic alpha\nΚαλημέρα\n');
const beta = new Uint8Array([0, 1, 127, 128, 255, 10]);
const attachments = [
{ partId: '2', blobId: 'blob-a', name: 'report.txt', type: 'application/octet-stream', size: alpha.length },
{ partId: '3', blobId: 'blob-b', name: 'data.bin', type: 'application/octet-stream', size: beta.length },
];
const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox;
const routes = ['primary', 'shared', 'secondary-stamped', 'secondary-folder'] as const;
type Route = typeof routes[number];
function DownloadToasts() {
const toasts = useToastStore(s => s.toasts);
return <ToastContainer toasts={toasts} onClose={useToastStore.getState().removeToast} />;
}
function bytesResponse(bytes: Uint8Array) {
const buffer = new Uint8Array(bytes).buffer;
const response = new Response(buffer, { headers: { 'Content-Type': 'application/octet-stream' } });
// Node's Response returns a Node Blob. JSZip uses the DOM FileReader, so
// return the identical bytes as a jsdom Blob, as in the drag routing tests.
response.blob = async () => new Blob([buffer], { type: 'application/octet-stream' });
return response;
}
function readBlob(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
async function connect(secondary: boolean) {
const server = `https://${secondary ? 'secondary' : 'primary'}.example.test`;
const accountId = secondary ? 'other-primary' : 'postal12345';
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: {
[accountId]: { name: 'Own', isPersonal: true, accountCapabilities: {} },
nrichmond: { name: 'Shared', isPersonal: false, accountCapabilities: {} },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': accountId },
apiUrl: `${server}/api`,
downloadUrl: `${server}/${secondary ? 'objects' : 'download'}/{accountId}/{blobId}/{name}?accept={type}`,
uploadUrl: `${server}/upload/{accountId}`, eventSourceUrl: '',
}), { status: 200 }));
const client = secondary
? JMAPClient.withBearer(server, 'synthetic-secondary-token', 'secondary-login')
: new JMAPClient(server, 'primary-login', 'synthetic-password');
clients.push(client);
await client.connect();
return client;
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
URL.createObjectURL = vi.fn(() => 'blob:download-all');
URL.revokeObjectURL = vi.fn();
useToastStore.getState().clearToasts();
useSettingsStore.setState({ markAsReadDelay: -1, attachmentImagePreviewsEnabled: false, mailAttachmentAction: 'download' });
});
afterEach(() => {
cleanup();
clients.splice(0).forEach(client => client.disconnect());
useAuthStore.setState(initialAuth);
useEmailStore.setState(initialEmail);
useSettingsStore.setState(initialSettings);
useToastStore.getState().clearToasts();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
async function openViewer(route: Route = 'secondary-stamped', parts = attachments) {
const a = await connect(false);
const b = await connect(true);
useAuthStore.setState({ client: a, activeAccountId: 'login-primary', identities: [],
getClientForAccount: id => id === 'login-primary' ? a : id === 'login-other' ? b : undefined,
getAllConnectedClients: () => new Map([['login-primary', a], ['login-other', b]]),
});
const email: Email = {
id: 'email', threadId: 'thread', blobId: 'raw-blob', subject: 'Download test',
mailboxIds: {}, keywords: { $seen: true }, size: 100, hasAttachment: true,
receivedAt: '2026-01-01T00:00:00Z', from: [{ email: 'sender@example.test' }], to: [],
preview: 'Body', textBody: [{ partId: '1', blobId: 'body-blob', type: 'text/plain', size: 4 }], htmlBody: [],
bodyValues: { '1': { value: 'Body', isEncodingProblem: false, isTruncated: false } },
attachments: parts,
...(route === 'secondary-stamped' ? { sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' } : {}),
};
const selectedMailbox = route === 'primary' ? 'inbox' : sharedMailbox.id;
useEmailStore.setState({ selectedEmail: email, selectedMailbox, isUnifiedView: false,
viewingAccountId: route === 'secondary-folder' ? 'login-other' : null,
mailboxes: [primaryMailbox, sharedMailbox], accountMailboxes: { 'login-other': [primaryMailbox, sharedMailbox] },
searchQuery: '', searchMailboxId: '',
});
const fetchMock = vi.mocked(fetch);
fetchMock.mockReset();
fetchMock.mockRejectedValue(new Error('Unexpected network request'));
const downloadA = vi.spyOn(a, 'downloadBlob');
const downloadB = vi.spyOn(b, 'downloadBlob');
const downloads: { href: string; name: string; connected: boolean; notifications: number }[] = [];
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function(this: HTMLAnchorElement) {
downloads.push({ href: this.href, name: this.download, connected: document.body.contains(this),
notifications: useToastStore.getState().toasts.length });
});
render(<NextIntlClientProvider locale="en" messages={en} timeZone="UTC">
<EmailViewer email={email} blobSource={resolveEmailBlobContext(email, a)}
mailboxes={[primaryMailbox, sharedMailbox]} selectedMailbox={selectedMailbox} />
<DownloadToasts />
</NextIntlClientProvider>);
const button = screen.getAllByTitle('Download all')[0];
const secondary = route.startsWith('secondary');
const owner = route === 'primary' ? 'postal12345' : 'nrichmond';
function assertRequests(blobs: string[]) {
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual(blobs.map(id => {
const name = parts.find(part => part.blobId === id)!.name;
return `https://${secondary ? 'secondary' : 'primary'}.example.test/${secondary ? 'objects' : 'download'}/${owner}/${id}/${name}?accept=application%2Foctet-stream`;
}));
blobs.forEach((_id, i) => {
expect(fetchMock.mock.calls[i][1]?.headers).toEqual(expect.objectContaining({
Authorization: secondary ? 'Bearer synthetic-secondary-token' : `Basic ${btoa('primary-login:synthetic-password')}`,
}));
});
expect(downloadA).not.toHaveBeenCalled();
expect(downloadB).not.toHaveBeenCalled();
expect(useEmailStore.getState().selectedEmail).toBe(email);
}
function click() {
fireEvent.click(button);
expect(button).toBeDisabled();
expect(button.querySelector('.animate-spin')).not.toBeNull();
}
async function finish() {
await waitFor(() => expect(button).toBeEnabled(), { timeout: 5000 });
for (const control of screen.getAllByTitle('Download all')) expect(control).toBeEnabled();
expect(button.querySelector('.animate-spin')).toBeNull();
}
async function assertZip(names: string[], contents: Uint8Array[]) {
// Browser-download initiation only; this does not verify native file creation.
expect(downloads).toEqual([{ href: 'blob:download-all', name: 'attachments_Download test.zip', connected: true, notifications: 0 }]);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob;
expect(blob.type).toBe('application/zip');
const zip = await JSZip.loadAsync(await readBlob(blob), { checkCRC32: true });
expect(Object.keys(zip.files)).toEqual(names);
for (let i = 0; i < names.length; i++) {
expect(Array.from(await zip.file(names[i])!.async('uint8array'))).toEqual(Array.from(contents[i]));
}
}
function success() {
fetchMock.mockImplementation(async url => bytesResponse(String(url).includes('/blob-a/') ? alpha : beta));
}
function noDownload() {
expect(downloads).toEqual([]);
expect(URL.createObjectURL).not.toHaveBeenCalled();
}
async function feedback(type: 'warning' | 'error', title: string) {
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([expect.objectContaining({ type, title })]));
expect(screen.getByText(title)).toBeVisible();
}
async function recover(previousRequests: string[]) {
act(() => useToastStore.getState().clearToasts());
success(); click();
await waitFor(() => expect(downloads).toHaveLength(1));
await finish();
assertRequests([...previousRequests, 'blob-a', 'blob-b']);
expect(useToastStore.getState().toasts).toEqual([]);
await assertZip(['report.txt', 'data.bin'], [alpha, beta]);
}
return { fetchMock, downloads, button, click, finish, assertRequests, assertZip, success, noDownload, feedback, recover };
}
describe('Download all attachments', () => {
it.each(routes)('downloads exact filenames and bytes through %s routing without feedback', async route => {
const p = await openViewer(route);
p.success(); p.click();
await waitFor(() => expect(p.downloads).toHaveLength(1));
await p.finish();
p.assertRequests(['blob-a', 'blob-b']);
expect(useToastStore.getState().toasts).toEqual([]);
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
});
it('preserves distinct bytes when attachment names collide', async () => {
const p = await openViewer('shared', attachments.map(part => ({ ...part, name: 'report.txt' })));
p.success(); p.click();
await waitFor(() => expect(p.downloads).toHaveLength(1));
await p.finish();
p.assertRequests(['blob-a', 'blob-b']);
expect(useToastStore.getState().toasts).toEqual([]);
await p.assertZip(['report.txt', 'report (1).txt'], [alpha, beta]);
});
it.each([1, 2])('discloses %i omitted attachments once, after initiating the partial ZIP download', async omitted => {
const parts = omitted === 1 ? attachments : [...attachments, { ...attachments[1], partId: '4', blobId: 'blob-c', name: 'notes.bin' }];
const p = await openViewer('secondary-stamped', parts);
p.fetchMock.mockResolvedValueOnce(bytesResponse(alpha)).mockResolvedValueOnce(new Response('refused', { status: 403 }));
if (omitted === 2) p.fetchMock.mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
p.click();
await waitFor(() => expect(p.downloads).toHaveLength(1));
await p.finish();
p.assertRequests(parts.map(part => part.blobId));
await p.assertZip(['report.txt'], [alpha]);
await p.feedback('warning', omitted === 1 ? '1 attachment was omitted from the ZIP.' : '2 attachments were omitted from the ZIP.');
});
it('reports total member failure once without an empty ZIP and retries through the same control', async () => {
const p = await openViewer();
p.fetchMock.mockResolvedValueOnce(new Response('refused', { status: 403 })).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
p.click(); await p.finish();
p.assertRequests(['blob-a', 'blob-b']);
p.noDownload();
await p.feedback('error', "Couldn't download");
await p.recover(['blob-a', 'blob-b']);
});
it('counts only an exhausted network retry as an omission and makes fresh requests on another attempt', async () => {
const p = await openViewer();
p.fetchMock.mockResolvedValueOnce(bytesResponse(alpha))
.mockRejectedValueOnce(new TypeError('synthetic offline')).mockRejectedValueOnce(new TypeError('synthetic offline'));
p.click();
await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 });
await p.finish();
p.assertRequests(['blob-a', 'blob-b', 'blob-b']);
await p.assertZip(['report.txt'], [alpha]);
await p.feedback('warning', '1 attachment was omitted from the ZIP.');
// Keep the UI/client/message, dismiss the old notification and recover.
p.downloads.length = 0;
vi.mocked(URL.createObjectURL).mockClear();
await p.recover(['blob-a', 'blob-b', 'blob-b']);
});
it.each([false, true])('reports ZIP-generation rejection once, including after member failure=%s, and can retry', async partial => {
const p = await openViewer();
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(JSZip.prototype, 'generateAsync').mockRejectedValueOnce(new Error('synthetic ZIP failure'));
p.fetchMock.mockResolvedValueOnce(bytesResponse(alpha)).mockResolvedValueOnce(partial
? new Response('refused', { status: 403 }) : bytesResponse(beta));
p.click(); await p.finish();
p.assertRequests(['blob-a', 'blob-b']);
p.noDownload();
await p.feedback('error', "Couldn't download");
expect(diagnostic).toHaveBeenCalledWith('Failed to download attachments:', expect.any(Error));
// No unhandled-rejection suppression: Vitest must exit successfully.
await p.recover(['blob-a', 'blob-b']);
});
it('does not report a terminal failure when the existing automatic retry recovers', async () => {
const p = await openViewer();
p.fetchMock.mockRejectedValueOnce(new TypeError('synthetic transient'))
.mockResolvedValueOnce(bytesResponse(alpha)).mockResolvedValueOnce(bytesResponse(beta));
p.click();
await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 });
await p.finish();
p.assertRequests(['blob-a', 'blob-a', 'blob-b']);
expect(useToastStore.getState().toasts).toEqual([]);
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
});
it('keeps the control disabled while fetching and ignores another click', async () => {
const p = await openViewer();
let release!: (response: Response) => void;
p.fetchMock.mockImplementationOnce(() => new Promise(resolve => { release = resolve; })).mockResolvedValueOnce(bytesResponse(beta));
p.click();
await waitFor(() => expect(p.fetchMock).toHaveBeenCalledTimes(1));
fireEvent.click(p.button);
expect(p.fetchMock).toHaveBeenCalledTimes(1);
expect(useToastStore.getState().toasts).toEqual([]);
await act(async () => release(bytesResponse(alpha)));
await waitFor(() => expect(p.downloads).toHaveLength(1));
await p.finish();
p.assertRequests(['blob-a', 'blob-b']);
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
});
});
+10 -2
View File
@@ -3028,7 +3028,10 @@ export function EmailViewer({
}
}
if (added === 0) return;
if (added === 0) {
toast.error(tFiles('download_error'));
return;
}
const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' });
const objectUrl = URL.createObjectURL(zipBlob);
@@ -3039,10 +3042,15 @@ export function EmailViewer({
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
const omitted = effectiveAttachments.length - added;
if (omitted > 0) toast.warning(t('download_all_partial', { count: omitted }));
} catch (error) {
console.error('Failed to download attachments:', error);
toast.error(tFiles('download_error'));
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
+1
View File
@@ -450,6 +450,7 @@
"important": "مهم",
"download": "تنزيل",
"download_all": "تنزيل الكل",
"download_all_partial": "{count, plural, zero {لم تُحذف أي مرفقات من ملف ZIP.} one {حُذف مرفق واحد من ملف ZIP.} two {حُذف مرفقان من ملف ZIP.} few {حُذفت # مرفقات من ملف ZIP.} many {حُذف # مرفقًا من ملف ZIP.} other {حُذف # مرفق من ملف ZIP.}}",
"from": "من",
"to": "إلى",
"cc": "نسخة",
+1
View File
@@ -450,6 +450,7 @@
"important": "Important",
"download": "Baixa",
"download_all": "Baixa-ho tot",
"download_all_partial": "{count, plural, one {S’ha omès # fitxer adjunt del ZIP.} other {S’han omès # fitxers adjunts del ZIP.}}",
"from": "De",
"to": "Per a",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Důležité",
"download": "Stáhnout",
"download_all": "Stáhnout vše",
"download_all_partial": "{count, plural, one {Ze ZIPu byla vynechána # příloha.} few {Ze ZIPu byly vynechány # přílohy.} many {Ze ZIPu bylo vynecháno # přílohy.} other {Ze ZIPu bylo vynecháno # příloh.}}",
"from": "Od",
"to": "Komu",
"cc": "Kopie",
+1
View File
@@ -450,6 +450,7 @@
"important": "Vigtig",
"download": "Download",
"download_all": "Download alle",
"download_all_partial": "{count, plural, one {# vedhæftet fil blev udeladt fra ZIP-filen.} other {# vedhæftede filer blev udeladt fra ZIP-filen.}}",
"from": "Fra",
"to": "Til",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Wichtig",
"download": "Herunterladen",
"download_all": "Alle herunterladen",
"download_all_partial": "{count, plural, one {# Anhang wurde im ZIP-Archiv ausgelassen.} other {# Anhänge wurden im ZIP-Archiv ausgelassen.}}",
"from": "Von",
"to": "An",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Important",
"download": "Download",
"download_all": "Download all",
"download_all_partial": "{count, plural, one {# attachment was omitted from the ZIP.} other {# attachments were omitted from the ZIP.}}",
"from": "From",
"to": "To",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Importante",
"download": "Descargar",
"download_all": "Descargar todo",
"download_all_partial": "{count, plural, one {Se ha omitido # archivo adjunto del ZIP.} other {Se han omitido # archivos adjuntos del ZIP.}}",
"from": "De",
"to": "Para",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Oluline",
"download": "Laadi alla",
"download_all": "Laadi kõik alla",
"download_all_partial": "{count, plural, one {ZIP-failist jäeti välja # manus.} other {ZIP-failist jäeti välja # manust.}}",
"from": "Saatja",
"to": "Saaja",
"cc": "Koopia",
+1
View File
@@ -450,6 +450,7 @@
"important": "مهم",
"download": "دانلود",
"download_all": "دانلود همه",
"download_all_partial": "{count, plural, one {# پیوست از فایل ZIP حذف شد.} other {# پیوست از فایل ZIP حذف شد.}}",
"from": "از",
"to": "به",
"cc": "رونوشت",
+1
View File
@@ -450,6 +450,7 @@
"important": "Important",
"download": "Télécharger",
"download_all": "Tout télécharger",
"download_all_partial": "{count, plural, one {# pièce jointe a été omise du ZIP.} other {# pièces jointes ont été omises du ZIP.}}",
"from": "De",
"to": "À",
"cc": "CC",
+1
View File
@@ -399,6 +399,7 @@
"important": "חָשׁוּב",
"download": "הורד",
"download_all": "הורד הכל",
"download_all_partial": "{count, plural, one {# קובץ מצורף הושמט מקובץ ה-ZIP.} two {# קבצים מצורפים הושמטו מקובץ ה-ZIP.} other {# קבצים מצורפים הושמטו מקובץ ה-ZIP.}}",
"from": "מאת",
"to": "אל",
"cc": "עותק",
+1
View File
@@ -450,6 +450,7 @@
"important": "Fontos",
"download": "Letöltés",
"download_all": "Összes letöltése",
"download_all_partial": "{count, plural, one {# melléklet kimaradt a ZIP-fájlból.} other {# melléklet kimaradt a ZIP-fájlból.}}",
"from": "Feladó",
"to": "Címzett",
"cc": "Másolat",
+1
View File
@@ -450,6 +450,7 @@
"important": "Importante",
"download": "Scarica",
"download_all": "Scarica tutto",
"download_all_partial": "{count, plural, one {# allegato è stato omesso dallo ZIP.} other {# allegati sono stati omessi dallo ZIP.}}",
"from": "Da",
"to": "A",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "重要",
"download": "ダウンロード",
"download_all": "すべてダウンロード",
"download_all_partial": "ZIPに含まれなかった添付ファイルは{count}件です。",
"from": "送信者",
"to": "宛先",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "중요",
"download": "다운로드",
"download_all": "모두 다운로드",
"download_all_partial": "ZIP에 첨부파일 {count}개가 포함되지 않았습니다.",
"from": "보낸 사람",
"to": "받는 사람",
"cc": "참조",
+1
View File
@@ -450,6 +450,7 @@
"important": "Svarīgi",
"download": "Lejupielādēt",
"download_all": "Lejupielādēt visu",
"download_all_partial": "{count, plural, zero {# pielikumi netika iekļauti ZIP failā.} one {# pielikums netika iekļauts ZIP failā.} other {# pielikumi netika iekļauti ZIP failā.}}",
"from": "No",
"to": "Kam",
"cc": "Kopija",
+1
View File
@@ -450,6 +450,7 @@
"important": "Чухал",
"download": "Татаж авах",
"download_all": "Бүгдийг нь татаж авах",
"download_all_partial": "ZIP файлд {count} хавсралт ороогүй.",
"from": "",
"to": "Хэнд",
"cc": "Хуулбар",
+1
View File
@@ -450,6 +450,7 @@
"important": "Viktig",
"download": "Last ned",
"download_all": "Last ned alle",
"download_all_partial": "{count, plural, one {# vedlegg ble utelatt fra ZIP-filen.} other {# vedlegg ble utelatt fra ZIP-filen.}}",
"from": "Fra",
"to": "Til",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Belangrijk",
"download": "Downloaden",
"download_all": "Alles downloaden",
"download_all_partial": "{count, plural, one {# bijlage is weggelaten uit het ZIP-bestand.} other {# bijlagen zijn weggelaten uit het ZIP-bestand.}}",
"from": "Van",
"to": "Aan",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Ważne",
"download": "Pobierz",
"download_all": "Pobierz wszystkie",
"download_all_partial": "{count, plural, one {Pominięto # załącznik w archiwum ZIP.} few {Pominięto # załączniki w archiwum ZIP.} many {Pominięto # załączników w archiwum ZIP.} other {Pominięto # załącznika w archiwum ZIP.}}",
"from": "Od",
"to": "Do",
"cc": "DW",
+1
View File
@@ -450,6 +450,7 @@
"important": "Importante",
"download": "Baixar",
"download_all": "Baixar tudo",
"download_all_partial": "{count, plural, one {# anexo foi omitido do ZIP.} other {# anexos foram omitidos do ZIP.}}",
"from": "De",
"to": "Para",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Important",
"download": "Descărcați",
"download_all": "Descărcați tot",
"download_all_partial": "{count, plural, one {# atașament a fost omis din ZIP.} few {# atașamente au fost omise din ZIP.} other {# de atașamente au fost omise din ZIP.}}",
"from": "De la",
"to": "Către",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "Важное",
"download": "Скачать",
"download_all": "Скачать все",
"download_all_partial": "{count, plural, one {# вложение не включено в ZIP-архив.} few {# вложения не включены в ZIP-архив.} many {# вложений не включены в ZIP-архив.} other {# вложения не включены в ZIP-архив.}}",
"from": "От",
"to": "Кому",
"cc": "Копия",
+1
View File
@@ -450,6 +450,7 @@
"important": "Dôležité",
"download": "Stiahnuť",
"download_all": "Stiahnuť všetko",
"download_all_partial": "{count, plural, one {Zo ZIPu bola vynechaná # príloha.} few {Zo ZIPu boli vynechané # prílohy.} many {Zo ZIPu bolo vynechaných # prílohy.} other {Zo ZIPu bolo vynechaných # príloh.}}",
"from": "Od",
"to": "Komu",
"cc": "Kópia",
+1
View File
@@ -1293,6 +1293,7 @@
"important": "สำคัญ",
"download": "ดาวน์โหลด",
"download_all": "ดาวน์โหลดทั้งหมด",
"download_all_partial": "ไฟล์แนบ {count} รายการไม่ถูกรวมในไฟล์ ZIP",
"from": "จาก",
"to": "ถึง",
"cc": "สำเนา (CC)",
+1
View File
@@ -450,6 +450,7 @@
"important": "Önemli",
"download": "İndir",
"download_all": "Tümünü indir",
"download_all_partial": "ZIP dosyasına {count} ek dahil edilemedi.",
"from": "Kimden",
"to": "Kime",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "важливо",
"download": "Завантажити",
"download_all": "Завантажити все",
"download_all_partial": "{count, plural, one {# вкладення не включено до ZIP-архіву.} few {# вкладення не включено до ZIP-архіву.} many {# вкладень не включено до ZIP-архіву.} other {# вкладення не включено до ZIP-архіву.}}",
"from": "Від",
"to": "до",
"cc": "CC",
+1
View File
@@ -450,6 +450,7 @@
"important": "重要",
"download": "下載",
"download_all": "全部下載",
"download_all_partial": "有 {count} 個附件未包含在 ZIP 中。",
"from": "寄件者",
"to": "收件者",
"cc": "副本",
+1
View File
@@ -450,6 +450,7 @@
"important": "重要",
"download": "下载",
"download_all": "全部下载",
"download_all_partial": "有 {count} 个附件未包含在 ZIP 中。",
"from": "发件人",
"to": "收件人",
"cc": "抄送",