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 #}
+
+
+ {# START MAIN CONTENT AREA #}
+
+
+
+
+
+
+ {% block message %}{% endblock %}
+
+ |
+
+
+ |
+
+ {# END MAIN CONTENT AREA #} {# START CONTENT AREA FOOTER #}
+
+
+
+ {% block footer %}
+
+ {% endblock footer %}
+
+ |
+
+ {# END CONTENT AREA 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 #}
+
+{# 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)