From 437f682af31b6fac4441d60ce9c0ce7a645ccfbc Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 18 Apr 2024 17:33:35 -0700 Subject: [PATCH] Good enough email template, for now. --- .env.sample | 9 + server/settings.py | 29 ++ server/utils/email.py | 70 +++++ server/vb/models.py | 2 +- server/vb/ops.py | 16 +- server/vb/templates/email/base/body.dhtml | 272 ++++++++++++++++++ server/vb/templates/email/base/body.txt | 9 + server/vb/templates/email/base/button.html | 79 +++++ server/vb/templates/email/base/center.html | 3 + .../vb/templates/email/base/center_close.html | 3 + server/vb/templates/email/base/p.html | 2 + server/vb/templates/email/base/p_close.html | 2 + server/vb/templates/email/validate/body.dhtml | 11 + server/vb/templates/email/validate/body.txt | 10 + .../vb/templates/email/validate/subject.txt | 1 + server/vb/urls.py | 4 +- server/vb/views.py | 2 +- 17 files changed, 518 insertions(+), 6 deletions(-) create mode 100644 server/vb/templates/email/base/body.dhtml create mode 100644 server/vb/templates/email/base/body.txt create mode 100644 server/vb/templates/email/base/button.html create mode 100644 server/vb/templates/email/base/center.html create mode 100644 server/vb/templates/email/base/center_close.html create mode 100644 server/vb/templates/email/base/p.html create mode 100644 server/vb/templates/email/base/p_close.html create mode 100644 server/vb/templates/email/validate/body.dhtml create mode 100644 server/vb/templates/email/validate/body.txt create mode 100644 server/vb/templates/email/validate/subject.txt diff --git a/.env.sample b/.env.sample index b8a1f37..1f62ccc 100644 --- a/.env.sample +++ b/.env.sample @@ -12,3 +12,12 @@ export DJANGO_SUPERUSER_PASSWORD=ultrasekret # export AWS_REGION=us-east-1 # export AGCOD_ENDPOINT_HOST=agcod-v2-gamma.amazon.com # export AGCOD_PARTNER_ID= + +# Enable these for Mandrill integration. See 1Password. +# export EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +# export EMAIL_HOST=smtp.mandrillapp.com +# export EMAIL_HOST_PASSWORD= +# export EMAIL_HOST_USER=VoterBowl +# export EMAIL_PORT=587 +# export EMAIL_USE_TLS=true +# export EMAIL_USE_SSL=false diff --git a/server/settings.py b/server/settings.py index 5394623..2e79400 100644 --- a/server/settings.py +++ b/server/settings.py @@ -165,3 +165,32 @@ # Sandbox endpoint: agcod-v2-gamma.amazon.com us-east-1 # Production endpoint: agcod-v2.amazon.com us-east-1 + + +# ---------------------------------------------------------------------------- +# Email Settings +# ---------------------------------------------------------------------------- + +# The email address that emails are sent from unless explicitly overridden +# when invoking Django's `send_mail` function +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "hello@voterbowl.org") + +# The *sending* email address used when Django emails admins about errors. +# For now, we make this the same as DEFAULT_FROM_EMAIL +SERVER_EMAIL = DEFAULT_FROM_EMAIL + +# The email addresses of our administrators. +ADMINS = [("Frontseat Developers", "dev@frontseat.org")] + +# How to send email. We default to console-based email, which simply prints +# emails to the console. This is useful for local development. In production, +# we'll configure SMTP. +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend" +) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", None) +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", None) +EMAIL_HOST = os.getenv("EMAIL_HOST", None) +EMAIL_PORT = os.getenv("EMAIL_PORT", None) +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "false").lower() == "true" +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" diff --git a/server/utils/email.py b/server/utils/email.py index 68a3bae..10f4761 100644 --- a/server/utils/email.py +++ b/server/utils/email.py @@ -1,4 +1,12 @@ import dataclasses +import logging +import typing as t + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string + +logger = logging.getLogger(__name__) @dataclasses.dataclass(frozen=True) @@ -42,3 +50,65 @@ def normalize_email( local = local.encode("ascii", "ignore").decode("ascii") domain = domain.encode("ascii", "ignore").decode("ascii") return f"{local}@{domain}" + + +def send_template_email( + to: str | t.Sequence[str], + template_base: str, + context: dict | None = None, + from_email: str | None = None, +) -> bool: + """ + Send a templatized email. + + Send an email to the `to` address, using the template files found under + the `template_base` to render contents. + + The following named templates must be found underneath `template_base`: + + - `subject.txt`: renders the subject line + - `body.txt`: renders the plain-text body + - `body.dhtml`: renders the HTML body + + Django's template system is flexible and can load templates from just about + anywhere, provided you write a plugin. But! By default, we're going to load + them from the filesystem; `template_base` is simply the name of the + directory that contains these three files, relative to the `templates` + directory in the app. + + For instance, if we have `subject`/`body` templates in + `server/assistant/templates/email/registration`, then `template_base` is + `email/registration`. + """ + to_array = [to] if isinstance(to, str) else to + + message = create_message(to_array, template_base, context, from_email) + try: + message.send() + return True + except Exception: + logger.exception(f"failed to send email to {to}") + return False + + +def create_message( + to: t.Sequence[str], + template_base: str, + context: dict[str, t.Any] | None = None, + from_email: str | None = None, +) -> EmailMultiAlternatives: + """Create the underlying email message to send.""" + context = context or {} + from_email = from_email or settings.DEFAULT_FROM_EMAIL + context.setdefault("BASE_URL", settings.BASE_URL) + + subject = render_to_string(f"{template_base}/subject.txt", context).strip() + text = render_to_string(f"{template_base}/body.txt", context) + html = render_to_string(f"{template_base}/body.dhtml", context) + + message = EmailMultiAlternatives( + from_email=from_email, to=to, subject=subject, body=text + ) + message.attach_alternative(html, "text/html") + + return message diff --git a/server/vb/models.py b/server/vb/models.py index fe341f0..c1f320a 100644 --- a/server/vb/models.py +++ b/server/vb/models.py @@ -361,7 +361,7 @@ def school(self) -> School: @property def relative_url(self) -> str: """Return the relative URL for the email validation link.""" - return reverse("vb:verify_email", args=[self.contest.school.slug, self.token]) + return reverse("vb:validate_email", args=[self.contest.school.slug, self.token]) @property def absolute_url(self) -> str: diff --git a/server/vb/ops.py b/server/vb/ops.py index b28bfef..91a2a8a 100644 --- a/server/vb/ops.py +++ b/server/vb/ops.py @@ -4,6 +4,7 @@ from django.db import transaction from server.utils.agcod import AGCODClient +from server.utils.email import send_template_email from server.utils.tokens import make_token from .models import Contest, EmailValidationLink, GiftCard, School, Student @@ -124,9 +125,20 @@ def send_validation_link_email( student: Student, contest: Contest, email: str ) -> EmailValidationLink: """Generate a validation link to a student for a contest.""" - # TODO dave link = EmailValidationLink.objects.create( student=student, contest=contest, email=email, token=make_token(12) ) - print("TODO SENDING A VALIDATION LINK: ", link.absolute_url) + success = send_template_email( + to=email, + template_base="email/validate", + context={ + "student": student, + "contest": contest, + "email": email, + "link": link, + "title": f"Get my ${contest.amount} gift card", + }, + ) + if not success: + logger.error(f"Failed to send email validation link to {email}") return link diff --git a/server/vb/templates/email/base/body.dhtml b/server/vb/templates/email/base/body.dhtml new file mode 100644 index 0000000..79a3ae1 --- /dev/null +++ b/server/vb/templates/email/base/body.dhtml @@ -0,0 +1,272 @@ + + + + + + {{ subject }} + + + + + + + + +
  +
+ {# START CENTERED WHITE CONTAINER #} + {{ subject }} + + {# START MAIN CONTENT AREA #} + + + + {# END MAIN CONTENT AREA #} {# START CONTENT AREA FOOTER #} + + + + {# END CONTENT AREA FOOTER #} +
+ + + + +
+ + {% block message %}{% endblock %} + +
+
+ + {% block footer %} + + {% endblock footer %} + +
+ {# END CONTENT CONTAINER #} {# START GRAY AREA FOOTER #} + + + + +
+ + + + +
+ © 2024 VoterBowl, All rights reserved. +
+
+ {# END GRAY AREA FOOTER #} +
+
+ + diff --git a/server/vb/templates/email/base/body.txt b/server/vb/templates/email/base/body.txt new file mode 100644 index 0000000..a5bb51d --- /dev/null +++ b/server/vb/templates/email/base/body.txt @@ -0,0 +1,9 @@ +{% autoescape off %} +{% block message %}{% endblock %} +{% block links %}{% endblock %} +{% block footer %} +--- +The VoterBowl Team +hello@voterbowl.org +{% endblock %} +{% endautoescape %} diff --git a/server/vb/templates/email/base/button.html b/server/vb/templates/email/base/button.html new file mode 100644 index 0000000..f038d14 --- /dev/null +++ b/server/vb/templates/email/base/button.html @@ -0,0 +1,79 @@ +{# call with include url=(url OR path) title=title #} {# START EMAIL BUTTON #} + + + + + + +
+ + + + + + +
+ + {% block link_title %}{{ title }}{% endblock %} + +
+
+{# END EMAIL BUTTON #} diff --git a/server/vb/templates/email/base/center.html b/server/vb/templates/email/base/center.html new file mode 100644 index 0000000..7f2ab62 --- /dev/null +++ b/server/vb/templates/email/base/center.html @@ -0,0 +1,3 @@ + + + + +
diff --git a/server/vb/templates/email/base/center_close.html b/server/vb/templates/email/base/center_close.html new file mode 100644 index 0000000..db9d430 --- /dev/null +++ b/server/vb/templates/email/base/center_close.html @@ -0,0 +1,3 @@ +
diff --git a/server/vb/templates/email/base/p.html b/server/vb/templates/email/base/p.html new file mode 100644 index 0000000..854f060 --- /dev/null +++ b/server/vb/templates/email/base/p.html @@ -0,0 +1,2 @@ +{# opening p tag only #} +

diff --git a/server/vb/templates/email/base/p_close.html b/server/vb/templates/email/base/p_close.html new file mode 100644 index 0000000..d0c4952 --- /dev/null +++ b/server/vb/templates/email/base/p_close.html @@ -0,0 +1,2 @@ +{# closing p tag only #} +

diff --git a/server/vb/templates/email/validate/body.dhtml b/server/vb/templates/email/validate/body.dhtml new file mode 100644 index 0000000..ccdd731 --- /dev/null +++ b/server/vb/templates/email/validate/body.dhtml @@ -0,0 +1,11 @@ +{% extends "email/base/body.dhtml" %} + +{% block message %} + {% include "email/base/p.html" %} + Hi {{ student.first_name }}, + {% include "email/base/p_close.html" %} + {% include "email/base/p.html" %} + Thanks for checking your voter registration status! Click this button to get your gift card. + {% include "email/base/p_close.html" %} + {% include "email/base/button.html" with url=link.relative_url title=title %} +{% endblock message %} diff --git a/server/vb/templates/email/validate/body.txt b/server/vb/templates/email/validate/body.txt new file mode 100644 index 0000000..6681a98 --- /dev/null +++ b/server/vb/templates/email/validate/body.txt @@ -0,0 +1,10 @@ +{% extends "email/base/body.txt" %} + +{% block message %}Hi {{ student.first_name }}, + +Thanks for checking your voter registration status! You're almost done. + +Click this link to get your ${{ contest.amount }} Amazon gift card: + +{{ link.absolute_url }} +{% endblock message %} diff --git a/server/vb/templates/email/validate/subject.txt b/server/vb/templates/email/validate/subject.txt new file mode 100644 index 0000000..e1227b5 --- /dev/null +++ b/server/vb/templates/email/validate/subject.txt @@ -0,0 +1 @@ +Your VoterBowl gift card awaits diff --git a/server/vb/urls.py b/server/vb/urls.py index b062425..e0ff73c 100644 --- a/server/vb/urls.py +++ b/server/vb/urls.py @@ -1,10 +1,10 @@ from django.urls import path -from .views import check, finish_check, home, school, verify_email +from .views import check, finish_check, home, school, validate_email app_name = "vb" urlpatterns = [ - path("/verify//", verify_email, name="verify_email"), + path("/v//", validate_email, name="validate_email"), path("/check/finish/", finish_check, name="finish_check"), path("/check/", check, name="check"), path("/", school, name="school"), diff --git a/server/vb/views.py b/server/vb/views.py index ed15f9b..7457fc6 100644 --- a/server/vb/views.py +++ b/server/vb/views.py @@ -110,7 +110,7 @@ def finish_check(request: HttpRequest, slug: str) -> HttpResponse: @require_GET -def verify_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: +def validate_email(request: HttpRequest, slug: str, token: str) -> HttpResponse: """Handle a student email validation link.""" link = get_object_or_404(EmailValidationLink, token=token) school = get_object_or_404(School, slug=slug)