Redesign claim-approved and password-reset emails as branded HTML
Confirmed Resend's /emails endpoint accepts an html field alongside text. Claim-approved email now carries an HTML alternative with the club's logo/colours; allauth's password-reset email is overridden with the same treatment, falling back to RosterChief's own branding outside a club context. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
@@ -7,8 +8,10 @@ from allauth.mfa.models import Authenticator
|
|||||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core import mail
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from django.test import RequestFactory, TestCase, override_settings
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -262,6 +265,65 @@ class AuthFormRenderingTests(TestCase):
|
|||||||
self.assertNotContains(response, 'name="password1"', status_code=403)
|
self.assertNotContains(response, 'name="password1"', status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||||
|
)
|
||||||
|
class PasswordResetEmailTests(TestCase):
|
||||||
|
"""allauth auto-attaches templates/account/email/password_reset_key_message.html
|
||||||
|
as an HTML alternative next to its own .txt body -- see
|
||||||
|
allauth.account.adapter.DefaultAccountAdapter.render_mail, which looks for
|
||||||
|
"<prefix>_message.<ext>" for ext in [TEMPLATE_EXTENSION ("html", unset here), "txt"].
|
||||||
|
No Python override needed; this only exercises the template."""
|
||||||
|
|
||||||
|
def test_the_html_email_carries_the_clubs_branding_on_a_club_subdomain(self):
|
||||||
|
Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
response = self.client.post(reverse("account_reset_password"), {"email": "parent@example.com"}, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
[(html_body, mimetype)] = mail.outbox[0].alternatives
|
||||||
|
self.assertEqual(mimetype, "text/html")
|
||||||
|
self.assertIn("Ajax United", html_body)
|
||||||
|
self.assertIn("/accounts/password/reset/key/", html_body)
|
||||||
|
|
||||||
|
def test_the_html_email_falls_back_to_rosterchief_branding_off_a_club_subdomain(self):
|
||||||
|
# The base domain has no tenant, e.g. a platform control-panel user
|
||||||
|
# resetting their own password -- club.context_processors.branding
|
||||||
|
# leaves `club` unset there, so the template must not assume one.
|
||||||
|
User.objects.create_user(email="admin@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
self.client.post(reverse("account_reset_password"), {"email": "admin@example.com"})
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
[(html_body, mimetype)] = mail.outbox[0].alternatives
|
||||||
|
self.assertEqual(mimetype, "text/html")
|
||||||
|
# The wordmark splits "Chief" into its own <span> for the sky-blue accent
|
||||||
|
# (matching templates/_platform_base.html), so the two halves aren't
|
||||||
|
# contiguous text in the raw HTML -- check for both rather than the
|
||||||
|
# combined word.
|
||||||
|
self.assertIn("Roster", html_body)
|
||||||
|
self.assertIn("Chief", html_body)
|
||||||
|
|
||||||
|
def test_the_password_reset_key_html_template_renders_directly(self):
|
||||||
|
# Mirrors authentication.tests.AuthFormRenderingTests' direct-render
|
||||||
|
# style: exercises the template's own branching (club vs. none, logo
|
||||||
|
# vs. initials) without going through the full request/email pipeline.
|
||||||
|
club = Club.objects.create(name="Ajax United", slug="ajax-united", primary_color="#1e40af")
|
||||||
|
|
||||||
|
with_club = render_to_string("account/email/password_reset_key_message.html", {"club": club, "password_reset_url": "https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/"})
|
||||||
|
self.assertIn("Ajax United", with_club)
|
||||||
|
self.assertIn("AU", with_club) # initials fallback: no logo set
|
||||||
|
self.assertIn("https://ajax-united.rosterchief.app/accounts/password/reset/key/abc-def/", with_club)
|
||||||
|
|
||||||
|
without_club = render_to_string("account/email/password_reset_key_message.html", {"club": None, "password_reset_url": "https://rosterchief.app/accounts/password/reset/key/abc-def/", "current_site": SimpleNamespace(name="rosterchief.app")})
|
||||||
|
self.assertIn("Roster", without_club)
|
||||||
|
self.assertIn("Chief", without_club)
|
||||||
|
self.assertIn("https://rosterchief.app/accounts/password/reset/key/abc-def/", without_club)
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorPageTests(TestCase):
|
class TwoFactorPageTests(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
0
club/templatetags/__init__.py
Normal file
0
club/templatetags/__init__.py
Normal file
25
club/templatetags/club_email.py
Normal file
25
club/templatetags/club_email.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Template helper shared by every HTML email that shows a club's logo.
|
||||||
|
|
||||||
|
An <img> in an email has no page to resolve a relative /media/... URL against
|
||||||
|
the way a browser tab would -- the inbox fetches it cold. Storage backends that
|
||||||
|
already return an absolute URL (e.g. S3 in production) are unaffected; this
|
||||||
|
only matters for the local FileSystemStorage used in dev, where FieldFile.url
|
||||||
|
is relative.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def absolute_media_url(context, file_field):
|
||||||
|
"""An absolute URL for ``file_field`` (e.g. ``club.logo``), for use in an
|
||||||
|
email. Falls back to the field's own (possibly relative) ``.url`` when
|
||||||
|
there's no request in the template context to build an absolute one from --
|
||||||
|
better a relative URL than a hard error while rendering the email."""
|
||||||
|
if not file_field:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
request = context.get("request")
|
||||||
|
return request.build_absolute_uri(file_field.url) if request is not None else file_field.url
|
||||||
@@ -1501,6 +1501,21 @@ class ParentClaimViewTests(ManagementTestBase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "password1")
|
self.assertContains(response, "password1")
|
||||||
|
|
||||||
|
def test_the_email_carries_an_html_alternative_alongside_the_plain_text(self):
|
||||||
|
self.submit()
|
||||||
|
claim = ParentClaim.objects.get(club=self.club)
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||||
|
|
||||||
|
sent = mail.outbox[0]
|
||||||
|
[(html_body, mimetype)] = sent.alternatives
|
||||||
|
self.assertEqual(mimetype, "text/html")
|
||||||
|
self.assertIn("Jamie", html_body)
|
||||||
|
self.assertIn(self.club.name, html_body)
|
||||||
|
[reset_path] = [line for line in sent.body.splitlines() if "/accounts/password/reset/key/" in line]
|
||||||
|
self.assertIn(reset_path.strip(), html_body)
|
||||||
|
|
||||||
def test_the_email_mentions_the_clubs_contact_email_when_set(self):
|
def test_the_email_mentions_the_clubs_contact_email_when_set(self):
|
||||||
self.club.contact_email = "info@ajax-united.example.com"
|
self.club.contact_email = "info@ajax-united.example.com"
|
||||||
self.club.save(update_fields=["contact_email"])
|
self.club.save(update_fields=["contact_email"])
|
||||||
@@ -1517,7 +1532,7 @@ class ParentClaimViewTests(ManagementTestBase):
|
|||||||
claim = ParentClaim.objects.get(club=self.club)
|
claim = ParentClaim.objects.get(club=self.club)
|
||||||
self.client.force_login(self.admin_user)
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
with mock.patch("members.services.claims.send_mail", side_effect=OSError("smtp down")):
|
with mock.patch("members.services.claims.EmailMultiAlternatives.send", side_effect=OSError("smtp down")):
|
||||||
response = self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
response = self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("management:parent_claim_list"))
|
self.assertRedirects(response, reverse("management:parent_claim_list"))
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from allauth.account.forms import default_token_generator
|
|||||||
from allauth.account.utils import user_pk_to_url_str
|
from allauth.account.utils import user_pk_to_url_str
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import EmailMultiAlternatives
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
@@ -170,12 +170,20 @@ def send_claim_approved_email(claim, *, child, request=None):
|
|||||||
path = reverse("account_reset_password_from_key", kwargs={"uidb36": user_pk_to_url_str(user), "key": default_token_generator.make_token(user)})
|
path = reverse("account_reset_password_from_key", kwargs={"uidb36": user_pk_to_url_str(user), "key": default_token_generator.make_token(user)})
|
||||||
set_password_url = request.build_absolute_uri(path) if request is not None else path
|
set_password_url = request.build_absolute_uri(path) if request is not None else path
|
||||||
|
|
||||||
context = {"club": claim.club, "child": child, "parent_first_name": claim.parent_first_name, "set_password_url": set_password_url}
|
# "request" rides along in the context (not used by the .txt template, only
|
||||||
|
# by the .html one's {% absolute_media_url %} tag) so the club's logo -- if
|
||||||
|
# it has one -- renders as an absolute URL an inbox can actually fetch, the
|
||||||
|
# same way set_password_url above is made absolute.
|
||||||
|
context = {"club": claim.club, "child": child, "parent_first_name": claim.parent_first_name, "set_password_url": set_password_url, "request": request}
|
||||||
subject = " ".join(render_to_string("members/email/claim_approved_subject.txt", context).split())
|
subject = " ".join(render_to_string("members/email/claim_approved_subject.txt", context).split())
|
||||||
body = render_to_string("members/email/claim_approved.txt", context).strip() + "\n"
|
text_body = render_to_string("members/email/claim_approved.txt", context).strip() + "\n"
|
||||||
|
html_body = render_to_string("members/email/claim_approved.html", context)
|
||||||
|
|
||||||
|
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [claim.parent_email])
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [claim.parent_email], fail_silently=False)
|
message.send(fail_silently=False)
|
||||||
except OSError:
|
except OSError:
|
||||||
# Anything the mail backend raises for an unreachable server or a refused
|
# Anything the mail backend raises for an unreachable server or a refused
|
||||||
# connection. The link stands; the club can resend from the queue.
|
# connection. The link stands; the club can resend from the queue.
|
||||||
|
|||||||
58
members/templates/members/email/claim_approved.html
Normal file
58
members/templates/members/email/claim_approved.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "email/_base.html" %}
|
||||||
|
{% load i18n club_email %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
HTML sibling of claim_approved.txt -- same content, same context (club,
|
||||||
|
child, parent_first_name, set_password_url), just laid out for an inbox
|
||||||
|
instead of a terminal. Kept in lockstep with the .txt version by hand:
|
||||||
|
there's no single source the two are generated from, so a change to one
|
||||||
|
reads as incomplete without the other.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block title %}{% blocktrans with club=club.name %}Your {{ club }} account is ready{% endblocktrans %}{% endblock title %}
|
||||||
|
|
||||||
|
{% block preheader %}{% blocktrans with club=club.name %}{{ club }} has confirmed you're a parent or guardian -- set your password to sign in.{% endblocktrans %}{% endblock preheader %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% absolute_media_url club.logo as logo_url %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td valign="middle">
|
||||||
|
{% if club.logo %}
|
||||||
|
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||||
|
{% else %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_content_color|default:"#ffffff" }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||||
|
{{ club.initials }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:14px;" valign="middle">
|
||||||
|
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endblock header %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p style="margin:0 0 16px 0;">{% blocktrans with name=parent_first_name %}Hello {{ name }},{% endblocktrans %}</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 20px 0;">{% blocktrans with club=club.name child=child %}{{ club }} has confirmed that you're {{ child }}'s parent or guardian, and your account is ready.{% endblocktrans %}</p>
|
||||||
|
|
||||||
|
{% trans "Set your password" as set_password_label %}
|
||||||
|
{% include "email/_button.html" with href=set_password_url label=set_password_label bg=club.primary_color|default:"#4f46e5" fg=club.primary_content_color|default:"#ffffff" %}
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px 0; font-size:13px; color:#6b7280;">{% trans "That link is for you alone — please don't forward it." %}</p>
|
||||||
|
|
||||||
|
<p style="margin:0;">{% trans "Once you're signed in you'll see the children linked to you." %}</p>
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
{% if club.contact_email %}
|
||||||
|
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Something not right? Reply to this note or write to {{ email }}.{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
|
||||||
|
{% endblock footer %}
|
||||||
78
templates/account/email/password_reset_key_message.html
Normal file
78
templates/account/email/password_reset_key_message.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "email/_base.html" %}
|
||||||
|
{% load i18n club_email %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
allauth's DefaultAccountAdapter.render_mail looks for
|
||||||
|
"<template_prefix>_message.<TEMPLATE_EXTENSION>" (TEMPLATE_EXTENSION
|
||||||
|
defaults to "html", unset in this project) next to the .txt template it
|
||||||
|
already renders, and attaches it as the HTML alternative automatically --
|
||||||
|
no Python override needed, see allauth.account.adapter.render_mail.
|
||||||
|
|
||||||
|
Context comes from allauth.account.internal.flows.password_reset
|
||||||
|
.request_password_reset() plus DefaultAccountAdapter.send_mail: user,
|
||||||
|
password_reset_url, uid, key, request, current_site, email, and username
|
||||||
|
only when ACCOUNT_LOGIN_METHODS includes "username" (it doesn't here, see
|
||||||
|
rosterchief/settings.py -- login is email-only -- but the check is kept for
|
||||||
|
parity with the .txt template in case that ever changes).
|
||||||
|
|
||||||
|
render_to_string is called with the real `request`
|
||||||
|
(allauth.account.adapter.render_mail passes `context.request`), so this
|
||||||
|
picks up club.context_processors.branding like any other page: `club` is
|
||||||
|
set when the reset was requested on a club subdomain, None on the base
|
||||||
|
domain (e.g. a platform control-panel user). Both cases must render
|
||||||
|
something sensible -- a password reset is exactly the kind of email that
|
||||||
|
still has to work with no club in play.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Reset your password" %}{% endblock title %}
|
||||||
|
|
||||||
|
{% block preheader %}{% trans "Use this link to choose a new password. If you didn't request this, you can ignore this email." %}{% endblock preheader %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% if club %}
|
||||||
|
{% absolute_media_url club.logo as logo_url %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td valign="middle">
|
||||||
|
{% if club.logo %}
|
||||||
|
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||||
|
{% else %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_content_color|default:"#ffffff" }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||||
|
{{ club.initials }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:14px;" valign="middle">
|
||||||
|
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-family: Arial, Helvetica, sans-serif; font-size:20px; font-weight:bold; color:#111827; letter-spacing:0.02em;">Roster<span style="color:#0ea5e9;">Chief</span></span>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock header %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p style="margin:0 0 20px 0;">{% trans "You're receiving this email because you or someone else has requested a password reset for your user account. It can be safely ignored if you did not request one." %}</p>
|
||||||
|
|
||||||
|
{% trans "Reset your password" as reset_label %}
|
||||||
|
{% include "email/_button.html" with href=password_reset_url label=reset_label bg=club.primary_color|default:"#0ea5e9" fg=club.primary_content_color|default:"#ffffff" %}
|
||||||
|
|
||||||
|
{% if username %}
|
||||||
|
<p style="margin:0;">{% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
<p style="margin:0;">
|
||||||
|
{% if club %}
|
||||||
|
{% blocktrans with club=club.name %}Sent by {{ club }}, powered by RosterChief.{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans with site_name=current_site.name %}Sent by {{ site_name }}.{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endblock footer %}
|
||||||
52
templates/email/_base.html
Normal file
52
templates/email/_base.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% load i18n %}<!DOCTYPE html>
|
||||||
|
{% comment %}
|
||||||
|
Shared shell for every transactional HTML email (the claim-approved parent
|
||||||
|
email, allauth's password-reset override, and anything after it). Deliberately
|
||||||
|
not a responsive email framework: a single ~600px table with generous padding
|
||||||
|
reads fine on a phone without media queries, and media queries are one of the
|
||||||
|
first things Outlook desktop and a lot of webmail strip anyway.
|
||||||
|
|
||||||
|
Everything is inline styles on table-based markup on purpose -- no <style>
|
||||||
|
block, no linked stylesheet (daisyUI/Tailwind's compiled CSS is a web asset,
|
||||||
|
not an email-safe one, and most inboxes strip a <style> block regardless).
|
||||||
|
|
||||||
|
Subclasses override `header`, `content` and `footer`; `preheader` is the
|
||||||
|
hidden preview-pane text most inboxes show next to the subject line.
|
||||||
|
{% endcomment %}
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{% block title %}{% endblock title %}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background-color:#f3f4f6; font-family: Arial, Helvetica, sans-serif;">
|
||||||
|
<div style="display:none; max-height:0; max-width:0; overflow:hidden; opacity:0; mso-hide:all;">
|
||||||
|
{% block preheader %}{% endblock preheader %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f3f4f6;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 24px 16px;">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:600px; background-color:#ffffff; border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 28px 40px; border-bottom: 1px solid #e5e7eb;">
|
||||||
|
{% block header %}{% endblock header %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 40px; color:#1f2933; font-size:15px; line-height:1.6;">
|
||||||
|
{% block content %}{% endblock content %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 40px 28px 40px; border-top: 1px solid #e5e7eb; color:#6b7280; font-size:12px; line-height:1.5;">
|
||||||
|
{% block footer %}{% endblock footer %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
templates/email/_button.html
Normal file
18
templates/email/_button.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% comment %}
|
||||||
|
A single call-to-action button, table-wrapped so it centres reliably in
|
||||||
|
Outlook desktop (which ignores margin: 0 auto on a bare <a>). Include with:
|
||||||
|
|
||||||
|
{% include "email/_button.html" with href=set_password_url label=_("Set your password") bg="#0ea5e9" fg="#ffffff" %}
|
||||||
|
|
||||||
|
`bg`/`fg` are plain hex strings (already resolved -- e.g. club.primary_color
|
||||||
|
with a fallback), not template logic, so this stays a dumb, reusable snippet.
|
||||||
|
{% endcomment %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 8px 0 24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="{{ bg }}" style="border-radius:6px; background-color:{{ bg }};">
|
||||||
|
<a href="{{ href }}" style="display:inline-block; padding:12px 28px; font-family: Arial, Helvetica, sans-serif; font-size:15px; font-weight:bold; color:{{ fg }}; text-decoration:none; border-radius:6px;">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
Reference in New Issue
Block a user