BUILT INTO EVERY GOODSPEED APP
Transactional Email via Resend
Templated emails (welcome, password reset, receipt, trial-ending) are sent via Resend from a shared Edge Function with every send logged to a delivery table, so you have an audit trail and can replay failed sends without duplicating send logic across features.
- Tier: Common
- Status: Config-toggled
- Config: features.transactionalEmail.enabled
WHY IT MATTERS
Every non-trivial app needs to send email: a welcome message when someone signs up, a password-reset link when they forget their credentials, a receipt when they complete a purchase. The naive path is to wire each of these separately, scattering Resend client calls across auth handlers, payment webhooks, and account management screens. That works until you need to audit whether a specific email was delivered, replay a failed send because Resend had a blip, or change a subject line without touching three different files. The gas-template takes a different approach: all outbound email flows through a single Edge Function at supabase/functions/send-email/. Callers pass a template key, a recipient address, a set of interpolation variables, and optionally the user ID for attribution. The function resolves the right template, renders subject, HTML body, and plain-text fallback, writes a pending row to the email_log table, calls the Resend API, then updates that row to sent or failed. The log row always exists before the send attempt, so a crash between the insert and the API call leaves a pending row you can find and replay rather than a silent gap in your audit trail.
The template map in handler.ts is typed: the TemplateKey union is derived from the keys of the templates object, so passing an unknown template key is a compile-time error rather than a 400 at runtime. Each template is a plain TypeScript module exporting a subject function, an html function, and a text function, all accepting a typed vars object. Adding a new email type means adding one file in supabase/functions/send-email/templates/ and one key in the templates record. No other plumbing changes. The email_log table stores the template name, recipient address, rendered subject, send status, the Resend message ID (for cross-referencing delivery events in the Resend dashboard), the timestamp, and any error string on failure. It is indexed on user_id so you can pull all email history for a given user during a support investigation or a GDPR export. The idempotency model is lightweight by default: callers pass a stable key (such as order ID for a receipt) and the function skips the send if a sent row already exists for that key, preventing duplicate emails from retry storms or webhook replays.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from supabase/functions/send-email/handler.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// supabase/functions/send-email/handler.ts -- template map + delivery log
const templates: Record<string, Template> = {
welcome,
password_reset: passwordReset,
receipt,
account_deletion_scheduled: accountDeletionScheduled,
data_export: dataExport,
};
export async function handleSendEmail(raw: Record<string, unknown>) {
const payload = raw as unknown as SendEmailPayload;
if (!payload.template || !templates[payload.template])
throw new HttpError(400, `send_email: unknown template "${String(payload.template)}"`);
// Log before sending -- ensures every attempt is auditable
const { data: logRow } = await client.from('email_log').insert({
user_id: payload.userId ?? null,
template: payload.template,
to_address: payload.to,
subject: tpl.subject(payload.vars),
status: 'pending',
}).select('id').single();
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to: payload.to, subject, html, text }),
});
const out = await res.json();
await client.from('email_log').update({
status: 'sent', resend_message_id: out?.id, sent_at: new Date().toISOString(),
}).eq('id', logRow.id);
} catch (e) {
await client.from('email_log').update({
status: 'failed', error: e instanceof Error ? e.message : String(e),
}).eq('id', logRow.id);
throw e;
}
}Source: goodspeed-apps/gas-template → supabase/functions/send-email/handler.ts
HONEST LIMITS
When Transactional Email via Resend is the wrong choice
Resend is built for transactional sends: messages triggered by a specific user action, sent to one recipient at a time, where deliverability and per-send audit matter more than throughput. The pricing model and rate limits reflect that. If your use case is a newsletter to ten thousand subscribers, a re-engagement campaign, or any scenario where you are sending the same message to a large list in a batch, Resend is the wrong tool. Those workloads belong on a purpose-built bulk-email platform such as Loops, Brevo, or SendGrid Marketing, which give you list management, unsubscribe handling, bounce suppression, and campaign analytics out of the box. The gas-template does not include a bulk-email integration; if you need both transactional and marketing email, the pattern is to use this Edge Function for triggered sends and a separate marketing platform for list-based sends, keeping the two concerns cleanly separated so a marketing unsubscribe does not suppress a password-reset email. Also consider the retry and idempotency model carefully. The default implementation writes a pending log row before calling Resend, then updates it to sent or failed. If your Edge Function is called by a webhook that retries on 5xx, you need to pass a stable idempotency key (such as an order ID for receipts) so the function can skip duplicate sends. Without that, a flaky webhook will send the same receipt email multiple times. The template provides the pattern and the log table provides the queryable state; wiring the idempotency check from your specific call sites is the integration step that falls to your app code. For fire-and-forget use cases where duplicates are acceptable, no extra work is needed.
Tier: Common · Config-toggled
Evaluate your use case
Check whether transactional email via resend aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.transactionalEmail.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes transactional email via resend. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Transactional Email via Resend capability breakdown
Concrete dimensions of what the built-in transactional email via resend implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Email provider | The HTTP client used to deliver messages | Resend API |
| Delivery log | Every send attempt is recorded before the API call is made | Supabase Postgres (email_log) |
| Template model | How email body and subject are rendered | Typed TypeScript modules (subject + html + text) |
| Idempotency | Mechanism for preventing duplicate sends on retry | Caller-supplied key checked against email_log |
| Failure handling | What happens when the Resend API returns an error | Log row updated to failed + error string; exception re-thrown |
COMMON QUESTIONS
Which email templates ship in the base template?
Five templates are included out of the box: welcome (sent after signup, vars: displayName and appName), password_reset (triggered by the forgot-password flow, vars: resetUrl and appName), receipt (sent after a successful payment, vars: amount, currency, description, and receiptUrl), account_deletion_scheduled (sent when a user initiates account deletion, vars: deletionDate and appName), and data_export (sent when a GDPR export is ready, vars: downloadUrl and expiresAt). Each template is a separate TypeScript file in supabase/functions/send-email/templates/ with three exports: subject, html, and text. The html export returns a minimal inline-styled string rather than an external MJML or Handlebars dependency, keeping the Edge Function self-contained with no build step. Adding a new template is a matter of creating a new file in that directory, exporting the same three functions, importing it in handler.ts, and adding it to the templates record. The TypeScript type system catches any call site that passes an unregistered template key before it reaches Deno Deploy.
How does the audit trail work, and can I use it for GDPR requests?
Every send attempt creates a row in the email_log table before the Resend API is called. The row records the user_id (nullable, for unauthenticated flows like password resets before sign-in), the template key, the recipient address, the rendered subject line, the initial status (pending), and the created_at timestamp. After the API call, the row is updated with the outcome: status becomes sent or failed, the Resend message ID is stored in resend_message_id for cross-referencing with Resend delivery dashboard, and sent_at is set. For failed sends, the error string from the exception is written to the error column. The table is indexed on user_id, so the GDPR data-export Edge Function (see the gdpr-data-export feature) can include the full email history for a user in the export payload. Row-level security restricts reads: service-role operations are the only path that can query across users, ensuring that a signed-in user cannot read another user email log.
What happens if the Resend API is down when a send is triggered?
The Edge Function writes the pending log row first, then calls Resend. If the API call throws (network error, timeout, non-2xx response), the catch block updates the log row to failed and stores the error string, then re-throws the exception. The caller receives a 500 and can retry. Because the log row already exists with status pending, you can query for all pending and failed rows in email_log as a replay queue. The template does not include an automatic retry scheduler, but pairing this feature with the job-queue feature gives you a durable retry loop: enqueue a retry job on failure, and the worker calls the Edge Function again with the same payload. The Resend message ID stored on success means a second call with the same payload will produce a second delivery unless you pass a stable idempotency key and check for an existing sent row before calling the function. For most transactional templates, lightweight idempotency on the caller side is sufficient without building a full deduplication layer inside the Edge Function itself.
Can I customize the sender name and domain, or am I locked to a shared Resend domain?
The sender address is controlled by the EMAIL_FROM environment variable set in your Supabase project secrets. During development it defaults to no-reply@example.com so the function runs without any configuration. For production, set EMAIL_FROM to a verified address on your own domain, such as no-reply@yourapp.com, after completing Resend domain verification (adding SPF, DKIM, and DMARC records in your DNS provider). Resend free plan allows sending from a verified domain; you do not need a paid plan to use a custom sender. The RESEND_API_KEY variable must also be set in project secrets. Both variables are documented in the template .env.example file. Sender name (the human-readable display name shown in mail clients before the address) is embedded in the EMAIL_FROM value as a formatted string: for example, My App <no-reply@yourapp.com>. You can set this to any value Resend accepts in that field.
GET IT BUILT INTO YOUR APP