237 lines
9.8 KiB
JavaScript
237 lines
9.8 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 = {}, 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,
|
|
})
|
|
}
|