How I built a disposable email service in TypeScript
Temp-mail services are everywhere, but most of them are slow, ad-stuffed, and
feel like they haven't been touched since 2012. I wanted to know how hard it
actually is to build a good one — so I built Mailfly. Here's the architecture,
the parts that were harder than expected, and what I'd tell anyone attempting
the same thing.
The core idea
A disposable email service has three jobs:
- Hand someone a throwaway address instantly (no signup).
- Receive real email sent to that address and show it fast.
- Delete everything when the address expires.
That's it. The hard part isn't the concept — it's receiving mail reliably
without becoming a spam relay, and showing it to the user in near-real-time.
Receiving mail: a custom SMTP server
The internet delivers email over SMTP, so to receive mail you need to run an
SMTP server that the world can reach on port 25. I used the smtp-server
library and built a receive-only server — no authentication, and crucially
not an open relay.
Two rules keep it safe:
- Reject any recipient whose domain isn't ours (otherwise you're an open relay that spammers will abuse within hours).
- Reject any recipient that isn't a live inbox (return SMTP 550).
When a valid message arrives, the server parses it, pulls out the OTP code and
any links, caps the size, stores attachments as base64, and publishes an event
to Redis.
Showing mail instantly: SSE over polling
Most temp-mail sites poll: the browser asks "any mail yet?" every few seconds.
It's wasteful and feels laggy. I used Server-Sent Events instead.
The flow: SMTP server receives mail -> publishes to a Redis channel -> the API
is subscribed -> it pushes the message down an open SSE connection to the
browser -> the inbox updates the instant the mail lands.
The subtle bug I hit: my SSE effect depended on the whole inbox object, which
got recreated on every timer tick, so the connection reconnected constantly
and flooded the server logs. Fixing the dependency to just the inbox ID and
token solved it. Lesson: with long-lived connections, be very deliberate about
what triggers a reconnect.
Storage and expiry
Postgres holds inboxes, messages, and attachments. The nice trick is
ON DELETE CASCADE: when a background job deletes expired inboxes once a
minute, all their messages and attachments vanish automatically. No orphans,
no manual cleanup, and it matches the privacy promise — when the timer hits
zero, the data is genuinely gone.
The stack, end to end
- SMTP receiver: TypeScript + smtp-server
- API: Fastify, with SSE endpoints and an OTP long-poll fallback
- Data: PostgreSQL + Redis (pub/sub for the live inbox)
- Front-end: Next.js, SSR, 5 languages
- Infra: Docker Compose, Caddy for automatic TLS, one VPS
Keeping it all in one language meant I could share code (OTP extraction,
local-part generation) between the SMTP server, the API, and the SDKs.
The hardest non-code problem: blocklists
Writing the service was the easy part. Keeping disposable domains working is
the ongoing battle. Sites block known temp-mail domains, and if your domain
ends up on a spam blocklist, deliverability drops. My approach: keep the brand
domain clean (never use it for public throwaway inboxes), rotate disposable
domains, and keep inbox lifetimes short so the surface area stays small.
Should you build one?
As a learning project — absolutely. You'll touch SMTP, real-time transport,
queue/pub-sub, and infra/TLS in one shot. As a business, the moat is operational
(deliverability, abuse handling), not the code. The code you can write in a
couple of weeks; staying unblocked is forever.
If you want to see the live inbox in action, it's at https://mailfly.email.
The free tier is open and there's an API + JS/Python SDKs if you need throwaway
inboxes in your tests.
Happy to answer architecture questions in the comments.
Top comments (0)