Skip to content

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


1. Prerequisites

  • Supabase CLI installed and linked:

    Terminal window
    npm install -g supabase
    supabase login
    supabase link --project-ref <your-project-ref>
  • Resend is already configured for this project (RESEND_API_KEY, shared with approve-waitlist and Auth SMTP) and the surfc.app sender 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:

Terminal window
supabase functions deploy contact-us

Expected output ends with Deployed Function: contact-us. Invocation URL:

https://<project-ref>.functions.supabase.co/contact-us

Reference: 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:

Terminal window
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):

Terminal window
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-tokenaccess_token), then:

Terminal window
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 / responseMeaning
RESEND_API_KEY not set — cannot sendSet the shared secret; response is 500 (the user is NOT told “sent”).
Resend non-2xx: 401RESEND_API_KEY wrong or revoked.
Resend non-2xx: 403Sender domain (surfc.app) not verified in Resend.
401 with no function logsGateway JWT gate rejected an anon/expired call (expected for anon).
200 {"ok":true} but no emailHoneypot tripped (intended), or Resend delivery issue — check Resend.
429 rate_limitedPer-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 from supabase.auth.getUser(token) — never the request body. A spoofed email/user_id in the body is ignored (covered by a payload-audit test).
  • Header-injection guard. subject is interpolated into the Resend subject header; 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-waitlist and 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.