import assert from 'node:assert/strict' import { readFileSync, readdirSync } from 'node:fs' import { test } from 'node:test' import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js' const DIR = new URL('../server/mail-templates/', import.meta.url) const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), ] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on])) test('alle 22 Vorlagen sind da', () => { assert.deepEqual(templateIds(), EXPECTED) }) for (const id of EXPECTED) { test(`${id}: Kopfkommentar passt zur Vorlage`, () => { const tpl = loadTemplate(id) const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') const opened = [...tpl.body.matchAll(/<!-- WENN (\w+) -->/g)].map((m) => m[1]) const closed = [...tpl.body.matchAll(/<!-- ENDE (\w+) -->/g)].map((m) => m[1]) assert.deepEqual([...new Set(opened)].sort(), [...tpl.blocks].sort(), 'deklarierte = verwendete Blöcke') assert.deepEqual(opened.slice().sort(), closed.slice().sort(), 'jeder Block wird geschlossen') }) test(`${id}: rendert ohne Reste, mit und ohne optionale Blöcke`, () => { const tpl = loadTemplate(id) for (const on of [true, false]) { const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on)) assert.deepEqual(mail.problems, []) assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/) assert.match(mail.html, /<!--\[if mso\]>/, 'bedingter Outlook-Kommentar bleibt') assert.equal((mail.html.match(/<!--/g) || []).length, 1, 'nur der Outlook-Kommentar bleibt') assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/) assert.doesNotMatch(mail.subject, /\{\{|\n/) assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities') } }) } test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden <br>', () => { const mail = renderMail('T01', { kunde_name: 'Max <b>&"\'', nachricht: 'Zeile 1\nZeile <2>' }) assert.match(mail.html, /Max <b>&"' hat im Kundenportal geschrieben/) assert.match(mail.html, /Zeile 1<br>Zeile <2>/) assert.doesNotMatch(mail.html, /<b>&/) assert.equal(mail.subject, 'Neue Portalnachricht von Max <b>&"\'') assert.match(mail.text, /Zeile 1\nZeile <2>/) assert.equal(escapeHtml('a&b'), 'a&b') }) test('optionale Blöcke: nur eingeschaltete erscheinen', () => { const values = { name: 'Max', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' } const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false }) assert.doesNotMatch(early.html, /Ihr Angebot:/) assert.match(early.html, /Sie haben ausdrücklich verlangt/) assert.doesNotMatch(early.html, /erst nach Ablauf der Widerrufsfrist/) const late = renderMail('K01', values, { angebot: true, sofortbeginn: false, beginn_nach_widerrufsfrist: true }) assert.match(late.html, /Ihr Angebot:<\/strong> Erste 2 Monate je 25,00 € \(wie vereinbart\)/) assert.doesNotMatch(late.html, /Sie haben ausdrücklich verlangt/) assert.match(late.html, /Leistungsbeginn und erste Abbuchung: 15\.10\.2026\./) }) test('verschachtelte Blöcke (T09) werden aufgelöst', () => { const values = { email: 'a@example.com', name: 'A', kundennummer: '', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' } const ok = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: true }) assert.match(ok.html, /sub_1 <span[^>]*>\(auf Kündigung gesetzt\)<\/span>/) assert.doesNotMatch(ok.html, /KEIN aktives Abo|NICHT automatisch/) const failed = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: false, kuendigung_fehlgeschlagen: true }) assert.doesNotMatch(failed.html, /auf Kündigung gesetzt\)/) assert.match(failed.html, /Abo sub_1 konnte NICHT automatisch auf Kündigung gesetzt werden/) const none = renderMail('T09', values, { kein_abo_gefunden: true }) assert.doesNotMatch(none.html, /Stripe-Abo|Abo-Beginn|Widerrufsfrist bis/) assert.match(none.html, /KEIN aktives Abo zu dieser E-Mail gefunden/) assert.doesNotMatch(none.html, /WENN|ENDE/) }) test('Anrede ohne Namen, fehlende und unbekannte Werte werden gemeldet', () => { const mail = renderMail('K10', { name: '', antrag_datum: '01.10.2026', antrag_nr: 'AB12CD34', begruendung: 'x', extra: 1 }, { gibtsnicht: true }) assert.match(mail.html, /Guten Tag,<br>/) assert.match(mail.text, /^Guten Tag,$/m) assert.deepEqual(mail.problems.sort(), ['unbekannter Block "gibtsnicht"', 'unbekannter Platzhalter {{extra}}']) const missing = renderMail('K10', { name: 'A', antrag_datum: '01.10.2026', antrag_nr: 'X' }) assert.deepEqual(missing.problems, ['kein Wert für {{begruendung}}']) }) test('Textfassung K01: alle Vertragsangaben lesbar', () => { const mail = renderMail( 'K01', { name: 'Erika Musterfrau', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' }, { sofortbeginn: true } ) const t = mail.text for (const line of [ 'Guten Tag Erika Musterfrau,', '50,00 € / Monat', 'gemäß § 19 UStG ohne Umsatzsteuer', 'Vertragsschluss: 01.10.2026', 'Leistungsbeginn: 01.10.2026', 'Laufzeit: Mindestlaufzeit 12 Monate bis 01.10.2027. Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar.', '• 1 Domain', 'Zum Kundenportal: https://project.webklar.com/dashboard.html', '📎 Widerrufsbelehrung mit Muster-Widerrufsformular', 'Ihre Zustimmung zu den Vertragsbedingungen (Version 2026-09-25-v3) haben wir am 01.10.2026, 10:07 Uhr erhalten.', 'Vertrag widerrufen (https://project.webklar.com/widerrufen.html)', 'Vertrag kündigen (https://project.webklar.com/kuendigen.html)', 'Telefon 0176 23726355 · support@webklar.com', 'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein', ]) { assert.ok(t.split('\n').includes(line), `Zeile fehlt: ${line}\n---\n${t}`) } assert.doesNotMatch(t, /Ihr Wartungs-Abo ist abgeschlossen\. Alle Vertragsunterlagen/, 'Vorschautext nicht im Text') }) test('htmlToText: Links, Buttons und Leerzeilen', () => { const t = htmlToText('<body><table><tr><td class="px">A <a href="mailto:x@y.de">x@y.de</a> B</td></tr><tr><td class="px"><a href="https://e.de/p" style="display:block">Los →</a></td></tr></table></body>') assert.equal(t, 'A x@y.de B\n\nLos: https://e.de/p\n') }) test('Vorlagen-Ordner enthält nur Vorlagen', () => { assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), []) })