From 19abac03734601b5e1db0e1ce4a0abbc04bf61b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:13:46 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge=20L5:=20Mails=20zur=20Auftragsbes?= =?UTF-8?q?t=C3=A4tigung=20(K31=20ohne=20BCC=20mit=20Teamkopie=20ohne=20Li?= =?UTF-8?q?nk,=20K32,=20T22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/mails.js | 161 +++++++++++++++++++++++ test/auftraegeBestaetigungHilfe.js | 44 +++++++ test/auftraegeMails.test.js | 204 +++++++++++++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 server/services/auftraege/mails.js create mode 100644 test/auftraegeBestaetigungHilfe.js create mode 100644 test/auftraegeMails.test.js diff --git a/server/services/auftraege/mails.js b/server/services/auftraege/mails.js new file mode 100644 index 0000000..c0247bf --- /dev/null +++ b/server/services/auftraege/mails.js @@ -0,0 +1,161 @@ +/** + * 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`) +} + +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.message}`) + } + await sendCustomerMail(adresse, { + ...k31Mail(auftrag, { token, erinnerung }), + attachments: [ + { filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' }, + { filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' }, + ], + bccTeam: false, + }) + const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true })) + if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet: ${kopie.reason}`) +} + +/** 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.message}`) + } + } + try { + const t22 = await sendAdminNotice(t22Mail(auftrag)) + if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet: ${t22.reason}`) + } catch (err) { + console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`) + } +} diff --git a/test/auftraegeBestaetigungHilfe.js b/test/auftraegeBestaetigungHilfe.js new file mode 100644 index 0000000..91919f3 --- /dev/null +++ b/test/auftraegeBestaetigungHilfe.js @@ -0,0 +1,44 @@ +import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js' +import { hashToken } from '../server/services/auftraege/token.js' + +// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau. +// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv). +const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes +export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) } +export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) } + +export const TOKEN = 'T'.repeat(43) +export const TOKEN_NEU = 'U'.repeat(43) +export const SHA = 'a'.repeat(64) +export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.' + +export const kunde = (kundenart = 'privat', extra = {}) => ({ + name: 'Erika Muster', + firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '', + strasse: 'Hauptstr. 1', + plz: '67722', + ort: 'Winnweiler', + land: 'DE', + email: 'k@example.invalid', + kundenart, + kundennummer: 'K-100', + ...extra, +}) + +/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */ +export const abDokument = (over = {}) => ({ + id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet', + kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [], + art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', + ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', + pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA, + gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '', + tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z', + bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', + storniertAm: null, widerrufenAm: null, + erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', + ...over, +}) diff --git a/test/auftraegeMails.test.js b/test/auftraegeMails.test.js new file mode 100644 index 0000000..d1131ed --- /dev/null +++ b/test/auftraegeMails.test.js @@ -0,0 +1,204 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort. +process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-')) +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js') +const { euroDe } = await import('../server/services/finance/format.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js') +const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') +const { speicherePdf } = await import('../server/services/auftraege/ablage.js') +const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js') + +const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-')) +const AB_PDF = Buffer.from('%PDF-1.4 AB-Test') +const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test') +writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF) +setzeAgbVerzeichnisFuerTests(agbVerzeichnis) +after(() => { + setzeAgbVerzeichnisFuerTests(null) + setMailTransportForTests(null) +}) + +let sink +let protokoll +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + protokoll = mock.method(console, 'error', () => {}) +}) +afterEach(() => mock.restoreAll()) +const adressen = (liste) => liste.map((a) => a.address) + +const AB = abDokument() +const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') } +const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over }) +const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei' +const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' +const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' } + +test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-.pdf; eine ungültige Fassung wirft', () => { + setzeAgbVerzeichnisFuerTests(null) + try { + const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url)) + assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`)) + assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf')) + for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung) + } finally { + setzeAgbVerzeichnisFuerTests(agbVerzeichnis) + } +}) + +test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => { + assert.deepEqual(k31Mail(AB, { token: TOKEN }), { + template: 'K31', + values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` }, + blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true }, + }) + // auch wenn man das Token mitgibt: die Teamkopie enthält es nie + assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), { + template: 'K31', + values: { ...WERTE_K31, link: '' }, + blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false }, + }) + const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true }) + assert.equal(nachts.values.datum, '07.10.2026') + assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`) + assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true }) +}) + +test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => { + const ja = BESTAETIGT({ fruehbeginn: true }) + assert.deepEqual(k32Mail(ja), { + template: 'K32', + values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT }, + blocks: { fruehbeginn: true, ohne_fruehbeginn: false }, + }) + const nein = BESTAETIGT({ bestaetigtUeber: 'portal' }) + assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026') + assert.equal(k32Mail(nein).values.fruehbeginn_text, '') + assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true }) + const firma = BESTAETIGT({ kunde: kunde('unternehmen') }) + assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false }) + assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', '']) + assert.deepEqual(t22Mail(nein), { + template: 'T22', + values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' }, + blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true }, + }) + assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH') + assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink') + assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false }) +}) + +test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => { + const nachrichten = [ + k31Mail(AB, { token: TOKEN }), + k31Mail(AB, { token: TOKEN, teamkopie: true }), + k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }), + k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }), + k32Mail(BESTAETIGT({ fruehbeginn: true })), + k32Mail(BESTAETIGT()), + t22Mail(BESTAETIGT({ fruehbeginn: true })), + t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })), + ] + for (const { template, values, blocks } of nachrichten) { + const vorlage = loadTemplate(template) + assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template) + assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template) + assert.deepEqual(renderMail(template, values, blocks).problems, [], template) + } +}) + +test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => { + await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }) + assert.equal(sink.sent.length, 2) + const [kundenmail, teamkopie] = sink.sent + assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid']) + assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink') + assert.equal(kundenmail.subject, K31_BETREFF) + assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`)) + assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']]) + assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF)) + assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF)) + assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails) + assert.equal(teamkopie.subject, K31_BETREFF) + assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.')) + assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem') + const geheim = JSON.stringify(teamkopie) + for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`) +}) + +test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => { + const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF) + await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true }) + assert.equal(sink.sent.length, 2) + for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt')) + assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF)) + assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN)) +}) + +test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => { + await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/) + await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError) + await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/) + assert.equal(sink.sent.length, 0) + let versuche = 0 + setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } }) + await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/) + assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie') +}) + +test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => { + let aufrufe = 0 + setMailTransportForTests({ + async sendMail(mail) { + aufrufe += 1 + if (aufrufe === 2) throw new Error('SMTP 451') + return sink.sendMail(mail) + }, + }) + await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }) + assert.equal(aufrufe, 2) + assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']]) + assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001'))) +}) + +test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => { + await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true })) + assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF]) + const [k32, t22] = sink.sent + assert.deepEqual(adressen(k32.to), ['k@example.invalid']) + assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails) + assert.deepEqual(adressen(t22.to), config.chatNotify.emails) + assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr')) + assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`)) +}) + +test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => { + let aufrufe = 0 + setMailTransportForTests({ + async sendMail(mail) { + aufrufe += 1 + if (aufrufe === 1) throw new Error('SMTP 451') + return sink.sendMail(mail) + }, + }) + await sendeBestaetigtMails(BESTAETIGT()) + assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF]) + assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001'))) + sink.sent.length = 0 + setMailTransportForTests(sink) + await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) })) + assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF]) +})