From 80a7cae2aed1cbe58a336baab919be676d21f98f Mon Sep 17 00:00:00 2001 From: Mathieu Agopian Date: Mon, 11 Feb 2019 16:01:53 +0100 Subject: [PATCH] Move all the mail sending related code to a separate module --- kinto/plugins/accounts/mails.py | 95 ++++++++++++++++++++++ kinto/plugins/accounts/utils.py | 11 --- kinto/plugins/accounts/views/__init__.py | 48 +---------- kinto/plugins/accounts/views/validation.py | 81 ++---------------- tests/plugins/test_accounts.py | 65 ++++++++++++++- 5 files changed, 166 insertions(+), 134 deletions(-) create mode 100644 kinto/plugins/accounts/mails.py diff --git a/kinto/plugins/accounts/mails.py b/kinto/plugins/accounts/mails.py new file mode 100644 index 000000000..74a38c1e8 --- /dev/null +++ b/kinto/plugins/accounts/mails.py @@ -0,0 +1,95 @@ +import string +from pyramid_mailer import get_mailer +from pyramid_mailer.message import Message + + +DEFAULT_EMAIL_SENDER = "admin@example.com" +DEFAULT_SUBJECT_TEMPLATE = "activate your account" +DEFAULT_BODY_TEMPLATE = "{activation-key}" +DEFAULT_CONFIRMATION_SUBJECT_TEMPLATE = "Account active" +DEFAULT_CONFIRMATION_BODY_TEMPLATE = "The account {id} is now active" +DEFAULT_RESET_SUBJECT_TEMPLATE = "Reset password" +DEFAULT_RESET_BODY_TEMPLATE = "{reset-password}" + + +class EmailFormatter(string.Formatter): + """Formatter class that will not fail if there's a missing key.""" + + def __init__(self, default="{{{0}}}"): + self.default = default + + def get_value(self, key, args, kwargs): + return kwargs.get(key, self.default.format(key)) + + +class Emailer: + def __init__(self, request, user): + self.request = request + self.settings = request.registry.settings + self.user = user + self.user_email = user["id"] + self.email_sender = self.settings.get( + "account_validation.email_sender", DEFAULT_EMAIL_SENDER + ) + self.mailer = get_mailer(request) + + def send_mail(self, subject_template, body_template, extra_data=None): + formatter = EmailFormatter() + if extra_data is None: + extra_data = {} + user_email_context = self.user.get("email-context", {}) + # We might have some previous email context. + try: + data = self.request.json.get("data", {}) + email_context = data.get("email-context", user_email_context) + except ValueError: + email_context = user_email_context + + formatted_subject = formatter.format( + subject_template, **self.user, **extra_data, **email_context + ) + formatted_body = formatter.format( + body_template, **self.user, **extra_data, **email_context + ) + message = Message( + subject=formatted_subject, + sender=self.email_sender, + recipients=[self.user_email], + body=formatted_body, + ) + self.mailer.send(message) + + def send_activation(self, activation_key): + extra_data = {"activation-key": activation_key} + + subject_template = self.settings.get( + "account_validation.email_subject_template", DEFAULT_SUBJECT_TEMPLATE + ) + body_template = self.settings.get( + "account_validation.email_body_template", DEFAULT_BODY_TEMPLATE + ) + self.send_mail(subject_template, body_template, extra_data) + + def send_confirmation(self): + subject_template = self.settings.get( + "account_validation.email_confirmation_subject_template", + DEFAULT_CONFIRMATION_SUBJECT_TEMPLATE, + ) + body_template = self.settings.get( + "account_validation.email_confirmation_body_template", + DEFAULT_CONFIRMATION_BODY_TEMPLATE, + ) + + self.send_mail(subject_template, body_template) + + def send_temporary_reset_password(self, reset_password): + extra_data = {"reset-password": reset_password} + subject_template = self.settings.get( + "account_validation.email_reset_password_subject_template", + DEFAULT_RESET_SUBJECT_TEMPLATE, + ) + body_template = self.settings.get( + "account_validation.email_reset_password_body_template", DEFAULT_RESET_BODY_TEMPLATE + ) + + self.send_mail(subject_template, body_template, extra_data) diff --git a/kinto/plugins/accounts/utils.py b/kinto/plugins/accounts/utils.py index f74e1c45b..f45411d40 100644 --- a/kinto/plugins/accounts/utils.py +++ b/kinto/plugins/accounts/utils.py @@ -1,5 +1,4 @@ import bcrypt -import string from kinto.core import utils @@ -34,13 +33,3 @@ def get_cached_reset_password(username, registry): cache = registry.cache cache_result = cache.get(cache_key) return cache_result - - -class EmailFormatter(string.Formatter): - """Formatter class that will not fail if there's a missing key.""" - - def __init__(self, default="{{{0}}}"): - self.default = default - - def get_value(self, key, args, kwargs): - return kwargs.get(key, self.default.format(key)) diff --git a/kinto/plugins/accounts/views/__init__.py b/kinto/plugins/accounts/views/__init__.py index cdfe8011a..53d4fc7ec 100644 --- a/kinto/plugins/accounts/views/__init__.py +++ b/kinto/plugins/accounts/views/__init__.py @@ -6,8 +6,6 @@ from pyramid.security import Authenticated, Everyone from pyramid.settings import aslist from pyramid.events import subscriber -from pyramid_mailer import get_mailer -from pyramid_mailer.message import Message from kinto.views import NameGenerator @@ -15,18 +13,15 @@ from kinto.core.errors import raise_invalid, http_error from kinto.core.events import ResourceChanged, ACTIONS +from ..mails import Emailer from ..utils import ( hash_password, - EmailFormatter, ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME, ACCOUNT_VALIDATION_CACHE_KEY, ) DEFAULT_EMAIL_REGEXP = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" -DEFAULT_EMAIL_SENDER = "admin@example.com" -DEFAULT_SUBJECT_TEMPLATE = "activate your account" -DEFAULT_BODY_TEMPLATE = "{activation-key}" DEFAULT_VALIDATION_KEY_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 @@ -100,19 +95,6 @@ def __init__(self, request, context): # Creation is anonymous, but author with write perm is this: self.model.current_principal = f"{ACCOUNT_POLICY_NAME}:{self.model.parent_id}" - if context.validation_enabled: - # pyramid_mailer instance. - self.mailer = get_mailer(request) - self.email_sender = settings.get( - "account_validation.email_sender", DEFAULT_EMAIL_SENDER - ) - self.email_subject_template = settings.get( - "account_validation.email_subject_template", DEFAULT_SUBJECT_TEMPLATE - ) - self.email_body_template = settings.get( - "account_validation.email_body_template", DEFAULT_BODY_TEMPLATE - ) - @reify def id_generator(self): # This generator is used for ID validation. @@ -236,31 +218,5 @@ def on_account_created(event): if activation_key is None: continue - extra_data = {"activation-key": activation_key} - # Send an email to the user with the link to activate their account. - email_context = account.get("email-context", {}) - formatter = EmailFormatter() - - email_subject_template = settings.get( - "account_validation.email_subject_template", DEFAULT_SUBJECT_TEMPLATE - ) - formatted_subject = formatter.format( - email_subject_template, **account, **extra_data, **email_context - ) - email_body_template = settings.get( - "account_validation.email_body_template", DEFAULT_BODY_TEMPLATE - ) - formatted_body = formatter.format( - email_body_template, **account, **extra_data, **email_context - ) - email_sender = settings.get("account_validation.email_sender", DEFAULT_EMAIL_SENDER) - - message = Message( - subject=formatted_subject, - sender=email_sender, - recipients=[user_email], - body=formatted_body, - ) - mailer = get_mailer(request) - mailer.send(message) + Emailer(request, account).send_activation(activation_key) diff --git a/kinto/plugins/accounts/views/validation.py b/kinto/plugins/accounts/views/validation.py index 91526fb57..57e7f7e26 100644 --- a/kinto/plugins/accounts/views/validation.py +++ b/kinto/plugins/accounts/views/validation.py @@ -2,8 +2,6 @@ import uuid from pyramid import httpexceptions from pyramid.events import subscriber -from pyramid_mailer import get_mailer -from pyramid_mailer.message import Message from kinto.core import Service, utils @@ -11,20 +9,12 @@ from kinto.core.events import ResourceChanged, ACTIONS from kinto.core.storage import exceptions as storage_exceptions -from ..utils import ( - hash_password, - EmailFormatter, - ACCOUNT_RESET_PASSWORD_CACHE_KEY, - ACCOUNT_VALIDATION_CACHE_KEY, -) +from ..mails import Emailer +from ..utils import hash_password, ACCOUNT_RESET_PASSWORD_CACHE_KEY, ACCOUNT_VALIDATION_CACHE_KEY -from . import DEFAULT_EMAIL_SENDER, DEFAULT_EMAIL_REGEXP +from . import DEFAULT_EMAIL_REGEXP DEFAULT_RESET_PASSWORD_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 -DEFAULT_CONFIRMATION_SUBJECT_TEMPLATE = "Account active" -DEFAULT_CONFIRMATION_BODY_TEMPLATE = "The account {id} is now active" -DEFAULT_RESET_SUBJECT_TEMPLATE = "Reset password" -DEFAULT_RESET_BODY_TEMPLATE = "{reset-password}" # Account validation (enable in the settings). validation = Service( @@ -136,44 +126,11 @@ def post_reset_password(request): raise_invalid(request, **error_details) reset_password = str(uuid.uuid4()) - extra_data = {"reset-password": reset_password} hashed_reset_password = hash_password(reset_password) cache_reset_password(hashed_reset_password, user_id, request.registry) # Send a temporary reset password by mail. - email_reset_password_subject_template = settings.get( - "account_validation.email_reset_password_subject_template", DEFAULT_RESET_SUBJECT_TEMPLATE - ) - email_reset_password_body_template = settings.get( - "account_validation.email_reset_password_body_template", DEFAULT_RESET_BODY_TEMPLATE - ) - email_sender = settings.get("account_validation.email_sender", DEFAULT_EMAIL_SENDER) - - mailer = get_mailer(request) - user_email_context = user.get( - "email-context", {} - ) # We might have some previous email context. - try: - data = request.json.get("data", {}) - email_context = data.get("email-context", user_email_context) - except ValueError: - email_context = user_email_context - - formatter = EmailFormatter() - formatted_subject = formatter.format( - email_reset_password_subject_template, **user, **extra_data, **email_context - ) - formatted_body = formatter.format( - email_reset_password_body_template, **user, **extra_data, **email_context - ) - - message = Message( - subject=formatted_subject, - sender=email_sender, - recipients=[user_email], - body=formatted_body, - ) - mailer.send(message) + Emailer(request, user).send_temporary_reset_password(reset_password) return {"message": "A temporary reset password has been sent by mail"} @@ -194,32 +151,4 @@ def on_account_activated(event): continue # Send a confirmation email. - email_confirmation_subject_template = settings.get( - "account_validation.email_confirmation_subject_template", - DEFAULT_CONFIRMATION_SUBJECT_TEMPLATE, - ) - email_confirmation_body_template = settings.get( - "account_validation.email_confirmation_body_template", - DEFAULT_CONFIRMATION_BODY_TEMPLATE, - ) - email_sender = settings.get("account_validation.email_sender", DEFAULT_EMAIL_SENDER) - - user_email = account["id"] - mailer = get_mailer(request) - email_context = account.get("email-context", {}) - - formatter = EmailFormatter() - formatted_subject = formatter.format( - email_confirmation_subject_template, **account, **email_context - ) - formatted_body = formatter.format( - email_confirmation_body_template, **account, **email_context - ) - - message = Message( - subject=formatted_subject, - sender=email_sender, - recipients=[user_email], - body=formatted_body, - ) - mailer.send(message) + Emailer(request, account).send_confirmation() diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index 9a8a97b23..a4f38fe80 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -4,7 +4,8 @@ from unittest import mock from kinto.core import utils -from kinto.core.testing import get_user_headers +from kinto.core.events import ACTIONS, ResourceChanged +from kinto.core.testing import get_user_headers, DummyRequest from pyramid.exceptions import ConfigurationError from pyramid_mailer import get_mailer @@ -15,6 +16,8 @@ ACCOUNT_VALIDATION_CACHE_KEY, ) from kinto.plugins.accounts.utils import hash_password +from kinto.plugins.accounts.views import on_account_created +from kinto.plugins.accounts.views.validation import on_account_activated from .. import support @@ -489,6 +492,66 @@ def test_use_reset_password_to_change_password(self): resp = self.app.get("/", headers=get_user_headers("alice@example.com", reset_password)) assert "user" not in resp.json + def test_user_creation_listener(self): + request = DummyRequest() + impacted_object = {"new": {"id": "alice", "password": "12éé6"}} + with mock.patch("kinto.plugins.accounts.mails.Emailer.send_mail") as mocked_send_mail: + # No email sent if account validation is not enabled. + event = ResourceChanged({"action": ACTIONS.UPDATE.value}, [impacted_object], request) + on_account_created(event) + mocked_send_mail.assert_not_called() + # No email sent if there's no activation key in the cache. + request.registry.settings["account_validation"] = True + event = ResourceChanged({"action": ACTIONS.UPDATE.value}, [impacted_object], request) + request.registry.cache.get = mock.MagicMock(return_value=None) + on_account_created(event) + mocked_send_mail.assert_not_called() + # Email sent if there is an activation key in the cache. + request.registry.cache.get = mock.MagicMock(return_value="some activation key") + on_account_created(event) + mocked_send_mail.assert_called_once() + + def test_user_validation_listener(self): + request = DummyRequest() + old_inactive = {"id": "alice", "password": "12éé6", "validated": False} + old_active = {"id": "alice", "password": "12éé6", "validated": True} + new_inactive = {"id": "alice", "password": "12éé6", "validated": False} + new_active = {"id": "alice", "password": "12éé6", "validated": True} + with mock.patch("kinto.plugins.accounts.mails.Emailer.send_mail") as mocked_send_mail: + # No email sent if account validation is not enabled. + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, + [{"old": old_inactive, "new": new_inactive}], + request, + ) + on_account_activated(event) + mocked_send_mail.assert_not_called() + # No email sent if the old account was already active. + request.registry.settings["account_validation"] = True + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, [{"old": old_active, "new": new_active}], request + ) + request.registry.cache.get = mock.MagicMock(return_value=None) + on_account_activated(event) + mocked_send_mail.assert_not_called() + # No email sent if the new account is still inactive. + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, + [{"old": old_inactive, "new": new_inactive}], + request, + ) + request.registry.cache.get = mock.MagicMock(return_value=None) + on_account_activated(event) + mocked_send_mail.assert_not_called() + # Email sent if there is an activation key in the cache. + event = ResourceChanged( + {"action": ACTIONS.UPDATE.value}, + [{"old": old_inactive, "new": new_active}], + request, + ) + on_account_activated(event) + mocked_send_mail.assert_called_once() + class AccountUpdateTest(AccountsWebTest): def setUp(self):