stages/email: start rewriting templates, add template tags to embed CSS and images

This commit is contained in:
Jens Langhammer 2020-05-10 20:16:58 +02:00
parent e989c61793
commit a67c53f46a
15 changed files with 499 additions and 276 deletions

View file

@ -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 %}
<!-- HERO -->
<tr>
<td bgcolor="#7c72dc" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">{% trans 'Trouble signing in?' %}</h1>
</td>
</tr>
</table>
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">{% 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." %}</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#7c72dc"><a href="{{ url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #7c72dc; display: inline-block;">{% trans 'Reset Password' %}</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- COPY CALLOUT -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
<!-- HEADLINE -->
<tr>
<td bgcolor="#111111" align="left" style="padding: 40px 30px 20px 30px; color: #ffffff; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<h2 style="font-size: 24px; font-weight: 400; margin: 0;">{% trans 'Want a more secure account?' %}</h2>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#111111" align="left" style="padding: 0px 30px 20px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">{% trans 'We support two-factor authentication to help keep your information private.' %}</p>
</td>
</tr>
<!-- COPY -->
<tr>
<td bgcolor="#111111" align="left" style="padding: 0px 30px 40px 30px; border-radius: 0px 0px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;"><a href="http://litmus.com" target="_blank" style="color: #7c72dc;">{% trans 'See how easy it is to get started' %}</a></p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View file

@ -1,129 +0,0 @@
{% load inline %}
{% load utils %}
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<title>passbook</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<style type="text/css">
/* CLIENT-SPECIFIC STYLES */
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* RESET STYLES */
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
table {
border-collapse: collapse !important;
}
body {
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* ANDROID CENTER FIX */
div[style*="margin: 16px 0;"] {
margin: 0 !important;
}
</style>
</head>
<body style="background-color: #1b2a32; margin: 0 !important; padding: 0 !important;">
<!-- HIDDEN PREHEADER TEXT -->
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Metropolis', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
{% block pre_header %}
{% endblock %}
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- LOGO -->
<tr>
<td bgcolor="#3625b7" align="center">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td align="center" valign="top" style="padding: 40px 10px 40px 10px;">
<a href="" target="_blank">
<img alt="Logo" src="{% inline_static 'assets/dark.svg' %}" width="64" height="64"
style="display: block; width: 64px; max-width: 64px; min-width: 64px; font-family: 'Metropolis', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
border="0">
</a>
</td>
</tr>
</table>
</td>
</tr>
{% block content %}
{% endblock %}
<!-- SUPPORT CALLOUT -->
<!-- <tr>
<td bgcolor="#1b2a32" align="center" style="padding: 30px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
HEADLINE
<tr>
<td bgcolor="#566572" align="center" style="padding: 30px 30px 30px 30px; border-radius: 4px 4px 4px 4px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<h2 style="font-size: 20px; font-weight: 400; color: ##E9ECEF; margin: 0;">Need more help?</h2>
<p style="margin: 0;"><a href="http://litmus.com" target="_blank" style="color: #3625b7;">We&rsquo;re
here, ready to talk</a></p>
</td>
</tr>
</table>
</td>
</tr> -->
<!-- FOOTER -->
<tr>
<td bgcolor="#1b2a32" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<!-- NAVIGATION -->
<tr>
<td bgcolor="#1b2a32" align="left" style="padding: 30px 30px 30px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;">
<p style="margin: 0;">
</p>
</td>
</tr>
<!-- ADDRESS -->
<tr>
<td bgcolor="#1b2a32" align="left" style="padding: 0px 30px 30px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;">
<p style="margin: 0;"><a href="passbook">passbook</a></p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

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

View file

@ -75,6 +75,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.postgres",
"django.contrib.humanize",
"rest_framework",
"drf_yasg",
"guardian",

View file

@ -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"""

View file

@ -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;
}
}

View file

@ -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()

View file

@ -0,0 +1,65 @@
{% load passbook_stages_email %}
{% load utils %}
{% load static %}
{% load i18n %}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Simple Transactional Email</title>
<style>{% inline_static_ascii "stages/email/css/base.css" %}</style>
</head>
<body class="">
<span class="preheader">
{% block pre_header %}
{% endblock %}
</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main">
<img src="{% inline_static_binary "passbook/logo.svg" %}" alt="">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
{% block content %}
{% endblock %}
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block powered-by">
Powered by <a href="https://beryju.github.io/passbook/">passbook</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,41 @@
{% extends "stages/email/for_email/base.html" %}
{% load utils %}
{% load i18n %}
{% load humanize %}
{% block content %}
<td>
<h2>
{% blocktrans with username=user.username %}
Hi {{ username }},
{% endblocktrans %}
</h2>
<p>
{% blocktrans %}
You recently requested to change your password for you passbook account. Use the button below to set a new password.
{% endblocktrans %}
</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{ url }}" target="_blank">{% trans 'Reset Password' %}</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>
{% 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 %}
</p>
</td>
{% endblock %}

View file

@ -0,0 +1 @@
check your emails mate

View file

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

View file

@ -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")