feat: add filename transform settings

This commit is contained in:
Linus Rath
2026-05-22 14:57:25 +02:00
parent 0dca019fe5
commit 3ac14ecf38
6 changed files with 199 additions and 52 deletions
+21 -3
View File
@@ -905,6 +905,24 @@ export function EmailViewer({
const dragOutActive = useMemo(() => isDragOutSupported(), []);
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
const emailFilenameOptions = useMemo(() => ({
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
const attachmentFilenameOptions = useMemo(() => ({
template: attachmentDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -2569,9 +2587,9 @@ export function EmailViewer({
(attachment: EffectiveAttachment) => {
const fallback = attachment.name || 'download';
if (!email) return fallback;
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentDownloadTemplate) || fallback;
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
},
[email, attachmentDownloadTemplate],
[email, attachmentFilenameOptions],
);
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
@@ -3072,7 +3090,7 @@ export function EmailViewer({
const handleExportEmail = async () => {
if (!email?.blobId || !client) return;
try {
await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822');
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
} catch {
toast.error(tNotifications('export_email_error'));
}
+68 -12
View File
@@ -3,7 +3,7 @@
import { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { SettingsSection } from "./settings-section";
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -14,6 +14,7 @@ import {
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
type EmailFilenameOptions,
} from "@/lib/download-filename";
function insertTokenAtCursor(
@@ -46,6 +47,7 @@ interface TemplateEditorProps {
preview: string;
onChange: (next: string) => void;
resetLabel: string;
previewLabel: string;
placeholder?: string;
}
@@ -58,6 +60,7 @@ function TemplateEditor({
preview,
onChange,
resetLabel,
previewLabel,
placeholder,
}: TemplateEditorProps) {
const inputRef = useRef<HTMLInputElement>(null);
@@ -114,7 +117,7 @@ function TemplateEditor({
))}
</div>
<div className="text-xs text-muted-foreground">
<span className="opacity-70">Preview: </span>
<span className="opacity-70">{previewLabel} </span>
<span className="font-mono text-foreground/90 break-all">{preview}</span>
</div>
</div>
@@ -124,23 +127,45 @@ function TemplateEditor({
export function DownloadsSettings() {
const t = useTranslations("settings.downloads");
const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore();
const {
emailDownloadTemplate,
attachmentDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
updateSetting,
} = useSettingsStore();
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
const transform = useMemo(
() => ({
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}),
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
);
const emailOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
[transform, emailDownloadTemplate],
);
const attachmentOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
[transform, attachmentDownloadTemplate],
);
const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE),
[sampleEmail, emailDownloadTemplate],
() => emailExportFilename(sampleEmail, emailOptions),
[sampleEmail, emailOptions],
);
const attachmentPreview = useMemo(
() =>
attachmentDownloadFilename(
sampleEmail,
sampleAttachment,
attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE,
),
[sampleEmail, sampleAttachment, attachmentDownloadTemplate],
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
[sampleEmail, sampleAttachment, attachmentOptions],
);
return (
@@ -154,6 +179,7 @@ export function DownloadsSettings() {
preview={emlPreview}
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_EMAIL_TEMPLATE}
/>
<TemplateEditor
@@ -165,8 +191,38 @@ export function DownloadsSettings() {
preview={attachmentPreview}
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
/>
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
<Select
value={filenameSpaceReplacement}
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
options={[
{ value: "keep", label: t("spaces.keep") },
{ value: "underscore", label: t("spaces.underscore") },
{ value: "dash", label: t("spaces.dash") },
]}
/>
</SettingItem>
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
<ToggleSwitch
checked={filenameLowercase}
onChange={(checked) => updateSetting("filenameLowercase", checked)}
/>
</SettingItem>
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
<ToggleSwitch
checked={filenameStripDiacritics}
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
/>
</SettingItem>
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
<ToggleSwitch
checked={filenameCollapseSeparators}
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+22 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, DragEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { IJMAPClient } from "@/lib/jmap/client-interface";
import { useEmailStore } from "@/stores/email-store";
@@ -8,7 +8,7 @@ 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, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-filename";
import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE, type EmailFilenameOptions } from "@/lib/download-filename";
import { useSettingsStore } from "@/stores/settings-store";
interface UseEmailDragOptions {
@@ -71,7 +71,7 @@ function selectionKey(ids: string[]): string {
return [...ids].sort().join(",");
}
async function buildEmailZip(client: IJMAPClient, emails: Email[], template: string): Promise<string | null> {
async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise<string | null> {
const eligible = emails.filter((em) => !!em.blobId);
if (eligible.length === 0) return null;
const { default: JSZip } = await import("jszip");
@@ -79,7 +79,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
const used = new Set<string>();
await Promise.all(
eligible.map(async (em) => {
const base = emailExportFilename(em, template).replace(/\.eml$/, "");
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
let name = `${base}.eml`;
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
used.add(name);
@@ -95,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
return URL.createObjectURL(zipBlob);
}
function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: string): void {
function prefetchEmailBundle(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): void {
const key = selectionKey(emails.map((e) => e.id));
if (currentBundle && currentBundle.key === key) return;
if (currentBundle?.url) {
@@ -108,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: str
url: null,
promise: null,
};
entry.promise = buildEmailZip(client, emails, template)
entry.promise = buildEmailZip(client, emails, options)
.then((url) => {
if (url && currentBundle === entry) entry.url = url;
return url;
@@ -130,7 +130,15 @@ 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 template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
const lowercase = useSettingsStore((s) => s.filenameLowercase);
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
const collapseSeparators = useSettingsStore((s) => s.filenameCollapseSeparators);
const filenameOptions: EmailFilenameOptions = useMemo(
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
);
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
const singleBlobUrlRef = useRef<string | null>(null);
@@ -152,7 +160,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, emailTemplate);
const name = emailExportFilename(email, filenameOptions);
inFlightRef.current = client
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
.then((url) => {
@@ -163,7 +171,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
.finally(() => {
inFlightRef.current = null;
});
}, [dragOutEnabled, client, email, emailTemplate]);
}, [dragOutEnabled, client, email, filenameOptions]);
const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return;
@@ -173,12 +181,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, emailTemplate);
prefetchEmailBundle(client, selected, filenameOptions);
}
} else {
prefetchSingle();
}
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]);
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
@@ -205,7 +213,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], emailTemplate);
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed
@@ -229,7 +237,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
);
} else {
// Kick off the bundle build for the next attempt.
prefetchEmailBundle(client, emailsToDrag, emailTemplate);
prefetchEmailBundle(client, emailsToDrag, filenameOptions);
}
}
}
@@ -244,7 +252,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, emailTemplate]);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions]);
const handleDragEnd = useCallback(() => {
endDrag();
+60 -23
View File
@@ -7,6 +7,26 @@ import type { Email } from "@/lib/jmap/types";
// non-ASCII scripts.
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
export type SpaceReplacement = "keep" | "underscore" | "dash";
export interface FilenameTransformOptions {
spaceReplacement?: SpaceReplacement;
lowercase?: boolean;
stripDiacritics?: boolean;
collapseSeparators?: boolean;
}
export interface EmailFilenameOptions extends FilenameTransformOptions {
template?: string;
}
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
spaceReplacement: "keep",
lowercase: false,
stripDiacritics: false,
collapseSeparators: true,
};
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
@@ -43,6 +63,24 @@ function sanitizePart(input: string, maxLen = 80): string {
return cleaned.slice(0, maxLen);
}
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
let s = input;
if (opts.stripDiacritics) {
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
// combining marks then leaves plain ASCII letters. `ß` has no
// decomposition so it survives as-is.
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
}
const repl = opts.spaceReplacement ?? "keep";
if (repl === "underscore") s = s.replace(/ +/g, "_");
else if (repl === "dash") s = s.replace(/ +/g, "-");
if (opts.collapseSeparators ?? true) {
s = s.replace(/_+/g, "_").replace(/-+/g, "-").replace(/ +/g, " ");
}
if (opts.lowercase) s = s.toLocaleLowerCase();
return s.replace(/^[._\- ]+|[._\- ]+$/g, "");
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
@@ -120,43 +158,38 @@ export function attachmentVars(email: Email, attachment: AttachmentLike): Record
};
}
// 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) => {
function renderRaw(template: string, vars: Record<string, string>): string {
return 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,
options: EmailFilenameOptions | string = {},
): string {
const stem = renderTemplate(template, emailVars(email), "email");
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "email";
return `${stem}.eml`;
}
export function attachmentDownloadFilename(
email: Email | null | undefined,
attachment: AttachmentLike,
template: string = DEFAULT_ATTACHMENT_TEMPLATE,
options: EmailFilenameOptions | string = {},
): 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.
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) {
const filename = (attachment.name || "attachment").trim();
return sanitizePart(filename, 200) || "attachment";
const cleaned = sanitizePart(filename, 200) || "attachment";
return applyTransforms(cleaned, opts) || cleaned;
}
const vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
@@ -166,12 +199,16 @@ export function attachmentDownloadFilename(
// 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;
if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned;
}
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
const ext = vars.ext;
return ext ? `${cleaned}.${ext}` : cleaned;
if (!ext) return transformedStem;
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
return `${transformedStem}.${transformedExt}`;
}
// Build a synthetic email for previewing templates in the settings UI.
@@ -189,7 +226,7 @@ export function buildSampleEmail(): Email {
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
subject: "Quarterly report draft",
subject: "Benachrichtigung von Ihrem Gerät",
preview: "",
hasAttachment: true,
blobId: "sample-blob",
+20
View File
@@ -1503,6 +1503,7 @@
"title": "Downloads",
"description": "Customize how downloaded emails and attachments are named.",
"reset": "Restore default",
"preview": "Preview:",
"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."
@@ -1510,6 +1511,25 @@
"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."
},
"spaces": {
"label": "Spaces",
"description": "Replace spaces in the resulting filename with another character.",
"keep": "Keep spaces",
"underscore": "Replace with _",
"dash": "Replace with -"
},
"lowercase": {
"label": "Lowercase",
"description": "Force the entire filename to lowercase."
},
"strip_diacritics": {
"label": "Strip diacritics",
"description": "Convert accented letters to their ASCII equivalents (ä → a, é → e). Useful for tools that mangle Unicode filenames."
},
"collapse_separators": {
"label": "Collapse repeated separators",
"description": "Collapse runs of spaces, underscores, or dashes to a single character."
}
},
"filters": {
+8
View File
@@ -242,6 +242,10 @@ interface SettingsState {
// Downloads
emailDownloadTemplate: string;
attachmentDownloadTemplate: string;
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
filenameLowercase: boolean;
filenameStripDiacritics: boolean;
filenameCollapseSeparators: boolean;
// Advanced
debugMode: boolean;
@@ -431,6 +435,10 @@ const DEFAULT_SETTINGS = {
// Downloads
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
attachmentDownloadTemplate: '{filename}',
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
filenameLowercase: false,
filenameStripDiacritics: false,
filenameCollapseSeparators: true,
// Advanced
debugMode: false,