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)
+})