Receive inbound email in Supabase Edge Functions

Integration~15 min setup

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

EmailConnect
Receive & parse
Edge function
Ingest
Postgres
Store
Storage
Attachments

How it works

1

An email arrives at your alias — your own domain, or the free system alias EmailConnect gives every account.

2

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).

3

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 = false

Or 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:

  1. Create an account and log in to the EmailConnect dashboard.
  2. 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.
  3. Create an alias.
  4. Add a webhook with the function URL from Step 1: https://<project-ref>.supabase.co/functions/v1/ingest-email.
  5. Optionally (Maker+): under Custom headers, add X-Webhook-Key with 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-attachments bucket, 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:

FieldWhat it is
message.sender / message.recipientName + email of who sent it and which alias received it
message.subject / message.dateSubject line and send timestamp
message.content.text / .html / .markdownPlain 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.isSpamSpam verdict (Maker+)
classification.typenormal, transactional, marketing, notification, or automated
domainId / aliasIdTop-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 free

Questions about wiring this up? Reach out at hello@emailconnect.eu.