· 7 min read · by Marek
The doorbell pattern: inbound email behind a firewall
Live Radio Director is the control room behind an internet radio station. It schedules programmes, prepares playlists and runs the AI DJs on air. Every DJ has their own email address on the station's domain, and listeners write in: requests, dedications, stories from the night bus. Those messages are screened and land in the DJ's inbox within seconds, ready to be read out live.
The interesting part is where Live Radio Director runs. It sits on a private network with no public endpoint at all. Nothing on the internet can open a connection to it. That rules out the one delivery mechanism most email providers offer for inbound mail, and it is exactly the case MailFlo's inbound stream was built for. This post walks through the pattern we use to connect the two. We call it the doorbell.
Inbound email usually means a webhook
The standard inbound setup across transactional email providers is the same everywhere: point your MX record at the provider, give it a public HTTPS URL, and every message arrives as a POST. It works well for a production API on the open internet.
It does not work for anything that can't be reached from outside. On-premise software, a box in a studio rack, a home lab, a desktop app, a worker inside a locked-down VPC: none of them can accept that POST. MailFlo also refuses webhook URLs that resolve to private or internal addresses, at save time and again at delivery time, so pointing a webhook at an internal hostname isn't an option either. That guard protects every tenant on the platform.
Teams in this position usually end up opening a port, running a tunnel, or building a public relay that forwards mail inward. Each of those adds a moving part to operate and a new surface to defend, just to learn that an email exists.
The doorbell: ring from outside, answer from inside
A courier doesn't push a parcel through your wall. They ring the bell, and you open the door. The doorbell pattern splits inbound delivery the same way, into two separate jobs:
- The ring. Live Radio Director dials outto MailFlo's inbound WebSocket stream and keeps the connection open. When mail lands, MailFlo sends a tiny
inbound.email.receivedevent down that socket: an id, the sender and the subject. Never the body. - The answer. On the ring, Live Radio Director pulls. It pages through
GET /api/v1/inboundfrom its stored cursor, saves everything new, and moves the cursor forward. The pull is authenticated with the same project API key and is the only place email content ever comes from.
Both connections start inside the private network. The firewall only ever sees outbound traffic, so there is nothing to open, forward or expose.
MailFlo (public)
1 · Mail lands
Parsed & stored
dj@station.fm - MX points at MailFlo, one durable record per message
2 · Ring
Doorbell event
{ type, id, from_email, subject } - no body, no attachments
Your system (private)
Always open · Dial out
Outbound WebSocket
Opened from inside the network with the project API key
3 · Answer
Pull drain
GET /api/v1/inbound?cursor=… - fetch everything new, dedupe, store
Safety net
Timer and cron run the same drain
A missed ring costs at most one interval, never an email
Why a missed ring never loses an email
The doorbell is deliberately best-effort. Sockets drop, processes restart, networks blink. Instead of making the push carry delivery guarantees, the guarantee lives in the pull, and the doorbell only decides when the pull runs.
In Live Radio Director the same drain function has four callers: the doorbell, a 60-second timer inside the subscriber, a once-a-minute cron, and a "Sync now" button. If a ring is missed, the next tick picks the message up. The worst case is a minute of latency, never a lost email. A few details make that hold up under real traffic:
- Drain on connect. Every time the socket opens, including after a reconnect, the subscriber drains immediately to collect anything that arrived while it was away.
- Debounce the burst. Ten emails in quick succession produce ten rings but one drain. Rings are coalesced over half a second, and a single page fetch picks up the whole batch.
- Ring again if busy. A ring that arrives mid-drain sets a flag, and the drain loops once more when it finishes. A message that lands just after a page was read is never stranded until the next timer.
- One drain at a time, idempotent writes. Overlapping callers serialise on a row lock taken with
FOR UPDATE SKIP LOCKED, and every insert dedupes on the MailFlo email id. Running the drain twice is harmless by construction. - Overlap on resume. Mid-scan the drain follows MailFlo's
next_cursor. At the tail it resumes from the last seen timestamp minus two seconds, and the dedupe absorbs the overlap. - Reconnect with backoff and jitter. From one second up to a minute, with a ping every 30 seconds to keep idle connections warm through proxies.
- A visible heartbeat. The subscriber records when the socket connected and when it last heard a ring, so the station's settings page can show whether the doorbell is live or the integration is running on the timer alone.
What it looks like in code
A condensed version of the subscriber. The cursor helpers and storage are yours; everything MailFlo-specific is in the two URLs and the event type:
import WebSocket from "ws";
const STREAM = "wss://YOUR_INBOUND_HOST/v1/inbound/stream";
const auth = { Authorization: `Bearer ${process.env.MAILFLO_API_KEY}` };
let draining = false;
let ringAgain = false;
// The one delivery guarantee: page through everything newer than our cursor.
async function drain() {
if (draining) return void (ringAgain = true); // rang mid-drain: go once more
draining = true;
try {
do {
ringAgain = false;
let cursor = await loadCursor();
for (;;) {
const res = await fetch(
`https://console.mailflo.dev/api/v1/inbound?${cursor.query}`,
{ headers: auth },
);
const page = await res.json();
await saveEmails(page.data); // upsert on the MailFlo email id
cursor = await advanceCursor(page);
if (!page.has_more) break;
}
} while (ringAgain);
} finally {
draining = false;
}
}
function connect(attempt = 0) {
const ws = new WebSocket(STREAM, { headers: auth });
ws.on("open", () => {
attempt = 0;
drain(); // catch anything that landed while we were away
});
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString());
if (event.type === "inbound.email.received") drain(); // ding-dong
});
ws.on("close", () => {
const backoff = Math.min(1000 * 2 ** attempt, 60_000) + Math.random() * 500;
setTimeout(() => connect(attempt + 1), backoff);
});
}
connect();
setInterval(drain, 60_000); // the safety netSecurity comes with the shape
- Zero inbound attack surface. No listening port, no public URL, no signature verification endpoint to harden. The private system only makes outbound requests.
- One credential. The project API key authenticates both the stream and the pull. There is no separate webhook secret to rotate.
- Content only travels over the authenticated API. The ring carries an id and envelope metadata. Bodies and attachments are fetched on demand, over HTTPS, with the key.
- Structural tenant isolation. Each MailFlo project gets its own isolated stream hub, so a connection can only ever hear rings for the project whose key opened it.
Why you rarely get this out of the box
Most transactional email platforms treat inbound as a webhook feature and stop there. If your consumer can't take a POST, the answer is a tunnel or a relay you build and run yourself, plus a polling loop as your safety net.
MailFlo ships both halves of the doorbell as part of the product: a per-project WebSocket stream for the ring, and a cursor-based inbound API built for idempotent ingestion on your side. Your webhook keeps working alongside them. You pick the path per consumer: the webhook for public services, the doorbell for everything behind a firewall, and the same GET /api/v1/inbound source of truth underneath both. The launch post on push notifications for inbound email covers the stream itself in more detail.
Where else the doorbell fits
Live Radio Director is one shape of a common problem. The same pattern works for on-premise and self-hosted products, support desks inside corporate networks, desktop and CLI tools, local development without a tunnel, and IoT or edge devices that only ever dial out. If it can hold a WebSocket and make an HTTPS request, it can receive email in real time.
Receiving email somewhere a webhook can't reach?
Apply for early access and get inbound running on your own domain.
Request an invite