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 { 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, telefon } (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 * @returns {Record} */ export function kaeuferWerte(ctx, order, now = new Date()) { 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 = 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: 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) { const alle = kaeuferWerte(ctx, order, now) 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))) } } 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 "-". Ist G09 zu lang fuer die Warteschlange * (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit * Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig. * @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, gekuerzt: boolean } | { 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 { values, blocks } = kaeuferMail(art, ctx, order, extra, now) const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail) if (an === '') throw new TypeError('Empfänger fehlt') let gekuerzt = false let mail = renderMail(art, values, blocks) if (art === 'G09' && zuLang(mail)) { values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb` mail = renderMail(art, values, blocks) gekuerzt = true } 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, gekuerzt } } 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, blocks: Record }} */ 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} values * @param {Record} [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.' } } }