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
+657 -316

No files matched your search

+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() })
})