feat: add Downloads settings tab with template editor for .eml and attachment filenames

This commit is contained in:
Linus Rath
2026-05-22 14:46:25 +02:00
parent 8bcb487442
commit ca0d6805cf
8 changed files with 446 additions and 74 deletions
+14 -12
View File
@@ -8,7 +8,8 @@ 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";
import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-filename";
import { useSettingsStore } from "@/stores/settings-store";
interface UseEmailDragOptions {
email: Email;
@@ -70,7 +71,7 @@ function selectionKey(ids: string[]): string {
return [...ids].sort().join(",");
}
async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise<string | null> {
async function buildEmailZip(client: IJMAPClient, emails: Email[], template: string): Promise<string | null> {
const eligible = emails.filter((em) => !!em.blobId);
if (eligible.length === 0) return null;
const { default: JSZip } = await import("jszip");
@@ -78,7 +79,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise<stri
const used = new Set<string>();
await Promise.all(
eligible.map(async (em) => {
const base = emailExportFilename(em).replace(/\.eml$/, "");
const base = emailExportFilename(em, template).replace(/\.eml$/, "");
let name = `${base}.eml`;
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
used.add(name);
@@ -94,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise<stri
return URL.createObjectURL(zipBlob);
}
function prefetchEmailBundle(client: IJMAPClient, emails: Email[]): void {
function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: string): void {
const key = selectionKey(emails.map((e) => e.id));
if (currentBundle && currentBundle.key === key) return;
if (currentBundle?.url) {
@@ -107,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[]): void {
url: null,
promise: null,
};
entry.promise = buildEmailZip(client, emails)
entry.promise = buildEmailZip(client, emails, template)
.then((url) => {
if (url && currentBundle === entry) entry.url = url;
return url;
@@ -129,6 +130,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile);
const client = useAuthStore((state) => state.client);
const emailTemplate = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
const singleBlobUrlRef = useRef<string | null>(null);
@@ -150,7 +152,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 = emailExportFilename(email);
const name = emailExportFilename(email, emailTemplate);
inFlightRef.current = client
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
.then((url) => {
@@ -161,7 +163,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
.finally(() => {
inFlightRef.current = null;
});
}, [dragOutEnabled, client, email]);
}, [dragOutEnabled, client, email, emailTemplate]);
const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return;
@@ -171,12 +173,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
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);
prefetchEmailBundle(client, selected, emailTemplate);
}
} else {
prefetchSingle();
}
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle]);
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
@@ -203,7 +205,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
const url = singleBlobUrlRef.current;
if (url) {
const name = emailExportFilename(emailsToDrag[0]);
const name = emailExportFilename(emailsToDrag[0], emailTemplate);
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this
// on drop and writes a real file; Firefox/Safari ignore it.
e.dataTransfer.setData(
@@ -224,7 +226,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
);
} else {
// Kick off the bundle build for the next attempt.
prefetchEmailBundle(client, emailsToDrag);
prefetchEmailBundle(client, emailsToDrag, emailTemplate);
}
}
}
@@ -239,7 +241,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle]);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, emailTemplate]);
const handleDragEnd = useCallback(() => {
endDrag();