Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 0c033ab1d2
commit 181445a69d
5 files changed
+514 -6

No files matched your search

+5 -4
View File
@@ -5,7 +5,8 @@ import { config } from '../config.js'
/**
* HTML-Mails aus den Vorlagen in server/mail-templates (Claude-Design-Projekt
* "Webklar Bestätigungsmail gestalten"; K = an Kunden, T = intern ans Team).
* "Webklar Bestätigungsmail gestalten"; K = an Kunden, T = intern ans Team, G = an Gäste unserer Kunden
* (Terminplaner, neutrales Layout ohne WEBklar-Logo)).
*
* Jede Vorlage beschreibt sich im Kopfkommentar selbst:
* Betreff: Text mit {{platzhaltern}}
@@ -23,10 +24,10 @@ const DESIGN_BASE_URL = 'https://project.webklar.com'
const cache = new Map()
/** IDs aller Vorlagen (K01, K02, ..., T11) */
/** IDs aller Vorlagen (G01, ..., K01, K02, ..., T11) */
export function templateIds() {
return readdirSync(TEMPLATE_DIR)
.filter((f) => /^[KT]\d{2} .*\.html$/.test(f))
.filter((f) => /^[GKT]\d{2} .*\.html$/.test(f))
.map((f) => f.slice(0, 3))
.sort()
}
@@ -43,7 +44,7 @@ export function loadTemplate(id) {
const file = readdirSync(TEMPLATE_DIR).find((f) => f.startsWith(`${id} `) && f.endsWith('.html'))
if (!file) throw new Error(`Mail-Vorlage ${id} fehlt`)
const source = readFileSync(path.join(TEMPLATE_DIR, file), 'utf8')
const header = source.match(/<!--[ \t]*\n\s*([KT]\d{2}) ·[\s\S]*?-->\n?/)
const header = source.match(/<!--[ \t]*\n\s*([GKT]\d{2}) ·[\s\S]*?-->\n?/)
if (header?.[1] !== id) throw new Error(`Mail-Vorlage ${file}: Kopfkommentar fehlt`)
const field = (label) => header[0].match(new RegExp(`^\\s*${label}[^:\\n]*:[ \\t]*(.*)$`, 'm'))?.[1]?.trim() || ''
const template = {
+38
View File
@@ -176,6 +176,44 @@ export async function sendCustomerMail(to, { attachments = [], bccTeam = true, .
})
}
// Genau ein Postfach (Terminplaner: kein Mail-Relais ueber Listen oder Anzeigenamen), hoechstens 254 Zeichen
const SINGLE_MAILBOX = /^[^\s,;<>"\\]+@[^\s,;<>"\\]+$/
const checkMailbox = (value, name) => {
if (typeof value !== 'string' || value.length > 254 || !SINGLE_MAILBOX.test(value)) {
throw new TypeError(`${name}: genau eine E-Mail-Adresse erwartet`)
}
}
const oneLine = (value) => String(value ?? '').replace(/\s*[\r\n]+\s*/g, ' ').trim()
/**
* Terminplaner: Mail an einen Gast (Spec 2026-10-02 Abschnitt 6). Verschickt schon gerenderte Inhalte (Betreff, HTML, Text
* kommen aus termineOutbox/G-Vorlagen); Absendername = Betrieb, Adresse bleibt SMTP_FROM, Antworten gehen an `replyTo`
* (Betriebs-E-Mail), nie BCC. Mit `ics` haengt die Kalenderdatei als icalEvent (nodemailer-Kalenderteil, kein Anhang) an.
* Wirft bei fehlendem SMTP, ungueltiger Adresse oder Methode - der Aufrufer (Outbox) entscheidet ueber Wiederholung.
* @param {{ to: string, fromName: string, replyTo?: string|null, subject: string, html: string, text: string,
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} message
*/
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
checkMailbox(to, 'to')
if (replyTo) checkMailbox(replyTo, 'replyTo')
const name = oneLine(fromName)
const subj = oneLine(subject)
if (!name) throw new TypeError('fromName fehlt')
if (!subj) throw new TypeError('subject fehlt')
if (ics && !['REQUEST', 'CANCEL', 'PUBLISH'].includes(icsMethod)) throw new TypeError('icsMethod muss REQUEST, CANCEL oder PUBLISH sein')
return mailer.sendMail({
from: { name, address: config.smtp.from },
replyTo: replyTo || undefined,
to,
subject: subj,
text,
html,
icalEvent: ics ? { method: icsMethod, filename: 'termin.ics', content: ics } : undefined,
})
}
/**
* Mail an eine einzelne Person im Team aus einer T-Vorlage (z. B. T15 Push-Zeitmail), ohne BCC.
* Wirft bei fehlendem SMTP oder Versandfehler - der Aufrufer entscheidet, ob das schlimm ist.
+135
View File
@@ -0,0 +1,135 @@
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Kalenderdateien des Terminplaners (iCalendar nach RFC 5545, Terminmitteilungen nach RFC 5546).
* Eigener kleiner Schreiber ohne Abhaengigkeit; Zeiten immer in UTC (`...Z`), damit keine VTIMEZONE noetig ist.
*
* - Zeilen enden mit CRLF und werden bei 75 Oktetten (UTF-8-Bytes, nie mitten in einem Zeichen) gefaltet.
* - TEXT-Werte (Titel, Ort, Beschreibung, Kalendername): `\`, `;`, `,` und Zeilenumbrueche werden maskiert, damit ein
* Wert nie eine neue Eigenschaft beginnen kann. Alles andere (UID, E-Mail, CN) verliert CR/LF und Steuerzeichen.
* - Pure Funktionen: kein config, keine Uhr; `jetzt` (DTSTAMP) kommt vom Aufrufer.
*
* UID: stabil je Buchung (`<bookingId>@termine.webklar.com`), SEQUENCE steigt bei jeder Aenderung des Termins.
*/
const PRODID = '-//WEBklar//Terminplaner//DE'
const METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH']
const encoder = new TextEncoder()
// Steuerzeichen ausser Tab (und LF/CR, die je nach Feld maskiert oder entfernt werden)
const STEUER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
const sauber = (wert) => String(wert ?? '').toWellFormed()
/** Wert ohne CR/LF und Steuerzeichen (UID, Parameter, E-Mail). */
const einzeilig = (wert) => sauber(wert).replace(/[\r\n]/g, '').replace(STEUER, '')
/** TEXT nach RFC 5545 3.3.11: Zeilenumbrueche werden zu `\n`, `\`, `;` und `,` bekommen einen Backslash. */
const text = (wert) =>
sauber(wert)
.replace(STEUER, '')
.replace(/[\\;,]/g, '\\$&')
.replace(/\r\n|[\r\n]/g, '\\n')
/** Parameterwert in Anfuehrungszeichen (CN): ohne `"`, CR/LF und Steuerzeichen. */
const parameter = (wert) => einzeilig(wert).replace(/"/g, '')
/** mailto-Adresse: genau ein Postfach, ohne Leerraum und Trennzeichen. */
const adresse = (wert) => einzeilig(wert).replace(/[\s<>",;\\]/g, '')
/** Zeitpunkt als `20261005T080000Z` (Sekundenbruchteile fallen weg). */
const utc = (ms) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
/** Eine logische Zeile bei 75 Oktetten falten; Folgezeilen beginnen mit einem Leerzeichen (zaehlt als Oktett). */
function falte(zeile) {
if (encoder.encode(zeile).length <= 75) return zeile
const teile = []
let aktuell = ''
let oktette = 0
for (const zeichen of zeile) {
const n = encoder.encode(zeichen).length
if (oktette + n > 75) {
teile.push(aktuell)
aktuell = ' '
oktette = 1
}
aktuell += zeichen
oktette += n
}
teile.push(aktuell)
return teile.join('\r\n')
}
function pruefeZeit(wert, name) {
const ms = zeitMs(wert)
if (!Number.isFinite(ms)) throw new TypeError(`${name} ist kein gueltiger Zeitpunkt`)
return ms
}
/** `;CN="Name"` (nur mit Name) und `mailto:`-Adresse einer Person; ohne Adresse kein gueltiger Eintrag. */
function person(p, feld) {
const email = adresse(p?.email)
pruefeText(email, `${feld}.email`)
const name = parameter(p.name)
return { cn: name ? `;CN="${name}"` : '', mailto: `mailto:${email}` }
}
/** Zeilen eines VEVENT. `methode` entscheidet ueber ATTENDEE (nicht bei PUBLISH) und STATUS. */
function veranstaltung(t, methode, jetztMs) {
pruefeText(einzeilig(t.uid), 'uid')
pruefeText(text(t.titel), 'titel')
const sequenz = t.sequenz ?? 0
if (!Number.isInteger(sequenz) || sequenz < 0) throw new TypeError('sequenz ist keine ganze Zahl ab 0')
const start = pruefeZeit(t.start, 'start')
const ende = pruefeZeit(t.ende, 'ende')
if (ende <= start) throw new TypeError('ende liegt nicht nach start')
const org = t.organisator
if (!org && methode !== 'PUBLISH') throw new TypeError(`organisator fehlt (${methode} braucht den Organisator)`)
const zeilen = ['BEGIN:VEVENT', `UID:${einzeilig(t.uid)}`, `DTSTAMP:${utc(jetztMs)}`, `SEQUENCE:${sequenz}`, `DTSTART:${utc(start)}`, `DTEND:${utc(ende)}`, `SUMMARY:${text(t.titel)}`]
if (text(t.beschreibung)) zeilen.push(`DESCRIPTION:${text(t.beschreibung)}`)
if (text(t.ort)) zeilen.push(`LOCATION:${text(t.ort)}`)
if (org) {
const o = person(org, 'organisator')
zeilen.push(`ORGANIZER${o.cn}:${o.mailto}`)
}
// RFC 5546: PUBLISH enthaelt keine Teilnehmer (Betriebsmail und Abo tragen keine Gaeste ein)
if (t.teilnehmer && methode !== 'PUBLISH') {
const g = person(t.teilnehmer, 'teilnehmer')
const partstat = methode === 'CANCEL' ? 'DECLINED' : 'ACCEPTED'
zeilen.push(`ATTENDEE${g.cn};ROLE=REQ-PARTICIPANT;PARTSTAT=${partstat};RSVP=FALSE:${g.mailto}`)
}
zeilen.push(`STATUS:${methode === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, 'TRANSP:OPAQUE', 'END:VEVENT')
return zeilen
}
function kalender(methode, termine, jetzt, kopf = []) {
const jetztMs = pruefeNow(jetzt, 'jetzt')
const zeilen = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${PRODID}`, 'CALSCALE:GREGORIAN', `METHOD:${methode}`, ...kopf]
for (const t of termine) zeilen.push(...veranstaltung(t, methode, jetztMs))
zeilen.push('END:VCALENDAR')
return `${zeilen.map(falte).join('\r\n')}\r\n`
}
/**
* Kalenderdatei mit genau einem Termin (Anhang einer Mail).
* @param {{ uid: string, sequenz?: number, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start: Date|string|number,
* ende: Date|string|number, titel: string, ort?: string, beschreibung?: string,
* organisator: { name: string, email: string }, teilnehmer?: { name: string, email: string } | null, jetzt: Date }} termin
* `methode` CANCEL setzt STATUS:CANCELLED (gleiche UID, hoehere SEQUENCE); PUBLISH traegt keinen ATTENDEE.
* @returns {string} CRLF-getrennt, mit abschliessendem CRLF
*/
export function icsTermin(termin) {
if (!METHODEN.includes(termin?.methode)) throw new TypeError(`methode muss ${METHODEN.join(', ')} sein`)
return kalender(termin.methode, [termin], termin.jetzt)
}
/**
* Kalender-Abo (METHOD:PUBLISH, mehrere Termine, ohne Gaeste). Jeder Eintrag wie bei icsTermin (`methode` und
* `teilnehmer` werden ignoriert; `organisator` ist hier freiwillig).
* @param {object[]} termine
* @param {{ name: string, jetzt: Date }} optionen
*/
export function icsKalender(termine, { name, jetzt }) {
return kalender('PUBLISH', termine, jetzt, [...(text(name) ? [`X-WR-CALNAME:${text(name)}`] : []), 'X-PUBLISHED-TTL:PT15M'])
}
+4 -2
View File
@@ -60,7 +60,9 @@ for (const id of EXPECTED) {
assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/)
assert.match(mail.html, /<!--\[if mso\]>/, 'bedingter Outlook-Kommentar bleibt')
assert.equal((mail.html.match(/<!--/g) || []).length, 1, 'nur der Outlook-Kommentar bleibt')
assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
// K und T tragen das WEBklar-Logo; G (Gästemails im Auftrag eines Betriebs) haben ein neutrales Layout ohne
if (/^[KT]/.test(id)) assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
else assert.doesNotMatch(mail.html, /webklar-logo\.png/, 'G-Vorlagen ohne WEBklar-Logo')
assert.doesNotMatch(mail.subject, /\{\{|\n/)
assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities')
}
@@ -148,7 +150,7 @@ test('htmlToText: Links, Buttons und Leerzeilen', () => {
})
test('Vorlagen-Ordner enthält nur Vorlagen', () => {
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), [])
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[GKT]\d{2} .*\.html$/.test(f)), [])
})
test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', () => {
+332
View File
@@ -0,0 +1,332 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
// Kalenderdatei (RFC 5545/5546) und Gaesteversand. Kein Netz: der Mailversand laeuft ueber die Senke, config.js braucht
// nur eine gueltige Umgebung.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { icsTermin, icsKalender } = await import('../server/services/termine/ics.js')
const { sendGuestMail, setMailTransportForTests } = await import('../server/services/mailer.js')
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-02T09:00:00.000Z')
const termin = (extra = {}) => ({
uid: 'b_9f3a1c@termine.webklar.com',
sequenz: 2,
methode: 'REQUEST',
start: '2026-10-05T10:00:00.000+02:00', // 08:00 UTC
ende: new Date('2026-10-05T08:45:00.000Z'),
titel: 'Haarschnitt – Salon Muster',
ort: 'Hauptstraße 1, 67655 Kaiserslautern',
beschreibung: 'Buchungsnummer AB12CD34',
organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' },
teilnehmer: { name: 'Anna Müller', email: 'anna@example.com' },
jetzt: JETZT,
...extra,
})
const enc = new TextEncoder()
const dec = new TextDecoder('utf-8', { fatal: true })
const octets = (s) => enc.encode(s).length
/** Zeilenfaltung rueckgaengig machen: CRLF + Leerzeichen faellt weg. */
const entfalte = (text) => text.replace(/\r\n /g, '')
/** Logische Zeilen (entfaltet, ohne das abschliessende CRLF). */
const zeilen = (text) => entfalte(text).split('\r\n').slice(0, -1)
const entescape = (s) => s.replace(/\\([\\;,nN])/g, (_, c) => (c === 'n' || c === 'N' ? '\n' : c))
const wert = (text, name) => zeilen(text).find((l) => l.startsWith(`${name}:`) || l.startsWith(`${name};`))
test('Aufbau: CRLF, Kopf, genau ein Termin, UTC-Zeiten, UID, SEQUENCE, DTSTAMP', () => {
const ics = icsTermin(termin())
assert.ok(ics.endsWith('END:VCALENDAR\r\n'), 'endet mit CRLF')
assert.ok(!/[^\r]\n|\r[^\n]/.test(ics), 'nur CRLF, kein einzelnes CR oder LF')
assert.deepEqual(zeilen(ics), [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//WEBklar//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
'UID:b_9f3a1c@termine.webklar.com',
'DTSTAMP:20261002T090000Z',
'SEQUENCE:2',
'DTSTART:20261005T080000Z',
'DTEND:20261005T084500Z',
'SUMMARY:Haarschnitt – Salon Muster',
'DESCRIPTION:Buchungsnummer AB12CD34',
'LOCATION:Hauptstraße 1\\, 67655 Kaiserslautern',
'ORGANIZER;CN="Salon Muster":mailto:info@salon-muster.de',
'ATTENDEE;CN="Anna Müller";ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:anna@example.com',
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR',
])
})
test('Zeiten: Date, ISO mit Zone und Millisekunden werden gleich zu UTC; Sekundenbruchteile fallen weg', () => {
const a = icsTermin(termin({ start: new Date('2026-10-05T08:00:00.999Z'), ende: Date.UTC(2026, 9, 5, 8, 45) }))
assert.equal(wert(a, 'DTSTART'), 'DTSTART:20261005T080000Z')
assert.equal(wert(a, 'DTEND'), 'DTEND:20261005T084500Z')
// Sommerzeitende (Berlin 2026-10-25): 02:30 +01:00 ist 01:30 UTC
const b = icsTermin(termin({ start: '2026-10-25T02:30:00+01:00', ende: '2026-10-25T03:00:00+01:00' }))
assert.equal(wert(b, 'DTSTART'), 'DTSTART:20261025T013000Z')
assert.equal(wert(b, 'DTEND'), 'DTEND:20261025T020000Z')
})
test('Zeilenlaenge: jede Zeile hoechstens 75 Oktette, nie mitten in einem UTF-8-Zeichen gefaltet', () => {
for (const baustein of ['a', 'ä', '€', '🙂', 'Ä߀🙂']) {
for (let n = 40; n < 130; n += 1) {
const titel = baustein.repeat(n)
const ics = icsTermin(termin({ titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }))
for (const zeile of ics.split('\r\n')) {
assert.ok(octets(zeile) <= 75, `Zeile zu lang (${octets(zeile)}): ${zeile}`)
assert.doesNotThrow(() => dec.decode(enc.encode(zeile)))
}
for (const folge of ics.split('\r\n').filter((l) => l.startsWith(' '))) assert.ok(folge.length > 1, 'Folgezeile nicht leer')
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel, 'Entfalten ergibt den Titel wieder')
}
}
})
test('Zeilenlaenge: eine Zeile von genau 75 Oktetten bleibt ungefaltet, 76 wird gefaltet', () => {
const zeileMit = (n) => icsTermin(termin({ titel: 'x'.repeat(n - 'SUMMARY:'.length) })).split('\r\n').find((l) => l.startsWith('SUMMARY:'))
assert.equal(octets(zeileMit(75)), 75)
const ics76 = icsTermin(termin({ titel: 'x'.repeat(76 - 'SUMMARY:'.length) }))
assert.match(ics76, /SUMMARY:x{67}\r\n x\r\n/)
})
test('Escaping: Backslash, Semikolon, Komma und Zeilenumbrueche in TEXT-Werten', () => {
const roh = 'a\\b;c,d\ne\r\nf\rg'
const ics = icsTermin(termin({ titel: roh, ort: roh, beschreibung: roh }))
const erwartet = 'a\\\\b\\;c\\,d\\ne\\nf\\ng'
assert.equal(wert(ics, 'SUMMARY'), `SUMMARY:${erwartet}`)
assert.equal(wert(ics, 'LOCATION'), `LOCATION:${erwartet}`)
assert.equal(wert(ics, 'DESCRIPTION'), `DESCRIPTION:${erwartet}`)
assert.equal(entescape(erwartet), 'a\\b;c,d\ne\nf\ng')
})
test('Einschleusen: CR/LF und Steuerzeichen in Eingaben erzeugen keine zusaetzlichen Zeilen', () => {
const boese = 'x\r\nATTENDEE:mailto:boese@example.com\r\nEND:VEVENT'
const ics = icsTermin(
termin({
uid: `uid${boese}`,
titel: boese,
ort: boese,
beschreibung: boese,
organisator: { name: `Org${boese}`, email: `org@example.com${boese}` },
teilnehmer: { name: `Gast${boese}\u0000\u0007`, email: `gast@example.com${boese}` },
})
)
const logisch = zeilen(ics)
assert.equal(logisch.filter((l) => l.startsWith('ATTENDEE')).length, 1, 'genau ein ATTENDEE')
assert.equal(logisch.filter((l) => l === 'END:VEVENT').length, 1, 'genau ein END:VEVENT')
assert.equal(logisch.filter((l) => l.startsWith('UID:')).length, 1)
assert.ok(!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(ics), 'keine Steuerzeichen')
assert.ok(!/[^\r]\n|\r[^\n]/.test(ics))
// jede Zeile ist eine bekannte Eigenschaft (kein eingeschleuster Name)
for (const l of logisch) assert.match(l, /^(BEGIN|END|VERSION|PRODID|CALSCALE|METHOD|UID|DTSTAMP|SEQUENCE|DTSTART|DTEND|SUMMARY|DESCRIPTION|LOCATION|ORGANIZER|ATTENDEE|STATUS|TRANSP)[:;]/)
})
test('CN="…": ohne Anfuehrungszeichen und Steuerzeichen, leerer Name laesst CN weg', () => {
const ics = icsTermin(termin({ organisator: { name: 'Salon "Chez; Muster"\n', email: 'info@salon-muster.de' }, teilnehmer: { name: '"\u0001"', email: 'anna@example.com' } }))
assert.equal(wert(ics, 'ORGANIZER'), 'ORGANIZER;CN="Salon Chez; Muster":mailto:info@salon-muster.de')
assert.equal(wert(ics, 'ATTENDEE'), 'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:anna@example.com')
})
test('E-Mail in mailto: ohne Leerraum und Trennzeichen', () => {
const ics = icsTermin(termin({ teilnehmer: { name: 'A', email: ' anna@example.com,boese@example.com ' } }))
assert.match(wert(ics, 'ATTENDEE'), /:mailto:anna@example\.comboese@example\.com$/)
})
test('ohne Teilnehmer, Ort und Beschreibung entfallen die Zeilen', () => {
const ics = icsTermin(termin({ teilnehmer: null, ort: '', beschreibung: undefined }))
assert.equal(wert(ics, 'ATTENDEE'), undefined)
assert.equal(wert(ics, 'LOCATION'), undefined)
assert.equal(wert(ics, 'DESCRIPTION'), undefined)
assert.ok(wert(ics, 'ORGANIZER'))
})
test('CANCEL: gleiche UID, hoehere Sequenz, STATUS:CANCELLED', () => {
const ics = icsTermin(termin({ methode: 'CANCEL', sequenz: 3 }))
assert.equal(wert(ics, 'METHOD'), 'METHOD:CANCEL')
assert.equal(wert(ics, 'UID'), 'UID:b_9f3a1c@termine.webklar.com')
assert.equal(wert(ics, 'SEQUENCE'), 'SEQUENCE:3')
assert.equal(wert(ics, 'STATUS'), 'STATUS:CANCELLED')
assert.match(wert(ics, 'ATTENDEE'), /;RSVP=FALSE:mailto:anna@example\.com$/)
assert.equal(zeilen(icsTermin(termin())).includes('STATUS:CANCELLED'), false, 'REQUEST ist nicht abgesagt')
})
test('PUBLISH (Betriebsmail, Abo): kein ATTENDEE, Organisator bleibt', () => {
const ics = icsTermin(termin({ methode: 'PUBLISH' }))
assert.equal(wert(ics, 'METHOD'), 'METHOD:PUBLISH')
assert.equal(wert(ics, 'ATTENDEE'), undefined)
assert.equal(wert(ics, 'STATUS'), 'STATUS:CONFIRMED')
assert.ok(wert(ics, 'ORGANIZER'))
})
test('Pflichtangaben: unbekannte Methode, ungueltige Zeiten, fehlende Felder werfen', () => {
assert.throws(() => icsTermin(termin({ methode: 'REPLY' })), TypeError)
assert.throws(() => icsTermin(termin({ methode: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ start: 'morgen' })), TypeError)
assert.throws(() => icsTermin(termin({ ende: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ ende: '2026-10-05T08:00:00Z' })), TypeError, 'Ende nicht nach Beginn')
assert.throws(() => icsTermin(termin({ jetzt: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ jetzt: '2026-10-02' })), TypeError)
assert.throws(() => icsTermin(termin({ uid: '' })), TypeError)
assert.throws(() => icsTermin(termin({ uid: '\r\n' })), TypeError)
assert.throws(() => icsTermin(termin({ titel: '' })), TypeError)
assert.throws(() => icsTermin(termin({ sequenz: -1 })), TypeError)
assert.throws(() => icsTermin(termin({ sequenz: 1.5 })), TypeError)
assert.throws(() => icsTermin(termin({ organisator: null })), TypeError, 'REQUEST braucht den Organisator')
assert.throws(() => icsTermin(termin({ methode: 'CANCEL', organisator: undefined })), TypeError)
})
test('icsKalender: PUBLISH-Feed mit Name, Aktualisierungshinweis, mehreren Terminen und ohne Gaeste', () => {
const feed = icsKalender(
[
termin({ uid: 'b_1@termine.webklar.com', teilnehmer: { name: 'Geheim', email: 'geheim@example.com' } }),
{ uid: 'x_sperre@termine.webklar.com', sequenz: 0, start: '2026-10-06T12:00:00Z', ende: '2026-10-06T13:00:00Z', titel: 'Gesperrt', organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' } },
],
{ name: 'Salon Muster, Stuhl 1; Süd', jetzt: JETZT }
)
assert.ok(feed.endsWith('END:VCALENDAR\r\n'))
assert.ok(!/[^\r]\n|\r[^\n]/.test(feed))
for (const zeile of feed.split('\r\n')) assert.ok(octets(zeile) <= 75)
const logisch = zeilen(feed)
assert.deepEqual(logisch.slice(0, 7), [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//WEBklar//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Salon Muster\\, Stuhl 1\\; Süd',
'X-PUBLISHED-TTL:PT15M',
])
assert.equal(logisch.filter((l) => l === 'BEGIN:VEVENT').length, 2)
assert.equal(logisch.filter((l) => l === 'END:VEVENT').length, 2)
assert.equal(logisch.filter((l) => l === 'DTSTAMP:20261002T090000Z').length, 2, 'DTSTAMP = jetzt')
assert.equal(logisch.filter((l) => l.startsWith('ATTENDEE')).length, 0, 'keine Gaeste im Abo')
assert.ok(!/geheim@example\.com/.test(feed))
assert.deepEqual(logisch.filter((l) => l.startsWith('UID:')), ['UID:b_1@termine.webklar.com', 'UID:x_sperre@termine.webklar.com'])
assert.deepEqual(logisch.filter((l) => l.startsWith('STATUS:')), ['STATUS:CONFIRMED', 'STATUS:CONFIRMED'])
})
test('icsKalender: leerer Feed ist ein gueltiger Kalender; ohne jetzt oder Name-Typ wirft', () => {
const leer = icsKalender([], { name: 'Leer', jetzt: JETZT })
assert.deepEqual(zeilen(leer).filter((l) => l.startsWith('BEGIN:') || l.startsWith('END:')), ['BEGIN:VCALENDAR', 'END:VCALENDAR'])
assert.throws(() => icsKalender([], { name: 'Leer' }), TypeError)
assert.throws(() => icsKalender([termin({ start: 'x' })], { name: 'Leer', jetzt: JETZT }), TypeError)
})
// ---------------------------------------------------------------------------------------------------------------------
// sendGuestMail
// ---------------------------------------------------------------------------------------------------------------------
const gastMail = (extra = {}) => ({
to: 'anna@example.com',
fromName: 'Salon Muster',
subject: 'Ihr Termin bei Salon Muster am 05.10.2026',
html: '<p>Hallo</p>',
text: 'Hallo',
...extra,
})
async function mitSenke(fn, from = 'noreply@webklar.com') {
const vorher = config.smtp.from
config.smtp.from = from
const senke = mailSink()
setMailTransportForTests(senke)
try {
return await fn(senke)
} finally {
setMailTransportForTests(null)
config.smtp.from = vorher
}
}
test('sendGuestMail: Absendername = Betrieb, Adresse = SMTP_FROM, replyTo = Betrieb, nie BCC, ohne ics kein icalEvent', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ replyTo: 'info@salon-muster.de' }))
assert.equal(senke.sent.length, 1)
const [m] = senke.sent
assert.deepEqual(m.from, { address: 'noreply@webklar.com', name: 'Salon Muster' })
assert.deepEqual(m.to, [{ address: 'anna@example.com', name: '' }])
assert.deepEqual(m.replyTo, [{ address: 'info@salon-muster.de', name: '' }])
assert.equal(m.subject, 'Ihr Termin bei Salon Muster am 05.10.2026')
assert.equal(m.html, '<p>Hallo</p>')
assert.equal(m.text, 'Hallo')
assert.equal('bcc' in m, false, 'nie BCC')
assert.equal('cc' in m, false)
assert.equal('icalEvent' in m, false, 'ohne ics keine Kalenderdatei')
assert.deepEqual(m.attachments ?? [], [])
})
})
test('sendGuestMail: ohne replyTo keine Antwortadresse, auch nicht die des Portals', async () => {
await mitSenke(async (senke) => {
const vorher = config.smtp.replyTo
config.smtp.replyTo = 'support@webklar.com'
try {
await sendGuestMail(gastMail())
} finally {
config.smtp.replyTo = vorher
}
assert.equal(senke.sent[0].replyTo ?? null, null, 'nodemailer schreibt eine fehlende Antwortadresse als null')
assert.equal('bcc' in senke.sent[0], false)
})
})
test('sendGuestMail: mit ics geht die Kalenderdatei als icalEvent (Standard REQUEST, termin.ics)', async () => {
await mitSenke(async (senke) => {
const ics = icsTermin(termin())
await sendGuestMail(gastMail({ ics }))
const m = senke.sent[0]
assert.equal(m.icalEvent.method, 'REQUEST')
assert.equal(m.icalEvent.filename, 'termin.ics')
assert.equal(m.icalEvent.content, ics)
assert.match(m.icalEvent.content, /BEGIN:VCALENDAR/)
assert.equal('bcc' in m, false)
})
})
test('sendGuestMail: icsMethod CANCEL und PUBLISH werden uebernommen, andere Methoden abgelehnt', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ ics: icsTermin(termin({ methode: 'CANCEL' })), icsMethod: 'CANCEL' }))
assert.equal(senke.sent[0].icalEvent.method, 'CANCEL')
await sendGuestMail(gastMail({ ics: icsTermin(termin({ methode: 'PUBLISH' })), icsMethod: 'PUBLISH' }))
assert.equal(senke.sent[1].icalEvent.method, 'PUBLISH')
await assert.rejects(sendGuestMail(gastMail({ ics: 'x', icsMethod: 'REPLY' })), TypeError)
assert.equal(senke.sent.length, 2)
})
})
test('sendGuestMail: Betreff und Absendername ohne Zeilenumbrueche; Anfuehrungszeichen im Namen bleiben heil', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ fromName: 'Salon "Chez, Muster"\r\nBcc: boese@example.com', subject: 'Termin\r\nBcc: boese@example.com' }))
const m = senke.sent[0]
assert.ok(!/[\r\n]/.test(m.subject))
assert.ok(!/[\r\n]/.test(m.from.name))
assert.equal(m.from.address, 'noreply@webklar.com')
assert.equal('bcc' in m, false)
})
})
test('sendGuestMail: genau ein Postfach als Empfaenger und Antwortadresse', async () => {
await mitSenke(async (senke) => {
for (const to of ['a@example.com, b@example.com', 'a@example.com;b@example.com', 'Max <a@example.com>', '"a b"@example.com', 'a@example.com\r\nBcc: b@example.com', '', undefined, 'ohne-at', `${'a'.repeat(250)}@example.com`]) {
await assert.rejects(sendGuestMail(gastMail({ to })), TypeError, String(to))
}
await assert.rejects(sendGuestMail(gastMail({ replyTo: 'a@example.com, b@example.com' })), TypeError)
await assert.rejects(sendGuestMail(gastMail({ subject: '' })), TypeError)
await assert.rejects(sendGuestMail(gastMail({ fromName: '' })), TypeError)
assert.equal(senke.sent.length, 0)
})
})
test('sendGuestMail: ohne SMTP-Zugang wirft sie (wie sendCustomerMail)', async () => {
setMailTransportForTests(null)
assert.equal(config.smtp.host, '', 'Testumgebung ohne SMTP')
await assert.rejects(sendGuestMail(gastMail()), /SMTP nicht konfiguriert/)
})