import { readdirSync, readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { config } from '../config.js' /** * HTML-Mails aus den Vorlagen in server/mail-templates (Claude-Design-Projekt * "Webklar Bestätigungsmail gestalten"; K = an Kunden, T = intern ans Team, G = an Gäste unserer Kunden * (Terminplaner, neutrales Layout ohne WEBklar-Logo)). * * Jede Vorlage beschreibt sich im Kopfkommentar selbst: * Betreff: Text mit {{platzhaltern}} * Platzhalter: {{name}}, {{email}}, ... * Optionale Blöcke (...): angebot, grund -> ... * renderMail() setzt die Werte HTML-sicher ein, entfernt nicht zutreffende Blöcke samt Inhalt und * liefert Betreff, HTML und eine Textfassung für Mailprogramme ohne HTML. Eine neue Fassung aus dem * Design kann eine Datei 1:1 ersetzen, solange Platzhalter und Blöcke gleich heißen. */ const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mail-templates') // In den Vorlagen fest eingetragene Portal-Adresse; beim Rendern gilt config.publicBaseUrl const DESIGN_BASE_URL = 'https://project.webklar.com' const cache = new Map() /** IDs aller Vorlagen (G01, ..., K01, K02, ..., T11) */ export function templateIds() { return readdirSync(TEMPLATE_DIR) .filter((f) => /^[GKT]\d{2} .*\.html$/.test(f)) .map((f) => f.slice(0, 3)) .sort() } const nameList = (line) => String(line || '') .split(',') .map((s) => s.trim().replace(/^\{\{|\}\}$/g, '')) .filter(Boolean) /** Vorlage lesen und Kopfkommentar auswerten (einmal je Prozess). */ export function loadTemplate(id) { if (cache.has(id)) return cache.get(id) const file = readdirSync(TEMPLATE_DIR).find((f) => f.startsWith(`${id} `) && f.endsWith('.html')) if (!file) throw new Error(`Mail-Vorlage ${id} fehlt`) const source = readFileSync(path.join(TEMPLATE_DIR, file), 'utf8') const header = source.match(/\n?/) if (header?.[1] !== id) throw new Error(`Mail-Vorlage ${file}: Kopfkommentar fehlt`) const field = (label) => header[0].match(new RegExp(`^\\s*${label}[^:\\n]*:[ \\t]*(.*)$`, 'm'))?.[1]?.trim() || '' const template = { id, file, subject: field('Betreff'), placeholders: nameList(field('Platzhalter')), blocks: nameList(field('Optionale Blöcke')), body: source.replace(header[0], ''), } if (!template.subject) throw new Error(`Mail-Vorlage ${file}: Betreff fehlt`) cache.set(id, template) return template } export const escapeHtml = (value) => String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') /** ... : Inhalt behalten (ohne Markierungen) oder ganz entfernen */ function applyBlocks(html, blocks) { const block = /\n?([\s\S]*?)\n?/g // Von außen nach innen, bis keine Markierung mehr übrig ist (Blöcke dürfen verschachtelt sein) for (let i = 0; i < 10 && html.includes('\n?/g, '') .replaceAll(DESIGN_BASE_URL, config.publicBaseUrl) html = html .replace(/\{\{(\w+)\}\}/g, (_, key) => escapeHtml(valueOf(key)).replace(/\r?\n/g, '
')) // Anrede ohne bekannten Namen .replace(/Guten Tag ,/g, 'Guten Tag,') const subject = template.subject .replace(/\{\{(\w+)\}\}/g, (_, key) => valueOf(key)) .replace(/\s+/g, ' ') .trim() return { id, subject, html, text: htmlToText(html), problems: [...new Set(problems)] } } // --------------------------------------------------------------------------- // Textfassung (multipart/alternative): aus dem gerenderten HTML abgeleitet, // damit beide Fassungen immer denselben Inhalt haben // --------------------------------------------------------------------------- const NAMED_ENTITIES = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rarr: '→' } function decodeEntities(text) { return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, code) => { if (code[0] !== '#') return NAMED_ENTITIES[code.toLowerCase()] ?? entity const point = code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10) return point === 847 ? '' : String.fromCodePoint(point) }) } function linkText({ href, label, button }) { const url = decodeEntities(href) const text = label.replace(/\s*→\s*$/, '').trim() if (/^(mailto|tel):/i.test(url) || !url) return text if (!text || text === url) return url return button ? `\n${text}: ${url}\n` : `${text} (${url})` } /** HTML der Vorlagen (Tabellen-Layout) in lesbaren Text umwandeln. */ export function htmlToText(html) { const body = (html.match(/]*>([\s\S]*)<\/body>/i)?.[1] ?? html) .replace(/]*>[\s\S]*?<\/span>/i, '') // Vorschautext im Posteingang .replace(//g, '') .replace(/\s+/g, ' ') let out = '' let link = null const cells = [] // Klassen der offenen for (const token of body.split(/(<[^>]+>)/)) { if (!token.startsWith('<')) { if (link) link.label += decodeEntities(token) else out += decodeEntities(token) continue } const [, closing, name = '', attrs = ''] = token.match(/^<(\/?)(\w+)([^>]*)>$/) || [] const tag = name.toLowerCase() if (tag === 'br') out += '\n' else if (tag === 'a' && !closing) { link = { href: attrs.match(/href="([^"]*)"/)?.[1] || '', label: '', button: /display:\s*block/.test(attrs) } } else if (tag === 'a' && link) { out += linkText(link) link = null } else if (tag === 'td' && !closing) { cells.push(attrs.match(/class="([^"]*)"/)?.[1] || '') } else if (tag === 'td') { const cls = cells.pop() || '' // Bezeichnung | Wert -> "Bezeichnung: Wert"; Abschnitte des Layouts mit Leerzeile trennen out += /\blbl\b/.test(cls) ? ': ' : /\bpx\b/.test(cls) ? '\n\n' : '\n' } else if ((tag === 'div' || tag === 'table') && closing) out += '\n' } return ( out .split('\n') .map((line) => line.replace(/[ \t ]+/g, ' ').trim()) // Fußzeile "Kundenportal (…) · Vertrag widerrufen (…) · …": je Link eine Zeile .flatMap((line) => ((line.match(/\(https?:/g) || []).length > 1 ? line.split(/ · /) : [line])) .join('\n') .replace(/\n{3,}/g, '\n\n') .trim() + '\n' ) }