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 dragOutActive = useMemo(() => isDragOutSupported(), []);
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_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 timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
@@ -2569,9 +2587,9 @@ export function EmailViewer({
(attachment: EffectiveAttachment) => { (attachment: EffectiveAttachment) => {
const fallback = attachment.name || 'download'; const fallback = attachment.name || 'download';
if (!email) return fallback; 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) => { const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
@@ -3072,7 +3090,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, emailDownloadTemplate), 'message/rfc822'); await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
} catch { } catch {
toast.error(tNotifications('export_email_error')); toast.error(tNotifications('export_email_error'));
} }
+68 -12
View File
@@ -3,7 +3,7 @@
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; 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 { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -14,6 +14,7 @@ import {
emailExportFilename, emailExportFilename,
attachmentDownloadFilename, attachmentDownloadFilename,
buildSampleEmail, buildSampleEmail,
type EmailFilenameOptions,
} from "@/lib/download-filename"; } from "@/lib/download-filename";
function insertTokenAtCursor( function insertTokenAtCursor(
@@ -46,6 +47,7 @@ interface TemplateEditorProps {
preview: string; preview: string;
onChange: (next: string) => void; onChange: (next: string) => void;
resetLabel: string; resetLabel: string;
previewLabel: string;
placeholder?: string; placeholder?: string;
} }
@@ -58,6 +60,7 @@ function TemplateEditor({
preview, preview,
onChange, onChange,
resetLabel, resetLabel,
previewLabel,
placeholder, placeholder,
}: TemplateEditorProps) { }: TemplateEditorProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -114,7 +117,7 @@ function TemplateEditor({
))} ))}
</div> </div>
<div className="text-xs text-muted-foreground"> <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> <span className="font-mono text-foreground/90 break-all">{preview}</span>
</div> </div>
</div> </div>
@@ -124,23 +127,45 @@ function TemplateEditor({
export function DownloadsSettings() { export function DownloadsSettings() {
const t = useTranslations("settings.downloads"); const t = useTranslations("settings.downloads");
const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore(); const {
emailDownloadTemplate,
attachmentDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
updateSetting,
} = useSettingsStore();
const sampleEmail = useMemo(() => buildSampleEmail(), []); const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []); 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( const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE), () => emailExportFilename(sampleEmail, emailOptions),
[sampleEmail, emailDownloadTemplate], [sampleEmail, emailOptions],
); );
const attachmentPreview = useMemo( const attachmentPreview = useMemo(
() => () => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
attachmentDownloadFilename( [sampleEmail, sampleAttachment, attachmentOptions],
sampleEmail,
sampleAttachment,
attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE,
),
[sampleEmail, sampleAttachment, attachmentDownloadTemplate],
); );
return ( return (
@@ -154,6 +179,7 @@ export function DownloadsSettings() {
preview={emlPreview} preview={emlPreview}
onChange={(next) => updateSetting("emailDownloadTemplate", next)} onChange={(next) => updateSetting("emailDownloadTemplate", next)}
resetLabel={t("reset")} resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_EMAIL_TEMPLATE} placeholder={DEFAULT_EMAIL_TEMPLATE}
/> />
<TemplateEditor <TemplateEditor
@@ -165,8 +191,38 @@ export function DownloadsSettings() {
preview={attachmentPreview} preview={attachmentPreview}
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)} onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
resetLabel={t("reset")} resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE} 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> </SettingsSection>
); );
} }
+22 -14
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, DragEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { IJMAPClient } from "@/lib/jmap/client-interface"; import { IJMAPClient } from "@/lib/jmap/client-interface";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -8,7 +8,7 @@ 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, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-filename"; import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE, type EmailFilenameOptions } from "@/lib/download-filename";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
interface UseEmailDragOptions { interface UseEmailDragOptions {
@@ -71,7 +71,7 @@ function selectionKey(ids: string[]): string {
return [...ids].sort().join(","); 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); 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");
@@ -79,7 +79,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
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, template).replace(/\.eml$/, ""); const base = emailExportFilename(em, options).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);
@@ -95,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
return URL.createObjectURL(zipBlob); 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)); 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) {
@@ -108,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: str
url: null, url: null,
promise: null, promise: null,
}; };
entry.promise = buildEmailZip(client, emails, template) entry.promise = buildEmailZip(client, emails, options)
.then((url) => { .then((url) => {
if (url && currentBundle === entry) entry.url = url; if (url && currentBundle === entry) entry.url = url;
return url; return url;
@@ -130,7 +130,15 @@ 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 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 dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
const singleBlobUrlRef = useRef<string | null>(null); const singleBlobUrlRef = useRef<string | null>(null);
@@ -152,7 +160,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, emailTemplate); const name = emailExportFilename(email, filenameOptions);
inFlightRef.current = client inFlightRef.current = client
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822") .fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
.then((url) => { .then((url) => {
@@ -163,7 +171,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
.finally(() => { .finally(() => {
inFlightRef.current = null; inFlightRef.current = null;
}); });
}, [dragOutEnabled, client, email, emailTemplate]); }, [dragOutEnabled, client, email, filenameOptions]);
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return; if (!dragOutEnabled || !client) return;
@@ -173,12 +181,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, emailTemplate); prefetchEmailBundle(client, selected, filenameOptions);
} }
} else { } else {
prefetchSingle(); prefetchSingle();
} }
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]); }, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => { const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag: // Determine which emails to drag:
@@ -205,7 +213,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], emailTemplate); const name = emailExportFilename(emailsToDrag[0], filenameOptions);
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects // `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk // the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed // (e.g. `%20` instead of a space). The sanitiser already removed
@@ -229,7 +237,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, emailTemplate); prefetchEmailBundle(client, emailsToDrag, filenameOptions);
} }
} }
} }
@@ -244,7 +252,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
}); });
startDrag(emailsToDrag, sourceMailboxId); 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(() => { const handleDragEnd = useCallback(() => {
endDrag(); endDrag();
+60 -23
View File
@@ -7,6 +7,26 @@ import type { Email } from "@/lib/jmap/types";
// non-ASCII scripts. // non-ASCII scripts.
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu; 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_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}"; export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
@@ -43,6 +63,24 @@ function sanitizePart(input: string, maxLen = 80): string {
return cleaned.slice(0, maxLen); 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 { function pad2(n: number): string {
return String(n).padStart(2, "0"); 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 function renderRaw(template: string, vars: Record<string, string>): string {
// value is sanitized to safe ASCII filename characters; the rest of the return template.replace(/\{(\w+)\}/g, (_, key: string) => {
// 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]; const value = vars[key];
if (value === undefined) return ""; if (value === undefined) return "";
return sanitizePart(value); return sanitizePart(value);
}); });
const cleaned = sanitizePart(rendered, 200);
return cleaned || fallback;
} }
export function emailExportFilename( export function emailExportFilename(
email: Email, email: Email,
template: string = DEFAULT_EMAIL_TEMPLATE, options: EmailFilenameOptions | string = {},
): 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`; return `${stem}.eml`;
} }
export function attachmentDownloadFilename( export function attachmentDownloadFilename(
email: Email | null | undefined, email: Email | null | undefined,
attachment: AttachmentLike, attachment: AttachmentLike,
template: string = DEFAULT_ATTACHMENT_TEMPLATE, options: EmailFilenameOptions | string = {},
): string { ): string {
// No email context (rare - e.g. compose attachments) means we can only const opts = typeof options === "string" ? { template: options } : options;
// honour the {filename}/{name}/{ext} subset, so fall back to the raw name const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
// when the template needs email data and we don't have it.
if (!email) { if (!email) {
const filename = (attachment.name || "attachment").trim(); 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 vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => { const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
@@ -166,12 +199,16 @@ export function attachmentDownloadFilename(
// sanitiser (it strips trailing dots otherwise). // sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value); 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 templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment"; 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; 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. // 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" }], from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }], to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [], cc: [],
subject: "Quarterly report draft", subject: "Benachrichtigung von Ihrem Gerät",
preview: "", preview: "",
hasAttachment: true, hasAttachment: true,
blobId: "sample-blob", blobId: "sample-blob",
+20
View File
@@ -1503,6 +1503,7 @@
"title": "Downloads", "title": "Downloads",
"description": "Customize how downloaded emails and attachments are named.", "description": "Customize how downloaded emails and attachments are named.",
"reset": "Restore default", "reset": "Restore default",
"preview": "Preview:",
"email_template": { "email_template": {
"label": "Email (.eml) filename", "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." "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": { "attachment_template": {
"label": "Attachment filename", "label": "Attachment filename",
"description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved." "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": { "filters": {
+8
View File
@@ -242,6 +242,10 @@ interface SettingsState {
// Downloads // Downloads
emailDownloadTemplate: string; emailDownloadTemplate: string;
attachmentDownloadTemplate: string; attachmentDownloadTemplate: string;
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
filenameLowercase: boolean;
filenameStripDiacritics: boolean;
filenameCollapseSeparators: boolean;
// Advanced // Advanced
debugMode: boolean; debugMode: boolean;
@@ -431,6 +435,10 @@ const DEFAULT_SETTINGS = {
// Downloads // Downloads
emailDownloadTemplate: '{date} ({from}-{to}) {subject}', emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
attachmentDownloadTemplate: '{filename}', attachmentDownloadTemplate: '{filename}',
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
filenameLowercase: false,
filenameStripDiacritics: false,
filenameCollapseSeparators: true,
// Advanced // Advanced
debugMode: false, debugMode: false,