Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c033ab1d2
commit
181445a69d
5 files changed
+514
-6
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
Reference in new issue
Block a user