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
+34 -19
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename } from "@/lib/email-filename";
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
@@ -800,6 +800,7 @@ interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
draggable: boolean;
onPointerEnter: () => void;
@@ -808,9 +809,9 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode;
}
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({
name: attachment.name || 'download',
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
@@ -833,7 +834,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
}
return null;
},
}), [attachment, client]);
}), [attachment, client, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>;
}
@@ -902,6 +903,8 @@ export function EmailViewer({
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []);
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -2562,6 +2565,15 @@ export function EmailViewer({
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
const fallback = attachment.name || 'download';
if (!email) return fallback;
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentDownloadTemplate) || fallback;
},
[email, attachmentDownloadTemplate],
);
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
@@ -2571,6 +2583,8 @@ export function EmailViewer({
&& mailAttachmentAction === 'preview'
&& isMimeTypeSafeForInlinePreview(attachment.type);
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
@@ -2581,7 +2595,7 @@ export function EmailViewer({
if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
return;
}
@@ -2601,7 +2615,7 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
@@ -2631,16 +2645,17 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
@@ -2650,7 +2665,7 @@ export function EmailViewer({
};
emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
return;
}
@@ -2663,7 +2678,7 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
@@ -2679,12 +2694,12 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment, email?.id]);
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
// Pre-fetch object URLs for image attachments so their actual contents can be
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
@@ -3057,7 +3072,7 @@ export function EmailViewer({
const handleExportEmail = async () => {
if (!email?.blobId || !client) return;
try {
await client.downloadBlob(email.blobId, emailExportFilename(email), 'message/rfc822');
await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822');
} catch {
toast.error(tNotifications('export_email_error'));
}
@@ -4209,7 +4224,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4289,7 +4304,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4908,7 +4923,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4993,7 +5008,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -5051,7 +5066,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -5130,7 +5145,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { SettingsSection } from "./settings-section";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ATTACHMENT_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
} from "@/lib/download-filename";
function insertTokenAtCursor(
input: HTMLInputElement,
token: string,
current: string,
onChange: (next: string) => void,
): void {
const start = input.selectionStart ?? current.length;
const end = input.selectionEnd ?? current.length;
const before = current.slice(0, start);
const after = current.slice(end);
const insertion = `{${token}}`;
const next = `${before}${insertion}${after}`;
onChange(next);
// Restore focus and place caret after the inserted token.
requestAnimationFrame(() => {
input.focus();
const caret = before.length + insertion.length;
input.setSelectionRange(caret, caret);
});
}
interface TemplateEditorProps {
label: string;
description: string;
value: string;
defaultValue: string;
tokens: { token: string; description: string }[];
preview: string;
onChange: (next: string) => void;
resetLabel: string;
placeholder?: string;
}
function TemplateEditor({
label,
description,
value,
defaultValue,
tokens,
preview,
onChange,
resetLabel,
placeholder,
}: TemplateEditorProps) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div data-search-label={label} className="space-y-3 py-3 border-b border-border last:border-0">
<div>
<label className="text-sm font-medium text-foreground">{label}</label>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-stretch gap-2">
<input
ref={inputRef}
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
/>
<button
type="button"
onClick={() => onChange(defaultValue)}
disabled={value === defaultValue}
title={resetLabel}
className={cn(
"px-2 rounded-md border border-border text-foreground transition-colors duration-150",
value === defaultValue
? "opacity-40 cursor-not-allowed"
: "hover:bg-muted cursor-pointer",
)}
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{tokens.map((t) => (
<button
key={t.token}
type="button"
title={t.description}
onClick={() => {
const input = inputRef.current;
if (!input) {
onChange(`${value}{${t.token}}`);
return;
}
insertTokenAtCursor(input, t.token, value, onChange);
}}
className="px-2 py-0.5 text-xs font-mono rounded bg-muted hover:bg-accent border border-border text-foreground transition-colors duration-150 cursor-pointer"
>
{`{${t.token}}`}
</button>
))}
</div>
<div className="text-xs text-muted-foreground">
<span className="opacity-70">Preview: </span>
<span className="font-mono text-foreground/90 break-all">{preview}</span>
</div>
</div>
</div>
);
}
export function DownloadsSettings() {
const t = useTranslations("settings.downloads");
const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore();
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE),
[sampleEmail, emailDownloadTemplate],
);
const attachmentPreview = useMemo(
() =>
attachmentDownloadFilename(
sampleEmail,
sampleAttachment,
attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE,
),
[sampleEmail, sampleAttachment, attachmentDownloadTemplate],
);
return (
<SettingsSection title={t("title")} description={t("description")}>
<TemplateEditor
label={t("email_template.label")}
description={t("email_template.description")}
value={emailDownloadTemplate}
defaultValue={DEFAULT_EMAIL_TEMPLATE}
tokens={EMAIL_TOKENS}
preview={emlPreview}
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
resetLabel={t("reset")}
placeholder={DEFAULT_EMAIL_TEMPLATE}
/>
<TemplateEditor
label={t("attachment_template.label")}
description={t("attachment_template.description")}
value={attachmentDownloadTemplate}
defaultValue={DEFAULT_ATTACHMENT_TEMPLATE}
tokens={ATTACHMENT_TOKENS}
preview={attachmentPreview}
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
resetLabel={t("reset")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
/>
</SettingsSection>
);
}