Merge pull request #776 from mouse-reeve/email-templates

Email templates
This commit is contained in:
Mouse Reeve 2021-03-21 12:51:50 -07:00 committed by GitHub
commit e94a5032fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 222 additions and 33 deletions

View file

@ -4,45 +4,61 @@ from django.template.loader import get_template
from bookwyrm import models from bookwyrm import models
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.settings import DOMAIN
def email_data():
""" fields every email needs """
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_path = "/images/{}".format(site.logo_small.url)
else:
logo_path = "/static/images/logo-small.png"
return {
"site_name": site.name,
"logo": logo_path,
"domain": DOMAIN,
"user": None,
}
def invite_email(invite_request): def invite_email(invite_request):
""" send out an invite code """ """ send out an invite code """
site = models.SiteSettings.objects.get() data = email_data()
data = { data["invite_link"] = invite_request.invite.link
"site_name": site.name, send_email.delay(invite_request.email, *format_email("invite", data))
"invite_link": invite_request.invite.link,
}
send_email.delay(invite_request.email, "invite", data)
def password_reset_email(reset_code): def password_reset_email(reset_code):
""" generate a password reset email """ """ generate a password reset email """
site = models.SiteSettings.objects.get() data = email_data()
data = { data["reset_link"] = reset_code.link
"site_name": site.name, data["user"] = reset_code.user.display_name
"reset_link": reset_code.link, send_email.delay(reset_code.user.email, *format_email("password_reset", data))
}
send_email.delay(reset_code.user.email, "password_reset", data)
@app.task def format_email(email_name, data):
def send_email(recipient, message_name, data): """ render the email templates """
""" use a task to send the email """
subject = ( subject = (
get_template("email/{}/subject.html".format(message_name)).render(data).strip() get_template("email/{}/subject.html".format(email_name)).render(data).strip()
) )
html_content = ( html_content = (
get_template("email/{}/html_content.html".format(message_name)) get_template("email/{}/html_content.html".format(email_name))
.render(data) .render(data)
.strip() .strip()
) )
text_content = ( text_content = (
get_template("email/{}/text_content.html".format(message_name)) get_template("email/{}/text_content.html".format(email_name))
.render(data) .render(data)
.strip() .strip()
) )
return (subject, html_content, text_content)
@app.task
def send_email(recipient, subject, html_content, text_content):
""" use a task to send the email """
email = EmailMultiAlternatives(subject, text_content, None, [recipient]) email = EmailMultiAlternatives(subject, text_content, None, [recipient])
email.attach_alternative(html_content, "text/html") email.attach_alternative(html_content, "text/html")
email.send() email.send()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -0,0 +1,26 @@
{% load i18n %}
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
<div style="padding: 1rem; overflow: auto;">
<div style="float: left; margin-right: 1rem;">
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="https://{{ domain }}/{{ logo }}" alt="logo"></a>
</div>
<div>
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>
{{ domain }}</a>
</div>
</div>
<div style="padding: 1rem; background-color: white;">
<p>
{% if user %}{{ user }},{% else %}{% trans "Hi there," %}{% endif %}
</p>
{% block content %}{% endblock %}
</div>
<div style="padding: 1rem; font-size: 0.8rem;">
<p style="margin: 0; color: #333;">{% blocktrans %}BookWyrm hosted on <a style="color: #3273dc;" href="https://{{ domain }}">{{ site_name }}</a>{% endblocktrans %}</p>
{% if user %}
<p style="margin: 0; color: #333;"><a style="color: #3273dc;" href="https://{{ domain }}{% url 'prefs-profile' %}">{% trans "Email preference" %}</a></p>
{% endif %}
</div>
</div>

View file

@ -1,2 +1,17 @@
{% extends 'email/html_layout.html' %}
{% load i18n %} {% load i18n %}
<a href="{{ invite_link }}">{% blocktrans %}Join {{ site_name }}{% endblocktrans %}</a>
{% block content %}
<p>
{% blocktrans %}You're invited to join {{ site_name }}!{% endblocktrans %}
</p>
{% trans "Join Now" as text %}
{% include 'email/snippets/action.html' with path=invite_link text=text %}
<p>
{% url 'code-of-conduct' as coc_path %}
{% url 'about' as about_path %}
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about this instance</a>.{% endblocktrans %}
</p>
{% endblock %}

View file

@ -1,2 +1,2 @@
{% load i18n %} {% load i18n %}
{% blocktrans %}You're invited! Join {{ site_name }}{% endblocktrans %} {% blocktrans %}You're invited to join {{ site_name }}!{% endblocktrans %}

View file

@ -1,2 +1,10 @@
{% extends 'email/text_layout.html' %}
{% load i18n %} {% load i18n %}
{% blocktrans %}Join {{ site_name }}: {{ invite_link }}{% endblocktrans %} {% block content %}
{% blocktrans %}You're invited to join {{ site_name }}! Click the link below to create an account.{% endblocktrans %}
{{ invite_link }}
{% trans "Learn more about this instance:" %} https://{{ domain }}{% url 'about' %}
{% endblock %}

View file

@ -1,2 +1,15 @@
{% extends 'email/html_layout.html' %}
{% load i18n %} {% load i18n %}
{% blocktrans %}Your password reset link is: {{ reset_link }}{% endblocktrans %}
{% block content %}
<p>
{% blocktrans %}You requested to reset your {{ site_name }} password. Click the link below to set a new password and log in to your account.{% endblocktrans %}
</p>
{% trans "Reset Password" as text %}
{% include 'email/snippets/action.html' with path=reset_link text=text %}
<p>
{% trans "If you didn't request to reset your password, you can ignore this email." %}
</p>
{% endblock %}

View file

@ -1,2 +1,2 @@
{% load i18n %} {% load i18n %}
{% blocktrans %}Reset your password on {{ site_name }}{% endblocktrans %} {% blocktrans %}Reset your {{ site_name }} password{% endblocktrans %}

View file

@ -1,2 +1,9 @@
{% extends 'email/text_layout.html' %}
{% load i18n %} {% load i18n %}
{% blocktrans %}Your password reset link is: {{ reset_link }}{% endblocktrans %} {% block content %}
{% blocktrans %}You requested to reset your {{ site_name }} password. Click the link below to set a new password and log in to your account.{% endblocktrans %}
{{ reset_link }}
{% trans "If you didn't request to reset your password, you can ignore this email." %}
{% endblock %}

View file

@ -0,0 +1,19 @@
<html>
<body>
<div>
<strong>Subject:</strong> {% include subject_path %}
</div>
<hr>
<strong>Html email:</strong>
<div style="border: 1px solid #c0c0c0; margin: 2em; padding: 1em;">
{% include html_content_path %}
</div>
<hr>
<strong>Text email:</strong>
<div style="border: 1px solid #c0c0c0; margin: 2em; padding: 1em; white-space: pre;">
{% include text_content_path %}
</div>
</body>
</html>

View file

@ -0,0 +1,5 @@
<p style="text-align: center; margin: 2rem;">
<a href="{{ path }}" style="background-color: #3273dc; color: #fff; border-radius: 4px; padding: 0.5rem 1rem; text-decoration: none;">
{{ text }}
</a>
</p>

View file

@ -0,0 +1,3 @@
{% load i18n %}
{% if user %}{{ user.display_name }},{% else %}{% trans "Hi there," %}{% endif %}
{% block content %}{% endblock %}

View file

@ -8,7 +8,9 @@
<div class="column is-half"> <div class="column is-half">
<div class="block"> <div class="block">
<h1 class="title">{% trans "Reset Password" %}</h1> <h1 class="title">{% trans "Reset Password" %}</h1>
{% if message %}<p>{{ message }}</p>{% endif %}
{% if message %}<p class="notification is-primary">{{ message }}</p>{% endif %}
<p>{% trans "A link to reset your password will be sent to your email address" %}</p> <p>{% trans "A link to reset your password will be sent to your email address" %}</p>
<form name="password-reset" method="post" action="/password-reset"> <form name="password-reset" method="post" action="/password-reset">
{% csrf_token %} {% csrf_token %}
@ -16,6 +18,9 @@
<label class="label" for="id_email_register">{% trans "Email address:" %}</label> <label class="label" for="id_email_register">{% trans "Email address:" %}</label>
<div class="control"> <div class="control">
<input type="email" name="email" maxlength="254" class="input" id="id_email_register"> <input type="email" name="email" maxlength="254" class="input" id="id_email_register">
{% if error %}
<p class="help is-danger">{{ error }}</p>
{% endif %}
</div> </div>
</div> </div>
<div class="field is-grouped"> <div class="field is-grouped">

View file

@ -0,0 +1,51 @@
""" test creating emails """
from unittest.mock import patch
from django.test import TestCase
from django.test.client import RequestFactory
import responses
from bookwyrm import emailing, models
@patch("bookwyrm.emailing.send_email.delay")
class Emailing(TestCase):
""" every response to a get request, html or json """
def setUp(self):
""" we need basic test data and mocks """
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
)
models.SiteSettings.objects.create()
def test_invite_email(self, email_mock):
""" load the invite email """
invite_request = models.InviteRequest.objects.create(
email="test@email.com",
invite=models.SiteInvite.objects.create(user=self.local_user),
)
emailing.invite_email(invite_request)
self.assertEqual(email_mock.call_count, 1)
args = email_mock.call_args[0]
self.assertEqual(args[0], "test@email.com")
self.assertEqual(args[1], "You're invited to join BookWyrm!")
self.assertEqual(len(args), 4)
def test_password_reset_email(self, email_mock):
""" load the password reset email """
reset = models.PasswordReset.objects.create(user=self.local_user)
emailing.password_reset_email(reset)
self.assertEqual(email_mock.call_count, 1)
args = email_mock.call_args[0]
self.assertEqual(args[0], "mouse@mouse.mouse")
self.assertEqual(args[1], "Reset your BookWyrm password")
self.assertEqual(len(args), 4)

View file

@ -9,7 +9,6 @@ import responses
from bookwyrm import models, importer from bookwyrm import models, importer
from bookwyrm.goodreads_import import GoodreadsImporter from bookwyrm.goodreads_import import GoodreadsImporter
from bookwyrm import importer
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
@ -17,8 +16,8 @@ class GoodreadsImport(TestCase):
""" importing from goodreads csv """ """ importing from goodreads csv """
def setUp(self): def setUp(self):
self.importer = GoodreadsImporter()
""" use a test csv """ """ use a test csv """
self.importer = GoodreadsImporter()
datafile = pathlib.Path(__file__).parent.joinpath("data/goodreads.csv") datafile = pathlib.Path(__file__).parent.joinpath("data/goodreads.csv")
self.csv = open(datafile, "r", encoding=self.importer.encoding) self.csv = open(datafile, "r", encoding=self.importer.encoding)
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(

View file

@ -42,7 +42,8 @@ class PasswordViews(TestCase):
request = self.factory.post("", {"email": "aa@bb.ccc"}) request = self.factory.post("", {"email": "aa@bb.ccc"})
view = views.PasswordResetRequest.as_view() view = views.PasswordResetRequest.as_view()
resp = view(request) resp = view(request)
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 200)
resp.render()
request = self.factory.post("", {"email": "mouse@mouse.com"}) request = self.factory.post("", {"email": "mouse@mouse.com"})
with patch("bookwyrm.emailing.send_email.delay"): with patch("bookwyrm.emailing.send_email.delay"):

View file

@ -48,6 +48,11 @@ urlpatterns = [
), ),
# admin # admin
re_path(r"^settings/site-settings", views.Site.as_view(), name="settings-site"), re_path(r"^settings/site-settings", views.Site.as_view(), name="settings-site"),
re_path(
r"^settings/email-preview",
views.site.email_preview,
name="settings-email-preview",
),
re_path( re_path(
r"^settings/federation", views.Federation.as_view(), name="settings-federation" r"^settings/federation", views.Federation.as_view(), name="settings-federation"
), ),
@ -87,8 +92,8 @@ urlpatterns = [
), ),
re_path(r"^report/?$", views.make_report, name="report"), re_path(r"^report/?$", views.make_report, name="report"),
# landing pages # landing pages
re_path(r"^about/?$", views.About.as_view()), re_path(r"^about/?$", views.About.as_view(), name="about"),
path("", views.Home.as_view()), path("", views.Home.as_view(), name="landing"),
re_path(r"^discover/?$", views.Discover.as_view()), re_path(r"^discover/?$", views.Discover.as_view()),
re_path(r"^notifications/?$", views.Notifications.as_view()), re_path(r"^notifications/?$", views.Notifications.as_view()),
# feeds # feeds

View file

@ -5,6 +5,7 @@ from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views import View from django.views import View
from bookwyrm import models from bookwyrm import models
@ -28,7 +29,8 @@ class PasswordResetRequest(View):
try: try:
user = models.User.objects.get(email=email) user = models.User.objects.get(email=email)
except models.User.DoesNotExist: except models.User.DoesNotExist:
return redirect("/password-reset") data = {"error": _("No user with that email address was found.")}
return TemplateResponse(request, "password_reset_request.html", data)
# remove any existing password reset cods for this user # remove any existing password reset cods for this user
models.PasswordReset.objects.filter(user=user).all().delete() models.PasswordReset.objects.filter(user=user).all().delete()
@ -36,7 +38,7 @@ class PasswordResetRequest(View):
# create a new reset code # create a new reset code
code = models.PasswordReset.objects.create(user=user) code = models.PasswordReset.objects.create(user=user)
password_reset_email(code) password_reset_email(code)
data = {"message": "Password reset link sent to %s" % email} data = {"message": _("A password reset link sent to %s" % email)}
return TemplateResponse(request, "password_reset_request.html", data) return TemplateResponse(request, "password_reset_request.html", data)

View file

@ -5,7 +5,7 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import forms, models from bookwyrm import emailing, forms, models
# pylint: disable= no-self-use # pylint: disable= no-self-use
@ -33,3 +33,17 @@ class Site(View):
form.save() form.save()
return redirect("settings-site") return redirect("settings-site")
@login_required
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
def email_preview(request):
""" for development, renders and example email template """
template = request.GET.get("email")
data = emailing.email_data()
data["subject_path"] = "email/{}/subject.html".format(template)
data["html_content_path"] = "email/{}/html_content.html".format(template)
data["text_content_path"] = "email/{}/text_content.html".format(template)
data["reset_link"] = "https://example.com/link"
data["invite_link"] = "https://example.com/link"
return TemplateResponse(request, "email/preview.html", data)