@@ -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 ( )