Files
Michael Schmeinck fb995cefdb mail ready
2026-09-27 18:30:34 +02:00

133 lines
6.2 KiB
Python

"""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})