Skip to content

Commit

Permalink
Move all the mail sending related code to a separate module
Browse files Browse the repository at this point in the history
  • Loading branch information
magopian committed Feb 11, 2019
1 parent caec88d commit 80a7cae
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 134 deletions.
95 changes: 95 additions & 0 deletions 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)
11 changes: 0 additions & 11 deletions kinto/plugins/accounts/utils.py
@@ -1,5 +1,4 @@
import bcrypt
import string

from kinto.core import utils

Expand Down Expand Up @@ -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))
48 changes: 2 additions & 46 deletions kinto/plugins/accounts/views/__init__.py
Expand Up @@ -6,27 +6,22 @@
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
from kinto.core import resource, utils
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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
81 changes: 5 additions & 76 deletions kinto/plugins/accounts/views/validation.py
Expand Up @@ -2,29 +2,19 @@
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
from kinto.core.errors import raise_invalid, http_error
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(
Expand Down Expand Up @@ -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"}

Expand All @@ -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()
65 changes: 64 additions & 1 deletion tests/plugins/test_accounts.py
Expand Up @@ -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

Expand All @@ -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


Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 80a7cae

Please sign in to comment.