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>
151 Zeilen
5.6 KiB
Python
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.",
|
|
)
|