diff --git a/mail-server/api/server.py b/mail-server/api/server.py index d4e57be..5b76906 100644 --- a/mail-server/api/server.py +++ b/mail-server/api/server.py @@ -13,7 +13,8 @@ Endpunkte: GET /outbox → aktuelle Outbox (Demo/Debug) POST /send → {"ok": true, "id": ..., "versandt": true/false} Body: {"to": "...", "subject": "...", "text": "...", "html": "...", - optional "cc": "a@b.de, c@d.de", "from_address"/"from_name" (nur @hocks.eu)} + optional "cc": "a@b.de, c@d.de", "from_address"/"from_name" (nur @hocks.eu), + optional "attachment_b64" (base64), "attachment_filename", "attachment_mime"} Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN """ @@ -87,6 +88,16 @@ def versenden(e: dict) -> bool: msg.set_content(e.get("text") or "") if e.get("html"): msg.add_alternative(e["html"], subtype="html") + # Optionaler Anhang (base64, v1.31) – z. B. erzeugte PDF-Abrechnungen + if e.get("anhang_b64"): + import base64 + daten = base64.b64decode(e["anhang_b64"]) + mime = e.get("anhang_mime") or "application/pdf" + haupt, _, unter = mime.partition("/") + msg.add_attachment( + daten, maintype=haupt or "application", subtype=unter or "octet-stream", + filename=e.get("anhang_dateiname") or "anhang.pdf", + ) with smtplib.SMTP(RELAY_HOST, RELAY_PORT, timeout=20) as smtp: smtp.send_message(msg) return True @@ -199,6 +210,16 @@ class Handler(BaseHTTPRequestHandler): "versandt": False, "angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"), } + # Optionaler Anhang (v1.31): base64 + Dateiname + MIME, max. ca. 20 MB + anhang_b64 = str(body.get("attachment_b64", "")) + if anhang_b64: + if len(anhang_b64) > 28_000_000: + self._antwort(413, {"fehler": "Anhang zu gross (max. ca. 20 MB)"}) + return + dateiname = os.path.basename(str(body.get("attachment_filename", "anhang.pdf"))) or "anhang.pdf" + eintrag["anhang_b64"] = anhang_b64 + eintrag["anhang_dateiname"] = dateiname + eintrag["anhang_mime"] = str(body.get("attachment_mime", "application/pdf")) try: versenden(eintrag) eintrag["versandt"] = True diff --git a/server/src/index.js b/server/src/index.js index 51e822f..c999abe 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -797,18 +797,85 @@ app.post('/api/documents/:id/upload', auth, adminOnly, async (req, res, next) => }) }) +// Lädt Dokument samt Berechtigungs- und Dateiprüfung (Download/Preview/E-Mail). +async function dokumentMitDatei(req, res) { + const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id]) + if (!rows.length) { res.status(404).json({ fehler: 'Dokument nicht gefunden.' }); return null } + const d = rows[0] + const erlaubt = req.user.rolle === 'verwaltung' || d.unit_id === null || d.unit_id === req.user.einheit_id + if (!erlaubt) { res.status(403).json({ fehler: 'Kein Zugriff auf dieses Dokument.' }); return null } + if (!d.file_path) { res.status(404).json({ fehler: 'Zu diesem Eintrag ist noch keine Datei hinterlegt.' }); return null } + const vollerPfad = path.join(UPLOAD_DIR, path.basename(d.file_path)) // basename: Pfad-Traversal unterbinden + if (!fs.existsSync(vollerPfad)) { res.status(404).json({ fehler: 'Datei nicht gefunden.' }); return null } + return { dokument: d, pfad: vollerPfad } +} + // PDF-Download: Verwaltung immer, Mieter nur bei eigener Wohnung oder Hausdokument app.get('/api/documents/:id/download', auth, async (req, res, next) => { try { - const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id]) - if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) - const d = rows[0] - const erlaubt = req.user.rolle === 'verwaltung' || d.unit_id === null || d.unit_id === req.user.einheit_id - if (!erlaubt) return res.status(403).json({ fehler: 'Kein Zugriff auf dieses Dokument.' }) - if (!d.file_path) return res.status(404).json({ fehler: 'Zu diesem Eintrag ist noch keine Datei hinterlegt.' }) - const vollerPfad = path.join(UPLOAD_DIR, path.basename(d.file_path)) // basename: Pfad-Traversal unterbinden - if (!fs.existsSync(vollerPfad)) return res.status(404).json({ fehler: 'Datei nicht gefunden.' }) - res.download(vollerPfad, d.file_name || `${d.title}.pdf`) + const gefunden = await dokumentMitDatei(req, res) + if (!gefunden) return + res.download(gefunden.pfad, gefunden.dokument.file_name || `${gefunden.dokument.title}.pdf`) + } catch (e) { next(e) } +}) + +// PDF-Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31) +app.get('/api/documents/:id/preview', auth, async (req, res, next) => { + try { + const gefunden = await dokumentMitDatei(req, res) + if (!gefunden) return + res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(gefunden.dokument.file_name || gefunden.dokument.title)}`) + res.setHeader('Content-Type', 'application/pdf') + res.sendFile(gefunden.pfad) + } catch (e) { next(e) } +}) + +// PDF per E-Mail versenden: Empfänger frei wählbar oder Mieter der Wohnung, +// immer mit CC an die Verwaltung (v1.31) +app.post('/api/documents/:id/email', auth, adminOnly, async (req, res, next) => { + try { + const gefunden = await dokumentMitDatei(req, res) + if (!gefunden) return + const d = gefunden.dokument + const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ + let empfaenger = String((req.body || {}).an || '').trim() + let empfaengerName = '' + if (!empfaenger && d.unit_id) { + const { rows: mieter } = await pool.query( + `SELECT name, email FROM users WHERE rolle = 'mieter' AND aktiv AND einheit_id = $1 AND email <> '' ORDER BY name LIMIT 1`, + [d.unit_id], + ) + if (mieter.length) { empfaenger = mieter[0].email; empfaengerName = mieter[0].name } + } + if (!empfaenger || !EMAIL_RE.test(empfaenger)) { + return res.status(400).json({ fehler: 'Kein gültiger Empfänger – E-Mail-Adresse angeben oder Mieter-E-Mail hinterlegen.' }) + } + const nachricht = String((req.body || {}).nachricht || '').trim() + const betreff = String((req.body || {}).betreff || '').trim() || `Dokument: ${d.title}` + const dateiname = d.file_name || `${d.title}.pdf` + const cc = await mail.verwaltungEmails() + const gruss = empfaengerName ? `Guten Tag ${empfaengerName},` : 'Guten Tag,' + const text = `${gruss}\n\nim Anhang finden Sie das Dokument „${d.title}" aus dem Mieterportal Hinterstein Immobilien.${nachricht ? `\n\n${nachricht}` : ''}\n\nMit freundlichen Grüßen\nHinterstein Immobilien` + const html = mail.baueHtmlMail({ + titel: d.title, + untertitel: 'Dokument aus dem Mieterportal', + einleitung: gruss, + zeilen: [ + ['Dokument', d.title], + ['Kategorie', d.category], + ...(d.unit_id ? [['Wohnung', mail.einheitLabel((await pool.query('SELECT * FROM units WHERE id = $1', [d.unit_id])).rows[0])]] : []), + ...(nachricht ? [['Nachricht', nachricht]] : []), + ], + statusBox: { text: 'Das PDF ist an diese E-Mail angehängt.', hintergrund: '#ecfdf5', farbe: '#047857' }, + fussnote: 'Bei Rückfragen antworten Sie bitte direkt auf diese E-Mail oder nutzen Sie das Nachrichten-Modul im Portal.', + }) + await mail.sendeMail(empfaenger, betreff, text, html, cc, { + dateiname, + inhaltB64: fs.readFileSync(gefunden.pfad).toString('base64'), + mime: 'application/pdf', + }) + console.log(`[mail] Dokument „${d.title}" an ${empfaenger} versendet (CC: ${cc || '–'})`) + res.json({ ok: true, an: empfaenger }) } catch (e) { next(e) } }) diff --git a/server/src/mail.js b/server/src/mail.js index 1da690d..b68fa18 100644 --- a/server/src/mail.js +++ b/server/src/mail.js @@ -108,7 +108,7 @@ function buttonHtml(text, url) { return `

${htmlEscape(text)}

` } -async function sendeMail(an, betreff, text, html, cc) { +async function sendeMail(an, betreff, text, html, cc, anhang) { if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return try { const res = await fetch(`${MAIL_API_URL}/send`, { @@ -118,6 +118,12 @@ async function sendeMail(an, betreff, text, html, cc) { to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, ...(html ? { html } : {}), ...(cc ? { cc } : {}), // v1.22: Komma-getrennte CC-Empfänger + // v1.31: optionaler Anhang (z. B. PDF-Abrechnung) + ...(anhang ? { + attachment_b64: anhang.inhaltB64, + attachment_filename: anhang.dateiname, + attachment_mime: anhang.mime || 'application/pdf', + } : {}), }), }) if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`) diff --git a/src/components/PdfLightbox.tsx b/src/components/PdfLightbox.tsx new file mode 100644 index 0000000..ae51cac --- /dev/null +++ b/src/components/PdfLightbox.tsx @@ -0,0 +1,162 @@ +import { useRef, useState } from 'react' +import { Printer, Mail, Download, Send, X } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useStore } from '../store/store' +import type { DocumentItem } from '../types' + +/** PDF-Lightbox (v1.31): Inline-Vorschau erzeugter/gespeicherter PDFs mit + * Drucken-, E-Mail- und Download-Option. Auth läuft über das Session-Cookie. */ +export default function PdfLightbox({ dokument, trigger }: { + dokument: DocumentItem + trigger: React.ReactNode +}) { + const { role, sendeDokumentEmail } = useStore() + const [open, setOpen] = useState(false) + const iframeRef = useRef(null) + const [zeigeEmail, setZeigeEmail] = useState(false) + const [an, setAn] = useState('') + const [betreff, setBetreff] = useState('') + const [nachricht, setNachricht] = useState('') + const [sende, setSende] = useState(false) + const [meldung, setMeldung] = useState(null) + + const drucken = () => { + try { + iframeRef.current?.contentWindow?.focus() + iframeRef.current?.contentWindow?.print() + } catch { + window.open(`/api/documents/${dokument.id}/preview`, '_blank') + } + } + + const emailFormularOeffnen = () => { + setBetreff(`Dokument: ${dokument.title}`) + setMeldung(null) + setZeigeEmail(true) + } + + const senden = async () => { + if (sende) return + setSende(true) + setMeldung(null) + const err = await sendeDokumentEmail(dokument.id, { + an: an.trim() || undefined, + betreff: betreff.trim(), + nachricht: nachricht.trim(), + }) + setSende(false) + if (err) return setMeldung(err) + setMeldung('E-Mail wurde versendet.') + setZeigeEmail(false) + } + + return ( + + {trigger} + + +
+
+ {dokument.title} +

+ {dokument.category} · {dokument.date} + {dokument.fileName && ` · ${dokument.fileName}`} +

+
+
+ + {role === 'admin' && ( + + )} + +
+
+
+ + {zeigeEmail && ( +
+
+

+ PDF per E-Mail versenden +

+ +
+
+
+ + setAn(e.target.value)} + /> +
+
+ + setBetreff(e.target.value)} /> +
+
+
+ +