} [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.' }
+ }
+}
diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js
index fc43e76..998ceb5 100644
--- a/server/services/termine/jobs.js
+++ b/server/services/termine/jobs.js
@@ -23,6 +23,7 @@ import { LOESCHFRIST_TAGE } from './frist.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
+import { shopModus } from '../shop/schalter.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
@@ -57,6 +58,12 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei
* TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen
* bleiben aus.
+ * - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb
+ * laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und
+ * Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles
+ * andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit
+ * `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung
+ * abgeschalteter Funktionen nicht.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der
* naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind
@@ -127,18 +134,21 @@ async function schritt(lauf, was, fn, ersatz) {
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
* `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der
* Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection).
- * `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true
- * und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
+ * `trotzAus` (async, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht
+ * `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) und der Job beschraenkt sich auf
+ * das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre
* gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen.
*/
async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) {
const nowMs = pruefeNow(now, job)
let eingeschraenkt = false
+ let freigabe = null
if (termineAus()) {
if (trotzAus === null) return { aus: true }
try {
- if (!(await trotzAus())) return { aus: true }
+ freigabe = await trotzAus()
+ if (!freigabe) return { aus: true }
} catch (err) {
console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`)
return { aus: true }
@@ -155,13 +165,16 @@ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}
}
laufend.add(sperre)
try {
- return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt })
+ return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt, freigabe })
} finally {
laufend.delete(sperre)
ueberlappGemeldet.delete(sperre)
}
}
+/** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */
+const shopMailsLaufen = async () => shopModus() !== 'off'
+
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 })
@@ -285,10 +298,15 @@ export async function minutenJob(now) {
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { faellig, gesendet, fehler: lauf.fehler }
},
- { teil: 'Mailversand' },
+ { teil: 'Mailversand', trotzAus: shopMailsLaufen },
),
])
- if (verfall.aus || mail.aus) return { aus: true }
+ if (verfall.aus && mail.aus) return { aus: true }
+ // Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief
+ if (verfall.aus) {
+ if (mail.uebersprungen) return { uebersprungen: true }
+ return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 }
+ }
if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true }
return {
verfallen: verfall.verfallen ?? 0,
@@ -792,6 +810,23 @@ async function gibtBezahlteTerminplaner() {
return treffer.length > 0
}
+/**
+ * Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes
+ * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen).
+ * Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher.
+ */
+async function tagesjobFreigabe() {
+ const mails = shopModus() !== 'off'
+ let termine = false
+ try {
+ termine = await gibtBezahlteTerminplaner()
+ } catch (err) {
+ if (!mails) throw err
+ console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`)
+ }
+ return termine || mails ? { termine, mails } : false
+}
+
/**
* Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des
* Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs
@@ -864,7 +899,7 @@ export async function taeglicherJob(now) {
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
},
- { trotzAus: gibtBezahlteTerminplaner },
+ { trotzAus: tagesjobFreigabe },
)
} catch (err) {
// Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter
@@ -880,19 +915,28 @@ export async function taeglicherJob(now) {
* und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team).
*/
async function eingeschraenkterTagesjob(lauf, now) {
- const aboAbgleich = await aboAbgleichSchritt(lauf, now)
- const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
- funktionen: 0,
- buchungen: 0,
- gedeckelt: 0,
- })
+ // Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile
+ const termine = lauf.freigabe?.termine !== false
+ const mails = lauf.freigabe?.mails === true
+ const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz()
+ const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine
+ ? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
+ : { funktionen: 0, buchungen: 0, gedeckelt: 0 }
+ // Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen
+ const mailErgebnis = mails
+ ? {
+ festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0),
+ aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0),
+ }
+ : {}
const ergebnis = {
eingeschraenkt: true,
aboAbgleich,
abgeschaltet,
- gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 },
+ gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 },
geloescht: abgeschaltet.buchungen,
aktiveBetriebe: null,
+ ...mailErgebnis,
fehler: lauf.fehler,
}
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
@@ -926,9 +970,12 @@ export function tagesbericht(ergebnis) {
? ''
: `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, `
if (ergebnis.eingeschraenkt) {
+ // Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen)
+ const mitMails = Number.isInteger(ergebnis.aufgeraeumt)
+ const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : ''
return (
- `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
- `${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler`
+ `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
+ `${a.funktionen} Funktionen bereinigt, ${mailSegment}${deckel}${aboSegment}${ergebnis.fehler} Fehler`
)
}
const l = ergebnis.loeschfristen
diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js
index df994b8..e12d26d 100644
--- a/server/services/termine/outbox.js
+++ b/server/services/termine/outbox.js
@@ -39,7 +39,7 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
* pause Anmeldepause laeuft
* budget Plaetze im Fenster >= mailsProStunde
* reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt
- * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten
+ * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12) und den normalen Mails vorbehalten
* kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen
*
* Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage.
@@ -71,8 +71,12 @@ const ID_BLOCK = 50
const iso = (wertMs) => new Date(wertMs).toISOString()
-/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */
-export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
+/**
+ * Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Dazu die beiden
+ * Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger) und G12 Eingangsbestaetigung des Widerrufs. Die
+ * uebrigen Shop-Mails G10, G11, G13, G14 sind 'normal'.
+ */
+export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12'])
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
@@ -115,7 +119,7 @@ function gesamtBremse(nowMs) {
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
* @param {number} nowMs
* @param {string} featureId
- * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21')
+ * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G14')
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
*/
export function pruefePlatz(nowMs, featureId, art) {
@@ -438,7 +442,7 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) {
}
/**
- * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21),
+ * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12) vor normalen vor nachrangigen (G05, K20, K21),
* in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege
* (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen
* kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js
index c909cbf..b6ce0cd 100644
--- a/test/mailTemplates.test.js
+++ b/test/mailTemplates.test.js
@@ -6,10 +6,12 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
-// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
+// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
-const EXPECTED = [...ids('G', 8), ...ids('K', 24), ...ids('T', 19), 'T20', 'T21']
+const EXPECTED = [...ids('G', 14), ...ids('K', 26), ...ids('T', 19), 'T20', 'T21']
+// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
const GUEST = ids('G', 8)
+const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14']
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
@@ -1073,3 +1075,191 @@ test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Pre
assert.match(mail.html, /Wir verkaufen keine <Waffen>\.
Bitte melde Dich\./)
assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/)
})
+
+// ---------------------------------------------------------------------------
+// Webshop E4: Käufermails G09–G14 (Sie-Form, Betreff in der Vorlage, Fuß mit Anbieter und Widerrufsfunktion)
+// und K25, K26 an den Händler (Du-Form)
+// ---------------------------------------------------------------------------
+
+const SHOP_GEMEIN = ['farbe', 'betrieb_name', 'anbieter_zeile', 'impressum_url', 'datenschutz_url', 'widerruf_url']
+
+test('G09–G14: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => {
+ const erwartet = {
+ G09: {
+ s: 'Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}',
+ p: ['bestellnummer', 'datum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'],
+ b: ['versand', 'abholung', 'ausschluss'],
+ },
+ G10: { s: 'Ihre Bestellung {{bestellnummer}} wurde versendet', p: ['bestellnummer', 'sendungsnummer', 'versanddienst'], b: ['sendung'] },
+ G11: { s: 'Ihre Bestellung {{bestellnummer}} ist abholbereit', p: ['bestellnummer', 'abholort', 'hinweis'], b: [] },
+ G12: { s: 'Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}', p: ['name', 'bestellnummer', 'eingang', 'nachricht'], b: ['nachricht'] },
+ G13: { s: 'Erstattung zu Ihrer Bestellung {{bestellnummer}}', p: ['bestellnummer', 'betrag', 'grund'], b: ['grund'] },
+ G14: { s: 'Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen', p: ['bestellnummer'], b: [] },
+ }
+ assert.deepEqual(Object.keys(erwartet), SHOP_GUEST)
+ for (const [id, { s, p, b }] of Object.entries(erwartet)) {
+ const tpl = loadTemplate(id)
+ assert.equal(tpl.subject, s, id)
+ assert.deepEqual([...tpl.placeholders].sort(), [...p, ...SHOP_GEMEIN].sort(), `${id}: Platzhalter`)
+ assert.deepEqual([...tpl.blocks].sort(), [...b].sort(), `${id}: Blöcke`)
+ }
+})
+
+test('G09–G14: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => {
+ for (const id of SHOP_GUEST) {
+ const tpl = loadTemplate(id)
+ const source = sourceOf(id)
+ assert.match(source, /]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`)
+ assert.doesNotMatch(source, / ]*>Impressum, id)
+ assert.match(mail.html, /href="Wert-datenschutz_url"[^>]*>Datenschutz, id)
+ assert.match(mail.html, /href="Wert-widerruf_url"[^>]*>Vertrag widerrufen, id)
+ assert.match(mail.html, /background:Wert-farbe/, id)
+ assert.doesNotMatch(mail.text, DU_FORM, `${id}: Sie-Form, nicht Du\n${mail.text}`)
+ }
+ }
+})
+
+const G_WERTE = { bestellnummer: '26-K7QM2X', betrieb_name: 'Töpferei ', farbe: '#112233', anbieter_zeile: 'Töpferei Muster, Hauptstr. 1, 12345 Musterstadt, info@example.com', impressum_url: 'https://shop.example/impressum', datenschutz_url: 'https://shop.example/datenschutz', widerruf_url: 'https://project.webklar.com/shop/sk/widerruf' }
+const g = (id, werte, blocks = {}) => renderMail(id, { ...G_WERTE, ...werte }, blocks)
+
+const G09_WERTE = {
+ datum: '05.10.2026',
+ positionen: '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €',
+ zwischensumme: '36,50 €',
+ versand: '4,90 €',
+ gesamt: '41,40 €',
+ steuer_hinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
+ lieferart_text: 'Versand nach Deutschland',
+ lieferadresse: 'Erika Beispiel\nMusterweg 3\n12345 Musterstadt',
+ abholort: 'Werkstatt, Hauptstr. 1',
+ lieferzeit: '2–4 Werktage',
+ zahlungsart: 'Kreditkarte',
+ ausschluss_text: 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
+ agb: 'AGB-Text Zeile 1\nAGB-Text Zeile 2',
+ widerrufsbelehrung: 'Widerrufsbelehrung Zeile 1\nZeile 2',
+ widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich',
+ gewaehrleistung: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre',
+}
+
+test('G09 Versand: Betreff mit Bestellnummer und Shop, Positionen, Summen, Lieferadresse, alle Rechtstexte, Links', () => {
+ const mail = g('G09', G09_WERTE, { versand: true, abholung: false, ausschluss: true })
+ assert.deepEqual(mail.problems, [])
+ assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei ')
+ assert.match(mail.html, /Töpferei <Muster>/, 'Shopname maskiert')
+ assert.match(mail.html, /2 × Tasse \(blau\) – 24,00 € 1 × Teller – 12,50 €/)
+ assert.match(mail.html, /Erika Beispiel Musterweg 3 12345 Musterstadt/)
+ for (const teil of ['26-K7QM2X', '05.10.2026', '36,50 €', '4,90 €', '41,40 €', '§ 19 UStG', 'Versand nach Deutschland', '2–4 Werktage', 'Kreditkarte', 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', 'AGB-Text Zeile 2', 'Widerrufsbelehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular', 'Allgemeine Geschäftsbedingungen']) {
+ assert.ok(mail.text.includes(teil), teil)
+ }
+ assert.match(mail.text, /Lieferadresse: Erika Beispiel/)
+ assert.doesNotMatch(mail.text, /Abholort|Werkstatt, Hauptstr/)
+ assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/shop\/sk\/widerruf"/)
+})
+
+test('G09 Abholung ohne Ausschluss: Abholort statt Lieferadresse, kein Widerrufs-Ausschluss', () => {
+ const mail = g('G09', { ...G09_WERTE, lieferart_text: 'Abholung', versand: 'Abholung – kostenlos' }, { versand: false, abholung: true, ausschluss: false })
+ assert.deepEqual(mail.problems, [])
+ assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/)
+ assert.doesNotMatch(mail.text, /Lieferadresse|Musterweg|Hinweis zum Widerrufsrecht|ausgeschlossen/)
+ assert.match(mail.text, /Abholung – kostenlos/)
+})
+
+test('G10: Sendungsnummer und Versanddienst nur im Block sendung', () => {
+ const mit = g('G10', { sendungsnummer: '0034<1>', versanddienst: 'DHL' }, { sendung: true })
+ assert.deepEqual(mit.problems, [])
+ assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet')
+ assert.match(mit.text, /Versanddienst: DHL/)
+ assert.match(mit.text, /Sendungsnummer: 0034<1>/)
+ assert.match(mit.html, /0034<1>/)
+ const ohne = g('G10', { sendungsnummer: '', versanddienst: '' }, {})
+ assert.deepEqual(ohne.problems, [])
+ assert.doesNotMatch(ohne.text, /Sendungsnummer|Versanddienst/)
+ assert.match(ohne.text, /versendet/)
+})
+
+test('G11: Abholort und Hinweis', () => {
+ const mail = g('G11', { abholort: 'Werkstatt, Hauptstr. 1', hinweis: 'Di–Fr 10–16 Uhr' })
+ assert.deepEqual(mail.problems, [])
+ assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit')
+ assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/)
+ assert.match(mail.text, /Hinweis: Di–Fr 10–16 Uhr/)
+})
+
+test('G12: Eingang mit Datum und Uhrzeit, Erklärung nur im Block nachricht, Händler meldet sich', () => {
+ const werte = { name: 'Erika ', bestellnummer: '26-ZZZZZZ', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Bitte stornieren.\nDanke' }
+ const mit = g('G12', werte, { nachricht: true })
+ assert.deepEqual(mit.problems, [])
+ assert.equal(mit.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei ')
+ assert.match(mit.text, /Guten Tag Erika ,/)
+ assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
+ assert.match(mit.text, /Bestellnummer: 26-ZZZZZZ/)
+ assert.match(mit.html, /Bitte stornieren\. Danke/)
+ assert.match(mit.text, /meldet sich bei Ihnen/)
+ const ohne = g('G12', werte, {})
+ assert.doesNotMatch(ohne.text, /Bitte stornieren|Ihre Erklärung:/)
+})
+
+test('G13: Betrag, Grund nur im Block, Gutschrift über die ursprüngliche Zahlungsart', () => {
+ const mit = g('G13', { betrag: '12,50 €', grund: 'Artikel beschädigt' }, { grund: true })
+ assert.deepEqual(mit.problems, [])
+ assert.equal(mit.subject, 'Erstattung zu Ihrer Bestellung 26-K7QM2X')
+ assert.match(mit.text, /Erstatteter Betrag: 12,50 €/)
+ assert.match(mit.text, /Grund: Artikel beschädigt/)
+ assert.match(mit.text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./)
+ assert.doesNotMatch(g('G13', { betrag: '12,50 €', grund: 'x' }, {}).text, /Grund:/)
+})
+
+test('G14: nichts berechnet, kein Betrag', () => {
+ const mail = g('G14', {})
+ assert.deepEqual(mail.problems, [])
+ assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen')
+ assert.match(mail.text, /wurde nicht abgeschlossen; es wurde nichts berechnet\./)
+})
+
+const K25_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', gesamt: '41,40 €', positionen: '2 × Tasse – 24,00 €\n1 × Teller – 12,50 €', lieferart_text: 'Versand nach Deutschland', kaeufer_name: 'Max ', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
+
+test('K25: Betreff, Platzhalter, Blöcke; Bestand-Warnung und Lastschrift-Hinweis nur im Block', () => {
+ const tpl = loadTemplate('K25')
+ assert.equal(tpl.file, 'K25 Neue Bestellung.html')
+ assert.equal(tpl.subject, 'Neue Bestellung {{bestellnummer}} in Deinem Webshop')
+ assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K25_WERTE).sort())
+ assert.deepEqual(tpl.blocks, ['ueberverkauft', 'sepa'])
+ const normal = renderMail('K25', K25_WERTE, {})
+ assert.deepEqual(normal.problems, [])
+ assert.equal(normal.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop')
+ assert.match(normal.text, /Hallo Erika/)
+ assert.match(normal.text, /Käufer: Max /)
+ assert.match(normal.html, /2 × Tasse – 24,00 € 1 × Teller – 12,50 €/)
+ assert.match(normal.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/)
+ assert.doesNotMatch(normal.text, /Achtung|Lastschrift/)
+ const voll = renderMail('K25', K25_WERTE, { ueberverkauft: true, sepa: true })
+ assert.match(voll.text, /Achtung: Bestand reichte nicht/)
+ assert.match(voll.text, /Zahlung per Lastschrift/)
+ assert.doesNotMatch(renderMail('K25', K25_WERTE, { sepa: true }).text, /Achtung/)
+ assert.doesNotMatch(renderMail('K25', K25_WERTE, { ueberverkauft: true }).text, /Lastschrift/)
+})
+
+const K26_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', kaeufer_name: 'Max ', kaeufer_email: 'max@example.com', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Ich widerrufe.\nBitte melden', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
+
+test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tage', () => {
+ const tpl = loadTemplate('K26')
+ assert.equal(tpl.file, 'K26 Widerruf im Webshop.html')
+ assert.equal(tpl.subject, 'Widerruf in Deinem Webshop eingegangen')
+ assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K26_WERTE).sort())
+ assert.deepEqual(tpl.blocks, ['nachricht'])
+ const mit = renderMail('K26', K26_WERTE, { nachricht: true })
+ assert.deepEqual(mit.problems, [])
+ assert.match(mit.text, /Hallo Erika/)
+ assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
+ assert.match(mit.text, /E-Mail: max@example\.com/)
+ assert.match(mit.html, /Ich widerrufe\. Bitte melden/)
+ assert.match(mit.text, /innerhalb von 14 Tagen/)
+ assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/)
+})
diff --git a/test/shopMails.test.js b/test/shopMails.test.js
new file mode 100644
index 0000000..c93de2b
--- /dev/null
+++ b/test/shopMails.test.js
@@ -0,0 +1,360 @@
+import { test, after, beforeEach, afterEach, mock } from 'node:test'
+import assert from 'node:assert/strict'
+import { startAppwriteMock } from './support/appwriteMock.js'
+import { mailSink } from './support/mailSink.js'
+import { schemaKeys } from './support/schema.js'
+import { useTestEnv } from './support/testEnv.js'
+import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
+
+// Webshop E4: Mails des Shops (services/shop/mails.js). Käufermails laufen durch die Warteschlange des Terminplaners
+// (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess.
+const aw = await startAppwriteMock({
+ schema: { termineOutbox: schemaKeys('termineOutbox') },
+ datetimes: TERMINE_DATETIMES,
+ defaultLimit: 25,
+})
+after(() => aw.close())
+useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
+
+const { config } = await import('../server/config.js')
+const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
+const { setMailTransportForTests } = await import('../server/services/mailer.js')
+const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
+const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
+const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js')
+const M = await import('../server/services/shop/mails.js')
+const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M
+after(() => setMailTransportForTests(null))
+
+const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin
+const SITE_KEY = 'abcdefghijklmnop'
+const ABSENDER_VORHER = config.smtp.from
+const BASIS_VORHER = config.publicBaseUrl
+
+const einstellungen = (over = {}) => ({
+ ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
+ shopname: 'Töpferei Muster',
+ anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' },
+ lieferzeit: '2–4 Werktage',
+ akzent: '#aa3300',
+ abholung: { aktiv: true, adresse: 'Werkstatt, Hauptstr. 1, 67722 Winnweiler', hinweis: 'Di–Fr 10–16 Uhr' },
+ ...over,
+})
+const texte = (over = {}) => ({
+ agb: 'AGB Zeile 1\nAGB Zeile 2',
+ widerrufsbelehrung: 'Belehrung Zeile 1\nZeile 2',
+ widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich',
+ impressumUrl: 'https://toepferei.example/impressum',
+ datenschutzUrl: 'https://toepferei.example/datenschutz',
+ ...over,
+})
+const ctxMit = (e = einstellungen(), t = texte()) => ({
+ doc: { $id: 'f_shop01', customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY },
+ shop: {
+ id: 'f_shop01',
+ customerId: 'c1',
+ projectId: 'p1',
+ siteKey: SITE_KEY,
+ einstellungen: e,
+ rechtstexte: { entwurf: texte(), bestaetigt: { texte: t, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } },
+ },
+})
+
+const bestellung = (over = {}, snap = {}) => ({
+ $id: 'o_bestellung01',
+ bestellnummer: '26-K7QM2X',
+ kaeuferEmail: 'max@example.com',
+ bezahltAt: JETZT.toISOString(),
+ erstelltAt: new Date(JETZT.getTime() - 600000).toISOString(),
+ summeCent: 4140,
+ bestellung: JSON.stringify({
+ positionen: [
+ { name: 'Tasse', varianteText: 'blau', menge: 2, summeCent: 2400, widerruf: 'gesetzlich' },
+ { name: 'Teller', varianteText: '', menge: 1, summeCent: 1250, summeText: '12,50 €', widerruf: 'individuell' },
+ ],
+ summen: { zwischensummeCent: 3650, versandCent: 490, gesamtCent: 4140, texte: { zwischensumme: '36,50 €', versand: '4,90 €', gesamt: '41,40 €', steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' } },
+ lieferart: 'versand',
+ land: 'DE',
+ kaeufer: { name: 'Max Mustermann', email: 'max@example.com' },
+ lieferadresse: { name: 'Max Mustermann', line1: 'Musterweg 3', postal_code: '12345', city: 'Musterstadt', country: 'DE' },
+ zahlungsart: 'card',
+ ...snap,
+ }),
+ ...over,
+})
+
+let sink
+let log
+beforeEach(async () => {
+ for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id)
+ for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id)
+ resetOutboxForTests()
+ config.smtp.from = 'noreply@webklar.com'
+ config.publicBaseUrl = 'https://project.webklar.com'
+ sink = mailSink()
+ setMailTransportForTests(sink)
+ log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
+ aw.seed('customers', { $id: 'c1', name: 'Erika Muster', email: 'erika@toepferei.example' })
+})
+afterEach(async () => {
+ await warteAufVersand()
+ mock.restoreAll()
+ config.smtp.from = ABSENDER_VORHER
+ config.publicBaseUrl = BASIS_VORHER
+})
+
+const eintraege = () => [...aw.docs('termineOutbox').values()]
+const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))
+
+// ---------------------------------------------------------------- Werte
+
+test('kaeuferWerte: Kopf, Fuß, Positionen, Summen, Lieferadresse und Rechtstexte aus Schnappschuss und Shop', () => {
+ const w = kaeuferWerte(ctxMit(), bestellung(), JETZT)
+ // euro() setzt ein geschütztes Leerzeichen vor das €-Zeichen
+ for (const [schluessel, wert] of Object.entries(w)) w[schluessel] = wert.replaceAll('\u00a0', ' ')
+ assert.equal(w.farbe, '#aa3300')
+ assert.equal(w.betrieb_name, 'Töpferei Muster')
+ assert.equal(w.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example')
+ assert.equal(w.widerruf_url, `https://project.webklar.com/shop/${SITE_KEY}/widerruf`)
+ assert.equal(w.impressum_url, 'https://toepferei.example/impressum')
+ assert.equal(w.datenschutz_url, 'https://toepferei.example/datenschutz')
+ assert.equal(w.bestellnummer, '26-K7QM2X')
+ assert.equal(w.datum, '05.10.2026')
+ assert.equal(w.positionen, '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €')
+ assert.deepEqual([w.zwischensumme, w.versand, w.gesamt], ['36,50 €', '4,90 €', '41,40 €'])
+ assert.equal(w.steuer_hinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
+ assert.equal(w.lieferart_text, 'Versand nach Deutschland')
+ assert.equal(w.lieferadresse, 'Max Mustermann\nMusterweg 3\n12345 Musterstadt')
+ assert.equal(w.abholort, 'Werkstatt, Hauptstr. 1, 67722 Winnweiler')
+ assert.equal(w.lieferzeit, '2–4 Werktage')
+ assert.equal(w.zahlungsart, 'Kreditkarte, Apple Pay, Google Pay')
+ assert.equal(w.ausschluss_text, 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt')
+ assert.equal(w.agb, 'AGB Zeile 1\nAGB Zeile 2')
+ assert.equal(w.widerrufsbelehrung, 'Belehrung Zeile 1\nZeile 2')
+ assert.equal(w.widerrufsformular, 'An Töpferei Muster\nHiermit widerrufe ich')
+ assert.equal(w.gewaehrleistung, gewaehrleistungsHinweis())
+})
+
+test('kaeuferWerte: Links fallen ohne Impressum-/Datenschutz-Adresse auf den Shop zurück, ungültige Farbe auf Schwarz, ohne Bestellung "–"', () => {
+ const w = kaeuferWerte(ctxMit(einstellungen({ akzent: 'red;x' }), texte({ impressumUrl: '', datenschutzUrl: '' })), null, JETZT)
+ assert.equal(w.farbe, '#111111')
+ assert.equal(w.impressum_url, `https://project.webklar.com/shop/${SITE_KEY}`)
+ assert.equal(w.datenschutz_url, `https://project.webklar.com/shop/${SITE_KEY}`)
+ assert.equal(w.bestellnummer, '–')
+ assert.equal(w.positionen, '–')
+ assert.equal(w.lieferadresse, '–')
+ assert.equal(w.datum, '05.10.2026', 'ohne Bestellung gilt das übergebene Datum')
+})
+
+// ---------------------------------------------------------------- Käufermails
+
+test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => {
+ const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT)
+ assert.equal(erg.ok, true)
+ assert.equal(erg.gekuerzt, false)
+ await warteAufVersand()
+
+ const [doc] = eintraege()
+ assert.equal(doc.art, 'G09')
+ assert.equal(doc.featureId, 'f_shop01')
+ assert.equal(doc.bookingId, 'o_bestellung01')
+ assert.equal(doc.fromName, 'Töpferei Muster')
+ assert.equal(doc.replyTo, 'info@toepferei.example')
+ assert.equal(doc.status, 'gesendet')
+ assert.equal(doc.an, '', 'nach dem Versand ist die Adresse gelöscht')
+
+ assert.equal(sink.sent.length, 1)
+ const mail = sink.sent[0]
+ assert.deepEqual(mail.to.map((t) => t.address), ['max@example.com'])
+ assert.ok(!mail.bcc, 'kein BCC')
+ assert.equal(mail.from.name, 'Töpferei Muster')
+ assert.equal(mail.from.address, 'noreply@webklar.com')
+ assert.equal(mail.replyTo[0].address, 'info@toepferei.example')
+ assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei Muster')
+ for (const teil of ['2 × Tasse (blau) – 24,00 €', '41,40 €', 'Max Mustermann', 'Musterweg 3', 'Vom Widerruf ausgeschlossen', 'AGB Zeile 2', 'Belehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Vertrag widerrufen']) {
+ assert.ok(mail.text.includes(teil), teil)
+ }
+ assert.match(mail.html, new RegExp(`href="https://project\\.webklar\\.com/shop/${SITE_KEY}/widerruf"`))
+ assert.doesNotMatch(mail.text, /Abholort/)
+ assert.deepEqual(warnLog(), [], 'keine Platzhalter-Warnung der Vorlage')
+})
+
+test('G09 Abholung: Abholort statt Lieferadresse', async () => {
+ const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { lieferart: 'abholung', lieferadresse: null, land: null, positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }] }), {}, JETZT)
+ assert.equal(erg.ok, true)
+ await warteAufVersand()
+ const text = sink.sent[0].text
+ assert.match(text, /Lieferart: Abholung/)
+ assert.match(text, /Abholort: Werkstatt, Hauptstr\. 1, 67722 Winnweiler/)
+ assert.doesNotMatch(text, /Lieferadresse|Hinweis zum Widerrufsrecht/)
+ assert.deepEqual(warnLog(), [])
+})
+
+test('G09: zu lang für die Warteschlange ⇒ der AGB-Text wird durch einen Link auf die AGB ersetzt, Belehrung und Formular bleiben', async () => {
+ const lang = 'Dies ist ein langer Satz der Geschäftsbedingungen.\n'.repeat(560) // etwa 28 000 Zeichen
+ const t = texte({ agb: lang, widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(1000), widerrufsformular: 'Formular Zeile\n'.repeat(300) })
+ const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT)
+ assert.equal(erg.ok, true)
+ assert.equal(erg.gekuerzt, true)
+ const doc = eintraege()[0]
+ assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`)
+ assert.match(doc.text, new RegExp(`Die vollständigen AGB finden Sie unter https://project\\.webklar\\.com/shop/${SITE_KEY}\\?ansicht=agb`))
+ assert.doesNotMatch(doc.text, /langer Satz der Geschäftsbedingungen/)
+ assert.match(doc.text, /Belehrung Satz\./)
+ assert.match(doc.text, /Formular Zeile/)
+ assert.match(doc.text, /GESETZLICHE GEWÄHRLEISTUNG/)
+})
+
+test('G09: auch gekürzt zu lang ⇒ { ok: false } ohne Wurf und ohne Eintrag', async () => {
+ const t = texte({ widerrufsbelehrung: 'x'.repeat(60000) })
+ const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT)
+ assert.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' })
+ assert.equal(eintraege().length, 0)
+ assert.equal(sink.sent.length, 0)
+ assert.doesNotMatch(warnLog().join('\n'), /max@example\.com/)
+})
+
+test('G10: Versanddienst und Sendungsnummer (Block sendung); ohne beide kein Block', async () => {
+ assert.equal((await anKaeufer('G10', ctxMit(), bestellung(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true)
+ assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, JETZT)).ok, true)
+ await warteAufVersand()
+ const [mit, ohne] = sink.sent
+ assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet')
+ assert.match(mit.text, /Versanddienst: DHL/)
+ assert.match(mit.text, /Sendungsnummer: 00340434/)
+ assert.doesNotMatch(ohne.text, /Sendungsnummer/)
+})
+
+test('G11: Abholort und Hinweis des Händlers, ersatzweise ein Standardhinweis', async () => {
+ await anKaeufer('G11', ctxMit(), bestellung(), {}, JETZT)
+ const ohneHinweis = einstellungen({ abholung: { aktiv: true, adresse: 'Werkstatt', hinweis: '' } })
+ await anKaeufer('G11', ctxMit(ohneHinweis), bestellung({ $id: 'o_zwei' }), {}, JETZT)
+ await warteAufVersand()
+ const texte11 = sink.sent.map((m) => m.text)
+ assert.match(texte11[0], /Abholort: Werkstatt, Hauptstr\. 1/)
+ assert.match(texte11[0], /Hinweis: Di–Fr 10–16 Uhr/)
+ assert.match(texte11[1], /Hinweis: Bitte bringen Sie Ihre Bestellnummer mit\./)
+ assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit')
+})
+
+test('G12: ohne passende Bestellung, an die Adresse der Erklärung, mit Datum und Uhrzeit; Vorrang-Klasse', async () => {
+ const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf01', name: 'Kai Käufer', bestellnummer: '26-ZZZZZZ', eingang: JETZT, nachricht: 'Ich widerrufe.' }, JETZT)
+ assert.equal(erg.ok, true)
+ assert.equal(erg.eintrag.bookingId, 'w_widerruf01')
+ await warteAufVersand()
+ const mail = sink.sent[0]
+ assert.deepEqual(mail.to.map((t) => t.address), ['kaeufer@example.com'])
+ assert.equal(mail.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei Muster')
+ assert.match(mail.text, /Guten Tag Kai Käufer,/)
+ assert.match(mail.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
+ assert.match(mail.text, /Bestellnummer: 26-ZZZZZZ/)
+ assert.match(mail.text, /Ihre Erklärung: Ich widerrufe\./)
+ assert.deepEqual(warnLog(), [])
+})
+
+test('G13: erstatteter Betrag in Euro, Grund nur wenn angegeben; ohne Betrag { ok: false }', async () => {
+ assert.equal((await anKaeufer('G13', ctxMit(), bestellung(), { betragCent: 1250, grund: 'Artikel beschädigt' }, JETZT)).ok, true)
+ assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_zwei' }), { betragCent: 4140 }, JETZT)).ok, true)
+ assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_drei' }), {}, JETZT)).ok, false)
+ assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_vier' }), { betragCent: 12.5 }, JETZT)).ok, false)
+ await warteAufVersand()
+ assert.equal(sink.sent.length, 2)
+ assert.match(sink.sent[0].text, /Erstatteter Betrag: 12,50 €/)
+ assert.match(sink.sent[0].text, /Grund: Artikel beschädigt/)
+ assert.match(sink.sent[1].text, /Erstatteter Betrag: 41,40 €/)
+ assert.doesNotMatch(sink.sent[1].text, /Grund:/)
+ assert.match(sink.sent[1].text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./)
+})
+
+test('G14: Zahlung fehlgeschlagen, nichts berechnet', async () => {
+ assert.equal((await anKaeufer('G14', ctxMit(), bestellung(), {}, JETZT)).ok, true)
+ await warteAufVersand()
+ assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen')
+ assert.match(sink.sent[0].text, /es wurde nichts berechnet/)
+})
+
+test('anKaeufer wirft nie: unbekannte Art, fehlende Adresse und Speicherfehler ergeben { ok: false }', async () => {
+ assert.equal((await anKaeufer('G01', ctxMit(), bestellung(), {}, JETZT)).ok, false)
+ assert.equal((await anKaeufer('G09', ctxMit(), bestellung({ kaeuferEmail: '' }), {}, JETZT)).ok, false)
+ assert.equal((await anKaeufer('G09', ctxMit(), bestellung(), {}, new Date('kaputt'))).ok, false)
+ aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' })
+ const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT)
+ assert.equal(erg.ok, false)
+ assert.equal(eintraege().length, 0)
+ assert.equal(sink.sent.length, 0)
+})
+
+test('Käufermails zählen als Gästemails: G09 und G12 mit Vorrang, die übrigen normal', async () => {
+ const { mailKlasse } = await import('../server/services/termine/outbox.js')
+ assert.deepEqual(['G09', 'G10', 'G11', 'G12', 'G13', 'G14'].map(mailKlasse), ['vorrang', 'normal', 'normal', 'vorrang', 'normal', 'normal'])
+})
+
+// ---------------------------------------------------------------- Händlermails
+
+test('K25: geht an das Kundenkonto, ohne BCC ans Team, mit Käufer, Positionen und Portal-Link', async () => {
+ const { values, blocks } = haendlerK25(ctxMit(), bestellung())
+ assert.deepEqual(blocks, { ueberverkauft: false, sepa: false })
+ const erg = await anHaendler('K25', ctxMit(), values, blocks)
+ assert.deepEqual(erg, { ok: true })
+ assert.equal(sink.sent.length, 1)
+ const mail = sink.sent[0]
+ assert.deepEqual(mail.to.map((t) => t.address), ['erika@toepferei.example'])
+ assert.ok(!mail.bcc, 'kein BCC ans Team')
+ assert.equal(mail.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop')
+ assert.match(mail.text, /Hallo Erika Muster/)
+ assert.match(mail.text, /Töpferei Muster/)
+ assert.match(mail.text, /Käufer: Max Mustermann/)
+ assert.match(mail.text, /Gesamt: 41,40 €/)
+ assert.match(mail.html.replaceAll('\u00a0', ' '), /2 × Tasse \(blau\) – 24,00 € 1 × Teller – 12,50 €/)
+ assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#webshop\?projekt=p1"/)
+ assert.doesNotMatch(mail.text, /Achtung|Lastschrift/)
+ assert.deepEqual(warnLog(), [])
+})
+
+test('K25: Blöcke ueberverkauft und sepa', async () => {
+ const { values, blocks } = haendlerK25(ctxMit(), bestellung({}, { zahlungsart: 'sepa_debit' }), { ueberverkauft: true })
+ assert.deepEqual(blocks, { ueberverkauft: true, sepa: true })
+ assert.equal((await anHaendler('K25', ctxMit(), values, blocks)).ok, true)
+ assert.match(sink.sent[0].text, /Achtung: Bestand reichte nicht/)
+ assert.match(sink.sent[0].text, /Zahlung per Lastschrift/)
+})
+
+test('K26: Widerruf mit Eingang in Berliner Zeit und Erklärung, ohne BCC; ohne Erklärung kein Block', async () => {
+ const widerruf = { name: 'Kai ', email: 'kai@example.com', bestellnummer: '26-ZZZZZZ', nachricht: 'Ich widerrufe.', eingangAt: JETZT.toISOString() }
+ const { values, blocks } = haendlerK26(ctxMit(), widerruf)
+ assert.equal(values.eingang, '05.10.2026, 14:03 Uhr')
+ assert.deepEqual(blocks, { nachricht: true })
+ assert.equal((await anHaendler('K26', ctxMit(), values, blocks)).ok, true)
+ const ohne = haendlerK26(ctxMit(), { ...widerruf, nachricht: ' ' })
+ assert.deepEqual(ohne.blocks, { nachricht: false })
+ assert.equal((await anHaendler('K26', ctxMit(), ohne.values, ohne.blocks)).ok, true)
+ const [mit, kurz] = sink.sent
+ assert.ok(!mit.bcc)
+ assert.equal(mit.subject, 'Widerruf in Deinem Webshop eingegangen')
+ assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
+ assert.match(mit.text, /Käufer: Kai /)
+ assert.match(mit.text, /E-Mail: kai@example\.com/)
+ assert.match(mit.text, /Erklärung: Ich widerrufe\./)
+ assert.match(mit.html, /Kai <K>/)
+ assert.doesNotMatch(kurz.text, /Erklärung:/)
+})
+
+test('anHaendler wirft nie: Kunde fehlt oder ohne E-Mail, Versandfehler ⇒ { ok: false } und Warnung ohne Adresse', async () => {
+ const { values, blocks } = haendlerK25(ctxMit(), bestellung())
+ const fremd = ctxMit()
+ fremd.shop.customerId = 'c_gibtsnicht'
+ assert.equal((await anHaendler('K25', fremd, values, blocks)).ok, false)
+ aw.seed('customers', { $id: 'c_ohne', name: 'Ohne', email: '' })
+ const ohneMail = ctxMit()
+ ohneMail.shop.customerId = 'c_ohne'
+ assert.equal((await anHaendler('K25', ohneMail, values, blocks)).ok, false)
+ setMailTransportForTests({
+ async sendMail() {
+ throw new Error('SMTP erika@toepferei.example kaputt')
+ },
+ })
+ assert.deepEqual(await anHaendler('K25', ctxMit(), values, blocks), { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' })
+ assert.equal(sink.sent.length, 0)
+ assert.doesNotMatch(warnLog().join('\n'), /erika@toepferei\.example/, 'keine Adresse im Log')
+ assert.ok(warnLog().some((zeile) => /K25 nicht gesendet/.test(zeile)))
+})
diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js
index 4e84318..55d222a 100644
--- a/test/termineJobs.test.js
+++ b/test/termineJobs.test.js
@@ -3016,3 +3016,90 @@ test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich (
}
}
})
+
+// ---------------------------------------------------------------- Webshop: die Mail-Warteschlange gehört auch dem Shop (E4)
+
+const SHOP_MODUS_VORHER = config.shop.mode
+afterEach(() => {
+ config.shop.mode = SHOP_MODUS_VORHER
+})
+
+test('Webshop aktiv, Terminplaner off: der Minutenjob sendet die Warteschlange, der Verfall und alles andere bleibt aus', async () => {
+ await richteFunktionEin()
+ await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) })
+ legeEintrag('o_faellig', { art: 'G09' })
+ legeEintrag('o_spaeter', { art: 'G09', naechsterVersuch: iso(plus(JETZT, 10 * MIN)) })
+ config.termine.mode = 'off'
+ config.shop.mode = 'pilot'
+
+ assert.deepEqual(await minutenJob(JETZT), { eingeschraenkt: true, verfallen: 0, faellig: 1, gesendet: 1, fehler: 0 })
+ assert.equal(sink.sent.length, 1)
+ assert.equal(aw.docs('termineOutbox').get('o_faellig').status, 'gesendet')
+ assert.equal(aw.docs('termineOutbox').get('o_spaeter').status, 'wartet')
+ assert.equal(status('b_abgelaufen'), 'gehalten', 'der Verfall der Haltungen bleibt aus')
+
+ // Shop aus, Terminplaner off: wie bisher nichts
+ config.shop.mode = 'off'
+ legeEintrag('o_nochmal', { art: 'G09' })
+ assert.deepEqual(await minutenJob(JETZT), { aus: true })
+ assert.equal(aw.docs('termineOutbox').get('o_nochmal').status, 'wartet')
+})
+
+test('Webshop aktiv, Terminplaner off und ohne bezahlte Terminplaner: der Tagesjob beendet festgefahrene Mails und räumt die Warteschlange auf, sonst nichts', async () => {
+ await richteFunktionEin() // aktiv, nicht bezahlt
+ await legeLoeschFall('b_verfallen', 'verfallen', 1)
+ legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) })
+ legeEintrag('o_gesendet', { art: 'G09', status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) })
+ legeEintrag('o_neu', { art: 'G09' })
+ config.termine.mode = 'off'
+ config.shop.mode = 'on'
+
+ const erg = await taeglicherJob(JETZT)
+ assert.deepEqual(erg, {
+ eingeschraenkt: true,
+ aboAbgleich: { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 },
+ abgeschaltet: { funktionen: 0, buchungen: 0 },
+ gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 },
+ geloescht: 0,
+ aktiveBetriebe: null,
+ festgefahren: 1,
+ aufgeraeumt: 1,
+ fehler: 0,
+ })
+ assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler')
+ assert.equal(aw.docs('termineOutbox').get('o_gesendet'), undefined, 'gesendeter Eintrag gelöscht')
+ assert.equal(aw.docs('termineOutbox').get('o_neu').status, 'wartet')
+ assert.ok(buchung('b_verfallen'), 'keine Löschfristen der Buchungen')
+ assert.equal(
+ tagesbericht(erg),
+ '[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung sowie Mail-Warteschlange): 0 Buchungen gelöscht (abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 1 Outbox, 1 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler',
+ )
+})
+
+test('Webshop aktiv, Terminplaner off, bezahlter Terminplaner: Abo-Abgleich und Löschung laufen wie bisher, dazu die Warteschlange', async () => {
+ await richteFunktionEin(F1, BEZAHLT)
+ wartung('active')
+ legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) })
+ config.termine.mode = 'off'
+ config.shop.mode = 'pilot'
+
+ const erg = await taeglicherJob(JETZT)
+ assert.equal(erg.eingeschraenkt, true)
+ assert.equal(erg.aboAbgleich.geprueft, 1)
+ assert.equal(erg.festgefahren, 1)
+ assert.equal(erg.aufgeraeumt, 0)
+ assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler')
+})
+
+test('Webshop aktiv, Terminplaner off: schlägt die Guard-Abfrage fehl, läuft nur die Warteschlange (Warnung ohne Kennungen)', async () => {
+ legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) })
+ config.termine.mode = 'off'
+ config.shop.mode = 'on'
+ aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' })
+
+ const erg = await taeglicherJob(JETZT)
+ assert.equal(erg.eingeschraenkt, true)
+ assert.equal(erg.festgefahren, 1)
+ assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /Nachbau kaputt/.test(zeile)).length, 1)
+ assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\bp_|\bc_|@/)
+})
diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js
index abe80bb..faaf274 100644
--- a/test/termineOutbox.test.js
+++ b/test/termineOutbox.test.js
@@ -308,10 +308,10 @@ test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verb
/** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */
const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra })
-test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => {
- assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08'])
+test('mailKlasse: G01/G02/G08 (Webshop: G09/G12) vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => {
+ assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08', 'G09', 'G12'])
assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21'])
- const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' }
+ const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', G09: 'vorrang', G10: 'normal', G11: 'normal', G12: 'vorrang', G13: 'normal', G14: 'normal', K20: 'nachrangig', K21: 'nachrangig' }
assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet)
assert.equal(mailKlasse('unbekannt'), 'normal')
})
|