feat: add Downloads settings tab with template editor for .eml and attachment filenames
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
Download,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
@@ -90,6 +92,7 @@ type Tab =
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
@@ -580,6 +586,7 @@ export default function SettingsPage() {
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, 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' },
|
||||
...(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 }] : []),
|
||||
@@ -666,6 +673,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -778,6 +778,7 @@
|
||||
"layout": "Layout",
|
||||
"reading": "Reading",
|
||||
"composing": "Composing",
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"debug": "Debug"
|
||||
@@ -1498,6 +1499,19 @@
|
||||
"categories_description": "Rename contact categories",
|
||||
"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": {
|
||||
"title": "Email Filters",
|
||||
"description": "Create rules to automatically sort, label, and manage incoming emails",
|
||||
|
||||
@@ -239,6 +239,10 @@ interface SettingsState {
|
||||
tourCompleted: boolean; // Interactive tour completed
|
||||
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: string;
|
||||
attachmentDownloadTemplate: string;
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
debugCategories: Record<DebugCategory, boolean>;
|
||||
@@ -424,6 +428,10 @@ const DEFAULT_SETTINGS = {
|
||||
tourCompleted: false,
|
||||
showOnboardingOnNewDevices: false,
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
||||
attachmentDownloadTemplate: '{filename}',
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
debugCategories: {
|
||||
|
||||
Reference in New Issue
Block a user