mail ready
This commit is contained in:
@@ -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
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -0,0 +1 @@
|
||||
gunicorn>=23,<24
|
||||
Reference in New Issue
Block a user