Private message-ingestion infrastructure for roitsystems.ca.
Stack: Caddy · CouchDB · NATS JetStream · Node.js/TypeScript API · Node.js Worker
Deployment: Docker Compose on a DigitalOcean droplet
- Contact Ingestion: Accepts contact-form submissions from roitsystems.ca via a secure HTTPS API.
- Message Storage: Stores each message durably in CouchDB.
- AI Analysis: Worker processes messages asynchronously via NATS, calling Claude API for intelligent analysis (company research, competitive insights, market timing).
- Notifications: Sends email summaries via Resend with extracted intelligence and recommendations.
- Image Conversion: Internal admin tool for converting images with ImageMagick (PNG, JPG, WebP and GIF in; those or SVG out) with advanced parameters.
- Admin Dashboard: Private UI for managing messages and image conversions.
git clone https://github.com/morgane-oger/roitsystems-infra.git
cd roitsystems-infra
cp .env.example .env
# Edit .env with your values (see below)
docker compose up -d --build| Variable | Description |
|---|---|
API_DOMAIN |
Public hostname, e.g. api.roitsystems.ca |
CADDY_EMAIL |
Email for Let's Encrypt TLS cert |
COUCHDB_USER |
CouchDB admin username |
COUCHDB_PASSWORD |
CouchDB admin password (make it strong) |
CORS_ORIGINS |
Comma-separated allowed origins, e.g. https://roitsystems.ca |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Google sign-in for the admin pages (see GOOGLE_OAUTH_SETUP.md) |
ADMIN_EMAILS |
Comma-separated Google accounts allowed in as admins; the APIs won't start without at least one |
SESSION_SECRET |
Signs admin session cookies; at least 32 characters (openssl rand -hex 32), or the APIs won't start |
RATE_LIMIT_MAX |
Max contact submissions per window per IP (default: 10) |
RATE_LIMIT_WINDOW_MS |
Rate limit window in ms (default: 900000 = 15 min) |
NOTIFICATION_WEBHOOK_URL |
Optional webhook for notifications (Signal/Telegram relay) |
LOG_LEVEL |
info in production, debug for troubleshooting |
Never commit .env to git. The .gitignore already excludes it.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/contact |
None (rate-limited) | Submit a contact message |
GET |
/api/admin/messages |
Admin session | List all messages (JSON) |
GET |
/api/admin/messages/:id |
Admin session | Get a single message (JSON) |
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/corporate-network/image-converter/api/image-jobs |
Admin session (rate-limited) | Submit an image conversion job |
GET |
/corporate-network/image-converter/api/image-jobs/:transaction_id |
Admin session | Poll for job status and result |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
None | Liveness check |
GET |
/corporate-network/contact/admin |
Admin session (for its data) | Admin message viewer |
GET |
/corporate-network/image-converter/convert |
Admin session (for its data) | Image converter admin tool |
GET |
/auth/login, /auth/callback, /auth/logout |
None | Google sign-in and sign-out |
"Admin session" is the session cookie set after Google sign-in, for an account
listed in ADMIN_EMAILS. Without one, contact-inbox's admin routes redirect to
/auth/login, and the image-converter API answers 401 (its page then sends the
browser to sign-in). A session for any other account gets 403.
{
"name": "Jane Smith",
"email": "jane@example.com",
"company": "Acme Corp",
"subject": "AI Readiness Assessment",
"message": "We need help with...",
"budget": "15,000–35,000",
"timeline": "This quarter",
"source_page": "https://roitsystems.ca/#contact",
"timestamp": "2026-04-26T18:00:00.000Z",
"consent": true,
"hp": ""
}hp is the honeypot field — must be empty. company, budget, timeline, source_page, timestamp, and consent are optional.
A JSON body, with the image itself base64-encoded in data:
{
"filename": "original-filename.png",
"data": "iVBORw0KGgo...base64-encoded-image...",
"format_in": "png",
"format_out": "webp",
"quality": 85,
"width": 1200,
"height": 630,
"rotate": 0,
"trim": false,
"colorspace": "sRGB",
"background": "#ffffff",
"flatten": false,
"density": 72,
"blur": 0,
"sharpen": 0
}filename, data, format_in and format_out are required; the rest are
optional. format_in is one of png, jpg, jpeg, webp, gif;
format_out may also be svg. A valid job is answered with 202:
{
"transaction_id": "uuid-here",
"status": "queued"
}Poll GET /corporate-network/image-converter/api/image-jobs/:transaction_id with the transaction_id above. When status === "done", the response includes data (base64-encoded, in format_out):
{
"transaction_id": "uuid-here",
"status": "done",
"data": "iVBORw0KGgo...base64-encoded-image...",
"updated_at": "2026-05-08T19:00:15.000Z"
}The admin pages and APIs use Google sign-in. Only the accounts listed in
ADMIN_EMAILS get in. Setting up the Google client is described in
GOOGLE_OAUTH_SETUP.md.
Open /corporate-network/contact/admin on the API domain and sign in with
Google. View all contact messages, their status, and AI-generated insights.
Open /corporate-network/image-converter/convert on the API domain and sign in
with Google. Upload images and convert them using ImageMagick with:
- Format conversion (PNG, JPG, WebP and GIF in; those or SVG out)
- Resize with aspect ratio lock
- Rotation, trim, blur, sharpen
- Quality control and color space adjustment
- Batch history tracking
The admin APIs accept only the browser session cookie; there are no API tokens.
A session lasts 8 hours, and /auth/logout ends it.
roitsystems-infra/
docker-compose.yml # all services
.env.example # configuration template
caddy/Caddyfile # reverse proxy + TLS config
nats/nats.conf # JetStream config
api/ # contact API + image converter (TypeScript/Fastify)
src/
admin-image-convert.html # image converter admin UI
worker/ # contact message processor (TypeScript)
imagemagick-service/ # image conversion worker (Node.js)
scripts/
init-couchdb.sh # manual DB initialisation helper
healthcheck.sh # post-deploy health verification
docs/
architecture.md
security.md
operations.md
-
docker compose up -dstarts the full stack - HTTPS works on the public API domain (via Caddy + Let's Encrypt)
-
POST /api/contactaccepts and validates messages - Messages stored durably in CouchDB
- NATS JetStream event published on each submission
- Worker consumes events and updates message status
- CouchDB and NATS not publicly reachable (no host port bindings)
- Admin UI lists messages with status
- No secrets committed to git