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:
1 parent
e3daffffa3
commit
18c3cdf3e5
11 files changed
+657
-316
No files matched your search
@@ -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) },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/** <!-- 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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user