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." %}
- |
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
- |
-
-{% 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 %}
-
-
-
-
-
-
- |
-
-
-
-
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 content %}
+ {% 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")