import { config } from '../../config.js' import { getDocument } from '../appwriteAdmin.js' import { preisHinweis, preisText, zahlungText } from './einstellungen.js' import { icsTermin } from './ics.js' import { websiteUrl } from './kontext.js' import { darfEmpfaenger } from './limits.js' import { einreihen } from './outbox.js' import { widerrufsbelehrung } from './rechtstexte.js' import { buchungsNummer, verwaltungsToken } from './token.js' import { pruefeNow, zeitMs } from './util.js' import { datumKurz, datumLang, uhrzeit } from './zeit.js' /** * Mails des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6): Betreffzeilen, Werte und * Bloecke fuer die Vorlagen G01-G08 (an Gaeste) und K20/K21 (an den Betrieb) und das Einreihen in die Warteschlange. * `art` ist immer die Vorlagen-ID ('G01' ... 'K21'); sie steht auch im Feld `art` des Outbox-Eintrags. * * Regeln fuer alle Mails: * - Genau ein Anrede-Block (sie oder du) je G-Mail, nach einstellung.anrede; K20/K21 sind immer Du-Form ohne diese Bloecke. * - Leere freiwillige Werte erscheinen als "–". Links auf Impressum und Datenschutz des Betriebs fallen auf die Website * des Betriebs zurueck (websiteUrl(ctx), zuletzt die Portal-Adresse; ein href darf nicht "–" sein). * - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer. * - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse); * in keinem Betreff steht ein Gastname. * - Freitext des Betriebs (G04 nachricht, G06 grund und nachricht bei Absage durch den Betrieb) geht nur bis zur * Empfaenger-Kappe (limits.js darfEmpfaenger) an eine Adresse; danach geht die Mail ohne diesen Text raus (anGast). * - Verwaltungslink `${publicBaseUrl}/termin.html#m=` (G02, G03, G05, G07), Bestaetigungslink `#v=` * (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`. * * `buchung` ist das Buchungsdokument; fuer G07 und alle Mails nach einer Aenderung der Zustand NACH der Aenderung. * Ohne Buchung (null oder nur Gastangaben) gehen nur G01 `zu_viele`, G08 `nicht_zugeordnet` und K21 `nicht_zugeordnet`. * * extra je Vorlage (fall = Variante der Vorlage; fehlt sie dort, wo sie Pflicht ist, wirft werteFuer): * G01 fall 'bestaetigen' (Vorgabe) | 'zu_viele'; verifyToken (nur bei 'bestaetigen'); an (Adresse, wenn keine Buchung existiert) * G02 anfrageAngenommen: true, wenn der Betrieb eine Anfrage angenommen hat * G04 nachricht (optionale Nachricht des Betriebs) * G06 von 'gast' | 'betrieb' | 'system' (Pflicht); grund, nachricht (nur bei Absage durch den Betrieb); sequenz (ICS, Vorgabe: icsSequenz + 1) * G07 altStart (bisheriger Beginn, Pflicht) * G06/G07 ohneIcs: true laesst die Kalenderdatei weg (eine unbestaetigte Anfrage hat nie eine Einladung bekommen, * es gibt im Kalender des Gastes nichts abzusagen oder zu verschieben) * G08 fall 'zugeordnet' | 'nach_frist' | 'begonnen' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (Adresse der * Erklaerung, Pflicht bei 'nicht_zugeordnet'). 'begonnen' (Widerruf nach Terminbeginn, Frist noch nicht um) und * 'nicht_zugeordnet' tragen keinen Fallblock (neutraler Satz) und keine Buchungsnummer * K20 fall (= block) 'anfrage' oder nichts * K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' | 'begonnen' (Pflicht); * altStart (verschoben); eingang (widerrufen, nach_frist, begonnen, nicht_zugeordnet); bei 'nicht_zugeordnet' ist buchung { gastName, gastEmail, * gastTelefon, gastNachricht } aus der Erklaerung * K20/K21 kundeName (Anrede des Kunden; sonst Name aus dem Kundendokument, zuletzt der Betriebsname) * Fuer Mails ohne Buchung gibt extra.bookingId den Bezug im Outbox-Eintrag an (Vorgabe '-'). */ const LEER = '–' const OHNE_BUCHUNG = '-' const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08'] const BETRIEBS_ARTEN = ['K20', 'K21'] const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' } const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ /** Felder mit Freitext des Betriebs je Gastmail (G06 nur bei von === 'betrieb'). */ const FREITEXT_FELDER = { G04: ['nachricht'], G06: ['grund', 'nachricht'] } function einfrieren(wert) { if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) { Object.freeze(wert) Object.values(wert).forEach(einfrieren) } return wert } /** * Betreffzeilen: BETREFFE[art][anrede][fall] mit {betrieb}, {leistung}, {datum} (TT.MM.JJJJ) und {zeit} (HH:MM). * K20/K21 stehen in der Du-Form (wie K15-K19). Kein Betreff enthaelt einen Gastnamen. */ export const BETREFFE = einfrieren({ G01: { sie: { bestaetigen: 'Bitte bestätigen Sie Ihren Termin bei {betrieb}', zu_viele: 'Ihr Termin bei {betrieb} konnte nicht vorgemerkt werden' }, du: { bestaetigen: 'Bitte bestätige deinen Termin bei {betrieb}', zu_viele: 'Dein Termin bei {betrieb} konnte nicht vorgemerkt werden' }, }, G02: { sie: { standard: 'Ihr Termin bei {betrieb} am {datum}' }, du: { standard: 'Dein Termin bei {betrieb} am {datum}' } }, G03: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } }, G04: { sie: { standard: 'Ihre Terminanfrage bei {betrieb} – leider abgelehnt' }, du: { standard: 'Deine Terminanfrage bei {betrieb} – leider abgelehnt' } }, G05: { sie: { standard: 'Erinnerung: Ihr Termin am {datum} um {zeit}' }, du: { standard: 'Erinnerung: Dein Termin am {datum} um {zeit}' }, }, G06: { sie: { standard: 'Ihr Termin am {datum} wurde abgesagt' }, du: { standard: 'Dein Termin am {datum} wurde abgesagt' } }, G07: { sie: { standard: 'Ihr Termin wurde verschoben' }, du: { standard: 'Dein Termin wurde verschoben' } }, G08: { sie: { standard: 'Eingangsbestätigung Ihres Widerrufs' }, du: { standard: 'Eingangsbestätigung deines Widerrufs' } }, K20: { du: { buchung: 'Neue Buchung: {leistung} am {datum} um {zeit}', anfrage: 'Neue Terminanfrage: {leistung} am {datum} um {zeit}', }, }, K21: { du: { abgesagt: 'Termin abgesagt: {leistung} am {datum} um {zeit}', verschoben: 'Termin verschoben: {leistung}, jetzt am {datum} um {zeit}', widerrufen: 'Termin widerrufen: {leistung} am {datum} um {zeit}', nach_frist: 'Widerruf nach Ablauf der Frist: {leistung} am {datum} um {zeit}', begonnen: 'Widerruf nach Terminbeginn: {leistung} am {datum} um {zeit}', nicht_zugeordnet: 'Widerruf ohne passende Buchung', }, }, }) function betreff(art, anrede, fall, variablen) { const muster = BETREFFE[art]?.[anrede]?.[fall] if (!muster) throw new TypeError(`Kein Betreff für ${art}/${anrede}/${fall}`) return muster.replace(/\{(\w+)\}/g, (_, name) => { if (!Object.hasOwn(variablen, name)) throw new TypeError(`Betreff ${art}: Wert für {${name}} fehlt`) return variablen[name] }) } // ---------------------------------------------------------------- Bausteine const hatText = (wert) => typeof wert === 'string' && wert.trim() !== '' /** Text oder "–". */ const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER) function pflichtText(wert, name) { if (!hatText(wert)) throw new TypeError(`${name} fehlt`) return wert.trim() } function pflichtDatum(wert, name) { const ms = zeitMs(wert) if (!Number.isFinite(ms)) throw new TypeError(`${name} fehlt oder ist kein Zeitpunkt`) return new Date(ms) } function wahlFall(fall, erlaubt, name) { if (!erlaubt.includes(fall)) throw new TypeError(`${name}: fall muss ${erlaubt.join(', ')} sein`) return fall } const verwaltungsLink = (buchung) => `${config.publicBaseUrl}/termin.html#m=${verwaltungsToken(pflichtText(buchung?.$id, 'buchung.$id'))}` const portalLink = () => `${config.publicBaseUrl}/dashboard.html#termine` function bestaetigungsLink(token) { if (typeof token !== 'string' || !VERIFY_TOKEN_RE.test(token)) throw new TypeError('extra.verifyToken fehlt oder ist ungültig') return `${config.publicBaseUrl}/termin.html#v=${token}` } /** Werte des Fusses und Kopfes aller Gaestemails. */ function betriebsWerte(ctx) { const e = ctx.einstellung const b = e.betrieb const seite = websiteUrl(ctx) return { farbe: e.farbe, betrieb_name: b.name, betrieb_adresse: oderLeer(b.adresse), betrieb_telefon: oderLeer(b.telefon), betrieb_email: b.email, impressum_url: b.impressumUrl || seite, datenschutz_url: b.datenschutzUrl || seite, } } /** leistung, datum ("Montag, 12.10.2026"), zeit ("10:00") und die Kurzformen fuer Betreffzeilen. */ function terminWerte(buchung) { const start = pflichtDatum(buchung?.startsAt, 'buchung.startsAt') return { leistung: pflichtText(buchung.leistungName, 'buchung.leistungName'), datum: datumLang(start), zeit: uhrzeit(start), kurz: { datum: datumKurz(start), zeit: uhrzeit(start) }, } } /** "45 Minuten", "1 Stunde", "1 Stunde 30 Minuten". */ export function dauerText(buchung) { const minuten = Math.round((pflichtDatum(buchung?.endsAt, 'buchung.endsAt') - pflichtDatum(buchung?.startsAt, 'buchung.startsAt')) / 60000) const stunden = Math.floor(minuten / 60) const rest = minuten % 60 return [stunden ? `${stunden} ${stunden === 1 ? 'Stunde' : 'Stunden'}` : '', rest || !stunden ? `${rest} ${rest === 1 ? 'Minute' : 'Minuten'}` : ''] .filter(Boolean) .join(' ') } /** Person oder Platz zeigen die Gaestemails nur, wenn der Betrieb mehrere Ressourcen hat. */ const personWert = (e, buchung) => (hatText(buchung?.ressourceName) && (e.ressourcen || []).length > 1 ? buchung.ressourceName.trim() : '') /** Bei Gast-Datum "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */ const eingangText = (eingang) => { const d = pflichtDatum(eingang, 'extra.eingang') return `${datumKurz(d)}, ${uhrzeit(d)} Uhr` } /** * Welcher Hinweis zum Widerruf gehoert in G02? 'widerruf' (Verbraucherbuchung), 'kein_widerruf' (Freizeitleistung mit * festem Termin fuer Verbraucher, § 312g Abs. 2 Nr. 9 BGB) oder null (Unternehmer, Handbuchung). */ function widerrufFall(e, buchung) { if (buchung.quelle === 'manuell') return null if (buchung.verbraucher === true) return 'widerruf' if (e.recht?.freizeit === true && e.recht.gaesteSind !== 'unternehmer') return 'kein_widerruf' return null } // ---------------------------------------------------------------- Werte je Vorlage function gastG01({ ctx, anrede, buchung, extra }) { const b = ctx.einstellung.betrieb const fall = wahlFall(extra.fall ?? 'bestaetigen', ['bestaetigen', 'zu_viele'], 'G01') const values = { ...betriebsWerte(ctx), leistung: LEER, datum: LEER, zeit: LEER, link: LEER } if (fall === 'bestaetigen') { const t = terminWerte(buchung) Object.assign(values, { leistung: t.leistung, datum: t.datum, zeit: t.zeit, link: bestaetigungsLink(extra.verifyToken) }) } values.betreff = betreff('G01', anrede, fall, { betrieb: b.name }) return { values, blocks: { [anrede]: true, [fall]: true } } } function gastG02({ ctx, anrede, buchung, extra }) { const e = ctx.einstellung const t = terminWerte(buchung) const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent }) const person = personWert(e, buchung) const storno = e.storno?.text const fall = widerrufFall(e, buchung) const link = verwaltungsLink(buchung) const values = { ...betriebsWerte(ctx), gast_name: oderLeer(buchung.gastName), leistung: t.leistung, person: oderLeer(person), datum: t.datum, zeit: t.zeit, dauer: dauerText(buchung), preis: preis ? `${preis} (${preisHinweis(e)})` : LEER, zahlung: preis ? zahlungText(e) : LEER, nummer: buchungsNummer(buchung.$id), link, ort: oderLeer(e.betrieb.adresse), storno_text: oderLeer(storno), // wirft, wenn Anschrift oder Telefon fehlen: eine unvollstaendige Belehrung geht nie raus widerrufsbelehrung: fall === 'widerruf' ? widerrufsbelehrung(e.betrieb) : LEER, widerruf_link: link, betreff: betreff('G02', anrede, 'standard', { betrieb: e.betrieb.name, datum: t.kurz.datum }), } const blocks = { [anrede]: true } if (preis) blocks.preis = true if (person) blocks.person = true if (hatText(storno)) blocks.storno = true if (fall) blocks[fall] = true if (extra.anfrageAngenommen === true) blocks.anfrage_angenommen = true return { values, blocks } } function gastG03({ ctx, anrede, buchung }) { const t = terminWerte(buchung) const values = { ...betriebsWerte(ctx), gast_name: oderLeer(buchung.gastName), leistung: t.leistung, datum: t.datum, zeit: t.zeit, link: verwaltungsLink(buchung), betreff: betreff('G03', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }), } return { values, blocks: { [anrede]: true } } } function gastG04({ ctx, anrede, buchung, extra }) { const t = terminWerte(buchung) const values = { ...betriebsWerte(ctx), gast_name: oderLeer(buchung.gastName), leistung: t.leistung, datum: t.datum, zeit: t.zeit, nachricht: oderLeer(extra.nachricht), betreff: betreff('G04', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }), } return { values, blocks: { [anrede]: true, ...(hatText(extra.nachricht) ? { nachricht: true } : {}) } } } function gastG05({ ctx, anrede, buchung }) { const t = terminWerte(buchung) const person = personWert(ctx.einstellung, buchung) const values = { ...betriebsWerte(ctx), gast_name: oderLeer(buchung.gastName), leistung: t.leistung, person: oderLeer(person), datum: t.datum, zeit: t.zeit, link: verwaltungsLink(buchung), betreff: betreff('G05', anrede, 'standard', { datum: t.kurz.datum, zeit: t.kurz.zeit }), } return { values, blocks: { [anrede]: true, ...(person ? { person: true } : {}) } } } function gastG06({ ctx, anrede, buchung, extra }) { const b = ctx.einstellung.betrieb const von = wahlFall(extra.von, ['gast', 'betrieb', 'system'], 'G06 extra.von') const t = terminWerte(buchung) // Grund und Nachricht kommen vom Betrieb; sagt der Gast selbst ab, gibt es beides nicht const grund = von === 'gast' ? '' : extra.grund const nachricht = von === 'gast' ? '' : extra.nachricht const values = { ...betriebsWerte(ctx), gast_name: oderLeer(buchung.gastName), leistung: t.leistung, datum: t.datum, zeit: t.zeit, // anredeneutral, weil der Wert allein in der Zeile "Abgesagt von" steht absage_von: von === 'gast' ? (anrede === 'du' ? 'Du selbst' : 'Sie selbst') : b.name, grund: oderLeer(grund), nachricht: oderLeer(nachricht), betreff: betreff('G06', anrede, 'standard', { datum: t.kurz.datum }), } const blocks = { [anrede]: true } if (hatText(grund)) blocks.grund = true if (hatText(nachricht)) blocks.nachricht = true return { values, blocks } } function gastG07({ ctx, anrede, buchung, extra }) { const t = terminWerte(buchung) const alt = pflichtDatum(extra.altStart, 'extra.altStart') const values = { ...betriebsWerte(ctx), gast_name: oderLeer(buchung.gastName), leistung: t.leistung, alt_datum: datumLang(alt), alt_zeit: uhrzeit(alt), datum: t.datum, zeit: t.zeit, link: verwaltungsLink(buchung), betreff: betreff('G07', anrede, 'standard', {}), } return { values, blocks: { [anrede]: true } } } function gastG08({ ctx, anrede, buchung, extra }) { const fall = wahlFall(extra.fall, ['zugeordnet', 'nach_frist', 'begonnen', 'nicht_zugeordnet'], 'G08') const mitBlock = fall === 'zugeordnet' || fall === 'nach_frist' const values = { ...betriebsWerte(ctx), eingang: eingangText(extra.eingang), // die gespeicherte Nummer der gefundenen Buchung, nie eine Eingabe des Gastes nummer: mitBlock ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER, betreff: betreff('G08', anrede, 'standard', {}), } return { values, blocks: { [anrede]: true, ...(mitBlock ? { [fall]: true } : {}) } } } const GAST_BAUER = { G01: gastG01, G02: gastG02, G03: gastG03, G04: gastG04, G05: gastG05, G06: gastG06, G07: gastG07, G08: gastG08, } /** Gemeinsame Gastangaben und Termin der Betriebsmails K20/K21 (Werte, die die Vorlage immer erwartet). */ function betriebsMailWerte(ctx, buchung, extra) { const e = ctx.einstellung return { kunde_name: oderLeer(extra.kundeName ?? e.betrieb.name), gast_name: oderLeer(buchung?.gastName), gast_email: oderLeer(buchung?.gastEmail), gast_telefon: oderLeer(buchung?.gastTelefon), gast_nachricht: oderLeer(buchung?.gastNachricht), portal_link: portalLink(), } } function betriebK20({ ctx, buchung, extra }) { const fall = wahlFall(extra.fall ?? 'buchung', ['buchung', 'anfrage'], 'K20') const t = terminWerte(buchung) const values = { ...betriebsMailWerte(ctx, buchung, extra), leistung: t.leistung, person: oderLeer(buchung.ressourceName), datum: t.datum, zeit: t.zeit, betreff: betreff('K20', 'du', fall, { leistung: t.leistung, datum: t.kurz.datum, zeit: t.kurz.zeit }), } return { values, blocks: fall === 'anfrage' ? { anfrage: true } : {} } } function betriebK21({ ctx, buchung, extra }) { const fall = wahlFall(extra.fall, ['abgesagt', 'verschoben', 'widerrufen', 'nicht_zugeordnet', 'nach_frist', 'begonnen'], 'K21') const values = { ...betriebsMailWerte(ctx, buchung, extra), leistung: LEER, person: LEER, datum: LEER, zeit: LEER, alt_datum: LEER, alt_zeit: LEER, eingang: LEER, } let kurz = { datum: LEER, zeit: LEER } if (fall !== 'nicht_zugeordnet') { const t = terminWerte(buchung) Object.assign(values, { leistung: t.leistung, person: oderLeer(buchung.ressourceName), datum: t.datum, zeit: t.zeit }) kurz = t.kurz } if (fall === 'verschoben') { const alt = pflichtDatum(extra.altStart, 'extra.altStart') Object.assign(values, { alt_datum: datumLang(alt), alt_zeit: uhrzeit(alt) }) } if (['widerrufen', 'nach_frist', 'begonnen', 'nicht_zugeordnet'].includes(fall)) values.eingang = eingangText(extra.eingang) values.betreff = betreff('K21', 'du', fall, { leistung: values.leistung, datum: kurz.datum, zeit: kurz.zeit }) return { values, blocks: { [fall]: true } } } /** * Betreff, Werte und Bloecke einer Mail (Vorlage `art`), bereit fuer renderMail/einreihen. Siehe Kopfkommentar zu extra. * @param {string} art Vorlagen-ID ('G01' ... 'G08', 'K20', 'K21') * @param {{ einstellung: object, project?: object|null, feature?: object }} ctx * @param {object|null} buchung * @param {object} [extra] * @returns {{ values: Record, blocks: Record }} values.betreff ist der Betreff */ export function werteFuer(art, ctx, buchung, extra = {}) { const e = ctx?.einstellung if (!e?.betrieb) throw new TypeError('werteFuer: ctx.einstellung fehlt') const anrede = e.anrede === 'du' ? 'du' : 'sie' const argumente = { ctx, anrede, buchung, extra: extra ?? {} } if (GAST_BAUER[art]) return GAST_BAUER[art](argumente) if (art === 'K20') return betriebK20(argumente) if (art === 'K21') return betriebK21(argumente) throw new TypeError(`werteFuer: unbekannte Art ${String(art).slice(0, 20)}`) } // ---------------------------------------------------------------- Kalenderdateien /** Kalenderdatei fuer G02/G07 (REQUEST), G06 (CANCEL) und K20 (PUBLISH, ohne Gast); sonst null. */ function kalenderDatei(art, ctx, buchung, extra, jetzt) { const methode = ICS_METHODE[art] if (!methode) return null const b = ctx.einstellung.betrieb const nummer = buchungsNummer(buchung.$id) const aktuell = Number.isInteger(buchung.icsSequenz) ? buchung.icsSequenz : 0 // CANCEL traegt eine hoehere Sequenz als die zuletzt gesendete Einladung const sequenz = Number.isInteger(extra.sequenz) ? extra.sequenz : aktuell + (methode === 'CANCEL' ? 1 : 0) return { methode, datei: icsTermin({ uid: `${buchung.$id}@termine.webklar.com`, sequenz, methode, start: buchung.startsAt, ende: buchung.endsAt, titel: art === 'K20' ? `${buchung.leistungName}: ${oderLeer(buchung.gastName)}` : `${buchung.leistungName} – ${b.name}`, ort: b.adresse, beschreibung: `Buchungsnummer: ${nummer}`, organisator: { name: b.name, email: b.email }, teilnehmer: methode === 'PUBLISH' ? null : { name: buchung.gastName, email: buchung.gastEmail }, jetzt, }), } } // ---------------------------------------------------------------- Einreihen /** * Zaehlt eine Mail mit Freitext des Betriebs gegen die Empfaenger-Kappe der Adresse (limits.js darfEmpfaenger) und gibt * das extra zurueck, mit dem die Mail rausgeht: unveraendert, solange die Kappe nicht erreicht ist oder kein Freitext * dabei ist (dann zaehlt nichts), sonst ohne die Freitextfelder (Log-Warnung ohne Adresse). G05, K20/K21 und der Rest * sind nicht betroffen, ebenso G06 des Gastes oder des Systems. */ function begrenzeFreitext(art, extra, empfaenger, now) { const felder = FREITEXT_FELDER[art] if (!felder || (art === 'G06' && extra.von !== 'betrieb') || !felder.some((feld) => hatText(extra[feld]))) return extra const adresse = hatText(empfaenger) ? empfaenger.trim().toLowerCase() : '' if (adresse === '' || darfEmpfaenger(adresse, now)) return extra console.warn(`[termine-mails] ${art}: Empfänger-Kappe erreicht, die Mail geht ohne Freitext raus`) return { ...extra, ...Object.fromEntries(felder.map((feld) => [feld, undefined])) } } /** * Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist * extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils * ausser mit extra.ohneIcs. Freitext des Betriebs in G04/G06 geht nur bis zur Empfaenger-Kappe der Adresse mit (siehe * begrenzeFreitext). * @param {string} art 'G01' ... 'G08' * @param {object} ctx * @param {object|null} buchung * @param {object} [extra] siehe Kopfkommentar * @param {Date} [now] * @returns {Promise} der Outbox-Eintrag (Status wartet; der erste Sendeversuch laeuft im Hintergrund) */ export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) { if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`) pruefeNow(now, 'anGast') const an = hatText(extra?.an) ? extra.an : buchung?.gastEmail extra = begrenzeFreitext(art, extra ?? {}, an, now) const { values, blocks } = werteFuer(art, ctx, buchung, extra) const b = ctx.einstellung.betrieb const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now) return einreihen( { featureId: ctx.feature?.$id, bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG, art, an, fromName: b.name, replyTo: b.email, template: art, values, blocks, ics: ics?.datei ?? null, icsMethod: ics?.methode ?? 'REQUEST', }, now, ) } /** Name des Kunden fuer die Anrede in K20/K21; ohne lesbares Kundendokument der Betriebsname. */ async function kundenName(ctx) { try { const kunde = await getDocument(config.collections.customers, ctx.feature.customerId) return kunde?.name || kunde?.email || ctx.einstellung.betrieb.name } catch (err) { if (err?.status !== 404) console.warn(`[termine-mails] Kunde ${ctx.feature?.customerId} nicht lesbar: ${err.message}`) return ctx.einstellung.betrieb.name } } /** * Reiht eine Mail an den Betrieb ein (K20/K21, Absender "WEBklar", an betrieb.email, nie BCC). K20 traegt einen * Kalendereintrag (PUBLISH), ausser bei einer unbestaetigten Anfrage; K21 hat keinen. * @param {string} art 'K20' | 'K21' * @param {object} ctx * @param {object|null} buchung * @param {{ block?: string|null, extra?: object }} [optionen] block = Variante der Vorlage (K20 'anfrage'; K21 Pflicht) * @param {Date} [now] * @returns {Promise} der Outbox-Eintrag */ export async function anBetrieb(art, ctx, buchung, { block = null, extra = {} } = {}, now = new Date()) { if (!BETRIEBS_ARTEN.includes(art)) throw new TypeError(`anBetrieb: ${String(art).slice(0, 20)} ist keine Betriebsmail`) pruefeNow(now, 'anBetrieb') extra = extra ?? {} const kundeName = hatText(extra.kundeName) ? extra.kundeName : await kundenName(ctx) const zusatz = { ...extra, kundeName, ...(block ? { fall: block } : {}) } const { values, blocks } = werteFuer(art, ctx, buchung, zusatz) const ics = art === 'K20' && block !== 'anfrage' ? kalenderDatei(art, ctx, buchung, zusatz, now) : null return einreihen( { featureId: ctx.feature?.$id, bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG, art, an: ctx.einstellung.betrieb.email, fromName: 'WEBklar', replyTo: null, template: art, values, blocks, ics: ics?.datei ?? null, icsMethod: ics?.methode ?? 'PUBLISH', }, now, ) }