Architecture
Koltrix is a small, opinionated set of services running behind nginx. Everything is multi-tenant; tenants are isolated at the PostgreSQL schema level so cross- tenant data leaks are impossible by construction.
Service map
┌─────────────────┐
│ Cloudflare │ *.koltrix.com → origin
└────────┬────────┘
│
┌──────▼──────┐
│ nginx │ TLS termination, host routing
└──┬──────┬───┘
┌───────────┘ └─────────────┐
│ │
app.koltrix.com api.koltrix.com / docs.koltrix.com
│ │
┌──────────▼──────────┐ ┌────────────▼────────────┐
│ koltrix-web │ │ koltrix-api (Fiber) │
│ (Next.js 14 SSR) │ │ /api/v1 + /api/v2 │
└──────────┬──────────┘ └──┬──────────────┬───────┘
│ │ │
└──────► REST ────────►│ │
▼ ▼
┌───────────┐ ┌──────────────┐
│ Asynq │ │ Postfix + │
│ worker │ │ Dovecot │
└─────┬─────┘ └──────┬───────┘
│ │
┌─────────┴───┐ ┌──────┴──────┐
│ koltrix-smtp│ │ Rspamd │
│ (relay 2525)│ │ (DKIM) │
└─────────────┘ └─────────────┘
Storage: Postgres 16 · Redis 7 · MinIO · MeilisearchContainers
| Service | Purpose |
|---|---|
koltrix-web | Next.js front-end (app, dashboard, settings) |
koltrix-api | Go HTTP + WebSocket API (Fiber) |
koltrix-worker | Background processor (campaigns, AI, webhooks) |
koltrix-smtp | ESMTP relay on port 2525 (AUTH PLAIN with kx_ keys) |
koltrix-docs | Documentation site (this app) |
postfix | Inbound MX + outbound SMTP (port 25/587/465) |
dovecot | IMAP for human mailboxes |
rspamd | Spam filter + DKIM signing |
postgres | Source of truth, schema-per-tenant |
redis | Asynq queue, sliding-window rate limits, idempotency cache |
minio | Object storage (HTML bodies, attachments) |
meilisearch | Full-text email search |
Tenancy model
public.tenants(id, slug, clerk_org_id, ...)— one row per organisation.- A per-tenant Postgres schema named
org_<sanitized_slug>holds all business data (threads, emails, lists, subscribers, campaigns, sequences, templates, …). - A trigger on
public.tenantsrunsensure_*_objects(schema)so new tenants are provisioned with the full set of tables automatically. - The auth middleware extracts the tenant from the Clerk JWT (
org_idororg_slug) and setssearch_pathfor every request.
Request lifecycle
nginx → recover → logger → CORS → IP rate-limit → Clerk JWT
→ tenant middleware (SET search_path)
→ per-user rate-limit
→ handlerFor /api/v2/* the Clerk middleware is skipped and an API-key middleware
authenticates instead, resolving the tenant from api_keys.tenant_id.
Outbound message lifecycle
- Caller hits
POST /api/v2/emails(or AUTH+DATA on SMTP relay). - Handler enqueues
TaskSendEmailand writes anoutbound_messagesrow with statusqueued. - Worker checks the suppression list for each recipient. Suppressed? skipped silently.
- Worker checks the warmup quota for the sender domain. Exceeded? the message is paused.
- Worker hands the MIME to Postfix on port 587. Rspamd DKIM-signs it.
- On success → status
sent+message.sentwebhook fires. On 5xx → statusbounced, recipient auto-added to suppression list,message.bouncedwebhook fires. - Recipients that open hit
/t/o/:token→outbound_messages.opened_atupdated +message.openedwebhook fires. Same for clicks via/t/c/:token.