Back

Building a Temporary Email Service

By Pablo Fernandez15 min read

A temporary email site is a strange thing to put on the internet. Anyone can open it, nobody signs up, and the address it hands out accepts mail from every server in the world. People use it for the most sensitive mail they get, the sign-up code and the password reset link, from a site they don't trust yet. And the people who want to abuse it have every reason to try: an anonymous inbox is a free file drop, a way to get a TLS certificate for someone else's domain, and a target for whoever wants to read other visitors' codes.

I built one. Buzontemporal is a Spanish temporary email site, now live in 28 languages: you open the page, an address is waiting, and whatever is sent to it shows up in real time for the next 60 minutes, which you can renew. The site itself is small, a Next.js app and a Bun API. What's behind it is Mailingest, my Rust mail platform, which has temporary mailboxes built in.

The Buzontemporal home: a generated address on correix.com with Copy, Renew, Choose a name and New mailbox, and an inbox with one unread confirmation code
The home with a fresh mailbox. The address is on correix.com, not on the brand's domain, and the message arrived without a reload.

So the question this post answers is mostly about the platform, not the site: what does a mail system have to do so that an anonymous, disposable inbox is safe to hand out, and cheap enough per message that a flood of spam doesn't take it down?

The short answer is that a temporary inbox should do less than a real one, on purpose, and every "less" is a security decision as much as a performance one.

Before that, the header, because it's how most readers get here. Under the wordmark there's a small "Powered by Mailingest" line that links to this post, and once after the page loads a pixel-art postman walks in, pushes the wordmark out of the header, and hops onto "Mailingest" on his way. This is the same code as the site, with the same timings:

Powered byMailingest
Buzontemporal's header. One requestAnimationFrame loop drives everything from the postman's position: the squash, the envelopes, the hop. Click the wordmark to play it again.

There's no animation library behind it. Every value comes from his position on one clock: the wordmark squashes against his hands and then follows them, held at a slight squash that pulses with his steps, and envelopes fall from his satchel, bounce once and fade. When his body is over "Mailingest", his walking clock stops for 0.66 seconds. He hops down onto the word on a short arc, and the word squashes under him on a damped spring before he hops back and keeps pushing. The hop's height is measured from where the line actually sits when the run starts, so if a layout puts the line somewhere else, he skips it. Under prefers-reduced-motion it never plays on its own.

Who talks to whom

The visitor's browser never holds a Mailingest API credential. It talks to www.buzontemporal.com, one origin where Traefik sends /rpc/* to the API (core-api) and everything else to the web, so the session cookie is first-party and there's no CORS. core-api talks to Mailingest over the internal network with an API key, through the same /v1/temp_mailboxes endpoints any account can use. The mail itself arrives at Mailingest on port 25, for addresses on a different set of domains.

The one thing the browser gets from Mailingest is a ticket for live updates. New mail is pushed by Mailingest's Centrifugo at rt.mailingest.com, and core-api asks Mailingest for a connection token on the visitor's behalf. The token is subscribed server-side to one channel, temp:<mailbox id>, and lives at most an hour, never past the mailbox. It can't list, read or delete anything: it only receives notices for that one mailbox.

visitor's browser ◀──── live notices, one channel ────┐ │ signed cookie, buzontemporal.com only │ uni_sse ▼ │┌────────────────────┐ API key ┌────────────┴───────┐│ Buzontemporal │ ────────────────▶ │ Mailingest ││ web and core-api │ ◀──────────────── │ /v1/temp_mailboxes │└────────────────────┘ list · message │ Centrifugo (rt.) │ · channel token └─────────▲──────────┘ │ SMTP :25 mail for the mailbox domains, never the brand domain
The browser reads its mail through buzontemporal.com; the only thing it opens on Mailingest is a live channel for its own mailbox.

The split between the brand and the mailbox domains is the first security decision, and it's about reputation more than secrets. Sites block temporary email domains. The addresses are on correix.com today, and when it lands on a blocklist, the plan is to retire it and add another. None of that touches buzontemporal.com, its cookie or its search ranking.

Keeping the browser away from Mailingest's API also shrinks what a stolen cookie is worth. Mailingest does have a public visitor API (temp_api) with its own tokens, and Buzontemporal doesn't use it. The cookie holds the mailbox's id, address and expiry, signed with HMAC-SHA256 and checked in constant time, with no session table behind it. It opens one visitor's mailbox, and nothing else on the mail platform.

The address is the password

On a temporary inbox site, knowing an address is the same as being able to read it, because there's no login. So the address has to be hard to guess, and some addresses must never be handed out at all.

When a visitor doesn't pick a name, Mailingest generates one in the style of go-haikunator, an adjective, a noun and eight random characters, from the operating system's random source:

crates/temp_mail/src/names.rs
pub fn generate() -> String {
    // OsRng: this local-part IS the mailbox address. A predictable generator lets
    // someone enumerate or pre-claim addresses others are about to be handed.
    let mut rng = rand::rngs::OsRng;
    let adj = *ADJECTIVES.choose(&mut rng).expect("non-empty");
    let noun = *NOUNS.choose(&mut rng).expect("non-empty");
    let token: String = (0..TOKEN_LEN)
        .map(|_| TOKEN_CHARS[rng.gen_range(0..TOKEN_CHARS.len())] as char)
        .collect();
    format!("{adj}-{noun}-{token}")
}

The two words make it something you can read out loud, and the eight characters from a-z0-9 carry the weight: 36 to the eighth power is about 2.8 trillion tokens for each pair of words. Someone guessing addresses on our domains doesn't find a visitor's mailbox by trying.

A visitor can also ask for a name, and that's where the more interesting refusal lives. Some local parts are reserved: the RFC 2142 role mailboxes (postmaster, abuse, hostmaster…) and the five addresses a certificate authority may email to prove you control a domain (admin, administrator, webmaster, hostmaster, postmaster, from section 3.2.2.4.4 of the CA/Browser Forum Baseline Requirements). On a site where anyone can claim any free name, whoever holds admin@ on one of our domains could get a TLS certificate for it. So Mailingest refuses them before it even looks at the database.

Creating a mailbox is the one unauthenticated write, so on Buzontemporal it costs a Turnstile check, verified by core-api, and it's counted: 10 new mailboxes per client address per hour, and 120 per minute for everyone together. That last limit matters because Mailingest caps live mailboxes per account and every visitor shares Buzontemporal's account; a flood of creations from many addresses must not use them all up. Every other call is capped at 120 a minute per address. Calls to Mailingest go through a bulkhead (200 in flight, the rest refused rather than queued) and a circuit breaker that opens after five failures in a row, where a 4xx from Mailingest doesn't count as one. Under load, answering "no" quickly is what keeps the rest working. temp_api follows the same rule for its own claim endpoint, with one more: when its counter can't be read, the claim is refused, not let through.

Whose mail it is

The worst failure for a site like this isn't spam, it's one person reading another person's codes. Mailingest had a version of that bug, and the fix shaped the design.

A temporary domain in Mailingest is an ordinary domain with mode = 'temp', owned by an account. A review in October found that any account could mark its own domain as offered on the public visitor site. The public site would then hand that domain's addresses to anonymous visitors, and the owner, who controls the domain's MX and its inbound rules, could attach a webhook and receive every visitor's sender, subject and preview. That's the one-time codes and reset links, delivered to a stranger.

Two changes close it. Only a platform administrator can offer a domain on the public site now, and a visitor's mailbox never feeds the domain owner's webhook, whatever rules the owner sets:

crates/temp_mail/src/lib.rs
// A visitor's mailbox is the visitor's: the domain's webhook never sees it.
let rule = rule.filter(|_| mailbox.owned_by_account);

Every mailbox has exactly one owner, a visitor's token or an account, enforced by a database CHECK, and the visitor token is compared in constant time after hashing both sides, so not even the token's length leaks through response timing.

The same suspicion applies to the message itself. Mailingest serves a stored message as a download, message/rfc822 with Content-Disposition: attachment, nosniff and a Content-Security-Policy: sandbox. The body is HTML a stranger wrote, and the origin that serves it holds credentials. Buzontemporal parses the message in core-api and renders its HTML in an <iframe srcdoc> whose sandbox leaves out allow-scripts, so a message's script never runs. The document it builds around the HTML carries its own Content-Security-Policy, default-src 'none'; img-src data: cid:, so nothing remote loads either: no image, font or stylesheet that would tell the sender the message was opened, and from where. Tracking pixels just don't fire. Links open in a new tab.

A mailbox with no attachments

This is the decision that does the most work. By default, a temporary mailbox in Mailingest stores no attachments at all. It's a per-scope policy (an account, a domain or one mailbox can keep them), and Buzontemporal leaves it at the default.

The security reason is direct: an inbox that anyone can write to and anyone with the address can read is a file host. Sending yourself a payload and sharing the address is easier than renting a bucket. If no attachment byte is ever stored, that use is gone. The visitor still sees that something was removed: the message gets a short notice listing each file by name, type and size.

An opened message on Buzontemporal: the sender and subject written like an envelope with a stamp and postmark, the HTML body with a six-digit code, and a notice that welcome-pack.pdf was removed
A real message, sent with a 40 KB PDF attached. The PDF never reached storage; the notice at the bottom (worded per domain, in Spanish on correix.com) is all that's left of it.

The hard part is removing attachments in a way the attacker can't route around. Mail is MIME, MIME parsers disagree, and a message that one parser reads as plain text can be a multipart with a PDF inside for the mail client that shows it. Mailingest handles that in three ways:

  • It never stores the message as received. Even when no attachment is found, the message is rebuilt from scratch: ten identity and conversation headers copied verbatim (From, To, Subject, Message-ID, References and the like), the text body, the HTML body, and nothing else.
  • Ambiguous structure is dropped. A part with two Content-Type headers (or two encodings, or two dispositions) is the trick that hid a PDF from the scan, so a message with one isn't cleaned, it's not stored at all.
  • Unparseable means not stored. If the parser can't read the message, it can't tell attachments apart, so nothing is kept.
crates/mail/src/attachments.rs
// Ambiguous structure cannot be cleaned; it is dropped
// like an unparseable message.
if parsed.parts.iter().any(has_duplicate_mime_headers) {
    return Prepared {
        message: Vec::new(),
        preview,
        removed: Vec::new(),
    };
}

There's an older trick it catches too: uuencoded blocks in the plain text, begin 644 name through end, which some mail clients still show as attached files. Those are cut from the text and listed in the notice like any other attachment.

What's left is small. A sign-up email is a few kilobytes of text and HTML, while the same message with a 20 MB PDF would cost 20 MB of storage, upload and download every time it's read. That's where the security decision turns into a throughput one.

The light path

Mailingest's normal inbound path does a lot per message, and all of it is right for a real inbox: SPF, DKIM, DMARC and ARC checks, spam scoring, indexing for search, rule processing. Its own scale review counts the cost by reading the code (not by measuring): about 40 database queries, 4 object storage operations and 17 Redis operations for each message received.

None of that applies to a disposable inbox. Nobody sorts their temporary mail into folders or searches it next week, and the visitor is about to read the message with their own eyes anyway. So when every recipient of a message is a temporary address, the event says so (temp_only), and the router takes a short branch:

services/router/src/processor.rs
// Mail only for temporary mailboxes takes the light path: none of the
// authentication, spam scanning, indexing or rule processing below applies to
// a disposable inbox, and the staged copy is dropped as soon as it is stored.
if event.temp_only {
    deliver_temp_only(&temp, &event, &raw_bytes).await?;
    if let Err(err) = storage.delete(&event.s3_key).await {
        tracing::warn!(id = %event.id, %err, "staged temp message not deleted; the inbox lifecycle rule will");
    }
    return Ok(());
}

The bounce processor, which otherwise downloads and parses everything that arrives looking for delivery reports, returns at once for these: nothing a temporary mailbox receives is a report about mail the platform sent.

smtp_ingest ── raw to S3 inbox/ ── event (key, recipients, temp_only) │ ▼ router ─────── temp_only? ─── no ──▶ SPF · DKIM · DMARC · spam │ index · rules · webhooks │ yes: light path ▼resolve mailbox ─▶ strip attachments ─▶ quota ─▶ S3 temp/ + row │ ▼notice on JetStream ─▶ Centrifugo ─▶ browser · staged copy deleted
Mail for a temporary address skips everything a real inbox needs and stores one slimmed copy.

The other half of "slim" is what moves between services. Mailingest's events are claim checks: an id, the addresses, an S3 key, never a message body. The raw message goes to object storage before the sender gets its 250 OK, and the NATS event carries only the reference. A test, the_largest_events_stay_small, fills every event type with its fields at their realistic maximum and fails the build if any of them weighs more than 256 KiB, a quarter of NATS's 1 MiB message limit. The new-message notice a browser receives is built the same way: sender, a subject cut to one 998-byte header line, a 240-character preview, the size and the count of removed attachments. The body is fetched only when someone opens it.

Addresses that receive nothing

A temporary domain gets sprayed. Spammers send to random names on any domain with an MX, and on our domains most of those names were never claimed.

Mailingest decides at RCPT TO, before the message body arrives. An address on a temporary domain that has no mailbox is accepted and then dropped when the session publishes: it never reaches the bus, the router or storage. Accepting rather than rejecting is deliberate. A 550 for every unclaimed name tells a spammer which names exist, and it bounces the probe back to a forged sender, which is backscatter aimed at someone who never wrote to us. Storage quotas follow the same rule. A domain already at its quota answers RCPT TO with a temporary 452, so the sender retries later and nothing is accepted that wouldn't be stored. A message that only crosses the quota after it was accepted is dropped quietly: not stored, not announced, and not bounced.

Limits that hold across replicas

Everything that can grow has a ceiling, and the ceilings are shared by every replica, because a limit that each process keeps on its own is multiplied by however many processes are running.

WhatLimitWhere it's counted
Mailbox lifetime24 h by default, 60 s to 30 days per policyPostgres, purged every pass
Message size on the way in25 MBthe SMTP session
New mailboxes on a catch-all domain1,000 per hourRedis
A live connection token1 hour at most, never past its mailboxthe token itself
Live notices kept for reconnectsthe last 50 per mailbox, for 15 minutesCentrifugo's channel history
Stored bytes per account and per domainset per account and per domainRedis counters, rebuilt from Postgres every minute

Two of these are worth a sentence each. The storage counters are only raised on delivery; nothing subtracts on expiry. Instead, the retention pass rewrites every counter from what Postgres actually holds, so a counter that drifted (a redelivery counted twice) is right again within a minute. And the purge deletes a message's stored body before its row. If the object can't be deleted, the row stays, because the row is how the next pass finds the object again. Deleting in the other order is how storage fills with mail nobody can see or remove.

Live delivery used to be Mailingest's own server-sent events, one stream per open inbox, each with its own NATS subscription and a Redis lease. That's gone. A stored message is now published to its mailbox's channel through a JetStream stream that Centrifugo consumes itself, so there's no HTTP call per message and a Centrifugo that was down catches up. The message id is both the Nats-Msg-Id and Centrifugo's idempotency key, so a redelivered message is never announced twice, and a failed publish is logged and counted but never fails the delivery: the row in Postgres is the record, the notice is a convenience.

The browser opens Centrifugo's unidirectional SSE transport with a plain EventSource, no SDK, and checks every frame against a schema. It fetches a new token 30 seconds before the current one expires. When it reconnects, it asks Centrifugo for what it missed since the last position it saw, then re-reads the message list anyway, because the list is the source of truth, and drops duplicates by id.

Buzontemporal's own limits are the exception to this section's title. core-api runs as one replica and keeps its counters in memory; a second replica would need them in Redis, or each would allow its own share. Its numbers are in the section on addresses, and the one a visitor sees is the lifetime: 60 minutes, renewable from the page, well under Mailingest's 24-hour default. Mailingest caps live mailboxes per account, and short lifetimes are what let many visitors share one.

What the visitor gives up

The visitor loses attachments, and that's a real cost. A site that sends its invoice as a PDF or its welcome pack as a ZIP will arrive at Buzontemporal as text plus a notice saying what was there. For what people actually use a temporary inbox for, a code, a link, a confirmation, that's nothing. For anything else, a temporary inbox was the wrong tool.

In exchange, the inbox can't host files, can't be tricked by a second Content-Type, can't leak a visitor's codes to a domain owner, and stores a few kilobytes per message instead of whatever the sender attached. The site that sits on top of it can stay small, because the hard parts were never the website's.

Related articles

Building a Pixel-Art AI Assistant

Sumalos' assistant is a 48-pixel rubber stamp with no sprite sheet: every frame is computed from distance functions, its face shows what the model does.

Building a Better WhatsApp SDK

WhatsApp's native protocol skips the headless browser but hands you a raw socket and a firehose of events. whatsweb is the thin layer that makes it a bot.

An Anti-Scraping Seal

A price on a page is a fact someone paid to produce, public for anything to copy. I'm building an anti-bot seal to change what that copy costs.