G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt, aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des Anbieters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
396 lines
19 KiB
JavaScript
396 lines
19 KiB
JavaScript
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<string, string>}
|
||
*/
|
||
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<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.' }
|
||
}
|
||
}
|