import nodemailer from 'nodemailer'; import { one, run } from './db.js'; import { decrypt } from './crypto.js'; import { config } from './config.js'; export interface SmtpConfig { host: string; port: number; secureMode: 'none' | 'starttls' | 'tls'; username: string | null; fromName: string | null; fromEmail: string } export type MailCategory = 'kundencenter' | 'stoerungsticket' | 'rechnungen'; /** Lädt die SMTP-Verbindung aus der Datenbank (Einstellungen > E-Mail). Kein Host/Absender gesetzt = nicht eingerichtet. */ export async function loadSmtpConfig(): Promise { const s = await one('SELECT * FROM smtp_settings WHERE id = 1'); if (!s?.host || !s?.from_email) return null; return { host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, fromName: s.from_name, fromEmail: s.from_email }; } /** Absenderprofil je Bereich (Kundencenter/Störungsticket/Rechnungen); leere Felder fallen auf den globalen SMTP-Absender zurück. */ async function fromFor(cfg: SmtpConfig, category?: MailCategory): Promise<{ name: string | null; email: string }> { if (!category) return { name: cfg.fromName, email: cfg.fromEmail }; const p = await one('SELECT from_name, from_email FROM mail_sender_profiles WHERE category = ?', [category]); return { name: p?.from_name ?? cfg.fromName, email: p?.from_email ?? cfg.fromEmail }; } async function smtpPassword(): Promise { const s = await one('SELECT secrets_enc FROM smtp_settings WHERE id = 1'); return s?.secrets_enc ? (JSON.parse(decrypt(s.secrets_enc)).password ?? null) : null; } async function transportFor(cfg: SmtpConfig) { return nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secureMode === 'tls', requireTLS: cfg.secureMode === 'starttls', auth: cfg.username ? { user: cfg.username, pass: (await smtpPassword()) ?? '' } : undefined, }); } /** Versand mit Protokoll. Ohne SMTP-Konfiguration wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */ export async function sendMail(to: string, template: string, subject: string, text: string, opts: { category?: MailCategory; html?: string } = {}): Promise<'sent' | 'failed' | 'not_configured'> { const cfg = await loadSmtpConfig(); if (!cfg) { await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']); return 'not_configured'; } try { const transport = await transportFor(cfg); const from = await fromFor(cfg, opts.category); await transport.sendMail({ from: from.name ? `"${from.name.replace(/"/g, "'")}" <${from.email}>` : from.email, to, subject, text, html: opts.html }); await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']); return 'sent'; } catch (e) { await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]); return 'failed'; } } /** Ersetzt {{platzhalter}} durch die übergebenen Werte; unbekannte Platzhalter bleiben unverändert stehen (fällt in der Vorschau auf). */ export function renderTemplateText(tpl: string, vars: Record): string { return tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k]! : m)); } const escapeHtml = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** Beschriftung des Schaltflächen-Buttons, je nach Variablenname (endet auf "Link"). */ const LINK_LABEL: Record = { inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen' }; /** * Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Eine Variable, deren Name auf * "Link" endet, wird nicht als nackte URL im Text belassen, sondern als Schaltfläche ans Ende gesetzt (der * Klartext-Teil der Mail behält den Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht). */ export function renderTemplateHtml(tpl: string, vars: Record): string { const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]); const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars; const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m)) .replace(/https?:\/\/[^\s<]+/g, (url) => `${url}`); // weitere Links im Text bleiben klickbar, auch ohne eigenen Button const paragraphs = rendered.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `

${p.replace(/\n/g, '
')}

`).join(''); const button = linkKey ? `

${LINK_LABEL[linkKey] ?? 'Öffnen'}

` : ''; return `
Kundencenter
${paragraphs}${button}
`; } /** Sendet anhand einer in der Datenbank hinterlegten Vorlage (Einstellungen > E-Mail > Vorlagen). Der Absender richtet sich nach dem Bereich der Vorlage (Kundencenter/Störungsticket/Rechnungen). */ export async function sendTemplateMail(to: string, key: string, vars: Record): Promise<'sent' | 'failed' | 'not_configured' | 'disabled' | 'no_template'> { const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]); if (!t) return 'no_template'; if (!t.enabled) return 'disabled'; return sendMail(to, key, renderTemplateText(t.subject, vars), renderTemplateText(t.body, vars), { category: t.category as MailCategory, html: renderTemplateHtml(t.body, vars) }); } export const baseUrl = config.baseUrl;