import DOMPurify from 'dompurify'; import { sanitizeEmailHtml } from './email-sanitization'; import type { EmailTemplate } from './template-types'; import { BUILT_IN_PLACEHOLDERS } from './template-types'; import { generateUUID } from './utils'; const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g; const MAX_TEMPLATE_NAME_LENGTH = 200; const STRIP_HTML_CONFIG = { ALLOWED_TAGS: [] as string[], ALLOWED_ATTR: [] as string[] }; export function extractPlaceholders(text: string): string[] { const matches = new Set(); let match: RegExpExecArray | null; const regex = new RegExp(PLACEHOLDER_REGEX.source, 'g'); while ((match = regex.exec(text)) !== null) { matches.add(match[1]); } return Array.from(matches); } export function substitutePlaceholders( text: string, values: Record ): string { return text.replace(PLACEHOLDER_REGEX, (full, name) => { if (values[name] === undefined) return full; return DOMPurify.sanitize(values[name], STRIP_HTML_CONFIG); }); } export function hasUnresolvedPlaceholders(text: string): boolean { return new RegExp(PLACEHOLDER_REGEX.source).test(text); } export function validateTemplateName(name: string): string | null { const trimmed = name.trim(); if (!trimmed) return 'empty'; if (trimmed.length > MAX_TEMPLATE_NAME_LENGTH) return 'too_long'; return null; } export interface AutoFillContext { senderName?: string; locale?: string; } export function getAutoFilledPlaceholders( context: AutoFillContext ): Record { const now = new Date(); const locale = context.locale || 'en'; const values: Record = { date: now.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }), // The locale's all-numeric form (19.10.2026, 10/19/2026), and the parts // for any other order or separator (#1284). short_date: now.toLocaleDateString(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }), day: String(now.getDate()).padStart(2, '0'), month: String(now.getMonth() + 1).padStart(2, '0'), year: String(now.getFullYear()), day_of_week: now.toLocaleDateString(locale, { weekday: 'long' }), }; if (context.senderName) { values.sender_name = context.senderName; } return values; } export function getPlaceholdersFromTemplate(template: EmailTemplate): string[] { const combined = `${template.subject} ${template.body}`; return extractPlaceholders(combined); } export function isBuiltInPlaceholder(name: string): boolean { return (BUILT_IN_PLACEHOLDERS as readonly string[]).includes(name); } export function filterTemplates(templates: EmailTemplate[], query: string): EmailTemplate[] { const lower = query.toLowerCase(); return templates.filter( (t) => t.name.toLowerCase().includes(lower) || t.subject.toLowerCase().includes(lower) || t.category.toLowerCase().includes(lower) ); } const SIGNATURE_START_SELECTOR = '[data-signature-block="separator"], [data-signature-block="start"]'; const SIGNATURE_END_SELECTOR = '[data-signature-block="end"]'; // Compose bodies carry the embedded signature bracketed by // data-signature-block markers (see email-composer's // buildEmbeddedSignatureHtml). Applying a template must replace only the // message content, so splice the template above the signature range instead // of overwriting the whole body. export function spliceTemplateAboveSignature(prevHtml: string, templateHtml: string): string { const doc = new DOMParser().parseFromString(prevHtml, 'text/html'); const startEl = doc.querySelector(SIGNATURE_START_SELECTOR); if (!startEl) return templateHtml; const endEl = doc.querySelector(SIGNATURE_END_SELECTOR); const host = doc.createElement('div'); let cursor: Node | null = startEl; while (cursor) { host.appendChild(cursor.cloneNode(true)); if (cursor === endEl) break; cursor = cursor.nextSibling; } return templateHtml + host.innerHTML; } /** * Whether a compose body holds anything the user wrote above the embedded * signature. A fresh compose body is just an empty paragraph plus the * signature range, so this stays false until the user types something - * the composer then inserts the template at the caret rather than splicing * it over the draft (#540). */ export function composeBodyHasUserContent(prevHtml: string): boolean { if (!prevHtml.trim()) return false; const doc = new DOMParser().parseFromString(prevHtml, 'text/html'); const startEl = doc.querySelector(SIGNATURE_START_SELECTOR); if (startEl) { // Drop the signature range [start … end] inclusive, mirroring the // traversal spliceTemplateAboveSignature uses to keep it. const endEl = doc.querySelector(SIGNATURE_END_SELECTOR); let cursor: Node | null = startEl; while (cursor) { const next: Node | null = cursor.nextSibling; const isEnd = cursor === endEl; cursor.parentNode?.removeChild(cursor); if (isEnd) break; cursor = next; } } if (doc.body.textContent?.trim()) return true; // Text-free content still counts as a draft worth keeping. return doc.body.querySelector('img, table, hr, [data-quoted-html]') !== null; } function sanitizeText(value: unknown): string { return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG); } /** * An HTML template body from outside the app (a templates file, a synced * settings blob written by another client) as the store may hold it. Bodies * the plugin API creates are sanitised at creation; these were kept raw and * reached the composer's placeholder preview as markup in the app document. */ function sanitizeTemplateHtml(value: unknown): string { return sanitizeEmailHtml(String(value || '')); } interface ExportData { version: 1; type: 'webmail-templates'; exportedAt: string; templates: EmailTemplate[]; } export function exportTemplates(templates: EmailTemplate[]): string { const data: ExportData = { version: 1, type: 'webmail-templates', exportedAt: new Date().toISOString(), templates, }; return JSON.stringify(data, null, 2); } export interface ImportResult { templates: EmailTemplate[]; errors: string[]; } // --- Cross-device sync (settings blob) helpers ----------------------------- // Templates ride along in the per-account settings sync blob (see // settings-store exportSettings/importSettings, #825). Unlike the file import // below, synced templates keep their ids so the same template converges to a // single entry across devices instead of duplicating on every server load. // The merge itself lives in template-merge so the settings API route can use // it without pulling DOMPurify into the server bundle. export { mergeSyncedTemplates, TEMPLATE_TOMBSTONE_TTL_MS, type SyncedTemplateState, } from './template-merge'; /** * Structurally validates a `templates` value from a synced settings blob or * settings-file import. Returns null when the value is not an array (a blob * from a build that predates template sync), so callers can leave the local * store untouched; malformed entries are dropped, ids are preserved. */ export function parseSyncedTemplates(value: unknown): EmailTemplate[] | null { if (!Array.isArray(value)) return null; const templates: EmailTemplate[] = []; const seen = new Set(); for (const item of value) { if (typeof item !== 'object' || item === null) continue; const t = item as Record; if (typeof t.id !== 'string' || !t.id || seen.has(t.id)) continue; if (typeof t.name !== 'string' || !t.name.trim()) continue; seen.add(t.id); const recipients = t.defaultRecipients as Record | undefined; const now = new Date().toISOString(); templates.push({ id: t.id, name: sanitizeText(t.name), subject: sanitizeText(t.subject), body: t.isHTML ? sanitizeTemplateHtml(t.body) : sanitizeText(t.body), isHTML: Boolean(t.isHTML), category: sanitizeText(t.category), defaultRecipients: recipients && typeof recipients === 'object' ? { to: Array.isArray(recipients.to) ? (recipients.to as string[]).map(String) : undefined, cc: Array.isArray(recipients.cc) ? (recipients.cc as string[]).map(String) : undefined, bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as string[]).map(String) : undefined, } : undefined, identityId: typeof t.identityId === 'string' ? t.identityId : undefined, isFavorite: Boolean(t.isFavorite), createdAt: typeof t.createdAt === 'string' ? t.createdAt : now, updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now, }); } return templates; } /** Structurally validates a `deletedTemplateIds` map from a synced blob. */ export function parseTemplateTombstones(value: unknown): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; const out: Record = {}; for (const [id, deletedAt] of Object.entries(value as Record)) { if (typeof deletedAt === 'string' && !Number.isNaN(Date.parse(deletedAt))) { out[id] = deletedAt; } } return out; } export function importTemplates(json: string): ImportResult { const errors: string[] = []; let parsed: unknown; try { parsed = JSON.parse(json); } catch { return { templates: [], errors: ['invalid_json'] }; } if (typeof parsed !== 'object' || parsed === null) { return { templates: [], errors: ['invalid_format'] }; } const data = parsed as Record; if (data.type !== 'webmail-templates') { return { templates: [], errors: ['invalid_type'] }; } if (data.version !== 1) { return { templates: [], errors: ['unsupported_version'] }; } if (!Array.isArray(data.templates)) { return { templates: [], errors: ['invalid_templates'] }; } const templates: EmailTemplate[] = []; for (const item of data.templates) { if (typeof item !== 'object' || item === null) { errors.push('invalid_template_entry'); continue; } const t = item as Record; if (typeof t.name !== 'string' || !t.name.trim()) { errors.push('missing_template_name'); continue; } const recipients = t.defaultRecipients as Record | undefined; templates.push({ id: generateUUID(), name: sanitizeText(t.name), subject: sanitizeText(t.subject), body: t.isHTML ? sanitizeTemplateHtml(t.body) : sanitizeText(t.body), isHTML: Boolean(t.isHTML), category: sanitizeText(t.category), defaultRecipients: recipients && typeof recipients === 'object' ? { to: Array.isArray(recipients.to) ? (recipients.to as string[]).map(String) : undefined, cc: Array.isArray(recipients.cc) ? (recipients.cc as string[]).map(String) : undefined, bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as string[]).map(String) : undefined, } : undefined, identityId: typeof t.identityId === 'string' ? t.identityId : undefined, isFavorite: Boolean(t.isFavorite), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } return { templates, errors }; }