How to send email with Supabase
Supabase's built-in email is for auth only and heavily rate limited. How to send your own transactional email from Edge Functions and database triggers.
Supabase sends email out of the box, and that leads two different projects into the same wall. The built-in sender is for authentication only — confirmations, magic links, password resets — and on the free tier it is rate limited to a handful an hour. It is not a sending service, and it was never meant to be.
There are two separate problems here. Solve whichever you have.
Before you start
You need three things, and they take about five minutes:
- An account. Sign up free — 5,000 emails a month, no card.
- A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
- An API key. Create one under API Keys. Treat it like a password: it can send mail as you.
Put the key in your environment rather than your source. Supabase reads it from there in every example below.
Problem 1: auth emails are rate limited or look wrong
Point Supabase Auth at your own SMTP. In the dashboard, Project Settings → Authentication → SMTP Settings:
Host: smtp.pigeonify.com
Port: 587
Username: your-smtp-username
Password: your-smtp-password
Sender: auth@yourdomain.com
The rate limit goes away, the mail comes from your domain, and the templates become yours to edit under Authentication → Email Templates.
Do this before launch. Magic links that land in spam are indistinguishable from a broken login, and you will hear about it as "the site doesn't work".
Problem 2: your application needs to send its own email
Supabase does not do this at all. Use an Edge Function:
// supabase/functions/send-email/index.ts
import { serve } from "https://deno.land/std/http/server.ts"
serve(async (request) => {
const auth = request.headers.get("Authorization")
if (auth !== `Bearer ${Deno.env.get("FUNCTION_SECRET")}`) {
return new Response("Unauthorized", { status: 401 })
}
const { to, subject, html } = await request.json()
const response = await fetch("https://www.pigeonify.com/api/v1/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": Deno.env.get("PIGEONIFY_API_KEY")!,
},
body: JSON.stringify({ from: "hello@yourdomain.com", to, subject, html }),
})
if (!response.ok) {
return new Response(JSON.stringify(await response.json()), { status: 502 })
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
})
})
Set the secrets with supabase secrets set PIGEONIFY_API_KEY=....
Check the Authorization header. An Edge Function is a public URL. Without that check, anyone who finds it can send mail from your domain.
Triggering from the database
Sometimes the natural place is a row insert. pg_net makes an async HTTP call from Postgres:
create extension if not exists pg_net;
create or replace function notify_new_signup()
returns trigger
language plpgsql
security definer
as $$
begin
perform net.http_post(
url := 'https://<project>.supabase.co/functions/v1/send-email',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.function_secret')
),
body := jsonb_build_object(
'to', new.email,
'subject', 'Welcome aboard',
'html', '<p>Thanks for signing up.</p>'
)
);
return new;
end;
$$;
create trigger on_signup
after insert on public.profiles
for each row execute function notify_new_signup();
net.http_post is asynchronous, so it does not block the insert. That is the point — a trigger that waits on an HTTP call turns every signup into a transaction held open across the internet.
Be careful what you put in a trigger. It fires on every insert including backfills and migrations, and a one-off import of 10,000 rows will cheerfully email all 10,000 people.
Which to use
| Need | Use |
|---|---|
| Confirmations, magic links, resets | Supabase Auth with custom SMTP |
| Receipts, notifications, digests | Edge Function calling an API |
| Something on a row change | Trigger with pg_net → Edge Function |
| Marketing campaigns | A sending platform, not Supabase |
What to get right before production
Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.
Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.
Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.
Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.
Where to go next
- Supabase API reference — every parameter, including attachments and custom headers
- Transactional vs marketing email — why they need different handling
- Why emails land in spam — the things that actually cause it
If you are weighing providers, we publish honest comparisons that say where the other product is better.