diff --git a/kimi.md b/kimi.md index 2ff9a56..be4b867 100644 --- a/kimi.md +++ b/kimi.md @@ -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. diff --git a/preview/08-mail-nachricht.png b/preview/08-mail-nachricht.png new file mode 100644 index 0000000..0555bb9 Binary files /dev/null and b/preview/08-mail-nachricht.png differ diff --git a/preview/08-mail-ticket.png b/preview/08-mail-ticket.png new file mode 100644 index 0000000..3be43f1 Binary files /dev/null and b/preview/08-mail-ticket.png differ diff --git a/server/src/index.js b/server/src/index.js index 002bd9e..e49cb20 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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) } }) diff --git a/server/src/mail.js b/server/src/mail.js index 7e54208..c1f5578 100644 --- a/server/src/mail.js +++ b/server/src/mail.js @@ -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, '"') +} + +/** 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]) => ` +
${htmlEscape(fussnote)}
` + : '' + + return ` + +| + + | +