Add group/club-wide event audiences and a Resend email backend

Events can now target members.Group audiences alongside teams, or go
club_wide (every ACTIVE ClubMembership member for the event's season)
instead of specific teams/groups -- the two are mutually exclusive,
enforced in EventForm/EventSeriesForm.clean() since an M2M can't be
validated via a DB CheckConstraint or Event.clean() (no PK yet). Attendance
sync (events/signals.py) now reacts to GroupMembership and ClubMembership
changes the same way it already did for TeamMembership. Authorization:
club.services.access.groups_manageable_by mirrors teams_managed_by (all
groups for an ADMIN, else only the ones the user belongs to -- Group has no
manager/owner concept); a non-admin needs at least one managed team or
belonged-to group to create/edit an event, club_wide stays admin-only, and
EventManagerRequiredMixin gained a get_groups() hook so a non-admin who
creates a group-only event isn't immediately locked out of managing it.

Also adds rosterchief.mail.ResendEmailBackend, an HTTP-API-based Django
email backend for Resend (resend.com) using the existing `requests`
dependency -- no new SDK. Opt in via DJANGO_EMAIL_BACKEND and RESEND_API_KEY;
every Django-sent email (allauth's password reset included) follows
whichever EMAIL_BACKEND is configured, so this covers all of them for free.
Resend's own SMTP relay remains a valid code-free alternative, documented
alongside it in .env.production.example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:02:26 +02:00
parent e737de9140
commit 581cc81ba7
22 changed files with 720 additions and 70 deletions

92
rosterchief/mail.py Normal file
View File

@@ -0,0 +1,92 @@
"""Django email backend that sends through Resend's HTTP API
(https://resend.com/docs/api-reference/emails/send-email) instead of SMTP.
Opt in with DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend and
RESEND_API_KEY set (see settings.py's Email section) -- every Django-sent
email (allauth's password reset, billing reminders, ...) goes through
django.core.mail's EMAIL_BACKEND setting, so nothing else has to change to
route mail through Resend once this is configured.
Resend's own SMTP relay is also a valid, code-free alternative (point the
stock django.core.mail.backends.smtp.EmailBackend at it with your API key as
the SMTP password) -- this backend exists for teams who'd rather go through
Resend's HTTP API directly.
"""
import base64
from email.mime.base import MIMEBase
import requests
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
RESEND_API_URL = "https://api.resend.com/emails"
REQUEST_TIMEOUT = 10
class ResendEmailBackend(BaseEmailBackend):
def send_messages(self, email_messages) -> int:
if not email_messages:
return 0
api_key = settings.RESEND_API_KEY
if not api_key:
if self.fail_silently:
return 0
raise ValueError("RESEND_API_KEY is not set.")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
sent = 0
with requests.Session() as session:
for message in email_messages:
try:
response = session.post(RESEND_API_URL, headers=headers, json=_payload_for(message), timeout=REQUEST_TIMEOUT)
response.raise_for_status()
except requests.RequestException:
if not self.fail_silently:
raise
continue
sent += 1
return sent
def _payload_for(message) -> dict:
payload = {
"from": message.from_email,
"to": list(message.to),
"subject": message.subject,
"text": message.body,
}
if message.cc:
payload["cc"] = list(message.cc)
if message.bcc:
payload["bcc"] = list(message.bcc)
if message.reply_to:
payload["reply_to"] = list(message.reply_to)
# EmailMultiAlternatives (what allauth's templated emails use) carries the
# HTML version as an "alternative" to the plain-text body, not a separate field.
html_body = next((content for content, mimetype in getattr(message, "alternatives", []) if mimetype == "text/html"), None)
if html_body:
payload["html"] = html_body
attachments = _attachments_for(message)
if attachments:
payload["attachments"] = attachments
return payload
def _attachments_for(message) -> list[dict]:
attachments = []
for attachment in message.attachments:
if isinstance(attachment, MIMEBase):
filename = attachment.get_filename()
content = attachment.get_payload(decode=True)
else:
filename, content, _mimetype = attachment
if isinstance(content, str):
content = content.encode()
attachments.append({"filename": filename, "content": base64.b64encode(content).decode("ascii")})
return attachments

View File

@@ -335,6 +335,12 @@ LOGGING = {
# DEFAULT rather than the dev-only branch -- a deployment that forgets to configure mail
# should print billing reminders to the log, not raise ConnectionRefused against localhost:25
# on a box with no MTA, which is what Django's own default does.
#
# Resend (resend.com) works either way: point DJANGO_EMAIL_BACKEND at Django's own SMTP
# backend with Resend's SMTP relay credentials, or set it to rosterchief.mail.ResendEmailBackend
# to send through Resend's HTTP API instead (see that module) -- set RESEND_API_KEY either way.
# Every Django-sent email (allauth's password reset included, since it goes through
# django.core.mail like everything else) follows whichever backend is configured here.
EMAIL_BACKEND = config("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend")
EMAIL_HOST = config("DJANGO_EMAIL_HOST", default="")
EMAIL_PORT = config("DJANGO_EMAIL_PORT", default=587, cast=int)
@@ -344,6 +350,10 @@ EMAIL_USE_TLS = config("DJANGO_EMAIL_USE_TLS", default=True, cast=bool)
EMAIL_USE_SSL = config("DJANGO_EMAIL_USE_SSL", default=False, cast=bool)
EMAIL_TIMEOUT = config("DJANGO_EMAIL_TIMEOUT", default=10, cast=int)
#: Only read by rosterchief.mail.ResendEmailBackend -- irrelevant for the SMTP backend
#: (which would use EMAIL_HOST_PASSWORD, e.g. Resend's own SMTP relay, instead).
RESEND_API_KEY = config("RESEND_API_KEY", default="")
DEFAULT_FROM_EMAIL = config("DJANGO_DEFAULT_FROM_EMAIL", default="RosterChief <noreply@rosterchief.app>")
SERVER_EMAIL = config("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)

View File

@@ -1,11 +1,14 @@
import importlib
from unittest import mock
import requests
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.db.utils import OperationalError
from django.test import SimpleTestCase, override_settings
from django.urls import Resolver404, clear_url_caches, resolve, reverse
from . import urls
from .mail import ResendEmailBackend
class BrowserReloadUrlTests(SimpleTestCase):
@@ -86,3 +89,106 @@ class HealthCheckTests(SimpleTestCase):
# container is unhealthy forever — which is exactly how the first deploy failed.
for host in ("127.0.0.1", "localhost"):
self.assertEqual(self.client.get(reverse("healthz"), HTTP_HOST=host).status_code, 200, host)
@override_settings(RESEND_API_KEY="test-key")
class ResendEmailBackendTests(SimpleTestCase):
def ok_response(self):
response = mock.Mock()
response.raise_for_status.return_value = None
return response
def test_sends_a_plain_text_message(self):
message = EmailMessage("Subject", "Body text.", "from@example.com", ["to@example.com"])
with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post:
sent = ResendEmailBackend().send_messages([message])
self.assertEqual(sent, 1)
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["from"], "from@example.com")
self.assertEqual(payload["to"], ["to@example.com"])
self.assertEqual(payload["subject"], "Subject")
self.assertEqual(payload["text"], "Body text.")
self.assertNotIn("html", payload)
def test_uses_a_bearer_token_from_settings(self):
message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])
with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post:
ResendEmailBackend().send_messages([message])
self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer test-key")
def test_includes_the_html_alternative(self):
message = EmailMultiAlternatives("Subject", "Plain body.", "from@example.com", ["to@example.com"])
message.attach_alternative("<p>HTML body.</p>", "text/html")
with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post:
ResendEmailBackend().send_messages([message])
self.assertEqual(post.call_args.kwargs["json"]["html"], "<p>HTML body.</p>")
def test_cc_bcc_and_reply_to_are_included(self):
message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"], cc=["cc@example.com"], bcc=["bcc@example.com"], reply_to=["reply@example.com"])
with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post:
ResendEmailBackend().send_messages([message])
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["cc"], ["cc@example.com"])
self.assertEqual(payload["bcc"], ["bcc@example.com"])
self.assertEqual(payload["reply_to"], ["reply@example.com"])
def test_attachments_are_base64_encoded(self):
message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])
message.attach("notes.txt", "hello world", "text/plain")
with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post:
ResendEmailBackend().send_messages([message])
[attachment] = post.call_args.kwargs["json"]["attachments"]
self.assertEqual(attachment["filename"], "notes.txt")
self.assertEqual(attachment["content"], "aGVsbG8gd29ybGQ=")
def test_sends_each_message_in_a_batch(self):
messages = [EmailMessage("A", "Body.", "from@example.com", ["a@example.com"]), EmailMessage("B", "Body.", "from@example.com", ["b@example.com"])]
with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post:
sent = ResendEmailBackend().send_messages(messages)
self.assertEqual(sent, 2)
self.assertEqual(post.call_count, 2)
@override_settings(RESEND_API_KEY="")
def test_a_missing_api_key_raises_by_default(self):
with self.assertRaises(ValueError):
ResendEmailBackend().send_messages([EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])])
@override_settings(RESEND_API_KEY="")
def test_a_missing_api_key_is_silent_when_fail_silently(self):
sent = ResendEmailBackend(fail_silently=True).send_messages([EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])])
self.assertEqual(sent, 0)
def test_a_failed_request_raises_by_default(self):
message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])
with mock.patch("rosterchief.mail.requests.Session.post", side_effect=requests.ConnectionError("down")):
with self.assertRaises(requests.ConnectionError):
ResendEmailBackend().send_messages([message])
def test_a_failed_request_is_silent_when_fail_silently(self):
message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])
with mock.patch("rosterchief.mail.requests.Session.post", side_effect=requests.ConnectionError("down")):
sent = ResendEmailBackend(fail_silently=True).send_messages([message])
self.assertEqual(sent, 0)
def test_no_messages_is_a_no_op(self):
with mock.patch("rosterchief.mail.requests.Session.post") as post:
sent = ResendEmailBackend().send_messages([])
self.assertEqual(sent, 0)
post.assert_not_called()