diff --git a/components/email/__tests__/download-all-attachments.test.tsx b/components/email/__tests__/download-all-attachments.test.tsx
new file mode 100644
index 000000000..df88e19fc
--- /dev/null
+++ b/components/email/__tests__/download-all-attachments.test.tsx
@@ -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 ;
+}
+
+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 {
+ 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(
+
+
+ );
+ 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]);
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index c38ab278e..ad0d816dc 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -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 ? (
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 53d1387de..3cbcd5ca8 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -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": "نسخة",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index f6518c120..62e9d8c91 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -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",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index a52d94e20..dbb1c0e31 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -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",
diff --git a/locales/da/common.json b/locales/da/common.json
index 3e04e5ef2..0e8eab4d8 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -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",
diff --git a/locales/de/common.json b/locales/de/common.json
index 87f90a0e2..b9133482b 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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",
diff --git a/locales/en/common.json b/locales/en/common.json
index e370559a0..48e0a43b3 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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",
diff --git a/locales/es/common.json b/locales/es/common.json
index 45d4255d7..9f5d6ac1d 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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",
diff --git a/locales/et/common.json b/locales/et/common.json
index 409dc7914..9d687114a 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -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",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index c67b0be54..df8107ed2 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -450,6 +450,7 @@
"important": "مهم",
"download": "دانلود",
"download_all": "دانلود همه",
+ "download_all_partial": "{count, plural, one {# پیوست از فایل ZIP حذف شد.} other {# پیوست از فایل ZIP حذف شد.}}",
"from": "از",
"to": "به",
"cc": "رونوشت",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 05d4a448c..284e0ddc8 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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",
diff --git a/locales/he/common.json b/locales/he/common.json
index 639fb6c7a..0ac358cb1 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -399,6 +399,7 @@
"important": "חָשׁוּב",
"download": "הורד",
"download_all": "הורד הכל",
+ "download_all_partial": "{count, plural, one {# קובץ מצורף הושמט מקובץ ה-ZIP.} two {# קבצים מצורפים הושמטו מקובץ ה-ZIP.} other {# קבצים מצורפים הושמטו מקובץ ה-ZIP.}}",
"from": "מאת",
"to": "אל",
"cc": "עותק",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index d73d1024c..f1cf3d685 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -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",
diff --git a/locales/it/common.json b/locales/it/common.json
index d233c37f2..7b85ee009 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 14443e59d..6f76e7751 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -450,6 +450,7 @@
"important": "重要",
"download": "ダウンロード",
"download_all": "すべてダウンロード",
+ "download_all_partial": "ZIPに含まれなかった添付ファイルは{count}件です。",
"from": "送信者",
"to": "宛先",
"cc": "CC",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 96e51cd9f..1d7b11282 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -450,6 +450,7 @@
"important": "중요",
"download": "다운로드",
"download_all": "모두 다운로드",
+ "download_all_partial": "ZIP에 첨부파일 {count}개가 포함되지 않았습니다.",
"from": "보낸 사람",
"to": "받는 사람",
"cc": "참조",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index ea3047e14..e85ee3bfc 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -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",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index e6ea18a15..1736cd115 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -450,6 +450,7 @@
"important": "Чухал",
"download": "Татаж авах",
"download_all": "Бүгдийг нь татаж авах",
+ "download_all_partial": "ZIP файлд {count} хавсралт ороогүй.",
"from": "",
"to": "Хэнд",
"cc": "Хуулбар",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6794e637c..a66624e80 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -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",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 21f2232f5..a416a046c 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index b0aae170a..6e68a40dd 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -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",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 3603dd30d..6b3a3d5a0 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 6f8502c27..7acb060ea 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -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",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index b88e0e8f0..a7398807b 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -450,6 +450,7 @@
"important": "Важное",
"download": "Скачать",
"download_all": "Скачать все",
+ "download_all_partial": "{count, plural, one {# вложение не включено в ZIP-архив.} few {# вложения не включены в ZIP-архив.} many {# вложений не включены в ZIP-архив.} other {# вложения не включены в ZIP-архив.}}",
"from": "От",
"to": "Кому",
"cc": "Копия",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 7c2579d36..ce37f6b31 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -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",
diff --git a/locales/th/common.json b/locales/th/common.json
index e8ce9fe3b..cef4c4299 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1293,6 +1293,7 @@
"important": "สำคัญ",
"download": "ดาวน์โหลด",
"download_all": "ดาวน์โหลดทั้งหมด",
+ "download_all_partial": "ไฟล์แนบ {count} รายการไม่ถูกรวมในไฟล์ ZIP",
"from": "จาก",
"to": "ถึง",
"cc": "สำเนา (CC)",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 848d215f2..7cfa61ffb 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -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",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index f6acc947b..c99127ea0 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -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",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index f3aa1b8b8..e72f394d6 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -450,6 +450,7 @@
"important": "重要",
"download": "下載",
"download_all": "全部下載",
+ "download_all_partial": "有 {count} 個附件未包含在 ZIP 中。",
"from": "寄件者",
"to": "收件者",
"cc": "副本",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 3e0ae974b..e01dbb08c 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -450,6 +450,7 @@
"important": "重要",
"download": "下载",
"download_all": "全部下载",
+ "download_all_partial": "有 {count} 个附件未包含在 ZIP 中。",
"from": "发件人",
"to": "收件人",
"cc": "抄送",