Automatische Mails im neuen Design (HTML mit Textfassung)

Alle 22 automatischen Mails gehen jetzt als HTML nach den Vorlagen aus
server/mail-templates raus, dazu eine aus dem HTML erzeugte Textfassung
(multipart/alternative). Werte werden HTML-sicher eingesetzt, optionale
Blöcke (WENN/ENDE) je nach Fall ein- oder ausgeblendet.

- Neu: services/mailTemplates.js (Vorlagen lesen, Platzhalter, Blöcke,
  Textfassung); mailer.js nimmt { template, values, blocks }.
- K11 "Passwort geändert" war nicht im Design-Export: aus den Bausteinen
  von K02 zusammengesetzt.
- T09 (Widerruf, intern): Fälle "kein Abo gefunden" und "Stripe-Kündigung
  fehlgeschlagen" ergänzt; bisher stand dort immer "auf Kündigung gesetzt".
- Einheitlich: Datum TT.MM.JJJJ, Beträge deutsch (T04 "50,00 €"),
  "WEBklar" statt "Webklar", Anrede mit Kundennamen, Team-Absender
  "WEBklar Kundenportal", Betreff T05 "Kündigung (Stripe)".
- Logo für die Mails: public/webklar-logo.png (192 px, aus dem Design-SVG).

Tests: 52 neue (jede Vorlage: Kopf, Platzhalter, Blöcke, Rendern,
Escaping, Textfassung), npm test 97/97 auf Node 20.19.5; alle 40 Auslöser
einmal durch den Server geschickt (Appwrite-Mock, Stripe-Double,
SMTP-Falle mit STARTTLS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 14:37:50 +00:00
1 parent e3daffffa3
commit 18c3cdf3e5
11 files changed
+656 -315

No files matched your search

+9
View File
@@ -56,6 +56,15 @@ Database: woms-database
`customerId` wird **im Ticketsystem** am `websiteProjects`-Eintrag gesetzt, nicht im Webhook.
## E-Mails
Alle automatischen Mails sind HTML-Vorlagen mit Textfassung in `server/mail-templates/`
(K01–K11 an Kunden, T01–T11 ans Team; Design aus Claude Design). Jede Vorlage nennt im
Kopfkommentar Betreff, Platzhalter (`{{name}}`) und optionale Blöcke
(`<!-- WENN x --> … <!-- ENDE x -->`). Eine neue Fassung aus dem Design kann die Datei direkt
ersetzen, solange Platzhalter und Blöcke gleich heißen; `npm test` prüft das. Das Logo in den
Mails liegt unter `public/webklar-logo.png`.
## Deployment
```bash
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K11 · Passwort geändert (Kundenportal)
Betreff: Ihr Passwort für das WEBklar Kundenportal wurde geändert
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Portal-Zugangs) · BCC intern
Platzhalter: {{name}}, {{email}}, {{geaendert_am}}
Nicht im Design-Export enthalten: aus den Bausteinen von K02 zusammengesetzt.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihr Passwort für das WEBklar Kundenportal wurde geändert</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ihr Passwort wurde am {{geaendert_am}} Uhr geändert.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Sicherheitshinweis</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Passwort geändert</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr Passwort wurde geändert.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>das Passwort für Ihren Zugang zum WEBklar Kundenportal ({{email}}) wurde am {{geaendert_am}} Uhr geändert.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Auf allen anderen Geräten wurden Sie dabei abgemeldet.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Waren Sie das? Dann ist nichts weiter zu tun.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf6e7;border:1px solid #f0dcb0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Sie waren das nicht?</div><div class="mut" style="color:#4a4843;">Dann melden Sie sich bitte umgehend bei uns: <a class="mut" href="mailto:support@webklar.com" style="color:#4a4843;">support@webklar.com</a> oder 0176 23726355.</div></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a class="mut" href="https://project.webklar.com/widerrufen.html" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a class="mut" href="https://project.webklar.com/kuendigen.html" style="color:#4a4843;text-decoration:underline;">Vertrag kündigen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -8,7 +8,7 @@
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -38,7 +38,19 @@ a{color:#111111;}
<!-- WENN kundennummer -->
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kundennummer}}</td></tr>
<!-- ENDE kundennummer -->
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}} <span class="mut" style="color:#6b6964;">(auf Kündigung gesetzt)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abo-Beginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abo_beginn}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Widerrufsfrist bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{fristende}}, 24 Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingang</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{rechtzeitig}}</strong></td></tr></table></td></tr>
<!-- WENN abo_gefunden -->
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}}<!-- WENN auf_kuendigung_gesetzt --> <span class="mut" style="color:#6b6964;">(auf Kündigung gesetzt)</span><!-- ENDE auf_kuendigung_gesetzt --></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abo-Beginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abo_beginn}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Widerrufsfrist bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{fristende}}, 24 Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingang</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{rechtzeitig}}</strong></td></tr>
<!-- ENDE abo_gefunden -->
</table></td></tr>
<!-- WENN kein_abo_gefunden -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">KEIN aktives Abo zu dieser E-Mail gefunden. Bitte von Hand zuordnen und die Widerrufsfrist prüfen.</div></td></tr></table></td></tr>
<!-- ENDE kein_abo_gefunden -->
<!-- WENN kuendigung_fehlgeschlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo {{stripe_abo_id}} konnte NICHT automatisch auf Kündigung gesetzt werden. Bitte in Stripe von Hand kündigen.</div></td></tr></table></td></tr>
<!-- ENDE kuendigung_fehlgeschlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung).</div></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
+143 -151
View File
@@ -27,9 +27,13 @@ const router = Router()
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
const fmtDate = (iso) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '–'
iso
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
: '–'
const fmtDateTime = (d) =>
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
const fmtEuro = (cents) =>
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
function addMonthsIso(iso, months) {
const d = new Date(iso)
@@ -182,88 +186,64 @@ router.post('/portal', requireSession, async (req, res) => {
// Stripe-Webhook
// ---------------------------------------------------------------------------
async function sendContractConfirmation(subDoc) {
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
async function customerNameOf(sub, fallback = '') {
const customer = sub?.customerId
? await getDocument(config.collections.customers, sub.customerId).catch(() => null)
: null
return customer?.name || fallback || ''
}
async function sendContractConfirmation(subDoc, fallbackName = '') {
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
const pdf = await readFile(pdfPath).catch(() => null)
const lateStart = subDoc.startChoice === 'nach_frist'
const consentLine = lateStart
? `Sie haben gewählt, dass wir erst nach Ablauf der Widerrufsfrist beginnen. Leistungsbeginn und erste Abbuchung: ${fmtDate(subDoc.trialEnd)}. Bis dahin erbringen wir keine Leistungen und buchen nichts ab.`
: 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahlen.'
await sendCustomerMail(
subDoc.email,
'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung',
[
'Guten Tag,',
'',
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
'',
' Vertrag: WEBklar Wartungs-Abo',
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
...(subDoc.offerText ? [` Angebot: ${subDoc.offerText} (wie vereinbart)`] : []),
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
...(minTermEndOf(subDoc)
? [
` Laufzeit: Mindestlaufzeit 12 Monate bis ${fmtDate(minTermEndOf(subDoc))}; Kündigung zu diesem Termin bis einen Monat vorher,`,
' danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar',
]
: [' Laufzeit: unbestimmt, jederzeit zum Ende des Abrechnungsmonats kündbar']),
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
'',
`Ihre Zustimmung zu den Vertragsbedingungen (Version ${subDoc.termsVersion || config.abo.termsVersion}) haben wir am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} erhalten.`,
consentLine,
'',
'Im Anhang finden Sie die vollständigen Vertragsunterlagen als PDF: Vertragsbedingungen mit Leistungsbeschreibung,',
'Widerrufsbelehrung mit Muster-Widerrufsformular und Auftragsverarbeitungsvertrag.',
'',
`Ihr Stundenkontingent sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Vertrag widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
`Verträge hier kündigen: ${config.publicBaseUrl}/kuendigen.html`,
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com',
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
],
pdf
await sendCustomerMail(subDoc.email, {
template: 'K01',
values: {
name: await customerNameOf(subDoc, fallbackName),
angebot_text: subDoc.offerText || '',
vertragsschluss: fmtDate(subDoc.startedAt),
leistungsbeginn: fmtDate(lateStart ? subDoc.trialEnd : subDoc.startedAt),
// Abos nach Bedingungen ohne Mindestlaufzeit (vor v3) entstehen nicht mehr; sie hätten hier "–"
mindestlaufzeit_bis: fmtDate(minTermEndOf(subDoc)),
bedingungen_version: subDoc.termsVersion || config.abo.termsVersion,
zustimmung_zeitpunkt: fmtDateTime(subDoc.termsAcceptedAt || Date.now()),
},
blocks: {
angebot: Boolean(subDoc.offerText),
sofortbeginn: !lateStart,
beginn_nach_widerrufsfrist: lateStart,
},
attachments: pdf
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
: []
)
: [],
})
await updateDocument(config.collections.subscriptions, subDoc.$id, {
confirmationSentAt: new Date().toISOString(),
})
}
/** Kuendigung ueber "Abo verwalten" (Stripe-Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
/** Kuendigung direkt bei Stripe (Dashboard/Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
async function sendPortalCancellationConfirmation(sub, createdUnix) {
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
const endsAt = sub.cancelAt || sub.currentPeriodEnd
await sendCustomerMail(sub.email, 'Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo', [
'Guten Tag,',
'',
`wir bestätigen Ihre Kündigung des WEBklar Wartungs-Abos, eingegangen am ${fmtDateTime(receivedAt)} über „Abo verwalten“ im Kundenportal.`,
'',
`Ihr Wartungs-Abo endet zum ${fmtDate(endsAt)}. Bis dahin bleiben alle Leistungen verfügbar,`,
'danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.',
'Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.',
'',
'Falls Sie das nicht veranlasst haben, melden Sie sich bitte umgehend bei uns.',
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com',
])
await sendCustomerMail(sub.email, {
template: 'K02',
values: {
name: await customerNameOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
vertragsende: fmtDate(endsAt),
},
})
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
}).catch(() => {})
await sendAdminNotice(`Kündigung (Kundenportal): ${sub.email}`, [
`Abo ${sub.stripeSubscriptionId} endet zum ${fmtDate(endsAt)}. Bestätigung an den Kunden ist raus.`,
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
])
await sendAdminNotice({
template: 'T05',
values: { email: sub.email, stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt) },
})
}
async function handleStripeEvent(event) {
@@ -273,20 +253,24 @@ async function handleStripeEvent(event) {
if (obj.mode !== 'subscription' || !obj.subscription) return
const sub = await syncSubscription(obj.subscription)
const offerProblem = await activateOfferFromCheckout(obj).then(
() => '',
() => false,
(e) => {
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
return `ACHTUNG: Angebot konnte nicht automatisch angewendet werden (${e.message}) -> Rabatt in Stripe am Abo prüfen!`
return true
}
)
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
`Stripe-Abo: ${obj.subscription}`,
...(obj.metadata?.offerText ? [`Angebot: ${obj.metadata.offerText}`] : []),
...(offerProblem ? [offerProblem] : []),
])
await sendContractConfirmation(sub).catch((e) =>
await sendAdminNotice({
template: 'T02',
values: {
email: sub?.email || obj.customer_details?.email || '',
kunde_name: obj.customer_details?.name || obj.customer_details?.email || '',
appwrite_id: sub?.customerId || obj.client_reference_id || '',
stripe_abo_id: obj.subscription,
angebot_text: obj.metadata?.offerText || '',
},
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem },
})
await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) =>
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
)
return
@@ -301,10 +285,14 @@ async function handleStripeEvent(event) {
case 'invoice.payment_failed': {
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
await sendAdminNotice(`Zahlung fehlgeschlagen: ${sub?.email || obj.customer_email || ''}`, [
`Rechnung ${obj.number || obj.id}, Betrag ${(obj.amount_due / 100).toFixed(2)} €.`,
'Stripe versucht die Zahlung automatisch erneut und mahnt per E-Mail.',
])
await sendAdminNotice({
template: 'T04',
values: {
email: sub?.email || obj.customer_email || '',
rechnung_nr: obj.number || obj.id,
betrag: fmtEuro(obj.amount_due),
},
})
return
}
case 'customer.subscription.created':
@@ -327,18 +315,16 @@ async function handleStripeEvent(event) {
)
}
if (reactivated && sub) {
await sendAdminNotice(`Kündigung zurückgenommen: ${sub.email}`, [
`Abo ${obj.id} läuft wieder normal weiter (Kündigung aufgehoben).`,
])
await sendAdminNotice({ template: 'T06', values: { email: sub.email, stripe_abo_id: obj.id } })
}
return
}
case 'customer.subscription.deleted': {
const sub = await syncSubscription(obj.id)
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
`Stripe-Abo ${obj.id} ist beendet (Status ${obj.status}). Restguthaben verfällt.`,
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
])
await sendAdminNotice({
template: 'T07',
values: { email: sub?.email || '', stripe_abo_id: obj.id, status: obj.status },
})
return
}
default:
@@ -426,44 +412,45 @@ router.post('/public/cancel', async (req, res) => {
}
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
const effectiveText = effectiveAt
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)}.${d.kind === 'geschaeftsaufgabe' ? ' Bitte senden Sie uns innerhalb von zwei Wochen einen Nachweis der Geschäftsaufgabe (z. B. Gewerbeabmeldung) an support@webklar.com.' : ''}`
: d.kind === 'ausserordentlich'
? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.'
: 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
const common = {
name: d.name,
email: d.email,
kundennummer: d.customerNumber,
wunschtermin: d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
grund: d.reason,
vertragsende: fmtDate(effectiveAt),
}
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
`Guten Tag ${d.name},`,
'',
`wir haben Ihre ${kindLabel} am ${fmtDateTime(receivedAt)} erhalten.`,
'',
` Name: ${d.name}`,
` E-Mail: ${d.email}`,
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
' Vertrag: Webklar Wartungs-Abo',
` Gewünschter Zeitpunkt: ${d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt'}`,
d.reason ? ` Grund: ${d.reason}` : null,
'',
effectiveText,
'Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und',
'Ihr Postfach stellen wir Ihnen auf Wunsch gemäß Ziff. 9 der Abo-Bedingungen zur Verfügung.',
'',
'Viele Grüße',
'Ihr Webklar-Team',
].filter((l) => l !== null)).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
await sendCustomerMail(d.email, {
template: 'K03',
values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
blocks: {
kundennummer: Boolean(d.customerNumber),
grund: Boolean(d.reason),
automatisch_gekuendigt: Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
sonderkuendigung: Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
ausserordentlich: !effectiveAt && d.kind === 'ausserordentlich',
kein_abo_gefunden: !effectiveAt && d.kind !== 'ausserordentlich',
},
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
`Art: ${kindLabel}, gewünschter Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
d.reason ? `Grund: ${d.reason}` : '',
effectiveAt
? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
: sub
? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
d.kind === 'geschaeftsaufgabe' ? 'Sonderkündigung Geschäftsaufgabe -> Nachweis (z. B. Gewerbeabmeldung) anfordern/prüfen; ohne Nachweis Kündigungstermin in Stripe auf Ende der Mindestlaufzeit setzen.' : '',
])
await sendAdminNotice({
template: 'T08',
values: {
...common,
kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1),
stripe_abo_id: sub?.stripeSubscriptionId || '',
},
blocks: {
kundennummer: Boolean(d.customerNumber),
grund: Boolean(d.reason),
automatisch_gekuendigt: Boolean(effectiveAt),
sonderkuendigung: d.kind === 'geschaeftsaufgabe',
// Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler
ausserordentlich: !effectiveAt && Boolean(sub),
kein_abo_gefunden: !sub,
},
})
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
})
@@ -475,11 +462,13 @@ router.post('/public/withdraw', async (req, res) => {
const receivedAt = new Date()
let sub = null
let canceled = false
try {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub) {
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')
canceled = true
await updateDocument(config.collections.subscriptions, sub.$id, {
withdrawalRequestedAt: receivedAt.toISOString(),
})
@@ -488,36 +477,39 @@ router.post('/public/withdraw', async (req, res) => {
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
}
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihres Widerrufs – Webklar Wartungs-Abo', [
`Guten Tag ${d.name},`,
'',
`wir haben Ihren Widerruf des Vertrags „Webklar Wartungs-Abo" am ${fmtDateTime(receivedAt)} erhalten.`,
'',
` Name: ${d.name}`,
` E-Mail: ${d.email}`,
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
'',
'Es erfolgen keine weiteren Abbuchungen. Bereits gezahlte Beträge erstatten wir innerhalb von 14 Tagen',
'über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf',
'erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.',
'',
'Viele Grüße',
'Ihr Webklar-Team',
].filter((l) => l !== null)).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
await sendCustomerMail(d.email, {
template: 'K04',
values: { name: d.name, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber },
blocks: { kundennummer: Boolean(d.customerNumber) },
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss
const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null
await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
sub
? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!',
deadline
? `Widerrufsfrist bis ${fmtDate(deadline.endsAt)}, 24 Uhr – Eingang ${receivedAt <= deadline.endsAt ? 'RECHTZEITIG' : 'NACH Fristende, vor einer Ablehnung bitte prüfen'}.`
: 'Widerrufsfrist: 14 Tage ab dem Tag nach Vertragsschluss bis 24 Uhr; fällt der letzte Tag auf Sa/So/Feiertag, am nächsten Werktag.',
'TODO: Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen',
'erstatten (Stripe -> Rückerstattung).',
])
await sendAdminNotice({
template: 'T09',
values: {
email: d.email,
name: d.name,
kundennummer: d.customerNumber,
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: fmtDate(deadline?.endsAt),
rechtzeitig: !sub
? 'ungeprüft (kein Abo gefunden)'
: !deadline
? 'ungeprüft (Abo-Beginn fehlt)'
: receivedAt <= deadline.endsAt
? 'RECHTZEITIG'
: 'NACH Fristende, vor einer Ablehnung bitte prüfen',
},
blocks: {
kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: canceled,
kein_abo_gefunden: !sub,
kuendigung_fehlgeschlagen: Boolean(sub) && !canceled,
},
})
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
})
+4 -16
View File
@@ -4,8 +4,6 @@ import { forgetStoredPortalPassword } from './customerAdmin.js'
import { sendCustomerMail } from './mailer.js'
import { rememberPasswordVersion } from './passwordVersion.js'
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
const fmtDateTime = (d) =>
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
@@ -77,20 +75,10 @@ function updateError(err) {
}
function sendPasswordChangedMail(email, name, changedAt) {
return sendCustomerMail(email, 'Ihr Passwort für den WEBklar-Kundenbereich wurde geändert', [
`Guten Tag ${name || ''}`.trim() + ',',
'',
`das Passwort für Ihren Zugang zum WEBklar-Kundenbereich (${email}) wurde am ${fmtDateTime(changedAt)} Uhr geändert.`,
'Auf allen anderen Geräten wurden Sie dabei abgemeldet.',
'',
'Waren Sie das? Dann ist nichts weiter zu tun.',
'Falls nicht, melden Sie sich bitte umgehend bei uns: support@webklar.com',
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
])
return sendCustomerMail(email, {
template: 'K11',
values: { name: name || '', email, geaendert_am: fmtDateTime(changedAt) },
})
}
/**
+188
View File
@@ -0,0 +1,188 @@
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).
*
* 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 (K01, K02, ..., T11) */
export function templateIds() {
return readdirSync(TEMPLATE_DIR)
.filter((f) => /^[KT]\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*([KT]\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'
)
}
+42 -21
View File
@@ -1,5 +1,10 @@
import nodemailer from 'nodemailer'
import { config } from '../config.js'
import { renderMail } from './mailTemplates.js'
// Absendernamen wie im Design: Kundenmails "WEBklar", Hinweise ans Team "WEBklar Kundenportal"
const CUSTOMER_SENDER = 'WEBklar'
const TEAM_SENDER = 'WEBklar Kundenportal'
let transporter = null
@@ -18,6 +23,13 @@ function getTransporter() {
return transporter
}
/** Vorlage rendern (server/mail-templates); Unstimmigkeiten landen im Log, die Mail geht trotzdem raus. */
function render({ template, values = {}, blocks = {} }) {
const mail = renderMail(template, values, blocks)
for (const problem of mail.problems) console.warn(`[mailer] Vorlage ${template}: ${problem}`)
return mail
}
// Throttle: pro Kunde maximal eine Benachrichtigung je Zeitfenster,
// damit ein aktiver Chat nicht pro Nachricht eine Mail ausloest.
const lastNotifyByCustomer = new Map() // customerId -> timestamp
@@ -37,18 +49,16 @@ export async function sendChatNotification(customer, text) {
if (now - last < throttleMs) return { sent: false, reason: 'throttled' }
lastNotifyByCustomer.set(customer.$id, now)
const name = customer.name || customer.email || 'Kunde'
const mail = render({
template: 'T01',
values: { kunde_name: customer.name || customer.email || 'Kunde', nachricht: text },
})
await mailer.sendMail({
from: `"WEBklar Kundenbereich" <${config.smtp.from}>`,
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
to: config.chatNotify.emails.join(', '),
subject: `Neue Portalnachricht von ${name}`,
text: [
`${name} hat im Kundenportal geschrieben:`,
'',
text,
'',
'Antworten: https://project.webklar.com/admin.html (Tab Chat)',
].join('\n'),
subject: mail.subject,
text: mail.text,
html: mail.html,
})
return { sent: true }
} catch (err) {
@@ -61,16 +71,22 @@ export async function sendChatNotification(customer, text) {
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
// ---------------------------------------------------------------------------
/** Mail an die Webklar-Admins (Abo-Ereignisse). Fehler werden nur geloggt. */
export async function sendAdminNotice(subject, lines) {
/**
* Hinweis an das WEBklar-Team (Abo-Ereignisse) aus einer T-Vorlage; der Betreff steht in der Vorlage.
* Fehler werden nur geloggt.
* @param {{ template: string, values?: object, blocks?: object }} message
*/
export async function sendAdminNotice(message) {
try {
const mailer = getTransporter()
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
const mail = render(message)
await mailer.sendMail({
from: `"WEBklar Kundenbereich" <${config.smtp.from}>`,
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
to: config.chatNotify.emails.join(', '),
subject: `[Abo] ${subject}`,
text: lines.join('\n'),
subject: mail.subject,
text: mail.text,
html: mail.html,
})
return { sent: true }
} catch (err) {
@@ -80,20 +96,25 @@ export async function sendAdminNotice(subject, lines) {
}
/**
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
* Mail an Kunden aus einer K-Vorlage (HTML mit Textfassung), Kopie per BCC ans Team.
* `attachments` fuer die Vertragsbestaetigung auf dauerhaftem Datentraeger (§ 312f BGB):
* Vertragsunterlagen als PDF.
* Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger).
* @param {string} to
* @param {{ template: string, values?: object, blocks?: object, attachments?: object[] }} message
*/
export async function sendCustomerMail(to, subject, lines, attachments = []) {
export async function sendCustomerMail(to, { attachments = [], ...message }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
const mail = render(message)
return mailer.sendMail({
from: `"WEBklar" <${config.smtp.from}>`,
from: `"${CUSTOMER_SENDER}" <${config.smtp.from}>`,
replyTo: config.smtp.replyTo || undefined,
to,
bcc: config.chatNotify.emails.join(', ') || undefined,
subject,
text: lines.join('\n'),
subject: mail.subject,
text: mail.text,
html: mail.html,
attachments,
})
}
+24 -37
View File
@@ -23,7 +23,8 @@ const COLLECTION = () => config.collections.aboOffers
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
function httpError(status, message) {
const err = new Error(message)
@@ -256,40 +257,21 @@ export function validateOfferInput(body) {
*/
async function mailOffer(doc, customer, nextChargeAt = null) {
const running = doc.scope === 'laufend'
const lines = [
`Guten Tag ${customer.name || ''}`.trim() + ',',
'',
running
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${nextChargeAt ? ` am ${fmtDate(nextChargeAt)}` : ''}:`
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
'',
` Angebot: ${offerText(doc)}`,
` Ihre Ersparnis: ${euro(savingsOf(doc))}`,
...(running ? [] : [` Gültig bis: ${fmtDate(doc.validUntil)}`]),
...(doc.note ? [` Hinweis: ${doc.note}`] : []),
'',
...(running
? ['Der Rabatt wird bei den Abbuchungen automatisch berücksichtigt und erscheint auf Ihren Rechnungen.']
: [
`Sie können das Abo zu diesem Preis bis zum ${fmtDate(doc.validUntil)} im Kundenportal abschließen:`,
`${config.publicBaseUrl}/dashboard.html#abo`,
'',
'Es gelten unsere Vertragsbedingungen (Mindestlaufzeit 12 Monate, danach monatlich kündbar; gemäß § 19 UStG',
`ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`,
]),
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
]
const values = {
name: customer.name || '',
angebot_text: offerText(doc),
ersparnis: euro(savingsOf(doc)),
hinweis: doc.note || '',
// K06 (laufendes Abo): ab wann der Rabatt gilt; K05: bis wann das Angebot angenommen werden kann
...(running ? { naechste_abbuchung: fmtDate(nextChargeAt) } : { gueltig_bis: fmtDate(doc.validUntil) }),
}
const ref = shortRef(doc.$id)
try {
const info = await sendCustomerMail(
customer.email,
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
lines
)
const info = await sendCustomerMail(customer.email, {
template: running ? 'K06' : 'K05',
values,
blocks: { hinweis: Boolean(doc.note) },
})
// Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg
const to = String(customer.email).toLowerCase()
if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) {
@@ -435,10 +417,15 @@ export async function activateOfferFromCheckout(session) {
const doc = await getDocument(COLLECTION(), offerId).catch(() => null)
if (!doc || doc.status === OFFER_STATUS.active) return doc
if (doc.status !== OFFER_STATUS.open) {
await sendAdminNotice(`Angebot ${shortRef(offerId)} war beim Abschluss nicht mehr offen`, [
`Abo ${subscriptionId} wurde mit dem Angebot „${offerText(doc)}“ abgeschlossen, das Angebot ist aber „${doc.status}“.`,
'Bitte in Stripe prüfen, ob der Rabatt am Abo hängt, und ggf. korrigieren.',
])
await sendAdminNotice({
template: 'T03',
values: {
angebot_nr: shortRef(offerId),
stripe_abo_id: subscriptionId,
angebot_text: offerText(doc),
angebot_status: OFFER_STATUS_LABEL[doc.status] || doc.status,
},
})
return null
}
if (session.metadata?.offerDeferred === '1') {
+51 -88
View File
@@ -48,7 +48,6 @@ export const REQUIRED_CHECKS = [
{ key: 'replaced', label: 'Die Leistung wird durch das Wartungs-Abo ersetzt (Umzug zu WEBklar)' },
]
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
const DAY_MS = 24 * 60 * 60 * 1000
function httpError(status, message) {
@@ -430,44 +429,35 @@ export async function createTakeoverRequest({ customer, input, uploads, consentI
}
const name = customer.name || customer.companyName || input.email
const summary = [
` Antrag: ${shortRef(doc.$id)}`,
` Leistung: ${servicesLabel(doc.services)}`,
` Bisher bei: ${doc.provider}`,
doc.domain ? ` Domain: ${doc.domain}` : null,
` Rechnung: ${fmtEuro(doc.invoiceAmountCents)} für ${monthsLabel(doc.billingMonths)}`,
` Bezahlt bis: ${fmtDate(doc.paidUntil)}`,
` Nachweise: ${files.map((f) => f.name).join(', ')}`,
].filter((l) => l !== null)
const summary = {
antrag_nr: shortRef(doc.$id),
leistung: servicesLabel(doc.services),
bisheriger_anbieter: doc.provider,
domain: doc.domain || '–',
rechnung_betrag: fmtEuro(doc.invoiceAmountCents),
rechnung_zeitraum: monthsLabel(doc.billingMonths),
bezahlt_bis: fmtDate(doc.paidUntil),
nachweise: files.map((f) => f.name).join(', '),
}
await sendCustomerMail(input.email, 'Eingangsbestätigung: Ihr Antrag auf Übernahmegutschrift – WEBklar', [
`Guten Tag ${customer.name || ''}`.trim() + ',',
'',
`wir haben Ihren Antrag auf eine Übernahmegutschrift am ${fmtDateTime(now)} erhalten.`,
'',
...summary,
'',
'Wir prüfen Ihre Angaben und den Nachweis und antworten Ihnen per E-Mail an diese Adresse.',
`Den Stand Ihres Antrags sehen Sie auch im Kundenportal: ${config.publicBaseUrl}/dashboard.html#gutschrift`,
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
]).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message))
await sendCustomerMail(input.email, {
template: 'K07',
values: { name: customer.name || '', eingang_zeitpunkt: fmtDateTime(now), ...summary },
}).catch((e) => console.error('[takeover] Eingangsbestaetigung fehlgeschlagen:', e.message))
// Nachweise bewusst nicht als Anhang: sie bleiben auf dem Server (Datenschutzerklaerung Abschnitt 7)
await sendAdminNotice(`Übernahmegutschrift beantragt: ${name}`, [
`${name}${customer.code ? ` (Kd.-Nr. ${customer.code})` : ''} hat eine Übernahmegutschrift beantragt.`,
`Antwort an: ${input.email}`,
'',
await sendAdminNotice({
template: 'T10',
values: {
kunde_name: name,
kundennummer: customer.code || '–',
email: input.email,
...summary,
'',
'Grund / Nachricht des Kunden:',
input.message,
'',
`Nachweise ansehen, prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
])
nachricht: input.message,
admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
},
blocks: { nachricht: Boolean(input.message) },
})
return doc
}
@@ -486,15 +476,17 @@ export async function answerQuestion({ customer, doc, message, uploads }) {
history: withHistory(doc, { type: 'antwort', by: 'customer', text, files: added.map((f) => f.id) }),
})
const name = customer.name || customer.companyName || doc.email
await sendAdminNotice(`Übernahmegutschrift: Antwort von ${name}`, [
`${name} hat auf die Rückfrage zum Antrag ${shortRef(doc.$id)} geantwortet:`,
'',
text || '(ohne Text)',
...(added.length ? ['', `Neue Nachweise: ${added.map((f) => f.name).join(', ')}`] : []),
'',
`Prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
])
await sendAdminNotice({
template: 'T11',
values: {
kunde_name: customer.name || customer.companyName || doc.email,
antrag_nr: shortRef(doc.$id),
antwort: text || '(ohne Text)',
neue_nachweise: added.map((f) => f.name).join(', '),
admin_link: `${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
},
blocks: { neue_nachweise: added.length > 0 },
})
return updated
}
@@ -804,57 +796,28 @@ export async function applyTakeoverCredit(doc) {
}
}
function customerGreeting(doc, customer) {
return `Guten Tag ${customer?.name || ''}`.trim() + ','
}
async function mailDecision(doc, customer, action) {
const ref = shortRef(doc.$id)
const submitted = fmtDate(doc.$createdAt)
const common = { name: customer?.name || '', antrag_datum: fmtDate(doc.$createdAt), antrag_nr: shortRef(doc.$id) }
const note = String(doc.decisionNote || '').trim()
const footer = ['', 'Viele Grüße', 'Ihr WEBklar-Team', '', FOOTER]
if (action === 'approve') {
return sendCustomerMail(doc.email, 'Ihre Übernahmegutschrift ist genehmigt – WEBklar', [
customerGreeting(doc, customer),
'',
`wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} geprüft und genehmigt.`,
'',
` Antrag: ${ref}`,
` Leistung: ${servicesLabel(doc.services)} (bisher bei ${doc.provider})`,
` Gutschrift: ${fmtEuro(doc.amountCents)}`,
` Berechnung: ${doc.calculation}`,
...(note ? ['', note] : []),
'',
'Die Gutschrift verrechnen wir automatisch mit den nächsten Monatsgebühren Ihres Wartungs-Abos.',
'Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben. Eine Auszahlung ist nicht möglich;',
'ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
...footer,
])
return sendCustomerMail(doc.email, {
template: 'K09',
values: {
...common,
gutschrift_betrag: fmtEuro(doc.amountCents),
leistung: servicesLabel(doc.services),
bisheriger_anbieter: doc.provider,
berechnung: doc.calculation || '–',
anmerkung: note,
},
blocks: { anmerkung: Boolean(note) },
})
}
if (action === 'reject') {
return sendCustomerMail(doc.email, 'Ihr Antrag auf Übernahmegutschrift – WEBklar', [
customerGreeting(doc, customer),
'',
`wir haben Ihren Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) geprüft und können ihn leider nicht genehmigen.`,
'',
note,
'',
'Bei Fragen antworten Sie einfach auf diese E-Mail.',
...footer,
])
return sendCustomerMail(doc.email, { template: 'K10', values: { ...common, begruendung: note } })
}
const question = [...historyOf(doc)].reverse().find((h) => h.type === 'rueckfrage')?.text || ''
return sendCustomerMail(doc.email, 'Rückfrage zu Ihrem Antrag auf Übernahmegutschrift – WEBklar', [
customerGreeting(doc, customer),
'',
`zu Ihrem Antrag auf Übernahmegutschrift vom ${submitted} (${ref}) haben wir eine Rückfrage:`,
'',
question,
'',
`Bitte antworten Sie im Kundenportal unter ${config.publicBaseUrl}/dashboard.html#gutschrift – dort können Sie`,
'auch weitere Nachweise hochladen. Alternativ antworten Sie einfach auf diese E-Mail.',
...footer,
])
return sendCustomerMail(doc.email, { template: 'K08', values: { ...common, rueckfrage_text: question } })
}
/**
+131
View File
@@ -0,0 +1,131 @@
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(/<title>([\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 &lt;b&gt;&amp;&quot;&#39; hat im Kundenportal geschrieben/)
assert.match(mail.html, /Zeile 1<br>Zeile &lt;2&gt;/)
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&amp;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 &rarr;</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)), [])
})