Deploying an Email Infrastructure on AWS
How to deploy a production-ready email ingestion infrastructure on AWS using FastAPI, aiosmtpd, Redis pub/sub, and Terraform — based on a real codebase.
Deploying an Email Infrastructure on AWS
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 (v=spf1 include:amazonses.com ~all) |
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 and requests DKIM tokens (three CNAME records).
- Configures a custom MAIL FROM subdomain (
mail.uncorreotemporal.com) so the envelope sender domain matches the sending domain — this satisfies stricter DMARC checks. - Creates an SNS topic and an SES receipt rule that:
- Optionally runs spam/virus checks.
- Publishes the raw email to the SNS topic.
- 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"
This makes the local environment a real SMTP server, which is useful for integration testing and for verifying email parsing behavior with actual MIME content.
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 serves two purposes: it prevents proxy and load balancer timeouts on idle WebSocket connections, and it gives clients a signal that the connection is still alive so they can detect drops.
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 the full message 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
On authentication, the incoming key is hashed and compared against the stored hash. If the database is compromised, raw API keys are not exposed.
Anonymous Sessions
Anonymous mailboxes are protected by a 32-byte session token generated at creation time and returned once to the client. It is never stored in plaintext in the database — rather, it is a bearer token that grants access to that specific mailbox. There are no shared credentials; each anonymous mailbox is its own authorization boundary.
SNS Signature Verification
The SES inbound webhook validates the SNS message signature using RSA-SHA1 before processing the payload. In production, this prevents arbitrary HTTP requests from injecting fake emails. The signature verification is disabled in development (where no real SNS is involved) via a configuration flag.
Domain-Based Recipient Filtering
The SMTP handler and delivery core reject any recipient address that does not end in @uncorreotemporal.com. This prevents the server from being used as an open relay, even if an attacker manages to send SMTP traffic directly to the instance.
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. This bounds the storage impact of any single account and limits how attractive the service is as an abuse vector.
8. Infrastructure as Code with Terraform
The entire AWS footprint is defined in Terraform, split into discrete modules. This structure matters: each module has a single responsibility and a clear interface.
terraform/
├── main.tf # Root module — wires everything together
├── variables.tf
├── outputs.tf
└── modules/
├── vpc/
├── ec2/
├── rds/
├── redis/
├── alb/
├── acm/
├── ses/
├── route53/
├── route53_zone/
├── ecr/
└── iam/
The root main.tf instantiates each module and passes outputs between them. For example, the VPC module outputs subnet IDs and security group IDs, which are consumed by the RDS and Redis modules. The ALB module outputs a DNS name, which becomes the A record alias in the Route 53 module.
Terraform state is stored in an S3 backend, which provides locking and history. Any engineer with appropriate IAM permissions can run terraform plan from a fresh checkout and get an accurate picture of what would change.
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. This reflects a real boundary between infrastructure provisioning and application initialization.
9. Scaling the System
The current architecture is intentionally sized for a single-instance deployment. Here is where the seams are and how they could be opened.
SMTP Concurrency
In production, SMTP ingestion is delegated entirely to AWS SES, which is horizontally scalable by design. The application sees only HTTP POSTs from SNS. Each POST is handled by an async FastAPI route — the bottleneck is the database write and the Redis publish, not the SMTP protocol.
If the SNS webhook becomes a throughput bottleneck, the approach is to push messages onto an async task queue (Celery + Redis or SQS) rather than processing them inline in the webhook handler.
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 would run the expiry loop independently, causing duplicate database writes. Moving to a dedicated worker process (or a cron job against the database) is the right solution when horizontal scaling becomes necessary.
Database Indexing
The messages table has a composite index on (mailbox_id, received_at) to support the common query pattern of retrieving recent messages for a mailbox. The mailboxes table has indexes on (expires_at, is_active) for the expiry query and (owner_id, is_active) for the user inbox listing. These indexes were added deliberately rather than discovered through slow query analysis — a reflection of knowing the access patterns before the data volume makes them urgent.
Redis Throughput
Redis pub/sub is single-threaded and in-process on the ElastiCache node. Each message delivery issues one PUBLISH command; each WebSocket connection issues one SUBSCRIBE. At modest scale (thousands of concurrent connections), this is fine. At high scale, the channels can be sharded by mailbox address across a Redis Cluster, though the client code would need to account for cluster topology.
10. Lessons Learned
Delegate SMTP, own the processing. Running a production SMTP server that is consistently reachable, handles TLS correctly, and survives IP reputation issues is a full-time job. Using AWS SES for the protocol layer and owning the processing logic via a webhook is the right division of responsibility.
Store raw email bytes. Parsing RFC 2822 is not trivial — encoding edge cases, multipart MIME trees, and attachment extraction all have real failure modes. Storing the original raw 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. This means any fix or improvement — quota logic, parsing, pub/sub — applies to both environments identically.
Subscriptions as the single source of truth. The initial schema had a plan_id column on the users table, which could diverge from the actual subscription state. Migration 0005 removed it. The user's effective plan is now always derived at query time from the subscriptions table. This is slightly more expensive (one extra query per request) but eliminates an entire class of data consistency bugs.
Async all the way down. FastAPI's async request handlers, asyncpg, aioredis, and aiosmtpd all run on the same event loop. There are no blocking calls in hot paths. This matters for WebSocket connections in particular — a blocking database call in one coroutine would stall all connected WebSocket clients.
11. Conclusion
Programmable email infrastructure is genuinely useful in three contexts that are growing quickly:
CI/CD testing. End-to-end tests that verify email verification flows, password resets, or notification delivery need a real inbox. Creating one programmatically via API, running the test, and discarding the inbox keeps tests isolated and deterministic.
Automation. Scraping workflows, monitoring systems, and integration tests often need to receive emails as part of a larger flow. An API-driven temporary inbox makes this composable.
AI agents. The MCP server exposes the full inbox workflow — create, list, read, delete — as tools that AI assistants can invoke. An agent can provision a temporary inbox, trigger an action that sends email, wait for the message, extract a verification code, and continue — all 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. Every component described above is derived from the actual codebase. The email infrastructure AWS stack is not glamorous, but it is the kind of reliable, observable plumbing that makes serious automation possible.
Written by
Software Engineer · Sr. Python Developer · AWS Certified Solutions Architect
Software engineer with 20 years of experience building Python backends, cloud infrastructure, and AI agent tooling. Builder of UnCorreoTemporal.
LinkedInReady to give your AI agents a real inbox?
Create your first temporary mailbox in 30 seconds. Free plan available.
Create your free mailbox