Email in and out of your Supabase app.
Inbound mail becomes rows in an emails table. Outbound mail is one Edge Function call.
Two functions, one migration, no servers.
MailKite docs · Supabase Edge Functions · mailkite.dev
| File | What it does |
|---|---|
supabase/functions/mailkite-inbound/index.ts |
Receives the MailKite webhook: verifies the HMAC signature, inserts the email into emails. |
supabase/functions/send-email/index.ts |
Sends email (e.g. a welcome email) via mk.send(). JWT-protected — only your signed-in users can call it. |
supabase/migrations/20260703120000_emails.sql |
The emails table + RLS (service-role writes, users read their own mail). |
supabase/config.toml |
Per-function JWT settings — verify_jwt = false for the webhook (MailKite can't send a Supabase JWT; the signature check is the auth). |
Paste this into Lovable, Bolt, Cursor, Claude Code, or any coding agent:
Add MailKite email to this Supabase app so it receives email as rows in an
`emails` table and can send email from an Edge Function. Use the reference
implementation at https://github.com/mailkite/supabase-mailkite — copy its two
Edge Functions (mailkite-inbound with verify_jwt = false in config.toml,
send-email with JWT on), its emails-table migration with RLS, and its README
steps: verify my domain with MailKite first, set the MAILKITE_API_KEY and
MAILKITE_WEBHOOK_SECRET secrets, deploy the functions, then point my MailKite
domain's webhook at the deployed mailkite-inbound URL. Use the `mailkite` npm
package (npm: specifier) — verify webhooks with MailKite.verifyWebhook(), send
with mk.send(). Do not hand-roll HMAC or raw fetch calls.
Prereqs: a Supabase project, the Supabase CLI (supabase link'd to it), and a MailKite account (free).
MailKite gates receiving and sending until your domain proves control via DNS (inbound = MX, outbound = SPF + DKIM), so start the DNS clock before anything else:
npx @mailkite/cli signup # or: login
npx @mailkite/cli domains add mail.yourapp.com # prints the DNS records to add
# …add the records at your DNS provider, then:
npx @mailkite/cli domains verify <dom_id>(Or do it in the dashboard — Domains → Add domain.)
git clone https://github.com/mailkite/supabase-mailkitethen copy supabase/functions/, supabase/migrations/, and the two
[functions.*] blocks from supabase/config.toml into your own supabase/
directory (or work from the clone directly).
supabase db pushnpx @mailkite/cli secret get # your webhook signing secret (whsec_…)
supabase secrets set MAILKITE_API_KEY=mk_live_…
supabase secrets set MAILKITE_WEBHOOK_SECRET=whsec_…(API keys live in the MailKite dashboard → API keys.)
supabase functions deploy mailkite-inbound
supabase functions deploy send-emailconfig.toml already sets verify_jwt = false for mailkite-inbound. If you
deploy without that config, pass --no-verify-jwt — otherwise Supabase rejects
every webhook delivery with 401 before your code runs.
npx @mailkite/cli webhook set <dom_id> https://<project-ref>.supabase.co/functions/v1/mailkite-inbound
npx @mailkite/cli webhook test <dom_id> # sends a signed test event end-to-endOr from code:
import { MailKite } from "mailkite";
const mk = new MailKite(process.env.MAILKITE_API_KEY);
await mk.setWebhook("<dom_id>", {
url: "https://<project-ref>.supabase.co/functions/v1/mailkite-inbound",
});Email anything@mail.yourapp.com, then:
select from_addr, subject, received_at from emails order by received_at desc;There's your inbox, in Postgres.
Edit FROM in send-email/index.ts to an address on your verified domain, then
call it from your app — supabase.functions.invoke sends the user's JWT
automatically:
const { data, error } = await supabase.functions.invoke("send-email", {
body: {
to: user.email,
subject: "Welcome to YourApp",
html: "<p>You're in. Reply to this email and it lands in your database.</p>",
},
});Server-side (a database webhook, a cron job, another function) works the same
way — or call MailKite directly with mk.send().
inbound someone@mail.yourapp.com ──▶ MailKite ──▶ POST /functions/v1/mailkite-inbound
(signed) verify signature → insert into emails
outbound your app ── supabase.functions.invoke("send-email") ──▶ mk.send() ──▶ recipient
- Signature verification — every delivery carries an
x-mailkite-signatureheader (HMAC-SHA256 over the raw body).MailKite.verifyWebhook()checks it locally, timing-safe, with replay protection. Never skip it on a public URL. - RLS — the table has no insert policy; only the function's service-role
client writes. Signed-in users can
selectmail whereto_addrmatches their own email. Adjust the read policy to your app's shape. - Retries — return non-2xx and MailKite redelivers; the function treats a duplicate-id insert as already-processed and acknowledges it.
supabase start
echo "MAILKITE_WEBHOOK_SECRET=whsec_…" >> supabase/functions/.env
echo "MAILKITE_API_KEY=mk_live_…" >> supabase/functions/.env
supabase functions serve --env-file supabase/functions/.envExpose the local URL (e.g. ngrok http 54321) and point webhook set at
https://<tunnel>/functions/v1/mailkite-inbound to receive real mail locally.
| Symptom | Fix |
|---|---|
| Webhook deliveries show 401 in MailKite | verify_jwt is still on — redeploy with the config.toml from this repo (or --no-verify-jwt). |
Function logs bad signature |
Wrong or rotated secret — re-run npx @mailkite/cli secret get and reset MAILKITE_WEBHOOK_SECRET. |
| Nothing arrives at all | Domain not verified yet — npx @mailkite/cli domains verify <dom_id> and check the MX record. |
send-email returns 401 |
Caller has no Supabase JWT — invoke it via supabase.functions.invoke from a signed-in session. |
send-email returns a MailKite error |
Usually "domain not verified" for the FROM address — finish step 1. |