From ba7363dfca7e5831e86e0c8af905bc2556f109bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Y=C3=A1=C3=B1ez?= Date: Wed, 3 Jun 2026 13:23:50 -0400 Subject: [PATCH 1/5] feat(2431): add email templates and send email test command --- templates/emails/_social_icon.html | 1 + templates/emails/base_email.html | 140 ++++++++++++ templates/emails/confirm_email.html | 42 ++++ templates/emails/confirm_email.txt | 19 ++ templates/emails/confirm_email_subject.txt | 1 + templates/emails/password_reset.html | 51 +++++ templates/emails/password_reset.txt | 20 ++ templates/emails/password_reset_subject.txt | 1 + users/management/commands/send_test_emails.py | 207 ++++++++++++++++++ 9 files changed, 482 insertions(+) create mode 100644 templates/emails/_social_icon.html create mode 100644 templates/emails/base_email.html create mode 100644 templates/emails/confirm_email.html create mode 100644 templates/emails/confirm_email.txt create mode 100644 templates/emails/confirm_email_subject.txt create mode 100644 templates/emails/password_reset.html create mode 100644 templates/emails/password_reset.txt create mode 100644 templates/emails/password_reset_subject.txt create mode 100644 users/management/commands/send_test_emails.py diff --git a/templates/emails/_social_icon.html b/templates/emails/_social_icon.html new file mode 100644 index 000000000..ef3f7adaa --- /dev/null +++ b/templates/emails/_social_icon.html @@ -0,0 +1 @@ +{% load custom_static %}{{ name }} diff --git a/templates/emails/base_email.html b/templates/emails/base_email.html new file mode 100644 index 000000000..ab068c49f --- /dev/null +++ b/templates/emails/base_email.html @@ -0,0 +1,140 @@ +{% load custom_static %}{% comment %} + Shared base for Boost transactional emails (Story #2431). + + Frontend / markup only — sending logic lives in the sign-up and + password-reset integration tickets. Render this with a context that + provides absolute-URL helpers so images and links resolve in an inbox: + + scheme "https" (request.scheme) + host "www.boost.org" (request.get_host) + action_url the CTA + fallback URL (confirmation or reset link) + preferences_url manage-email-preferences URL (optional, defaults to #) + unsubscribe_url unsubscribe URL (optional, defaults to #) + + Child templates override the blocks: preheader, hero, card_title, + card_body, cta_label and after_card. +{% endcomment %} + + + + + + + + + + {% block title %}Boost{% endblock %} + + + + + +
+ {% block preheader %}{% endblock %} +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/templates/emails/confirm_email.html b/templates/emails/confirm_email.html new file mode 100644 index 000000000..7bbf09327 --- /dev/null +++ b/templates/emails/confirm_email.html @@ -0,0 +1,42 @@ +{% extends "emails/base_email.html" %} +{% load custom_static %} + +{% comment %} + Email 1 — Sign-up confirmation (Story #2431). + Subject: "Confirm your email" (see confirm_email_subject.txt). + Context: first_name, action_url (confirmation URL), scheme, host. +{% endcomment %} + +{% block title %}Confirm your email{% endblock %} + +{% block preheader %}Confirm your email address to activate your Boost account.{% endblock %} + +{% block hero %} + + + + +
+ +
+{% endblock %} + +{% block card_title %}Welcome to Boost{% endblock %} + +{% block card_body %} +

Hi {{ first_name|default:"there" }},

+

Thanks for signing up for Boost. To activate your account, please confirm your email address by clicking the button below.

+

This link expires in 24 hours.

+{% endblock %} + +{% block cta_label %}Confirm my email{% endblock %} + +{% block after_card %} + + + + +
+ If you didn't create a Boost account, you can safely ignore this email. +
+{% endblock %} diff --git a/templates/emails/confirm_email.txt b/templates/emails/confirm_email.txt new file mode 100644 index 000000000..c09b19c20 --- /dev/null +++ b/templates/emails/confirm_email.txt @@ -0,0 +1,19 @@ +Welcome to Boost + +Hi {{ first_name|default:"there" }}, + +Thanks for signing up for Boost. To activate your account, please confirm +your email address by visiting the link below. + +This link expires in 24 hours. + +Confirm my email: +{{ action_url }} + +If you didn't create a Boost account, you can safely ignore this email. + +-- +Boost.org is supported by grants from The C++ Alliance. + +Want to change how you receive these emails? +You can update your preferences or unsubscribe from this list. diff --git a/templates/emails/confirm_email_subject.txt b/templates/emails/confirm_email_subject.txt new file mode 100644 index 000000000..54142fa0b --- /dev/null +++ b/templates/emails/confirm_email_subject.txt @@ -0,0 +1 @@ +Confirm your email diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html new file mode 100644 index 000000000..ca75dd063 --- /dev/null +++ b/templates/emails/password_reset.html @@ -0,0 +1,51 @@ +{% extends "emails/base_email.html" %} +{% load custom_static %} + +{% comment %} + Email 2 — Password reset (Story #2431). + Subject: "Password reset link" (see password_reset_subject.txt). + Context: first_name, user_email, action_url (reset URL), scheme, host. +{% endcomment %} + +{% block title %}Password reset link{% endblock %} + +{% block preheader %}Reset the password for your Boost account.{% endblock %} + +{% block hero %} + + + + +
+ +
+{% endblock %} + +{% block card_title %}Reset your password{% endblock %} + +{% block card_body %} +

Hi {{ first_name|default:"there" }},

+

We received a request to reset the password for your Boost account ({{ user_email }}).

+

Click the button below to set a new password. This link will expire in 1 hour.

+{% endblock %} + +{% block cta_label %}Reset my password{% endblock %} + +{% block after_card %} + + + + +
+ + + + + +
+ + + Didn't ask for this? Maybe someone typed your email by accident. You can ignore this email safely — your password will stay the same. +
+
+{% endblock %} diff --git a/templates/emails/password_reset.txt b/templates/emails/password_reset.txt new file mode 100644 index 000000000..42d20435d --- /dev/null +++ b/templates/emails/password_reset.txt @@ -0,0 +1,20 @@ +Reset your password + +Hi {{ first_name|default:"there" }}, + +We received a request to reset the password for your Boost account +({{ user_email }}). + +Click the link below to set a new password. This link will expire in 1 hour. + +Reset my password: +{{ action_url }} + +Didn't ask for this? Maybe someone typed your email by accident. You can +ignore this email safely - your password will stay the same. + +-- +Boost.org is supported by grants from The C++ Alliance. + +Want to change how you receive these emails? +You can update your preferences or unsubscribe from this list. diff --git a/templates/emails/password_reset_subject.txt b/templates/emails/password_reset_subject.txt new file mode 100644 index 000000000..7676edcc7 --- /dev/null +++ b/templates/emails/password_reset_subject.txt @@ -0,0 +1 @@ +Password reset link diff --git a/users/management/commands/send_test_emails.py b/users/management/commands/send_test_emails.py new file mode 100644 index 000000000..87a4728bf --- /dev/null +++ b/users/management/commands/send_test_emails.py @@ -0,0 +1,207 @@ +"""Send the transactional email templates (Story #2431) to a test inbox. + +Frontend test helper only -- this does NOT touch the real sign-up / password +reset flows (those live in their own integration tickets). It renders the +templates in ``templates/emails/`` and sends them through the project's +configured email backend (``settings.EMAIL_BACKEND``) -- the same backend the +real transactional emails use. Locally that is the ``maildev`` SMTP container +(``EMAIL_HOST``/``EMAIL_PORT``); deployed environments use the configured ESP. + +Examples +-------- +Send both emails to your local maildev inbox:: + + python manage.py send_test_emails --to you@example.com + +By default the email images are embedded inline (multipart/related CID parts) so +they render even when the static host is not publicly reachable. This requires an +SMTP backend (the local default). Pass ``--no-inline-images`` together with +``--base-url`` to instead reference the images served from the real host (S3 +large-static) -- needed when sending through a non-SMTP ESP backend. +""" + +import re +import time +from email.message import EmailMessage as PyEmailMessage + +import djclick as click +from django.conf import settings +from django.contrib.staticfiles import finders +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string + +# Available templates: key -> subject / text / html templates + a sample link. +TEMPLATES = { + "confirm": { + "subject": "emails/confirm_email_subject.txt", + "text": "emails/confirm_email.txt", + "html": "emails/confirm_email.html", + "action_url": "https://www.boost.org/auth/confirm?token=test-confirm-token-abc123", + }, + "password_reset": { + "subject": "emails/password_reset_subject.txt", + "text": "emails/password_reset.txt", + "html": "emails/password_reset.html", + "action_url": "https://www.boost.org/auth/reset?token=test-reset-token-xyz789", + }, +} + +# Matches the URL of any email image, e.g. src="/static/static-large/img/emails/x.png" +# (local dev) or the absolute S3 URL (prod) -- both end in "img/emails/". +EMAIL_IMG_RE = re.compile(r'src="[^"]*?(img/emails/[^"]+\.(?:png|jpe?g))"') +EXT_SUBTYPE = {"png": "png", "jpg": "jpeg", "jpeg": "jpeg"} + + +def _collect_inline_images(html): + """Rewrite email image ``src`` to ``cid:`` refs; return (html, images). + + ``images`` is a list of ``(cid, subtype, bytes)`` for each referenced file. + """ + relpaths = {m.group(1) for m in EMAIL_IMG_RE.finditer(html)} + html = EMAIL_IMG_RE.sub( + lambda m: 'src="cid:{}"'.format(m.group(1).split("/")[-1]), html + ) + images = [] + for relpath in sorted(relpaths): + # Email images are served from large static (static/static-large/, synced + # to S3); the rendered URL drops that prefix, so add it back to locate the + # file on disk for inline embedding. + path = finders.find(f"static-large/{relpath}") + if not path: + raise click.ClickException(f"Static file not found: {relpath}") + cid = relpath.split("/")[-1] + subtype = EXT_SUBTYPE[relpath.rsplit(".", 1)[1].lower()] + with open(path, "rb") as fh: + images.append((cid, subtype, fh.read())) + return html, images + + +def _send_inline(connection, subject, text_body, html_body, from_email, recipient): + """Build a multipart/related message with inline images and SMTP-send it.""" + html_body, images = _collect_inline_images(html_body) + + msg = PyEmailMessage() + msg["Subject"] = subject + msg["From"] = from_email + msg["To"] = recipient + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + # The HTML alternative is the last payload; attach images related to it so + # clients resolve the cid: references (multipart/related). + html_part = msg.get_payload()[-1] + for cid, subtype, data in images: + html_part.add_related(data, maintype="image", subtype=subtype, cid=f"<{cid}>") + + connection.open() + connection.connection.send_message(msg, from_addr=from_email, to_addrs=[recipient]) + + +@click.command() +@click.option("--to", "recipient", required=True, help="Recipient email address.") +@click.option( + "--template", + "which", + type=click.Choice(["confirm", "password_reset", "all"]), + default="all", + show_default=True, + help="Which template(s) to send.", +) +@click.option("--first-name", default="Vinnie", show_default=True) +@click.option( + "--user-email", + default="", + help="Account email shown in the password reset body (defaults to --to).", +) +@click.option( + "--from-email", + default=settings.DEFAULT_FROM_EMAIL, + show_default=True, + help="From address.", +) +@click.option( + "--base-url", + default="https://www.boost.org", + show_default=True, + help="scheme://host used to build absolute asset/link URLs.", +) +@click.option( + "--inline-images/--no-inline-images", + default=True, + show_default=True, + help="Embed images as inline CID parts (recommended for previews; SMTP only).", +) +@click.option( + "--delay", + type=float, + default=0.0, + show_default=True, + help="Seconds to wait between messages (raise it if your ESP rate-limits bursts).", +) +def command( + recipient, + which, + first_name, + user_email, + from_email, + base_url, + inline_images, + delay, +): + """Render and send the Story #2431 transactional email templates.""" + scheme, _, host = base_url.partition("://") + if not host: # base_url given without a scheme + scheme, host = "https", base_url + + # Use the project's configured email backend -- the same one the real + # transactional emails go through (local maildev SMTP, or the deployed ESP). + connection = get_connection() + is_smtp = "smtp" in settings.EMAIL_BACKEND + if inline_images and not is_smtp: + raise click.ClickException( + "Inline images require an SMTP backend, but EMAIL_BACKEND is " + f"{settings.EMAIL_BACKEND!r}. Re-run with --no-inline-images." + ) + target = ( + f"{settings.EMAIL_HOST}:{settings.EMAIL_PORT}" + if is_smtp + else settings.EMAIL_BACKEND + ) + + keys = ["confirm", "password_reset"] if which == "all" else [which] + click.secho(f"Sending via {target} -> {recipient}", fg="green") + + for index, key in enumerate(keys): + if index and delay: + time.sleep(delay) + spec = TEMPLATES[key] + context = { + "scheme": scheme, + "host": host, + "first_name": first_name, + "user_email": user_email or recipient, + "action_url": spec["action_url"], + "preferences_url": f"{base_url}/account/preferences", + "unsubscribe_url": f"{base_url}/account/unsubscribe", + } + subject = render_to_string(spec["subject"], context).strip() + text_body = render_to_string(spec["text"], context) + html_body = render_to_string(spec["html"], context) + + if inline_images: + _send_inline( + connection, subject, text_body, html_body, from_email, recipient + ) + else: + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=from_email, + to=[recipient], + connection=connection, + ) + msg.attach_alternative(html_body, "text/html") + msg.send() + click.secho(f" sent: {key} — {subject!r}", fg="cyan") + + connection.close() + click.secho("Done.", fg="green") From 928db9689ece64c57d08f3d3a1a9a52f97a477b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Y=C3=A1=C3=B1ez?= Date: Fri, 5 Jun 2026 17:52:19 -0400 Subject: [PATCH 2/5] 2431: improve sent test emails commands and make mail settings customizable by env vars --- config/settings.py | 27 +++-- users/management/commands/send_test_emails.py | 100 +++++++++++++----- 2 files changed, 93 insertions(+), 34 deletions(-) diff --git a/config/settings.py b/config/settings.py index abdcdf82b..b95d00c0a 100755 --- a/config/settings.py +++ b/config/settings.py @@ -561,15 +561,30 @@ # with PKs is safe since users.User's PKs are integers. ] -# EMAIL SETTINGS -- THESE NEED ADJUSTMENT WHEN DECIDED WHICH ESP WILL BE USED -EMAIL_HOST = "maildev" -EMAIL_PORT = 1025 -DEFAULT_FROM_EMAIL = "boost@cppalliance.org" +# EMAIL SETTINGS +# Production sends through Mailgun (Anymail). In local development the default +# is the maildev SMTP container, but every setting below is overridable via env +# so you can route test sends through your own email service provider -- either +# an SMTP provider (set EMAIL_HOST/EMAIL_PORT/EMAIL_HOST_USER/ +# EMAIL_HOST_PASSWORD/EMAIL_USE_TLS) or an Anymail API backend (set +# EMAIL_BACKEND plus that backend's credentials in ANYMAIL). +EMAIL_HOST = env("EMAIL_HOST", default="maildev") +EMAIL_PORT = env.int("EMAIL_PORT", default=1025) +EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="") +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=False) +# Use a "from" on a domain your provider has verified (some providers only +# accept senders on a domain you own). +DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="boost@cppalliance.org") SERVER_EMAIL = "errors@cppalliance.org" -# Deployed email configuration if LOCAL_DEVELOPMENT: - EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_BACKEND = env( + "EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" + ) + # Credentials for an Anymail backend, as a JSON env var, e.g. + # ANYMAIL='{"_API_TOKEN": "..."}'. Empty/ignored for plain SMTP. + ANYMAIL = env.json("ANYMAIL", default={}) else: EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" ANYMAIL = { diff --git a/users/management/commands/send_test_emails.py b/users/management/commands/send_test_emails.py index 87a4728bf..43e178482 100644 --- a/users/management/commands/send_test_emails.py +++ b/users/management/commands/send_test_emails.py @@ -5,7 +5,8 @@ templates in ``templates/emails/`` and sends them through the project's configured email backend (``settings.EMAIL_BACKEND``) -- the same backend the real transactional emails use. Locally that is the ``maildev`` SMTP container -(``EMAIL_HOST``/``EMAIL_PORT``); deployed environments use the configured ESP. +(``EMAIL_HOST``/``EMAIL_PORT``); deployed environments use the configured email +service provider. Examples -------- @@ -14,15 +15,23 @@ python manage.py send_test_emails --to you@example.com By default the email images are embedded inline (multipart/related CID parts) so -they render even when the static host is not publicly reachable. This requires an -SMTP backend (the local default). Pass ``--no-inline-images`` together with -``--base-url`` to instead reference the images served from the real host (S3 -large-static) -- needed when sending through a non-SMTP ESP backend. +they render even when the static host is not publicly reachable. This works with +both SMTP backends (maildev, or your own SMTP server) and API +email-service-provider backends (any Anymail backend). Pass +``--no-inline-images`` together with ``--base-url`` to instead reference the +images served from the real host (S3 large-static). + +To send real preview emails through your own email service provider in local +development, point the email settings at it -- an SMTP provider via the +``EMAIL_*`` settings, or an Anymail API backend via ``EMAIL_BACKEND`` + +``ANYMAIL`` -- and use a ``DEFAULT_FROM_EMAIL`` / ``--from-email`` on a domain +that provider has verified. """ import re import time from email.message import EmailMessage as PyEmailMessage +from email.mime.image import MIMEImage import djclick as click from django.conf import settings @@ -77,23 +86,55 @@ def _collect_inline_images(html): def _send_inline(connection, subject, text_body, html_body, from_email, recipient): - """Build a multipart/related message with inline images and SMTP-send it.""" + """Send a message with inline (CID) images, for any email backend. + + The two backends need different handling (Django 6 dropped the high-level + ``multipart/related`` hooks, so attachments otherwise land in a flat + ``multipart/mixed``): + + * SMTP (maildev, or your own SMTP server) -- hand-build a proper + ``multipart/alternative[text, multipart/related[html, images]]`` tree so + clients render the ``cid:`` references inline. + * API email service provider (any Anymail backend) -- attach the images + as ``Content-ID`` inline parts; Anymail turns those into the provider's + native inline images. + """ html_body, images = _collect_inline_images(html_body) - msg = PyEmailMessage() - msg["Subject"] = subject - msg["From"] = from_email - msg["To"] = recipient - msg.set_content(text_body) - msg.add_alternative(html_body, subtype="html") - # The HTML alternative is the last payload; attach images related to it so - # clients resolve the cid: references (multipart/related). - html_part = msg.get_payload()[-1] + if "smtp" in settings.EMAIL_BACKEND: + msg = PyEmailMessage() + msg["Subject"] = subject + msg["From"] = from_email + msg["To"] = recipient + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + # The HTML alternative is the last payload; attach the images to it so + # they form a multipart/related group resolving the cid: references. + html_part = msg.get_payload()[-1] + for cid, subtype, data in images: + html_part.add_related( + data, maintype="image", subtype=subtype, cid=f"<{cid}>" + ) + connection.open() + # No from_addr/to_addrs: let smtplib derive the envelope from the + # headers so a "Name " --from-email still yields a bare MAIL FROM. + connection.connection.send_message(msg) + return + + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=from_email, + to=[recipient], + connection=connection, + ) + msg.attach_alternative(html_body, "text/html") for cid, subtype, data in images: - html_part.add_related(data, maintype="image", subtype=subtype, cid=f"<{cid}>") - - connection.open() - connection.connection.send_message(msg, from_addr=from_email, to_addrs=[recipient]) + image = MIMEImage(data, _subtype=subtype) + image.add_header("Content-ID", f"<{cid}>") + image.add_header("Content-Disposition", "inline", filename=cid) + msg.attach(image) + msg.send() @click.command() @@ -116,7 +157,11 @@ def _send_inline(connection, subject, text_body, html_body, from_email, recipien "--from-email", default=settings.DEFAULT_FROM_EMAIL, show_default=True, - help="From address.", + help=( + "From address. Overrides DEFAULT_FROM_EMAIL -- use a domain your email " + "service provider has verified (most providers require the sender " + "domain to be verified)." + ), ) @click.option( "--base-url", @@ -128,14 +173,17 @@ def _send_inline(connection, subject, text_body, html_body, from_email, recipien "--inline-images/--no-inline-images", default=True, show_default=True, - help="Embed images as inline CID parts (recommended for previews; SMTP only).", + help="Embed images as inline CID parts (recommended for previews).", ) @click.option( "--delay", type=float, default=0.0, show_default=True, - help="Seconds to wait between messages (raise it if your ESP rate-limits bursts).", + help=( + "Seconds to wait between messages (raise it if your email service " + "provider rate-limits bursts)." + ), ) def command( recipient, @@ -153,14 +201,10 @@ def command( scheme, host = "https", base_url # Use the project's configured email backend -- the same one the real - # transactional emails go through (local maildev SMTP, or the deployed ESP). + # transactional emails go through (local maildev SMTP, or the deployed + # email service provider). connection = get_connection() is_smtp = "smtp" in settings.EMAIL_BACKEND - if inline_images and not is_smtp: - raise click.ClickException( - "Inline images require an SMTP backend, but EMAIL_BACKEND is " - f"{settings.EMAIL_BACKEND!r}. Re-run with --no-inline-images." - ) target = ( f"{settings.EMAIL_HOST}:{settings.EMAIL_PORT}" if is_smtp From 0a60b825ab38acf4c252f72bca9faaab06e8c3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Y=C3=A1=C3=B1ez?= Date: Wed, 10 Jun 2026 12:48:12 -0400 Subject: [PATCH 3/5] 2431: remove references to tickets in comments --- templates/emails/base_email.html | 4 ++-- templates/emails/confirm_email.html | 2 +- templates/emails/password_reset.html | 2 +- users/management/commands/send_test_emails.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/emails/base_email.html b/templates/emails/base_email.html index ab068c49f..976730608 100644 --- a/templates/emails/base_email.html +++ b/templates/emails/base_email.html @@ -1,8 +1,8 @@ {% load custom_static %}{% comment %} - Shared base for Boost transactional emails (Story #2431). + Shared base for Boost transactional emails. Frontend / markup only — sending logic lives in the sign-up and - password-reset integration tickets. Render this with a context that + password-reset flows. Render this with a context that provides absolute-URL helpers so images and links resolve in an inbox: scheme "https" (request.scheme) diff --git a/templates/emails/confirm_email.html b/templates/emails/confirm_email.html index 7bbf09327..2c146d482 100644 --- a/templates/emails/confirm_email.html +++ b/templates/emails/confirm_email.html @@ -2,7 +2,7 @@ {% load custom_static %} {% comment %} - Email 1 — Sign-up confirmation (Story #2431). + Sign-up confirmation email. Subject: "Confirm your email" (see confirm_email_subject.txt). Context: first_name, action_url (confirmation URL), scheme, host. {% endcomment %} diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html index ca75dd063..e16325a51 100644 --- a/templates/emails/password_reset.html +++ b/templates/emails/password_reset.html @@ -2,7 +2,7 @@ {% load custom_static %} {% comment %} - Email 2 — Password reset (Story #2431). + Password reset email. Subject: "Password reset link" (see password_reset_subject.txt). Context: first_name, user_email, action_url (reset URL), scheme, host. {% endcomment %} diff --git a/users/management/commands/send_test_emails.py b/users/management/commands/send_test_emails.py index 43e178482..41668f001 100644 --- a/users/management/commands/send_test_emails.py +++ b/users/management/commands/send_test_emails.py @@ -1,7 +1,7 @@ -"""Send the transactional email templates (Story #2431) to a test inbox. +"""Send the transactional email templates to a test inbox. Frontend test helper only -- this does NOT touch the real sign-up / password -reset flows (those live in their own integration tickets). It renders the +reset flows. It renders the templates in ``templates/emails/`` and sends them through the project's configured email backend (``settings.EMAIL_BACKEND``) -- the same backend the real transactional emails use. Locally that is the ``maildev`` SMTP container @@ -195,7 +195,7 @@ def command( inline_images, delay, ): - """Render and send the Story #2431 transactional email templates.""" + """Render and send the transactional email templates.""" scheme, _, host = base_url.partition("://") if not host: # base_url given without a scheme scheme, host = "https", base_url From d30697cbf88e038815bbd0611435714f08dee0af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Y=C3=A1=C3=B1ez?= Date: Wed, 17 Jun 2026 15:39:33 -0400 Subject: [PATCH 4/5] 2431: use dynamic link duration --- templates/emails/confirm_email.html | 5 ++-- templates/emails/confirm_email.txt | 2 +- templates/emails/password_reset.html | 5 ++-- templates/emails/password_reset.txt | 2 +- users/management/commands/send_test_emails.py | 27 +++++++++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/templates/emails/confirm_email.html b/templates/emails/confirm_email.html index 2c146d482..4aafb716f 100644 --- a/templates/emails/confirm_email.html +++ b/templates/emails/confirm_email.html @@ -4,7 +4,8 @@ {% comment %} Sign-up confirmation email. Subject: "Confirm your email" (see confirm_email_subject.txt). - Context: first_name, action_url (confirmation URL), scheme, host. + Context: first_name, action_url (confirmation URL), scheme, host, + confirmation_link_lifetime (e.g. "3 days"). {% endcomment %} {% block title %}Confirm your email{% endblock %} @@ -26,7 +27,7 @@ {% block card_body %}

Hi {{ first_name|default:"there" }},

Thanks for signing up for Boost. To activate your account, please confirm your email address by clicking the button below.

-

This link expires in 24 hours.

+

This link expires in {{ confirmation_link_lifetime }}.

{% endblock %} {% block cta_label %}Confirm my email{% endblock %} diff --git a/templates/emails/confirm_email.txt b/templates/emails/confirm_email.txt index c09b19c20..18d76c5f9 100644 --- a/templates/emails/confirm_email.txt +++ b/templates/emails/confirm_email.txt @@ -5,7 +5,7 @@ Hi {{ first_name|default:"there" }}, Thanks for signing up for Boost. To activate your account, please confirm your email address by visiting the link below. -This link expires in 24 hours. +This link expires in {{ confirmation_link_lifetime }}. Confirm my email: {{ action_url }} diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html index e16325a51..34d557093 100644 --- a/templates/emails/password_reset.html +++ b/templates/emails/password_reset.html @@ -4,7 +4,8 @@ {% comment %} Password reset email. Subject: "Password reset link" (see password_reset_subject.txt). - Context: first_name, user_email, action_url (reset URL), scheme, host. + Context: first_name, user_email, action_url (reset URL), scheme, host, + password_reset_link_lifetime (e.g. "3 days"). {% endcomment %} {% block title %}Password reset link{% endblock %} @@ -26,7 +27,7 @@ {% block card_body %}

Hi {{ first_name|default:"there" }},

We received a request to reset the password for your Boost account ({{ user_email }}).

-

Click the button below to set a new password. This link will expire in 1 hour.

+

Click the button below to set a new password. This link will expire in {{ password_reset_link_lifetime }}.

{% endblock %} {% block cta_label %}Reset my password{% endblock %} diff --git a/templates/emails/password_reset.txt b/templates/emails/password_reset.txt index 42d20435d..076995272 100644 --- a/templates/emails/password_reset.txt +++ b/templates/emails/password_reset.txt @@ -5,7 +5,7 @@ Hi {{ first_name|default:"there" }}, We received a request to reset the password for your Boost account ({{ user_email }}). -Click the link below to set a new password. This link will expire in 1 hour. +Click the link below to set a new password. This link will expire in {{ password_reset_link_lifetime }}. Reset my password: {{ action_url }} diff --git a/users/management/commands/send_test_emails.py b/users/management/commands/send_test_emails.py index 41668f001..9dd7621b4 100644 --- a/users/management/commands/send_test_emails.py +++ b/users/management/commands/send_test_emails.py @@ -30,15 +30,33 @@ import re import time +from datetime import timedelta from email.message import EmailMessage as PyEmailMessage from email.mime.image import MIMEImage import djclick as click +from allauth.account import app_settings as allauth_account_settings from django.conf import settings from django.contrib.staticfiles import finders from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string + +def _humanize_link_lifetime(delta): + """Return a short phrase for a link lifetime, e.g. "3 days" or "1 hour". + + Picks the largest whole unit so the copy reads naturally for whole-day or + whole-hour settings, and pluralizes correctly (so a one-day or one-hour + timeout never renders as "1 days" / "0 days"). + """ + seconds = int(delta.total_seconds()) + for unit_seconds, name in ((86400, "day"), (3600, "hour"), (60, "minute")): + count = seconds // unit_seconds + if count: + return f"{count} {name}{'' if count == 1 else 's'}" + return f"{seconds} second{'' if seconds == 1 else 's'}" + + # Available templates: key -> subject / text / html templates + a sample link. TEMPLATES = { "confirm": { @@ -226,6 +244,15 @@ def command( "action_url": spec["action_url"], "preferences_url": f"{base_url}/account/preferences", "unsubscribe_url": f"{base_url}/account/unsubscribe", + # Link lifetimes shown in the email bodies, sourced from the same + # settings the real flows enforce (allauth email confirmation in + # days, Django's password reset token timeout in seconds). + "confirmation_link_lifetime": _humanize_link_lifetime( + timedelta(days=allauth_account_settings.EMAIL_CONFIRMATION_EXPIRE_DAYS) + ), + "password_reset_link_lifetime": _humanize_link_lifetime( + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT) + ), } subject = render_to_string(spec["subject"], context).strip() text_body = render_to_string(spec["text"], context) From 69feb65afb0e9071d2a82aeb547c6c92c02b3474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Y=C3=A1=C3=B1ez?= Date: Tue, 23 Jun 2026 13:15:12 -0400 Subject: [PATCH 5/5] 2431: update images and sync font stack update from password reset branch --- templates/emails/base_email.html | 8 ++++++-- templates/emails/confirm_email.html | 4 ++-- templates/emails/password_reset.html | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/templates/emails/base_email.html b/templates/emails/base_email.html index 976730608..717bdb05e 100644 --- a/templates/emails/base_email.html +++ b/templates/emails/base_email.html @@ -11,6 +11,10 @@ preferences_url manage-email-preferences URL (optional, defaults to #) unsubscribe_url unsubscribe URL (optional, defaults to #) + Body copy uses the platform UI font stack (Segoe UI on Windows, San Francisco + on Apple, Roboto on Android) at a light 300 weight; the title, button and + footer use Arial. + Child templates override the blocks: preheader, hero, card_title, card_body, cta_label and after_card. {% endcomment %} @@ -77,7 +81,7 @@
 
-
+
{% block card_body %}{% endblock %}
@@ -89,7 +93,7 @@ -
+
Or copy and paste this URL into your browser:
{{ action_url|default:'#' }}
diff --git a/templates/emails/confirm_email.html b/templates/emails/confirm_email.html index 4aafb716f..b71009265 100644 --- a/templates/emails/confirm_email.html +++ b/templates/emails/confirm_email.html @@ -16,7 +16,7 @@
- +
@@ -35,7 +35,7 @@ {% block after_card %} - diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html index 34d557093..a31b17cbf 100644 --- a/templates/emails/password_reset.html +++ b/templates/emails/password_reset.html @@ -15,8 +15,8 @@ {% block hero %}
+ If you didn't create a Boost account, you can safely ignore this email.
-
- + +
@@ -41,7 +41,7 @@ - + Didn't ask for this? Maybe someone typed your email by accident. You can ignore this email safely — your password will stay the same.