feat: add settings template for multi-email .zip filename

This commit is contained in:
Linus Rath
2026-05-22 15:30:15 +02:00
parent 52f5a5b42c
commit e3f6ae874d
21 changed files with 152 additions and 10 deletions
@@ -8,9 +8,12 @@ import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ATTACHMENT_TEMPLATE,
DEFAULT_BUNDLE_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
BUNDLE_TOKENS,
bundleExportFilename,
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
@@ -130,6 +133,7 @@ export function DownloadsSettings() {
const {
emailDownloadTemplate,
attachmentDownloadTemplate,
bundleDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
@@ -158,6 +162,10 @@ export function DownloadsSettings() {
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
[transform, attachmentDownloadTemplate],
);
const bundleOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: bundleDownloadTemplate || DEFAULT_BUNDLE_TEMPLATE }),
[transform, bundleDownloadTemplate],
);
const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailOptions),
@@ -167,6 +175,12 @@ export function DownloadsSettings() {
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
[sampleEmail, sampleAttachment, attachmentOptions],
);
const bundlePreview = useMemo(
// Render with the email's fixed sample date so the preview is stable as the
// user types in the template field.
() => bundleExportFilename(3, bundleOptions, sampleEmail.receivedAt ?? undefined),
[bundleOptions, sampleEmail],
);
return (
<SettingsSection title={t("title")} description={t("description")}>
@@ -194,6 +208,18 @@ export function DownloadsSettings() {
previewLabel={t("preview")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
/>
<TemplateEditor
label={t("bundle_template.label")}
description={t("bundle_template.description")}
value={bundleDownloadTemplate}
defaultValue={DEFAULT_BUNDLE_TEMPLATE}
tokens={BUNDLE_TOKENS}
preview={bundlePreview}
onChange={(next) => updateSetting("bundleDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_BUNDLE_TEMPLATE}
/>
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
<Select
value={filenameSpaceReplacement}
+26 -10
View File
@@ -8,7 +8,13 @@ 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, type EmailFilenameOptions } from "@/lib/download-filename";
import {
bundleExportFilename,
DEFAULT_BUNDLE_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
emailExportFilename,
type EmailFilenameOptions,
} from "@/lib/download-filename";
import { useSettingsStore } from "@/stores/settings-store";
interface UseEmailDragOptions {
@@ -50,8 +56,8 @@ function createDragPreview(count: number): HTMLElement {
return preview;
}
function bundleFilename(count: number): string {
return `emails-${count}.zip`;
function bundleFilename(count: number, options: EmailFilenameOptions): string {
return bundleExportFilename(count, options);
}
// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in
@@ -95,7 +101,12 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], options: Emai
return URL.createObjectURL(zipBlob);
}
function prefetchEmailBundle(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): void {
function prefetchEmailBundle(
client: IJMAPClient,
emails: Email[],
emailOptions: EmailFilenameOptions,
bundleOptions: EmailFilenameOptions,
): void {
const key = selectionKey(emails.map((e) => e.id));
if (currentBundle && currentBundle.key === key) return;
if (currentBundle?.url) {
@@ -104,11 +115,11 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[], options: Emai
}
const entry: BundleEntry = {
key,
name: bundleFilename(emails.length),
name: bundleFilename(emails.length, bundleOptions),
url: null,
promise: null,
};
entry.promise = buildEmailZip(client, emails, options)
entry.promise = buildEmailZip(client, emails, emailOptions)
.then((url) => {
if (url && currentBundle === entry) entry.url = url;
return url;
@@ -131,6 +142,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const isMobile = useUIStore((state) => state.isMobile);
const client = useAuthStore((state) => state.client);
const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const bundleTemplate = useSettingsStore((s) => s.bundleDownloadTemplate) || DEFAULT_BUNDLE_TEMPLATE;
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
const lowercase = useSettingsStore((s) => s.filenameLowercase);
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
@@ -139,6 +151,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
);
const bundleOptions: EmailFilenameOptions = useMemo(
() => ({ template: bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
[bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
);
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
const singleBlobUrlRef = useRef<string | null>(null);
@@ -181,12 +197,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, filenameOptions);
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
}
} else {
prefetchSingle();
}
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions]);
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
@@ -237,7 +253,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
);
} else {
// Kick off the bundle build for the next attempt.
prefetchEmailBundle(client, emailsToDrag, filenameOptions);
prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions);
}
}
}
@@ -252,7 +268,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions]);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragEnd = useCallback(() => {
endDrag();
+30
View File
@@ -29,6 +29,7 @@ export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
export const DEFAULT_BUNDLE_TEMPLATE = "emails-{count}";
export const EMAIL_TOKENS: { token: string; description: string }[] = [
{ token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" },
@@ -53,6 +54,16 @@ export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
{ token: "ext", description: "Attachment file extension without leading dot" },
];
export const BUNDLE_TOKENS: { token: string; description: string }[] = [
{ token: "count", description: "Number of emails in the bundle" },
{ token: "date", description: "Current date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Current date, e.g. 2026-05-22" },
{ token: "time", description: "Current time, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
];
function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input
.replace(SAFE_CHARS, "_")
@@ -211,6 +222,25 @@ export function attachmentDownloadFilename(
return `${transformedStem}.${transformedExt}`;
}
export function bundleVars(count: number, iso?: string): Record<string, string> {
const dp = dateParts(iso ?? new Date().toISOString());
return { ...dp, count: String(count) };
}
export function bundleExportFilename(
count: number,
options: EmailFilenameOptions | string = {},
iso?: string,
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
const rendered = renderRaw(template, bundleVars(count, iso));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "emails";
return `${stem}.zip`;
}
// 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.
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Název souboru přílohy",
"description": "Šablona použitá při stahování nebo přetahování přílohy. Pokud vynecháte {filename} a {ext}, původní přípona se zachová."
},
"bundle_template": {
"label": "Název .zip souboru s více e-maily",
"description": "Šablona použitá při přetažení nebo stažení několika vybraných e-mailů jako jediného .zip archivu. Přípona .zip se přidává automaticky."
},
"spaces": {
"label": "Mezery",
"description": "Nahradit mezery ve výsledném názvu souboru jiným znakem.",
+4
View File
@@ -1512,6 +1512,10 @@
"label": "Filnavn på vedhæftet fil",
"description": "Skabelon som bruges når du downloader eller trækker en vedhæftet fil ud. Hvis du udelader {filename} og {ext}, bevares den oprindelige endelse."
},
"bundle_template": {
"label": "Filnavn for .zip med flere e-mails",
"description": "Skabelon som bruges når du trækker ud eller downloader flere valgte e-mails som ét .zip-arkiv. Endelsen .zip tilføjes automatisk."
},
"spaces": {
"label": "Mellemrum",
"description": "Erstat mellemrum i det endelige filnavn med et andet tegn.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Anhang-Dateiname",
"description": "Vorlage zum Herunterladen oder Herausziehen eines Anhangs. Wenn {filename} und {ext} fehlen, bleibt die ursprüngliche Endung erhalten."
},
"bundle_template": {
"label": "Mehrere E-Mails als .zip",
"description": "Vorlage, die beim Herausziehen oder Herunterladen mehrerer ausgewählter E-Mails als einzelnes .zip-Archiv verwendet wird. Die Endung .zip wird automatisch ergänzt."
},
"spaces": {
"label": "Leerzeichen",
"description": "Ersetze Leerzeichen im Dateinamen durch ein anderes Zeichen.",
+4
View File
@@ -1512,6 +1512,10 @@
"label": "Attachment filename",
"description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved."
},
"bundle_template": {
"label": "Multi-email .zip filename",
"description": "Template used when you drag out or download several selected emails as a single .zip archive. The .zip extension is added automatically."
},
"spaces": {
"label": "Spaces",
"description": "Replace spaces in the resulting filename with another character.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Nombre del archivo adjunto",
"description": "Plantilla usada al descargar o arrastrar un adjunto. Si omites {filename} y {ext}, se conserva la extensión original."
},
"bundle_template": {
"label": "Nombre de archivo .zip multi-correo",
"description": "Plantilla utilizada al arrastrar o descargar varios correos seleccionados como un único archivo .zip. La extensión .zip se añade automáticamente."
},
"spaces": {
"label": "Espacios",
"description": "Reemplaza los espacios del nombre de archivo resultante por otro carácter.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Nom du fichier de pièce jointe",
"description": "Modèle utilisé lors du téléchargement ou du glisser-déposer d'une pièce jointe. Si vous omettez {filename} et {ext}, l'extension d'origine est conservée."
},
"bundle_template": {
"label": "Nom du fichier .zip multi-e-mails",
"description": "Modèle utilisé lorsque vous faites glisser ou téléchargez plusieurs e-mails sélectionnés sous forme d'archive .zip unique. L'extension .zip est ajoutée automatiquement."
},
"spaces": {
"label": "Espaces",
"description": "Remplacer les espaces dans le nom de fichier final par un autre caractère.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Nome file dellallegato",
"description": "Modello usato quando scarichi o trascini un allegato. Se ometti {filename} e {ext}, l'estensione originale viene mantenuta."
},
"bundle_template": {
"label": "Nome del file .zip multi-email",
"description": "Modello usato quando trascini o scarichi più email selezionate come un singolo archivio .zip. L'estensione .zip viene aggiunta automaticamente."
},
"spaces": {
"label": "Spazi",
"description": "Sostituisci gli spazi nel nome file risultante con un altro carattere.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "添付ファイルのファイル名",
"description": "添付ファイルをダウンロードまたはドラッグするときに使用するテンプレートです。{filename} と {ext} を省略すると、元の拡張子が保持されます。"
},
"bundle_template": {
"label": "複数メールの .zip ファイル名",
"description": "複数の選択したメールを 1 つの .zip としてドラッグまたはダウンロードするときに使用するテンプレートです。拡張子 .zip は自動的に付加されます。"
},
"spaces": {
"label": "スペース",
"description": "最終的なファイル名のスペースを別の文字に置き換えます。",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "첨부 파일 이름",
"description": "첨부 파일을 다운로드하거나 드래그할 때 사용하는 템플릿입니다. {filename}과 {ext}를 생략하면 원래 확장자가 유지됩니다."
},
"bundle_template": {
"label": "다중 이메일 .zip 파일 이름",
"description": "여러 선택한 이메일을 하나의 .zip 아카이브로 드래그하거나 다운로드할 때 사용하는 템플릿입니다. .zip 확장자는 자동으로 추가됩니다."
},
"spaces": {
"label": "공백",
"description": "결과 파일 이름의 공백을 다른 문자로 바꿉니다.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Pielikuma faila nosaukums",
"description": "Veidne, ko izmanto, lejupielādējot vai velkot pielikumu. Ja izlaižat {filename} un {ext}, sākotnējais paplašinājums tiek saglabāts."
},
"bundle_template": {
"label": "Vairāku e-pastu .zip faila nosaukums",
"description": "Veidne, ko izmanto, kad velkat vai lejupielādējat vairākus atlasītos e-pastus kā vienu .zip arhīvu. Paplašinājums .zip tiek pievienots automātiski."
},
"spaces": {
"label": "Atstarpes",
"description": "Aizstāt atstarpes izvades faila nosaukumā ar citu rakstzīmi.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Bestandsnaam van bijlage",
"description": "Sjabloon bij het downloaden of slepen van een bijlage. Als je {filename} en {ext} weglaat, blijft de oorspronkelijke extensie behouden."
},
"bundle_template": {
"label": "Bestandsnaam .zip met meerdere e-mails",
"description": "Sjabloon dat wordt gebruikt wanneer je meerdere geselecteerde e-mails als één .zip-archief sleept of downloadt. De extensie .zip wordt automatisch toegevoegd."
},
"spaces": {
"label": "Spaties",
"description": "Vervang spaties in de uiteindelijke bestandsnaam door een ander teken.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Nazwa pliku załącznika",
"description": "Szablon używany przy pobieraniu lub przeciąganiu załącznika. Jeśli pominiesz {filename} i {ext}, oryginalne rozszerzenie zostanie zachowane."
},
"bundle_template": {
"label": "Nazwa pliku .zip wielu wiadomości",
"description": "Szablon używany przy przeciąganiu lub pobieraniu kilku wybranych wiadomości jako jednego archiwum .zip. Rozszerzenie .zip jest dodawane automatycznie."
},
"spaces": {
"label": "Spacje",
"description": "Zastąp spacje w nazwie pliku innym znakiem.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Nome do arquivo do anexo",
"description": "Modelo usado ao baixar ou arrastar um anexo. Se você omitir {filename} e {ext}, a extensão original é preservada."
},
"bundle_template": {
"label": "Nome do arquivo .zip multi-e-mails",
"description": "Modelo usado ao arrastar ou baixar vários e-mails selecionados como um único arquivo .zip. A extensão .zip é adicionada automaticamente."
},
"spaces": {
"label": "Espaços",
"description": "Substitui espaços no nome de arquivo resultante por outro caractere.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Имя файла вложения",
"description": "Шаблон, используемый при загрузке или перетаскивании вложения. Если опустить {filename} и {ext}, исходное расширение сохраняется."
},
"bundle_template": {
"label": "Имя файла .zip с несколькими письмами",
"description": "Шаблон, используемый при перетаскивании или загрузке нескольких выбранных писем одним .zip-архивом. Расширение .zip добавляется автоматически."
},
"spaces": {
"label": "Пробелы",
"description": "Заменять пробелы в итоговом имени файла другим символом.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Ek dosyası adı",
"description": "Bir eki indirirken veya sürüklerken kullanılan şablon. {filename} ve {ext} yazılmazsa orijinal uzantı korunur."
},
"bundle_template": {
"label": "Çoklu e-posta .zip dosya adı",
"description": "Birden çok seçili e-postayı tek bir .zip arşivi olarak sürüklediğinizde veya indirdiğinizde kullanılan şablon. .zip uzantısı otomatik olarak eklenir."
},
"spaces": {
"label": "Boşluklar",
"description": "Sonuçtaki dosya adındaki boşlukları başka bir karakterle değiştirin.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "Ім'я файлу вкладення",
"description": "Шаблон, що використовується при завантаженні або перетягуванні вкладення. Якщо пропустити {filename} та {ext}, оригінальне розширення зберігається."
},
"bundle_template": {
"label": "Ім'я файлу .zip з кількома листами",
"description": "Шаблон, що використовується при перетягуванні або завантаженні кількох вибраних листів одним .zip-архівом. Розширення .zip додається автоматично."
},
"spaces": {
"label": "Пробіли",
"description": "Замінити пробіли в підсумковому імені файлу іншим символом.",
+4
View File
@@ -1509,6 +1509,10 @@
"label": "附件文件名",
"description": "下载或拖出附件时使用的模板。如果省略 {filename} 和 {ext},将保留原始扩展名。"
},
"bundle_template": {
"label": "多邮件 .zip 文件名",
"description": "将多个选中的邮件作为单个 .zip 拖出或下载时使用的模板。扩展名 .zip 会自动添加。"
},
"spaces": {
"label": "空格",
"description": "用其他字符替换最终文件名中的空格。",
+2
View File
@@ -242,6 +242,7 @@ interface SettingsState {
// Downloads
emailDownloadTemplate: string;
attachmentDownloadTemplate: string;
bundleDownloadTemplate: string;
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
filenameLowercase: boolean;
filenameStripDiacritics: boolean;
@@ -435,6 +436,7 @@ const DEFAULT_SETTINGS = {
// Downloads
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
attachmentDownloadTemplate: '{filename}',
bundleDownloadTemplate: 'emails-{count}',
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
filenameLowercase: false,
filenameStripDiacritics: false,