import type { Email } from "@/lib/jmap/types"; // Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a // small set of safe punctuation. Everything else - emojis, RTL/zero-width // marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses // to `_`. Keeps filenames usable across Windows/macOS/Linux without flattening // non-ASCII scripts. const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu; 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 { 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 { 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, 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", }; }