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
+8
View File
@@ -33,6 +33,7 @@ import {
Languages, Languages,
Info, Info,
Bug, Bug,
Download,
X, X,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings'; import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
@@ -90,6 +92,7 @@ type Tab =
| 'layout' | 'layout'
| 'reading' | 'reading'
| 'composing' | 'composing'
| 'downloads'
| 'identities' | 'identities'
| 'vacation' | 'vacation'
| 'filters' | 'filters'
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
layout: LayoutGrid, layout: LayoutGrid,
reading: BookOpen, reading: BookOpen,
composing: PenLine, composing: PenLine,
downloads: Download,
identities: UserPen, identities: UserPen,
vacation: PalmtreeIcon, vacation: PalmtreeIcon,
filters: Filter, filters: Filter,
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.signature_position', 'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter', 'settings.email_behavior.sub_address_delimiter',
], ],
downloads: ['settings.downloads'],
identities: ['settings.identities'], identities: ['settings.identities'],
vacation: ['settings.vacation'], vacation: ['settings.vacation'],
filters: ['settings.filters'], filters: ['settings.filters'],
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
layout: 'toolbar sidebar account switcher unified mailbox icons rail', layout: 'toolbar sidebar account switcher unified mailbox icons rail',
reading: 'mark read preview thread conversation archive delete attachment open', reading: 'mark read preview thread conversation archive delete attachment open',
composing: 'editor signature plain text reply forward draft compose', composing: 'editor signature plain text reply forward draft compose',
downloads: 'download filename template eml attachment save export',
identities: 'from address signature email', identities: 'from address signature email',
vacation: 'auto reply away out of office holiday responder', vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules block junk forward', filters: 'sieve rules block junk forward',
@@ -580,6 +586,7 @@ export default function SettingsPage() {
// Mail // Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
@@ -666,6 +673,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'layout' && <LayoutSettings />} {effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />} {effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />} {effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />} {effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />} {effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />} {effectiveActiveTab === 'filters' && <FilterSettings />}
+34 -19
View File
@@ -3,7 +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 { 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 { 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";
@@ -800,6 +800,7 @@ interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment; attachment: EffectiveAttachment;
client: IJMAPClient | null; client: IJMAPClient | null;
enabled: boolean; enabled: boolean;
downloadName?: string;
children: (dragProps: { children: (dragProps: {
draggable: boolean; draggable: boolean;
onPointerEnter: () => void; onPointerEnter: () => void;
@@ -808,9 +809,9 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode; }) => React.ReactNode;
} }
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) { function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({ const source = useMemo<AttachmentDragSource>(() => ({
name: attachment.name || 'download', name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream', type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => { getBlobUrl: async () => {
if (attachment.blobId && client) { if (attachment.blobId && client) {
@@ -833,7 +834,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
} }
return null; return null;
}, },
}), [attachment, client]); }), [attachment, client, downloadName]);
const drag = useAttachmentDrag(source, enabled); const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>; return <>{children(drag)}</>;
} }
@@ -902,6 +903,8 @@ export function EmailViewer({
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []); 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 timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
@@ -2562,6 +2565,15 @@ export function EmailViewer({
return emailContent; return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); }, [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 handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
// Blob URLs inherit our origin; script-bearing MIME types (text/html, // Blob URLs inherit our origin; script-bearing MIME types (text/html,
@@ -2571,6 +2583,8 @@ export function EmailViewer({
&& mailAttachmentAction === 'preview' && mailAttachmentAction === 'preview'
&& isMimeTypeSafeForInlinePreview(attachment.type); && isMimeTypeSafeForInlinePreview(attachment.type);
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = { const info: AttachmentInfo = {
name: attachment.name || '', name: attachment.name || '',
type: attachment.type, type: attachment.type,
@@ -2581,7 +2595,7 @@ export function EmailViewer({
if (attachment.blobId && onDownloadAttachment) { if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
return; return;
} }
@@ -2601,7 +2615,7 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
@@ -2631,16 +2645,17 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
} }
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment, email?.id]); }, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = { const info: AttachmentInfo = {
name: attachment.name || '', name: attachment.name || '',
type: attachment.type, type: attachment.type,
@@ -2650,7 +2665,7 @@ export function EmailViewer({
}; };
emailHooks.onAttachmentDownload.emit(info); emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) { if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true); onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
return; return;
} }
@@ -2663,7 +2678,7 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
@@ -2679,12 +2694,12 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = objectUrl; anchor.href = objectUrl;
anchor.download = attachment.name || 'download'; anchor.download = downloadName;
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); 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 // 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. // rendered as thumbnails inside the chip. Skips images larger than 10 MB.
@@ -3057,7 +3072,7 @@ export function EmailViewer({
const handleExportEmail = async () => { const handleExportEmail = async () => {
if (!email?.blobId || !client) return; if (!email?.blobId || !client) return;
try { try {
await client.downloadBlob(email.blobId, emailExportFilename(email), 'message/rfc822'); await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822');
} catch { } catch {
toast.error(tNotifications('export_email_error')); toast.error(tNotifications('export_email_error'));
} }
@@ -4209,7 +4224,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id]; const thumbUrl = imageThumbUrls[attachment.id];
return ( 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) => ( {(dragProps) => (
<div <div
className={cn( className={cn(
@@ -4289,7 +4304,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( 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) => ( {(dragProps) => (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full" 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 opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id]; const thumbUrl = imageThumbUrls[attachment.id];
return ( 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) => ( {(dragProps) => (
<div <div
className={cn( className={cn(
@@ -4993,7 +5008,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( 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) => ( {(dragProps) => (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full" 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 opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id]; const thumbUrl = imageThumbUrls[attachment.id];
return ( 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) => ( {(dragProps) => (
<div <div
className={cn( className={cn(
@@ -5130,7 +5145,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return ( 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) => ( {(dragProps) => (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full" 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>
);
}
+14 -12
View File
@@ -8,7 +8,8 @@ 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"; import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-filename";
import { useSettingsStore } from "@/stores/settings-store";
interface UseEmailDragOptions { interface UseEmailDragOptions {
email: Email; email: Email;
@@ -70,7 +71,7 @@ function selectionKey(ids: string[]): string {
return [...ids].sort().join(","); 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); const eligible = emails.filter((em) => !!em.blobId);
if (eligible.length === 0) return null; if (eligible.length === 0) return null;
const { default: JSZip } = await import("jszip"); const { default: JSZip } = await import("jszip");
@@ -78,7 +79,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 = emailExportFilename(em).replace(/\.eml$/, ""); const base = emailExportFilename(em, template).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);
@@ -94,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[]): Promise<stri
return URL.createObjectURL(zipBlob); 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)); const key = selectionKey(emails.map((e) => e.id));
if (currentBundle && currentBundle.key === key) return; if (currentBundle && currentBundle.key === key) return;
if (currentBundle?.url) { if (currentBundle?.url) {
@@ -107,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[]): void {
url: null, url: null,
promise: null, promise: null,
}; };
entry.promise = buildEmailZip(client, emails) entry.promise = buildEmailZip(client, emails, template)
.then((url) => { .then((url) => {
if (url && currentBundle === entry) entry.url = url; if (url && currentBundle === entry) entry.url = url;
return url; return url;
@@ -129,6 +130,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
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 client = useAuthStore((state) => state.client);
const emailTemplate = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client; const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
const singleBlobUrlRef = useRef<string | null>(null); const singleBlobUrlRef = useRef<string | null>(null);
@@ -150,7 +152,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 = emailExportFilename(email); const name = emailExportFilename(email, emailTemplate);
inFlightRef.current = client inFlightRef.current = client
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
.then((url) => { .then((url) => {
@@ -161,7 +163,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
.finally(() => { .finally(() => {
inFlightRef.current = null; inFlightRef.current = null;
}); });
}, [dragOutEnabled, client, email]); }, [dragOutEnabled, client, email, emailTemplate]);
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return; if (!dragOutEnabled || !client) return;
@@ -171,12 +173,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const selected = emails.filter((em) => selectedEmailIds.has(em.id)); const selected = emails.filter((em) => selectedEmailIds.has(em.id));
// Only worth bundling when at least one selected email has a blobId. // Only worth bundling when at least one selected email has a blobId.
if (selected.some((em) => em.blobId)) { if (selected.some((em) => em.blobId)) {
prefetchEmailBundle(client, selected); prefetchEmailBundle(client, selected, emailTemplate);
} }
} else { } else {
prefetchSingle(); prefetchSingle();
} }
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle]); }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => { const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag: // Determine which emails to drag:
@@ -203,7 +205,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 = emailExportFilename(emailsToDrag[0]); const name = emailExportFilename(emailsToDrag[0], emailTemplate);
// `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(
@@ -224,7 +226,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
); );
} else { } else {
// Kick off the bundle build for the next attempt. // 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); 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(() => { const handleDragEnd = useCallback(() => {
endDrag(); endDrag();
+196
View File
@@ -0,0 +1,196 @@
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;
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
export const EMAIL_TOKENS: { token: string; description: string }[] = [
{ token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Date only, e.g. 2026-05-22" },
{ token: "time", description: "Time only, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
{ token: "from", description: "Sender display name (falls back to email user part)" },
{ token: "from_email", description: "Sender full email address" },
{ token: "from_name", description: "Sender name only" },
{ token: "to", description: "First recipient display name" },
{ token: "to_email", description: "First recipient full email address" },
{ token: "to_name", description: "First recipient name only" },
{ token: "subject", description: "Email subject" },
];
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
...EMAIL_TOKENS,
{ token: "filename", description: "Original attachment filename including extension" },
{ token: "name", description: "Attachment filename without extension" },
{ token: "ext", description: "Attachment file extension without leading dot" },
];
function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input
.replace(SAFE_CHARS, "_")
.replace(/_+/g, "_")
.replace(/\s+/g, " ")
.trim()
.replace(/^[._-]+|[._-]+$/g, "");
return cleaned.slice(0, maxLen);
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function dateParts(iso: string | null | undefined) {
const d = iso ? new Date(iso) : new Date();
if (Number.isNaN(d.getTime())) {
return {
date: "0000-00-00 00.00.00",
date_short: "0000-00-00",
time: "00.00.00",
year: "0000",
month: "00",
day: "00",
};
}
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const day = pad2(d.getDate());
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
return {
date: `${year}-${month}-${day} ${time}`,
date_short: `${year}-${month}-${day}`,
time,
year,
month,
day,
};
}
function addrLabel(addr: { name?: string | null; email: string } | undefined): {
name: string;
email: string;
label: string;
} {
if (!addr) return { name: "", email: "", label: "unknown" };
const name = (addr.name && addr.name.trim()) || "";
const email = addr.email || "";
const label = name || email.split("@")[0] || email || "unknown";
return { name, email, label };
}
export function emailVars(email: Email): Record<string, string> {
const dp = dateParts(email.receivedAt || email.sentAt);
const from = addrLabel(email.from?.[0]);
const to = addrLabel(email.to?.[0]);
return {
...dp,
from: from.label,
from_email: from.email,
from_name: from.name,
to: to.label,
to_email: to.email,
to_name: to.name,
subject: email.subject || "no subject",
};
}
export interface AttachmentLike {
name?: string | null;
type?: string | null;
}
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
const filename = (attachment.name || "attachment").trim();
const dot = filename.lastIndexOf(".");
const hasExt = dot > 0 && dot < filename.length - 1;
const name = hasExt ? filename.slice(0, dot) : filename;
const ext = hasExt ? filename.slice(dot + 1) : "";
return {
...emailVars(email),
filename,
name,
ext,
};
}
// Render a template by substituting `{key}` occurrences. Each substituted
// value is sanitized to safe ASCII filename characters; the rest of the
// template (literal text) is sanitized as a whole at the end so the template
// itself can't introduce path separators or control chars.
export function renderTemplate(
template: string,
vars: Record<string, string>,
fallback: string,
): string {
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
return sanitizePart(value);
});
const cleaned = sanitizePart(rendered, 200);
return cleaned || fallback;
}
export function emailExportFilename(
email: Email,
template: string = DEFAULT_EMAIL_TEMPLATE,
): string {
const stem = renderTemplate(template, emailVars(email), "email");
return `${stem}.eml`;
}
export function attachmentDownloadFilename(
email: Email | null | undefined,
attachment: AttachmentLike,
template: string = DEFAULT_ATTACHMENT_TEMPLATE,
): string {
// No email context (rare - e.g. compose attachments) means we can only
// honour the {filename}/{name}/{ext} subset, so fall back to the raw name
// when the template needs email data and we don't have it.
if (!email) {
const filename = (attachment.name || "attachment").trim();
return sanitizePart(filename, 200) || "attachment";
}
const vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
// Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
});
// Preserve the original extension when the template doesn't reference it.
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment";
if (templateMentionsExt) return cleaned;
const ext = vars.ext;
return ext ? `${cleaned}.${ext}` : cleaned;
}
// Build a synthetic email for previewing templates in the settings UI.
export function buildSampleEmail(): Email {
// Use a fixed date so the preview doesn't churn as the user types.
const iso = "2026-05-22T14:05:33Z";
return {
id: "sample-1",
threadId: "sample-thread-1",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 12345,
receivedAt: iso,
sentAt: iso,
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
subject: "Quarterly report draft",
preview: "",
hasAttachment: true,
blobId: "sample-blob",
};
}
-43
View File
@@ -1,43 +0,0 @@
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`;
}
+14
View File
@@ -778,6 +778,7 @@
"layout": "Layout", "layout": "Layout",
"reading": "Reading", "reading": "Reading",
"composing": "Composing", "composing": "Composing",
"downloads": "Downloads",
"content_senders": "Content & Senders", "content_senders": "Content & Senders",
"about_data": "About & Data", "about_data": "About & Data",
"debug": "Debug" "debug": "Debug"
@@ -1498,6 +1499,19 @@
"categories_description": "Rename contact categories", "categories_description": "Rename contact categories",
"no_categories": "No categories found" "no_categories": "No categories found"
}, },
"downloads": {
"title": "Downloads",
"description": "Customize how downloaded emails and attachments are named.",
"reset": "Restore default",
"email_template": {
"label": "Email (.eml) filename",
"description": "Template used when you export an email or drag one out to the file system. The .eml extension is added automatically."
},
"attachment_template": {
"label": "Attachment filename",
"description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved."
}
},
"filters": { "filters": {
"title": "Email Filters", "title": "Email Filters",
"description": "Create rules to automatically sort, label, and manage incoming emails", "description": "Create rules to automatically sort, label, and manage incoming emails",
+8
View File
@@ -239,6 +239,10 @@ interface SettingsState {
tourCompleted: boolean; // Interactive tour completed tourCompleted: boolean; // Interactive tour completed
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
// Downloads
emailDownloadTemplate: string;
attachmentDownloadTemplate: string;
// Advanced // Advanced
debugMode: boolean; debugMode: boolean;
debugCategories: Record<DebugCategory, boolean>; debugCategories: Record<DebugCategory, boolean>;
@@ -424,6 +428,10 @@ const DEFAULT_SETTINGS = {
tourCompleted: false, tourCompleted: false,
showOnboardingOnNewDevices: false, showOnboardingOnNewDevices: false,
// Downloads
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
attachmentDownloadTemplate: '{filename}',
// Advanced // Advanced
debugMode: false, debugMode: false,
debugCategories: { debugCategories: {