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
This commit is contained in:
2026-10-02 12:21:12 +02:00
parent fbe033ebd6
commit 5503ce5fc5
9 changed files with 225 additions and 18 deletions
+64
View File
@@ -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<boolean> {
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}`
}
+52 -7
View File
@@ -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<typeof useStore>['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('')
}
+25 -3
View File
@@ -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)