Files
Webklar-Kundenbereich/server/services/mailer.js
T
2026-10-07 14:12:47 +00:00

236 lines
9.6 KiB
JavaScript

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
let testTransport = null
/**
* Nur für Tests (F25): alle Mails über diesen Transport statt per SMTP, z. B. mailSink() aus
* test/support/mailSink.js (nodemailer jsonTransport, gesendete Mails gesammelt). null schaltet zurück.
* Gerendert wird wie im Betrieb; so lassen sich Vorlagen, Blöcke und Anhänge in Routentests prüfen.
*/
export function setMailTransportForTests(transport) {
testTransport = transport || null
}
function getTransporter() {
if (testTransport) return testTransport
if (!config.smtp.host || !config.smtp.user) return null
if (!transporter) {
transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
// Port 587: ohne STARTTLS nicht senden (Hetzner erlaubt die Anmeldung auch unverschluesselt)
requireTLS: !config.smtp.secure,
auth: { user: config.smtp.user, pass: config.smtp.pass },
})
}
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
/**
* Benachrichtigt die Admins per E-Mail ueber eine neue Portal-Chatnachricht.
* Fehler werden nur geloggt - der Chat darf am Mailversand nie scheitern.
*/
export async function sendChatNotification(customer, text) {
try {
const mailer = getTransporter()
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
const now = Date.now()
const last = lastNotifyByCustomer.get(customer.$id) || 0
const throttleMs = config.chatNotify.throttleMin * 60 * 1000
if (now - last < throttleMs) return { sent: false, reason: 'throttled' }
lastNotifyByCustomer.set(customer.$id, now)
const mail = render({
template: 'T01',
values: {
kunde_name: customer.name || customer.email || 'Kunde',
nachricht: text,
// Knopf "Im Ticketsystem antworten": Messenger mit dem Gespraech dieses Kunden
team_link: `${config.womsBaseUrl}/customers/chat?kunde=${encodeURIComponent(customer.$id)}`,
},
})
await mailer.sendMail({
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
to: config.chatNotify.emails.join(', '),
subject: mail.subject,
text: mail.text,
html: mail.html,
})
return { sent: true }
} catch (err) {
console.error('[mailer] Chat-Benachrichtigung fehlgeschlagen:', err.message)
return { sent: false, reason: err.message }
}
}
// Gleiches Zeitfenster in Gegenrichtung: das Team schreibt, der Kunde bekommt K17
const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp
/**
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
* Wirft nie; das Ergebnis zeigt das Ticketsystem unter der Nachricht an.
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
*/
export async function sendCustomerChatNotice(customer, { sender, text }) {
const to = String(customer.email || '').trim()
if (!to) return { sent: false, reason: 'keine E-Mail-Adresse' }
if (!customer.portalAccessEnabled) return { sent: false, reason: 'kein Portalzugang' }
const now = Date.now()
const last = lastCustomerNoticeByCustomer.get(customer.$id) || 0
if (now - last < config.chatNotify.throttleMin * 60 * 1000) return { sent: false, reason: 'throttled', to }
try {
lastCustomerNoticeByCustomer.set(customer.$id, now)
await sendCustomerMail(to, {
template: 'K17',
bccTeam: false,
values: {
kunde_name: customer.name || to,
absender: sender || 'Dein WEBklar-Team',
nachricht: text,
portal_url: `${config.publicBaseUrl}/dashboard.html#chat`,
},
})
return { sent: true, to }
} catch (err) {
lastCustomerNoticeByCustomer.delete(customer.$id)
console.error(`[mailer] Chat-Hinweis an Kunde ${customer.$id} fehlgeschlagen:`, err.message)
return { sent: false, reason: err.message, to }
}
}
/** Nur fuer Tests: Zeitfenster der Kunden-Hinweise zuruecksetzen. */
export function resetChatNoticeThrottleForTests() {
lastCustomerNoticeByCustomer.clear()
}
// ---------------------------------------------------------------------------
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
// ---------------------------------------------------------------------------
/**
* 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: `"${TEAM_SENDER}" <${config.smtp.from}>`,
to: config.chatNotify.emails.join(', '),
subject: mail.subject,
text: mail.text,
html: mail.html,
})
return { sent: true }
} catch (err) {
console.error('[mailer] Admin-Hinweis fehlgeschlagen:', err.message)
return { sent: false, reason: err.message }
}
}
/**
* 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
* `bccTeam: false` laesst die Kopie ans Team weg (K17: das Team hat die Nachricht selbst geschrieben).
* @param {{ template: string, values?: object, blocks?: object, attachments?: object[], bccTeam?: boolean }} message
*/
export async function sendCustomerMail(to, { attachments = [], bccTeam = true, ...message }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
const mail = render(message)
return mailer.sendMail({
from: `"${CUSTOMER_SENDER}" <${config.smtp.from}>`,
replyTo: config.smtp.replyTo || undefined,
to,
bcc: (bccTeam && config.chatNotify.emails.join(', ')) || undefined,
subject: mail.subject,
text: mail.text,
html: mail.html,
attachments,
})
}
// Genau ein Postfach (Terminplaner: kein Mail-Relais ueber Listen oder Anzeigenamen), hoechstens 254 Zeichen
const SINGLE_MAILBOX = /^[^\s,;<>"\\]+@[^\s,;<>"\\]+$/
const checkMailbox = (value, name) => {
if (typeof value !== 'string' || value.length > 254 || !SINGLE_MAILBOX.test(value)) {
throw new TypeError(`${name}: genau eine E-Mail-Adresse erwartet`)
}
}
const oneLine = (value) => String(value ?? '').replace(/\s*[\r\n]+\s*/g, ' ').trim()
/**
* Terminplaner: Mail an einen Gast (Spec 2026-10-02 Abschnitt 6). Verschickt schon gerenderte Inhalte (Betreff, HTML, Text
* kommen aus termineOutbox/G-Vorlagen); Absendername = Betrieb, Adresse bleibt SMTP_FROM, Antworten gehen an `replyTo`
* (Betriebs-E-Mail), nie BCC. Mit `ics` haengt die Kalenderdatei als icalEvent (nodemailer-Kalenderteil, kein Anhang) an.
* Wirft bei fehlendem SMTP, ungueltiger Adresse oder Methode - der Aufrufer (Outbox) entscheidet ueber Wiederholung.
* @param {{ to: string, fromName: string, replyTo?: string|null, subject: string, html: string, text: string,
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH', attachments?: object[]|null (nodemailer, unveraendert, z. B. Rechnungs-PDF) }} message
*/
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST', attachments = null }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
checkMailbox(to, 'to')
if (replyTo) checkMailbox(replyTo, 'replyTo')
const name = oneLine(fromName)
const subj = oneLine(subject)
if (!name) throw new TypeError('fromName fehlt')
if (!subj) throw new TypeError('subject fehlt')
if (ics && !['REQUEST', 'CANCEL', 'PUBLISH'].includes(icsMethod)) throw new TypeError('icsMethod muss REQUEST, CANCEL oder PUBLISH sein')
return mailer.sendMail({
from: { name, address: config.smtp.from },
replyTo: replyTo || undefined,
to,
subject: subj,
text,
html,
icalEvent: ics ? { method: icsMethod, filename: 'termin.ics', content: ics } : undefined,
attachments: Array.isArray(attachments) && attachments.length ? attachments : undefined,
})
}
/**
* Mail an eine einzelne Person im Team aus einer T-Vorlage (z. B. T15 Push-Zeitmail), ohne BCC.
* Wirft bei fehlendem SMTP oder Versandfehler - der Aufrufer entscheidet, ob das schlimm ist.
* @param {string} to
* @param {{ template: string, values?: object, blocks?: object }} message
*/
export async function sendStaffMail(to, message) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
const mail = render(message)
return mailer.sendMail({
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
to,
subject: mail.subject,
text: mail.text,
html: mail.html,
})
}