Files
Webklar-Kundenbereich/server/services/mailer.js
T
2026-10-02 03:52:54 +00:00

197 lines
7.5 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,
})
}
/**
* 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,
})
}