diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js new file mode 100644 index 0000000..2e268a1 --- /dev/null +++ b/server/services/termine/outbox.js @@ -0,0 +1,327 @@ +import { config } from '../../config.js' +import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { sendGuestMail } from '../mailer.js' +import { renderMail } from '../mailTemplates.js' +import { pruefeNow, pruefeText } from './util.js' + +/** + * Mail-Warteschlange des Terminplaners (termineOutbox; Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6). + * Alle Termine-Mails laufen hier durch: erst speichern (einreihen), dann senden (sendeEintrag, der einzige Sendepfad). + * + * Sendepfad (sendeEintrag): Pause und Stundenbudget pruefen, Sperre `termine-mail:` (je Eintrag, parallele Laeufe + * senden nie doppelt), Eintrag neu lesen, senden, Ergebnis speichern. Fehlerklassen: + * - TypeError (ungueltige Adresse, fehlender Betreff ...) => sofort `fehler`, kein Versuch am Server + * - EAUTH / SMTP 535 (Anmeldung) => 15 Minuten Pause fuer ALLE Eintraege; der Eintrag bleibt + * `wartet`, auch `versuche` bleibt (kein Fehlversuch der Mail) + * - sonstige 5xx => sofort `fehler` + * - 4xx, Verbindungsfehler, nicht konfiguriertes SMTP => erneut nach 1, 5 und 30 Minuten, nach dem vierten + * Fehlschlag `fehler` + * Nach `gesendet` werden an, html, text und ics geleert (personenbezogene Daten). + * + * `naechsterVersuch` ist die Faelligkeit des Eintrags: bei `wartet` der naechste Sendeversuch, bei `gesendet` der + * Loeschtermin (Versand + 7 Tage), bei `fehler` ebenfalls (Fehlschlag + 48 Stunden). So findet raeumeAuf alles ueber + * den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht. + * + * Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der + * Sendezeitpunkte bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt (gleichzeitige Versuche koennen das Budget + * nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist immer ein Parameter (feste Uhr in Tests); + * ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr. + */ + +const col = () => config.collections.termineOutbox + +const MIN_MS = 60 * 1000 +const STUNDE_MS = 60 * MIN_MS +const TAG_MS = 24 * STUNDE_MS +/** Wartezeit nach dem 1., 2. und 3. Fehlschlag; der vierte ist endgueltig. */ +const WIEDERHOLUNG_MS = [1 * MIN_MS, 5 * MIN_MS, 30 * MIN_MS] +const ANMELDE_PAUSE_MS = 15 * MIN_MS +const GESENDET_BEHALTEN_MS = 7 * TAG_MS +const FEHLER_BEHALTEN_MS = 48 * STUNDE_MS +const ICS_METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH'] +/** Buchungs-IDs je Abfrage in zustellFehler (haelt die Adresse kurz). */ +const ID_BLOCK = 50 + +const iso = (wertMs) => new Date(wertMs).toISOString() + +// Zustand des Prozesses +let pauseBisMs = 0 +/** Zeitpunkte (ms) der Sendeversuche im letzten Stundenfenster (belegte Plaetze). */ +let sendeZeiten = [] +/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */ +const laufend = new Set() +/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */ +const unverbucht = new Map() + +/** Anzahl freier Plaetze im Stundenbudget zum Zeitpunkt nowMs; raeumt abgelaufene Plaetze aus dem Fenster. */ +function freieBudgetPlaetze(nowMs) { + const grenze = nowMs - STUNDE_MS + sendeZeiten = sendeZeiten.filter((zeit) => zeit > grenze) + return Math.max(0, config.termine.mailsProStunde - sendeZeiten.length) +} + +/** Pause oder ausgeschoepftes Budget (nur lesen). */ +const gebremst = (nowMs) => pauseBisMs > nowMs || freieBudgetPlaetze(nowMs) === 0 + +/** Belegt einen Platz im Budget (synchron, ohne Zwischenschritt); false bei Pause oder leerem Budget. */ +function belegePlatz(nowMs) { + if (gebremst(nowMs)) return false + sendeZeiten.push(nowMs) + return true +} + +function gibPlatzFrei(nowMs) { + const stelle = sendeZeiten.indexOf(nowMs) + if (stelle >= 0) sendeZeiten.splice(stelle, 1) +} + +/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */ +function fehlerArt(err) { + if (err instanceof TypeError) return 'dauerhaft' + const antwort = Number(err?.responseCode) + if (err?.code === 'EAUTH' || antwort === 535) return 'anmeldung' + if (antwort >= 500 && antwort <= 599) return 'dauerhaft' + return 'vorlaeufig' +} + +/** Kurzer Fehlertext fuers Feld `fehler` (hoechstens 300 Zeichen). */ +const fehlerText = (err) => + [err?.code, err?.responseCode, err?.message] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .slice(0, 300) + +/** + * Mail einreihen: rendert die Vorlage sofort, speichert den Eintrag als `wartet` und stoesst den ersten Sendeversuch + * an, ohne auf ihn zu warten (Fehler dabei stehen im Log, der Eintrag bleibt fuer verarbeite). Wirft bei ungueltigen + * Eingaben (TypeError) oder wenn das Speichern scheitert; dann ist nichts gespeichert und nichts gesendet. + * `ics` und `icsMethod` gehoeren zusammen: die METHOD-Zeile der Kalenderdatei muss icsMethod entsprechen. + * @param {{ featureId: string, bookingId: string, art: string, an: string, fromName: string, replyTo?: string|null, + * template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} daten + * @param {Date} [now] + * @returns {Promise} das gespeicherte Dokument (Status wartet) + */ +export async function einreihen( + { featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST' }, + now = new Date(), +) { + for (const [wert, name] of [[featureId, 'featureId'], [bookingId, 'bookingId'], [art, 'art'], [an, 'an'], [fromName, 'fromName'], [template, 'template']]) { + pruefeText(wert, name) + } + const nowMs = pruefeNow(now, 'einreihen') + if (ics) { + if (!ICS_METHODEN.includes(icsMethod)) throw new TypeError(`icsMethod muss ${ICS_METHODEN.join(', ')} sein`) + if (!new RegExp(`^METHOD:${icsMethod}\\r?$`, 'm').test(ics)) throw new TypeError(`ics: METHOD passt nicht zu icsMethod ${icsMethod}`) + } + + const mail = renderMail(template, values, blocks) + for (const problem of mail.problems) console.warn(`[termine-outbox] Vorlage ${template}: ${problem}`) + + const doc = await createDocument(col(), { + featureId, + bookingId, + art, + an, + fromName, + replyTo: replyTo || '', + subject: mail.subject, + html: mail.html, + text: mail.text, + ics: ics || '', + icsMethod: ics ? icsMethod : '', + status: 'wartet', + versuche: 0, + naechsterVersuch: iso(nowMs), + fehler: '', + createdAt: iso(nowMs), + }) + sendeEintrag(doc.$id, now).catch((err) => console.error(`[termine-outbox] Eintrag ${doc.$id}: ${err.message}`)) + return doc +} + +/** + * Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause + * laeuft und das Stundenbudget reicht. + * @param {string} id + * @param {Date} [now] + * @returns {Promise<'gesendet'|'wartet'|'fehler'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet; + * wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, noch nicht faellig, vorlaeufiger Fehler); fehler = dieser + * Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen + */ +export function sendeEintrag(id, now = new Date()) { + const lauf = sende(id, now).finally(() => laufend.delete(lauf)) + laufend.add(lauf) + return lauf +} + +async function sende(id, now) { + const nowMs = pruefeNow(now, 'sendeEintrag') + pruefeText(id, 'id') + if (gebremst(nowMs)) return 'wartet' + + return withLocks([`termine-mail:${id}`], async () => { + let doc + try { + doc = await getDocument(col(), id) + } catch (err) { + if (err.status === 404) return 'uebersprungen' + throw err + } + if (doc.status !== 'wartet') { + unverbucht.delete(id) + return 'uebersprungen' + } + if (unverbucht.has(id)) { + await speichereGesendet(doc, unverbucht.get(id)) + return 'gesendet' + } + if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet' + // Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen das Budget nicht ueberziehen + if (!belegePlatz(nowMs)) return 'wartet' + + try { + await sendGuestMail({ + to: doc.an, + fromName: doc.fromName, + replyTo: doc.replyTo || null, + subject: doc.subject, + html: doc.html, + text: doc.text, + ics: doc.ics || null, + icsMethod: doc.icsMethod || 'REQUEST', + }) + } catch (err) { + gibPlatzFrei(nowMs) + return speichereFehlschlag(doc, err, nowMs) + } + await speichereGesendet(doc, nowMs) + return 'gesendet' + }) +} + +/** + * Nach dem Versand: Status, Zeiten setzen und personenbezogene Felder leeren. Scheitert das Speichern (auch beim + * zweiten Versuch), merkt sich der Prozess den Versand, damit der Eintrag nicht noch einmal gesendet wird. + */ +async function speichereGesendet(doc, gesendetMs) { + const felder = { + status: 'gesendet', + versuche: (Number(doc.versuche) || 0) + 1, + gesendetAt: iso(gesendetMs), + naechsterVersuch: iso(gesendetMs + GESENDET_BEHALTEN_MS), + fehler: '', + an: '', + html: '', + text: '', + ics: '', + } + let letzterFehler + for (let versuch = 0; versuch < 2; versuch++) { + try { + await updateDocument(col(), doc.$id, felder) + unverbucht.delete(doc.$id) + return + } catch (err) { + letzterFehler = err + } + } + unverbucht.set(doc.$id, gesendetMs) + console.error(`[termine-outbox] Eintrag ${doc.$id} gesendet, Speichern gescheitert: ${letzterFehler.message}`) +} + +async function speichereFehlschlag(doc, err, nowMs) { + const art = fehlerArt(err) + if (art === 'anmeldung') { + pauseBisMs = Math.max(pauseBisMs, nowMs + ANMELDE_PAUSE_MS) + console.error(`[termine-outbox] SMTP-Anmeldung fehlgeschlagen (${err.code || err.responseCode}), Warteschlange pausiert bis ${iso(pauseBisMs)}`) + return 'wartet' + } + const versuche = (Number(doc.versuche) || 0) + 1 + const wartezeit = WIEDERHOLUNG_MS[versuche - 1] + if (art === 'vorlaeufig' && wartezeit !== undefined) { + await updateDocument(col(), doc.$id, { versuche, naechsterVersuch: iso(nowMs + wartezeit), fehler: fehlerText(err) }) + return 'wartet' + } + await updateDocument(col(), doc.$id, { status: 'fehler', versuche, naechsterVersuch: iso(nowMs + FEHLER_BEHALTEN_MS), fehler: fehlerText(err) }) + console.error(`[termine-outbox] Eintrag ${doc.$id} endgueltig gescheitert nach ${versuche} Versuch(en): ${err.code || ''} ${err.responseCode || ''}`.trim()) + return 'fehler' +} + +/** + * Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen. + * Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht. + * @param {Date} now + * @returns {Promise<{ faellig: number, gesendet: number }>} + */ +export async function verarbeite(now) { + const nowMs = pruefeNow(now, 'verarbeite') + if (pauseBisMs > nowMs) return { faellig: 0, gesendet: 0 } + const faellig = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.lessThanEqual('naechsterVersuch', iso(nowMs))]) + faellig.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || (a.$id < b.$id ? -1 : 1)) + let gesendet = 0 + for (const eintrag of faellig) { + if (gebremst(nowMs)) break + try { + if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') gesendet += 1 + } catch (err) { + console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`) + } + } + return { faellig: faellig.length, gesendet } +} + +/** Nur fuer Tests: wartet, bis alle laufenden Sendeversuche (auch die aus einreihen) beendet sind. */ +export async function warteAufVersand() { + while (laufend.size > 0) await Promise.allSettled([...laufend]) +} + +/** + * Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler). Das Portal zeigt dort + * „Bestätigung konnte nicht zugestellt werden“. Die Eintraege verschwinden mit raeumeAuf (48 Stunden nach dem Fehler). + * @param {string[]} bookingIds + * @returns {Promise>} + */ +export async function zustellFehler(bookingIds) { + const ids = [...new Set((bookingIds ?? []).filter((id) => typeof id === 'string' && id !== ''))] + const betroffen = new Set() + for (let ab = 0; ab < ids.length; ab += ID_BLOCK) { + const eintraege = await listAllDocuments(col(), [Query.equal('bookingId', ids.slice(ab, ab + ID_BLOCK)), Query.equal('status', 'fehler')]) + for (const eintrag of eintraege) betroffen.add(eintrag.bookingId) + } + return betroffen +} + +/** + * Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand und fehlgeschlagene 48 Stunden nach dem Fehler + * (beides steht in naechsterVersuch). Wartende Eintraege bleiben. + * @param {Date} now + * @returns {Promise} Anzahl geloeschter Eintraege + */ +export async function raeumeAuf(now) { + const nowMs = pruefeNow(now, 'raeumeAuf') + const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))]) + let geloescht = 0 + for (const eintrag of alt) { + try { + await deleteDocument(col(), eintrag.$id) + geloescht += 1 + } catch (err) { + if (err.status !== 404) throw err // 404: ein paralleler Lauf war schneller + } + } + return geloescht +} + +/** Ende der letzten Anmeldepause (Date) oder null, wenn es noch keine gab. Eine Pause gilt, solange das Ende nach `now` liegt. */ +export function pausiertBis() { + return pauseBisMs ? new Date(pauseBisMs) : null +} + +/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */ +export function resetOutboxForTests() { + pauseBisMs = 0 + sendeZeiten = [] + unverbucht.clear() +} diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js new file mode 100644 index 0000000..5a898fe --- /dev/null +++ b/test/termineOutbox.test.js @@ -0,0 +1,586 @@ +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' + +// Mail-Warteschlange (termineOutbox): Nachbau von Appwrite mit Schema und Datumsfeldern, Standard-Limit 25 wie in +// Produktion. Der Mailtransport ist ein Fake, der Sendeversuche zaehlt und gezielt Fehler wirft (nodemailer-Fehler +// tragen `code` und SMTP-`responseCode`). Alle Zeiten sind feste Parameter. +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 { createDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { loadTemplate } = await import('../server/services/mailTemplates.js') +const { icsTermin } = await import('../server/services/termine/ics.js') +const O = await import('../server/services/termine/outbox.js') +const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, pausiertBis, resetOutboxForTests } = O +after(() => setMailTransportForTests(null)) + +const MIN = 60000 +const STUNDE = 60 * MIN +const TAG = 24 * STUNDE +const JETZT = new Date('2026-10-05T08:00:00.000Z') +const plus = (ms, von = JETZT) => new Date(von.getTime() + ms) +const ms = (wert) => new Date(wert).getTime() +const col = () => config.collections.termineOutbox + +/** Transport: zaehlt Aufrufe, wirft die Fehler aus `fehler` der Reihe nach (leer = Erfolg), sonst landet die Mail in der Senke. */ +function fakeTransport() { + const senke = mailSink() + const transport = { + senke, + aufrufe: 0, + fehler: [], + immer: null, + verzoegerungMs: 0, + async sendMail(mail) { + transport.aufrufe += 1 + if (transport.verzoegerungMs) await new Promise((resolve) => setTimeout(resolve, transport.verzoegerungMs)) + const fehler = transport.fehler.shift() ?? transport.immer + if (fehler) throw fehler + return senke.sendMail(mail) + }, + } + return transport +} +const smtp = (responseCode, code = 'EENVELOPE') => Object.assign(new Error(`${responseCode} Testfehler`), { responseCode, code }) +const EAUTH = Object.assign(new Error('Invalid login: 535 Authentication failed'), { code: 'EAUTH', responseCode: 535 }) +const NUR_535 = Object.assign(new Error('535 5.7.8 Authentication failed'), { responseCode: 535 }) +const ETIMEDOUT = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' }) + +const ABSENDER_VORHER = config.smtp.from +let tr +let log +beforeEach(async () => { + config.smtp.from = 'noreply@webklar.com' + for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id) + resetOutboxForTests() + config.termine.mailsProStunde = 200 + tr = fakeTransport() + setMailTransportForTests(tr) + // Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.termine.mailsProStunde = 200 + config.smtp.from = ABSENDER_VORHER +}) + +const werte = (id, extra = {}) => ({ ...Object.fromEntries(loadTemplate(id).placeholders.map((p) => [p, `Wert-${p}`])), ...extra }) +const eintrag = (extra = {}) => ({ + featureId: 'f_feature01', + bookingId: 'b_buchung01', + art: 'G01', + an: 'anna@example.com', + fromName: 'Salon Muster', + replyTo: 'info@salon-muster.de', + template: 'G01', + values: werte('G01', { betreff: 'Bitte bestätigen Sie Ihren Termin bei Salon Muster' }), + blocks: { sie: true, bestaetigen: true }, + ...extra, +}) +const kalender = (methode) => + icsTermin({ + uid: 'b_buchung01@termine.webklar.com', + sequenz: 0, + methode, + start: '2026-10-12T10:00:00.000+02:00', + ende: new Date('2026-10-12T08:45:00.000Z'), + titel: 'Haarschnitt – Salon Muster', + ort: 'Hauptstraße 1, 67655 Kaiserslautern', + organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' }, + teilnehmer: { name: 'Anna Müller', email: 'anna@example.com' }, + jetzt: JETZT, + }) +/** Eintrag direkt in der Datenbank (wartet), ohne Sendeversuch. */ +const legeAn = (extra = {}) => + createDocument(col(), { + featureId: 'f_feature01', + bookingId: 'b_direkt', + art: 'G05', + an: 'anna@example.com', + fromName: 'Salon Muster', + replyTo: 'info@salon-muster.de', + subject: 'Erinnerung: Ihr Termin', + html: '

Hallo

', + text: 'Hallo', + ics: '', + icsMethod: '', + status: 'wartet', + versuche: 0, + naechsterVersuch: JETZT.toISOString(), + fehler: '', + createdAt: JETZT.toISOString(), + ...extra, + }) +const lies = async (id) => aw.docs('termineOutbox').get(id) +const alle = () => [...aw.docs('termineOutbox').values()] + +// --------------------------------------------------------------------------------------------------------------------- +// Einreihen und Senden +// --------------------------------------------------------------------------------------------------------------------- + +test('einreihen: rendert sofort, speichert wartet und sendet danach im Hintergrund (From = Betrieb, replyTo, nie BCC)', async () => { + const doc = await einreihen(eintrag(), JETZT) + assert.equal(doc.status, 'wartet') + assert.equal(doc.versuche, 0) + assert.equal(tr.aufrufe, 0, 'einreihen wartet nicht auf den Versand') + assert.deepEqual( + { featureId: doc.featureId, bookingId: doc.bookingId, art: doc.art, an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo }, + { featureId: 'f_feature01', bookingId: 'b_buchung01', art: 'G01', an: 'anna@example.com', fromName: 'Salon Muster', replyTo: 'info@salon-muster.de' }, + ) + assert.equal(doc.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Muster') + assert.match(doc.html, /Wert-leistung/, 'Vorlage ist gerendert gespeichert') + assert.match(doc.text, /Wert-leistung/) + assert.equal(ms(doc.createdAt), JETZT.getTime()) + assert.equal(ms(doc.naechsterVersuch), JETZT.getTime()) + + await warteAufVersand() + assert.equal(tr.senke.sent.length, 1) + const [mail] = tr.senke.sent + assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'Salon Muster' }) + assert.deepEqual(mail.to, [{ address: 'anna@example.com', name: '' }]) + assert.deepEqual(mail.replyTo, [{ address: 'info@salon-muster.de', name: '' }]) + assert.equal(mail.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Muster') + assert.equal(mail.html, doc.html) + assert.equal(mail.text, doc.text) + assert.equal('bcc' in mail, false) + assert.equal('icalEvent' in mail, false) +}) + +test('Erfolg: Status gesendet, an/html/text/ics geleert, Betreff und Absender bleiben; Löschtermin 7 Tage nach dem Versand', async () => { + const doc = await einreihen(eintrag({ template: 'G02', art: 'G02', values: werte('G02', { betreff: 'Ihr Termin' }), blocks: { sie: true }, ics: kalender('REQUEST') }), JETZT) + await warteAufVersand() + const danach = await lies(doc.$id) + assert.equal(danach.status, 'gesendet') + assert.equal(danach.versuche, 1) + assert.equal(ms(danach.gesendetAt), JETZT.getTime()) + assert.equal(ms(danach.naechsterVersuch), JETZT.getTime() + 7 * TAG) + for (const feld of ['an', 'html', 'text', 'ics']) assert.equal(danach[feld] || '', '', `${feld} ist geleert`) + assert.equal(danach.subject, 'Ihr Termin') + assert.equal(danach.fromName, 'Salon Muster') + assert.equal(danach.bookingId, 'b_buchung01') + assert.equal(danach.art, 'G02') +}) + +test('Kalenderdatei geht mit ihrer Methode als icalEvent raus (REQUEST, CANCEL, PUBLISH)', async () => { + for (const methode of ['REQUEST', 'CANCEL', 'PUBLISH']) { + const ics = kalender(methode) + await einreihen(eintrag({ bookingId: `b_${methode}`, ics, icsMethod: methode }), JETZT) + } + await warteAufVersand() + assert.equal(tr.senke.sent.length, 3) + assert.deepEqual(tr.senke.sent.map((m) => m.icalEvent.method).sort(), ['CANCEL', 'PUBLISH', 'REQUEST']) + for (const mail of tr.senke.sent) assert.match(mail.icalEvent.content, new RegExp(`METHOD:${mail.icalEvent.method}`)) +}) + +test('einreihen weist falsche Eingaben ab und speichert nichts (fehlende Felder, ics-Methode passt nicht oder ist unbekannt)', async () => { + for (const feld of ['featureId', 'bookingId', 'art', 'an', 'fromName', 'template']) { + await assert.rejects(() => einreihen(eintrag({ [feld]: '' }), JETZT), TypeError, feld) + } + await assert.rejects(() => einreihen(eintrag({ ics: kalender('REQUEST'), icsMethod: 'CANCEL' }), JETZT), TypeError, 'METHOD in ics = REQUEST, icsMethod = CANCEL') + await assert.rejects(() => einreihen(eintrag({ ics: kalender('REQUEST'), icsMethod: 'REPLY' }), JETZT), TypeError, 'unbekannte Methode') + await assert.rejects(() => einreihen(eintrag(), 'gestern'), TypeError, 'now muss ein Date sein') + assert.equal(alle().length, 0) + await warteAufVersand() + assert.equal(tr.aufrufe, 0) +}) + +test('Vorlagenprobleme (fehlender Wert) landen im Log, die Mail wird trotzdem gespeichert und gesendet', async () => { + const unvollstaendig = werte('G01', { betreff: 'Betreff' }) + delete unvollstaendig.leistung + await einreihen(eintrag({ values: unvollstaendig }), JETZT) + await warteAufVersand() + assert.ok(log.warn.mock.calls.some((call) => /G01.*leistung/.test(call.arguments.join(' '))), 'Warnung zu {{leistung}}') + assert.equal(tr.senke.sent.length, 1) +}) + +test('sendeEintrag meldet das Ergebnis: gesendet, uebersprungen (unbekannt oder schon erledigt)', async () => { + const doc = await legeAn() + assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet') + assert.equal(await sendeEintrag(doc.$id, JETZT), 'uebersprungen', 'zweiter Aufruf: nichts mehr zu tun') + assert.equal(await sendeEintrag('gibt-es-nicht', JETZT), 'uebersprungen') + assert.equal(tr.aufrufe, 1) +}) + +test('verarbeite sendet nur fällige wartet-Einträge, älteste zuerst; gesendet und fehler bleiben unberührt', async () => { + const faellig = await legeAn({ bookingId: 'b_faellig', an: 'faellig@example.com', naechsterVersuch: plus(-5 * MIN).toISOString(), createdAt: plus(-9 * MIN).toISOString() }) + const aelter = await legeAn({ bookingId: 'b_aelter', an: 'aelter@example.com', naechsterVersuch: plus(-1 * MIN).toISOString(), createdAt: plus(-30 * MIN).toISOString() }) + const spaeter = await legeAn({ bookingId: 'b_spaeter', naechsterVersuch: plus(MIN).toISOString() }) + const fertig = await legeAn({ bookingId: 'b_fertig', status: 'gesendet', an: '', html: '', text: '' }) + const kaputt = await legeAn({ bookingId: 'b_kaputt', status: 'fehler' }) + const ergebnis = await verarbeite(JETZT) + assert.deepEqual(ergebnis, { faellig: 2, gesendet: 2 }) + assert.equal(tr.aufrufe, 2) + assert.deepEqual(tr.senke.sent.map((m) => m.to[0].address), ['aelter@example.com', 'faellig@example.com'], 'älteste zuerst') + assert.equal((await lies(faellig.$id)).status, 'gesendet') + assert.equal((await lies(aelter.$id)).status, 'gesendet') + assert.equal((await lies(spaeter.$id)).status, 'wartet') + assert.equal((await lies(fertig.$id)).status, 'gesendet') + assert.equal((await lies(kaputt.$id)).status, 'fehler') + await assert.rejects(() => verarbeite('jetzt'), TypeError) +}) + +test('verarbeite liest alle Seiten (mehr als 25 fällige Einträge)', async () => { + for (let i = 0; i < 30; i++) await legeAn({ bookingId: `b_${i}` }) + const ergebnis = await verarbeite(JETZT) + assert.deepEqual(ergebnis, { faellig: 30, gesendet: 30 }) + assert.equal(tr.senke.sent.length, 30) +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Stundenbudget +// --------------------------------------------------------------------------------------------------------------------- + +test('Stundenbudget 2: der dritte Eintrag wartet (unverändert, ohne Versuch) und geht erst nach einer Stunde raus', async () => { + config.termine.mailsProStunde = 2 + for (const nummer of [1, 2, 3]) await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT) + await warteAufVersand() + assert.equal(tr.senke.sent.length, 2) + const zustaende = alle().map((d) => d.status).sort() + assert.deepEqual(zustaende, ['gesendet', 'gesendet', 'wartet']) + const wartend = alle().find((d) => d.status === 'wartet') + assert.equal(wartend.versuche, 0) + assert.ok(wartend.an && wartend.html, 'Inhalt bleibt erhalten') + + assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 1, gesendet: 0 }) + assert.equal(tr.senke.sent.length, 2, 'innerhalb der Stunde bleibt es bei zwei Mails') + assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 1, gesendet: 1 }) + assert.equal(tr.senke.sent.length, 3) + assert.equal(tr.aufrufe, 3, 'nur gesendete Mails zaehlen, kein Versuch fuer den wartenden Eintrag') +}) + +test('Stundenbudget ist ein gleitendes Fenster und zählt nur Mails, die wirklich rausgingen', async () => { + config.termine.mailsProStunde = 2 + await einreihen(eintrag({ bookingId: 'b_a' }), JETZT) + await einreihen(eintrag({ bookingId: 'b_b' }), plus(30 * MIN)) + await warteAufVersand() + await einreihen(eintrag({ bookingId: 'b_c' }), plus(61 * MIN)) // a ist raus aus dem Fenster, b nicht: ein Platz frei + await einreihen(eintrag({ bookingId: 'b_d' }), plus(61 * MIN)) + await warteAufVersand() + assert.equal(tr.senke.sent.length, 3) + assert.deepEqual(alle().filter((d) => d.status === 'wartet').map((d) => d.bookingId), ['b_d']) + await verarbeite(plus(89 * MIN)) + assert.equal(tr.senke.sent.length, 3, 'b ist noch im Fenster (bis 90 Minuten)') + await verarbeite(plus(90 * MIN)) + assert.equal(tr.senke.sent.length, 4) +}) + +test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verbindungsfehler)', async () => { + config.termine.mailsProStunde = 1 + tr.fehler.push(smtp(451), ETIMEDOUT) + const doc = await einreihen(eintrag(), JETZT) + await warteAufVersand() + await verarbeite(plus(MIN)) // zweiter Fehlversuch + assert.equal(tr.aufrufe, 2) + assert.equal((await lies(doc.$id)).status, 'wartet') + await verarbeite(plus(6 * MIN)) // gelingt: der einzige Platz war frei + assert.equal(tr.senke.sent.length, 1) + assert.equal((await lies(doc.$id)).status, 'gesendet') +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Fehlerklassen +// --------------------------------------------------------------------------------------------------------------------- + +test('4xx: erneut nach 1, 5 und 30 Minuten (ab dem jeweils letzten Versuch), nach dem vierten Fehlschlag fehler', async () => { + tr.immer = smtp(451) + const doc = await einreihen(eintrag(), JETZT) + await warteAufVersand() + assert.equal(tr.aufrufe, 1) + let d = await lies(doc.$id) + assert.equal(d.status, 'wartet') + assert.equal(d.versuche, 1) + assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 1 * MIN) + assert.match(d.fehler, /451/) + + await verarbeite(plus(59 * 1000)) + assert.equal(tr.aufrufe, 1, 'vor Ablauf der Minute kein neuer Versuch') + + await verarbeite(plus(1 * MIN)) + assert.equal(tr.aufrufe, 2) + d = await lies(doc.$id) + assert.equal(d.versuche, 2) + assert.equal(ms(d.naechsterVersuch), plus(6 * MIN).getTime(), '5 Minuten nach dem zweiten Versuch') + + await verarbeite(plus(5 * MIN)) + assert.equal(tr.aufrufe, 2) + await verarbeite(plus(6 * MIN)) + assert.equal(tr.aufrufe, 3) + d = await lies(doc.$id) + assert.equal(d.status, 'wartet') + assert.equal(ms(d.naechsterVersuch), plus(36 * MIN).getTime(), '30 Minuten nach dem dritten Versuch') + + await verarbeite(plus(35 * MIN)) + assert.equal(tr.aufrufe, 3) + await verarbeite(plus(36 * MIN)) + assert.equal(tr.aufrufe, 4) + d = await lies(doc.$id) + assert.equal(d.status, 'fehler') + assert.equal(d.versuche, 4) + assert.match(d.fehler, /451/) + assert.equal(ms(d.naechsterVersuch), plus(36 * MIN).getTime() + 48 * STUNDE, 'Löschtermin 48 Stunden nach dem endgültigen Fehler') + assert.ok(d.an && d.html, 'bei fehler bleibt der Inhalt bis zum Löschen (Portal zeigt den Hinweis)') + + await verarbeite(plus(3 * TAG)) + assert.equal(tr.aufrufe, 4, 'danach kein weiterer Versuch') + assert.equal(log.error.mock.calls.length >= 1, true, 'endgültiger Fehler steht im Log') +}) + +test('Verbindungsfehler ohne SMTP-Antwort werden wie 4xx wiederholt; der zweite Versuch kann gelingen', async () => { + tr.fehler.push(ETIMEDOUT) + const doc = await einreihen(eintrag(), JETZT) + await warteAufVersand() + let d = await lies(doc.$id) + assert.equal(d.status, 'wartet') + assert.equal(d.versuche, 1) + assert.match(d.fehler, /ETIMEDOUT/) + + await verarbeite(plus(MIN)) + d = await lies(doc.$id) + assert.equal(d.status, 'gesendet') + assert.equal(d.versuche, 2) + assert.equal(ms(d.gesendetAt), plus(MIN).getTime()) + assert.equal(d.an || '', '') + assert.equal(tr.senke.sent.length, 1) +}) + +test('nicht konfigurierter SMTP-Server ist ein vorübergehender Fehler (wartet, kein Absturz im Hintergrund)', async () => { + setMailTransportForTests(null) + const doc = await einreihen(eintrag(), JETZT) + await warteAufVersand() + const d = await lies(doc.$id) + assert.equal(d.status, 'wartet') + assert.equal(d.versuche, 1) + assert.match(d.fehler, /SMTP/) +}) + +test('550 und andere 5xx (außer 535): sofort fehler, kein zweiter Versuch', async () => { + for (const [nummer, fehler] of [[550, smtp(550)], [554, smtp(554, 'EMESSAGE')], [500, smtp(500)]]) { + tr.immer = fehler + const doc = await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT) + await warteAufVersand() + const d = await lies(doc.$id) + assert.equal(d.status, 'fehler', String(nummer)) + assert.equal(d.versuche, 1) + assert.match(d.fehler, new RegExp(String(nummer))) + assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 48 * STUNDE) + } + const vorher = tr.aufrufe + await verarbeite(plus(2 * TAG)) + assert.equal(tr.aufrufe, vorher) +}) + +test('ungültige Eingabe (TypeError aus sendGuestMail) ist endgültig: fehler ohne Versuch am Transport', async () => { + const doc = await einreihen(eintrag({ an: 'keine-adresse' }), JETZT) + await warteAufVersand() + const d = await lies(doc.$id) + assert.equal(d.status, 'fehler') + assert.equal(d.versuche, 1) + assert.equal(tr.aufrufe, 0) + await verarbeite(plus(TAG)) + assert.equal(tr.aufrufe, 0) +}) + +for (const [name, fehler] of [['EAUTH', EAUTH], ['535 ohne Code', NUR_535]]) { + test(`Anmeldefehler (${name}): 15 Minuten Pause für alle, Einträge bleiben wartet, in der Pause keine Sendeversuche, danach weiter`, async () => { + tr.fehler.push(fehler) + const a = await einreihen(eintrag({ bookingId: 'b_a' }), JETZT) + await warteAufVersand() + assert.equal(tr.aufrufe, 1) + const da = await lies(a.$id) + assert.equal(da.status, 'wartet') + assert.equal(da.versuche, 0, 'Anmeldefehler ist kein Fehlversuch dieser Mail') + assert.equal(ms(da.naechsterVersuch), JETZT.getTime()) + assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime()) + assert.ok(log.error.mock.calls.some((call) => /pausiert/i.test(call.arguments.join(' '))), 'Pause steht im Log') + + // neue Einträge und Jobläufe in der Pause: kein einziger Sendeversuch + const b = await einreihen(eintrag({ bookingId: 'b_b' }), plus(1 * MIN)) + await warteAufVersand() + await verarbeite(plus(5 * MIN)) + await verarbeite(plus(14 * MIN + 59 * 1000)) + assert.equal(await sendeEintrag(a.$id, plus(10 * MIN)), 'wartet') + assert.equal(tr.aufrufe, 1, 'während der Pause kein Sendeversuch') + assert.equal((await lies(b.$id)).status, 'wartet') + + // nach der Pause geht alles raus + assert.deepEqual(await verarbeite(plus(15 * MIN)), { faellig: 2, gesendet: 2 }) + assert.equal(tr.aufrufe, 3) + assert.equal(tr.senke.sent.length, 2) + assert.equal((await lies(a.$id)).status, 'gesendet') + assert.equal((await lies(b.$id)).status, 'gesendet') + }) +} + +test('pausiertBis: null ohne Pause; weitere Anmeldefehler nach der Pause verlängern sie erneut', async () => { + assert.equal(pausiertBis(), null) + tr.fehler.push(EAUTH, EAUTH) + await einreihen(eintrag(), JETZT) + await warteAufVersand() + assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime()) + await verarbeite(plus(15 * MIN)) + assert.equal(pausiertBis().getTime(), plus(30 * MIN).getTime()) + resetOutboxForTests() + assert.equal(pausiertBis(), null) +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Parallelität +// --------------------------------------------------------------------------------------------------------------------- + +test('zwei verarbeite (und ein sendeEintrag) gleichzeitig ⇒ genau eine Mail', async () => { + tr.verzoegerungMs = 30 + const doc = await legeAn() + await Promise.all([verarbeite(JETZT), verarbeite(JETZT), sendeEintrag(doc.$id, JETZT)]) + assert.equal(tr.aufrufe, 1) + assert.equal(tr.senke.sent.length, 1) + const d = await lies(doc.$id) + assert.equal(d.status, 'gesendet') + assert.equal(d.versuche, 1) +}) + +test('zwei verarbeite gleichzeitig bei einem Fehlschlag ⇒ nur ein Versuch (der zweite sieht den neuen Termin)', async () => { + tr.verzoegerungMs = 30 + tr.immer = smtp(451) + const doc = await legeAn() + await Promise.all([verarbeite(JETZT), verarbeite(JETZT)]) + assert.equal(tr.aufrufe, 1) + const d = await lies(doc.$id) + assert.equal(d.versuche, 1) + assert.equal(ms(d.naechsterVersuch), plus(MIN).getTime()) +}) + +test('Stundenbudget gilt auch bei gleichzeitigen Versuchen für verschiedene Einträge', async () => { + config.termine.mailsProStunde = 3 + tr.verzoegerungMs = 20 + const ids = [] + for (let i = 0; i < 6; i++) ids.push((await legeAn({ bookingId: `b_${i}` })).$id) + await Promise.all(ids.map((id) => sendeEintrag(id, JETZT))) + assert.equal(tr.aufrufe, 3) + assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3) +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Speicherfehler nach dem Versand +// --------------------------------------------------------------------------------------------------------------------- + +/** Die nächsten `n` PATCH-Anfragen scheitern mit 500 (der Nachbau kann nur einen eingeplanten Fehler gleichzeitig). */ +function patchSchlaegtFehl(n) { + const echt = globalThis.fetch + let rest = n + globalThis.fetch = async (url, options) => { + if (options?.method === 'PATCH' && rest > 0) { + rest -= 1 + return new Response(JSON.stringify({ message: 'Nachbau: Speichern gescheitert', code: 500 }), { status: 500 }) + } + return echt(url, options) + } + return () => (globalThis.fetch = echt) +} + +test('Speichern nach erfolgreichem Versand scheitert einmal: zweiter Speicherversuch, nur eine Mail', async () => { + const zurueck = patchSchlaegtFehl(1) + try { + const doc = await einreihen(eintrag(), JETZT) + await warteAufVersand() + assert.equal(tr.senke.sent.length, 1) + assert.equal((await lies(doc.$id)).status, 'gesendet') + } finally { + zurueck() + } +}) + +test('Speichern scheitert dauerhaft: keine zweite Mail, der Status wird beim nächsten Lauf nachgetragen', async () => { + const zurueck = patchSchlaegtFehl(2) + let doc + try { + doc = await legeAn() + assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet') + } finally { + zurueck() + } + assert.equal(tr.senke.sent.length, 1) + assert.equal((await lies(doc.$id)).status, 'wartet', 'Datenbank kennt den Versand noch nicht') + assert.ok(log.error.mock.calls.length >= 1) + + await verarbeite(plus(MIN)) + assert.equal(tr.senke.sent.length, 1, 'nicht erneut gesendet') + const d = await lies(doc.$id) + assert.equal(d.status, 'gesendet') + assert.equal(d.an || '', '') + assert.equal(ms(d.gesendetAt), JETZT.getTime(), 'Zeitpunkt des echten Versands') +}) + +// --------------------------------------------------------------------------------------------------------------------- +// Aufräumen und Zustellhinweis +// --------------------------------------------------------------------------------------------------------------------- + +test('raeumeAuf: gesendet 7 Tage nach dem Versand, fehler 48 Stunden nach dem Fehler; wartet nie', async () => { + const ok = await einreihen(eintrag({ bookingId: 'b_ok' }), JETZT) + await warteAufVersand() + tr.immer = smtp(550) + const kaputt = await einreihen(eintrag({ bookingId: 'b_kaputt' }), JETZT) + await warteAufVersand() + const wartend = await legeAn({ bookingId: 'b_wartet', createdAt: plus(-30 * TAG).toISOString() }) + + assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0) + assert.equal(await raeumeAuf(plus(48 * STUNDE)), 1) + assert.equal(await lies(kaputt.$id), undefined) + assert.ok(await lies(ok.$id)) + + assert.equal(await raeumeAuf(plus(7 * TAG - 1)), 0) + assert.equal(await raeumeAuf(plus(7 * TAG)), 1) + assert.equal(await lies(ok.$id), undefined) + + assert.equal(await raeumeAuf(plus(60 * TAG)), 0) + assert.ok(await lies(wartend.$id), 'wartende Einträge löscht das Aufräumen nie') + await assert.rejects(() => raeumeAuf(), TypeError) +}) + +test('raeumeAuf löscht mehr als eine Seite (über 25 Einträge)', async () => { + for (let i = 0; i < 40; i++) await legeAn({ bookingId: `b_${i}`, status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: JETZT.toISOString() }) + assert.equal(await raeumeAuf(JETZT), 40) + assert.equal(alle().length, 0) +}) + +test('zustellFehler: Menge der Buchungen mit endgültig gescheiterter Mail (nur fehler, auch über mehrere Blöcke)', async () => { + await legeAn({ bookingId: 'b_fehler', status: 'fehler' }) + await legeAn({ bookingId: 'b_fehler', status: 'fehler' }) + await legeAn({ bookingId: 'b_spaet', status: 'fehler' }) + await legeAn({ bookingId: 'b_gesendet', status: 'gesendet' }) + await legeAn({ bookingId: 'b_wartet', status: 'wartet' }) + await legeAn({ bookingId: 'b_fremd', status: 'fehler' }) + + const treffer = await zustellFehler(['b_fehler', 'b_gesendet', 'b_wartet', 'b_unbekannt']) + assert.ok(treffer instanceof Set) + assert.deepEqual([...treffer], ['b_fehler']) + + const viele = Array.from({ length: 130 }, (_, i) => `b_x${i}`) + viele.splice(120, 0, 'b_spaet') + assert.deepEqual([...(await zustellFehler(viele))], ['b_spaet']) + assert.deepEqual([...(await zustellFehler([]))], []) + assert.deepEqual([...(await zustellFehler(undefined))], []) +}) + +test('zustellFehler: auch eine gescheiterte Mail an den Betrieb (K20) zählt; erfolgreiche Mails derselben Buchung ändern daran nichts', async () => { + tr.immer = smtp(550) + await einreihen(eintrag({ bookingId: 'b_1', template: 'K20', art: 'K20', values: werte('K20', { betreff: 'Neue Buchung' }), blocks: {} }), JETZT) + await warteAufVersand() + tr.immer = null + await einreihen(eintrag({ bookingId: 'b_1' }), JETZT) + await warteAufVersand() + assert.deepEqual([...(await zustellFehler(['b_1']))], ['b_1']) +})