mail ready
This commit is contained in:
+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})
|
||||
Reference in New Issue
Block a user