diff --git a/README.md b/README.md
index 5312ea5..cdcd09c 100644
--- a/README.md
+++ b/README.md
@@ -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
+(` … `). 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
diff --git a/public/webklar-logo.png b/public/webklar-logo.png
new file mode 100644
index 0000000..a3afab8
Binary files /dev/null and b/public/webklar-logo.png differ
diff --git a/server/mail-templates/K11 Passwort geaendert.html b/server/mail-templates/K11 Passwort geaendert.html
new file mode 100644
index 0000000..30081a6
--- /dev/null
+++ b/server/mail-templates/K11 Passwort geaendert.html
@@ -0,0 +1,50 @@
+
+
+
+
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
-->
@@ -38,7 +38,19 @@ a{color:#111111;}
| Kundennummer | {{kundennummer}} |
-| Stripe-Abo | {{stripe_abo_id}} (auf Kündigung gesetzt) |
| Abo-Beginn | {{abo_beginn}} |
| Widerrufsfrist bis | {{fristende}}, 24 Uhr |
| Eingang | {{rechtzeitig}} |
+
+| Stripe-Abo | {{stripe_abo_id}} (auf Kündigung gesetzt) |
| Abo-Beginn | {{abo_beginn}} |
| Widerrufsfrist bis | {{fristende}}, 24 Uhr |
| Eingang | {{rechtzeitig}} |
+
+
+
+
+Achtung KEIN aktives Abo zu dieser E-Mail gefunden. Bitte von Hand zuordnen und die Widerrufsfrist prüfen. |
|
+
+
+
+Achtung Abo {{stripe_abo_id}} konnte NICHT automatisch auf Kündigung gesetzt werden. Bitte in Stripe von Hand kündigen. |
|
+
+
Achtung Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung). |
|
| |
diff --git a/server/routes/abo.js b/server/routes/abo.js
index 9a589f7..07c2399 100644
--- a/server/routes/abo.js
+++ b/server/routes/abo.js
@@ -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() })
})
diff --git a/server/services/customerPassword.js b/server/services/customerPassword.js
index 2d59ba9..32b9553 100644
--- a/server/services/customerPassword.js
+++ b/server/services/customerPassword.js
@@ -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) },
+ })
}
/**
diff --git a/server/services/mailTemplates.js b/server/services/mailTemplates.js
new file mode 100644
index 0000000..ec01dfa
--- /dev/null
+++ b/server/services/mailTemplates.js
@@ -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 -> ...
+ * 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(/\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'
+ )
+}
diff --git a/server/services/mailer.js b/server/services/mailer.js
index 41a219c..5bca5d7 100644
--- a/server/services/mailer.js
+++ b/server/services/mailer.js
@@ -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,
})
}
diff --git a/server/services/offers.js b/server/services/offers.js
index e1a55a8..961a462 100644
--- a/server/services/offers.js
+++ b/server/services/offers.js
@@ -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') {
diff --git a/server/services/takeoverCredits.js b/server/services/takeoverCredits.js
index d3381e2..7ef03f9 100644
--- a/server/services/takeoverCredits.js
+++ b/server/services/takeoverCredits.js
@@ -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}`,
- '',
- ...summary,
- '',
- 'Grund / Nachricht des Kunden:',
- input.message,
- '',
- `Nachweise ansehen, prüfen und entscheiden: ${config.publicBaseUrl}/admin.html?gutschrift=${encodeURIComponent(doc.$id)}`,
- ])
+ await sendAdminNotice({
+ template: 'T10',
+ values: {
+ kunde_name: name,
+ kundennummer: customer.code || '–',
+ email: input.email,
+ ...summary,
+ 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 } })
}
/**
diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js
new file mode 100644
index 0000000..b2c1ca5
--- /dev/null
+++ b/test/mailTemplates.test.js
@@ -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(/([\s\S]*?)<\/title>/)[1]
+ assert.equal(title, tpl.subject, 'Betreff im Kopf und 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(//g)].map((m) => m[1])
+ const closed = [...tpl.body.matchAll(//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, / |