Files
Webklar-Kundenbereich/server/services/shop/mails.js
T
ClaudeandClaude Opus 5.5 43473c0879 Webshop: IP-Hash entfällt mit Zahlung oder Storno, keine Käufer-Telefonnummer mehr gespeichert
schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist
(Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon
übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00

405 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../../config.js'
import { renderMail, loadTemplate } from '../mailTemplates.js'
import { sendCustomerMail } from '../mailer.js'
import { customerOf } from '../siteFeatures.js'
import { einreihen } from '../termine/outbox.js'
import { pruefeNow, zeitMs } from '../termine/util.js'
import { datumKurz, uhrzeit } from '../termine/zeit.js'
import { euro } from './geld.js'
import { oeffentlicheRechtstexte } from './kontext.js'
import { holeArchivierteRechtstexte } from './archiv.js'
import { gewaehrleistungsHinweis } from './rechtstexte.js'
/**
* Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4).
*
* Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung
* fehlgeschlagen, G15 Eingang der Bestellung) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname,
* Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des
* Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die
* Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht.
* Haendler (K25 neue Bestellung, K26 Widerruf) gehen direkt per sendCustomerMail an die Kunden-E-Mail, ohne BCC ans Team
* (die Mails enthalten Kaeuferdaten).
*
* Beide Wege werfen nie wegen eines Mailfehlers: Rueckgabe { ok: true, ... } | { ok: false, fehler }; der Fehlertext steht im Log (ohne
* Adresse), die Bestellung bleibt unberuehrt.
*
* Erwartete Form der Bestellung (`order` = shopOrders-Dokument; der JSON-Schnappschuss steht in `bestellung`, ersatzweise `daten`,
* als Text oder Objekt; alle Felder sind optional, Fehlendes erscheint als "–"):
* order.bestellnummer, order.kaeuferEmail, order.bezahltAt | order.erstelltAt
* snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') }
* snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } }
* snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT')
* snap.kaeufer { name, email } (oder snap.name)
* snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land }
* snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text
* snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops)
*/
const LEER = '–'
const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'])
/** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */
const MAX_HTML = 95000
const MAX_TEXT = 47000
const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' }
const ZAHLUNGSARTEN = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
// Wie WIDERRUF_TEXTE in produkte.js (dort nicht exportiert)
const WIDERRUF_AUSSCHLUSS = {
verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware',
individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde',
}
const ABHOLUNG_HINWEIS = 'Bitte bringen Sie Ihre Bestellnummer mit.'
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER)
const einzeilig = (wert) => text(wert).replace(/\s+/g, ' ')
// ---------------------------------------------------------------- Bausteine
/** Schnappschuss der Bestellung als Objekt ({} wenn es keinen oder keinen lesbaren gibt). */
function schnappschuss(order) {
for (const feld of ['bestellung', 'daten']) {
const roh = order?.[feld]
if (istObjekt(roh)) return roh
if (typeof roh === 'string' && roh !== '') {
try {
const geparst = JSON.parse(roh)
if (istObjekt(geparst)) return geparst
} catch {
// unlesbar: naechstes Feld
}
}
}
return {}
}
const einstellungenVon = (ctx) => ctx?.shop?.einstellungen ?? {}
const anbieterVon = (ctx, snap) => ({ ...(einstellungenVon(ctx).anbieter ?? {}), ...(istObjekt(snap?.einstellungenAuszug?.anbieter) ? snap.einstellungenAuszug.anbieter : {}) })
/** Name des Shops in Betreff, Kopf und Absender: Shopname, sonst Name des Anbieters. */
function shopName(ctx, snap = {}) {
return einzeilig(snap?.einstellungenAuszug?.shopname) || einzeilig(einstellungenVon(ctx).shopname) || einzeilig(anbieterVon(ctx, snap).name) || 'Webshop'
}
function anbieterZeile(a) {
const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ')
const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : ''
const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ')
return [text(a.name), anschrift, text(a.email), text(a.telefon) ? `Tel. ${einzeilig(a.telefon)}` : ''].filter(Boolean).join(', ') || LEER
}
const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || ''
const shopSeite = (ctx) => `${config.publicBaseUrl}/shop/${siteKeyVon(ctx)}`
/** Akzentfarbe als #rrggbb (nur geprueft, damit nichts in das style-Attribut gelangt). */
const farbeVon = (e) => (/^#[0-9a-fA-F]{6}$/.test(e?.akzent ?? '') ? e.akzent : '#111111')
/** Menge × Name (Variante) – Summe, eine Zeile je Position. */
function positionenText(positionen) {
const zeilen = (Array.isArray(positionen) ? positionen : [])
.filter(istObjekt)
.map((p) => {
const variante = text(p.varianteText) ? ` (${einzeilig(p.varianteText)})` : ''
const summe = hatText(p.summeText) ? p.summeText.trim() : Number.isInteger(p.summeCent) ? euro(p.summeCent) : LEER
return `${Number.isInteger(p.menge) ? p.menge : LEER} × ${einzeilig(p.name) || LEER}${variante} – ${summe}`
})
return zeilen.length ? zeilen.join('\n') : LEER
}
function adresseText(a, fallbackName) {
if (!istObjekt(a)) return LEER
const feld = (...namen) => text(namen.map((n) => a[n]).find(hatText))
const land = feld('country', 'land')
const zeilen = [
feld('name') || text(fallbackName),
feld('line1', 'strasse'),
feld('line2'),
[feld('postal_code', 'plz'), feld('city', 'ort')].filter(Boolean).join(' '),
land && land !== 'DE' ? (LANDNAMEN[land] ?? land) : '',
].filter(Boolean)
return zeilen.length ? zeilen.join('\n') : LEER
}
function zahlungsartText(snap, e) {
const roh = text(snap.zahlungsart)
if (roh) return ZAHLUNGSARTEN[roh] ?? roh
return Array.isArray(e.zahlungsarten) && e.zahlungsarten.length === 1 ? (ZAHLUNGSARTEN[e.zahlungsarten[0]] ?? LEER) : 'Online-Zahlung'
}
function ausschlussText(positionen) {
return (Array.isArray(positionen) ? positionen : [])
.filter((p) => istObjekt(p) && Object.hasOwn(WIDERRUF_AUSSCHLUSS, p.widerruf))
.map((p) => `${einzeilig(p.name) || LEER}: ${WIDERRUF_AUSSCHLUSS[p.widerruf]}`)
.join('\n')
}
/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit); ungueltige Zeitpunkte werfen. */
function eingangText(eingang) {
const ms = zeitMs(eingang)
if (!Number.isFinite(ms)) throw new TypeError('Eingang ist kein Zeitpunkt')
const d = new Date(ms)
return `${datumKurz(d)}, ${uhrzeit(d)} Uhr`
}
/** Datum des ersten brauchbaren Zeitpunkts (Berliner Tag), sonst des Ersatzdatums. */
const datumVon = (now, ...zeitpunkte) => {
for (const wert of zeitpunkte) {
const ms = zeitMs(wert)
if (Number.isFinite(ms)) return datumKurz(new Date(ms))
}
return datumKurz(now)
}
// ---------------------------------------------------------------- Werte
/**
* Gemeinsame Werte der Kaeufermails aus dem Schnappschuss der Bestellung und dem Shop-Kontext: Kopf und Fuss (farbe, betrieb_name,
* anbieter_zeile, widerruf_url, impressum_url, datenschutz_url) und alle Werte von G09 (bestellnummer, datum, positionen, Summen,
* Lieferart, Adresse, Zahlungsart, Rechtstexte). `order` darf null sein (G12 ohne passende Bestellung): dann sind die
* Bestellwerte "–". Es sind mehr Werte als eine einzelne Vorlage braucht; anKaeufer reicht nur die deklarierten weiter.
* @param {{ doc?: object, shop: object }} ctx
* @param {object|null} order
* @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat
* @param {{ archiv?: object|null }} [optionen] archiv: archivierte Rechtstexte der Bestellung (archiv.js); ohne sie die aktuell bestaetigten
* @returns {Record<string, string>}
*/
export function kaeuferWerte(ctx, order, now = new Date(), { archiv = null } = {}) {
if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt')
const snap = schnappschuss(order)
const e = einstellungenVon(ctx)
const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {}
const anbieter = anbieterVon(ctx, snap)
const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) }
const rechtstexte = (istObjekt(archiv) ? archiv : oeffentlicheRechtstexte(ctx)) ?? {}
const summen = istObjekt(snap.summen) ? snap.summen : snap
const texte = istObjekt(summen.texte) ? summen.texte : {}
const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER)
const lieferart = snap.lieferart === 'abholung' ? 'abholung' : snap.lieferart === 'versand' ? 'versand' : ''
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
const land = text(snap.land)
const shop = shopSeite(ctx)
return {
farbe: farbeVon(e),
betrieb_name: shopName(ctx, snap),
anbieter_zeile: anbieterZeile(anbieter),
widerruf_url: `${shop}/widerruf`,
impressum_url: text(rechtstexte.impressumUrl) || shop,
datenschutz_url: text(rechtstexte.datenschutzUrl) || shop,
bestellnummer: oderLeer(order?.bestellnummer),
datum: datumVon(now, order?.erstelltAt),
zahlungsdatum: datumVon(now, order?.bezahltAt, order?.erstelltAt),
positionen: positionenText(snap.positionen),
zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent),
versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)),
gesamt: text(texte.gesamt) || cent(summen.gesamtCent ?? order?.summeCent),
steuer_hinweis: text(texte.steuerHinweis) || (e.kleinunternehmer === true ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : LEER),
lieferart_text: lieferart === 'abholung' ? 'Abholung' : lieferart === 'versand' ? `Versand${land ? ` nach ${LANDNAMEN[land] ?? land}` : ''}` : LEER,
lieferadresse: adresseText(snap.lieferadresse ?? kaeufer.lieferadresse ?? kaeufer.adresse, kaeufer.name ?? snap.name),
abholort: oderLeer(abholung.adresse),
lieferzeit: oderLeer(auszug.lieferzeit ?? e.lieferzeit),
zahlungsart: zahlungsartText(snap, e),
ausschluss_text: ausschlussText(snap.positionen),
agb: oderLeer(rechtstexte.agb),
widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung),
widerrufsformular: oderLeer(rechtstexte.widerrufsformular),
gewaehrleistung: hatText(rechtstexte.gewaehrleistung) ? rechtstexte.gewaehrleistung.trim() : gewaehrleistungsHinweis(),
// fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat)
hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS,
}
}
/**
* Werte und Bloecke einer Kaeufermail (alle Werte von kaeuferWerte plus die Werte der Aktion aus `extra`, gefiltert auf die
* Platzhalter der Vorlage).
* extra je Art:
* G10 versanddienst, sendungsnummer (beide optional; Block sendung, wenn eines da ist)
* G11 hinweis (ersetzt den Hinweis des Haendlers)
* G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht
* G13 betragCent (Pflicht, ganze Cent), grund
*/
function kaeuferMail(art, ctx, order, extra, now, archiv = null) {
const alle = kaeuferWerte(ctx, order, now, { archiv })
const blocks = {}
switch (art) {
case 'G09':
blocks.versand = schnappschuss(order).lieferart === 'versand'
blocks.abholung = schnappschuss(order).lieferart === 'abholung'
blocks.ausschluss = alle.ausschluss_text !== ''
break
case 'G10':
alle.versanddienst = oderLeer(extra.versanddienst)
alle.sendungsnummer = oderLeer(extra.sendungsnummer)
blocks.sendung = hatText(extra.versanddienst) || hatText(extra.sendungsnummer)
break
case 'G11':
if (hatText(extra.hinweis)) alle.hinweis = extra.hinweis.trim()
break
case 'G12':
alle.name = text(extra.name)
if (hatText(extra.bestellnummer)) alle.bestellnummer = extra.bestellnummer.trim()
alle.eingang = eingangText(extra.eingang)
alle.nachricht = text(extra.nachricht)
blocks.nachricht = alle.nachricht !== ''
break
case 'G13':
if (!Number.isInteger(extra.betragCent) || extra.betragCent <= 0) throw new TypeError('extra.betragCent muss eine positive ganze Zahl sein')
alle.betrag = euro(extra.betragCent)
alle.grund = text(extra.grund)
blocks.grund = alle.grund !== ''
break
default:
break
}
const vorlage = loadTemplate(art)
const values = Object.fromEntries(Object.entries(alle).filter(([schluessel]) => vorlage.placeholders.includes(schluessel)))
return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) }
}
/** Archivierte Rechtstexte zum Hash der Bestellung (G09); null (mit Warnung) fuer Bestellungen ohne Archiveintrag. Datenbankfehler werfen. */
async function archivFuer(ctx, order) {
const hash = text(schnappschuss(order).rechtstexteHash)
if (hash === '') return null
const shopId = ctx?.shop?.id || ctx?.doc?.$id
const archiv = await holeArchivierteRechtstexte(shopId, hash)
if (!archiv) console.warn(`[shop-mails] G09: Rechtstexte der Bestellung ${String(order?.$id).slice(0, 36)} fehlen im Archiv, die aktuell bestätigte Fassung wird verwendet`)
return archiv
}
const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT
// ---------------------------------------------------------------- Einreihen
/**
* Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist
* order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". G09 enthaelt die Rechtstexte in der Fassung der
* Bestellung (archiv.js, Hash bestellung.rechtstexteHash); fehlt sie im Archiv, gilt mit Warnung die aktuell bestaetigte. Ist die
* Mail zu lang fuer die Warteschlange, scheitert sie (ok: false), statt Texte durch einen Link zu ersetzen.
* @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js)
* @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null)
* @param {object} [extra] siehe kaeuferMail
* @param {Date} [now]
* @returns {Promise<{ ok: true, eintrag: object } | { ok: false, fehler: string }>}
*/
export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
try {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`)
pruefeNow(now, 'anKaeufer')
extra = extra ?? {}
const archiv = art === 'G09' ? await archivFuer(ctx, order) : null
const { values, blocks } = kaeuferMail(art, ctx, order, extra, now, archiv)
const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail)
if (an === '') throw new TypeError('Empfänger fehlt')
// Nie ein Link statt der Texte: die Bestaetigung muss den Vertragsinhalt selbst enthalten (§ 312f Abs. 2 BGB). Passt die Mail nicht
// in die Warteschlange, scheitert sie hier; der Aufrufer behaelt den Marker offen, zaehlt den Versuch und wiederholt.
const mail = renderMail(art, values, blocks)
if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange')
const e = einstellungenVon(ctx)
const eintrag = await einreihen(
{
featureId: ctx.doc.$id,
bookingId: order?.$id || (hatText(extra.bookingId) ? extra.bookingId : '-'),
art,
an,
fromName: shopName(ctx, schnappschuss(order)).slice(0, 120),
replyTo: text(anbieterVon(ctx, schnappschuss(order)).email) || text(e.anbieter?.email) || null,
template: art,
values,
blocks,
},
now,
)
return { ok: true, eintrag }
} catch (err) {
console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }
}
}
// ---------------------------------------------------------------- Haendler
/** Portal-Adresse der Webshop-Seite des Projekts. */
const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(ctx?.shop?.projectId || ctx?.doc?.projectId || '')}`
/**
* Werte und Bloecke von K25 "Neue Bestellung" (an den Haendler) aus der bezahlten Bestellung.
* @param {{ ueberverkauft?: boolean }} [optionen] ueberverkauft: der Bestand reichte nicht (Block ueberverkauft)
* @returns {{ values: Record<string, string>, blocks: Record<string, boolean> }}
*/
export function haendlerK25(ctx, order, { ueberverkauft = false } = {}) {
const snap = schnappschuss(order)
const alle = kaeuferWerte(ctx, order)
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
return {
values: {
shopname: alle.betrieb_name,
bestellnummer: alle.bestellnummer,
gesamt: alle.gesamt,
positionen: alle.positionen,
lieferart_text: alle.lieferart_text,
kaeufer_name: oderLeer(kaeufer.name ?? snap.name),
portal_url: portalUrl(ctx),
},
blocks: { ueberverkauft: ueberverkauft === true, sepa: text(snap.zahlungsart) === 'sepa_debit' },
}
}
/**
* Werte und Bloecke von K26 "Widerruf eingegangen" (an den Haendler) aus dem Widerruf (shopWiderrufe-Dokument oder
* { name, email, bestellnummer, nachricht, eingang }); eingang = Date | ISO.
*/
export function haendlerK26(ctx, widerruf, now = new Date()) {
const nachricht = text(widerruf?.nachricht)
return {
values: {
shopname: shopName(ctx),
bestellnummer: oderLeer(widerruf?.bestellnummer),
kaeufer_name: oderLeer(widerruf?.name),
kaeufer_email: oderLeer(widerruf?.email),
eingang: eingangText(widerruf?.eingang ?? widerruf?.eingangAt ?? now),
nachricht,
portal_url: portalUrl(ctx),
},
blocks: { nachricht: nachricht !== '' },
}
}
/**
* Schickt eine Haendlermail (K25, K26) an die E-Mail des Kundenkontos, ohne BCC ans Team. kunde_name, shopname und portal_url
* ergaenzt die Funktion, wenn die Vorlage sie kennt und values sie nicht setzt.
* @param {string} template 'K25' | 'K26'
* @param {{ doc: object, shop: object }} ctx
* @param {Record<string, string>} values
* @param {Record<string, boolean>} [blocks]
* @returns {Promise<{ ok: true } | { ok: false, fehler: string }>}
*/
export async function anHaendler(template, ctx, values = {}, blocks = {}) {
try {
const kunde = await customerOf(ctx?.shop?.customerId)
if (!hatText(kunde?.email)) {
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: Kunde ohne E-Mail-Adresse`)
return { ok: false, fehler: 'Der Kunde hat keine E-Mail-Adresse.' }
}
const erlaubt = loadTemplate(template).placeholders
const standard = { kunde_name: text(kunde.name) || 'Kunde', shopname: shopName(ctx), portal_url: portalUrl(ctx) }
const alle = { ...standard, ...values }
await sendCustomerMail(kunde.email, {
template,
values: Object.fromEntries(Object.entries(alle).filter(([schluessel]) => erlaubt.includes(schluessel))),
blocks,
bccTeam: false,
})
return { ok: true }
} catch (err) {
// Nur Art und Code des Fehlers: Meldungen des Mailservers koennen die Adresse enthalten
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: ${err?.name} ${String(err?.code ?? err?.responseCode ?? err?.status ?? '').slice(0, 40)}`.trim())
return { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }
}
}