mail ready

This commit is contained in:
Michael Schmeinck
2026-09-27 18:30:34 +02:00
parent 774c601889
commit fb995cefdb
33 changed files with 2787 additions and 193 deletions
+11
View File
@@ -0,0 +1,11 @@
# Kontaktformular mit Microsoft 365
Den Ordner `backend/` vorzugsweise außerhalb des Nginx-Webroots installieren. Wenn das ganze Projekt als Webroot dient, muss die beigefügte Nginx-Regel `/backend/` sperren. Die statischen Seiten bleiben bei Nginx. Nur `/api/contact` wird an den lokalen Python-WSGI-Dienst weitergeleitet.
1. In Microsoft Entra eine eigene App registrieren, Microsoft Graph **Anwendungsberechtigung** `Mail.Send` erteilen und Administratorzustimmung geben. Die Sendeberechtigung in Exchange Online auf das vorgesehene Postfach einschränken (Application RBAC). Der Absender muss ein vorhandenes Exchange-Online-Postfach sein. Für das Formular kann `info@dilibworld.com` zugleich Absender und Empfänger sein; die Besuchermail steht in `Reply-To`.
2. `contact.env.example` als nicht öffentlich erreichbare Environment-Datei außerhalb des Webroots ablegen. `CONTACT_FORM_SECRET` mit `openssl rand -hex 32` erzeugen. IDs, Secret und Absender eintragen. Die Secret-Datei mit restriktiven Rechten schützen und **nie** in Git oder den Webroot legen.
3. Python-Umgebung anlegen: `python3 -m venv /opt/dilibworld-contact-venv` und `/opt/dilibworld-contact-venv/bin/pip install -r backend/requirements.txt`. Dienst mit einer Gunicorn-Worker-Instanz starten: `gunicorn --workers 1 --bind 127.0.0.1:8091 app:application` (Arbeitsverzeichnis `backend/`, Environment-Datei durch systemd `EnvironmentFile=` einlesen). Nur localhost binden. Bei mehreren Workern muss der einmalige Formularschlüssel in einem gemeinsamen Speicher verwaltet werden.
4. Die beiden Snippets aus `nginx-contact.conf.example` an den angegebenen Stellen in die bestehende HTTPS-Konfiguration aufnehmen. HTTP nach HTTPS umleiten. `nginx -t` ausführen und Nginx neu laden. Wenn `www.dilibworld.com` erreichbar ist, auf `dilibworld.com` umleiten, damit die Origin-Prüfung passt.
5. Nach Einrichtung eine echte Anfrage senden und Empfang sowie `Reply-To` prüfen. Ein gefülltes Feld `website` wird still verworfen. Bei Mailfehlern erscheint eine Fehlermeldung im Formular. Die Datenschutzvorlagen enthalten noch auszufüllende Angaben.
Der Server prüft Origin, Formularalter, Honeypot, Pflichtfelder, Inhaltstyp und Länge. Nginx begrenzt Anfragen pro IP. Es werden keine Tokens oder Mailinhalte protokolliert. Microsoft Graph bestätigt eine angenommene Nachricht mit HTTP 202; die tatsächliche Zustellung ist damit noch nicht garantiert.
+132
View File
@@ -0,0 +1,132 @@
"""Small WSGI contact endpoint. Run behind HTTPS and the Nginx rate limit."""
import base64
import binascii
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger(__name__)
ORIGIN = os.environ.get('CONTACT_ORIGIN', 'https://dilibworld.com').rstrip('/')
SECRET = os.environ.get('CONTACT_FORM_SECRET', '').encode()
SENDER = os.environ.get('GRAPH_SENDER', 'info@dilibworld.com')
RECIPIENT = os.environ.get('CONTACT_RECIPIENT', 'info@dilibworld.com')
FIELDS = {
'de': ('Vorname', 'Nachname', 'Unternehmen', 'E-Mail', 'Telefon', 'Thema', 'Nachricht'),
'en': ('First name', 'Last name', 'Company', 'Email', 'Phone', 'Topic', 'Message'),
}
USED = {}
def reply(start_response, status, data):
body = json.dumps(data).encode()
start_response(status, [('Content-Type', 'application/json; charset=utf-8'),
('Content-Length', str(len(body))), ('Cache-Control', 'no-store'),
('X-Content-Type-Options', 'nosniff')])
return [body]
def token():
value = f'{int(time.time())}:{secrets.token_urlsafe(18)}'
signature = hmac.new(SECRET, value.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f'{value}:{signature}'.encode()).decode()
def valid_token(value):
try:
raw = base64.urlsafe_b64decode(value.encode()).decode()
timestamp, nonce, signature = raw.split(':')
signed = f'{timestamp}:{nonce}'
age = time.time() - int(timestamp)
digest = hmac.new(SECRET, signed.encode(), hashlib.sha256).hexdigest()
if not (3 <= age <= 3600 and hmac.compare_digest(digest, signature)):
return False
now = time.time()
for key, expiry in list(USED.items()):
if expiry < now:
del USED[key]
if signature in USED:
return False
USED[signature] = now + 3600
return True
except (ValueError, UnicodeError, TypeError, binascii.Error):
return False
def graph_send(values, lang):
tenant = os.environ['GRAPH_TENANT_ID']
app_id = os.environ['GRAPH_CLIENT_ID']
client_secret = os.environ['GRAPH_CLIENT_SECRET']
form = urllib.parse.urlencode({'client_id': app_id, 'client_secret': client_secret,
'scope': 'https://graph.microsoft.com/.default',
'grant_type': 'client_credentials'}).encode()
url = f'https://login.microsoftonline.com/{urllib.parse.quote(tenant, safe="")}/oauth2/v2.0/token'
request = urllib.request.Request(url, form, {'Content-Type': 'application/x-www-form-urlencoded'})
with urllib.request.urlopen(request, timeout=10) as response:
access_token = json.load(response)['access_token']
labels = FIELDS[lang]
content = '\n'.join(f'{label}: {values.get(label, "")}' for label in labels)
email = values[labels[3]]
message = {'message': {
'subject': 'Kontaktanfrage über dilibworld.com',
'body': {'contentType': 'Text', 'content': content},
'toRecipients': [{'emailAddress': {'address': RECIPIENT}}],
'replyTo': [{'emailAddress': {'address': email}}],
}, 'saveToSentItems': True}
endpoint = 'https://graph.microsoft.com/v1.0/users/' + urllib.parse.quote(SENDER, safe='@') + '/sendMail'
request = urllib.request.Request(endpoint, json.dumps(message).encode(),
{'Content-Type': 'application/json', 'Authorization': f'Bearer {access_token}'})
with urllib.request.urlopen(request, timeout=15) as response:
if response.status != 202:
raise RuntimeError('Graph did not accept the message')
def application(environ, start_response):
if environ.get('PATH_INFO') != '/api/contact':
return reply(start_response, '404 Not Found', {'error': 'not_found'})
if not SECRET or not all(os.environ.get(key) for key in ('GRAPH_TENANT_ID', 'GRAPH_CLIENT_ID', 'GRAPH_CLIENT_SECRET')):
LOG.error('Contact service configuration incomplete')
return reply(start_response, '503 Service Unavailable', {'error': 'unavailable'})
method = environ.get('REQUEST_METHOD')
if method == 'GET':
return reply(start_response, '200 OK', {'token': token()})
if method != 'POST':
return reply(start_response, '405 Method Not Allowed', {'error': 'method'})
if environ.get('HTTP_ORIGIN') != ORIGIN or environ.get('CONTENT_TYPE', '').split(';')[0] != 'application/json':
return reply(start_response, '403 Forbidden', {'error': 'origin'})
try:
length = int(environ.get('CONTENT_LENGTH', '0'))
if not 0 < length <= 12000:
raise ValueError('length')
data = json.loads(environ['wsgi.input'].read(length))
lang = data.get('lang')
if lang not in FIELDS or not isinstance(data.get('website'), str) or not isinstance(data.get('token'), str):
raise ValueError('fields')
if data['website']:
return reply(start_response, '200 OK', {'ok': True})
if not valid_token(data['token']):
raise ValueError('token')
labels = FIELDS[lang]
values = {key: data.get(key, '') for key in labels}
if any(not isinstance(v, str) or len(v) > (5000 if key == labels[6] else 200) for key, v in values.items()):
raise ValueError('field length')
if not all(values[key].strip() for key in (labels[0], labels[1], labels[3], labels[6])):
raise ValueError('required')
if not re.fullmatch(r'[^\s@\r\n]+@[^\s@\r\n]+\.[^\s@\r\n]+', values[labels[3]]) or len(values[labels[3]]) > 254:
raise ValueError('email')
if data.get('privacy') not in ('akzeptiert', 'accepted'):
raise ValueError('privacy')
graph_send(values, lang)
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return reply(start_response, '400 Bad Request', {'error': 'invalid'})
except (urllib.error.URLError, TimeoutError, RuntimeError):
LOG.exception('Contact delivery failed')
return reply(start_response, '502 Bad Gateway', {'error': 'delivery'})
return reply(start_response, '200 OK', {'ok': True})
+7
View File
@@ -0,0 +1,7 @@
CONTACT_ORIGIN=https://dilibworld.com
CONTACT_FORM_SECRET=REPLACE_WITH_RANDOM_SECRET
GRAPH_TENANT_ID=e966b988-0d5e-446c-9d7d-7ebd2b751546
GRAPH_CLIENT_ID=9876e61a-b2ff-4b31-90c9-39e583b3cf73
GRAPH_CLIENT_SECRET=JXA8Q~QMOKIXy~waf~OCJGz2M3QUu5~qBgr5vb0V
GRAPH_SENDER=info@dilibworld.com
CONTACT_RECIPIENT=info@dilibworld.com
+7
View File
@@ -0,0 +1,7 @@
CONTACT_ORIGIN=https://dilibworld.com
CONTACT_FORM_SECRET=REPLACE_WITH_RANDOM_SECRET
GRAPH_TENANT_ID=REPLACE_WITH_TENANT_GUID
GRAPH_CLIENT_ID=REPLACE_WITH_APP_GUID
GRAPH_CLIENT_SECRET=REPLACE_WITH_SECRET_VALUE
GRAPH_SENDER=info@dilibworld.com
CONTACT_RECIPIENT=info@dilibworld.com
+14
View File
@@ -0,0 +1,14 @@
# Add limit_req_zone in the nginx http {} context (not inside server {}):
limit_req_zone $binary_remote_addr zone=contact_form:10m rate=3r/m;
# Add this location inside the existing HTTPS server for dilibworld.com:
location = /api/contact {
client_max_body_size 12k;
limit_req zone=contact_form burst=5 nodelay;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_pass http://127.0.0.1:8091;
}
# If the project directory itself is the Nginx document root, deny backend code:
location ^~ /backend/ { return 404; }
+1
View File
@@ -0,0 +1 @@
gunicorn>=23,<24