Abo-Angebote: Mail-Status in der Liste und E-Mail erneut senden

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 <Zeitpunkt>" 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 <Ref> vom Mailserver angenommen:
  <Message-ID> 250 … queued as …" bzw. "… fehlgeschlagen: <Grund>"
- 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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-27 11:12:39 +00:00
1 parent 81119c19bc
commit 32e441c8bc
5 files changed
+161 -18

No files matched your search

+38 -4
View File
@@ -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 `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
}
// Versandstand der Angebots-Mail; "nicht verschickt" nur, solange sie noch sinnvoll ist (mailSendable)
function offerMailStatus(o) {
if (o.notifiedAt) return `<br><span class="muted">E-Mail gesendet ${fmtDateTime(o.notifiedAt)}</span>`
if (o.mailSendable) return '<br><span style="color:#fbbf24">E-Mail nicht verschickt</span>'
if (o.status === 'offen' && !o.customerEmail) return '<br><span style="color:#fbbf24">Keine E-Mail-Adresse hinterlegt</span>'
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 = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
@@ -863,18 +872,23 @@ async function loadOffers() {
return `
<tr>
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
<td>${offerStatusPill(o)}
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
${offerMailStatus(o)}
${['offen', 'abgelaufen'].includes(o.status) || o.mailSendable ? `<div class="cust-actions" style="margin-top:6px">
${o.mailSendable ? `<button class="mini-btn" data-send-offer-mail="${escapeHtml(o.id)}" title="Angebots-E-Mail an ${escapeHtml(o.customerEmail)} senden">${o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}</button>` : ''}
${['offen', 'abgelaufen'].includes(o.status) ? `<button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button>` : ''}</div>` : ''}</td>
</tr>`
}).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 = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
}
@@ -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) {