Webshop E4: Mails (G09-G14 an Käufer, K25/K26 an Händler), shop/mails.js, Mailversand der Warteschlange auch bei Terminplaner off

Käufermails laufen über die Warteschlange des Terminplaners (G09/G12 mit Vorrang),
G09 wird bei Überlänge um den AGB-Text gekürzt (Link statt Text). Der Mailteil des
Minutenjobs und die Aufräumschritte des Tagesjobs laufen auch bei TERMINE_ENABLED=off,
solange SHOP_ENABLED nicht off ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:56:40 +00:00
1 parent 38bd56fe8b
commit 0ebd2e6663
15 files changed
+1570 -26

No files matched your search

+390
View File
@@ -0,0 +1,390 @@
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) 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'])
/** 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)].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`
}
const datumText = (order, now) => {
const ms = zeitMs(order?.bezahltAt || order?.erstelltAt)
return datumKurz(Number.isFinite(ms) ? new Date(ms) : 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: datumText(order, now),
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.' }
}
}