Most "temp mail" sites share one annoyance: you sign up for something, switch to the throwaway inbox, and then mash refresh waiting for the verification email. I wanted to build one where messages just appear the
instant they arrive — and where developers could drive the whole thing from code for automated testing.
This is a write-up of how MailboxTemp works under the hood: a disposable email service with a real-time inbox and a small open API. No framework magic, just Node, an SMTP server, a queue, and
WebSockets.
## The problem
A disposable email service has to do something most web apps never touch: receive real SMTP mail from the public internet, parse it safely, and get it onto a user's screen fast. That breaks into four jobs:
- Accept inbound mail on port 25 (and reject anything not addressed to a live inbox)
- Parse the message — including extracting one-time codes
- Store it briefly, then auto-delete it
- Push it to the open browser tab in real time
## The architecture
Internet (SMTP :25)
│
Haraka ──► Redis queue ──► Worker ──► Postgres
│ │
(reject unknown └──► WebSocket ──► Browser
recipients early)
### 1. Receiving mail with Haraka
Haraka is a fast SMTP server written in Node, which made it a natural fit. A small plugin does two things:
-
hook_rcpt— when a sender provides a recipient, we check Redis for a live inbox key (inbox:<address>). If it doesn't exist, we reject at RCPT time with a550. This is important: you reject before accepting the message body, so you never waste bandwidth on mail for inboxes that don't exist, and you can't be used as an open relay. -
hook_queue— for accepted mail, push the raw message onto a Redis list and returnOK.
exports.hook_rcpt = function (next, connection, params) {
const address = params[0].address().toLowerCase();
client.get(`inbox:${address}`, (err, val) => {
if (err || !val) return next(DENY, 'Mailbox not found or expired');
return next(OK);
});
};
Keeping the "does this inbox exist?" check in Redis (not Postgres) matters — it's on the hot path for every recipient of every connection, including the flood of spam every port-25 server receives.
### 2. The queue + worker
Haraka's only job is to accept and enqueue. A separate worker process does the slow work — parsing, OTP extraction, storage, broadcasting — so the SMTP path stays fast and a parsing error can never block mail acceptance.
while (true) {
const job = await redis.brpop('email:queue', 0); // blocking pop
if (job) await processEmail(job[1]);
}
Parsing uses mailparser. One-time codes are extracted with a set of heuristics over the text and subject (the classic "your code is 123456" patterns), so the UI can surface
the code without the user opening the email.
### 3. The security bit everyone gets wrong: rendering untrusted HTML email
This is the part I want to flag, because it's where a lot of webmail-style projects quietly introduce an XSS hole. HTML email is attacker-controlled input. If you drop it into the DOM, you've handed every sender
script execution on your origin.
Two layers:
- Sanitize server-side with DOMPurify before storing.
-
Render inside a fully sandboxed iframe —
sandbox=""with noallow-scripts, noallow-same-origin— usingsrcdoc. Even if something slipped past sanitization, it executes in a powerless, origin-less sandbox.
const bodyMarkup = bodyHtml
? `<iframe sandbox="" srcdoc="${escapeForAttr(bodyHtml)}"></iframe>`
: `<div class="text">${escape(bodyText)}</div>`;
Belt and suspenders. Never trust one layer for untrusted markup.
### 4. Real-time delivery with WebSockets
When the worker stores a message, it broadcasts to anyone subscribed to that inbox over a WebSocket. The browser tab subscribed to inbox:<address> gets the new message pushed and renders it immediately — no polling, no
refresh. There's a polling fallback for when the socket drops, but the happy path is instant.
### 5. Auto-expiry
Every inbox has a TTL. A scheduled job purges expired inboxes, their messages, and any stored attachments. Crucially it deletes the attachment blobs from object storage before the DB rows cascade away — otherwise you
orphan files forever. (That ordering bug is easy to write and annoying to notice.)
## The developer angle: an API for email testing
Here's the part I think is genuinely useful beyond "throwaway signups." If your app sends email — welcome messages, verification codes, password resets — testing those flows usually means a human checking a shared
mailbox. That's slow, flaky, and impossible to parallelize.
With a disposable inbox per test, runs are isolated and self-cleaning. So I published a tiny zero-dependency client:
npm install mailboxtemp
const { MailboxTemp } = require('mailboxtemp');
const mt = new MailboxTemp();
test('signup sends a verification code', async () => {
const { address } = await mt.createInbox();
await signUp(address); // your code under test
const code = await mt.waitForOtp(address, { timeoutMs: 30000 });
expect(code).toMatch(/^\d{6}$/);
});
waitForOtp just polls the inbox until an email with a detected code arrives, then returns the code. No shared mailbox, no manual checking, no cross-test collisions.
- 📦 npm: mailboxtemp
- 🛠 source: github.com/mersiouiunes-sketch/mailboxtemp-node
### A side effect: detecting disposable domains
Building this meant keeping a list of disposable domains around, which is also the exact thing the other side wants — people running signups who'd rather not accept throwaway inboxes. So I put together a small free
disposable email domain checker that matches an address (or a whole pasted list) against the public-domain disposable-email-domains list — entirely
client-side, nothing uploaded. It's the mirror image of the service: one hands out throwaway inboxes, the other flags them.
## Honest limitations
- It's receive-only — inboxes accept mail but can't send (this also keeps the service from being abused for spam).
- It won't bypass phone verification — that's a different signal entirely.
- Brand-new domains occasionally get blocked by big sites, which is why there's a pool of domains to rotate through.
- It's not anonymity in the network sense — it hides your email address, not your IP.
## Takeaways
- Reject unknown recipients at RCPT time, from a fast store (Redis), not after accepting the body.
- Keep SMTP acceptance and message processing in separate processes so parsing can't block mail.
- Treat HTML email as hostile: sanitize + fully sandboxed iframe, both layers.
- Delete object-storage blobs before the DB rows that reference them.
If you want to poke at it, the live service is at mailboxtemp.com, and there's a deeper developer writeup on using disposable email for automated
testing.
Happy to answer questions about the SMTP/queue side in the comments.
Top comments (0)