From ae5d3975126f6817fdae3c5065786a002ae0a4b1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:46:41 +0200 Subject: [PATCH] feat: add "Download all" button to bundle attachments into a zip #466 --- components/email/email-viewer.tsx | 90 ++++++++++++++++++++++++- lib/__tests__/download-filename.test.ts | 19 ++++++ lib/download-filename.ts | 9 +++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 12b21c8f..a76c8fdd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; -import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; +import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; @@ -1004,6 +1004,7 @@ export function EmailViewer({ const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false); + const [isDownloadingAll, setIsDownloadingAll] = useState(false); const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState(null); const belowHeaderRowRef = useRef(null); const belowHeaderGhostRef = useRef(null); @@ -2789,6 +2790,86 @@ export function EmailViewer({ setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); }, [onDownloadAttachment, email?.id, resolveAttachmentName]); + // Bundle every attachment of this email into a single .zip and download it. + // Fetches blob-backed attachments through the JMAP client and reuses already + // decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual + // failures are skipped so a single bad blob doesn't sink the whole archive. + const handleDownloadAllAttachments = useCallback(async () => { + if (isDownloadingAll || effectiveAttachments.length === 0) return; + setIsDownloadingAll(true); + try { + const { default: JSZip } = await import('jszip'); + const zip = new JSZip(); + const used = new Set(); + // Zip entries must be unique; suffix collisions with " (n)" before the + // extension so duplicates stay recognisable. + const uniqueName = (raw: string): string => { + const base = raw || 'attachment'; + if (!used.has(base)) { used.add(base); return base; } + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + let i = 1; + let candidate = `${stem} (${i})${ext}`; + while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; } + used.add(candidate); + return candidate; + }; + + let added = 0; + for (const attachment of effectiveAttachments) { + const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type)); + try { + if (attachment.blobId && client) { + const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type); + zip.file(entryName, blob); + added++; + } else if (attachment.tnefData) { + zip.file(entryName, attachment.tnefData); + added++; + } else if (attachment.decryptedAttachment) { + const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); + if (bytes && bytes.byteLength > 0) { + zip.file(entryName, bytes); + added++; + } + } + } catch { + // Skip individual failures; remaining attachments still bundle. + } + } + + if (added === 0) return; + + const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' }); + const objectUrl = URL.createObjectURL(zipBlob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = attachmentsBundleFilename(email); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + } finally { + setIsDownloadingAll(false); + } + }, [isDownloadingAll, effectiveAttachments, client, email]); + + // Shared "Download all" chip, shown only when bundling is worthwhile (2+). + const downloadAllButton = effectiveAttachments.length > 1 ? ( + + ) : null; + // Pre-fetch object URLs for image attachments so their actual contents can be // rendered as thumbnails inside the chip. Skips images larger than 10 MB. useEffect(() => { @@ -4533,6 +4614,7 @@ export function EmailViewer({ +{effectiveAttachments.length - 2} {t('more')} )} + {downloadAllButton} {/* Floating popup for remaining attachments */} {showAllBesideAttachments && effectiveAttachments.length > 2 && ( <> @@ -5181,8 +5263,8 @@ export function EmailViewer({ {/* === ATTACHMENTS below header (below-header mode, desktop only) === */} {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
-
-
+
+
{/* Hidden ghost row used purely for measuring chip widths */}
)}
+ {downloadAllButton} {showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && ( <>
setShowAllBelowHeaderAttachments(false)} /> @@ -5440,6 +5523,7 @@ export function EmailViewer({ +{effectiveAttachments.length - 2} {t('more')} )} + {downloadAllButton} {showAllMobileAttachments && effectiveAttachments.length > 2 && ( <>
setShowAllMobileAttachments(false)} /> diff --git a/lib/__tests__/download-filename.test.ts b/lib/__tests__/download-filename.test.ts index 7302713f..1a8f615b 100644 --- a/lib/__tests__/download-filename.test.ts +++ b/lib/__tests__/download-filename.test.ts @@ -6,6 +6,7 @@ import type { Email } from '@/lib/jmap/types'; import { emailExportFilename, attachmentDownloadFilename, + attachmentsBundleFilename, bundleExportFilename, emailVars, attachmentVars, @@ -93,6 +94,24 @@ describe('bundleExportFilename', () => { }); }); +describe('attachmentsBundleFilename', () => { + it('embeds the sanitised subject', () => { + expect(attachmentsBundleFilename(makeEmail({ subject: 'Invoice March' }))).toBe('attachments_Invoice March.zip'); + }); + + it('sanitises filesystem-reserved characters', () => { + expect(attachmentsBundleFilename(makeEmail({ subject: 'Q1/Q2: report' }))).toBe('attachments_Q1_Q2_ report.zip'); + }); + + it('falls back when the subject is empty', () => { + expect(attachmentsBundleFilename(makeEmail({ subject: '' }))).toBe('attachments.zip'); + }); + + it('falls back when there is no email', () => { + expect(attachmentsBundleFilename(null)).toBe('attachments.zip'); + }); +}); + describe('emailVars (date + address labels)', () => { it('returns the invalid-date sentinel for an unparseable date', () => { const v = emailVars(makeEmail({ receivedAt: 'not-a-date', sentAt: undefined })); diff --git a/lib/download-filename.ts b/lib/download-filename.ts index 11dcc00e..670213d5 100644 --- a/lib/download-filename.ts +++ b/lib/download-filename.ts @@ -227,6 +227,15 @@ export function attachmentDownloadFilename( return `${transformedStem}.${transformedExt}`; } +// Name for a .zip bundling every attachment of a single email, e.g. +// `attachments_Invoice March.zip`. Falls back to `attachments.zip` when the +// subject is empty or sanitises away to nothing. +export function attachmentsBundleFilename(email: Email | null | undefined): string { + const subject = email?.subject?.trim(); + const stem = subject ? sanitizePart(subject, FILENAME_MAX_LEN) : ""; + return stem ? `attachments_${stem}.zip` : "attachments.zip"; +} + export function bundleVars(count: number, iso?: string): Record { const dp = dateParts(iso ?? new Date().toISOString()); return { ...dp, count: String(count) };