feat: drag emails out to file explorer as .eml
This commit is contained in:
+182
-4
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, DragEvent } from "react";
|
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
|
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
|
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
||||||
|
|
||||||
interface UseEmailDragOptions {
|
interface UseEmailDragOptions {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -15,6 +18,7 @@ interface UseEmailDragOptions {
|
|||||||
interface UseEmailDragReturn {
|
interface UseEmailDragReturn {
|
||||||
dragHandlers: {
|
dragHandlers: {
|
||||||
draggable: boolean;
|
draggable: boolean;
|
||||||
|
onPointerEnter?: () => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||||
};
|
};
|
||||||
@@ -44,10 +48,145 @@ function createDragPreview(count: number): HTMLElement {
|
|||||||
return preview;
|
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<string | null> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentBundle: BundleEntry | null = null;
|
||||||
|
|
||||||
|
function selectionKey(ids: string[]): string {
|
||||||
|
return [...ids].sort().join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise<string | null> {
|
||||||
|
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<string>();
|
||||||
|
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 {
|
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||||
const { selectedEmailIds, emails } = useEmailStore();
|
const { selectedEmailIds, emails } = useEmailStore();
|
||||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
const client = useAuthStore((state) => state.client);
|
||||||
|
|
||||||
|
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
|
||||||
|
const singleBlobUrlRef = useRef<string | null>(null);
|
||||||
|
const inFlightRef = useRef<Promise<string | null> | 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<HTMLDivElement>) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
// Determine which emails to drag:
|
// Determine which emails to drag:
|
||||||
@@ -59,8 +198,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||||
: threadEmails || [email];
|
: threadEmails || [email];
|
||||||
|
|
||||||
// Set data transfer
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
e.dataTransfer.setData(
|
e.dataTransfer.setData(
|
||||||
"application/x-email-ids",
|
"application/x-email-ids",
|
||||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
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(", ")
|
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: <mime>:<filename>:<url>. 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
|
// Create custom drag image
|
||||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||||
@@ -80,10 +249,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
});
|
});
|
||||||
|
|
||||||
startDrag(emailsToDrag, sourceMailboxId);
|
startDrag(emailsToDrag, sourceMailboxId);
|
||||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
|
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
endDrag();
|
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]);
|
}, [endDrag]);
|
||||||
|
|
||||||
// Check if this specific email is being dragged
|
// Check if this specific email is being dragged
|
||||||
@@ -94,6 +271,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
|
||||||
: {
|
: {
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
|
||||||
onDragStart: handleDragStart,
|
onDragStart: handleDragStart,
|
||||||
onDragEnd: handleDragEnd,
|
onDragEnd: handleDragEnd,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ const EMAIL_LIST_PROPERTIES = [
|
|||||||
"subject",
|
"subject",
|
||||||
"preview",
|
"preview",
|
||||||
"hasAttachment",
|
"hasAttachment",
|
||||||
|
// Needed so list rows can serve drag-out to the file system as .eml.
|
||||||
|
"blobId",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
||||||
|
|||||||
Reference in New Issue
Block a user