Receive inbound email in Supabase Edge Functions
Emails to you@yourdomain.com arrive in your Supabase project as structured JSON — parsed and stored in Postgres, with attachments in Supabase Storage. No mail server, no IMAP polling, and the core flow runs on the free tier of both products.
What you'll build
How it works
An email arrives at your alias — your own domain, or the free system alias EmailConnect gives every account.
EmailConnect parses it — text, HTML, links, attachments — and runs spam, SPF, DKIM, and DMARC checks server-side, in the EU (spam analysis on Maker+ plans).
The result is POSTed as one JSON payload to your edge function URL.
The pattern we see most across live aliases is exactly this: an alias pointed at https://<project-ref>.supabase.co/functions/v1/ingest-email. This guide is that pattern, hardened.
Prerequisites
- A Supabase project + Supabase CLI logged in and linked
- An EmailConnect account (free tier: 3 aliases)
- Optionally, a domain you control
Tip
No domain handy? Every EmailConnect account gets a system alias that works immediately: <your-account-id>+anything@user.emailconnect.eu — the ID prefix is shown in your dashboard. You can finish this whole guide before touching DNS.
Step 1 — Create the edge function
Scaffold the function with the Supabase CLI:
supabase functions new ingest-email This creates supabase/functions/ingest-email/index.ts. Start with the minimal version below — just enough to receive a payload and log it. We'll build it up into the full version over the next few steps.
// supabase/functions/ingest-email/index.ts
import { withSupabase } from 'npm:@supabase/server@^1'
export default {
fetch: withSupabase({ auth: 'none' }, async (req) => {
const payload = await req.json()
console.log('email from', payload.message.sender.email, '—', payload.message.subject)
return Response.json({ ok: true })
}),
}auth: 'none' matters here: EmailConnect is an external service, not a logged-in Supabase user, so it doesn't send a Supabase JWT with its webhook requests. Left at the default, Supabase's platform auth would reject every delivery with a 401 before your code ever runs. auth: 'none' tells the function to accept unauthenticated requests — you bring your own auth instead, which is exactly what Step 2 does.
There are two ways to disable Supabase's platform JWT check — pick whichever fits your workflow.
In config.toml
# supabase/config.toml
[functions.ingest-email]
verify_jwt = falseOr as a deploy flag
supabase functions deploy ingest-email --no-verify-jwt Either way, after deploying, your function is reachable at https://<project-ref>.supabase.co/functions/v1/ingest-email.
Step 2 — Secure the endpoint
Disabling the platform JWT check means your function is now open to the internet. Here are three ways to lock it back down, in increasing order of rigor.
The query-param key
The most common thing we see in the wild is a key in the URL: …/ingest-email?key=abc123. It works — but URLs end up in logs, browser history, and analytics, so your secret leaks by default.
A custom header (recommended)
EmailConnect webhooks support custom HTTP headers on Maker plans and above — on the free plan, the query-string key above is your option. Set a secret Supabase can read at runtime:
supabase secrets set EMAILCONNECT_WEBHOOK_KEY=$(openssl rand -hex 32)Check it in the function:
const key = req.headers.get('x-webhook-key')
if (key !== Deno.env.get('EMAILCONNECT_WEBHOOK_KEY')) {
return new Response('Unauthorized', { status: 401 })
} …and configure header X-Webhook-Key: <the same value> on the EmailConnect webhook (Step 3).
Webhook signing (production)
For production traffic, EmailConnect supports Standard Webhooks HMAC-SHA256 signatures with timestamped replay protection — verify with any Standard Webhooks-compatible library. It's the strongest of the three tiers; we won't implement it inline here, but the linked guide has full working verification code.
Step 3 — Point an alias at your function
With the function deployed and secured, wire an alias to it in the EmailConnect dashboard:
- Create an account and log in to the EmailConnect dashboard.
- Use the system alias created for you at registration — or set up your own domain: add it, then set the MX and
verify-ec=TXT records at your DNS provider. - Create an alias.
- Add a webhook with the function URL from Step 1:
https://<project-ref>.supabase.co/functions/v1/ingest-email. - Optionally (Maker+): under Custom headers, add
X-Webhook-Keywith the secret from Step 2.
Step 4 — Store emails in Postgres
Run this in the Supabase SQL editor, or save it as a migration:
create table public.inbound_emails (
id uuid primary key default gen_random_uuid(),
received_at timestamptz not null default now(),
sent_at timestamptz,
from_email text not null,
from_name text,
to_email text not null,
subject text,
text_body text,
html_body text,
spam_score numeric,
is_spam boolean,
classification text,
alias_id text,
attachment_paths text[] not null default '{}',
payload jsonb not null
);
-- RLS on, no policies: only the service role (your function) can touch it
alter table public.inbound_emails enable row level security; Insert from the function via ctx.supabaseAdmin — the service-role client withSupabase hands you, with no keys to manage:
const { error } = await ctx.supabaseAdmin.from('inbound_emails').insert({
sent_at: message.date,
from_email: message.sender.email,
from_name: message.sender.name,
to_email: message.recipient.email,
subject: message.subject,
text_body: message.content.text,
html_body: message.content.html,
spam_score: payload.spam?.score,
is_spam: payload.spam?.isSpam,
classification: payload.classification?.type,
alias_id: payload.aliasId,
attachment_paths: attachmentPaths,
payload,
})Step 5 — Handle attachments
Create a private Storage bucket named email-attachments (Dashboard → Storage → New bucket).
Inline attachment delivery (base64, up to a size cap) is included on the free plan; S3 offloading for larger files — where the payload carries a download URL instead of content — requires Maker or higher.
Attachments arrive in the payload one of two ways: base64-encoded inline for small files, or as S3 download URLs on Maker+ plans for larger ones — see attachment processing for exact size cutoffs. Handle the inline case:
import { decodeBase64 } from 'jsr:@std/encoding@^1/base64'
const attachmentPaths: string[] = []
for (const att of message.attachments ?? []) {
if (!att.content) continue // offloaded delivery — store att.url instead
const path = `${crypto.randomUUID()}/${att.filename}`
const { error } = await ctx.supabaseAdmin.storage
.from('email-attachments')
.upload(path, decodeBase64(att.content), { contentType: att.contentType })
if (!error) attachmentPaths.push(path)
}The complete function
Putting Steps 1 through 5 together — typed payload interfaces, the header check, the attachment loop, the Postgres insert, and error handling — this is the whole thing:
// supabase/functions/ingest-email/index.ts
import { withSupabase } from 'npm:@supabase/server@^1'
import { decodeBase64 } from 'jsr:@std/encoding@^1/base64'
interface EmailAttachment {
filename: string
contentType: string
size: number
content?: string // base64 (inline delivery)
url?: string // S3 download URL (offloaded delivery, Maker+)
}
interface EmailWebhookPayload {
domainId: string
aliasId: string
message: {
date: string
subject: string
sender: { name: string | null; email: string }
recipient: { name: string | null; email: string }
content: { text: string; html: string; markdown?: string }
attachments?: EmailAttachment[]
}
spam?: { score: number; isSpam: boolean }
classification?: { type: string }
}
export default {
fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
// EmailConnect sends this custom header — configure it on the webhook
const key = req.headers.get('x-webhook-key')
if (key !== Deno.env.get('EMAILCONNECT_WEBHOOK_KEY')) {
return new Response('Unauthorized', { status: 401 })
}
const payload: EmailWebhookPayload = await req.json()
const { message } = payload
const attachmentPaths: string[] = []
for (const att of message.attachments ?? []) {
if (!att.content) continue // offloaded delivery — store att.url instead
const path = `${crypto.randomUUID()}/${att.filename}`
const { error } = await ctx.supabaseAdmin.storage
.from('email-attachments')
.upload(path, decodeBase64(att.content), { contentType: att.contentType })
if (!error) attachmentPaths.push(path)
}
const { error } = await ctx.supabaseAdmin.from('inbound_emails').insert({
sent_at: message.date,
from_email: message.sender.email,
from_name: message.sender.name,
to_email: message.recipient.email,
subject: message.subject,
text_body: message.content.text,
html_body: message.content.html,
spam_score: payload.spam?.score,
is_spam: payload.spam?.isSpam,
classification: payload.classification?.type,
alias_id: payload.aliasId,
attachment_paths: attachmentPaths,
payload,
})
if (error) {
console.error('insert failed:', error)
return new Response('Database error', { status: 500 })
}
return Response.json({ ok: true })
}),
}Redeploy: supabase functions deploy ingest-email --no-verify-jwt
Test the pipeline
Send a real email to your alias, then check three places:
- The function's logs — Dashboard → Edge Functions → ingest-email → Logs
- Postgres — run
select from_email, subject, received_at from inbound_emails order by received_at desc;in the SQL editor - The
email-attachmentsbucket, if the email had attachments
EmailConnect keeps its own delivery log too — every webhook attempt shows the request and response, which is the fastest way to tell whether a failure is on EmailConnect's side or your function's.
The payload, at a glance
The complete field reference is linked below; here's just the fields the code above touches:
| Field | What it is |
|---|---|
message.sender / message.recipient | Name + email of who sent it and which alias received it |
message.subject / message.date | Subject line and send timestamp |
message.content.text / .html / .markdown | Plain text, HTML, and Markdown (Maker+) versions of the body |
message.attachments[] | Each with base64 content (inline) or an S3 url (offloaded, Maker+) |
spam.score / spam.isSpam | Spam verdict (Maker+) |
classification.type | normal, transactional, marketing, notification, or automated |
domainId / aliasId | Top-level IDs to correlate deliveries back to your account |
Full field-by-field reference, including integrity hashes and virus-scan results: What's in your webhook payload.
FAQ
Why do I have to disable JWT verification?
Because EmailConnect is an external caller and doesn't hold a Supabase JWT; you replace platform auth with your own header check. verify_jwt = false only disables Supabase's gate — the function still rejects anything without your X-Webhook-Key.
Is a key in the URL good enough?
It works, but a custom header keeps the secret out of logs — and webhook signing beats both. See the three tiers in Step 2 for the tradeoffs; headers and signing are Maker+ features, so on the free plan the query key is the pragmatic choice.
What about large attachments?
Small files arrive inline as base64; larger ones arrive as S3 download URLs on Maker+ plans. See attachment processing for exact size cutoffs and the async upload flow.
Does this work on free plans?
Yes — the core receive, parse, and store flow works on EmailConnect's free tier (3 aliases) plus Supabase's free tier. Inline attachments are included on the free plan; S3 attachment offloading, spam scores, and markdown conversion need a Maker plan; virus scanning is Business+.
Can I filter which senders reach my function?
Yes — alias rules and sender-domain whitelisting filter before delivery, so junk never hits your function. See alias rules and whitelisting sender domains.
Get started
Create a free EmailConnect account, point an alias at your function, and start receiving structured email in Postgres today.
Get started freeRelated
Questions about wiring this up? Reach out at hello@emailconnect.eu.