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] 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: {