How we built a production-ready, programmable temporary email platform using FastAPI, aiosmtpd, Redis, and Terraform.
1. Introduction
Receiving emails programmatically is deceptively hard. Sending email is a solved problem — you call an API, provide credentials, and a third-party handles everything. Receiving email is a different challenge entirely.
To receive email, you need a listening SMTP server, an MX DNS record pointing at it, a strategy for spam filtering, a mechanism to parse RFC 2822 messages reliably, a way to route incoming messages to the right user or process, and a real-time notification layer so that waiting clients know when something arrived. And all of that has to survive production traffic without a single SMTP connection timing out.
This article walks through the full architecture of uncorreotemporal.com, a programmable temporary email platform. The platform lets developers and automation systems create disposable inboxes, receive real emails, and access them via a REST API, WebSocket stream, or MCP server (for AI agent workflows). Every claim here is grounded in the actual codebase.
2. The Problem with Email Infrastructure
Before diving into the implementation, it is worth naming the specific challenges that make email ingestion infrastructure hard to get right.
SMTP is a stateful, connection-oriented protocol. Unlike an HTTP request that fails fast, an SMTP session involves a multi-step handshake (EHLO, MAIL FROM, RCPT TO, DATA). If anything in that chain fails or is too slow, the sending server retries — often for hours or days. Your ingestion server must respond correctly and quickly, or legitimate senders will conclude your domain is broken.
Deliverability and reception are different concerns. SPF and DKIM records affect whether outbound email is trusted. For inbound email, the critical record is your MX record, and the critical decision is who handles your SMTP traffic — a real server that speaks the full protocol, or a managed relay like AWS SES.
Real-time processing is expected. Users of temporary email systems expect messages to appear instantly. Polling a database every few seconds is not acceptable. This drives the need for a pub/sub layer that can push events to waiting WebSocket clients the moment a message is written to storage.
Inbox systems are write-heavy with unpredictable bursts. A campaign sending 10,000 test emails, or a CI/CD pipeline spinning up hundreds of parallel verification flows, can produce sudden spikes that need to be absorbed cleanly.
3. High-Level Architecture
The system is composed of four layers:
[External Sender]
│
▼
[AWS SES Inbound] ──── SNS Topic ──── HTTPS Webhook
│ │
│ [FastAPI API Server]
│ │
└───────────────────────────► [PostgreSQL (RDS)]
│
[Redis]
│
[WebSocket Clients]
In development, a local aiosmtpd SMTP server replaces AWS SES. In production, SES handles the SMTP session, runs spam and virus checks, and forwards messages to an SNS topic which triggers a webhook on the FastAPI server. This design decouples SMTP availability (a managed AWS concern) from application availability (our concern).
The FastAPI application is a single async process running on a t3.medium EC2 instance behind an Application Load Balancer. PostgreSQL (RDS) provides durable storage. Redis provides the pub/sub channel that bridges email delivery to WebSocket clients.
4. AWS Infrastructure Design
All infrastructure is defined with Terraform, organized into modules: vpc, ec2, rds, redis, alb, acm, ses, route53, and ecr.
VPC Layout
The VPC spans two availability zones with public and private subnets. A NAT gateway per AZ provides high-availability outbound access for private instances. The EC2 instance hosting the application lives in a public subnet (accessible via the ALB), while RDS and ElastiCache live in private subnets.
# terraform/modules/vpc/main.tf (abbreviated)
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
}
EC2 for Application Hosting
A single t3.medium EC2 instance (Ubuntu 22.04) runs the full Docker Compose stack — FastAPI API and Nginx frontend. An Elastic IP is assigned so the address is stable across instance stops and restarts. Security groups allow inbound traffic only from the ALB on port 8000 and from trusted CIDR blocks for SSH.
The application is deployed as a Docker image pulled from ECR:
uncorreotemporal-api → FastAPI + Uvicorn (port 8000, internal)
uncorreotemporal-front → Nginx + Angular SPA (port 80, external)
Docker networking puts both containers on a shared bridge (uncorreotemporal_net). Nginx inside the front container handles TLS termination (via the ALB), then proxies API traffic to api:8000.
RDS for Persistence
The Terraform rds module provisions a db.t3.small PostgreSQL 15 instance with Multi-AZ enabled, 50 GB of encrypted gp3 storage, 7-day automated backups, and deletion protection. The database is not exposed to the internet — only the EC2 security group has access on port 5432.
The application uses asyncpg as the PostgreSQL driver, wrapped by SQLAlchemy's async ORM:
# db/session.py
engine = create_async_engine(
settings.async_database_url,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession)
pool_pre_ping=True ensures stale connections (common after RDS failovers) are detected and recycled before being handed to a request handler.
Redis for Real-Time Events
In production, ElastiCache runs a replication group with two cache.t3.small nodes, Multi-AZ failover enabled, and TLS enforced. The application connects using rediss:// when redis_url starts with that scheme:
# core/redis_client.py
_client = redis.Redis.from_url(
settings.redis_url, # rediss:// in prod
decode_responses=True,
)
Redis is used exclusively for pub/sub — it is not a job queue or a cache. Every time a message is delivered to a mailbox, a single publish call fires on a per-mailbox channel. This design is intentionally lightweight.
Application Load Balancer and HTTPS
The ALB module provisions a public-facing load balancer with an HTTP listener (port 80) that issues a 301 redirect to HTTPS, and an HTTPS listener (port 443) that terminates TLS using an ACM certificate and forwards to the EC2 instance on port 8000. The health check hits GET /health on the FastAPI app.
The ALB provides TLS offload, so the application never needs to manage certificates. ACM handles automatic renewal via Route 53 DNS validation.
Route 53 and DNS Records
The route53 module creates the hosted zone and all records needed:
| Record | Type | Purpose |
|---|---|---|
uncorreotemporal.com |
A (alias) | Points to the ALB |
uncorreotemporal.com |
MX | Routes inbound email to SES |
uncorreotemporal.com |
TXT | SPF policy |
mail.uncorreotemporal.com |
MX/TXT | Custom MAIL FROM for SES |
_domainkey |
CNAME | DKIM tokens from SES |
The MX record pointing to SES inbound endpoints is what makes the domain receivable. Without it, external senders cannot locate the mail server.
SES and Email Deliverability
AWS SES handles the full inbound SMTP session. The ses Terraform module creates a domain identity, requests DKIM tokens, configures a custom MAIL FROM subdomain, creates an SNS topic and receipt rule. The SNS topic has an HTTPS subscription pointing at the FastAPI webhook endpoint.
When the application server boots and processes a SubscriptionConfirmation message from SNS, it calls the confirmation URL, activating the subscription. This handshake must succeed before any inbound email flows through.
5. Email Ingestion Pipeline
Once DNS, SES, and SNS are configured, every email sent to @uncorreotemporal.com follows this path:
External sender → SES SMTP endpoint → spam/virus check
→ SNS topic → HTTPS POST /api/v1/ses/inbound
→ core/delivery.deliver_raw_email()
→ Mailbox lookup + quota check
→ Parse RFC 2822 message
→ INSERT into messages table
→ Redis PUBLISH mailbox:{address}
→ WebSocket clients receive notification
The SES Webhook Handler
# api/routers/ses_inbound.py
@router.post("/api/v1/ses/inbound")
async def ses_inbound(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.json()
msg_type = body.get("Type")
if msg_type == "SubscriptionConfirmation":
async with httpx.AsyncClient() as client:
await client.get(body["SubscribeURL"])
return {"status": "confirmed"}
if msg_type == "Notification":
payload = json.loads(body["Message"])
raw_email = base64.b64decode(payload["content"])
recipient = payload["receipt"]["recipients"][0]
await deliver_raw_email(raw_email, recipient.lower(), db)
return {"status": "ok"}
The endpoint always returns 200 OK regardless of internal errors. If it returned a 5xx, SNS would retry for up to 23 days, creating a storm of duplicate delivery attempts.
The Delivery Core
The deliver_raw_email function in core/delivery.py is the single entry point for both the SES webhook and the development SMTP handler:
async def deliver_raw_email(raw: bytes, address: str, db: AsyncSession) -> bool:
mailbox = await get_active_mailbox(address, db)
if not mailbox:
return False
plan = await get_user_active_plan(mailbox.owner, db)
if not await check_message_quota(mailbox, plan, db):
return False
parsed = parse_email(raw) # core/parser.py — RFC 2822
message = Message(
mailbox_id=mailbox.id,
from_address=parsed.from_address,
subject=parsed.subject,
body_text=parsed.text,
body_html=parsed.html,
raw_email=raw,
attachments=parsed.attachments,
)
db.add(message)
await db.commit()
await redis_client.publish(f"mailbox:{address}", json.dumps({
"event": "new_message",
"message_id": str(message.id),
}))
return True
The raw RFC 2822 bytes are stored alongside the parsed fields. This makes it possible to re-parse messages if the extraction logic improves, without touching the source data.
Development: aiosmtpd
In development, the smtp Docker service runs an aiosmtpd server bound to port 25:
# smtp/handler.py
class MailHandler:
async def handle_DATA(self, server, session, envelope):
raw = envelope.content
for rcpt in envelope.rcpt_tos:
if rcpt.lower().endswith("@uncorreotemporal.com"):
await deliver_raw_email(raw, rcpt.lower(), db)
return "250 Message accepted for delivery"
6. Handling Real-Time Email Events
The WebSocket endpoint subscribes to the Redis channel for the requested mailbox. Two concurrent async tasks run for each connected client:
# ws/inbox.py
@router.websocket("/ws/inbox/{address}")
async def websocket_inbox(websocket: WebSocket, address: str, ...):
await websocket.accept()
pubsub = await redis_client.subscribe(f"mailbox:{address}")
async def send_loop():
async for message in pubsub.listen():
if message["type"] == "message":
await websocket.send_text(message["data"])
async def ping_loop():
while True:
await asyncio.sleep(30)
await websocket.send_json({"event": "ping"})
tasks = [
asyncio.create_task(send_loop()),
asyncio.create_task(ping_loop()),
]
try:
await asyncio.gather(*tasks)
except WebSocketDisconnect:
for t in tasks:
t.cancel()
await pubsub.unsubscribe(f"mailbox:{address}")
The ping loop prevents proxy and load balancer timeouts on idle connections. When deliver_raw_email calls redis.publish, the message arrives in send_loop within milliseconds. The client receives only a message ID — it then fetches full content from the REST API. This keeps the WebSocket channel thin and stateless.
7. Security Considerations
API Key Hashing
API keys are never stored in plaintext. On creation, the server generates a random key in the format uct_<32 URL-safe base64 chars>, stores its SHA-256 hash, and returns the raw key once to the user:
# models/api_key.py
raw_key = "uct_" + secrets.token_urlsafe(32)
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_prefix = raw_key[:12] # for UI display only
Anonymous Sessions
Anonymous mailboxes are protected by a 32-byte session token returned once at creation. Each anonymous mailbox is its own authorization boundary — no shared credentials.
SNS Signature Verification
The SES inbound webhook validates the SNS message signature using RSA-SHA1 before processing the payload. This prevents arbitrary HTTP requests from injecting fake emails.
Abuse Prevention Through Quotas
Plan-based quotas (max_mailboxes, max_messages_per_mailbox, max_ttl_minutes) are enforced in the delivery path and at mailbox creation time. Anonymous users receive the most restrictive limits.
8. Infrastructure as Code with Terraform
The entire AWS footprint is defined in Terraform, split into discrete modules:
terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
├── vpc/
├── ec2/
├── rds/
├── redis/
├── alb/
├── acm/
├── ses/
├── route53/
├── ecr/
└── iam/
Terraform state is stored in an S3 backend. One non-obvious decision: the ses module does not create the SNS HTTPS subscription automatically — it creates the topic and outputs its ARN. The subscription is confirmed at runtime when the application first boots and handles the SubscriptionConfirmation request from SNS.
9. Scaling the System
Background Workers and the Single-Worker Constraint
The mailbox expiry background task runs inside the FastAPI process as an asyncio task:
# core/expiry.py — runs every 60 seconds
async def run_expiry_loop():
while True:
await asyncio.sleep(60)
async with AsyncSessionLocal() as db:
expired = await db.execute(
select(Mailbox).where(
Mailbox.expires_at <= datetime.utcnow(),
Mailbox.is_active == True,
)
)
for mailbox in expired.scalars():
mailbox.is_active = False
await db.commit()
This works correctly only with uvicorn --workers 1. With multiple workers, every process runs the expiry loop independently causing duplicate writes. A dedicated worker process or cron job is the right solution for horizontal scaling.
Database Indexing
The messages table has a composite index on (mailbox_id, received_at). The mailboxes table has indexes on (expires_at, is_active) and (owner_id, is_active). These were added proactively based on known access patterns.
Redis Throughput
Each message delivery issues one PUBLISH command; each WebSocket connection issues one SUBSCRIBE. At high scale, channels can be sharded by mailbox address across a Redis Cluster.
10. Lessons Learned
Delegate SMTP, own the processing. Running a production SMTP server that survives IP reputation issues is a full-time job. AWS SES handles the protocol layer; a webhook handles the processing.
Store raw email bytes. Parsing RFC 2822 has real failure modes. Storing original bytes alongside parsed fields means parsing bugs are recoverable without data loss.
Make the delivery path the single entry point. Both the development SMTP handler and the production SES webhook call the same deliver_raw_email function.
Subscriptions as the single source of truth. Migration 0005 removed plan_id from the users table. The effective plan is now always derived at query time from the subscriptions table — eliminating an entire class of data consistency bugs.
Async all the way down. FastAPI, asyncpg, aioredis, and aiosmtpd all run on the same event loop. A blocking call in one coroutine stalls all connected WebSocket clients.
11. Conclusion
Programmable email infrastructure is genuinely useful for CI/CD testing (real inbox per test run), automation workflows, and AI agents (the MCP server exposes create/read/delete as tools an agent can invoke without human intervention).
The architecture described here — FastAPI email backend, async ingestion pipeline, Redis pub/sub, Terraform infrastructure on AWS — is production-deployed and handling real traffic. Visit uncorreotemporal.com to try the API.
Top comments (0)