Files
Webklar-Kundenbereich/server/services/mailTemplates.js
T

190 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
/** <!-- 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,')
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'
)
}