feat: add Downloads settings tab with template editor for .eml and attachment filenames
This commit is contained in:
@@ -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 />}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user