Architecture
How mail-catcher processes inbound emails from SES to API.
mail-catcher is a serverless pipeline that receives emails via AWS SES and makes them queryable through a REST API. Here's how every component fits together.
System overview
┌─────────────────────────────────────────┐
│ AWS Account │
│ │
Email ──────────► │ SES (catch-all receipt rule) │
│ │ │
│ ▼ │
│ S3 Bucket │
│ └── incoming/{messageId} (.eml) │
│ └── attachments/{messageId}/{file} │
│ │ │
│ ▼ (S3 event notification) │
│ Ingest Lambda │
│ └── parse email (mailparser) │
│ └── extract attachments → S3 │
│ └── write metadata → DynamoDB │
│ │ │
│ ▼ │
│ DynamoDB │
│ ├── EmailsTable (PK=inbox, SK=time) │
│ └── ApiKeysTable (PK=keyHash) │
│ │ │
│ ▼ │
│ API Lambda (Hono) │
│ └── GET /v1/emails │
│ └── GET /v1/emails/:id │
│ └── GET /v1/emails/:id/raw │
│ └── GET /v1/emails/:id/attachments/:f │
│ └── DELETE /v1/emails/:id │
│ └── DELETE /v1/emails │
│ │
└─────────────────────────────────────────┘Email reception (SES)
Amazon SES is configured with a catch-all receipt rule on your domain. Every email sent to anything@yourdomain.com is stored as a raw .eml file in S3 under the incoming/ prefix.
SES inbound is only available in three regions: us-east-1, us-west-2, and eu-west-1. DNS records (MX and TXT) are either managed automatically via Route 53 or exported as a BIND zone file for manual setup.
Email parsing (Ingest Lambda)
When a new .eml file lands in S3, an event notification triggers the Ingest Lambda. It:
- Downloads and parses the raw email using
mailparser(RFC 5322) - Extracts the inbox name from the recipient address (the part before
@) - Saves each attachment to S3 under
attachments/{messageId}/{filename} - Writes a metadata record to DynamoDB with: sender, recipient, subject, body (plain + HTML), attachment info, and a 7-day TTL
Data storage
DynamoDB: EmailsTable
| Key | Format | Purpose |
|---|---|---|
PK (Partition) | inbox | Groups emails by inbox name |
SK (Sort) | {ISO timestamp}#{messageId} | Enables chronological ordering |
GSI: MessageIdIndex | messageId | Enables direct lookup by message ID |
Records include a ttl attribute set to 7 days from ingestion for automatic cleanup.
DynamoDB: ApiKeysTable
| Key | Format | Purpose |
|---|---|---|
keyHash (Partition) | SHA-256 hash | Stores hashed API keys for auth |
Keys are permanent until manually revoked.
S3: EmailBucket
| Prefix | Content | Retention |
|---|---|---|
incoming/ | Raw .eml files | 8 days (lifecycle rule) |
attachments/ | Parsed attachment files | 8 days (lifecycle rule) |
The 1-day buffer between DynamoDB TTL (7 days) and S3 lifecycle (8 days) ensures S3 objects outlive their index entries. See Data Retention for customization details.
API layer (Hono on Lambda)
The REST API runs on a single Lambda function using Hono as the HTTP framework. It provides:
- Email listing with pagination, filtering, and long-polling
- Single email retrieval by message ID
- Raw email download via pre-signed S3 URLs (15-minute expiry)
- Attachment download via pre-signed S3 URLs
- Email deletion (single and bulk)
- Bearer token authentication with SHA-256 hashed keys stored in DynamoDB
An optional CloudFront distribution + custom domain can be configured via the API_DOMAIN environment variable.
Infrastructure as code
All resources are defined using SST v4 (built on Pulumi). The infrastructure code lives in packages/infra/src/:
index.ts: S3 bucket, DynamoDB tables, Lambda functions, API routerses-inbound.ts: SES receipt rules and DNS records
SST stages (dev, prod) provide full resource isolation between environments.