191 lines
7.7 KiB
JavaScript
191 lines
7.7 KiB
JavaScript
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 -> <!-- WENN angebot --> ... <!-- ENDE angebot -->
|
||
* 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(/<!--[ \t]*\n\s*([GKT]\d{2}) ·[\s\S]*?-->\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, '"')
|
||
.replace(/'/g, ''')
|
||
|
||
/** <!-- WENN x --> ... <!-- ENDE x -->: Inhalt behalten (ohne Markierungen) oder ganz entfernen */
|
||
function applyBlocks(html, blocks) {
|
||
const block = /<!-- WENN (\w+) -->\n?([\s\S]*?)<!-- ENDE \1 -->\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('<!-- WENN '); i += 1) {
|
||
html = html.replace(block, (_, name, inner) => (blocks[name] ? inner : ''))
|
||
}
|
||
return html
|
||
}
|
||
|
||
/**
|
||
* Mail aus einer Vorlage erzeugen.
|
||
* @param {string} id z. B. 'K01'
|
||
* @param {Record<string, unknown>} values Platzhalterwerte (Klartext; Zeilenumbrüche werden zu <br>)
|
||
* @param {Record<string, boolean>} blocks optionale Blöcke, die erscheinen sollen
|
||
* @returns {{ id: string, subject: string, html: string, text: string, problems: string[] }}
|
||
* problems: unbekannte oder fehlende Platzhalter/Blöcke (die Mail geht trotzdem raus)
|
||
*/
|
||
export function renderMail(id, values = {}, blocks = {}) {
|
||
const template = loadTemplate(id)
|
||
const problems = []
|
||
for (const key of Object.keys(values)) {
|
||
if (!template.placeholders.includes(key)) problems.push(`unbekannter Platzhalter {{${key}}}`)
|
||
}
|
||
for (const key of Object.keys(blocks)) {
|
||
if (!template.blocks.includes(key)) problems.push(`unbekannter Block "${key}"`)
|
||
}
|
||
const valueOf = (key) => {
|
||
if (!(key in values)) {
|
||
problems.push(`kein Wert für {{${key}}}`)
|
||
return ''
|
||
}
|
||
return values[key] === null || values[key] === undefined ? '' : String(values[key])
|
||
}
|
||
|
||
let html = applyBlocks(template.body, blocks)
|
||
// übrige Hinweis-Kommentare raus, bedingte Kommentare für Outlook (<!--[if mso]>) bleiben
|
||
.replace(/<!--(?!\[if)[\s\S]*?-->\n?/g, '')
|
||
.replaceAll(DESIGN_BASE_URL, config.publicBaseUrl)
|
||
html = html
|
||
.replace(/\{\{(\w+)\}\}/g, (_, key) => escapeHtml(valueOf(key)).replace(/\r?\n/g, '<br>'))
|
||
// Anrede ohne bekannten Namen
|
||
.replace(/Guten Tag ,/g, 'Guten Tag,')
|
||
.replace(/Hallo ,/g, 'Hallo,')
|
||
|
||
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(/<body[^>]*>([\s\S]*)<\/body>/i)?.[1] ?? html)
|
||
.replace(/<span style="display:none[^"]*"[^>]*>[\s\S]*?<\/span>/i, '') // Vorschautext im Posteingang
|
||
.replace(/<!--[\s\S]*?-->/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
|
||
let out = ''
|
||
let link = null
|
||
const cells = [] // Klassen der offenen <td>
|
||
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'
|
||
)
|
||
}
|