Files
Mieterportal/mail-server/api/server.py
T

219 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)}
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()
_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")
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
# 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"),
}
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()