Schulportal-Konventionen übernommen: Dark Mode (ThemeToggle), beispieldaten.py + generated.json, kimi.md, mail-server (Postfix-Relay + API), Deploy-Workflow (pscp/plink, npm_default), Screenshots in preview/
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
#!/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": "..."}
|
||||
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", "Hocks Mieterportal")
|
||||
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()
|
||||
_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"{SENDER_NAME} <{SENDER_ADDRESS}>"
|
||||
msg["To"] = e["to"]
|
||||
msg["Subject"] = e["subject"]
|
||||
msg.set_content(e.get("text") or "")
|
||||
if e.get("html"):
|
||||
msg.add_alternative(e["html"], subtype="html")
|
||||
with smtplib.SMTP(RELAY_HOST, RELAY_PORT, timeout=20) as smtp:
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
|
||||
|
||||
def outbox_eintrag(e: dict) -> None:
|
||||
eintraege = outbox_laden()
|
||||
eintraege.append(e)
|
||||
outbox_speichern(eintraege)
|
||||
|
||||
|
||||
def worker() -> None:
|
||||
"""Retry-Worker: versucht offene Outbox-Einträge mit Backoff erneut."""
|
||||
while True:
|
||||
time.sleep(30)
|
||||
jetzt = time.time()
|
||||
geaendert = False
|
||||
eintraege = outbox_laden()
|
||||
for e in eintraege:
|
||||
if e.get("versandt") or e.get("versuche", 0) >= MAX_VERSUCHE:
|
||||
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)
|
||||
e["versandt"] = True
|
||||
e["versandtAm"] = time.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except OSError:
|
||||
pass # nächster Versuch später
|
||||
geaendert = True
|
||||
if geaendert:
|
||||
outbox_speichern(eintraege)
|
||||
|
||||
|
||||
# ── 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
|
||||
eintrag = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"to": an,
|
||||
"subject": betreff,
|
||||
"text": str(body.get("text", "")),
|
||||
"html": str(body.get("html", "")),
|
||||
"versuche": 0,
|
||||
"versandt": False,
|
||||
"angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
}
|
||||
try:
|
||||
versenden(eintrag)
|
||||
eintrag["versandt"] = True
|
||||
eintrag["versandtAm"] = eintrag["angenommenAm"]
|
||||
eintrag["letzterVersuch"] = time.time()
|
||||
except OSError:
|
||||
outbox_eintrag(eintrag) # Worker versucht es spaeter erneut
|
||||
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()
|
||||
Reference in New Issue
Block a user