Skip to content

feat(email): send transactional mail directly via Resend - #25

Merged
AlaskanTuna merged 5 commits into
mainfrom
feat/resend-email
Jul 30, 2026
Merged

feat(email): send transactional mail directly via Resend#25
AlaskanTuna merged 5 commits into
mainfrom
feat/resend-email

Conversation

@AlaskanTuna

@AlaskanTuna AlaskanTuna commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Email previously travelled app → Supabase Auth → Resend SMTP relay. Supabase Auth is gone (#24), so the backend now calls Resend directly. No DNS work was needed — solarsim.tech was already verified.

Built by two parallel Codex workers on disjoint files (templates / dispatch service), against a contract (backend/src/emails/types.ts) written up front so neither could block the other.

What changed

  • backend/src/emails/ (new) — the four branded templates as typed render functions returning { subject, html }, replacing Supabase's Go-template {{ .ConfirmationURL }} tokens. Subjects preserved exactly from supabase/config.toml.
  • emailService.ts — real Resend dispatch replacing the placeholder that logged links. Sends as EMAIL_FROM, defaulting to the DKIM-aligned SolarSim <noreply@solarsim.tech>.
  • Rate limiting — the retired email_sent = 30/hour guard reimplemented as an in-process sliding window. Slots are reserved before the API call so concurrent signups cannot overrun the quota. No new dependency, no DB table.
  • Error handling — the Resend SDK returns { data, error } rather than throwing; error is checked explicitly and rethrown with the recipient in the message, so a failed send is never swallowed.

Template debt cleared

docs/limitations.md G-4 recorded that the email templates still carried the pre-27/04/26 brand mark and couldn't be fixed because that required the Supabase dashboard. They're in-repo now, so it's fixed.

The mark was the generic sun character &#9788;. The real logo was a 1024×1024 643 KB PNG — far too heavy for email — so a 160×160 6 KB asset was generated with sharp and is referenced via EMAIL_ASSET_BASE_URL rather than a hardcoded domain, with explicit dimensions, alt text, and the SolarSim wordmark retained so the layout still reads when clients block images (most do by default).

Verified live

  • Resend domain solarsim.tech: verified; DKIM verified; SPF verified (region ap-northeast-1)
  • A real send through sendVerificationEmail reached Resend with last_event: delivered, from: SolarSim <noreply@solarsim.tech>, subject Confirm Your SolarSim Account, the logo <img> present and zero unreplaced {{ }} tokens
  • Sent to delivered@resend.dev (Resend's sink) rather than a real inbox — proves the path without emailing a person
  • pnpm typecheck exit 0 · pnpm test 216 frontend + 142 backend (up from 124) · eslint --max-warnings 68 exit 0 (61 warnings) · prettier --check . exit 0

Judgement calls worth reviewing

  1. Three templates were redesigned, not just ported. Only confirm.html was actually branded (5.9 KB vs ~2.4 KB for the others); the worker applied the branded card layout to all four for consistency, preserving each email's purpose and exact subject. Defensible, but it is a visual change beyond a straight port.
  2. No fire-and-forget wrapper. resend.emails.send() awaits provider acceptance, not mailbox delivery, so it's fast; detaching it would risk losing transactional failures. Left blocking deliberately.

Not verified

Raw-header DKIM/SPF pass on a delivered message. Domain-level DKIM and SPF are verified and Resend signs all mail from verified domains, but confirming the headers on an actual received message needs a real inbox. Happy to send one to an address of your choosing.

Closes #7

Summary by CodeRabbit

  • New Features
    • Added branded transactional HTML emails for verification, password reset, email change, and invitations, including CTAs and fallback links.
    • Transactional emails are now sent via Resend with provider-backed delivery and support for additional email flows.
  • Bug Fixes
    • Email links are safely escaped; embedded logos use a public, absolute asset URL. Rate limiting and provider error handling improve delivery reliability.
    • Google OAuth now uses an absolute callback URL for consistent redirects.
  • Documentation
    • Updated environment examples for Neon Postgres pool timeout and expanded email configuration (sender and asset base URL).
  • Tests
    • Strengthened email rendering and email service dispatch tests for deterministic outputs and rate limits.

Removes the Supabase Auth SMTP relay that disappeared with Supabase Auth.
The four branded templates move in-repo as typed render functions, and
the backend calls Resend itself.

Also clears template debt that was previously unfixable: the brand mark
was still the generic sun character because updating it required the
Supabase dashboard. Now a 6KB email-sized asset referenced through
EMAIL_ASSET_BASE_URL, with the wordmark carrying meaning when clients
block images.

Carries over the retired email_sent=30/hour guard as an in-process
sliding window so a signup loop cannot burn the Resend free quota.

Verified live: domain, DKIM and SPF all verified in Resend; a real send
through the service reached Resend with the correct sender identity,
subject, and rendered logo.

Closes #7
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AlaskanTuna, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f2c6b76-3d4e-420d-a7c9-2fa84c866ad4

📥 Commits

Reviewing files that changed from the base of the PR and between 87db43c and 311db16.

📒 Files selected for processing (1)
  • backend/src/app.ts
📝 Walkthrough

Walkthrough

Adds Resend configuration and dependency support, four HTML email renderers with escaped URLs, rate-limited transactional email dispatch with provider error handling, Neon pool settings, and an absolute Google OAuth callback URL.

Changes

Resend email delivery

Layer / File(s) Summary
Email configuration and dependency
.env.example, backend/package.json, backend/src/config/env.ts
Adds the Resend SDK and validates sender, API key, and absolute email asset URL configuration.
Email contracts and templates
backend/src/emails/types.ts, backend/src/emails/*.ts, backend/src/emails/index.ts, backend/src/emails/__tests__/*
Adds the RenderedEmail contract, four escaped-URL HTML renderers, centralized exports, and parameterized template tests.
Rate-limited email dispatch
backend/src/services/emailService.ts, backend/src/services/__tests__/*
Sends rendered verification, password-reset, email-change, and invitation emails through Resend, enforcing a 30-per-hour in-memory limit and wrapping provider errors.

OAuth and database configuration

Layer / File(s) Summary
Neon connection-pool settings
.env.example
Adds pool_timeout=20 to the example Neon connection strings and documents its relationship with connect_timeout.
Absolute Google OAuth callback
frontend/src/hooks/useAuth.tsx, frontend/src/hooks/__tests__/useAuth.test.tsx
Uses the browser origin when constructing the Google sign-in callback URL and updates the corresponding test expectation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AuthFlow
  participant emailService
  participant EmailRenderer
  participant Resend
  AuthFlow->>emailService: sendVerificationEmail(email, url)
  emailService->>EmailRenderer: renderVerificationEmail(url)
  EmailRenderer-->>emailService: subject and html
  emailService->>Resend: Resend.emails.send(to, subject, html)
  Resend-->>emailService: send result
Loading

Possibly related PRs

  • AlaskanTuna/SolarSim#24: Adds the preceding email service placeholder work that this PR replaces with Resend-backed delivery.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The frontend OAuth redirect and test updates are unrelated to the email/Resend issue scope. Move the OAuth callbackURL changes and their test updates into a separate PR unless they are required for email delivery.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: transactional email now goes directly through Resend.
Linked Issues check ✅ Passed The PR adds Resend-backed email dispatch, ports four templates, preserves sender identity, and carries over the 30/hour rate limit.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/resend-email

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The 42px logo sat inside the orange gradient square that used to frame
the old sun character. When a client blocks images — most do by default
— the alt text rendered clipped inside that box and read as broken.

Removes the chip, matching what the app itself did when it adopted the
new mark, and sets alt="" so a blocked load collapses cleanly and screen
readers announce the adjacent wordmark once instead of twice.
…rigin

The email wrapper declared width:100% alongside 32px of horizontal
padding. Gmail strips the `*` box-sizing reset, so the wrapper rendered
wider than the viewport and produced a horizontal scrollbar. A block div
is already full width, so the declaration is simply removed.

signInWithGoogle passed a relative callbackURL, which Better Auth
resolves against its own baseURL — the API origin. In dev that is the
backend on :3001, which does not serve the SPA, so a successful sign-in
landed on a 404. Restores the absolute window.location.origin form the
Supabase implementation used.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
backend/src/emails/verification.ts (1)

4-21: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Validate the URL scheme and origin before rendering.

escapeHtml prevents HTML attribute injection but still permits values such as javascript: or data:. Because these renderers accept arbitrary URL strings, the email service/callers must allowlist HTTPS URLs and the expected application origin before passing them here. The identical helper is duplicated across all four templates, so centralizing this security boundary would also prevent drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/emails/verification.ts` around lines 4 - 21, Update the
URL-rendering flow around escapeHtml so arbitrary schemes and origins are
rejected before interpolation: allow only HTTPS URLs from the expected
application origin, while preserving HTML escaping for accepted values.
Centralize this validation and escaping helper and replace the duplicated
implementations across all four email templates so every renderer enforces the
same security boundary.
backend/src/emails/__tests__/emails.test.ts (1)

40-46: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert every URL interpolation is escaped.

toContain(escapedUrl) can pass even if one CTA or fallback link regresses to the raw URL, because another occurrence may still be escaped. Assert the escaped value in both href contexts and fallback text, and assert that the raw testUrl is absent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/emails/__tests__/emails.test.ts` around lines 40 - 46, Update the
parameterized test in the “email renderers” suite to verify every URL
interpolation: assert the escaped URL appears in both href contexts and fallback
text, and assert the raw testUrl does not appear anywhere in the rendered HTML.
Keep the existing subject assertion and renderer coverage unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@backend/src/emails/__tests__/emails.test.ts`:
- Around line 40-46: Update the parameterized test in the “email renderers”
suite to verify every URL interpolation: assert the escaped URL appears in both
href contexts and fallback text, and assert the raw testUrl does not appear
anywhere in the rendered HTML. Keep the existing subject assertion and renderer
coverage unchanged.

In `@backend/src/emails/verification.ts`:
- Around line 4-21: Update the URL-rendering flow around escapeHtml so arbitrary
schemes and origins are rejected before interpolation: allow only HTTPS URLs
from the expected application origin, while preserving HTML escaping for
accepted values. Centralize this validation and escaping helper and replace the
duplicated implementations across all four email templates so every renderer
enforces the same security boundary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b8becf8-f995-45a6-b9d8-9818a1c862b0

📥 Commits

Reviewing files that changed from the base of the PR and between fbc9f75 and 87db43c.

📒 Files selected for processing (8)
  • .env.example
  • backend/src/emails/__tests__/emails.test.ts
  • backend/src/emails/emailChange.ts
  • backend/src/emails/invite.ts
  • backend/src/emails/passwordReset.ts
  • backend/src/emails/verification.ts
  • frontend/src/hooks/__tests__/useAuth.test.tsx
  • frontend/src/hooks/useAuth.tsx

…dler

The Better Auth handler must precede express.json() to receive a raw
body, which also placed it ahead of requestLogger — so every sign-in,
signup and OAuth callback was invisible in the logs, including failures.
An empty log was indistinguishable from a request that never arrived,
which made a reported OAuth failure impossible to diagnose.

requestLogger reads nothing from the body, so it is safe to run first.
@AlaskanTuna
AlaskanTuna merged commit 3ac75a5 into main Jul 30, 2026
3 checks passed
@AlaskanTuna
AlaskanTuna deleted the feat/resend-email branch July 30, 2026 14:33
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.

Send verification and password-reset email directly via Resend

1 participant