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] 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,