From 32e441c8bc79420b91bf161efb72a82655bd7219 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:29:07 +0000 Subject: [PATCH] Abo-Angebote: Mail-Status in der Liste und E-Mail erneut senden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher sah man nirgends, ob die Angebots-Mail rausging, Fehler landeten nicht im Log, und senden ließ sie sich nur beim Anlegen. - Angebotsliste: Kunden-Adresse unter dem Namen, im Status "E-Mail gesendet " bzw. "E-Mail nicht verschickt" / "Keine E-Mail-Adresse hinterlegt" - Knopf "E-Mail senden" / "Erneut senden" mit Rückfrage (Adresse, letzter Versand), solange das Angebot offen und gültig ist; bei laufendem Abo, solange die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie). POST /api/portal-admin/offers/:id/notify setzt notifiedAt. - Server-Log: "[offers] Angebots-Mail vom Mailserver angenommen: 250 … queued as …" bzw. "… fehlgeschlagen: " - Lehnt der Mailserver nur den Kunden ab (die BCC-Kopie ans Team geht trotzdem raus), gilt die Mail als nicht verschickt - sendCustomerMail gibt die nodemailer-Info zurück Tests: npm test (40), Server-Durchlauf gegen Appwrite-Mock, SMTP-Sink und Stripe-Double (28 Prüfungen), Oberfläche (18 Prüfungen). Co-Authored-By: Claude Opus 5.5 --- public/admin.js | 42 +++++++++++++++++++--- server/routes/offers.js | 11 +++++- server/services/mailer.js | 3 +- server/services/offers.js | 73 ++++++++++++++++++++++++++++++++------- test/offerMail.test.js | 50 +++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 test/offerMail.test.js diff --git a/public/admin.js b/public/admin.js index 010b269..1af05ca 100644 --- a/public/admin.js +++ b/public/admin.js @@ -786,7 +786,7 @@ const OFFER_STATUS = { zurueckgezogen: ['pill-muted', 'zurückgezogen'], } -const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null } +const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() } function offerStatusPill(o) { const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status] @@ -794,6 +794,14 @@ function offerStatusPill(o) { return `${label}${sub ? `
${sub}` : ''}` } +// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable) +function offerMailStatus(o) { + if (o.notifiedAt) return `
E-Mail gesendet ${fmtDateTime(o.notifiedAt)}` + if (o.mailSendable) return '
E-Mail nicht verschickt' + if (o.status === 'offen' && !o.customerEmail) return '
Keine E-Mail-Adresse hinterlegt' + return '' +} + // Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt function offerPreviewText(price, months, regular, running) { const eur = (cents) => euroFromCents(cents) @@ -853,6 +861,7 @@ async function loadOffers() { const tbody = document.getElementById('offer-rows') try { const { offers } = await api('/api/portal-admin/offers') + offerState.offers = new Map(offers.map((o) => [o.id, o])) const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers if (!shown.length) { tbody.innerHTML = `${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}` @@ -863,18 +872,23 @@ async function loadOffers() { return ` ${fmtDateTime(o.createdAt)}
${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''} - ${escapeHtml(o.customerName)}${o.customerCode ? `
Kd.-Nr. ${escapeHtml(o.customerCode)}` : ''} + ${escapeHtml(o.customerName)}${o.customerEmail ? `
${escapeHtml(o.customerEmail).replace('@', '@')}` : ''}${o.customerCode ? `
Kd.-Nr. ${escapeHtml(o.customerCode)}` : ''} ${escapeHtml(o.text)}
Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''} ${discount ? `
${escapeHtml(discount)}` : ''} ${o.internalNote ? `
intern: ${escapeHtml(o.internalNote)}` : ''} ${o.scope === 'laufend' ? 'laufendes Abo' : fmtDate(o.validUntil)} ${offerStatusPill(o)}
Stripe: ${o.stripeCouponUrl ? `Coupon` : '–'}${o.stripeSubscriptionUrl ? ` · Abo` : ''} - ${['offen', 'abgelaufen'].includes(o.status) ? `
` : ''} + ${offerMailStatus(o)} + ${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `
+ ${o.mailSendable ? `` : ''} + ${['offen', 'abgelaufen'].includes(o.status) ? `` : ''}
` : ''} ` }).join('') tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) => btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn))) + tbody.querySelectorAll('[data-send-offer-mail]').forEach((btn) => + btn.addEventListener('click', () => sendOfferMailAction(btn.dataset.sendOfferMail, btn))) } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } @@ -893,6 +907,22 @@ async function withdrawOfferAction(id, btn) { } } +async function sendOfferMailAction(id, btn) { + const o = offerState.offers.get(id) + if (!o) return + const again = o.notifiedAt ? `\n\nZuletzt gesendet ${fmtDateTime(o.notifiedAt)}.` : '' + if (!window.confirm(`E-Mail mit dem Angebot an ${o.customerName} (${o.customerEmail}) ${o.notifiedAt ? 'erneut ' : ''}senden?${again}`)) return + btn.disabled = true + try { + await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/notify`, { method: 'POST' }) + showOfferMessage(`Die E-Mail an ${o.customerEmail} ist raus, der Mailserver hat sie angenommen. Kommt sie nicht an: Spam-Ordner prüfen, bei Gmail auch den Tab „Werbung“.`) + loadOffers() + } catch (err) { + btn.disabled = false + alert(err.message) + } +} + const offerCustomerLabel = (c) => [c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ') @@ -1001,7 +1031,11 @@ async function saveOffer(form) { showOfferMessage([ `Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}${until}.`, `Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`, - v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '', + v.notifyCustomer + ? (result.mailSent + ? `Die E-Mail an ${o.customerEmail} ist raus.` + : `E-Mail an den Kunden fehlgeschlagen (${result.mailError})${o.mailSendable ? ', sie lässt sich in der Liste erneut senden' : ''}.`) + : '', ].filter(Boolean).join(' ')) loadOffers() } catch (err) { diff --git a/server/routes/offers.js b/server/routes/offers.js index fba2078..2effa21 100644 --- a/server/routes/offers.js +++ b/server/routes/offers.js @@ -1,7 +1,7 @@ import { Router } from 'express' import { config } from '../config.js' import { listDocuments, Query } from '../services/appwriteAdmin.js' -import { adminOfferView, createOffer, listOffers, offerContext, withdrawOffer } from '../services/offers.js' +import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js' /** * Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote"). @@ -67,3 +67,12 @@ offersAdminRouter.post('/:id/withdraw', async (req, res) => { return sendError(res, err, 'Angebot konnte nicht zurückgezogen werden') } }) + +offersAdminRouter.post('/:id/notify', async (req, res) => { + try { + const doc = await resendOfferMail(req.params.id) + return res.json({ sent: true, notifiedAt: doc.notifiedAt }) + } catch (err) { + return sendError(res, err, 'E-Mail konnte nicht verschickt werden') + } +}) diff --git a/server/services/mailer.js b/server/services/mailer.js index ec72f1b..0445dce 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -80,11 +80,12 @@ export async function sendAdminNotice(subject, lines) { /** * Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem * Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang. + * Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger). */ export async function sendCustomerMail(to, subject, lines, attachments = []) { const mailer = getTransporter() if (!mailer) throw new Error('SMTP nicht konfiguriert') - await mailer.sendMail({ + return mailer.sendMail({ from: `"Webklar" <${config.smtp.from}>`, to, bcc: config.chatNotify.emails.join(', ') || undefined, diff --git a/server/services/offers.js b/server/services/offers.js index d5d9ea6..e1a55a8 100644 --- a/server/services/offers.js +++ b/server/services/offers.js @@ -107,17 +107,27 @@ function stripeDashboardUrl(kind, id) { * Testkonten bekommen den Coupon erst nach dem Abschluss (activateOfferFromCheckout), wie ein laufendes Abo. */ export function adminOfferView(doc, customer, sub = null) { + const view = offerView(doc) const testAccount = isTestEmail(customer?.email) const attachedLater = doc.scope === 'laufend' || testAccount + const discount = doc.status === OFFER_STATUS.active + ? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub) + : null + // Angebots-Mail (erneut) senden: solange das Angebot offen und gueltig ist, bei laufendem Abo solange + // die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie als "naechste Abbuchung") + const mailSendable = Boolean(customer?.email) && ( + view.status === OFFER_STATUS.open || + (doc.status === OFFER_STATUS.active && doc.scope === 'laufend' && Boolean(discount?.known) && new Date(discount.firstChargeAt) > new Date()) + ) return { - ...offerView(doc), + ...view, testAccount, - discount: doc.status === OFFER_STATUS.active - ? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub) - : null, + discount, + mailSendable, customerId: doc.customerId, customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)', customerCode: customer?.code || '', + customerEmail: customer?.email || '', internalNote: doc.internalNote || '', createdBy: doc.createdBy || '', notifiedAt: doc.notifiedAt || null, @@ -239,13 +249,18 @@ export function validateOfferInput(body) { } } -async function mailOffer(doc, customer, sub) { +/** + * Angebots-Mail an den Kunden (Kopie per BCC ans Team). nextChargeAt: bei laufendem Abo die erste + * rabattierte Abbuchung. Wirft mit Grund, wenn der Mailserver die Mail oder den Kunden als Empfaenger + * nicht annimmt; Ergebnis und Fehler stehen im Server-Log. + */ +async function mailOffer(doc, customer, nextChargeAt = null) { const running = doc.scope === 'laufend' const lines = [ `Guten Tag ${customer.name || ''}`.trim() + ',', '', running - ? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${sub?.currentPeriodEnd ? ` am ${fmtDate(sub.currentPeriodEnd)}` : ''}:` + ? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${nextChargeAt ? ` am ${fmtDate(nextChargeAt)}` : ''}:` : 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:', '', ` Angebot: ${offerText(doc)}`, @@ -268,11 +283,23 @@ async function mailOffer(doc, customer, sub) { '', FOOTER, ] - await sendCustomerMail( - customer.email, - running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo', - lines - ) + const ref = shortRef(doc.$id) + try { + const info = await sendCustomerMail( + customer.email, + running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo', + lines + ) + // Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg + const to = String(customer.email).toLowerCase() + if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) { + throw new Error(`Mailserver hat die Adresse abgelehnt (${info.rejectedErrors?.[0]?.response || info.response || 'ohne Antwort'})`) + } + console.log(`[offers] Angebots-Mail ${ref} vom Mailserver angenommen: ${info?.messageId || ''} ${info?.response || ''}`) + } catch (err) { + console.error(`[offers] Angebots-Mail ${ref} fehlgeschlagen:`, err.message) + throw err + } } /** @@ -345,7 +372,7 @@ export async function createOffer(body, admin) { if (!customer.email) { mail = { sent: false, reason: 'Kunde hat keine E-Mail-Adresse' } } else { - mail = await mailOffer(doc, customer, sub).then( + mail = await mailOffer(doc, customer, running ? sub.currentPeriodEnd : null).then( () => ({ sent: true }), (e) => ({ sent: false, reason: e.message }) ) @@ -374,6 +401,28 @@ export async function withdrawOffer(id, admin) { }) } +/** Angebots-Mail (erneut) senden, z. B. wenn sie beim Kunden nicht angekommen ist. */ +export async function resendOfferMail(id) { + const doc = await getDocument(COLLECTION(), id).catch(() => null) + if (!doc) throw httpError(404, 'Angebot nicht gefunden') + const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null) + if (!customer) throw httpError(404, 'Kunde nicht gefunden') + if (!customer.email) throw httpError(400, 'Beim Kunden ist keine E-Mail-Adresse hinterlegt.') + const sub = doc.stripeSubscriptionId + ? await getDocument(config.collections.subscriptions, doc.stripeSubscriptionId).catch(() => null) + : null + const { mailSendable, discount } = adminOfferView(doc, customer, sub) + if (!mailSendable) { + throw httpError(409, 'Für dieses Angebot geht keine E-Mail mehr raus: Es ist abgelaufen, zurückgezogen oder schon eingelöst.') + } + try { + await mailOffer(doc, customer, doc.scope === 'laufend' ? discount.firstChargeAt : null) + } catch (err) { + throw httpError(502, `E-Mail nicht verschickt: ${err.message}`) + } + return updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() }) +} + // --------------------------------------------------------------------------- // Checkout abgeschlossen (Stripe-Webhook) // --------------------------------------------------------------------------- diff --git a/test/offerMail.test.js b/test/offerMail.test.js new file mode 100644 index 0000000..feb2988 --- /dev/null +++ b/test/offerMail.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { adminOfferView } from '../server/services/offers.js' + +const DAY = 86400000 +const iso = (ms) => new Date(ms).toISOString() +const ymd = (ms) => iso(ms).slice(0, 10) +const now = Date.now() + +const offer = (fields = {}) => ({ + $id: 'offer-1', $createdAt: iso(now - DAY), status: 'offen', scope: 'neu', months: 2, + monthlyPriceCents: 2500, regularPriceCents: 5000, customerId: 'c1', + validUntil: `${ymd(now + 10 * DAY)}T00:00:00.000Z`, notifiedAt: null, ...fields, +}) +const customer = { name: 'Martin', email: 'martin@example.invalid' } + +test('Offenes, gültiges Angebot: Mail kann (erneut) raus, Adresse steht in der Admin-Ansicht', () => { + const v = adminOfferView(offer(), customer) + assert.equal(v.mailSendable, true) + assert.equal(v.customerEmail, 'martin@example.invalid') + assert.equal(v.notifiedAt, null) + assert.equal(adminOfferView(offer({ notifiedAt: iso(now) }), customer).mailSendable, true) +}) + +test('Keine Mail bei abgelaufenem, zurückgezogenem oder eingelöstem Angebot und ohne Adresse', () => { + assert.equal(adminOfferView(offer({ validUntil: `${ymd(now - 2 * DAY)}T00:00:00.000Z` }), customer).mailSendable, false) + assert.equal(adminOfferView(offer({ status: 'zurueckgezogen' }), customer).mailSendable, false) + const sub = { startedAt: iso(now - DAY), trialEnd: null } + assert.equal(adminOfferView(offer({ status: 'aktiv', appliedAt: iso(now - DAY + 5000) }), customer, sub).mailSendable, false) + const v = adminOfferView(offer(), { name: 'Ohne Mail', email: '' }) + assert.equal(v.mailSendable, false) + assert.equal(v.customerEmail, '') +}) + +test('Laufendes Abo: Mail nur, solange die erste rabattierte Abbuchung bevorsteht', () => { + const future = adminOfferView( + offer({ status: 'aktiv', scope: 'laufend', appliedAt: iso(now - 3600000) }), + customer, + { startedAt: iso(now - 10 * DAY), trialEnd: null } + ) + assert.equal(future.mailSendable, true) + const past = adminOfferView( + offer({ status: 'aktiv', scope: 'laufend', appliedAt: iso(now - 40 * DAY) }), + customer, + { startedAt: iso(now - 50 * DAY), trialEnd: null } + ) + assert.equal(past.mailSendable, false) + // Abo-Dokument fehlt: Termin unbekannt, also keine Mail + assert.equal(adminOfferView(offer({ status: 'aktiv', scope: 'laufend', appliedAt: iso(now) }), customer, null).mailSendable, false) +})