From 5503ce5fc5cd740a6ec787289f4717615b55f5af Mon Sep 17 00:00:00 2001 From: ah-admin Date: Fri, 2 Oct 2026 12:21:12 +0200 Subject: [PATCH] v1.2: E-Mail-Benachrichtigungen an Portal angeschlossen - nginx-Proxy POST /api/mail/send -> mp-mail-api:8080 (nur POST, 64k Limit) - Token serverseitig via envsubst-Template + portal.env (gitignored, nur Server) - src/lib/mail.ts: fire-and-forget Versand, Empfaenger-Aufloesung (Verwaltung/Mieter) - Benachrichtigung: neue Schadensmeldung -> Verwaltung, Nachrichten/Antworten -> Gegenstelle - E2E live verifiziert (versandt: true an axel@hocks.eu), GET 403 - Doku (kimi.md, README) nachgefuehrt --- .gitignore | 1 + README.md | 10 +++- deploy.sh | 4 +- docker-compose.yml | 5 +- docker/templates/default.conf.template | 43 +++++++++++++++++ kimi.md | 29 ++++++++++-- src/lib/mail.ts | 64 ++++++++++++++++++++++++++ src/pages/Messages.tsx | 59 +++++++++++++++++++++--- src/pages/Tickets.tsx | 28 +++++++++-- 9 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 docker/templates/default.conf.template create mode 100644 src/lib/mail.ts diff --git a/.gitignore b/.gitignore index 8298fe6..98a2d85 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dist-ssr # Projekt-spezifisch: Secrets und lokale Artefakte mail-server/mail.env +portal.env mail-url.txt mail-vorschau*.html tmp-* diff --git a/README.md b/README.md index d1025c3..0196703 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,16 @@ Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Conta Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8080:80 mieterportal`. -## Optional: Mail-Stack +## Mail-Stack & Benachrichtigungen `mail-server/` enthält den aus dem Schulportal übernommenen Aufbau: Postfix-Relay (nur intern, kein offenes Relay) + schlanke Python-API. Konfiguration über `mail-server/mail.env` (Vorlage: `mail.env.example`), **nicht** im Repo. + +Auf dem Server läuft der Stack unter `/opt/mp-mail`. Das Portal ist verkabelt: +Der nginx des `mp-portal`-Containers proxiet ausschließlich `POST /api/mail/send` +an die Mail-API und authentifiziert sich dabei serverseitig mit dem Token aus +`portal.env` (nur auf dem Server, gitignored). Das Frontend benachrichtigt die +Verwaltung bei neuen Schadensmeldungen und die jeweilige Gegenstelle bei +Nachrichten – Versand fire-and-forget, ein Ausfall der Mail-API beeinträchtigt +das Portal nicht. diff --git a/deploy.sh b/deploy.sh index c422754..ce47a08 100644 --- a/deploy.sh +++ b/deploy.sh @@ -20,10 +20,10 @@ echo "==> 1/4 Frontend bauen" npm run build echo "==> 2/4 Paket erstellen und hochladen" -rm -rf tmp-deploy && mkdir -p tmp-deploy/docker +rm -rf tmp-deploy && mkdir -p tmp-deploy/docker/templates cp -r dist tmp-deploy/dist cp docker-compose.yml tmp-deploy/ -cp docker/nginx.conf tmp-deploy/docker/ +cp docker/templates/default.conf.template tmp-deploy/docker/templates/ tar -czf mp-dist.tar.gz -C tmp-deploy . rm -rf tmp-deploy "$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \ diff --git a/docker-compose.yml b/docker-compose.yml index c8f71c2..b263313 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,10 @@ services: restart: unless-stopped volumes: - ./dist:/usr/share/nginx/html:ro - - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro + # envsubst ersetzt ${MAIL_API_TOKEN} beim Start aus portal.env + - ./docker/templates/default.conf.template:/etc/nginx/templates/default.conf.template:ro + env_file: + - portal.env networks: - npm_default diff --git a/docker/templates/default.conf.template b/docker/templates/default.conf.template new file mode 100644 index 0000000..c2dffa9 --- /dev/null +++ b/docker/templates/default.conf.template @@ -0,0 +1,43 @@ +server { + listen 80; + server_name mieterportal.hocks.eu localhost _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript image/svg+xml application/json; + gzip_min_length 1024; + + # HTML nie cachen (Assets sind hash-benannt → ein Reload genügt) + location = /index.html { + add_header Cache-Control "no-cache" always; + } + + # Cache für gehashte Build-Assets + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Mail-API des Portals (Container mp-mail-api im selben Docker-Netz npm_default). + # Das Token kommt aus portal.env (envsubst beim Container-Start) und liegt + # NIE im Repo und NIE im Browser – der Endpunkt ist nur vom Server erreichbar. + location = /api/mail/send { + limit_except POST { deny all; } + proxy_pass http://mp-mail-api:8080/send; + proxy_set_header Host $host; + proxy_set_header X-Api-Token "${MAIL_API_TOKEN}"; + proxy_set_header X-Request-Source mieterportal-nginx; + client_max_body_size 64k; + proxy_read_timeout 15s; + } + + # SPA-Fallback für React Router + location / { + try_files $uri $uri/ /index.html; + } + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; +} diff --git a/kimi.md b/kimi.md index cc991fb..55a9eba 100644 --- a/kimi.md +++ b/kimi.md @@ -48,7 +48,7 @@ Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-08 | Auth | `src/lib/auth.ts` (SHA-256, Session 8 h, Keys `mp_benutzer`/`mp_benutzer_session`), Route-Guard in `App.tsx` | | Branding | Kunde: `src/assets/hinterstein/` (logo.png, joseph-hinterstein.jpg, kontakt.jpg); KI-generiert: `src/assets/logo.png`, `hero.jpg`, `modules.jpg` | | Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` | -| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live auf dem Server** unter `/opt/mp-mail` | +| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live**, `/opt/mp-mail` auf dem Server; **verkabelt**: Portal-nginx proxyt `POST /api/mail/send` → `mp-mail-api:8080` und injiziert das Token aus `portal.env` (serverseitig, nie im Browser/Repo) | | Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) | ## 3. Wichtige Dateien @@ -68,6 +68,8 @@ ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.). | `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) | | `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) | | `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login-Formular + Registrierung-Link + Demo-Einstiege | +| `src/lib/mail.ts` | E-Mail-Benachrichtigungen: `sendeBenachrichtigung` (fire-and-forget) an `/api/mail/send`, Empfänger-Auflösung (Verwaltung / Mieter je Wohnung) | +| `docker/templates/default.conf.template` | nginx-Site als envsubst-Template; proxiet `POST /api/mail/send` → `mp-mail-api:8080` mit `${MAIL_API_TOKEN}` aus `portal.env` | | `src/pages/Benutzer.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer, Gruppen, Registrierungscodes | | `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow | | `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header | @@ -101,6 +103,10 @@ tar -czf mp-dist.tar.gz -C dist . ``` - SSH: Port **38592**, Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk` (Pageant), Hostkey-Flag zwingend. +- **`portal.env` auf dem Server wird vom compose als `env_file` gebraucht** + (liefert `MAIL_API_TOKEN` für den nginx-envsubst). Einmalig angelegt: + `grep '^MAIL_API_TOKEN=' /opt/mp-mail/mail.env > /opt/mieterportal/portal.env` + Liegt nur auf dem Server (gitignored), wird von deploy.sh nicht hochgeladen. - **Vorsicht Fail2Ban:** zu viele gescheiterte SSH-Versuche von einer IP führen zu einem Ban (Timeout-Verbindungsbild). Ban aufheben auf dem Server: `fail2ban-client unban --all`. - **Live (seit 02.10.2026):** Container `mp-portal` läuft auf hocks.eu, statisches `dist/` @@ -126,7 +132,17 @@ 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. - Noch nicht mit dem Portal-Frontend verkabelt (keine automatischen Benachrichtigungen). +- **Mail-Benachrichtigungen im Portal (seit 02.10., v1.2):** Der Portal-nginx + (`docker/templates/default.conf.template`, envsubst) proxiet ausschließlich + `POST /api/mail/send` an `http://mp-mail-api:8080/send` und setzt das Token + aus `/opt/mieterportal/portal.env` (chmod 600, **nicht im Repo**, gitignored). + Das Frontend (`src/lib/mail.ts`) sendet fire-and-forget: neue Schadensmeldung → + alle aktiven Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle + (Mieter ↔ Verwaltung, Empfänger über Wohnungsbezug). E2E verifiziert: + POST über `https://mieterportal.hocks.eu/api/mail/send` → `versandt: true` + (Test an axel@hocks.eu); GET wird mit 403 blockiert. Demo-Empfängeradressen + (z. B. joseph@hinterstein-immo.de) sind fiktiv → dort kein Empfang möglich, + echte Adressen in der Benutzerverwaltung pflegen. ## 6. Konventionen & Besonderheiten @@ -174,11 +190,16 @@ aktiviert (`/opt/mp-mail`, Testmail + API-/send verifiziert). Store-Key auf `hinterstein-mieterportal-v2` gehoben. Live deployed und geprüft (HTTP 200, Titel „Hinterstein Immobilien · Mieterportal"). +**Erledigt 02.10. (v1.2 live):** E-Mail-Benachrichtigungen an das Portal +angeschlossen: nginx-Proxy `/api/mail/send` mit serverseitig injiziertem Token +(envsubst-Template + `portal.env` auf dem Server), `src/lib/mail.ts` sendet bei +neuen Schadensmeldungen (→ Verwaltung) und Nachrichten/Antworten (→ Gegenstelle). +E2E-Versand live verifiziert (versandt: true), GET auf den Endpunkt 403. + **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. -- Portal-Frontend an Mail-API verkabeln: automatische E-Mail an die Verwaltung bei neuen - Schadensmeldungen und an Mieter bei Antworten. +- Demo-Empfängeradressen durch echte ersetzen, sobald bekannt. - Mögliche nächste Schritte: serverseitige Datenbank (PostgreSQL/Node-API) statt localStorage, Mehrseitigkeit für 50 echte Mietverhältnisse. diff --git a/src/lib/mail.ts b/src/lib/mail.ts new file mode 100644 index 0000000..85b2025 --- /dev/null +++ b/src/lib/mail.ts @@ -0,0 +1,64 @@ +/** E-Mail-Benachrichtigungen über die Portal-Mail-API. + * + * Der Browser ruft nur denselben Ursprung an: POST /api/mail/send wird vom + * Portal-nginx (Container mp-portal) an die Mail-API (mp-mail-api:8080) + * weitergereicht und dabei serverseitig mit dem API-Token authentifiziert. + * Das Token liegt also weder im Repo noch im JavaScript. + * + * Versand ist bewusst „fire-and-forget": Klappt der Versand nicht (Offline, + * API down), bleibt die Portal-Aktion trotzdem erhalten – der Fehler landet + * nur in der Browser-Konsole. + */ + +import type { Benutzer } from '../types' + +const ENDPUNKT = '/api/mail/send' + +export async function sendeMail(an: string, betreff: string, text: string): Promise { + try { + const res = await fetch(ENDPUNKT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to: an, subject: betreff, text }), + }) + return res.ok + } catch { + return false + } +} + +/** Fire-and-forget an eine oder mehrere Adressen (null/leer = nichts senden). */ +export function sendeBenachrichtigung(an: string | string[] | null | undefined, betreff: string, text: string) { + const adressen = (Array.isArray(an) ? an : an ? [an] : []).filter(Boolean) + for (const adresse of adressen) { + sendeMail(adresse, betreff, text).then((ok) => { + if (!ok) console.warn(`[mail] Versand an ${adresse} fehlgeschlagen`) + }) + } +} + +const FUSSZEILE = + '\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien' + +export function nachrichtenText(koerper: string): string { + return `${koerper}${FUSSZEILE}` +} + +/** E-Mail-Adressen aller aktiven Verwaltungsbenutzer. */ +export function verwaltungsAdressen(benutzer: Benutzer[]): string[] { + return benutzer + .filter((b) => b.rolle === 'verwaltung' && b.aktiv && b.email) + .map((b) => b.email) +} + +/** E-Mail-Adressen der Mieter einer bestimmten Wohnung. */ +export function mieterAdressenFuerEinheit(benutzer: Benutzer[], einheitId: string): string[] { + return benutzer + .filter((b) => b.rolle === 'mieter' && b.aktiv && b.einheitId === einheitId && b.email) + .map((b) => b.email) +} + +/** Anzeigename einer Wohnung, z. B. „Hauptstraße 12, Whg. 7". */ +export function einheitLabel(einheit: { building: string; number: string }): string { + return `${einheit.building}, Whg. ${einheit.number}` +} diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index a77f2b9..deef8e0 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -13,9 +13,30 @@ import { } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import { useStore } from '../store/store' +import { + sendeBenachrichtigung, + verwaltungsAdressen, + mieterAdressenFuerEinheit, + nachrichtenText, +} from '../lib/mail' + +/** Gegenstelle benachrichtigen: Mieter → Verwaltung, Verwaltung → Mieter der Wohnung. */ +function benachrichtigeGegenstelle(opts: { + benutzer: ReturnType['benutzer'] + vonRolle: 'tenant' | 'admin' + einheitId: string + betreff: string + text: string +}) { + const adressen = + opts.vonRolle === 'tenant' + ? verwaltungsAdressen(opts.benutzer) + : mieterAdressenFuerEinheit(opts.benutzer, opts.einheitId) + sendeBenachrichtigung(adressen, opts.betreff, nachrichtenText(opts.text)) +} function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) { - const { dispatch, role, me, meineEinheit } = useStore() + const { dispatch, role, me, meineEinheit, benutzer } = useStore() const [open, setOpen] = useState(false) const [subject, setSubject] = useState('') const [text, setText] = useState('') @@ -25,12 +46,14 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) { const submit = () => { if (!subject.trim() || !text.trim()) return const id = `m-${Date.now()}` + const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1' + const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName dispatch({ type: 'newThread', thread: { id, - unitId: role === 'tenant' ? meineEinheit.id : 'hs-1', - tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName, + unitId, + tenant: autor, subject: subject.trim(), unreadByAdmin: true, unreadByTenant: false, @@ -38,13 +61,23 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) { { id: `${id}-1`, from: role, - author: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName, + author: autor, text: text.trim(), at: new Date().toLocaleString('de-DE'), }, ], }, }) + benachrichtigeGegenstelle({ + benutzer, + vonRolle: role, + einheitId: unitId, + betreff: + role === 'tenant' + ? `Neue Nachricht im Mieterportal: ${subject.trim()}` + : `Nachricht der Verwaltung: ${subject.trim()}`, + text: `Neue Unterhaltung von ${autor}:\n\n„${text.trim()}“`, + }) setSubject('') setText('') setOpen(false) @@ -84,7 +117,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) { } export default function Messages() { - const { threads, role, dispatch, units, me, meineEinheit } = useStore() + const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore() const meinName = me?.name ?? 'Mieter' const verwaltungsName = 'Joseph Hinterstein' const visible = role === 'tenant' @@ -103,17 +136,29 @@ export default function Messages() { const send = () => { if (!active || !draft.trim()) return + const text = draft.trim() + const autor = role === 'tenant' ? meinName : verwaltungsName dispatch({ type: 'addMessage', threadId: active.id, message: { id: `${active.id}-${Date.now()}`, from: role, - author: role === 'tenant' ? meinName : verwaltungsName, - text: draft.trim(), + author: autor, + text, at: new Date().toLocaleString('de-DE'), }, }) + benachrichtigeGegenstelle({ + benutzer, + vonRolle: role, + einheitId: active.unitId, + betreff: + role === 'tenant' + ? `Neue Antwort im Mieterportal: ${active.subject}` + : `Antwort der Verwaltung: ${active.subject}`, + text: `Neue Nachricht von ${autor} in der Unterhaltung „${active.subject}“:\n\n„${text}“`, + }) setDraft('') } diff --git a/src/pages/Tickets.tsx b/src/pages/Tickets.tsx index 0d498a8..537ecca 100644 --- a/src/pages/Tickets.tsx +++ b/src/pages/Tickets.tsx @@ -13,6 +13,12 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' +import { + sendeBenachrichtigung, + verwaltungsAdressen, + nachrichtenText, + einheitLabel, +} from '../lib/mail' import { Select, SelectContent, @@ -46,7 +52,7 @@ const categories = [ ] function NewTicketDialog() { - const { dispatch, role, me, meineEinheit } = useStore() + const { dispatch, role, me, meineEinheit, benutzer, units } = useStore() const [open, setOpen] = useState(false) const [title, setTitle] = useState('') const [category, setCategory] = useState('Sanitär') @@ -55,12 +61,15 @@ function NewTicketDialog() { const submit = () => { if (!title.trim()) return + const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1' + const einheit = units.find((u) => u.id === unitId) ?? meineEinheit + const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)' dispatch({ type: 'addTicket', ticket: { id: `t-${Date.now()}`, - unitId: role === 'tenant' ? meineEinheit.id : 'hs-1', - tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)', + unitId, + tenant: mieterName, title: title.trim(), description: description.trim() || '–', category, @@ -69,6 +78,19 @@ function NewTicketDialog() { createdAt: new Date().toLocaleDateString('de-DE'), }, }) + // Verwaltung per E-Mail benachrichtigen (fire-and-forget) + sendeBenachrichtigung( + verwaltungsAdressen(benutzer), + `Neue Schadensmeldung: ${title.trim()} (${einheitLabel(einheit)})`, + nachrichtenText( + `Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` + + `Wohnung: ${einheitLabel(einheit)}\n` + + `Meldung von: ${mieterName}\n` + + `Kategorie: ${category}\n` + + `Dringlichkeit: ${priority}\n\n` + + `${title.trim()}\n${description.trim() || '–'}`, + ), + ) setTitle('') setDescription('') setOpen(false)