Conversation
Adds email service, API route, and tests for sending welcome emails to new signups via Resend. Blocked on Resend dashboard + DNS config. - email-service.server.ts: ky-based Resend integration (createContact, sendWelcomeEmail, handleNewSignup) - api.welcome-email.ts: POST endpoint with Zod validation - email-service.server.test.ts: 11 tests covering happy path and error cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sets - Fix Resend API: use `template` object with `variables` (not `template_id`) - Use verified domain `emails.agi.cash` (not `email.agi.cash`) - Add full logo and section icon PNGs to public/ for email template - Convert 6 SVG section icons to PNGs (email clients strip SVGs) - Pass `email` variable for unsubscribe link in template footer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
Move env var validation from module-level (crashes on import) to function-level (crashes only when email features are actually called). Fixes Vercel deploy crash when Resend env vars aren't configured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GET /api/unsubscribe?email=<base64> marks the contact as unsubscribed via Resend PATCH API and returns a branded HTML confirmation page. Also passes unsubscribeUrl template variable to the welcome email. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Supabase Edge Function (Deno) that sends welcome emails via Resend API
when a new user is inserted into wallet.users. Trigger skips guests
(null email). Function retries up to 2x with exponential backoff.
Also supports manual invocation with { email, firstName } payload.
Migration creates pg_net trigger that calls the Edge Function async.
Vault secrets required: edge_function_base_url, service_role_key.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Edge Function replaces the app-level email service. Unsubscribe is out of scope for the transactional welcome email — will be added when marketing emails are introduced. Removed: - app/features/email/email-service.server.ts - app/features/email/email-service.server.test.ts - app/routes/api.welcome-email.ts - app/routes/api.unsubscribe.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
I see. Yeah seems fine. Can't be uploaded to resend where template is created?
There was a problem hiding this comment.
Resend templates reference images by URL — they don't host the files. So the images need to live somewhere publicly accessible. Our public/ dir serves them via Vercel (agi.cash/agicash-logo.png). We could use an external CDN instead, but public/ is simpler for now.
Contact/audience management is not needed for a single transactional welcome email. Edge Function now makes one API call (send email) instead of two. RESEND_AUDIENCE_ID env var no longer required. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use ky (npm:ky@1.7.5) with built-in retry instead of custom withRetry
- Validate payload with zod (npm:zod@3.24.3)
- Remove WebhookPayload type — trigger sends simple { id, email, firstName }
- Include user ID in trigger payload and Edge Function response
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| end if; | ||
|
|
||
| -- fire async HTTP request to the welcome-email edge function via pg_net | ||
| select net.http_post( |
There was a problem hiding this comment.
what happens if this call fails? will Supabase retry?
There was a problem hiding this comment.
pg_net does not retry. The HTTP request fires once asynchronously. If it fails, the response is logged to net._http_response (unlogged table, cleared after 6h) but there's no automatic retry. The DB transaction is not affected — the user insert always succeeds regardless.
For now this is acceptable (welcome email is best-effort). If we need retries later, we'd add pgmq as discussed.
| }, | ||
| ); | ||
| } catch (error) { | ||
| console.error("Welcome email function error:", error); |
There was a problem hiding this comment.
we need to see how to send this to Sentry
There was a problem hiding this comment.
Agreed. Supabase Edge Functions support @sentry/deno (npm:@sentry/deno). We'd need to:
- Add Sentry DSN as an Edge Function secret
- Initialize Sentry in the function
- Capture exceptions
I'll note this as a follow-up — want me to add it now or keep it separate?
| email: string, | ||
| firstName: string | undefined, | ||
| ): Promise<void> { | ||
| await resend.post("emails", { |
There was a problem hiding this comment.
there should be some timeout so that this can't take too long because edge function can cost more if it takes too long (correct me if I am wrong)
There was a problem hiding this comment.
Good point. Adding a timeout to the ky client config. ky's default timeout is 10s which is reasonable, but I'll set it explicitly to 10s to be clear. The Edge Function itself has a 150s wall clock limit (free) / 400s (paid), but 2s CPU limit — our async HTTP calls don't count against CPU, only wall clock.
| Authorization: `Bearer ${apiKey}`, | ||
| }, | ||
| retry: { | ||
| limit: 2, |
There was a problem hiding this comment.
does this mean initial attempt plus 2 retries meaning 3 total or 2 total?
There was a problem hiding this comment.
ky's retry.limit means number of retries AFTER the initial attempt. So limit: 2 = 1 initial + 2 retries = 3 total attempts. Adding a comment to clarify.
| "authorization, x-client-info, apikey, content-type", | ||
| }; | ||
|
|
||
| Deno.serve(async (req) => { |
There was a problem hiding this comment.
how does one invoke this function manually from the Supabase dashboard now?
There was a problem hiding this comment.
From the Supabase Dashboard: Edge Functions → welcome-email → "Test" button. You can send a POST request with the payload:
{
"id": "user-uuid",
"email": "user@example.com",
"firstName": "Name"
}Or via CLI:
curl -X POST https://<project>.supabase.co/functions/v1/welcome-email \
-H "Authorization: Bearer <service_role_key>" \
-H "Content-Type: application/json" \
-d '{"id":"user-uuid","email":"user@example.com","firstName":"Name"}'Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Captures exceptions and flushes to Sentry before returning error responses. Uses Sentry Deno SDK with defaultIntegrations disabled (no Deno.serve instrumentation). Tags with SB_REGION and SB_EXECUTION_ID for observability. New secrets needed: SENTRY_DSN, SENTRY_ENVIRONMENT (optional, defaults to production). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@jbojcic1 orveth added the 10s timeout with retry and sentry to this PR. I still need to test it, but code lgtm |
|
|
||
| const { id, email, firstName } = result.data; | ||
|
|
||
| const apiKey = getRequiredEnv("RESEND_API_KEY"); |
There was a problem hiding this comment.
@jbojcic1 any updated thoughts on this thread?
I still think we should keep the api keys seperate per capability to keep them isolated, resend logs also allows you to filter by api key so we can see logs for the emails triggered by our code vs. the emails that open secret is sending. If we ever need to revoke a key we don't have to remember to rotate all the places using the same key.
|
|
||
| await sendWelcomeEmail(resend, templateId, email, firstName); | ||
|
|
||
| console.log("Welcome email sent", { id, email }); |
There was a problem hiding this comment.
should we also send this log to sentry?
Uses `welcome-email/<user-id>` as the idempotency key on POST /emails to prevent duplicate sends. Makes user ID required in the payload schema. Resend deduplicates within a 24h window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
deno.land/x/sentry stopped at 8.55.0 — 9.6.0 was never published there. The SDK migrated to npm as @sentry/deno. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| ): Promise<void> { | ||
| await resend.post("emails", { | ||
| headers: { | ||
| "Idempotency-Key": `welcome-email/${id}`, |
There was a problem hiding this comment.
I added this. The key expires after 24 hours so we could then send again after that. This seems like a good safeguard to not accidentally spam our users like what happened with Maple

Summary
Welcome email sent via Supabase Edge Function when a new user signs up.
supabase/functions/welcome-email/index.ts) — creates Resend contact + sends welcome email via template. Retries up to 2x with exponential backoff. Handles both webhook and manual payloads.wallet.handle_new_user) — fires on INSERT towallet.users, calls Edge Function async viapg_net. Skips guests (null email). Only fires for genuine inserts (not upsert-updates).public/for email template renderingSetup required
supabase functions deploy welcome-emailRESEND_API_KEY,RESEND_AUDIENCE_ID,RESEND_WELCOME_TEMPLATE_IDedge_function_base_url,service_role_keyManual retry: Invoke from Supabase dashboard with
{ "email": "user@example.com", "firstName": "Name" }Test plan
🤖 Generated with Claude Code