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:
92
rosterchief/mail.py
Normal file
92
rosterchief/mail.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user