diff --git a/server/services/mailTemplates.js b/server/services/mailTemplates.js index ec01dfa..ecb9deb 100644 --- a/server/services/mailTemplates.js +++ b/server/services/mailTemplates.js @@ -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(/\n?/) + const header = source.match(/\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 = { diff --git a/server/services/mailer.js b/server/services/mailer.js index 04f208f..5a5781d 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -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. diff --git a/server/services/termine/ics.js b/server/services/termine/ics.js new file mode 100644 index 0000000..2cc44c4 --- /dev/null +++ b/server/services/termine/ics.js @@ -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 (`@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']) +} diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 440585e..766f38d 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -60,7 +60,9 @@ for (const id of EXPECTED) { assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/) assert.match(mail.html, /