Configuring the contact-us Edge Function
Configuring the contact-us Edge Function
SUR-213
The contact-us Edge Function backs the in-app Contact us form. An
authenticated user submits a subject + message; the function emails the
support inbox via Resend with the user’s verified
identity attached (so a reply goes back to them).
Unlike approve-waitlist, this function is invoked
directly by the signed-in client — there is no Database Webhook and JWT
verification stays ON. The user’s email / id / reply-to are taken from the
verified JWT, never the request body.
Related files
- Function source:
supabase/functions/contact-us/index.ts - Client wrapper:
invokeContactUsinsrc/supabase.js - Tests:
supabase/functions/contact-us/__tests__/index.test.ts
1. Prerequisites
-
Supabase CLI installed and linked:
Terminal window npm install -g supabasesupabase loginsupabase link --project-ref <your-project-ref> -
Resend is already configured for this project (
RESEND_API_KEY, shared withapprove-waitlistand Auth SMTP) and thesurfc.appsender domain is verified (SUR-186). No new key is required.
2. Deploy the function
Important — the opposite of approve-waitlist. This function is called
by the browser with the user’s Supabase JWT, so the Edge Runtime gateway’s
default JWT check must stay ON. There is no [functions.contact-us]
block in supabase/config.toml — that is
deliberate; do not add one and do not deploy with --no-verify-jwt. An
anonymous (no-JWT) caller is rejected by the gateway with 401 before the
function runs; the handler additionally resolves the caller via
supabase.auth.getUser(token) and 401s on an invalid/expired token.
Deploy from the repo root:
supabase functions deploy contact-usExpected output ends with Deployed Function: contact-us. Invocation URL:
https://<project-ref>.functions.supabase.co/contact-usReference: Deploying Edge Functions
3. Secrets
RESEND_API_KEY is already set (shared). Optional overrides — both have
sensible defaults in the function, so you usually set nothing:
supabase secrets set CONTACT_EMAIL_FROM="Surfc Contact <hello@surfc.app>"supabase secrets set CONTACT_EMAIL_TO="hello@surfc.app"SUPABASE_URL / SUPABASE_ANON_KEY are injected by the runtime — do not set
them.
Reference: Edge Function secrets
4. No webhook
This function is not wired to a Database Webhook. It is invoked directly
by the client via supabase.functions.invoke('contact-us', …)
(invokeContactUs in src/supabase.js). Nothing to configure in
Dashboard → Database → Webhooks.
5. Smoke test
5a. Anonymous call is rejected (gateway JWT gate):
curl -i -X POST https://<project-ref>.functions.supabase.co/contact-us \ -H 'Content-Type: application/json' \ -d '{"subject":"hi","message":"anon"}'Expected: 401 (no valid JWT — never reaches the handler).
5b. Authenticated call delivers an email. Grab a real access token from a
signed-in browser session (DevTools → Application → Local Storage →
sb-…-auth-token → access_token), then:
curl -i -X POST https://<project-ref>.functions.supabase.co/contact-us \ -H "Authorization: Bearer <access-token>" \ -H 'Content-Type: application/json' \ -d '{"subject":"Runbook smoke test","message":"Hello from the curl smoke test."}'Expected: 200 {"ok":true} and an email at hello@surfc.app within ~5s with:
- Subject
[Surfc] Runbook smoke test - Reply-To = the signed-in user’s email (verified, from the JWT)
- An identity footer:
From / User ID / App version / User agent - An “unverified user-submitted content” banner
5c. Honeypot (silent drop): add "hp_trap":"x" to the body → still
200 {"ok":true} but no email is sent (bots can’t tell the difference).
5d. Validation: empty/missing subject or message, subject > 120,
message > 4000, or a subject containing CR/LF/control chars → 400.
5e. Rate limit (best-effort): 4 sends within 5 minutes from the same user
→ the 4th returns 429. Note: this is an in-memory per-isolate
speed-bump, not a cross-isolate guarantee — Supabase Edge runs multiple
isolates and recycles them, so the limit can be exceeded under isolate churn.
This was accepted in writing (founder, 2026-05-18): mandatory JWT already
caps abuse to authenticated accounts; a durable limiter would need a
migration and is out of scope for SUR-213. Treat the bucket as a deterrent,
not a wall.
6. Logs and troubleshooting
Dashboard → Edge Functions → contact-us → Logs.
| Log fragment / response | Meaning |
|---|---|
RESEND_API_KEY not set — cannot send | Set the shared secret; response is 500 (the user is NOT told “sent”). |
Resend non-2xx: 401 | RESEND_API_KEY wrong or revoked. |
Resend non-2xx: 403 | Sender domain (surfc.app) not verified in Resend. |
401 with no function logs | Gateway JWT gate rejected an anon/expired call (expected for anon). |
200 {"ok":true} but no email | Honeypot tripped (intended), or Resend delivery issue — check Resend. |
429 rate_limited | Per-isolate bucket hit (best-effort; see §5e). |
7. Operational notes
- Identity is JWT-derived.
reply_to, the From-line email, and the user id in the footer come only fromsupabase.auth.getUser(token)— never the request body. A spoofedemail/user_idin the body is ignored (covered by a payload-audit test). - Header-injection guard.
subjectis interpolated into the Resendsubjectheader; CR/LF and control chars are rejected with 400. The body is plaintext only. - Rotating the Resend key. Set the new key in Resend, update the secret
via the Supabase CLI; no redeploy required (shared with
approve-waitlistand Auth SMTP — rotate deliberately). - No DB writes, no migration. This function only reads the JWT and calls Resend; nothing to roll back on the data plane.