1 bis 8 Party: mobile Web-App (Regeln, Karten, Spielleiter) + Docker-Deploy

This commit is contained in:
Kimi Work
2026-10-10 15:59:26 +02:00
commit 6ac0282851
92 changed files with 17107 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Hocks Mieterportal – schlanke Mail-API (Muster: Schulportal).
Nimmt Mails vom Portal entgegen (POST /send, Token-Pflicht) und leitet sie
über den internen Postfix-Relay (mp-mail:25) aus. Versand läuft über eine
Outbox mit Retry-Worker (Backoff bis 60 min, max. 10 Versuche) – fällt der
Versand aus (Rate-Limit etc.), wird automatisch erneut versucht.
Reine Standardbibliothek (python:3.12-alpine, keine pip-Abhängigkeiten).
Endpunkte:
GET /healthz → {"ok": true}
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 "attachment_b64" (base64), "attachment_filename", "attachment_mime"}
Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN
"""
import json
import os
import re
import smtplib
import threading
import time
import uuid
from email.message import EmailMessage
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ── Konfiguration ─────────────────────────────────────────────
RELAY_HOST = os.environ.get("RELAY_HOST", "mp-mail")
RELAY_PORT = int(os.environ.get("RELAY_PORT", "25"))
PORT = int(os.environ.get("PORT", "8080"))
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
DATA_DIR = os.environ.get("DATA_DIR", "/data")
SENDER_ADDRESS = os.environ.get("SENDER_ADDRESS", "noreply@hocks.eu")
SENDER_NAME = os.environ.get("SENDER_NAME", "Hinterstein Immobilien")
MAIL_API_TOKEN = os.environ.get("MAIL_API_TOKEN", "")
RATE_LIMIT = int(os.environ.get("RATE_LIMIT", "500")) # Mails pro Stunde
MAX_VERSUCHE = 10
BACKOFF_MAX_SEK = 3600
os.makedirs(DATA_DIR, exist_ok=True)
OUTBOX_DATEI = os.path.join(DATA_DIR, "outbox.json")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_lock = threading.Lock()
_outbox_lock = threading.Lock() # serialisiert Outbox-Laden/Speichern
_send_log: list[float] = [] # Timestamps für Rate-Limit
# ── Outbox ────────────────────────────────────────────────────
def outbox_laden() -> list[dict]:
try:
with open(OUTBOX_DATEI, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return []
def outbox_speichern(eintraege: list[dict]) -> None:
tmp = OUTBOX_DATEI + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(eintraege, f, ensure_ascii=False, indent=1)
os.replace(tmp, OUTBOX_DATEI)
def rate_limit_ok() -> bool:
jetzt = time.time()
global _send_log
with _lock:
_send_log = [t for t in _send_log if jetzt - t < 3600]
if len(_send_log) >= RATE_LIMIT:
return False
_send_log.append(jetzt)
return True
def versenden(e: dict) -> bool:
msg = EmailMessage()
msg["From"] = f"{e.get('from_name') or SENDER_NAME} <{e.get('from_address') or SENDER_ADDRESS}>"
msg["To"] = e["to"]
if e.get("cc"):
msg["Cc"] = e["cc"] # Komma-getrennte CC-Empfaenger (v1.22)
msg["Subject"] = e["subject"]
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
def outbox_eintrag(e: dict) -> None:
with _outbox_lock:
eintraege = outbox_laden()
eintraege.append(e)
outbox_speichern(eintraege)
def worker() -> None:
"""Retry-Worker: versucht offene Outbox-Einträge mit Backoff erneut.
Erfolgreiche Mails werden aus der Outbox entfernt und geloggt; nach
MAX_VERSUCHE erfolgt ein endgültiger Abbruch mit ALARM-Zeile (docker logs),
damit verlorene Mails sichtbar statt stillschweigend verschwinden."""
while True:
time.sleep(30)
jetzt = time.time()
with _outbox_lock:
eintraege = outbox_laden()
for e in list(eintraege):
if e.get("versandt"):
with _outbox_lock:
alle = outbox_laden()
alle = [x for x in alle if x.get("id") != e.get("id")]
outbox_speichern(alle)
continue
if e.get("versuche", 0) >= MAX_VERSUCHE:
print(f"[{time.strftime('%H:%M:%S')}] ALARM: Versand an {e.get('to')} "
f"nach {MAX_VERSUCHE} Versuchen endgültig abgebrochen: "
f"{e.get('subject')}", flush=True)
with _outbox_lock:
alle = outbox_laden()
alle = [x for x in alle if x.get("id") != e.get("id")]
outbox_speichern(alle)
continue
if jetzt - e.get("letzterVersuch", 0) < min(60 * 2 ** e.get("versuche", 0), BACKOFF_MAX_SEK):
continue
try:
e["versuche"] = e.get("versuche", 0) + 1
e["letzterVersuch"] = jetzt
versenden(e)
print(f"[{time.strftime('%H:%M:%S')}] VERSANDT OK an {e.get('to')}: "
f"{e.get('subject')}", flush=True)
with _outbox_lock:
alle = outbox_laden()
alle = [x for x in alle if x.get("id") != e.get("id")]
outbox_speichern(alle)
except Exception as exc:
print(f"[{time.strftime('%H:%M:%S')}] Versand an {e.get('to')} fehlgeschlagen "
f"(Versuch {e['versuche']}/{MAX_VERSUCHE}): {exc}", flush=True)
if e["versuche"] >= MAX_VERSUCHE:
print(f"[{time.strftime('%H:%M:%S')}] ALARM: Versand an {e.get('to')} "
f"nach {MAX_VERSUCHE} Versuchen endgültig abgebrochen: "
f"{e.get('subject')}", flush=True)
with _outbox_lock:
alle = outbox_laden()
alle = [x for x in alle if x.get("id") != e.get("id")]
outbox_speichern(alle)
else:
with _outbox_lock:
alle = outbox_laden()
for x in alle:
if x.get("id") == e.get("id"):
x["versuche"] = e["versuche"]
x["letzterVersuch"] = e["letzterVersuch"]
outbox_speichern(alle)
# ── HTTP ──────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
server_version = "mp-mail-api/1.0"
def _antwort(self, code: int, body: dict) -> None:
daten = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(daten)))
if ALLOWED_ORIGIN != "*":
self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN)
self.end_headers()
self.wfile.write(daten)
def _body(self) -> dict:
try:
laenge = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(laenge) or b"{}")
except (ValueError, json.JSONDecodeError):
return {}
def do_OPTIONS(self) -> None: # noqa: N802
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Api-Token")
self.end_headers()
def do_GET(self) -> None: # noqa: N802
if self.path.split("?")[0] == "/healthz":
self._antwort(200, {"ok": True, "service": "mp-mail-api"})
elif self.path.split("?")[0] == "/outbox":
self._antwort(200, {"eintraege": outbox_laden()})
else:
self._antwort(404, {"fehler": "nicht gefunden"})
def do_POST(self) -> None: # noqa: N802
if self.path.split("?")[0] != "/send":
self._antwort(404, {"fehler": "nicht gefunden"})
return
body = self._body()
token = self.headers.get("X-Api-Token") or body.get("token") or ""
if not MAIL_API_TOKEN or token != MAIL_API_TOKEN:
self._antwort(401, {"fehler": "ungueltiger Token"})
return
an = str(body.get("to", "")).strip()
betreff = str(body.get("subject", "")).strip()
if not EMAIL_RE.match(an) or not betreff:
self._antwort(400, {"fehler": "Empfaenger oder Betreff ungueltig"})
return
if not rate_limit_ok():
self._antwort(429, {"fehler": "Rate-Limit erreicht"})
return
# Optionaler eigener Absender (nur hocks.eu-Domain, sonst Fallback)
absender = str(body.get("from_address", "")).strip().lower()
absender_name = str(body.get("from_name", "")).strip()
if (not EMAIL_RE.match(absender) or not absender.endswith("@hocks.eu")
or len(absender_name) > 60 or any(c in absender_name for c in "<>\r\n")):
absender, absender_name = SENDER_ADDRESS, SENDER_NAME
# Optional CC (Komma-getrennt, einzeln validiert) – v1.22
cc = ','.join(
a for a in (x.strip() for x in str(body.get("cc", "")).split(","))
if a and EMAIL_RE.match(a)
)
eintrag = {
"id": uuid.uuid4().hex[:12],
"to": an,
"subject": betreff,
"text": str(body.get("text", "")),
"html": str(body.get("html", "")),
"cc": cc,
"from_address": absender,
"from_name": absender_name,
"versuche": 0,
"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
eintrag["versandtAm"] = eintrag["angenommenAm"]
eintrag["letzterVersuch"] = time.time()
except Exception:
# Jeder Fehler (Relay-Timeout, RCPT-Ablehnung 4xx/5xx, DNS ...) wird
# in die Outbox gelegt; der Worker wiederholt mit exponentiellem Backoff.
outbox_eintrag(eintrag)
self._antwort(200, {"ok": True, "id": eintrag["id"], "versandt": eintrag["versandt"]})
def log_message(self, fmt: str, *args) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {self.address_string()} {fmt % args}")
if __name__ == "__main__":
threading.Thread(target=worker, daemon=True).start()
print(f"mp-mail-api lauscht auf :{PORT} (Relay {RELAY_HOST}:{RELAY_PORT}, Limit {RATE_LIMIT}/h)")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()