#!/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()