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] 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}";