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`; +}