Aufträge L5: Mails zur Auftragsbestätigung (K31 ohne BCC mit Teamkopie ohne Link, K32, T22)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:13:46 +00:00
1 parent f0402a7ea9
commit 19abac0373
3 files changed
+409

No files matched your search

+161
View File
@@ -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}`)
}
}
+44
View File
@@ -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,
})
+204
View File
@@ -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-<Fassung>.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])
})