feat(email): send transactional mail directly via Resend - #25
Conversation
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
|
Warning Review limit reached
Next review available in: 43 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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. 📝 WalkthroughWalkthroughAdds 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. ChangesResend email delivery
OAuth and database configuration
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
backend/src/emails/verification.ts (1)
4-21: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winValidate the URL scheme and origin before rendering.
escapeHtmlprevents HTML attribute injection but still permits values such asjavascript:ordata:. 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 winAssert 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 bothhrefcontexts and fallback text, and assert that the rawtestUrlis 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
📒 Files selected for processing (8)
.env.examplebackend/src/emails/__tests__/emails.test.tsbackend/src/emails/emailChange.tsbackend/src/emails/invite.tsbackend/src/emails/passwordReset.tsbackend/src/emails/verification.tsfrontend/src/hooks/__tests__/useAuth.test.tsxfrontend/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.
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.techwas 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 fromsupabase/config.toml.emailService.ts— real Resend dispatch replacing the placeholder that logged links. Sends asEMAIL_FROM, defaulting to the DKIM-alignedSolarSim <noreply@solarsim.tech>.email_sent = 30/hourguard 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.{ data, error }rather than throwing;erroris checked explicitly and rethrown with the recipient in the message, so a failed send is never swallowed.Template debt cleared
docs/limitations.mdG-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
☼. 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 viaEMAIL_ASSET_BASE_URLrather than a hardcoded domain, with explicit dimensions, alt text, and theSolarSimwordmark retained so the layout still reads when clients block images (most do by default).Verified live
solarsim.tech: verified; DKIM verified; SPF verified (regionap-northeast-1)sendVerificationEmailreached Resend withlast_event: delivered,from: SolarSim <noreply@solarsim.tech>, subjectConfirm Your SolarSim Account, the logo<img>present and zero unreplaced{{ }}tokensdelivered@resend.dev(Resend's sink) rather than a real inbox — proves the path without emailing a personpnpm typecheckexit 0 ·pnpm test216 frontend + 142 backend (up from 124) ·eslint --max-warnings 68exit 0 (61 warnings) ·prettier --check .exit 0Judgement calls worth reviewing
confirm.htmlwas 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.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