Self-hosted email infrastructure for a custom domain, built entirely on AWS. Receives email via SES, stores raw RFC822 in S3, indexes metadata in DynamoDB, and serves a lightweight emergency webmail interface — all for under $5/month.
Postamt (German: post office) — because your email deserves its own address.
graph TD
subgraph AWS["AWS (eu-west-1)"]
SES[SES]
S3["S3 (incoming/{messageId})"]
IndexLambda[Index Lambda]
DynamoDB["DynamoDB (email-index)"]
CF[CloudFront]
S3Static["S3 (static frontend)"]
APIGW[API Gateway]
LambdaAPI["Lambda (Python)"]
SES --> S3
S3 --> IndexLambda
IndexLambda --> DynamoDB
CF --> S3Static
CF --> APIGW
APIGW --> LambdaAPI
LambdaAPI --> S3
LambdaAPI --> DynamoDB
LambdaAPI --> SES
end
Email[Incoming Email] --> SES
Browser --> CF
| Component | Purpose |
|---|---|
| AWS SES | Receive and send email, DKIM signing, TLS enforcement |
| S3 | Raw email storage with lifecycle (→ IA → Glacier) |
| DynamoDB | Email metadata index for fast listing by date |
| Lambda (Python 3.11) | Auth, list, read, send, and index email functions |
| API Gateway | REST API with CORS, rate limiting |
| CloudFront | HTTPS frontend with security headers |
| Route 53 | DNS management, MX records, DKIM CNAMEs |
| CDK (TypeScript) | Infrastructure as code, 3 stacks |
├── backend/ # Python Lambda functions
│ ├── auth.py # Token validation, JWT issuance
│ ├── jwt_utils.py # HMAC-SHA256 JWT (zero dependencies)
│ ├── list_emails.py # Query DynamoDB index
│ ├── read_email.py # Fetch + parse RFC822 from S3
│ ├── send_email.py # Send via SES
│ └── index_email.py # S3 trigger → DynamoDB indexer
├── frontend/ # Static webmail UI
│ ├── index.html # Login, inbox, reader, composer
│ ├── app.js # SPA logic, API client
│ └── styles.css # Responsive CSS (<6KB)
├── infrastructure/ # AWS CDK (TypeScript)
│ ├── bin/infrastructure.ts # App entry point, stack wiring
│ └── lib/
│ ├── storage-stack.ts # S3 bucket, DynamoDB table, index Lambda
│ ├── email-stack.ts # SES, receipt rules, SMTP/sync IAM users
│ └── webmail-stack.ts # CloudFront, API Gateway, webmail Lambdas
└── infrastructure/scripts/
├── show-dns-config.sh # Display required DNS records
└── test-email-infrastructure.sh # Post-deploy validation
- AWS CLI configured with appropriate permissions
- Node.js 22+
- Domain hosted in Route 53
npm install
npm run build
# Store secrets in AWS Secrets Manager (first-time setup)
aws secretsmanager create-secret --name /webmail/jwt-secret \
--secret-string "$(openssl rand -hex 32)"
aws secretsmanager create-secret --name /webmail/auth-secret \
--secret-string "$(openssl rand -hex 16)"
# Deploy all stacks
npx cdk deploy --all --require-approval never# Test infrastructure
./infrastructure/scripts/test-email-infrastructure.sh pfeiffer.rocks
# Show DNS config
./infrastructure/scripts/show-dns-config.sh pfeiffer.rocksOpen https://webmail.<your-domain> and log in with your AUTH_SECRET value.
- MX record points to SES (
inbound-smtp.eu-west-1.amazonaws.com) - SES stores raw RFC822 email in S3 under
incoming/{messageId} - S3 event triggers the index Lambda
- Index Lambda parses headers (From, To, Subject, Date) and writes metadata to DynamoDB
- User authenticates with a shared secret → receives a short-lived JWT (1 hour)
- Inbox queries DynamoDB GSI sorted by
receivedAt(no S3 scanning) - Reading an email: DynamoDB lookup for S3 key → fetch and parse full RFC822
- Sending: validated recipient → SES
send_email
A Kubernetes cronjob on the Pi cluster syncs emails from S3 to a local Dovecot IMAP server every 5 minutes, using ETag-based deduplication.
- Authentication: HMAC-SHA256 JWT with
iss/audclaims, 1-hour expiry - XSS Protection: HTML emails rendered in sandboxed
<iframe>(no script execution) - Rate Limiting: API Gateway usage plan (5 req/s, burst 10)
- Security Headers: CSP, HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff
- Auto-Logout: 15-minute inactivity timeout
- CORS: Restricted to
https://webmail.<domain> - IAM: Least-privilege — SES scoped to domain, DynamoDB read-only for webmail
- Encryption: S3 server-side encryption, TLS enforced on SES receipt rules
- Error Handling: Generic error messages to client, details logged to CloudWatch
| Service | Monthly Cost |
|---|---|
| SES (receive + send) | ~$1.00 |
| S3 (storage + lifecycle) | ~$2.00 |
| DynamoDB (on-demand) | ~$0.10 |
| Lambda | ~$0.10 |
| API Gateway | ~$0.20 |
| CloudFront | ~$0.10 |
| Route 53 | ~$0.50 |
| Total | ~$4.00 |
| Stack | Resources | Stateful |
|---|---|---|
StorageStack |
S3 email bucket, DynamoDB index, index Lambda | Yes (RETAIN) |
EmailStack |
SES identity, receipt rules, SMTP user, sync user | No |
WebmailStack |
CloudFront, API Gateway, 4 Lambdas, S3 site bucket | No |
# Useful CDK commands
npx cdk diff # Preview changes
npx cdk deploy StorageStack # Deploy single stack
npx cdk deploy --all # Deploy everything
npx cdk destroy WebmailStack # Tear down (stateful resources retained)| Type | Name | Value |
|---|---|---|
| MX | <domain> |
10 inbound-smtp.eu-west-1.amazonaws.com |
| TXT | <domain> |
v=spf1 include:amazonses.com ~all |
| CNAME | <token>._domainkey.<domain> |
DKIM (3 records, AWS-managed) |
| A | webmail.<domain> |
CloudFront distribution (alias) |
MIT