Files

278 lines
12 KiB
Python
Raw Permalink 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),
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()