Skip to content

refactor(email): migrate email sending from Resend to Amazon SES - #85

Open
klin2686 wants to merge 14 commits into
mainfrom
refactor/migrate-email-to-ses
Open

klin2686 wants to merge 14 commits into
mainfrom
refactor/migrate-email-to-ses

Conversation

@klin2686

@klin2686 klin2686 commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  1. Switches email sending from Resend (flat monthly subscription) to Amazon SES (pay per email), which fits Science Olympiad's seasonal usage much better.
  2. Adds a support contact, the organization line and an invite opt-out to every email, which SES production access review expects.
  3. Adds a script that renders every email for previewing without sending.

Closes #84

What changed

Backend

Logic

Sending (app/services/email_service.py)

  • _send() now calls SES v2 send_email through boto3, run in asyncio.to_thread so it never blocks the event loop.
  • _get_ses_client() caches one sesv2 client per credential set, built from a dedicated boto3 Session (clients are thread-safe, the default session isn't).
  • Retries use botocore standard mode, since the default (legacy) mode doesn't retry SES v2's TooManyRequestsException.
  • A throttled send is retried up to SES_MAX_ATTEMPTS times before the address is reported as failed.
  • New _SendRateLimiter spaces all sends 1 / SES_MAX_SEND_RATE seconds apart so bulk staff invites stay under the SES quota.
  • Rate limiting is per process: with 2+ uvicorn workers, set SES_MAX_SEND_RATE to quota / N.
  • Without AWS keys, production raises, preview logs a one-line skip, and development logs each email as a framed block with its recipient, subject and plain-text body so links can be clicked.

Email content

  • Every email's footer, HTML and plain text, now ends with "Questions? Email support@nexus.socalscioly.org" and the Southern California Science Olympiad 501(c)(3) line.
  • Staff invites tell recipients they can email support to stop receiving invites.
  • The email-change and password-changed notices bold "If you made this request, no action is needed" and add a "secure your account" line.
  • The password-changed notice's button now reads "Secure your account" instead of "Reset password".
  • Verification and password-reset footnotes now use the shared "If this wasn't you, please contact support" line.

Config

  • Replaces RESEND_API_KEY with AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION (default us-west-2), EMAIL_FROM_ADDRESS, SES_MAX_SEND_RATE (default 1, the sandbox cap) and SES_MAX_ATTEMPTS (default 4).
  • The sender is now configurable through EMAIL_FROM_ADDRESS, and the default changes from verify@ to NEXUS <noreply@nexus.socalscioly.org>.
  • Settings ignores unknown .env keys, so a leftover RESEND_API_KEY doesn't crash startup.
  • resend[async] removed and boto3 added.

Dev tooling and docs

  • New scripts/render_emails.py (uv run python -m scripts.render_emails) writes every email to a gitignored tmp_emails/ with an index.html preview page, using a recording SES client so nothing is sent.
  • New backend/docs/email-previews.md documents the script; backend/CLAUDE.md, README.md and CONTRIBUTING.md point to it.
  • CONTRIBUTING.md and backend/CLAUDE.md now describe SES setup instead of Resend.

Out of scope

  • SES bounce and complaint handling (configuration set, SNS notifications, suppression).
  • A rate limiter shared across workers or replicas.

Test plan

  • pytest passes locally

Automated

  • New tests/services/test_email_service.py covers the SES request shape, failure propagation, staff-invite partial failure, real botocore retry behavior, rate-limit spacing, event-loop responsiveness, and the unconfigured paths in development, preview and production.
  • Removes the Resend-only test_resend_has_async_http_client.
  • Existing tests that use the mock_send_email fixture (auth, users, staff invites) pass unchanged.

Manual (personal sandbox SES account in us-west-2, sending from the verified test domain nexus.kennethlin.dev, with Easy DKIM and custom MAIL FROM mail.nexus.kennethlin.dev)

  • Signup verification: email arrives from NEXUS <noreply@nexus.kennethlin.dev>, and the link verifies the account.
  • Forgot password: the reset email arrives, the link sets a new password, and the "password changed" notice arrives.
  • Email change: the confirmation reaches the new address, the security notice reaches the old one, and the confirm link switches the email.
  • Staff invite, mixed batch of verified and unverified sandbox addresses: sent and failed counts match the verified and unverified recipients.
  • Received email shows SPF, DKIM and DMARC all passing, aligned to the sending domain.
  • With no AWS keys in dev, resend-verification returns 200 and logs the skipped email with its body.
  • render_emails writes all seven emails, and FRONTEND_URL=https://nexus.socalscioly.org switches their links to the real domain.

@klin2686 klin2686 linked an issue Sep 26, 2026 that may be closed by this pull request
@railway-app

railway-app Bot commented Sep 26, 2026

Copy link
Copy Markdown

This PR was not deployed automatically as @klin2686 does not have access to the Railway project.

In order to get automatic PR deploys, please add @klin2686 to your workspace on Railway.

@vercel

vercel Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nexus Ready Ready Preview Oct 13, 2026 8:21pm UTC

…l-to-ses

# Conflicts:
#	backend/requirements.txt
#	backend/tests/services/test_email_service.py
@klin2686

klin2686 commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

TODO before merging:

This branch was successfully deployed

1 active and 1 inactive (outdated) deployments
Preview — 6c449c17 Deployed Oct 13, 2026 by vercel[bot]
nexus / test2 — a5dfa8de Deployed Oct 2, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(email): migrate email sending from Resend to Amazon SES

2 participants