DEV Community

Francisco Perez
Francisco Perez

Posted on • Originally published at uncorreotemporal.com

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]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)