/** * Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne * Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der * Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge. * Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF). */ import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { config } from '../../config.js' import { berlinDate, formatDe } from '../finance/dates.js' import { euroDe } from '../finance/format.js' import { sendAdminNotice, sendCustomerMail } from '../mailer.js' import { lesePdf } from './ablage.js' import { fruehesterBeginn } from './auftragSicht.js' import { AGB_FASSUNG } from './konstanten.js' import { FRUEHBEGINN_TEXT } from './texte.js' import { hashToken } from './token.js' // server/services/auftraege/ -> drei Ebenen nach oben = Repo const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url)) let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/ /** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */ export function setzeAgbVerzeichnisFuerTests(verzeichnis) { agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS } export function agbPdfPfad(fassung = AGB_FASSUNG) { if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung') return path.join(agbVerzeichnis, `agb-${fassung}.pdf`) } // Betreff-Zusaetze der K31 (die Vorlage selbst bleibt fuer alle Faelle gleich) const ERINNERUNG_PRAEFIX = 'Erinnerung: ' const KOPIE_PRAEFIX = 'Kopie: ' const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) function zeitpunkt(iso) { const datum = new Date(iso) if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig') return datum } const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso))) const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}` const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents)) const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim() export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) { const kunde = auftrag.kunde ?? {} return { template: 'K31', values: { name: kunde.name, nummer: auftrag.nummer, titel: auftrag.titel, datum: tagText(auftrag.gesendetAm), betrag: preisText(auftrag), // die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`, gueltig_bis: tagText(auftrag.tokenGueltigBis), }, blocks: { erinnerung: Boolean(erinnerung), privat: kunde.kundenart === 'privat' && !teamkopie, festpreis: auftrag.art === 'festpreis', aufwand: auftrag.art === 'aufwand', teamkopie: Boolean(teamkopie), kundenmail: !teamkopie, }, } } function frueherBeginn(auftrag) { const privat = auftrag.kunde?.kundenart === 'privat' const tag = fruehesterBeginn(auftrag) return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' } } export function k32Mail(auftrag) { const kunde = auftrag.kunde ?? {} const f = frueherBeginn(auftrag) return { template: 'K32', values: { name: kunde.name, nummer: auftrag.nummer, titel: auftrag.titel, zeitpunkt: zeitpunktText(auftrag.bestaetigtAm), fruehester_beginn: f.beginnText, fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '', }, blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne }, } } const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' } export function t22Mail(auftrag) { const kunde = auftrag.kunde ?? {} const f = frueherBeginn(auftrag) return { template: 'T22', values: { kunde: kunde.firma || kunde.name || kunde.email || '', nummer: auftrag.nummer, titel: auftrag.titel, zeitpunkt: zeitpunktText(auftrag.bestaetigtAm), ueber: UEBER[auftrag.bestaetigtUeber] ?? '', woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`, fruehester_beginn: f.beginnText, }, blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne }, } } /** * K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler * der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler. */ export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) { const adresse = empfaenger(auftrag) if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung') if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format') const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei) const fassung = auftrag.agbFassung || AGB_FASSUNG let agbPdf try { agbPdf = await readFile(agbPdfPfad(fassung)) } catch (err) { throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.name}`) } await sendCustomerMail(adresse, { ...k31Mail(auftrag, { token, erinnerung }), subjectPrefix: erinnerung ? ERINNERUNG_PRAEFIX : '', attachments: [ { filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' }, { filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' }, ], bccTeam: false, }) // Der Grund steht nicht im Protokoll: Meldungen des Mailservers nennen oft die Empfaengeradresse const kopie = await sendAdminNotice({ ...k31Mail(auftrag, { erinnerung, teamkopie: true }), subjectPrefix: `${KOPIE_PRAEFIX}${erinnerung ? ERINNERUNG_PRAEFIX : ''}` }) if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet`) } /** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */ export async function sendeBestaetigtMails(auftrag) { const adresse = empfaenger(auftrag) if (!adresse) { console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`) } else { try { await sendCustomerMail(adresse, k32Mail(auftrag)) } catch (err) { console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`) } } try { const t22 = await sendAdminNotice(t22Mail(auftrag)) if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet`) } catch (err) { console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err?.code || err?.name || 'Fehler'}`) } }