Skip to content

feat: welcome email with Resend integration#980

Open
orveth wants to merge 12 commits intomasterfrom
feat/welcome-email
Open

feat: welcome email with Resend integration#980
orveth wants to merge 12 commits intomasterfrom
feat/welcome-email

Conversation

@orveth
Copy link
Copy Markdown
Contributor

@orveth orveth commented Apr 3, 2026

Summary

Welcome email sent via Supabase Edge Function when a new user signs up.

  • Edge Function (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.
  • DB trigger (wallet.handle_new_user) — fires on INSERT to wallet.users, calls Edge Function async via pg_net. Skips guests (null email). Only fires for genuine inserts (not upsert-updates).
  • Hosted assets — logo PNG + 6 section icon PNGs in public/ for email template rendering

Setup required

  1. Deploy Edge Function: supabase functions deploy welcome-email
  2. Set Edge Function secrets: RESEND_API_KEY, RESEND_AUDIENCE_ID, RESEND_WELCOME_TEMPLATE_ID
  3. Set Vault secrets: edge_function_base_url, service_role_key
  4. Apply migration

Manual retry: Invoke from Supabase dashboard with { "email": "user@example.com", "firstName": "Name" }

Test plan

  • Template live-tested with bob and josip — logo, icons, layout render correctly
  • TypeScript compiles clean
  • Deploy Edge Function to staging and test with real user insert
  • Verify trigger fires only on INSERT (not upsert-update for returning users)

🤖 Generated with Claude Code

orveth and others added 2 commits April 3, 2026 12:59
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>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agicash Ready Ready Preview, Comment Apr 10, 2026 7:49pm

Request Review

@supabase
Copy link
Copy Markdown

supabase bot commented Apr 3, 2026

This pull request has been ignored for the connected project hrebgkfhjpkbxpztqqke because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jbojcic1 we needed to have the icons be pngs so they show up in all email clients (see discord here)

wdyt about hosting them here? Seems fine for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Yeah seems fine. Can't be uploaded to resend where template is created?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is needed so we can render the logo in the welcome email

Image

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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if this call fails? will Supabase retry?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to see how to send this to Sentry

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Supabase Edge Functions support @sentry/deno (npm:@sentry/deno). We'd need to:

  1. Add Sentry DSN as an Edge Function secret
  2. Initialize Sentry in the function
  3. 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", {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this mean initial attempt plus 2 retries meaning 3 total or 2 total?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does one invoke this function manually from the Supabase dashboard now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@gudnuf gudnuf marked this pull request as ready for review April 10, 2026 06:52
@gudnuf
Copy link
Copy Markdown
Contributor

gudnuf commented Apr 10, 2026

@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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also send this log to sentry?

orveth and others added 2 commits April 10, 2026 12:28
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}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants