Email

Lesson 15 · about 15 minutes · one function, two callers, two signatures: how every email the product sends leaves through send-email.

Supabase Auth can send its own emails, but they are unbranded and cannot be customised per surface. This project replaces them with a Send Email Hook: Auth builds the token, then POSTs the payload to our edge function, which renders a React Email template and delivers it through Resend. The same function also sends the waitlist welcome email, triggered from a database insert. The two routes share one Resend client, one retry loop and one Slack alert path, but they authenticate the caller in completely different ways.

Route by path, auth by route

1dispatch looks only at the path suffix. Anything ending in /waitlist-welcome goes to the internal handler; everything else is treated as the auth hook. send-email/index.ts:646-653
2aAuth hook: Standard Webhooks signature. Supabase signs each hook call with webhook-id, webhook-timestamp and webhook-signature headers. The function reads the raw body before parsing, because the signature covers the exact bytes, then new Webhook(hookSecret).verify(payload, headers). A bad signature is a 401. send-email/index.ts:259-273
2bWaitlist: service role credential. The trigger sends the service key as both apikey and Authorization because local Kong strips non-JWT bearers. The handler compares against SUPABASE_SERVICE_ROLE_KEY and uses a lazily-built admin client that bypasses RLS. send-email/index.ts:390-416 · migrations/20260420120000:39-47
3The hook secret has a prefix to strip. The dashboard generates v1,whsec_<base64>; the standardwebhooks library wants raw base64. Forgetting the replace makes every real email fail verification while the mocks still pass. send-email/index.ts:66-71
4Template selection. email_action_type (signup, magiclink, recovery, invite, email_change…) picks the subject via getEmailSubject. One special case: if redirect_to contains assets.wallstreetbets.com the CDN upload page is signing in, and it gets the OTP code template instead of a magic link. send-email/index.ts:296-300 · send-email/_shared/subjects.ts
5Render with React, no build step. _templates/*.tsx are React components; @react-email/render turns them into HTML at request time. Deno compiles TSX directly because functions/deno.json sets jsx: react-jsx with npm:react@19.2.4 as the import source. send-email/_templates/magic-link.tsx · functions/deno.json
6Send with retry, alert on give-up. sendWithRetry honours Resend's Retry-After on 429 and retries 5xx up to SEND_EMAIL_MAX_RETRIES. Invalid recipients (isInvalidRecipientError) are not retried. Exhausted retries post to SLACK_ALERT_WEBHOOK_URL; a Slack outage is swallowed so it can never cascade into the email path. send-email/_shared/sendWithRetry.ts · send-email/index.ts:206-225
Why does the waitlist path stamp welcome_email_sent_at after sending, not before? pg_net is fire-and-forget: the trigger enqueues an HTTP call and the insert commits regardless. If the function crashed after stamping but before sending, the row would look done and the user would never get the email. Stamping after a successful Resend response makes a null column a durable "not sent yet" signal that a reconciliation job could act on. The function also checks the stamp first, so a retried trigger call is idempotent. send-email/index.ts:565-618

The waitlist trigger, end to end

StepWhereFailure behaviour
Landing page inserts into public.waitlist as anonPostgREST + RLSNormal insert errors
AFTER INSERT trigger fires send_waitlist_welcome_email()migrations/20260420120000:66-70SECURITY DEFINER so an anon insert can read Vault
Reads supabase_url and service key from Vaultmigrations/20260420120000:24-27Missing secrets: RAISE WARNING, insert succeeds, no email (local default)
net.http_post to /functions/v1/send-email/waitlist-welcomemigrations/20260420120000:35-54Wrapped in EXCEPTION WHEN OTHERS: pg_net trouble never breaks signup
Function looks up row, checks stamp, sends, stampssend-email/index.ts:540-622200 with skipped on missing row or already sent

This trigger predates private.post_to_edge_function (lesson 2) and hand-rolls the same net.http_post. It works because it sets the Origin header itself. A new trigger should call the shared function instead.

Config and environment

SettingWhereEffect
[auth.email] otp_expiry = 900, max_frequency = "1m0s"config.toml:41-4715-minute links, one email per minute per user. MAGIC_LINK_EXPIRY_MINUTES in the template must match by hand.
[auth.rate_limit] email_sent = 2000config.toml:26Matches prod. The scaffold default of 2 blocked all magic-link auth once.
double_confirm_changes = trueconfig.tomlEmail change sends to both addresses; both must confirm. See docs/EMAIL_CHANGE.md.
RESEND_API_KEY, SEND_EMAIL_HOOK_SECRET, SENDER_EMAIL, SLACK_ALERT_WEBHOOK_URLfunction envThe hook itself is enabled in the dashboard, not in config.toml; a fresh local stack sends through Mailpit on port 54324 instead.
SEND_EMAIL_MOCK_429 / _5XX / _4XXfunction envShort-circuit Resend to exercise the retry loop without network. send-email/index.ts:84-96

Previewing templates locally

scripts/preview-email-templates.ts renders the components to temp/*.html. It runs under scripts/deno.json, which has an imports map so the script can write import React from 'react' while the function itself writes npm:react@19.2.4. Same package, two resolution styles; lesson 19 covers why both work.

Check yourself

Do this in the repo

  1. Render the templates: deno run --allow-write --allow-read scripts/preview-email-templates.ts from the repo root, then open temp/email-preview-signup.html.
  2. With the stack served, insert a waitlist row through Studio (port 54323) and watch the functions log. Confirm you see the trigger's vault warning rather than an email.
  3. Run cd src && deno test --allow-all supabase/tests/functions/send-email-send-with-retry-unit-test.ts and read the test that covers the invalid-recipient branch.

Primary source

Supabase: Send Email Hook documents the payload shape and signature headers the function verifies. send-email/README.md covers the in-repo specifics.