Skip to content
Snippets Groups Projects
Commit a49a56c3 authored by Martin Mareš's avatar Martin Mareš
Browse files

Mail: Do obálkového odesílatele kódujeme data pro zpracování DSN

První část Issue #116.
parent 18bcbab2
Branches
No related tags found
No related merge requests found
...@@ -36,6 +36,11 @@ MAIL_CONTACT = "osmo@mo.mff.cuni.cz" ...@@ -36,6 +36,11 @@ MAIL_CONTACT = "osmo@mo.mff.cuni.cz"
# Pro testování je možné všechny odesílané maily přesměrovat na jinou adresu # Pro testování je možné všechny odesílané maily přesměrovat na jinou adresu
# MAIL_INSTEAD = "mares@kam.mff.cuni.cz" # MAIL_INSTEAD = "mares@kam.mff.cuni.cz"
# Pokud chceme automaticky zpracovávat nedoručenky, je potřeba nastavit klíč
# pro podepisování tokenů v adrese odesílatele (podobně jako SECRET_KEY, jen jiný).
# Do MAIL_FROM se pak automaticky přidává parametr oddělený "+".
# MAIL_TOKEN_SECRET = "..."
# URL, na kterém aplikace běží # URL, na kterém aplikace běží
WEB_ROOT = 'https://mo.mff.cuni.cz/osmo-test/' WEB_ROOT = 'https://mo.mff.cuni.cz/osmo-test/'
......
# Rozesílání e-mailových notifikací všeho druhu # Rozesílání e-mailových notifikací všeho druhu
import datetime import base64
from datetime import datetime, timedelta
import dateutil.tz
import email.message import email.message
import email.headerregistry import email.headerregistry
import hmac
import re
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
import subprocess import subprocess
import textwrap import textwrap
import token_bucket import token_bucket
import traceback import traceback
from typing import Mapping, Optional from typing import Mapping, Optional, Tuple
import urllib.parse import urllib.parse
import mo.db as db import mo.db as db
...@@ -17,12 +21,17 @@ import mo.users ...@@ -17,12 +21,17 @@ import mo.users
from mo.util import logger, ExceptionInfo from mo.util import logger, ExceptionInfo
def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool: def send_email(send_to: str, full_name: str, subject: str, body: str, dsn_token: Optional[str] = None) -> bool:
mail_from = getattr(config, 'MAIL_FROM', None) mail_from = getattr(config, 'MAIL_FROM', None)
if mail_from is None: if mail_from is None:
logger.error('Mail: V configu chybí nastavení MAIL_FROM') logger.error('Mail: V configu chybí nastavení MAIL_FROM')
return False return False
env_from = mail_from
if dsn_token is not None:
logger.info(f'Mail: DSN token {dsn_token}')
env_from = env_from.replace('@', f'+{dsn_token}@')
msg = email.message.EmailMessage() msg = email.message.EmailMessage()
msg['From'] = email.headerregistry.Address( msg['From'] = email.headerregistry.Address(
display_name='Odevzdávací Systém MO', display_name='Odevzdávací Systém MO',
...@@ -39,7 +48,7 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool: ...@@ -39,7 +48,7 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool:
addr_spec=config.MAIL_CONTACT, addr_spec=config.MAIL_CONTACT,
) )
msg['Subject'] = subject msg['Subject'] = subject
msg['Date'] = datetime.datetime.now().astimezone() msg['Date'] = datetime.now().astimezone()
msg.set_content(body, cte='quoted-printable') msg.set_content(body, cte='quoted-printable')
...@@ -56,7 +65,7 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool: ...@@ -56,7 +65,7 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool:
'/usr/sbin/sendmail', '/usr/sbin/sendmail',
'-oi', '-oi',
'-f', '-f',
mail_from, env_from,
send_to, send_to,
], ],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
...@@ -70,7 +79,67 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool: ...@@ -70,7 +79,67 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool:
return True return True
def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = False, override_email: Optional[str] = None) -> bool: def dsn_token_signature(raw_token: str, user: db.User, secret: str) -> str:
body = f'{raw_token}-{user.email}'
raw_sig = hmac.digest(secret.encode('utf-8'), body.encode('utf-8'), 'sha1')[:5]
return base64.b32encode(raw_sig).decode('utf-8').lower()
def gen_dsn_token(user: db.User) -> Optional[str]:
# E-maily posílané na adresy našich uživatelů mají obálkového odesilatele
# parametrizovaného tokenem, který používáme při párování Delivery Status
# Notifications s účtem.
#
# Tokeny obsahují user_id, čas a kryptografický podpis (HMAC), který
# podepisuje i e-mailovou adresu účtu, abychom se nenechali zmást ani falešnou
# nedoručenkou, ani opožděnou nedoručenkou, zatímco si uživatel změnil adresu.
#
# Formát tokenu: <user_id>-<unix_timestamp>-<podpis>
secret = getattr(config, 'MAIL_TOKEN_SECRET', None)
if secret is None:
return None
now = int(mo.now.timestamp())
assert now > 0
raw_token = f'{user.user_id}-{now}'
sig = dsn_token_signature(raw_token, user, secret)
return f'{raw_token}-{sig}'
def validate_dsn_token(token: str) -> Tuple[db.User, datetime]:
secret = getattr(config, 'MAIL_TOKEN_SECRET', None)
if secret is None:
raise ValueError("MAIL_TOKEN_SECRET nenastaven")
fields = token.split('-')
if (len(fields) != 3 or
not re.match(r'[1-9]\d{0,9}', fields[0]) or
not re.match(r'[1-9]\d{0,9}', fields[1])):
raise ValueError("Chybná syntaxe")
user_id, timestamp, given_sig = int(fields[0]), int(fields[1]), fields[2]
when = datetime.fromtimestamp(timestamp).astimezone(dateutil.tz.UTC)
age = mo.now - when
if age > timedelta(days=14):
raise ValueError("Příliš starý token")
if age < -timedelta(hours=1):
raise ValueError("Token z budoucnosti")
sess = db.get_session()
user = sess.query(db.User).get(user_id)
if user is None:
raise ValueError("Uživatel neexistuje")
raw_token = f'{user_id}-{timestamp}'
correct_sig = dsn_token_signature(raw_token, user, secret)
if given_sig != correct_sig:
raise ValueError("Nesouhlasí podpis")
return user, when
def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = False, override_email: Optional[str] = None, send_dsn_token: bool = True) -> bool:
if override_email: if override_email:
email = override_email email = override_email
elif user.user_id == 0: elif user.user_id == 0:
...@@ -82,12 +151,17 @@ def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = F ...@@ -82,12 +151,17 @@ def send_user_email(user: db.User, subject: str, body: str, add_footer: bool = F
logger.info(f'Mail: "{subject}" -> {email} (#{user.user_id})') logger.info(f'Mail: "{subject}" -> {email} (#{user.user_id})')
if send_dsn_token:
dsn_token = gen_dsn_token(user)
else:
dsn_token = None
if add_footer: if add_footer:
body += "\n" + ("=" * 76) + "\n" body += "\n" + ("=" * 76) + "\n"
body += "Pokud nechcete tyto e-maily dostávat, vypněte si notifikace v nastavení\n" body += "Pokud nechcete tyto e-maily dostávat, vypněte si notifikace v nastavení\n"
body += f"svého účtu na {settings_url()}." body += f"svého účtu na {settings_url()}."
return send_email(email, user.full_name(), 'OSMO – ' + subject, body) return send_email(email, user.full_name(), 'OSMO – ' + subject, body, dsn_token=dsn_token)
def activate_url(token: str) -> str: def activate_url(token: str) -> str:
...@@ -149,7 +223,7 @@ def send_confirm_create_email(user: db.User, token: str) -> bool: ...@@ -149,7 +223,7 @@ def send_confirm_create_email(user: db.User, token: str) -> bool:
{} {}
Váš OSMO Váš OSMO
'''.format(confirm_url('r', token)))) '''.format(confirm_url('r', token))), send_dsn_token=False)
def send_confirm_change_email(user: db.User, token: str, new_email: str) -> bool: def send_confirm_change_email(user: db.User, token: str, new_email: str) -> bool:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment