DEV Community

mersiouiunes-sketch
mersiouiunes-sketch

Posted on • Originally published at mailboxtemp.com

Building a real-time disposable email service (Haraka + Redis + WebSockets)

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:

  1. Accept inbound mail on port 25 (and reject anything not addressed to a live inbox)
  2. Parse the message — including extracting one-time codes
  3. Store it briefly, then auto-delete it
  4. 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)
Enter fullscreen mode Exit fullscreen mode

### 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 a 550. 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 return OK.
  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);
    });
  };
Enter fullscreen mode Exit fullscreen mode

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]);
  }
Enter fullscreen mode Exit fullscreen mode

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:

  1. Sanitize server-side with DOMPurify before storing.
  2. Render inside a fully sandboxed iframesandbox="" with no allow-scripts, no allow-same-origin — using srcdoc. 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>`;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  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}$/);
  });
Enter fullscreen mode Exit fullscreen mode

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.

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