From a67c53f46a4720fea7c0f1e8952f37900d3f7abf Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 10 May 2020 20:16:58 +0200 Subject: [PATCH] stages/email: start rewriting templates, add template tags to embed CSS and images --- .../email/account_password_reset.html | 77 ----- passbook/core/templates/email/base.html | 129 ------- passbook/lib/templatetags/inline.py | 20 -- passbook/root/settings.py | 1 + passbook/stages/email/stage.py | 37 +- .../email/static/stages/email/css/base.css | 325 ++++++++++++++++++ passbook/stages/email/tasks.py | 15 +- .../email/for_email}/account_confirm.html | 0 .../stages/email/for_email/base.html | 65 ++++ .../email/for_email}/generic_email.html | 0 .../email/for_email/password_reset.html | 41 +++ .../stages/email/waiting_message.html | 1 + .../stages/email/templatetags/__init__.py | 0 .../templatetags/passbook_stages_email.py | 30 ++ passbook/stages/email/utils.py | 34 +- 15 files changed, 499 insertions(+), 276 deletions(-) delete mode 100644 passbook/core/templates/email/account_password_reset.html delete mode 100644 passbook/core/templates/email/base.html delete mode 100644 passbook/lib/templatetags/inline.py create mode 100644 passbook/stages/email/static/stages/email/css/base.css rename passbook/{core/templates/email => stages/email/templates/stages/email/for_email}/account_confirm.html (100%) create mode 100644 passbook/stages/email/templates/stages/email/for_email/base.html rename passbook/{core/templates/email => stages/email/templates/stages/email/for_email}/generic_email.html (100%) create mode 100644 passbook/stages/email/templates/stages/email/for_email/password_reset.html create mode 100644 passbook/stages/email/templates/stages/email/waiting_message.html create mode 100644 passbook/stages/email/templatetags/__init__.py create mode 100644 passbook/stages/email/templatetags/passbook_stages_email.py diff --git a/passbook/core/templates/email/account_password_reset.html b/passbook/core/templates/email/account_password_reset.html deleted file mode 100644 index 827ab9716..000000000 --- a/passbook/core/templates/email/account_password_reset.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "email/base.html" %} - -{% load utils %} -{% load i18n %} - -{% block pre_header %} -{% trans "Looks like you tried signing in a few too many times. Let's see if we can get you back into your account." %} -{% endblock %} - -{% block content %} - - - - - - - -
-

{% trans 'Trouble signing in?' %}

-
- - - - - - - - - - - - - - -
-

{% trans "Resetting your password is easy. Just press the button below and follow the instructions. We'll have you up and running in no time." %}

-
- - - - -
- - - - -
{% trans 'Reset Password' %}
-
-
- - - - - - - - - - - - - - - - - - -
-

{% trans 'Want a more secure account?' %}

-
-

{% trans 'We support two-factor authentication to help keep your information private.' %}

-
-

{% trans 'See how easy it is to get started' %}

-
- - -{% endblock %} diff --git a/passbook/core/templates/email/base.html b/passbook/core/templates/email/base.html deleted file mode 100644 index 86b82f8ae..000000000 --- a/passbook/core/templates/email/base.html +++ /dev/null @@ -1,129 +0,0 @@ -{% load inline %} -{% load utils %} -{% load static %} -{% load i18n %} - - - - passbook - - - - - - - - - -
- {% block pre_header %} - {% endblock %} -
- - - - - - - {% block content %} - {% endblock %} - - - - - - -
- - - - -
- - Logo - -
-
- - - - - - - - - -
-

-

-
-

passbook

-
-
- - diff --git a/passbook/lib/templatetags/inline.py b/passbook/lib/templatetags/inline.py deleted file mode 100644 index 9e805e523..000000000 --- a/passbook/lib/templatetags/inline.py +++ /dev/null @@ -1,20 +0,0 @@ -"""passbook core inlining template tags""" -import os - -from django import template -from django.conf import settings - -register = template.Library() - - -@register.simple_tag() -def inline_static(path): - """Inline static asset. If file is binary, return b64 representation""" - prefix = "data:image/svg+xml;utf8," - data = "" - full_path = settings.STATIC_ROOT + "/" + path - if os.path.exists(full_path): - if full_path.endswith(".svg"): - with open(full_path) as _file: - data = _file.read() - return prefix + data diff --git a/passbook/root/settings.py b/passbook/root/settings.py index c0ffdb0ba..b711f396b 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -75,6 +75,7 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.postgres", + "django.contrib.humanize", "rest_framework", "drf_yasg", "guardian", diff --git a/passbook/stages/email/stage.py b/passbook/stages/email/stage.py index 129e57d4f..76cb97726 100644 --- a/passbook/stages/email/stage.py +++ b/passbook/stages/email/stage.py @@ -1,14 +1,17 @@ """passbook multi-stage authentication engine""" +from datetime import timedelta +from urllib.parse import quote + from django.contrib import messages from django.http import HttpRequest from django.shortcuts import reverse +from django.utils.timezone import now from django.utils.translation import gettext as _ from structlog import get_logger from passbook.core.models import Nonce from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.stage import AuthenticationStage -from passbook.lib.config import CONFIG from passbook.stages.email.tasks import send_mails from passbook.stages.email.utils import TemplateEmailMessage @@ -18,32 +21,40 @@ LOGGER = get_logger() class EmailStageView(AuthenticationStage): """E-Mail stage which sends E-Mail for verification""" - def get_context_data(self, **kwargs): - kwargs["show_password_forget_notice"] = CONFIG.y( - "passbook.password_reset.enabled" - ) - return super().get_context_data(**kwargs) + template_name = "stages/email/waiting_message.html" def get(self, request, *args, **kwargs): + # TODO: Form to make sure email is only sent once pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - nonce = Nonce.objects.create(user=pending_user) + # TODO: Get expiry from Stage setting + valid_delta = timedelta( + minutes=31 + ) # 31 because django timesince always rounds down + nonce = Nonce.objects.create(user=pending_user, expires=now() + valid_delta) # Send mail to user message = TemplateEmailMessage( - subject=_("Forgotten password"), - template_name="email/account_password_reset.html", + subject=_("passbook - Password Recovery"), + template_name="stages/email/for_email/password_reset.html", to=[pending_user.email], template_context={ "url": self.request.build_absolute_uri( reverse( - "passbook_core:auth-password-reset", - kwargs={"nonce": nonce.uuid}, + "passbook_flows:flow-executor", + kwargs={"flow_slug": self.executor.flow.slug}, ) - ) + + "?token=" + + quote(nonce.uuid.hex) + ), + "user": pending_user, + "expires": nonce.expires, }, ) send_mails(self.executor.current_stage, message) messages.success(request, _("Check your E-Mails for a password reset link.")) - return self.executor.cancel() + # We can't call stage_ok yet, as we're still waiting + # for the user to click the link in the email + # return self.executor.stage_ok() + return super().get(request, *args, **kwargs) def post(self, request: HttpRequest): """Just redirect to next stage""" diff --git a/passbook/stages/email/static/stages/email/css/base.css b/passbook/stages/email/static/stages/email/css/base.css new file mode 100644 index 000000000..6f624e138 --- /dev/null +++ b/passbook/stages/email/static/stages/email/css/base.css @@ -0,0 +1,325 @@ +/* ------------------------------------- +GLOBAL RESETS +------------------------------------- */ + +/*All the styling goes here*/ + +img { + border: none; + -ms-interpolation-mode: bicubic; + max-width: 100%; +} + +body { + background-color: #fafafa; + font-family: sans-serif; + -webkit-font-smoothing: antialiased; + font-size: 14px; + line-height: 1.4; + margin: 0; + padding: 0; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} + +table { + border-collapse: separate; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + width: 100%; } + table td { + font-family: sans-serif; + font-size: 14px; + vertical-align: top; + } + + /* ------------------------------------- + BODY & CONTAINER + ------------------------------------- */ + + .body { + background-color: #fafafa; + width: 100%; + } + + /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */ + .container { + display: block; + margin: 0 auto !important; + /* makes it centered */ + max-width: 580px; + padding: 10px; + width: 580px; + } + + /* This should also be a block element, so that it will fill 100% of the .container */ + .content { + box-sizing: border-box; + display: block; + margin: 0 auto; + max-width: 580px; + padding: 10px; + } + + /* ------------------------------------- + HEADER, FOOTER, MAIN + ------------------------------------- */ + .main { + background: #ffffff; + border-radius: 3px; + width: 100%; + } + + .wrapper { + box-sizing: border-box; + padding: 20px; + } + + .content-block { + padding-bottom: 10px; + padding-top: 10px; + } + + .footer { + clear: both; + margin-top: 10px; + text-align: center; + width: 100%; + } + .footer td, + .footer p, + .footer span, + .footer a { + color: #999999; + font-size: 12px; + text-align: center; + } + + /* ------------------------------------- + TYPOGRAPHY + ------------------------------------- */ + h1, + h2, + h3, + h4 { + color: #000000; + font-family: sans-serif; + font-weight: 400; + line-height: 1.4; + margin: 0; + margin-bottom: 30px; + } + + h1 { + font-size: 35px; + font-weight: 300; + text-align: center; + text-transform: capitalize; + } + + p, + ul, + ol { + font-family: sans-serif; + font-size: 14px; + font-weight: normal; + margin: 0; + margin-bottom: 15px; + } + p li, + ul li, + ol li { + list-style-position: inside; + margin-left: 5px; + } + + a { + color: #06c; + border-radius: 3px; + text-decoration: underline; + } + + /* ------------------------------------- + BUTTONS + ------------------------------------- */ + .btn { + box-sizing: border-box; + width: 100%; } + .btn > tbody > tr > td { + padding-bottom: 15px; } + .btn table { + width: auto; + } + .btn table td { + background-color: #ffffff; + border-radius: 5px; + text-align: center; + } + .btn a { + background-color: #ffffff; + border: solid 1px #06c; + border-radius: 5px; + box-sizing: border-box; + color: #06c; + cursor: pointer; + display: inline-block; + font-size: 14px; + font-weight: bold; + margin: 0; + padding: 12px 25px; + text-decoration: none; + text-transform: capitalize; + } + + .btn-primary table td { + background-color: #06c; + } + + .btn-primary a { + background-color: #06c; + border-color: #06c; + color: #ffffff; + } + + /* ------------------------------------- + OTHER STYLES THAT MIGHT BE USEFUL + ------------------------------------- */ + .last { + margin-bottom: 0; + } + + .first { + margin-top: 0; + } + + .align-center { + text-align: center; + } + + .align-right { + text-align: right; + } + + .align-left { + text-align: left; + } + + .clear { + clear: both; + } + + .mt0 { + margin-top: 0; + } + + .mb0 { + margin-bottom: 0; + } + + .preheader { + color: transparent; + display: none; + height: 0; + max-height: 0; + max-width: 0; + opacity: 0; + overflow: hidden; + mso-hide: all; + visibility: hidden; + width: 0; + } + + .powered-by a { + text-decoration: none; + } + + hr { + border: 0; + border-bottom: 1px solid #fafafa; + margin: 20px 0; + } + + /* ------------------------------------- + RESPONSIVE AND MOBILE FRIENDLY STYLES + ------------------------------------- */ + @media only screen and (max-width: 620px) { + table[class=body] h1 { + font-size: 28px !important; + margin-bottom: 10px !important; + } + table[class=body] p, + table[class=body] ul, + table[class=body] ol, + table[class=body] td, + table[class=body] span, + table[class=body] a { + font-size: 16px !important; + } + table[class=body] .wrapper, + table[class=body] .article { + padding: 10px !important; + } + table[class=body] .content { + padding: 0 !important; + } + table[class=body] .container { + padding: 0 !important; + width: 100% !important; + } + table[class=body] .main { + border-left-width: 0 !important; + border-radius: 0 !important; + border-right-width: 0 !important; + } + table[class=body] .btn table { + width: 100% !important; + } + table[class=body] .btn a { + width: 100% !important; + } + table[class=body] .img-responsive { + height: auto !important; + max-width: 100% !important; + width: auto !important; + } + } + + /* ------------------------------------- + PRESERVE THESE STYLES IN THE HEAD + ------------------------------------- */ + @media all { + .ExternalClass { + width: 100%; + } + .ExternalClass, + .ExternalClass p, + .ExternalClass span, + .ExternalClass font, + .ExternalClass td, + .ExternalClass div { + line-height: 100%; + } + .apple-link a { + color: inherit !important; + font-family: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + text-decoration: none !important; + } + #MessageViewBody a { + color: inherit; + text-decoration: none; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + line-height: inherit; + } + .btn-primary table td:hover { + background-color: #34495e !important; + } + .btn-primary a:hover { + background-color: #34495e !important; + border-color: #34495e !important; + } + } diff --git a/passbook/stages/email/tasks.py b/passbook/stages/email/tasks.py index 265f69cb2..9ff45eb17 100644 --- a/passbook/stages/email/tasks.py +++ b/passbook/stages/email/tasks.py @@ -3,7 +3,7 @@ from smtplib import SMTPException from typing import Any, Dict, List from celery import group -from django.core.mail import EmailMessage +from django.core.mail import EmailMultiAlternatives from structlog import get_logger from passbook.root.celery import CELERY_APP @@ -12,7 +12,7 @@ from passbook.stages.email.models import EmailStage LOGGER = get_logger() -def send_mails(stage: EmailStage, *messages: List[EmailMessage]): +def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]): """Wrapper to convert EmailMessage to dict and send it from worker""" tasks = [] for message in messages: @@ -22,7 +22,9 @@ def send_mails(stage: EmailStage, *messages: List[EmailMessage]): return promise -@CELERY_APP.task(bind=True) +@CELERY_APP.task( + bind=True, autoretry_for=(SMTPException, ConnectionError,), retry_backoff=True +) def _send_mail_task(self, email_stage_pk: int, message: Dict[Any, Any]): """Send E-Mail according to EmailStage parameters from background worker. Automatically retries if message couldn't be sent.""" @@ -31,14 +33,11 @@ def _send_mail_task(self, email_stage_pk: int, message: Dict[Any, Any]): backend.open() # Since django's EmailMessage objects are not JSON serialisable, # we need to rebuild them from a dict - message_object = EmailMessage() + message_object = EmailMultiAlternatives() for key, value in message.items(): setattr(message_object, key, value) message_object.from_email = stage.from_address LOGGER.debug("Sending mail", to=message_object.to) - try: - num_sent = stage.backend.send_messages([message_object]) - except SMTPException as exc: - raise self.retry(exc=exc) + num_sent = stage.backend.send_messages([message_object]) if num_sent != 1: raise self.retry() diff --git a/passbook/core/templates/email/account_confirm.html b/passbook/stages/email/templates/stages/email/for_email/account_confirm.html similarity index 100% rename from passbook/core/templates/email/account_confirm.html rename to passbook/stages/email/templates/stages/email/for_email/account_confirm.html diff --git a/passbook/stages/email/templates/stages/email/for_email/base.html b/passbook/stages/email/templates/stages/email/for_email/base.html new file mode 100644 index 000000000..8677d592a --- /dev/null +++ b/passbook/stages/email/templates/stages/email/for_email/base.html @@ -0,0 +1,65 @@ +{% load passbook_stages_email %} +{% load utils %} +{% load static %} +{% load i18n %} + + + + + + + + Simple Transactional Email + + + + + + {% block pre_header %} + {% endblock %} + + + + + + + + + + + diff --git a/passbook/core/templates/email/generic_email.html b/passbook/stages/email/templates/stages/email/for_email/generic_email.html similarity index 100% rename from passbook/core/templates/email/generic_email.html rename to passbook/stages/email/templates/stages/email/for_email/generic_email.html diff --git a/passbook/stages/email/templates/stages/email/for_email/password_reset.html b/passbook/stages/email/templates/stages/email/for_email/password_reset.html new file mode 100644 index 000000000..deb24fb61 --- /dev/null +++ b/passbook/stages/email/templates/stages/email/for_email/password_reset.html @@ -0,0 +1,41 @@ +{% extends "stages/email/for_email/base.html" %} + +{% load utils %} +{% load i18n %} +{% load humanize %} + +{% block content %} + +

+ {% blocktrans with username=user.username %} + Hi {{ username }}, + {% endblocktrans %} +

+

+ {% blocktrans %} + You recently requested to change your password for you passbook account. Use the button below to set a new password. + {% endblocktrans %} +

+ + + + + + + +

+ {% blocktrans with expires=expires|naturaltime %} + If you did not request a password change, please ignore this E-Mail. The link above is valid for {{ expires }}. + {% endblocktrans %} +

+ + +{% endblock %} diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/passbook/stages/email/templates/stages/email/waiting_message.html new file mode 100644 index 000000000..4a4ad8b1d --- /dev/null +++ b/passbook/stages/email/templates/stages/email/waiting_message.html @@ -0,0 +1 @@ +check your emails mate diff --git a/passbook/stages/email/templatetags/__init__.py b/passbook/stages/email/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/email/templatetags/passbook_stages_email.py b/passbook/stages/email/templatetags/passbook_stages_email.py new file mode 100644 index 000000000..d39668eff --- /dev/null +++ b/passbook/stages/email/templatetags/passbook_stages_email.py @@ -0,0 +1,30 @@ +"""passbook core inlining template tags""" +import os +from pathlib import Path +from typing import Optional + +from django import template +from django.contrib.staticfiles import finders + +register = template.Library() + + +@register.simple_tag() +def inline_static_ascii(path: str) -> Optional[str]: + """Inline static asset. Doesn't check file contents, plain text is assumed""" + result = finders.find(path) + if os.path.exists(result): + with open(result) as _file: + return _file.read() + return None + + +@register.simple_tag() +def inline_static_binary(path: str) -> Optional[str]: + """Inline static asset. Uses file extension for base64 block""" + result = finders.find(path) + suffix = Path(path).suffix + if os.path.exists(result): + with open(result) as _file: + return f"data:image/{suffix};base64," + _file.read() + return None diff --git a/passbook/stages/email/utils.py b/passbook/stages/email/utils.py index a94f4c5d6..e46f27d7d 100644 --- a/passbook/stages/email/utils.py +++ b/passbook/stages/email/utils.py @@ -8,34 +8,10 @@ class TemplateEmailMessage(EmailMultiAlternatives): """Wrapper around EmailMultiAlternatives with integrated template rendering""" # pylint: disable=too-many-arguments - def __init__( - self, - subject="", - body=None, - from_email=None, - to=None, - bcc=None, - connection=None, - attachments=None, - headers=None, - cc=None, - reply_to=None, - template_name=None, - template_context=None, - ): + def __init__(self, template_name=None, template_context=None, **kwargs): html_content = render_to_string(template_name, template_context) - if not body: - body = strip_tags(html_content) - super().__init__( - subject=subject, - body=body, - from_email=from_email, - to=to, - bcc=bcc, - connection=connection, - attachments=attachments, - headers=headers, - cc=cc, - reply_to=reply_to, - ) + if "body" not in kwargs: + kwargs["body"] = strip_tags(html_content) + super().__init__(**kwargs) + self.content_subtype = "html" self.attach_alternative(html_content, "text/html")