Dateien
AegisSight-Monitor/src/routers/feedback.py
claude-dev 5e5267572b Fix feedback attachments: proper MIME mixed/alternative structure
Rewrite feedback.py with correct MIMEMultipart(mixed) containing
alternative text part + base64-encoded image attachments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:42:10 +01:00

151 Zeilen
5.6 KiB
Python

"""Feedback-Router: Nutzer-Feedback per E-Mail an das Team."""
import html
import time
import logging
from collections import defaultdict
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from typing import List
import aiosmtplib
from fastapi import APIRouter, Depends, HTTPException, status, Form, UploadFile, File
from auth import get_current_user
from config import (
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASSWORD,
SMTP_FROM_EMAIL,
SMTP_FROM_NAME,
SMTP_USE_TLS,
)
logger = logging.getLogger("osint.feedback")
router = APIRouter(prefix="/api", tags=["feedback"])
FEEDBACK_EMAIL = "feedback@aegis-sight.de"
CATEGORY_LABELS = {
"bug": "Fehlerbericht",
"feature": "Feature-Wunsch",
"question": "Frage",
"other": "Sonstiges",
}
# In-Memory Rate-Limiting: max 3 pro Nutzer/Stunde
_user_timestamps: dict[int, list[float]] = defaultdict(list)
_MAX_PER_HOUR = 3
_WINDOW = 3600
_ALLOWED_TYPES = {"image/jpeg", "image/png"}
_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
_MAX_FILES = 3
@router.post("/feedback", status_code=204)
async def send_feedback(
category: str = Form(...),
message: str = Form(..., min_length=10, max_length=5000),
files: List[UploadFile] = File(default=[]),
current_user: dict = Depends(get_current_user),
):
"""Feedback per E-Mail an das Team senden (mit optionalen Bild-Anhaengen)."""
# Kategorie validieren
if category not in CATEGORY_LABELS:
raise HTTPException(status_code=422, detail="Ungueltige Kategorie.")
user_id = current_user["id"]
# Rate-Limiting
now = time.time()
cutoff = now - _WINDOW
_user_timestamps[user_id] = [t for t in _user_timestamps[user_id] if t > cutoff]
if len(_user_timestamps[user_id]) >= _MAX_PER_HOUR:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Maximal 3 Feedback-Nachrichten pro Stunde. Bitte spaeter erneut versuchen.",
)
# Dateien validieren
if len(files) > _MAX_FILES:
raise HTTPException(status_code=422, detail=f"Maximal {_MAX_FILES} Dateien erlaubt.")
for f in files:
if f.content_type not in _ALLOWED_TYPES:
raise HTTPException(status_code=422, detail=f"Dateityp {f.content_type} nicht erlaubt (nur JPEG/PNG).")
if not SMTP_HOST:
logger.warning("SMTP nicht konfiguriert - Feedback nicht gesendet")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="E-Mail-Versand nicht verfuegbar.",
)
email = current_user.get("email", "")
display_name = email.split("@")[0] if email else "Unbekannt"
category_label = CATEGORY_LABELS.get(category, category)
message_escaped = html.escape(message)
subject = f"[AegisSight Feedback] {category_label} von {display_name}"
html_body = f"""\
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#151D2E;color:#E8ECF4;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="margin:0;color:#C8A851;">Neues Feedback</h2>
</div>
<div style="background:#1A2440;color:#E8ECF4;padding:20px;border-radius:0 0 8px 8px;">
<table style="border-collapse:collapse;">
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">Kategorie:</td><td><strong>{category_label}</strong></td></tr>
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">Nutzer:</td><td>{html.escape(display_name)}</td></tr>
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">E-Mail:</td><td>{html.escape(email) if email else "nicht hinterlegt"}</td></tr>
</table>
<hr style="border:none;border-top:1px solid #1E2D45;margin:16px 0;">
<div style="white-space:pre-wrap;line-height:1.5;">{message_escaped}</div>
</div>
</div>"""
msg = MIMEMultipart("mixed")
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
msg["To"] = FEEDBACK_EMAIL
msg["Subject"] = subject
if email:
msg["Reply-To"] = email
# Text-Teil (alternative: plain + html)
text_part = MIMEMultipart("alternative")
text_fallback = f"Feedback von {display_name} ({category_label}):\n\n{message}"
text_part.attach(MIMEText(text_fallback, "plain", "utf-8"))
text_part.attach(MIMEText(html_body, "html", "utf-8"))
msg.attach(text_part)
# Bild-Anhaenge
for f in files:
file_data = await f.read()
if len(file_data) > _MAX_FILE_SIZE:
raise HTTPException(status_code=422, detail=f"Datei {f.filename} ist groesser als 5 MB.")
sub_type = (f.content_type or "image/jpeg").split("/")[1]
attachment = MIMEBase("image", sub_type)
attachment.set_payload(file_data)
encoders.encode_base64(attachment)
attachment.add_header("Content-Disposition", "attachment", filename=f.filename or "bild.jpg")
msg.attach(attachment)
logger.debug(f"Anhang: {f.filename} ({len(file_data)} Bytes, {f.content_type})")
try:
await aiosmtplib.send(
msg,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USER if SMTP_USER else None,
password=SMTP_PASSWORD if SMTP_PASSWORD else None,
start_tls=SMTP_USE_TLS,
)
_user_timestamps[user_id].append(now)
logger.info(f"Feedback von {display_name} ({category_label}) gesendet, {len(files)} Anhaenge")
except Exception as e:
logger.error(f"Feedback-E-Mail fehlgeschlagen: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="E-Mail konnte nicht gesendet werden.",
)