From 1fc6185002d171464c622ea171d0f998f90cd871 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 12:22:03 +0200 Subject: [PATCH 01/19] chore: update version to 1.7.1 --- CHANGELOG.md | 13 +++++++++++++ README.md | 2 +- VERSION | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358dd5fd..f233e76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.7.1 (2026-05-22) + +### Features + +- **Admin**: Expose PWA branding fields in the admin Branding tab +- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space + +### Fixes + +- **Mail**: Preserve inline images when replying (#163) +- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313) +- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop + ## 1.7.0 (2026-05-21) > **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced. diff --git a/README.md b/README.md index acd19eab..4abe0f3d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.7.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) diff --git a/VERSION b/VERSION index bd8bf882..943f9cbc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.0 +1.7.1 diff --git a/package-lock.json b/package-lock.json index ef4c7308..243d9a05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.7.0", + "version": "1.7.1", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.24", diff --git a/package.json b/package.json index 7d8505d1..dec66256 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.7.0", + "version": "1.7.1", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From d3778e65218ed8deb034e6ffb6e4cec5c4cf1111 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:04:02 +0200 Subject: [PATCH 02/19] fix: handle malformed event dates in calendar route #316 --- components/calendar/event-card.tsx | 6 +++++- lib/calendar-utils.ts | 13 ++++++++++--- stores/calendar-store.ts | 13 ++++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 902a6e5c..f797f0ea 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -78,7 +78,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM const calendarName = calendar?.name || ""; const durationMinutes = parseDuration(event.duration); const endTime = getEventEndDate(event); - const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`; + const safeFormat = (d: Date, fmt: string) => { + if (isNaN(d.getTime())) return "--:--"; + try { return format(d, fmt); } catch { return "--:--"; } + }; + const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`; const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; const handleDragStart = useCallback((e: DragEvent) => { diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 64179fb3..a6bcac0a 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -24,8 +24,14 @@ export interface TimedEventLayout { export function getEventStartDate( event: Pick, ): Date { - const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start; - return parseISO(source); + // Prefer utcStart for timed events but fall back to start if utcStart is + // missing or unparseable - a malformed utcStart used to surface as an + // Invalid Date that crashed downstream format() calls (#316). + if (!event.showWithoutTime && event.utcStart) { + const utc = parseISO(event.utcStart); + if (!isNaN(utc.getTime())) return utc; + } + return parseISO(event.start); } export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] { @@ -56,7 +62,8 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe export function getEventEndDate(event: CalendarEvent): Date { if (!event.showWithoutTime && event.utcEnd) { - return parseISO(event.utcEnd); + const utc = parseISO(event.utcEnd); + if (!isNaN(utc.getTime())) return utc; } const start = getEventStartDate(event); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 1fb94a40..3d21a554 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -7,6 +7,7 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { parseDuration } from '@/components/calendar/event-card'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { parseISO } from 'date-fns'; import { generateUUID } from '@/lib/utils'; import { apiFetch } from '@/lib/browser-navigation'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; @@ -302,8 +303,12 @@ export const useCalendarStore = create()( after: start, before: end, }); - // Filter out malformed events missing required 'start' field - const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start); + // Filter out malformed events missing required 'start' field, or + // whose start string fails to parse (would otherwise crash format() + // calls in the rendering path - #316). + const validEvents = rawEvents.filter(e => + typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime()) + ); const droppedEvents = rawEvents.length - validEvents.length; // Expand recurring events client-side (Stalwart doesn't support // mutations on synthetic IDs from server-side expandRecurrences) @@ -366,7 +371,9 @@ export const useCalendarStore = create()( accounts.map(async ({ client, localAccountId }) => { try { const raw = await client.queryAllCalendarEvents({ after: start, before: end }); - const valid = raw.filter(e => typeof e.start === 'string' && e.start); + const valid = raw.filter(e => + typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime()) + ); const expanded = expandRecurringEvents(valid, start, end); return prefixEventsWithLocalAccount( expanded, From 8810a632620696f2ac4d1c85d2a1e5805a0b14d0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:04:52 +0200 Subject: [PATCH 03/19] feat: drag emails out to file explorer as .eml --- hooks/use-email-drag.ts | 186 +++++++++++++++++++++++++++++++++++++++- lib/jmap/client.ts | 2 + 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index d4847d4f..9a3de69a 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -1,10 +1,13 @@ "use client"; -import { useCallback, DragEvent } from "react"; +import { useCallback, useEffect, useRef, DragEvent } from "react"; import { Email } from "@/lib/jmap/types"; +import { IJMAPClient } from "@/lib/jmap/client-interface"; import { useEmailStore } from "@/stores/email-store"; +import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; +import { isDragOutSupported } from "@/hooks/use-attachment-drag"; interface UseEmailDragOptions { email: Email; @@ -15,6 +18,7 @@ interface UseEmailDragOptions { interface UseEmailDragReturn { dragHandlers: { draggable: boolean; + onPointerEnter?: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; }; @@ -44,10 +48,145 @@ function createDragPreview(count: number): HTMLElement { return preview; } +function sanitizeFilenamePart(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_").trim().slice(0, 80) || "email"; +} + +function emlFilename(subject: string | null | undefined): string { + return `${sanitizeFilenamePart(subject || "email")}.eml`; +} + +function bundleFilename(count: number): string { + return `emails-${count}.zip`; +} + +// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in +// the selection, so two rows in the same selection reuse the same in-flight +// build. When the selection changes, the previous bundle URL is scheduled for +// revoke and a new build starts. +type BundleEntry = { + key: string; + name: string; + url: string | null; + promise: Promise | null; +}; + +let currentBundle: BundleEntry | null = null; + +function selectionKey(ids: string[]): string { + return [...ids].sort().join(","); +} + +async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise { + const eligible = emails.filter((em) => !!em.blobId); + if (eligible.length === 0) return null; + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + const used = new Set(); + const pad = String(eligible.length).length; + await Promise.all( + eligible.map(async (em, i) => { + const base = sanitizeFilenamePart(em.subject || "email"); + const indexStr = String(i + 1).padStart(pad, "0"); + let name = `${indexStr}-${base}.eml`; + while (used.has(name)) name = `${indexStr}-${base}-${em.id.slice(0, 6)}.eml`; + used.add(name); + try { + const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822"); + zip.file(name, blob); + } catch { + // Skip individual failures; remaining messages still bundle. + } + }), + ); + const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" }); + return URL.createObjectURL(zipBlob); +} + +function prefetchEmailBundle(client: IJMAPClient, emails: Email[]): void { + const key = selectionKey(emails.map((e) => e.id)); + if (currentBundle && currentBundle.key === key) return; + if (currentBundle?.url) { + const old = currentBundle.url; + setTimeout(() => URL.revokeObjectURL(old), 60_000); + } + const entry: BundleEntry = { + key, + name: bundleFilename(emails.length), + url: null, + promise: null, + }; + entry.promise = buildEmailZip(client, emails) + .then((url) => { + if (url && currentBundle === entry) entry.url = url; + return url; + }) + .catch(() => null); + currentBundle = entry; +} + +function getReadyBundle(emails: Email[]): { url: string; name: string } | null { + const key = selectionKey(emails.map((e) => e.id)); + if (currentBundle && currentBundle.key === key && currentBundle.url) { + return { url: currentBundle.url, name: currentBundle.name }; + } + return null; +} + export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn { const { selectedEmailIds, emails } = useEmailStore(); const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext(); const isMobile = useUIStore((state) => state.isMobile); + const client = useAuthStore((state) => state.client); + + const dragOutEnabled = !isMobile && isDragOutSupported() && !!client; + const singleBlobUrlRef = useRef(null); + const inFlightRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (singleBlobUrlRef.current) { + const url = singleBlobUrlRef.current; + singleBlobUrlRef.current = null; + // Defer revoke - Chromium asynchronously reads the blob: URL after the + // drop completes, so revoking immediately can race the OS. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } + inFlightRef.current = null; + }; + }, [email.id]); + + const prefetchSingle = useCallback(() => { + if (!dragOutEnabled || !client || !email.blobId) return; + if (singleBlobUrlRef.current || inFlightRef.current) return; + const name = emlFilename(email.subject); + inFlightRef.current = client + .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") + .then((url) => { + if (url && !singleBlobUrlRef.current) singleBlobUrlRef.current = url; + return url; + }) + .catch(() => null) + .finally(() => { + inFlightRef.current = null; + }); + }, [dragOutEnabled, client, email.blobId, email.subject]); + + const handlePointerEnter = useCallback(() => { + if (!dragOutEnabled || !client) return; + const isSelected = selectedEmailIds.has(email.id); + const isMulti = isSelected && selectedEmailIds.size > 1; + if (isMulti) { + const selected = emails.filter((em) => selectedEmailIds.has(em.id)); + // Only worth bundling when at least one selected email has a blobId. + if (selected.some((em) => em.blobId)) { + prefetchEmailBundle(client, selected); + } + } else { + prefetchSingle(); + } + }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle]); const handleDragStart = useCallback((e: DragEvent) => { // Determine which emails to drag: @@ -59,8 +198,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD ? emails.filter(em => selectedEmailIds.has(em.id)) : threadEmails || [email]; - // Set data transfer - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; e.dataTransfer.setData( "application/x-email-ids", JSON.stringify(emailsToDrag.map(em => em.id)) @@ -70,6 +208,37 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD emailsToDrag.map(em => em.subject || "(no subject)").join(", ") ); + // Drag-out to file explorer. + if (dragOutEnabled && client) { + if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) { + const url = singleBlobUrlRef.current; + if (url) { + const name = emlFilename(emailsToDrag[0].subject); + // `DownloadURL` format: ::. Chromium reads this + // on drop and writes a real file; Firefox/Safari ignore it. + e.dataTransfer.setData( + "DownloadURL", + `message/rfc822:${encodeURIComponent(name)}:${url}`, + ); + } else { + // Not warmed up yet — kick off so the next attempt works. Don't + // preventDefault: in-app drop still has to function. + prefetchSingle(); + } + } else if (emailsToDrag.length > 1) { + const ready = getReadyBundle(emailsToDrag); + if (ready) { + e.dataTransfer.setData( + "DownloadURL", + `application/zip:${encodeURIComponent(ready.name)}:${ready.url}`, + ); + } else { + // Kick off the bundle build for the next attempt. + prefetchEmailBundle(client, emailsToDrag); + } + } + } + // Create custom drag image const dragPreview = createDragPreview(emailsToDrag.length); e.dataTransfer.setDragImage(dragPreview, 0, 0); @@ -80,10 +249,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD }); startDrag(emailsToDrag, sourceMailboxId); - }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]); + }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle]); const handleDragEnd = useCallback(() => { endDrag(); + // Defer-revoke the per-row single .eml URL. The shared bundle URL stays + // cached until the selection changes - revoking it here would break a + // subsequent drag of the same selection. + if (singleBlobUrlRef.current) { + const url = singleBlobUrlRef.current; + singleBlobUrlRef.current = null; + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } }, [endDrag]); // Check if this specific email is being dragged @@ -94,6 +271,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD ? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} } : { draggable: true, + onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined, onDragStart: handleDragStart, onDragEnd: handleDragEnd, }, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d827423d..31d2de4c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -93,6 +93,8 @@ const EMAIL_LIST_PROPERTIES = [ "subject", "preview", "hasAttachment", + // Needed so list rows can serve drag-out to the file system as .eml. + "blobId", ] as const; // Stalwart's default property list for Calendar/get omits shareWith, isVisible, From 0245ec67e13df94c1014cc1c178319a4e96619cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:25:30 +0200 Subject: [PATCH 04/19] feat: enhance email filename generation and sanitization for drag-and-drop functionality --- hooks/use-email-drag.ts | 45 +++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 9a3de69a..f423525d 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -48,13 +48,34 @@ function createDragPreview(count: number): HTMLElement { return preview; } -function sanitizeFilenamePart(s: string): string { +function sanitizeFilenamePart(s: string, maxLen = 80): string { // eslint-disable-next-line no-control-regex - return s.replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_").trim().slice(0, 80) || "email"; + const cleaned = s.replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_").replace(/\s+/g, " ").trim(); + return cleaned.slice(0, maxLen) || ""; } -function emlFilename(subject: string | null | undefined): string { - return `${sanitizeFilenamePart(subject || "email")}.eml`; +function formatEmlDate(iso: string | null | undefined): string { + const d = iso ? new Date(iso) : new Date(); + if (Number.isNaN(d.getTime())) return "0000-00-00 00.00.00"; + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}.${pad(d.getMinutes())}.${pad(d.getSeconds())}` + ); +} + +function addressLabel(addr: { name?: string | null; email: string } | undefined, maxLen = 30): string { + if (!addr) return ""; + const label = (addr.name && addr.name.trim()) || addr.email.split("@")[0] || addr.email; + return sanitizeFilenamePart(label, maxLen); +} + +function emlFilename(email: Email): string { + const date = formatEmlDate(email.receivedAt || email.sentAt); + const from = addressLabel(email.from?.[0]); + const to = addressLabel(email.to?.[0]); + const subject = sanitizeFilenamePart(email.subject || "no subject"); + return `${date} (${from}-${to}) ${subject}.eml`; } function bundleFilename(count: number): string { @@ -84,13 +105,11 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise(); - const pad = String(eligible.length).length; await Promise.all( - eligible.map(async (em, i) => { - const base = sanitizeFilenamePart(em.subject || "email"); - const indexStr = String(i + 1).padStart(pad, "0"); - let name = `${indexStr}-${base}.eml`; - while (used.has(name)) name = `${indexStr}-${base}-${em.id.slice(0, 6)}.eml`; + eligible.map(async (em) => { + const base = emlFilename(em).replace(/\.eml$/, ""); + let name = `${base}.eml`; + while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`; used.add(name); try { const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822"); @@ -160,7 +179,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const prefetchSingle = useCallback(() => { if (!dragOutEnabled || !client || !email.blobId) return; if (singleBlobUrlRef.current || inFlightRef.current) return; - const name = emlFilename(email.subject); + const name = emlFilename(email); inFlightRef.current = client .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") .then((url) => { @@ -171,7 +190,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD .finally(() => { inFlightRef.current = null; }); - }, [dragOutEnabled, client, email.blobId, email.subject]); + }, [dragOutEnabled, client, email]); const handlePointerEnter = useCallback(() => { if (!dragOutEnabled || !client) return; @@ -213,7 +232,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) { const url = singleBlobUrlRef.current; if (url) { - const name = emlFilename(emailsToDrag[0].subject); + const name = emlFilename(emailsToDrag[0]); // `DownloadURL` format: ::. Chromium reads this // on drop and writes a real file; Firefox/Safari ignore it. e.dataTransfer.setData( From 8bcb4874429134f38745f5450df807da329663f5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:28:36 +0200 Subject: [PATCH 05/19] feat: name dragged/exported .eml files as "date (from-to) subject" with ASCII-only chars --- components/email/email-viewer.tsx | 4 +-- hooks/use-email-drag.ts | 41 +++++------------------------ lib/email-filename.ts | 43 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 lib/email-filename.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9d6444a3..d7eefbc0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,6 +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 } from "@/lib/email-filename"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { Button } from "@/components/ui/button"; @@ -3056,8 +3057,7 @@ export function EmailViewer({ const handleExportEmail = async () => { if (!email?.blobId || !client) return; try { - const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100); - await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822'); + await client.downloadBlob(email.blobId, emailExportFilename(email), 'message/rfc822'); } catch { toast.error(tNotifications('export_email_error')); } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index f423525d..4a74f47c 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useCallback, useEffect, useRef, DragEvent } from "react"; import { Email } from "@/lib/jmap/types"; @@ -8,6 +8,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; +import { emailExportFilename } from "@/lib/email-filename"; interface UseEmailDragOptions { email: Email; @@ -48,36 +49,6 @@ function createDragPreview(count: number): HTMLElement { return preview; } -function sanitizeFilenamePart(s: string, maxLen = 80): string { - // eslint-disable-next-line no-control-regex - const cleaned = s.replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_").replace(/\s+/g, " ").trim(); - return cleaned.slice(0, maxLen) || ""; -} - -function formatEmlDate(iso: string | null | undefined): string { - const d = iso ? new Date(iso) : new Date(); - if (Number.isNaN(d.getTime())) return "0000-00-00 00.00.00"; - const pad = (n: number) => String(n).padStart(2, "0"); - return ( - `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + - `${pad(d.getHours())}.${pad(d.getMinutes())}.${pad(d.getSeconds())}` - ); -} - -function addressLabel(addr: { name?: string | null; email: string } | undefined, maxLen = 30): string { - if (!addr) return ""; - const label = (addr.name && addr.name.trim()) || addr.email.split("@")[0] || addr.email; - return sanitizeFilenamePart(label, maxLen); -} - -function emlFilename(email: Email): string { - const date = formatEmlDate(email.receivedAt || email.sentAt); - const from = addressLabel(email.from?.[0]); - const to = addressLabel(email.to?.[0]); - const subject = sanitizeFilenamePart(email.subject || "no subject"); - return `${date} (${from}-${to}) ${subject}.eml`; -} - function bundleFilename(count: number): string { return `emails-${count}.zip`; } @@ -107,7 +78,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise(); await Promise.all( eligible.map(async (em) => { - const base = emlFilename(em).replace(/\.eml$/, ""); + const base = emailExportFilename(em).replace(/\.eml$/, ""); let name = `${base}.eml`; while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`; used.add(name); @@ -179,7 +150,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const prefetchSingle = useCallback(() => { if (!dragOutEnabled || !client || !email.blobId) return; if (singleBlobUrlRef.current || inFlightRef.current) return; - const name = emlFilename(email); + const name = emailExportFilename(email); inFlightRef.current = client .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") .then((url) => { @@ -232,7 +203,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) { const url = singleBlobUrlRef.current; if (url) { - const name = emlFilename(emailsToDrag[0]); + const name = emailExportFilename(emailsToDrag[0]); // `DownloadURL` format: ::. Chromium reads this // on drop and writes a real file; Firefox/Safari ignore it. e.dataTransfer.setData( @@ -240,7 +211,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD `message/rfc822:${encodeURIComponent(name)}:${url}`, ); } else { - // Not warmed up yet — kick off so the next attempt works. Don't + // Not warmed up yet — kick off so the next attempt works. Don't // preventDefault: in-app drop still has to function. prefetchSingle(); } diff --git a/lib/email-filename.ts b/lib/email-filename.ts new file mode 100644 index 00000000..28be50b4 --- /dev/null +++ b/lib/email-filename.ts @@ -0,0 +1,43 @@ +import type { Email } from "@/lib/jmap/types"; + +// Restrict filenames to ASCII letters/digits and a small set of safe +// punctuation. Everything else collapses to `_`. Keeps names predictable +// across Windows/macOS/Linux file systems and avoids emoji/RTL/zero-width +// surprises in subject lines. +const SAFE_CHARS = /[^A-Za-z0-9 _\-().,!@#&+=[\]{}']/g; + +function sanitizePart(input: string, maxLen: number): string { + const cleaned = input + .replace(SAFE_CHARS, "_") + .replace(/_+/g, "_") + .replace(/\s+/g, " ") + .trim() + .replace(/^[._-]+|[._-]+$/g, ""); + return cleaned.slice(0, maxLen); +} + +function formatDate(iso: string | null | undefined): string { + const d = iso ? new Date(iso) : new Date(); + if (Number.isNaN(d.getTime())) return "0000-00-00 00.00.00"; + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}.${pad(d.getMinutes())}.${pad(d.getSeconds())}` + ); +} + +function addressLabel(addr: { name?: string | null; email: string } | undefined, maxLen = 30): string { + if (!addr) return "unknown"; + const raw = (addr.name && addr.name.trim()) || addr.email.split("@")[0] || addr.email; + return sanitizePart(raw, maxLen) || "unknown"; +} + +// Produce `YYYY-MM-DD HH.mm.SS (from-to) subject.eml`. All components are +// sanitized to ASCII-safe filename characters. +export function emailExportFilename(email: Email): string { + const date = formatDate(email.receivedAt || email.sentAt); + const from = addressLabel(email.from?.[0]); + const to = addressLabel(email.to?.[0]); + const subject = sanitizePart(email.subject || "no subject", 80) || "no subject"; + return `${date} (${from}-${to}) ${subject}.eml`; +} From ca0d6805cfab59bab7d16c43a31fc2fd433ec055 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:46:25 +0200 Subject: [PATCH 06/19] feat: add Downloads settings tab with template editor for .eml and attachment filenames --- app/(main)/[locale]/settings/page.tsx | 8 + components/email/email-viewer.tsx | 53 ++++-- components/settings/downloads-settings.tsx | 172 ++++++++++++++++++ hooks/use-email-drag.ts | 26 +-- lib/download-filename.ts | 196 +++++++++++++++++++++ lib/email-filename.ts | 43 ----- locales/en/common.json | 14 ++ stores/settings-store.ts | 8 + 8 files changed, 446 insertions(+), 74 deletions(-) create mode 100644 components/settings/downloads-settings.tsx create mode 100644 lib/download-filename.ts delete mode 100644 lib/email-filename.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 9d129369..ab7dcad7 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -33,6 +33,7 @@ import { Languages, Info, Bug, + Download, X, type LucideIcon, } from 'lucide-react'; @@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings'; +import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; @@ -90,6 +92,7 @@ type Tab = | 'layout' | 'reading' | 'composing' + | 'downloads' | 'identities' | 'vacation' | 'filters' @@ -126,6 +129,7 @@ const tabIcons: Record = { layout: LayoutGrid, reading: BookOpen, composing: PenLine, + downloads: Download, identities: UserPen, vacation: PalmtreeIcon, filters: Filter, @@ -202,6 +206,7 @@ const tabSearchPaths: Record = { 'settings.email_behavior.signature_position', 'settings.email_behavior.sub_address_delimiter', ], + downloads: ['settings.downloads'], identities: ['settings.identities'], vacation: ['settings.vacation'], filters: ['settings.filters'], @@ -236,6 +241,7 @@ const tabKeywords: Record = { layout: 'toolbar sidebar account switcher unified mailbox icons rail', reading: 'mark read preview thread conversation archive delete attachment open', composing: 'editor signature plain text reply forward draft compose', + downloads: 'download filename template eml attachment save export', identities: 'from address signature email', vacation: 'auto reply away out of office holiday responder', filters: 'sieve rules block junk forward', @@ -580,6 +586,7 @@ export default function SettingsPage() { // Mail { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, + { id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), @@ -666,6 +673,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'layout' && } {effectiveActiveTab === 'reading' && } {effectiveActiveTab === 'composing' && } + {effectiveActiveTab === 'downloads' && } {effectiveActiveTab === 'identities' && } {effectiveActiveTab === 'vacation' && } {effectiveActiveTab === 'filters' && } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d7eefbc0..5060f5b4 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 } from "@/lib/email-filename"; +import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { Button } from "@/components/ui/button"; @@ -800,6 +800,7 @@ interface DraggableAttachmentChipProps { attachment: EffectiveAttachment; client: IJMAPClient | null; enabled: boolean; + downloadName?: string; children: (dragProps: { draggable: boolean; onPointerEnter: () => void; @@ -808,9 +809,9 @@ interface DraggableAttachmentChipProps { }) => React.ReactNode; } -function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) { +function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) { const source = useMemo(() => ({ - name: attachment.name || 'download', + name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', getBlobUrl: async () => { if (attachment.blobId && client) { @@ -833,7 +834,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag } return null; }, - }), [attachment, client]); + }), [attachment, client, downloadName]); const drag = useAttachmentDrag(source, enabled); return <>{children(drag)}; } @@ -902,6 +903,8 @@ export function EmailViewer({ const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const dragOutActive = useMemo(() => isDragOutSupported(), []); + const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; + const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE; const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; @@ -2562,6 +2565,15 @@ export function EmailViewer({ return emailContent; }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); + const resolveAttachmentName = useCallback( + (attachment: EffectiveAttachment) => { + const fallback = attachment.name || 'download'; + if (!email) return fallback; + return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentDownloadTemplate) || fallback; + }, + [email, attachmentDownloadTemplate], + ); + const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => { const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); // Blob URLs inherit our origin; script-bearing MIME types (text/html, @@ -2571,6 +2583,8 @@ export function EmailViewer({ && mailAttachmentAction === 'preview' && isMimeTypeSafeForInlinePreview(attachment.type); + const downloadName = resolveAttachmentName(attachment); + const info: AttachmentInfo = { name: attachment.name || '', type: attachment.type, @@ -2581,7 +2595,7 @@ export function EmailViewer({ if (attachment.blobId && onDownloadAttachment) { emailHooks.onAttachmentDownload.emit(info); - onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); + onDownloadAttachment(attachment.blobId, downloadName, attachment.type); return; } @@ -2601,7 +2615,7 @@ export function EmailViewer({ emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); @@ -2631,16 +2645,17 @@ export function EmailViewer({ emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); } setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); - }, [mailAttachmentAction, onDownloadAttachment, email?.id]); + }, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]); const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { + const downloadName = resolveAttachmentName(attachment); const info: AttachmentInfo = { name: attachment.name || '', type: attachment.type, @@ -2650,7 +2665,7 @@ export function EmailViewer({ }; emailHooks.onAttachmentDownload.emit(info); if (attachment.blobId && onDownloadAttachment) { - onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true); + onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true); return; } @@ -2663,7 +2678,7 @@ export function EmailViewer({ const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); @@ -2679,12 +2694,12 @@ export function EmailViewer({ const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); - }, [onDownloadAttachment, email?.id]); + }, [onDownloadAttachment, email?.id, resolveAttachmentName]); // 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. @@ -3057,7 +3072,7 @@ export function EmailViewer({ const handleExportEmail = async () => { if (!email?.blobId || !client) return; try { - await client.downloadBlob(email.blobId, emailExportFilename(email), 'message/rfc822'); + await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822'); } catch { toast.error(tNotifications('export_email_error')); } @@ -4209,7 +4224,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return ( - + {(dragProps) => (
+ {(dragProps) => (
+ {(dragProps) => (
+ {(dragProps) => (
+ {(dragProps) => (
+ {(dragProps) => (
void, +): void { + const start = input.selectionStart ?? current.length; + const end = input.selectionEnd ?? current.length; + const before = current.slice(0, start); + const after = current.slice(end); + const insertion = `{${token}}`; + const next = `${before}${insertion}${after}`; + onChange(next); + // Restore focus and place caret after the inserted token. + requestAnimationFrame(() => { + input.focus(); + const caret = before.length + insertion.length; + input.setSelectionRange(caret, caret); + }); +} + +interface TemplateEditorProps { + label: string; + description: string; + value: string; + defaultValue: string; + tokens: { token: string; description: string }[]; + preview: string; + onChange: (next: string) => void; + resetLabel: string; + placeholder?: string; +} + +function TemplateEditor({ + label, + description, + value, + defaultValue, + tokens, + preview, + onChange, + resetLabel, + placeholder, +}: TemplateEditorProps) { + const inputRef = useRef(null); + return ( +
+
+ +

{description}

+
+
+
+ onChange(e.target.value)} + spellCheck={false} + className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150" + /> + +
+
+ {tokens.map((t) => ( + + ))} +
+
+ Preview: + {preview} +
+
+
+ ); +} + +export function DownloadsSettings() { + const t = useTranslations("settings.downloads"); + const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore(); + + const sampleEmail = useMemo(() => buildSampleEmail(), []); + const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []); + + const emlPreview = useMemo( + () => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE), + [sampleEmail, emailDownloadTemplate], + ); + const attachmentPreview = useMemo( + () => + attachmentDownloadFilename( + sampleEmail, + sampleAttachment, + attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE, + ), + [sampleEmail, sampleAttachment, attachmentDownloadTemplate], + ); + + return ( + + updateSetting("emailDownloadTemplate", next)} + resetLabel={t("reset")} + placeholder={DEFAULT_EMAIL_TEMPLATE} + /> + updateSetting("attachmentDownloadTemplate", next)} + resetLabel={t("reset")} + placeholder={DEFAULT_ATTACHMENT_TEMPLATE} + /> + + ); +} diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 4a74f47c..b760c330 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -8,7 +8,8 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; -import { emailExportFilename } from "@/lib/email-filename"; +import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-filename"; +import { useSettingsStore } from "@/stores/settings-store"; interface UseEmailDragOptions { email: Email; @@ -70,7 +71,7 @@ function selectionKey(ids: string[]): string { return [...ids].sort().join(","); } -async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise { +async function buildEmailZip(client: IJMAPClient, emails: Email[], template: string): Promise { const eligible = emails.filter((em) => !!em.blobId); if (eligible.length === 0) return null; const { default: JSZip } = await import("jszip"); @@ -78,7 +79,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise(); await Promise.all( eligible.map(async (em) => { - const base = emailExportFilename(em).replace(/\.eml$/, ""); + const base = emailExportFilename(em, template).replace(/\.eml$/, ""); let name = `${base}.eml`; while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`; used.add(name); @@ -94,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise e.id)); if (currentBundle && currentBundle.key === key) return; if (currentBundle?.url) { @@ -107,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[]): void { url: null, promise: null, }; - entry.promise = buildEmailZip(client, emails) + entry.promise = buildEmailZip(client, emails, template) .then((url) => { if (url && currentBundle === entry) entry.url = url; return url; @@ -129,6 +130,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext(); const isMobile = useUIStore((state) => state.isMobile); const client = useAuthStore((state) => state.client); + const emailTemplate = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; const dragOutEnabled = !isMobile && isDragOutSupported() && !!client; const singleBlobUrlRef = useRef(null); @@ -150,7 +152,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const prefetchSingle = useCallback(() => { if (!dragOutEnabled || !client || !email.blobId) return; if (singleBlobUrlRef.current || inFlightRef.current) return; - const name = emailExportFilename(email); + const name = emailExportFilename(email, emailTemplate); inFlightRef.current = client .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") .then((url) => { @@ -161,7 +163,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD .finally(() => { inFlightRef.current = null; }); - }, [dragOutEnabled, client, email]); + }, [dragOutEnabled, client, email, emailTemplate]); const handlePointerEnter = useCallback(() => { if (!dragOutEnabled || !client) return; @@ -171,12 +173,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const selected = emails.filter((em) => selectedEmailIds.has(em.id)); // Only worth bundling when at least one selected email has a blobId. if (selected.some((em) => em.blobId)) { - prefetchEmailBundle(client, selected); + prefetchEmailBundle(client, selected, emailTemplate); } } else { prefetchSingle(); } - }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle]); + }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]); const handleDragStart = useCallback((e: DragEvent) => { // Determine which emails to drag: @@ -203,7 +205,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) { const url = singleBlobUrlRef.current; if (url) { - const name = emailExportFilename(emailsToDrag[0]); + const name = emailExportFilename(emailsToDrag[0], emailTemplate); // `DownloadURL` format: ::. Chromium reads this // on drop and writes a real file; Firefox/Safari ignore it. e.dataTransfer.setData( @@ -224,7 +226,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD ); } else { // Kick off the bundle build for the next attempt. - prefetchEmailBundle(client, emailsToDrag); + prefetchEmailBundle(client, emailsToDrag, emailTemplate); } } } @@ -239,7 +241,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD }); startDrag(emailsToDrag, sourceMailboxId); - }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle]); + }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, emailTemplate]); const handleDragEnd = useCallback(() => { endDrag(); diff --git a/lib/download-filename.ts b/lib/download-filename.ts new file mode 100644 index 00000000..64262b20 --- /dev/null +++ b/lib/download-filename.ts @@ -0,0 +1,196 @@ +import type { Email } from "@/lib/jmap/types"; + +// Restrict filenames to ASCII letters/digits and a small set of safe +// punctuation. Everything else collapses to `_`. Keeps names predictable +// across Windows/macOS/Linux file systems and avoids emoji/RTL/zero-width +// surprises in subject lines. +const SAFE_CHARS = /[^A-Za-z0-9 _\-().,!@#&+=[\]{}']/g; + +export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}"; +export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}"; + +export const EMAIL_TOKENS: { token: string; description: string }[] = [ + { token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" }, + { token: "date_short", description: "Date only, e.g. 2026-05-22" }, + { token: "time", description: "Time only, e.g. 14.05.33" }, + { token: "year", description: "4-digit year" }, + { token: "month", description: "2-digit month" }, + { token: "day", description: "2-digit day" }, + { token: "from", description: "Sender display name (falls back to email user part)" }, + { token: "from_email", description: "Sender full email address" }, + { token: "from_name", description: "Sender name only" }, + { token: "to", description: "First recipient display name" }, + { token: "to_email", description: "First recipient full email address" }, + { token: "to_name", description: "First recipient name only" }, + { token: "subject", description: "Email subject" }, +]; + +export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [ + ...EMAIL_TOKENS, + { token: "filename", description: "Original attachment filename including extension" }, + { token: "name", description: "Attachment filename without extension" }, + { token: "ext", description: "Attachment file extension without leading dot" }, +]; + +function sanitizePart(input: string, maxLen = 80): string { + const cleaned = input + .replace(SAFE_CHARS, "_") + .replace(/_+/g, "_") + .replace(/\s+/g, " ") + .trim() + .replace(/^[._-]+|[._-]+$/g, ""); + return cleaned.slice(0, maxLen); +} + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +function dateParts(iso: string | null | undefined) { + const d = iso ? new Date(iso) : new Date(); + if (Number.isNaN(d.getTime())) { + return { + date: "0000-00-00 00.00.00", + date_short: "0000-00-00", + time: "00.00.00", + year: "0000", + month: "00", + day: "00", + }; + } + const year = String(d.getFullYear()); + const month = pad2(d.getMonth() + 1); + const day = pad2(d.getDate()); + const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`; + return { + date: `${year}-${month}-${day} ${time}`, + date_short: `${year}-${month}-${day}`, + time, + year, + month, + day, + }; +} + +function addrLabel(addr: { name?: string | null; email: string } | undefined): { + name: string; + email: string; + label: string; +} { + if (!addr) return { name: "", email: "", label: "unknown" }; + const name = (addr.name && addr.name.trim()) || ""; + const email = addr.email || ""; + const label = name || email.split("@")[0] || email || "unknown"; + return { name, email, label }; +} + +export function emailVars(email: Email): Record { + const dp = dateParts(email.receivedAt || email.sentAt); + const from = addrLabel(email.from?.[0]); + const to = addrLabel(email.to?.[0]); + return { + ...dp, + from: from.label, + from_email: from.email, + from_name: from.name, + to: to.label, + to_email: to.email, + to_name: to.name, + subject: email.subject || "no subject", + }; +} + +export interface AttachmentLike { + name?: string | null; + type?: string | null; +} + +export function attachmentVars(email: Email, attachment: AttachmentLike): Record { + const filename = (attachment.name || "attachment").trim(); + const dot = filename.lastIndexOf("."); + const hasExt = dot > 0 && dot < filename.length - 1; + const name = hasExt ? filename.slice(0, dot) : filename; + const ext = hasExt ? filename.slice(dot + 1) : ""; + return { + ...emailVars(email), + filename, + name, + ext, + }; +} + +// Render a template by substituting `{key}` occurrences. Each substituted +// value is sanitized to safe ASCII filename characters; the rest of the +// template (literal text) is sanitized as a whole at the end so the template +// itself can't introduce path separators or control chars. +export function renderTemplate( + template: string, + vars: Record, + fallback: string, +): string { + const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => { + const value = vars[key]; + if (value === undefined) return ""; + return sanitizePart(value); + }); + const cleaned = sanitizePart(rendered, 200); + return cleaned || fallback; +} + +export function emailExportFilename( + email: Email, + template: string = DEFAULT_EMAIL_TEMPLATE, +): string { + const stem = renderTemplate(template, emailVars(email), "email"); + return `${stem}.eml`; +} + +export function attachmentDownloadFilename( + email: Email | null | undefined, + attachment: AttachmentLike, + template: string = DEFAULT_ATTACHMENT_TEMPLATE, +): string { + // No email context (rare - e.g. compose attachments) means we can only + // honour the {filename}/{name}/{ext} subset, so fall back to the raw name + // when the template needs email data and we don't have it. + if (!email) { + const filename = (attachment.name || "attachment").trim(); + return sanitizePart(filename, 200) || "attachment"; + } + const vars = attachmentVars(email, attachment); + const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => { + const value = vars[key]; + if (value === undefined) return ""; + // Preserve dots in {filename} so the original extension survives the + // sanitiser (it strips trailing dots otherwise). + return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value); + }); + // Preserve the original extension when the template doesn't reference it. + const templateMentionsExt = /\{(ext|filename)\}/.test(template); + const cleaned = sanitizePart(rendered, 200) || "attachment"; + if (templateMentionsExt) return cleaned; + const ext = vars.ext; + return ext ? `${cleaned}.${ext}` : cleaned; +} + +// Build a synthetic email for previewing templates in the settings UI. +export function buildSampleEmail(): Email { + // Use a fixed date so the preview doesn't churn as the user types. + const iso = "2026-05-22T14:05:33Z"; + return { + id: "sample-1", + threadId: "sample-thread-1", + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 12345, + receivedAt: iso, + sentAt: iso, + from: [{ name: "Alice Sender", email: "alice@example.com" }], + to: [{ name: "Bob Recipient", email: "bob@example.com" }], + cc: [], + subject: "Quarterly report draft", + preview: "", + hasAttachment: true, + blobId: "sample-blob", + }; +} diff --git a/lib/email-filename.ts b/lib/email-filename.ts deleted file mode 100644 index 28be50b4..00000000 --- a/lib/email-filename.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Email } from "@/lib/jmap/types"; - -// Restrict filenames to ASCII letters/digits and a small set of safe -// punctuation. Everything else collapses to `_`. Keeps names predictable -// across Windows/macOS/Linux file systems and avoids emoji/RTL/zero-width -// surprises in subject lines. -const SAFE_CHARS = /[^A-Za-z0-9 _\-().,!@#&+=[\]{}']/g; - -function sanitizePart(input: string, maxLen: number): string { - const cleaned = input - .replace(SAFE_CHARS, "_") - .replace(/_+/g, "_") - .replace(/\s+/g, " ") - .trim() - .replace(/^[._-]+|[._-]+$/g, ""); - return cleaned.slice(0, maxLen); -} - -function formatDate(iso: string | null | undefined): string { - const d = iso ? new Date(iso) : new Date(); - if (Number.isNaN(d.getTime())) return "0000-00-00 00.00.00"; - const pad = (n: number) => String(n).padStart(2, "0"); - return ( - `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + - `${pad(d.getHours())}.${pad(d.getMinutes())}.${pad(d.getSeconds())}` - ); -} - -function addressLabel(addr: { name?: string | null; email: string } | undefined, maxLen = 30): string { - if (!addr) return "unknown"; - const raw = (addr.name && addr.name.trim()) || addr.email.split("@")[0] || addr.email; - return sanitizePart(raw, maxLen) || "unknown"; -} - -// Produce `YYYY-MM-DD HH.mm.SS (from-to) subject.eml`. All components are -// sanitized to ASCII-safe filename characters. -export function emailExportFilename(email: Email): string { - const date = formatDate(email.receivedAt || email.sentAt); - const from = addressLabel(email.from?.[0]); - const to = addressLabel(email.to?.[0]); - const subject = sanitizePart(email.subject || "no subject", 80) || "no subject"; - return `${date} (${from}-${to}) ${subject}.eml`; -} diff --git a/locales/en/common.json b/locales/en/common.json index e1979b6d..e347944c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -778,6 +778,7 @@ "layout": "Layout", "reading": "Reading", "composing": "Composing", + "downloads": "Downloads", "content_senders": "Content & Senders", "about_data": "About & Data", "debug": "Debug" @@ -1498,6 +1499,19 @@ "categories_description": "Rename contact categories", "no_categories": "No categories found" }, + "downloads": { + "title": "Downloads", + "description": "Customize how downloaded emails and attachments are named.", + "reset": "Restore default", + "email_template": { + "label": "Email (.eml) filename", + "description": "Template used when you export an email or drag one out to the file system. The .eml extension is added automatically." + }, + "attachment_template": { + "label": "Attachment filename", + "description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved." + } + }, "filters": { "title": "Email Filters", "description": "Create rules to automatically sort, label, and manage incoming emails", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index caaac19d..4df42fe7 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -239,6 +239,10 @@ interface SettingsState { tourCompleted: boolean; // Interactive tour completed showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device + // Downloads + emailDownloadTemplate: string; + attachmentDownloadTemplate: string; + // Advanced debugMode: boolean; debugCategories: Record; @@ -424,6 +428,10 @@ const DEFAULT_SETTINGS = { tourCompleted: false, showOnboardingOnNewDevices: false, + // Downloads + emailDownloadTemplate: '{date} ({from}-{to}) {subject}', + attachmentDownloadTemplate: '{filename}', + // Advanced debugMode: false, debugCategories: { From 0dca019fe5d579a242b41a82d55540ce47dcae3d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:49:17 +0200 Subject: [PATCH 07/19] fix: stop URL-encoding drag-out filenames and preserve Unicode letters --- hooks/use-attachment-drag.ts | 8 +++++--- hooks/use-email-drag.ts | 11 +++++++---- lib/download-filename.ts | 11 ++++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index 4730ba8d..ef8bd9c6 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -92,9 +92,11 @@ export function useAttachmentDrag( return; } - // `DownloadURL` format: ::. Chromium reads this on - // drop and writes a real file at the destination. - e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`); + // `DownloadURL` format: ::. The filename must be + // raw - URL-encoding it lands literally on disk (`%20` instead of a + // space). Callers are expected to sanitise reserved chars (`:` etc.) + // beforehand. + e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`); e.dataTransfer.effectAllowed = "copyMove"; }, [source.name, source.type, prefetch], diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index b760c330..1e65d09b 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -206,11 +206,14 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const url = singleBlobUrlRef.current; if (url) { const name = emailExportFilename(emailsToDrag[0], emailTemplate); - // `DownloadURL` format: ::. Chromium reads this - // on drop and writes a real file; Firefox/Safari ignore it. + // `DownloadURL` format: ::. Chromium expects + // the filename raw - URL-encoding it ends up literally on disk + // (e.g. `%20` instead of a space). The sanitiser already removed + // `:` and other reserved chars, so embedding the name as-is is + // safe. Firefox/Safari ignore this entry entirely. e.dataTransfer.setData( "DownloadURL", - `message/rfc822:${encodeURIComponent(name)}:${url}`, + `message/rfc822:${name}:${url}`, ); } else { // Not warmed up yet — kick off so the next attempt works. Don't @@ -222,7 +225,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (ready) { e.dataTransfer.setData( "DownloadURL", - `application/zip:${encodeURIComponent(ready.name)}:${ready.url}`, + `application/zip:${ready.name}:${ready.url}`, ); } else { // Kick off the bundle build for the next attempt. diff --git a/lib/download-filename.ts b/lib/download-filename.ts index 64262b20..a99dda98 100644 --- a/lib/download-filename.ts +++ b/lib/download-filename.ts @@ -1,10 +1,11 @@ import type { Email } from "@/lib/jmap/types"; -// Restrict filenames to ASCII letters/digits and a small set of safe -// punctuation. Everything else collapses to `_`. Keeps names predictable -// across Windows/macOS/Linux file systems and avoids emoji/RTL/zero-width -// surprises in subject lines. -const SAFE_CHARS = /[^A-Za-z0-9 _\-().,!@#&+=[\]{}']/g; +// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a +// small set of safe punctuation. Everything else - emojis, RTL/zero-width +// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses +// to `_`. Keeps filenames usable across Windows/macOS/Linux without flattening +// non-ASCII scripts. +const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu; export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}"; export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}"; From 3ac14ecf382a5e59f6754dd007cfba83135ed667 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 14:57:25 +0200 Subject: [PATCH 08/19] feat: add filename transform settings --- components/email/email-viewer.tsx | 24 ++++++- components/settings/downloads-settings.tsx | 80 +++++++++++++++++---- hooks/use-email-drag.ts | 36 ++++++---- lib/download-filename.ts | 83 ++++++++++++++++------ locales/en/common.json | 20 ++++++ stores/settings-store.ts | 8 +++ 6 files changed, 199 insertions(+), 52 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 5060f5b4..8ff180b1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -905,6 +905,24 @@ export function EmailViewer({ const dragOutActive = useMemo(() => isDragOutSupported(), []); const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE; + const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement); + const filenameLowercase = useSettingsStore((state) => state.filenameLowercase); + const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics); + const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators); + const emailFilenameOptions = useMemo(() => ({ + template: emailDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]); + const attachmentFilenameOptions = useMemo(() => ({ + template: attachmentDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]); const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; @@ -2569,9 +2587,9 @@ export function EmailViewer({ (attachment: EffectiveAttachment) => { const fallback = attachment.name || 'download'; if (!email) return fallback; - return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentDownloadTemplate) || fallback; + return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback; }, - [email, attachmentDownloadTemplate], + [email, attachmentFilenameOptions], ); const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => { @@ -3072,7 +3090,7 @@ export function EmailViewer({ const handleExportEmail = async () => { if (!email?.blobId || !client) return; try { - await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822'); + await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822'); } catch { toast.error(tNotifications('export_email_error')); } diff --git a/components/settings/downloads-settings.tsx b/components/settings/downloads-settings.tsx index 1f6406c2..eabf46e3 100644 --- a/components/settings/downloads-settings.tsx +++ b/components/settings/downloads-settings.tsx @@ -3,7 +3,7 @@ import { useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section"; import { RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -14,6 +14,7 @@ import { emailExportFilename, attachmentDownloadFilename, buildSampleEmail, + type EmailFilenameOptions, } from "@/lib/download-filename"; function insertTokenAtCursor( @@ -46,6 +47,7 @@ interface TemplateEditorProps { preview: string; onChange: (next: string) => void; resetLabel: string; + previewLabel: string; placeholder?: string; } @@ -58,6 +60,7 @@ function TemplateEditor({ preview, onChange, resetLabel, + previewLabel, placeholder, }: TemplateEditorProps) { const inputRef = useRef(null); @@ -114,7 +117,7 @@ function TemplateEditor({ ))}
- Preview: + {previewLabel} {preview}
@@ -124,23 +127,45 @@ function TemplateEditor({ export function DownloadsSettings() { const t = useTranslations("settings.downloads"); - const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore(); + const { + emailDownloadTemplate, + attachmentDownloadTemplate, + filenameSpaceReplacement, + filenameLowercase, + filenameStripDiacritics, + filenameCollapseSeparators, + updateSetting, + } = useSettingsStore(); const sampleEmail = useMemo(() => buildSampleEmail(), []); const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []); + const transform = useMemo( + () => ({ + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }), + [filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators], + ); + + const emailOptions: EmailFilenameOptions = useMemo( + () => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }), + [transform, emailDownloadTemplate], + ); + const attachmentOptions: EmailFilenameOptions = useMemo( + () => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }), + [transform, attachmentDownloadTemplate], + ); + const emlPreview = useMemo( - () => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE), - [sampleEmail, emailDownloadTemplate], + () => emailExportFilename(sampleEmail, emailOptions), + [sampleEmail, emailOptions], ); const attachmentPreview = useMemo( - () => - attachmentDownloadFilename( - sampleEmail, - sampleAttachment, - attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE, - ), - [sampleEmail, sampleAttachment, attachmentDownloadTemplate], + () => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions), + [sampleEmail, sampleAttachment, attachmentOptions], ); return ( @@ -154,6 +179,7 @@ export function DownloadsSettings() { preview={emlPreview} onChange={(next) => updateSetting("emailDownloadTemplate", next)} resetLabel={t("reset")} + previewLabel={t("preview")} placeholder={DEFAULT_EMAIL_TEMPLATE} /> updateSetting("attachmentDownloadTemplate", next)} resetLabel={t("reset")} + previewLabel={t("preview")} placeholder={DEFAULT_ATTACHMENT_TEMPLATE} /> + + e.id)); if (currentBundle && currentBundle.key === key) return; if (currentBundle?.url) { @@ -104,11 +115,11 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[], options: Emai } const entry: BundleEntry = { key, - name: bundleFilename(emails.length), + name: bundleFilename(emails.length, bundleOptions), url: null, promise: null, }; - entry.promise = buildEmailZip(client, emails, options) + entry.promise = buildEmailZip(client, emails, emailOptions) .then((url) => { if (url && currentBundle === entry) entry.url = url; return url; @@ -131,6 +142,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const isMobile = useUIStore((state) => state.isMobile); const client = useAuthStore((state) => state.client); const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; + const bundleTemplate = useSettingsStore((s) => s.bundleDownloadTemplate) || DEFAULT_BUNDLE_TEMPLATE; const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement); const lowercase = useSettingsStore((s) => s.filenameLowercase); const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics); @@ -139,6 +151,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD () => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }), [template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators], ); + const bundleOptions: EmailFilenameOptions = useMemo( + () => ({ template: bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }), + [bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators], + ); const dragOutEnabled = !isMobile && isDragOutSupported() && !!client; const singleBlobUrlRef = useRef(null); @@ -181,12 +197,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const selected = emails.filter((em) => selectedEmailIds.has(em.id)); // Only worth bundling when at least one selected email has a blobId. if (selected.some((em) => em.blobId)) { - prefetchEmailBundle(client, selected, filenameOptions); + prefetchEmailBundle(client, selected, filenameOptions, bundleOptions); } } else { prefetchSingle(); } - }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions]); + }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]); const handleDragStart = useCallback((e: DragEvent) => { // Determine which emails to drag: @@ -237,7 +253,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD ); } else { // Kick off the bundle build for the next attempt. - prefetchEmailBundle(client, emailsToDrag, filenameOptions); + prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions); } } } @@ -252,7 +268,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD }); startDrag(emailsToDrag, sourceMailboxId); - }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions]); + }, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]); const handleDragEnd = useCallback(() => { endDrag(); diff --git a/lib/download-filename.ts b/lib/download-filename.ts index 1306ca1a..cbf4cad1 100644 --- a/lib/download-filename.ts +++ b/lib/download-filename.ts @@ -29,6 +29,7 @@ export const DEFAULT_TRANSFORM: Required = { export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}"; export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}"; +export const DEFAULT_BUNDLE_TEMPLATE = "emails-{count}"; export const EMAIL_TOKENS: { token: string; description: string }[] = [ { token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" }, @@ -53,6 +54,16 @@ export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [ { token: "ext", description: "Attachment file extension without leading dot" }, ]; +export const BUNDLE_TOKENS: { token: string; description: string }[] = [ + { token: "count", description: "Number of emails in the bundle" }, + { token: "date", description: "Current date and time, e.g. 2026-05-22 14.05.33" }, + { token: "date_short", description: "Current date, e.g. 2026-05-22" }, + { token: "time", description: "Current time, e.g. 14.05.33" }, + { token: "year", description: "4-digit year" }, + { token: "month", description: "2-digit month" }, + { token: "day", description: "2-digit day" }, +]; + function sanitizePart(input: string, maxLen = 80): string { const cleaned = input .replace(SAFE_CHARS, "_") @@ -211,6 +222,25 @@ export function attachmentDownloadFilename( return `${transformedStem}.${transformedExt}`; } +export function bundleVars(count: number, iso?: string): Record { + const dp = dateParts(iso ?? new Date().toISOString()); + return { ...dp, count: String(count) }; +} + +export function bundleExportFilename( + count: number, + options: EmailFilenameOptions | string = {}, + iso?: string, +): string { + const opts = typeof options === "string" ? { template: options } : options; + const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE; + const rendered = renderRaw(template, bundleVars(count, iso)); + const cleaned = sanitizePart(rendered, 200); + const transformed = applyTransforms(cleaned, opts); + const stem = transformed.slice(0, 200) || "emails"; + return `${stem}.zip`; +} + // Build a synthetic email for previewing templates in the settings UI. export function buildSampleEmail(): Email { // Use a fixed date so the preview doesn't churn as the user types. diff --git a/locales/cs/common.json b/locales/cs/common.json index bf03e236..a84844ae 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1509,6 +1509,10 @@ "label": "Název souboru přílohy", "description": "Šablona použitá při stahování nebo přetahování přílohy. Pokud vynecháte {filename} a {ext}, původní přípona se zachová." }, + "bundle_template": { + "label": "Název .zip souboru s více e-maily", + "description": "Šablona použitá při přetažení nebo stažení několika vybraných e-mailů jako jediného .zip archivu. Přípona .zip se přidává automaticky." + }, "spaces": { "label": "Mezery", "description": "Nahradit mezery ve výsledném názvu souboru jiným znakem.", diff --git a/locales/da/common.json b/locales/da/common.json index 9a830b0b..ac7da26f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1512,6 +1512,10 @@ "label": "Filnavn på vedhæftet fil", "description": "Skabelon som bruges når du downloader eller trækker en vedhæftet fil ud. Hvis du udelader {filename} og {ext}, bevares den oprindelige endelse." }, + "bundle_template": { + "label": "Filnavn for .zip med flere e-mails", + "description": "Skabelon som bruges når du trækker ud eller downloader flere valgte e-mails som ét .zip-arkiv. Endelsen .zip tilføjes automatisk." + }, "spaces": { "label": "Mellemrum", "description": "Erstat mellemrum i det endelige filnavn med et andet tegn.", diff --git a/locales/de/common.json b/locales/de/common.json index ee129229..b4e41157 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1509,6 +1509,10 @@ "label": "Anhang-Dateiname", "description": "Vorlage zum Herunterladen oder Herausziehen eines Anhangs. Wenn {filename} und {ext} fehlen, bleibt die ursprüngliche Endung erhalten." }, + "bundle_template": { + "label": "Mehrere E-Mails als .zip", + "description": "Vorlage, die beim Herausziehen oder Herunterladen mehrerer ausgewählter E-Mails als einzelnes .zip-Archiv verwendet wird. Die Endung .zip wird automatisch ergänzt." + }, "spaces": { "label": "Leerzeichen", "description": "Ersetze Leerzeichen im Dateinamen durch ein anderes Zeichen.", diff --git a/locales/en/common.json b/locales/en/common.json index 8d1b9e32..4227bfe2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1512,6 +1512,10 @@ "label": "Attachment filename", "description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved." }, + "bundle_template": { + "label": "Multi-email .zip filename", + "description": "Template used when you drag out or download several selected emails as a single .zip archive. The .zip extension is added automatically." + }, "spaces": { "label": "Spaces", "description": "Replace spaces in the resulting filename with another character.", diff --git a/locales/es/common.json b/locales/es/common.json index d2fcac44..a9a387cd 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1509,6 +1509,10 @@ "label": "Nombre del archivo adjunto", "description": "Plantilla usada al descargar o arrastrar un adjunto. Si omites {filename} y {ext}, se conserva la extensión original." }, + "bundle_template": { + "label": "Nombre de archivo .zip multi-correo", + "description": "Plantilla utilizada al arrastrar o descargar varios correos seleccionados como un único archivo .zip. La extensión .zip se añade automáticamente." + }, "spaces": { "label": "Espacios", "description": "Reemplaza los espacios del nombre de archivo resultante por otro carácter.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 2970c893..365490a5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1509,6 +1509,10 @@ "label": "Nom du fichier de pièce jointe", "description": "Modèle utilisé lors du téléchargement ou du glisser-déposer d'une pièce jointe. Si vous omettez {filename} et {ext}, l'extension d'origine est conservée." }, + "bundle_template": { + "label": "Nom du fichier .zip multi-e-mails", + "description": "Modèle utilisé lorsque vous faites glisser ou téléchargez plusieurs e-mails sélectionnés sous forme d'archive .zip unique. L'extension .zip est ajoutée automatiquement." + }, "spaces": { "label": "Espaces", "description": "Remplacer les espaces dans le nom de fichier final par un autre caractère.", diff --git a/locales/it/common.json b/locales/it/common.json index 27736557..c168e8ea 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1509,6 +1509,10 @@ "label": "Nome file dell’allegato", "description": "Modello usato quando scarichi o trascini un allegato. Se ometti {filename} e {ext}, l'estensione originale viene mantenuta." }, + "bundle_template": { + "label": "Nome del file .zip multi-email", + "description": "Modello usato quando trascini o scarichi più email selezionate come un singolo archivio .zip. L'estensione .zip viene aggiunta automaticamente." + }, "spaces": { "label": "Spazi", "description": "Sostituisci gli spazi nel nome file risultante con un altro carattere.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 5c8447c3..09b2be6d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1509,6 +1509,10 @@ "label": "添付ファイルのファイル名", "description": "添付ファイルをダウンロードまたはドラッグするときに使用するテンプレートです。{filename} と {ext} を省略すると、元の拡張子が保持されます。" }, + "bundle_template": { + "label": "複数メールの .zip ファイル名", + "description": "複数の選択したメールを 1 つの .zip としてドラッグまたはダウンロードするときに使用するテンプレートです。拡張子 .zip は自動的に付加されます。" + }, "spaces": { "label": "スペース", "description": "最終的なファイル名のスペースを別の文字に置き換えます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 959f2129..71867471 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1509,6 +1509,10 @@ "label": "첨부 파일 이름", "description": "첨부 파일을 다운로드하거나 드래그할 때 사용하는 템플릿입니다. {filename}과 {ext}를 생략하면 원래 확장자가 유지됩니다." }, + "bundle_template": { + "label": "다중 이메일 .zip 파일 이름", + "description": "여러 선택한 이메일을 하나의 .zip 아카이브로 드래그하거나 다운로드할 때 사용하는 템플릿입니다. .zip 확장자는 자동으로 추가됩니다." + }, "spaces": { "label": "공백", "description": "결과 파일 이름의 공백을 다른 문자로 바꿉니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index c259d1c3..a1a04290 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1509,6 +1509,10 @@ "label": "Pielikuma faila nosaukums", "description": "Veidne, ko izmanto, lejupielādējot vai velkot pielikumu. Ja izlaižat {filename} un {ext}, sākotnējais paplašinājums tiek saglabāts." }, + "bundle_template": { + "label": "Vairāku e-pastu .zip faila nosaukums", + "description": "Veidne, ko izmanto, kad velkat vai lejupielādējat vairākus atlasītos e-pastus kā vienu .zip arhīvu. Paplašinājums .zip tiek pievienots automātiski." + }, "spaces": { "label": "Atstarpes", "description": "Aizstāt atstarpes izvades faila nosaukumā ar citu rakstzīmi.", diff --git a/locales/nl/common.json b/locales/nl/common.json index cdb90633..ce6953c3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1509,6 +1509,10 @@ "label": "Bestandsnaam van bijlage", "description": "Sjabloon bij het downloaden of slepen van een bijlage. Als je {filename} en {ext} weglaat, blijft de oorspronkelijke extensie behouden." }, + "bundle_template": { + "label": "Bestandsnaam .zip met meerdere e-mails", + "description": "Sjabloon dat wordt gebruikt wanneer je meerdere geselecteerde e-mails als één .zip-archief sleept of downloadt. De extensie .zip wordt automatisch toegevoegd." + }, "spaces": { "label": "Spaties", "description": "Vervang spaties in de uiteindelijke bestandsnaam door een ander teken.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 0e3296e6..1788421f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1509,6 +1509,10 @@ "label": "Nazwa pliku załącznika", "description": "Szablon używany przy pobieraniu lub przeciąganiu załącznika. Jeśli pominiesz {filename} i {ext}, oryginalne rozszerzenie zostanie zachowane." }, + "bundle_template": { + "label": "Nazwa pliku .zip wielu wiadomości", + "description": "Szablon używany przy przeciąganiu lub pobieraniu kilku wybranych wiadomości jako jednego archiwum .zip. Rozszerzenie .zip jest dodawane automatycznie." + }, "spaces": { "label": "Spacje", "description": "Zastąp spacje w nazwie pliku innym znakiem.", diff --git a/locales/pt/common.json b/locales/pt/common.json index afa9df27..a3b978ea 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1509,6 +1509,10 @@ "label": "Nome do arquivo do anexo", "description": "Modelo usado ao baixar ou arrastar um anexo. Se você omitir {filename} e {ext}, a extensão original é preservada." }, + "bundle_template": { + "label": "Nome do arquivo .zip multi-e-mails", + "description": "Modelo usado ao arrastar ou baixar vários e-mails selecionados como um único arquivo .zip. A extensão .zip é adicionada automaticamente." + }, "spaces": { "label": "Espaços", "description": "Substitui espaços no nome de arquivo resultante por outro caractere.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 30a87474..86884c5c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1509,6 +1509,10 @@ "label": "Имя файла вложения", "description": "Шаблон, используемый при загрузке или перетаскивании вложения. Если опустить {filename} и {ext}, исходное расширение сохраняется." }, + "bundle_template": { + "label": "Имя файла .zip с несколькими письмами", + "description": "Шаблон, используемый при перетаскивании или загрузке нескольких выбранных писем одним .zip-архивом. Расширение .zip добавляется автоматически." + }, "spaces": { "label": "Пробелы", "description": "Заменять пробелы в итоговом имени файла другим символом.", diff --git a/locales/tr/common.json b/locales/tr/common.json index af5f1a1c..d0d4361b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1509,6 +1509,10 @@ "label": "Ek dosyası adı", "description": "Bir eki indirirken veya sürüklerken kullanılan şablon. {filename} ve {ext} yazılmazsa orijinal uzantı korunur." }, + "bundle_template": { + "label": "Çoklu e-posta .zip dosya adı", + "description": "Birden çok seçili e-postayı tek bir .zip arşivi olarak sürüklediğinizde veya indirdiğinizde kullanılan şablon. .zip uzantısı otomatik olarak eklenir." + }, "spaces": { "label": "Boşluklar", "description": "Sonuçtaki dosya adındaki boşlukları başka bir karakterle değiştirin.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3ca5c529..675d732e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1509,6 +1509,10 @@ "label": "Ім'я файлу вкладення", "description": "Шаблон, що використовується при завантаженні або перетягуванні вкладення. Якщо пропустити {filename} та {ext}, оригінальне розширення зберігається." }, + "bundle_template": { + "label": "Ім'я файлу .zip з кількома листами", + "description": "Шаблон, що використовується при перетягуванні або завантаженні кількох вибраних листів одним .zip-архівом. Розширення .zip додається автоматично." + }, "spaces": { "label": "Пробіли", "description": "Замінити пробіли в підсумковому імені файлу іншим символом.", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4051e48f..787531c0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1509,6 +1509,10 @@ "label": "附件文件名", "description": "下载或拖出附件时使用的模板。如果省略 {filename} 和 {ext},将保留原始扩展名。" }, + "bundle_template": { + "label": "多邮件 .zip 文件名", + "description": "将多个选中的邮件作为单个 .zip 拖出或下载时使用的模板。扩展名 .zip 会自动添加。" + }, "spaces": { "label": "空格", "description": "用其他字符替换最终文件名中的空格。", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index a9c95a87..2cad20c0 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -242,6 +242,7 @@ interface SettingsState { // Downloads emailDownloadTemplate: string; attachmentDownloadTemplate: string; + bundleDownloadTemplate: string; filenameSpaceReplacement: 'keep' | 'underscore' | 'dash'; filenameLowercase: boolean; filenameStripDiacritics: boolean; @@ -435,6 +436,7 @@ const DEFAULT_SETTINGS = { // Downloads emailDownloadTemplate: '{date} ({from}-{to}) {subject}', attachmentDownloadTemplate: '{filename}', + bundleDownloadTemplate: 'emails-{count}', filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash', filenameLowercase: false, filenameStripDiacritics: false, From 58e4a3d117cddd4562694448b745ed2711003a93 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 15:36:15 +0200 Subject: [PATCH 12/19] feat: add post-export action setting (keep/archive/trash) --- components/email/email-viewer.tsx | 4 ++++ components/settings/downloads-settings.tsx | 12 ++++++++++++ locales/cs/common.json | 7 +++++++ locales/da/common.json | 7 +++++++ locales/de/common.json | 7 +++++++ locales/en/common.json | 7 +++++++ locales/es/common.json | 7 +++++++ locales/fr/common.json | 7 +++++++ locales/it/common.json | 7 +++++++ locales/ja/common.json | 7 +++++++ locales/ko/common.json | 7 +++++++ locales/lv/common.json | 7 +++++++ locales/nl/common.json | 7 +++++++ locales/pl/common.json | 7 +++++++ locales/pt/common.json | 7 +++++++ locales/ru/common.json | 7 +++++++ locales/tr/common.json | 7 +++++++ locales/uk/common.json | 7 +++++++ locales/zh/common.json | 7 +++++++ stores/settings-store.ts | 2 ++ 20 files changed, 137 insertions(+) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1ddc9967..cdfb0c10 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3094,7 +3094,11 @@ export function EmailViewer({ await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822'); } catch { toast.error(tNotifications('export_email_error')); + return; } + const action = useSettingsStore.getState().postExportAction; + if (action === 'archive') onArchive?.(); + else if (action === 'trash') onDelete?.(); }; // Import email from .eml file or .zip archive containing .eml files diff --git a/components/settings/downloads-settings.tsx b/components/settings/downloads-settings.tsx index 5fc9b4f4..a242c41f 100644 --- a/components/settings/downloads-settings.tsx +++ b/components/settings/downloads-settings.tsx @@ -138,6 +138,7 @@ export function DownloadsSettings() { filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators, + postExportAction, updateSetting, } = useSettingsStore(); @@ -249,6 +250,17 @@ export function DownloadsSettings() { onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)} /> + + updateSetting('deleteAction', value as 'trash' | 'permanent')} + onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')} options={[ { value: 'trash', label: t('delete_action.trash') }, + { value: 'trash-and-read', label: t('delete_action.trash_and_read') }, { value: 'permanent', label: t('delete_action.permanent') }, ]} /> diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index cac0344e..18e00360 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -264,10 +264,11 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } - async moveToTrash(emailId: string, trashMailboxId: string): Promise { + async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise { const email = this.data.emails.find(e => e.id === emailId); if (!email) return; email.mailboxIds = { [trashMailboxId]: true }; + if (markAsRead) email.keywords.$seen = true; this.recalcMailboxCounts(); } @@ -277,10 +278,13 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } - async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise { + async batchMoveEmails(emailIds: string[], toMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise { for (const id of emailIds) { const email = this.data.emails.find(e => e.id === id); - if (email) email.mailboxIds = { [toMailboxId]: true }; + if (email) { + email.mailboxIds = { [toMailboxId]: true }; + if (markAsRead) email.keywords.$seen = true; + } } this.recalcMailboxCounts(); } @@ -355,10 +359,13 @@ export class DemoJMAPClient implements IJMAPClient { return count; } - async markAsSpam(emailId: string): Promise { + async markAsSpam(emailId: string, _accountId?: string, markAsRead?: boolean): Promise { const email = this.data.emails.find(e => e.id === emailId); const junkMb = this.data.mailboxes.find(m => m.role === 'junk'); - if (email && junkMb) email.mailboxIds = { [junkMb.id]: true }; + if (email && junkMb) { + email.mailboxIds = { [junkMb.id]: true }; + if (markAsRead) email.keywords.$seen = true; + } this.recalcMailboxCounts(); } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 1ccbbd5f..5ec9ec0e 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -96,9 +96,9 @@ export interface IJMAPClient { setKeyword(emailId: string, keyword: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string): Promise; - moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise; + moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; batchDeleteEmails(emailIds: string[]): Promise; - batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise; + batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; batchArchiveEmails( emails: Array<{ id: string; receivedAt: string }>, archiveMailboxId: string, @@ -110,7 +110,7 @@ export interface IJMAPClient { emptyMailbox(mailboxId: string): Promise; markMailboxAsRead(mailboxId: string, accountId?: string): Promise; markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise; - markAsSpam(emailId: string, accountId?: string): Promise; + markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise; undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise; // ── Threads ─────────────────────────────────────────────────── diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d27d5e45..ced1c366 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1226,16 +1226,14 @@ export class JMAPClient implements IJMAPClient { ]); } - async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise { + async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise { const targetAccountId = accountId || this.accountId; + const patch: Record = { mailboxIds: { [trashMailboxId]: true } }; + if (markAsRead) patch["keywords/$seen"] = true; await this.request([ ["Email/set", { accountId: targetAccountId, - update: { - [emailId]: { - mailboxIds: { [trashMailboxId]: true }, - }, - }, + update: { [emailId]: patch }, }, "0"], ]); } @@ -1251,10 +1249,15 @@ export class JMAPClient implements IJMAPClient { ]); } - async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise { + async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise { if (emailIds.length === 0) return; - const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }])); + const buildPatch = () => { + const patch: Record = { mailboxIds: { [toMailboxId]: true } }; + if (markAsRead) patch["keywords/$seen"] = true; + return patch; + }; + const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()])); await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ]); @@ -1507,7 +1510,7 @@ export class JMAPClient implements IJMAPClient { return totalMarked; } - async markAsSpam(emailId: string, accountId?: string): Promise { + async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise { const targetAccountId = accountId || this.accountId; const mailboxes = await this.getMailboxes(); @@ -1526,14 +1529,13 @@ export class JMAPClient implements IJMAPClient { ? junkMailbox.originalId : junkMailbox.id; + const patch: Record = { mailboxIds: { [mailboxId]: true } }; + if (markAsRead) patch["keywords/$seen"] = true; + await this.request([ ["Email/set", { accountId: targetAccountId, - update: { - [emailId]: { - mailboxIds: { [mailboxId]: true }, - }, - }, + update: { [emailId]: patch }, }, "0"], ]); } diff --git a/locales/en/common.json b/locales/en/common.json index 09c44c0a..43545e7d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -974,6 +974,7 @@ "label": "Delete Action", "description": "What happens when you delete an email", "trash": "Move to Trash", + "trash_and_read": "Move to Trash and mark as read", "permanent": "Delete Permanently", "warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible." }, diff --git a/stores/email-store.ts b/stores/email-store.ts index 76532385..9798792e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -773,14 +773,18 @@ export const useEmailStore = create((set, get) => ({ forceDelete = true; } - // If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox - if (deleteAction === 'trash' && !forceDelete) { + // If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox + if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) { const trashMailbox = findTrashMailbox(mailboxes, { accountId }); + const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread; if (trashMailbox) { // Use originalId for shared mailboxes if available const trashId = trashMailbox.originalId || trashMailbox.id; - await effectiveClient.moveToTrash(emailId, trashId, accountId); + await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead); + + // After marking read in the same request, the email arrives in trash as read. + const arrivesUnread = isUnread && !alsoMarkRead; // Remove from local state (email moved to trash, not in current view) set((state) => { @@ -803,9 +807,9 @@ export const useEmailStore = create((set, get) => ({ return { ...mailbox, totalEmails: mailbox.totalEmails + 1, - unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, + unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, totalThreads: mailbox.totalThreads + 1, - unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads + unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads }; } return mailbox; @@ -1514,6 +1518,7 @@ export const useEmailStore = create((set, get) => ({ const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; const isInJunk = currentMailbox?.role === 'junk'; const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk); + const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; // Group emails by accountId (handles unified view and search results spanning accounts). const emailsByAccount = new Map(); @@ -1554,7 +1559,7 @@ export const useEmailStore = create((set, get) => ({ return; } const trashId = trashMailbox.originalId || trashMailbox.id; - await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId); + await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead); ids.forEach(id => movedEmailIds.add(id)); }); await Promise.allSettled(promises); @@ -1745,7 +1750,9 @@ export const useEmailStore = create((set, get) => ({ }); try { - await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId); + const isUnread = !email.keywords?.$seen; + const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread; + await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead); set(state => ({ emails: state.emails.filter(e => e.id !== emailId), @@ -1800,16 +1807,20 @@ export const useEmailStore = create((set, get) => ({ }, batchMarkAsSpam: async (client, emailIds) => { - const { selectedMailbox } = get(); + const { selectedMailbox, emails } = get(); const mailboxes = resolveActionMailboxes(); const effectiveClient = resolveActionClient(client); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (!currentMailbox) return; + const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; + try { for (const emailId of emailIds) { - await effectiveClient.markAsSpam(emailId, currentMailbox.accountId); + const email = emails.find(e => e.id === emailId); + const markRead = alsoMarkRead && !!email && !email.keywords?.$seen; + await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead); } set(state => ({ diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 4f839e5f..fd8dcfe1 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -28,7 +28,7 @@ export type FontSize = 'small' | 'medium' | 'large'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; /** @deprecated Use Density instead */ export type ListDensity = Density; -export type DeleteAction = 'trash' | 'permanent'; +export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type ReplyMode = 'reply' | 'replyAll'; export type SignaturePosition = 'above_quote' | 'below_quote'; export type DateFormat = 'regional' | 'iso' | 'custom';