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

+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') {