feat: name dragged/exported .eml files as "date (from-to) subject" with ASCII-only chars
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
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 { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -3056,8 +3057,7 @@ export function EmailViewer({
|
|||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
if (!email?.blobId || !client) return;
|
if (!email?.blobId || !client) return;
|
||||||
try {
|
try {
|
||||||
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100);
|
await client.downloadBlob(email.blobId, emailExportFilename(email), 'message/rfc822');
|
||||||
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(tNotifications('export_email_error'));
|
toast.error(tNotifications('export_email_error'));
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-35
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
@@ -8,6 +8,7 @@ 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";
|
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
||||||
|
import { emailExportFilename } from "@/lib/email-filename";
|
||||||
|
|
||||||
interface UseEmailDragOptions {
|
interface UseEmailDragOptions {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -48,36 +49,6 @@ function createDragPreview(count: number): HTMLElement {
|
|||||||
return preview;
|
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 {
|
function bundleFilename(count: number): string {
|
||||||
return `emails-${count}.zip`;
|
return `emails-${count}.zip`;
|
||||||
}
|
}
|
||||||
@@ -107,7 +78,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise<stri
|
|||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
eligible.map(async (em) => {
|
eligible.map(async (em) => {
|
||||||
const base = emlFilename(em).replace(/\.eml$/, "");
|
const base = emailExportFilename(em).replace(/\.eml$/, "");
|
||||||
let name = `${base}.eml`;
|
let name = `${base}.eml`;
|
||||||
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
||||||
used.add(name);
|
used.add(name);
|
||||||
@@ -179,7 +150,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
const prefetchSingle = useCallback(() => {
|
const prefetchSingle = useCallback(() => {
|
||||||
if (!dragOutEnabled || !client || !email.blobId) return;
|
if (!dragOutEnabled || !client || !email.blobId) return;
|
||||||
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
||||||
const name = emlFilename(email);
|
const name = emailExportFilename(email);
|
||||||
inFlightRef.current = client
|
inFlightRef.current = client
|
||||||
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
@@ -232,7 +203,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
||||||
const url = singleBlobUrlRef.current;
|
const url = singleBlobUrlRef.current;
|
||||||
if (url) {
|
if (url) {
|
||||||
const name = emlFilename(emailsToDrag[0]);
|
const name = emailExportFilename(emailsToDrag[0]);
|
||||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this
|
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this
|
||||||
// on drop and writes a real file; Firefox/Safari ignore it.
|
// on drop and writes a real file; Firefox/Safari ignore it.
|
||||||
e.dataTransfer.setData(
|
e.dataTransfer.setData(
|
||||||
@@ -240,7 +211,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
`message/rfc822:${encodeURIComponent(name)}:${url}`,
|
`message/rfc822:${encodeURIComponent(name)}:${url}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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.
|
// preventDefault: in-app drop still has to function.
|
||||||
prefetchSingle();
|
prefetchSingle();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user