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 = {}, subjectPrefix = '' }) { const mail = renderMail(template, values, blocks) for (const problem of mail.problems) console.warn(`[mailer] Vorlage ${template}: ${problem}`) // optional: Text vor dem Betreff der Vorlage (z. B. "Kopie: " bei einer Teamkopie) return subjectPrefix ? { ...mail, subject: `${subjectPrefix}${mail.subject}` } : 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, }) }