Production-grade email gateway — FastAPI + Postgres + async SMTP. API-key auth (hashed at rest), per-key daily limits enforced atomically in Postgres, full observability stack in one docker compose up: Prometheus, Grafana (pre-provisioned dashboard), Mailpit SMTP sink, admin UI.
The gateway under load — provisioned Grafana dashboard, out of the box:
Red line in "Responses by status" = 429s from a key exhausting its daily limit — the limiter doing its job under load.
cp .env.example .env
docker compose up -d # app + postgres + redis + prometheus + grafana + mailpit# create an API key
docker compose exec app uv run --no-sync python create_api_key.py
# send an email
curl -X POST http://localhost:8088/api/v1/send \
-H "X-API-Key: <your_key>" -H "Content-Type: application/json" \
-d '{"to": "user@example.com", "subject": "Hello", "text": "Hi!", "html": "<p>Hi!</p>"}'
# → {"status":"queued","remaining":49}Every email lands in Mailpit (localhost:8025) — a real SMTP round-trip, no external provider needed:
flowchart LR
C[Client] -->|X-API-Key| A[FastAPI gateway :8088]
A --> K["auth: key hash lookup<br/>+ atomic daily counter"]
K --> Q[background send task]
Q -->|aiosmtplib, STARTTLS| S[SMTP<br/>Mailpit locally / any provider in prod]
A --> DB[(PostgreSQL<br/>keys, send log, quotas)]
A -.-> P[(Prometheus)] -.-> G[Grafana dashboard]
ADM[Admin UI /admin<br/>BasicAuth] --> DB
Atomic daily limits. The per-key counter is a single UPDATE ... WHERE count < limit RETURNING — no race between check and increment, no Redis needed for correctness. 429 when exhausted; the remaining quota is returned on every send.
Keys are hashed at rest. The API key table stores bcrypt/PBKDF2 hashes — a leaked DB dump doesn't leak keys.
Send is backgrounded, log is not. The HTTP request commits an audit row and returns 202 queued immediately; SMTP happens in a background task that back-fills the message ID. Slow SMTP never blocks the API.
Admin UI included — key management, daily usage, send logs (BasicAuth or header-key protected):
| Endpoint | Description |
|---|---|
POST /api/v1/send |
Send email (to, subject, text and/or html, optional headers) — 202 / 401 / 429 / 422 |
GET /api/v1/health |
Healthcheck |
GET /metrics |
Prometheus (via prometheus-fastapi-instrumentator) |
GET /admin |
Admin UI: API keys, usage, send logs |
GET /docs |
Swagger UI |
| Service | Port | Purpose |
|---|---|---|
| app | 8088 | the gateway |
| Mailpit | 8025 | SMTP sink UI — every sent email, viewable |
| Grafana | 3000 | pre-provisioned "Mail Gateway" dashboard (anonymous viewer enabled) |
| Prometheus | 9090 | scrapes the app every 5 s |
| Postgres | 15432 | published for local inspection |
For production SMTP set SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASSWORD/SMTP_STARTTLS in .env — the Mailpit values in compose are dev defaults.
docker compose exec app uv run --no-sync pytest -qCI runs lint + tests on every PR, nightly workflow exercises the full compose stack, coverage is tracked per-PR (see badges).
MIT — see LICENSE.


