v1.4: HTML-E-Mail-Benachrichtigungen im Schulportal-Stil
- server/src/mail.js: baueHtmlMail/buttonHtml (600-px-Karte, Kopfzeile in Hinterstein-Farben #22333b + Amber, Zeilen-Tabelle, Status-Box, Zitat-Box, Aktions-Button 'Zum Mieterportal', Footer), immer mit Plain-Text-Fallback - index.js: Ticket, neue Unterhaltung und Antwort versenden HTML + Text (multipart/alternative ueber die Mail-API) - Vorschauen: preview/08-mail-ticket.png, preview/08-mail-nachricht.png - E2E live verifiziert (Zustellung an axel@hocks.eu, status=sent), Doku
This commit is contained in:
@@ -151,13 +151,16 @@ tar -czf mp-dist.tar.gz -C dist .
|
||||
- **Mail-Stack live (seit 02.10.2026):** Container `mp-mail` + `mp-mail-api` laufen
|
||||
auf hocks.eu unter `/opt/mp-mail` (Relay über `hocks-eu.mail.protection.outlook.com`).
|
||||
Testmail und API-`/send` (mit `X-Api-Token`) verifiziert (status=sent); ohne Token 401.
|
||||
- **Mail-Benachrichtigungen (seit v1.2 nginx-Proxy, seit v1.3 serverseitig):** Der
|
||||
Portal-nginx proxiet `POST /api/mail/send` an `http://mp-mail-api:8080/send` mit
|
||||
Token aus `portal.env` (403 für alles außer POST). **Versand aus der Node-API**
|
||||
- **Mail-Benachrichtigungen (seit v1.2 nginx-Proxy, seit v1.3 serverseitig,
|
||||
seit v1.4 im HTML-Format wie im Schulportal):** Der Portal-nginx proxiet
|
||||
`POST /api/mail/send` an `http://mp-mail-api:8080/send` mit Token aus
|
||||
`portal.env` (403 für alles außer POST). **Versand aus der Node-API**
|
||||
(`server/src/mail.js`, fire-and-forget): neue Schadensmeldung → alle aktiven
|
||||
Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle (Mieter ↔ Verwaltung,
|
||||
Auflösung über Wohnungsbezug). E2E verifiziert: Zustellung an axel@hocks.eu
|
||||
mit Postfix status=sent (02.10., 10:42 Uhr).
|
||||
Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle. HTML im
|
||||
Schulportal-Stil (`baueHtmlMail`: 600-px-Karte, Kopfzeile in Hinterstein-
|
||||
Farben #22333b/Amber, Zeilen-Tabelle, Status-Box, Zitat-Box, Button „Zum
|
||||
Mieterportal“, Footer) immer mit Plain-Text-Fallback (multipart/alternative).
|
||||
E2E verifiziert: Zustellung an axel@hocks.eu, Postfix status=sent.
|
||||
|
||||
## 6. Konventionen & Besonderheiten
|
||||
|
||||
@@ -229,6 +232,14 @@ an axel@hocks.eu) E2E verifiziert; Testdaten danach bereinigt. deploy.sh baut
|
||||
das API-Image auf dem Server; `.env`-Symlink auf portal.env für
|
||||
Compose-Interpolation beachten (POSTGRES_PASSWORD!).
|
||||
|
||||
**Erledigt 02.10. (v1.4 live):** HTML-E-Mail-Benachrichtigungen im Schulportal-
|
||||
Stil: `baueHtmlMail`/`buttonHtml` in `server/src/mail.js` (600-px-Karte,
|
||||
Kopfzeile Hinterstein-Farben, Zeilen, Status-Box, Zitat, Aktions-Button, Footer),
|
||||
immer mit Plain-Text-Fallback (multipart/alternative). Umgesetzt für
|
||||
Schadensmeldung, neue Unterhaltung und Antwort. Vorschauen lokal renderbar
|
||||
(`mail-vorschau*.html`, Screenshots in `preview/08-*.png`). Live getestet:
|
||||
Zustellung an axel@hocks.eu status=sent, Test-Ticket bereinigt.
|
||||
|
||||
**Offen:**
|
||||
- Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge
|
||||
erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+47
-11
@@ -258,12 +258,25 @@ app.post('/api/tickets', auth, async (req, res, next) => {
|
||||
deutschesDatum()],
|
||||
)
|
||||
const label = mail.einheitLabel(mapUnit(unitRows[0]))
|
||||
mail.benachrichtigeVerwaltung(
|
||||
`Neue Schadensmeldung: ${String(title).trim()} (${label})`,
|
||||
const betreff = `Neue Schadensmeldung: ${String(title).trim()} (${label})`
|
||||
const text =
|
||||
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
|
||||
`Wohnung: ${label}\nMeldung von: ${mieterName}\nKategorie: ${String(category || 'Sonstiges')}\n\n` +
|
||||
`${String(title).trim()}\n${String(description || '–').trim()}`,
|
||||
)
|
||||
`Wohnung: ${label}\nMeldung von: ${mieterName}\nKategorie: ${String(category || 'Sonstiges')}\n\n` +
|
||||
`${String(title).trim()}\n${String(description || '–').trim()}`
|
||||
const html = mail.baueHtmlMail({
|
||||
titel: 'Neue Schadensmeldung',
|
||||
untertitel: label,
|
||||
einleitung: 'Im Mieterportal wurde eine neue Schadensmeldung erstellt:',
|
||||
zeilen: [
|
||||
['Wohnung', label],
|
||||
['Meldung von', mieterName],
|
||||
['Kategorie', String(category || 'Sonstiges')],
|
||||
],
|
||||
statusBox: { hintergrund: '#f0fdfa', farbe: '#0f766e', text: 'Status: offen – neu eingegangen' },
|
||||
zitat: `${String(title).trim()}\n${String(description || '–').trim()}`,
|
||||
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/schadensmeldungen`),
|
||||
})
|
||||
mail.benachrichtigeVerwaltung(betreff, text, html)
|
||||
res.json({ ticket: mapTicket(rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -308,9 +321,20 @@ app.post('/api/threads', auth, async (req, res, next) => {
|
||||
const betreff = vonRolle === 'tenant'
|
||||
? `Neue Nachricht im Mieterportal: ${String(subject).trim()}`
|
||||
: `Nachricht der Verwaltung: ${String(subject).trim()}`
|
||||
const inhalt = `Neue Unterhaltung von ${autor}:\n\n„${String(text).trim()}“`
|
||||
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, inhalt)
|
||||
else mail.benachrichtigeMieter(unitId, betreff, inhalt)
|
||||
const textVersion = `Neue Unterhaltung von ${autor}:\n\n„${String(text).trim()}“`
|
||||
const html = mail.baueHtmlMail({
|
||||
titel: vonRolle === 'tenant' ? 'Neue Nachricht' : 'Nachricht der Verwaltung',
|
||||
untertitel: String(subject).trim(),
|
||||
einleitung: `Neue Unterhaltung von ${autor}:`,
|
||||
zeilen: [
|
||||
['Wohnung', mail.einheitLabel(mapUnit(unitRows[0]))],
|
||||
['Betreff', String(subject).trim()],
|
||||
],
|
||||
zitat: String(text).trim(),
|
||||
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`),
|
||||
})
|
||||
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, textVersion, html)
|
||||
else mail.benachrichtigeMieter(unitId, betreff, textVersion, html)
|
||||
const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [id])
|
||||
const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [id])
|
||||
res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } })
|
||||
@@ -340,9 +364,21 @@ app.post('/api/threads/:id/messages', auth, async (req, res, next) => {
|
||||
const betreff = vonRolle === 'tenant'
|
||||
? `Neue Antwort im Mieterportal: ${th.subject}`
|
||||
: `Antwort der Verwaltung: ${th.subject}`
|
||||
const inhalt = `Neue Nachricht von ${autor} in der Unterhaltung „${th.subject}“:\n\n„${text}“`
|
||||
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, inhalt)
|
||||
else mail.benachrichtigeMieter(th.unit_id, betreff, inhalt)
|
||||
const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [th.unit_id])
|
||||
const textVersion = `Neue Nachricht von ${autor} in der Unterhaltung „${th.subject}“:\n\n„${text}“`
|
||||
const html = mail.baueHtmlMail({
|
||||
titel: vonRolle === 'tenant' ? 'Neue Antwort' : 'Antwort der Verwaltung',
|
||||
untertitel: th.subject,
|
||||
einleitung: `Neue Nachricht von ${autor}:`,
|
||||
zeilen: [
|
||||
['Wohnung', mail.einheitLabel(mapUnit(einheitRows[0]))],
|
||||
['Unterhaltung', th.subject],
|
||||
],
|
||||
zitat: text,
|
||||
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`),
|
||||
})
|
||||
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, textVersion, html)
|
||||
else mail.benachrichtigeMieter(th.unit_id, betreff, textVersion, html)
|
||||
const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [th.id])
|
||||
const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [th.id])
|
||||
res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } })
|
||||
|
||||
+113
-9
@@ -3,21 +3,118 @@
|
||||
* Fire-and-forget: Ein Fehler (Mail-API down, Relay-Fehler) beeinträchtigt
|
||||
* die auslösende Portal-Aktion nie. Die Mail-API selbst puffert im Fehlerfall
|
||||
* in ihrer Outbox und versucht es erneut.
|
||||
*
|
||||
* Gestaltung wie im Schulportal: HTML-Karte (600 px, Kopfzeile in Portal-Farben,
|
||||
* Zeilen-Tabelle, optional Status-Box/Zitat/Aktions-Button) plus Plain-Text-
|
||||
* Fallback, immer beide an die Mail-API (sendet multipart/alternative).
|
||||
*/
|
||||
|
||||
const MAIL_API_URL = process.env.MAIL_API_URL || '' // z. B. http://mp-mail-api:8080
|
||||
const MAIL_API_TOKEN = process.env.MAIL_API_TOKEN || ''
|
||||
const PORTAL_URL = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'
|
||||
|
||||
const FUSSZEILE =
|
||||
'\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
|
||||
// Portal-Farben (Hinterstein): dunkles Petrol + Amber-Akzent (vgl. Login-Seite)
|
||||
const FARBE_KOPF = '#22333b' // Dunkel-Petrol
|
||||
const FARBE_KOPF_ZEILE = '#fcd34d' // Amber-200 (Kicker-Zeile)
|
||||
const FARBE_UNTERTITEL = '#cbd5e1'
|
||||
const FARBE_BUTTON = '#f59e0b' // Amber-500
|
||||
const FARBE_BUTTON_TEXT = '#1c1917'
|
||||
const HINTERGRUND = '#f1f5f9'
|
||||
const KARTE = '#ffffff'
|
||||
const TEXT = '#0f172a'
|
||||
const TEXT_SOFT = '#64748b'
|
||||
const RAND = '#e2e8f0'
|
||||
|
||||
async function sendeMail(an, betreff, text) {
|
||||
const FUSSZEILE_TEXT =
|
||||
'— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
|
||||
|
||||
function htmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** Baut die HTML-Karte im Schulportal-Stil. Alle Inhalte werden escaped. */
|
||||
function baueHtmlMail(opts) {
|
||||
const { titel, untertitel = '', einleitung = '', zeilen = [], statusBox, zitat, aktionenHtml = '', fussnote = '' } = opts
|
||||
|
||||
const zeilenHtml = zeilen
|
||||
.map(
|
||||
([label, wert]) => `
|
||||
<tr>
|
||||
<td style="padding:8px 12px 8px 0;vertical-align:top;color:${TEXT_SOFT};font-size:13px;white-space:nowrap;">${htmlEscape(label)}</td>
|
||||
<td style="padding:8px 0;vertical-align:top;font-size:14px;color:${TEXT};">${htmlEscape(wert)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
const statusHtml = statusBox
|
||||
? `
|
||||
<div style="margin:20px 0;padding:12px 16px;border-radius:8px;background:${statusBox.hintergrund};color:${statusBox.farbe};font-size:14px;font-weight:600;">${htmlEscape(statusBox.text)}</div>`
|
||||
: ''
|
||||
|
||||
const zitatHtml = zitat
|
||||
? `
|
||||
<div style="margin:16px 0 0;padding:12px 16px;border-left:4px solid ${FARBE_BUTTON};background:#fffbeb;border-radius:0 8px 8px 0;font-size:14px;color:${TEXT};line-height:1.6;white-space:pre-wrap;">${htmlEscape(zitat)}</div>`
|
||||
: ''
|
||||
|
||||
const fussnotenHtml = fussnote
|
||||
? `<p style="margin:20px 0 0;font-size:12px;color:${TEXT_SOFT};line-height:1.6;border-top:1px solid ${RAND};padding-top:16px;">${htmlEscape(fussnote)}</p>`
|
||||
: ''
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(titel)}</title></head>
|
||||
<body style="margin:0;padding:0;background:${HINTERGRUND};">
|
||||
<div style="display:none;max-height:0;overflow:hidden;">${htmlEscape(einleitung || titel)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${HINTERGRUND};">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 12px;">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:${KARTE};border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(15,23,42,.08);">
|
||||
<tr>
|
||||
<td style="background:${FARBE_KOPF};padding:20px 28px;">
|
||||
<p style="margin:0;color:${FARBE_KOPF_ZEILE};font-size:12px;letter-spacing:.08em;text-transform:uppercase;">Hinterstein Immobilien</p>
|
||||
<h1 style="margin:6px 0 0;color:#ffffff;font-size:18px;font-weight:700;">${htmlEscape(titel)}</h1>
|
||||
${untertitel ? `<p style="margin:4px 0 0;color:${FARBE_UNTERTITEL};font-size:12px;">${htmlEscape(untertitel)}</p>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
${einleitung ? `<p style="margin:0 0 8px;font-size:14px;color:${TEXT};line-height:1.6;">${htmlEscape(einleitung)}</p>` : ''}
|
||||
${statusHtml}
|
||||
${zitatHtml}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-top:1px solid ${RAND};">${zeilenHtml}</table>
|
||||
${aktionenHtml}
|
||||
${fussnotenHtml}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#f8fafc;padding:14px 28px;border-top:1px solid ${RAND};">
|
||||
<p style="margin:0;font-size:11px;color:${TEXT_SOFT};">Hinterstein Immobilien (Demo) · <a href="${PORTAL_URL}" style="color:${FARBE_KOPF};">mieterportal.hocks.eu</a><br>Diese E-Mail wurde automatisch vom Mieterportal erzeugt. Bitte antworten Sie nicht direkt – nutzen Sie das Nachrichten-Modul im Portal.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/** Aktions-Button (portal-interne Links). */
|
||||
function buttonHtml(text, url) {
|
||||
return `<p style="margin:24px 0 0;"><a href="${htmlEscape(url)}" style="display:inline-block;background:${FARBE_BUTTON};color:${FARBE_BUTTON_TEXT};text-decoration:none;font-size:14px;font-weight:700;padding:12px 22px;border-radius:10px;">${htmlEscape(text)}</a></p>`
|
||||
}
|
||||
|
||||
async function sendeMail(an, betreff, text, html) {
|
||||
if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return
|
||||
try {
|
||||
const res = await fetch(`${MAIL_API_URL}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN },
|
||||
body: JSON.stringify({ to: an, subject: betreff, text: `${text}${FUSSZEILE}` }),
|
||||
body: JSON.stringify({ to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, ...(html ? { html } : {}) }),
|
||||
})
|
||||
if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`)
|
||||
} catch (e) {
|
||||
@@ -26,24 +123,31 @@ async function sendeMail(an, betreff, text) {
|
||||
}
|
||||
|
||||
/** Alle aktiven Verwaltungsnutzer benachrichtigen. */
|
||||
async function benachrichtigeVerwaltung(betreff, text) {
|
||||
async function benachrichtigeVerwaltung(betreff, text, html) {
|
||||
const { rows } = await require('./db').pool.query(
|
||||
`SELECT email FROM users WHERE rolle = 'verwaltung' AND aktiv AND email <> ''`,
|
||||
)
|
||||
for (const r of rows) sendeMail(r.email, betreff, text)
|
||||
for (const r of rows) sendeMail(r.email, betreff, text, html)
|
||||
}
|
||||
|
||||
/** Mieter einer bestimmten Wohnung benachrichtigen. */
|
||||
async function benachrichtigeMieter(einheitId, betreff, text) {
|
||||
async function benachrichtigeMieter(einheitId, betreff, text, html) {
|
||||
const { rows } = await require('./db').pool.query(
|
||||
`SELECT email FROM users WHERE rolle = 'mieter' AND aktiv AND einheit_id = $1 AND email <> ''`,
|
||||
[einheitId],
|
||||
)
|
||||
for (const r of rows) sendeMail(r.email, betreff, text)
|
||||
for (const r of rows) sendeMail(r.email, betreff, text, html)
|
||||
}
|
||||
|
||||
function einheitLabel(u) {
|
||||
return u ? `${u.building}, Whg. ${u.number}` : ''
|
||||
}
|
||||
|
||||
module.exports = { sendeMail, benachrichtigeVerwaltung, benachrichtigeMieter, einheitLabel }
|
||||
module.exports = {
|
||||
sendeMail,
|
||||
benachrichtigeVerwaltung,
|
||||
benachrichtigeMieter,
|
||||
einheitLabel,
|
||||
baueHtmlMail,
|
||||
buttonHtml,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user