feat: name dragged/exported .eml files as "date (from-to) subject" with ASCII-only chars

This commit is contained in:
Linus Rath
2026-05-22 14:28:36 +02:00
parent 0245ec67e1
commit 8bcb487442
3 changed files with 51 additions and 37 deletions
+6 -35
View File
@@ -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<stri
const used = new Set<string>();
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: <mime>:<filename>:<url>. 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();
}