See all./smtp-handshake-to-sweep

From SMTP handshake to sweep — the Mailiy pipeline, honestly

Thu May 14 2026 00:00:00 GMT+0000 (Coordinated Universal Time)· 7 min

Why we did not pick Postfix

When a mail arrives at a Mailiy address it runs through a deliberately narrow pipeline. Instead of Postfix we use the Node smtp-server package. It accepts connections, checks the recipient against the Postgres mailbox table in the onRcptTo hook, and rejects unknown or expired addresses immediately with a 550. Nobody should pay for bandwidth just to receive mail we cannot deliver anyway.

Postfix would have given us more battle-tested queueing semantics, but at the cost of a second process, its own config language, and a maildir we would have to garbage-collect ourselves. For a service whose entire promise is "the mailbox dies in 60 minutes," that is a lot of moving parts to keep in sync with a row in Postgres.

Accept, queue, drain

Accepted mails are validated against the recipient tier's size and storage caps in the onData stream:

onData(stream, session, callback) {
  const tier = session.mailbox.tier
  const maxBytes = SIZE_LIMITS[tier]   // 5 MB anon, 10 MB free, up to 50 MB Pro
  // ... stream into a length-capped buffer
}

Once we have a complete RFC 822 string, it goes into a BullMQ queue called incoming-mail. The worker picks it up, parses with mailparser, writes the Message and its Attachments rows to Postgres, and fires one message.received event per registered webhook into a second queue (webhook-dispatch).

Running both stages through queues instead of inline in the SMTP handler is intentional: if the worker stalls for 30 seconds, SMTP keeps accepting — the mails sit in Redis and get drained afterwards. That is much friendlier to senders than a 4xx tempfail at the wrong moment, and it means we can do non-trivial processing (virus scan, attachment indexing) without the sender ever waiting.

The delete side is just a loop

The expiry side is a plain loop: a periodic expiry-sweep job runs every 60 seconds and drops every mailbox whose expiresAt has passed.

const expired = await prisma.mailbox.findMany({
  where: { expiresAt: { lt: new Date() } },
  select: { id: true },
})
await prisma.mailbox.deleteMany({ where: { id: { in: expired.map((m) => m.id) } } })

Prisma cascades the delete to Messages and Attachments rows in the same transaction. There is no second copy anywhere — no backup, no S3 lifecycle, no warm "archive". Once the sweep runs, the address is gone, and with it everything that was ever sent to it.

We thought about a soft-delete with a tombstone for forensic purposes. We decided against it. The whole product promise is "after expiry there is nothing to recover" — and you cannot honestly say that if there is a row somewhere with deletedAt set and the body untouched.

What this means for the REST API

Because the same Postgres instance is read by the API and written by the SMTP ingest, the propagation delay between an incoming mail and GET /v1/mailbox/:id/messages returning it is sub-second. For Playwright and Cypress test flows that is the difference between a one-second poll loop and a flaky test.

What can still go wrong

Two real failure modes, both visible in the design:

  1. Redis loss — if Redis disappears between SMTP accepting a mail and the worker writing it to Postgres, that mail is gone. We use Redis persistence (AOF) to make this rare, but it is not impossible.
  2. Clock skew on the sweep node — if the sweeper's clock drifts ahead of Postgres, mailboxes can be deleted up to that drift early. NTP keeps this bounded to milliseconds, but it is worth being explicit about.

Neither of these is hidden from the user. A mail that never lands in the inbox is the same observable failure as a 60-minute address that expired — you generate a new one and move on. The whole product is built around that fact.


Try it now → Generate a disposable address on the mailiy homepage — one click, ready in under two seconds, no signup, no account.

Free. No signup.

Generate a disposable email address in under two seconds — no account, no trackers, no compromises.

Generate an email