Source: errorHander.js

import nodemailer from 'nodemailer'

/**
 * Fehler-Meldung mit zwei Stufen — statt eines Sammel-Etiketts.
 *
 * **Warum das geaendert wurde (26.09.):** `server.js` ersetzte global
 * `console.error` durch dieses Modul, und der Ersatz schrieb pauschal
 * `"Fatal error"`. Damit hiess **jeder** Fehler im Prozess „Fatal" — auch eine
 * abgelaufene Browser-Session (`jwt expired`, im Dev-Log 18-mal) oder eine
 * fehlgeschlagene PDF-Vorschau. Zur Fehlersuche war das Rauschen in genau der
 * Datei, in der man nach Signalen sucht.
 *
 * Zweites Problem: `server.js` setzt in Produktion `console.log` auf eine leere
 * Funktion. Da der alte Ersatz ueber `console.log` schrieb, verschwand die Zeile
 * dort komplett — blieb die Mail unkonfiguriert, war der Fehler **unsichtbar**.
 *
 * Drittes Problem: der Mail-Versand hing am globalen `console.error`. Jeder der
 * 34 `console.error`-Aufrufe im Code loeste damit eine Mail aus — und
 * `errorLogger` (jeder protokollierte Fehler) ueber den Hijack ebenfalls. Ohne
 * Drosselung und ohne `verify()`.
 *
 * Jetzt gilt:
 * - {@link logError} — Betriebsfehler (Request, Upload, Auth). Geht nach stderr,
 *   **keine** Mail.
 * - {@link logFatal} — echter Fatal (Startfehler). Geht nach stderr **und**
 *   mailt, wenn konfiguriert und nicht gedrosselt.
 *
 * `console.error` bleibt unangetastet: Node schreibt es nach stderr, und stderr
 * wird durch die `console.log`-Abschaltung in Produktion **nicht** beruehrt.
 *
 * Der Dateiname ist bewusst unveraendert (`errorHander` ohne „l"): er steht in
 * generierten Dokus (`docuviewer/`, `documentation/`). Eine Umbenennung waere
 * nur Doku-Drift.
 *
 * Der Datei-Log bleibt die dauerhafte Spur: `errorLogger` schreibt zusaetzlich
 * nach `logs/db/error.log` (rotierend, siehe `utils/requestLogger.js`).
 */

/**
 * Liest die Mail-Konfiguration — oder `null`, wenn sie unvollstaendig ist.
 *
 * Der alte Guard lautete
 * `if(process.env.mailHost && process.env.mailPort && process.env.mailUser && process.env.mailPassword, process.env.errorMail)`.
 * Das ist ein **Komma-Operator**: der linke Ausdruck wird ausgewertet und
 * verworfen, geprueft wird nur `errorMail`. Eine halb gesetzte Konfiguration
 * lief damit in `createTransport` und warf erst beim Senden.
 *
 * @returns {{host: string, port: string, user: string, password: string, to: string} | null}
 */
const mailConfig = () => {
    const host = process.env.mailHost
    const port = process.env.mailPort
    const user = process.env.mailUser
    const password = process.env.mailPassword
    const to = process.env.errorMail

    if (!host || !port || !user || !password || !to) return null
    return { host, port, user, password, to }
}

/** Zeitpunkt der letzten Fehler-Mail (ms). */
let lastMailAt = 0

/**
 * Macht Fehler mailbar.
 *
 * `JSON.stringify(new Error('x'))` ergibt `{}` — `message` und `stack` sind
 * nicht-enumerable und fielen damit aus dem Mailtext heraus. Die Fehler-Mail
 * enthielt deshalb nur `[{}]` und war als Meldung wertlos.
 *
 * @param {unknown[]} errors
 * @returns {string}
 */
const serializeErrors = (errors) => JSON.stringify(errors, (_key, value) => {
    if (value instanceof Error) {
        return { name: value.name, message: value.message, stack: value.stack }
    }
    return value
}, 2)

/**
 * Mindestabstand zwischen zwei Fehler-Mails.
 *
 * Ein Fehlersturm darf kein Mailsturm werden: `errorLogger` wird pro
 * fehlgeschlagenem Request gerufen, und ein einziger kaputter Aufrufpfad kann
 * das im Sekundentakt ausloesen.
 */
const MAIL_THROTTLE_MS = 60_000

/**
 * Verschickt eine Fehler-Mail — fire-and-forget.
 *
 * Ein Fehler im Mailer darf die Fehlerbehandlung nicht aufhalten oder gar
 * werfen, deshalb ist alles umschlossen und der Versand wird nicht abgewartet.
 *
 * @param {{host: string, port: string, user: string, password: string, to: string}} cfg
 * @param {string} label - `FATAL` (nur hierfuer wird gemailt)
 * @param {unknown[]} errors
 * @returns {void}
 */
const sendErrorMail = (cfg, label, errors) => {
    const now = Date.now()
    if (now - lastMailAt < MAIL_THROTTLE_MS) return
    lastMailAt = now

    try {
        const transporter = nodemailer.createTransport({
            host: cfg.host,
            port: cfg.port,
            secure: true, // true for 465, false for other ports
            auth: {
                user: cfg.user,
                pass: cfg.password
            }
        })

        transporter.sendMail({
            from: {
                name: process.env.mailUserName,
                address: cfg.user
            },
            replyTo: {
                address: cfg.to
            },
            to: cfg.to,
            subject: `!!!!kpe20 error report kpe20${process.env.baseUrl ?? ''} [${label}]`,
            text: serializeErrors(errors)
        })
        .catch(mailErr => {
            console.error('[errorHandler] Fehler-Mail konnte nicht gesendet werden:', mailErr)
        })
    } catch (mailSetupErr) {
        console.error('[errorHandler] Fehler-Mail konnte nicht aufgebaut werden:', mailSetupErr)
    }
}

/**
 * Betriebsfehler: protokollieren, **nicht** mailen.
 *
 * @param {...unknown} err
 * @returns {void}
 */
export const logError = (...err) => {
    console.error('[ERROR]', ...err)
}

/**
 * Echter Fatal: protokollieren **und** mailen (gedrosselt, wenn konfiguriert).
 *
 * Gedacht fuer den Startpfad — wenn der Server nicht hochkommt, ist das die
 * einzige Meldung, die jemand mitbekommt.
 *
 * @param {...unknown} err
 * @returns {void}
 */
export const logFatal = (...err) => {
    console.error('[FATAL]', ...err)

    const cfg = mailConfig()
    if (cfg) sendErrorMail(cfg, 'FATAL', err)
}

// Standard-Export bleibt `logError`: er ist die richtige Stufe fuer einen
// generischen Fehler-Sink und haelt bestehende Importe gueltig.
export default logError