The deceptive simplicity of developer tools like email services masks complex infrastructure challenges. This article explores the engineering principles behind building scalable developer tools through the lens of email infrastructure.
When we look at developer tools from the outside, many appear deceptively simple. A temporary email service, for instance, seems like it should take a weekend to build: create an address, receive mail, expire it after N minutes. The interface is trivial. The implementation is not.
Building a programmable temporary email infrastructure for automation, CI pipelines, and AI agents reveals a set of engineering challenges that most "simple" developer tools eventually surface: concurrent ingestion, time-bounded data, real-time event propagation, and API contracts that hold up under machine load.
The Illusion of Simplicity
The most dangerous developer tools to build are the ones where the interface is clean. A queue with three methods. An email API with two endpoints. An auth service with one decision. The surface area looks small, which makes it easy to underestimate the engine behind it.
What do email APIs, queues, authentication services, and testing tools have in common? They are all infrastructure primitives. They do not do business logic—they are the layer below business logic. And infrastructure primitives fail in ways that are invisible until they do not: message loss, split-brain states, TTL races, partial writes, replay ambiguity.
When you build a temporary email service for developers, you are not building a toy. You are building infrastructure that test suites depend on to create isolated inboxes, receive confirmation emails, and clean up reliably after every run. If it drops a message or delivers to an expired inbox, a CI job fails with a confusing, non-deterministic error. If it has an unpredictable API, automation scripts break when the contract shifts.
The simplicity of the interface is a contract with your users—maintaining it requires solving complexity underneath.
Real Email Infrastructure Challenges
SMTP is a 40-year-old protocol designed for delivery, not for programmable use. Building on top of it means accepting its characteristics: messages are raw bytes, headers are often inconsistent, multipart structure is implicit, and the protocol itself has no concept of a "user" or an "inbox"—only a recipient address and a message.
The ingestion layer has two distinct modes. In development and local testing, an async SMTP server handles incoming mail. In production, AWS SES receives the mail, publishes an SNS notification, and calls a webhook. Both paths converge at a core delivery module that parses raw RFC 2822 bytes into structured data.
A critical architectural choice: attachment binary payloads are not stored separately. Only metadata (filename, content type, size, content-id) is persisted as JSONB. The complete raw_email bytes are stored alongside. This means the original message is always re-parseable without duplication—and it simplifies the schema at the cost of making attachment retrieval slightly more expensive.

Designing for Concurrency
The entire stack is async: FastAPI with SQLAlchemy async sessions, aiosmtpd for SMTP, aioredis for pub/sub, and asyncio tasks for background work. This was not an afterthought—it was a prerequisite for any form of meaningful throughput.
The delivery pipeline illustrates the async design. It is a six-step sequence where each step is an awaited call: look up the mailbox, load the plan, check quota, parse the email, insert the message, publish to Redis. No blocking I/O, no thread-per-connection overhead.
The fan-out from message delivery to real-time notification follows a pub/sub pattern over Redis. The publish happens after the database commit. If Redis is unreachable, the exception is caught and logged—the message is already durable in PostgreSQL. Redis is the notification layer, not the source of truth. This ordering matters: message loss in Redis is recoverable (the client can poll); message loss in the DB is not.
Time-Based Data Is a Hidden Challenge
Expiring data is deceptively hard. The naive implementation—DELETE FROM mailboxes WHERE expires_at < now()—conflates two separate concerns: enforcement and storage reclamation.
The production implementation separates them. Expiration is a state transition, not a deletion. The background worker runs as an asyncio task and fires every 60 seconds, updating mailboxes to set is_active=False when they expire. One SQL statement. No Python-level iteration. No loading rows into memory. The entire sweep is a single round-trip to PostgreSQL, bounded in cost by the number of newly expired inboxes since the last cycle.
The subtler challenge is the race between the polling window and message delivery. Between an inbox's expires_at timestamp and when the background worker next runs, is_active may still be True even though the inbox has expired. The delivery layer closes this window with a double-check that verifies both is_active and expires_at > now.
API Design for Automation
Developer tools built for automation require a different API design philosophy than consumer products. The contract must be machine-readable, predictable, and composable with CI pipelines and agent loops.
The mailbox creation endpoint is designed for automation-first use: POST /api/v1/mailboxes?ttl_minutes=30. No request body—TTL is a query parameter because it is always optional and has a plan-dependent default. The response is flat and explicit, with expires_at always as an ISO 8601 timestamp with UTC offset—no ambiguity about timezone, no state to track.
Plan limits are enforced at the API layer before any resource is created: effective_ttl = min(ttl_minutes or plan.default_ttl_minutes, plan.max_ttl_minutes). A free plan user requesting a 48-hour inbox gets a 60-minute inbox, silently capped. The expires_at in the response reflects the actual cap.
The messages API separates listing from retrieval: GET /api/v1/mailboxes/{address}/messages returns metadata only, while GET /api/v1/mailboxes/{address}/messages/{id} returns the full message and marks it as read. The list endpoint never returns body content—only id, from_address, subject, received_at, is_read, has_attachments. A CI test polling for an OTP email reads the list, finds the matching subject, fetches only that message.
Real-Time Systems Change the Game
Polling is the wrong abstraction for event-driven workflows. A test suite that polls GET /messages every second burns API quota and introduces latency that scales with polling interval.
WebSockets change the model entirely. The WebSocket endpoint accepts connections authenticated via either a session token or an API key. Once connected, it subscribes to the Redis pub/sub channel for that inbox and runs two concurrent asyncio tasks: one to forward Redis messages to the WebSocket, and another to send keepalive pings.
The client receives two event types: new_message notifications and pings. On new_message, the client calls GET /api/v1/mailboxes/{address}/messages/{id} to retrieve the full body. The WebSocket is a notification channel only—it carries the signal, not the payload.
For AI agents and CI pipelines: open a WebSocket before triggering the flow that sends the email, wait for the new_message event, fetch the body, extract the OTP. Zero polling. Sub-second latency from email arrival to agent response.

Infrastructure Lessons Learned
Async is the default, not an optimization. Every I/O operation—database queries, Redis operations, SMTP handling, WebSocket messaging—is non-blocking. This is the design that makes a single-process deployment serve concurrent WebSocket connections, handle incoming SMTP sessions, and run background expiration sweeps without threading complexity.
Design for failure at every layer. The Redis publish in the delivery pipeline is wrapped in a try/except that logs the error and returns True. The message is already in PostgreSQL. Redis failure does not become delivery failure. The expiry loop wraps its core operation in a try/except so that a transient database error does not kill the loop.
Separate enforcement from cleanup. The expiry model distinguishes between is_active = False (enforcement, near-real-time) and physical deletion (cleanup, deferred). This separation means the delivery layer can enforce TTL without depending on a garbage collector having run.
Single source of truth for state. Plan limits and TTL caps live in the database, not in application configuration. A free plan with max_ttl_minutes = 60 is a row in the plans table. Changing it requires no deployment.
Dual ingestion path, shared logic. The aiosmtpd handler and the SES webhook share core delivery logic entirely. The SMTP handler's handle_DATA method is three lines of routing logic; everything else is shared. Local development tests exactly the same delivery pipeline as production SES ingestion.
The Hidden Complexity Behind "Simple" Products
The products that developers call "simple" are the ones where the interface succeeded. Stripe's charge API is simple. S3's put/get is simple. The simplicity is the product of enormous engineering effort spent hiding complexity from the caller.
A temporary email service that works looks like this from the outside: create inbox, receive mail, read message, inbox expires. Four operations. What it requires underneath: a domain registered with MX records pointing at your infrastructure, an async SMTP ingestion layer with domain validation and silent rejection, an RFC 2822 parser that handles malformed multipart messages without crashing, a quota enforcement system tied to a plan hierarchy, a background expiration worker with correct race condition handling, a real-time notification system over Redis pub/sub with WebSocket fan-out, and an API designed for machine consumption at every endpoint.
None of these are visible in the API contract. That invisibility is the job. The engineering trap is believing that because the interface is simple, the implementation can be simple too. It cannot. The interface's simplicity is the result of pushing complexity inward—into the infrastructure, into the error handling, into the data model. When you skip that work, the complexity leaks out into the caller's code.
Building Developer Tools That Scale
Building developer tools that scale comes down to a few durable engineering principles:
Make async the baseline, not the upgrade path. If your infrastructure needs to handle concurrent connections, background workers, and real-time events—and any non-trivial tool will—an async framework is the correct starting point, not a future refactor.
Design data lifecycle explicitly. Time-bounded data requires a model that separates enforcement from cleanup, and an enforcement mechanism that does not depend on cleanup having run. Race conditions in time-based systems are not edge cases—they are the default condition.
Build APIs for machines first. Predictable response shapes, explicit timestamps, flat structures, and quiet enforcement of limits are properties that make automation reliable. An API designed for a human UI can often be driven by a machine; an API designed for a machine can always be used by a human.
Isolate failure. Every I/O operation has failure modes. The question is whether those failures propagate or are contained. Redis unavailability should not cause message loss. A transient database error should not kill a background worker. Design the blast radius before the failure happens.
The measure of infrastructure engineering is not what happens under ideal conditions—it is what happens when things go wrong, and whether those failures are invisible to the people building on top of you. That is what "simple" actually means.
The full system is running at uncorreotemporal.com. Anonymous inboxes need no signup: POST /api/v1/mailboxes?ttl_minutes=5 creates a live inbox with a five-minute TTL and returns a WebSocket-ready address. It is as simple as it looks.

Comments
Please log in or register to join the discussion