✉️ Emails
Supabase sends Auth mail. Resend is optional product email — welcome, trial, and payment events — off until you enable it.
Send product emails from day one — or don’t. The app boots either way.
GoShipped splits email in two. Mix them up and you will debug the wrong dashboard.
Signup / reset password → Supabase Auth
Welcome / trial / receipt → Resend (optional)
Stripe invoices → StripeTwo channels
| Channel | Owner | Examples |
|---|---|---|
| Auth | Supabase Auth + supabase/templates/ | Confirm signup, reset password |
| Product | apps/api/app/email/ via Resend | Welcome, trial ending, payment failed |
| Receipts | Stripe | Invoices |
Resend is not required to start GoShipped. Default is EMAIL_ENABLED=false. Auth mail still works. See Authentication.
Turn product email on
Create an API key, then in apps/api/.env:
EMAIL_ENABLED=true
RESEND_API_KEY=re_...
EMAIL_FROM=Your App <hello@your-domain.com>
EMAIL_REPLY_TO=
FRONTEND_URL=https://localhost:3000Doctor only requires Resend when EMAIL_ENABLED=true.
| Variable | Role |
|---|---|
EMAIL_ENABLED | Master switch |
RESEND_API_KEY | Resend API key |
EMAIL_FROM | Must be a domain you verify in Resend |
EMAIL_REPLY_TO | Optional |
FRONTEND_URL | Links inside the email (required to actually send) |
EMAIL_DEV_RECIPIENT | Non-production only — redirect all product mail to one inbox |
EMAIL_PRODUCT_NAME / EMAIL_SUPPORT_EMAIL | Optional overrides; prefer starter.config.json |
Brand strings should come from app.name and support.email. Templates do not hardcode “GoShipped”.
What happens when it is off
| Condition | Result |
|---|---|
EMAIL_ENABLED=false | Skip, log email_skipped reason=email_disabled |
Missing key or EMAIL_FROM | Skip (error-level log in production) |
| Duplicate idempotency key | No second Resend call |
| Resend error | Soft fail — /me and Stripe webhooks still succeed |
Users can sign up and chat with product email disabled.
Where the code lives
| Piece | Path |
|---|---|
| Config | apps/api/app/email/config.py |
| Send + ledger | apps/api/app/email/service.py |
| Lifecycle helpers | apps/api/app/email/events.py |
| HTML / text | apps/api/app/email/templates.py |
| Resend HTTP | apps/api/app/email/client.py |
Call events.py (or send_email) — not Resend — from your own hooks.
Welcome already fires from authenticated GET /me (once per user):
# apps/api/app/email/events.py
async def maybe_send_welcome_email(db, user, *, cfg=None):
key = f"welcome:{user.id}"
existing = await find_by_idempotency_key(db, key)
if existing is not None and existing.status in {"sent", "skipped", "pending"}:
return None
return await send_welcome_email(db, user, cfg=cfg)Billing mail (trial ending, payment failed, …) runs after subscription sync in the Stripe webhook. Keep customer.subscription.trial_will_end on the webhook endpoint or that email never sends. See Payments.
To send something new: add a template in templates.py, an idempotent helper in events.py, and call it from the event you already own.
Production
- Verify the sending domain in Resend
EMAIL_FROMon that domainEMAIL_ENABLED=true,RESEND_API_KEY,FRONTEND_URL= real web originAPP_ENV=production(disablesEMAIL_DEV_RECIPIENT)- Auth still needs Supabase SMTP for confirmation and reset — that is not Resend
The deploy wizard can upload Resend keys. Domain verification stays a dashboard task.