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
dispatch 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-653webhook-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-273apikey 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-47v1,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-71email_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_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.jsonsendWithRetry 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-225welcome_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-618The waitlist trigger, end to end
| Step | Where | Failure behaviour |
|---|---|---|
Landing page inserts into public.waitlist as anon | PostgREST + RLS | Normal insert errors |
AFTER INSERT trigger fires send_waitlist_welcome_email() | migrations/20260420120000:66-70 | SECURITY DEFINER so an anon insert can read Vault |
Reads supabase_url and service key from Vault | migrations/20260420120000:24-27 | Missing secrets: RAISE WARNING, insert succeeds, no email (local default) |
net.http_post to /functions/v1/send-email/waitlist-welcome | migrations/20260420120000:35-54 | Wrapped in EXCEPTION WHEN OTHERS: pg_net trouble never breaks signup |
| Function looks up row, checks stamp, sends, stamps | send-email/index.ts:540-622 | 200 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
| Setting | Where | Effect |
|---|---|---|
[auth.email] otp_expiry = 900, max_frequency = "1m0s" | config.toml:41-47 | 15-minute links, one email per minute per user. MAGIC_LINK_EXPIRY_MINUTES in the template must match by hand. |
[auth.rate_limit] email_sent = 2000 | config.toml:26 | Matches prod. The scaffold default of 2 blocked all magic-link auth once. |
double_confirm_changes = true | config.toml | Email 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_URL | function env | The 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 / _4XX | function env | Short-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
- Render the templates:
deno run --allow-write --allow-read scripts/preview-email-templates.tsfrom the repo root, then opentemp/email-preview-signup.html. - 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.
- Run
cd src && deno test --allow-all supabase/tests/functions/send-email-send-with-retry-unit-test.tsand 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.
Next lesson: 16, Internal utilities: the small functions (Slack relay, FX rates, proxy, sprint guard, feedback) and what each one teaches about auth choices.
Ask your teacher: "show me the retry loop line by line" or "why does the email change flow need both confirmations".