https://github.com/crodorg/infrasaas
Agnostic outreach + CRM + automation infrastructure. Rust workspace, HTTP API, terminal CRM.
https://github.com/crodorg/infrasaas
automation axum crm openbsd outreach postgres ratatui rust self-hosted sqlx tokio tui
Last synced: about 1 month ago
JSON representation
Agnostic outreach + CRM + automation infrastructure. Rust workspace, HTTP API, terminal CRM.
- Host: GitHub
- URL: https://github.com/crodorg/infrasaas
- Owner: crodorg
- License: mit
- Created: 2026-05-06T02:38:17.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-06T03:35:35.000Z (about 1 month ago)
- Last Synced: 2026-05-06T04:31:25.697Z (about 1 month ago)
- Topics: automation, axum, crm, openbsd, outreach, postgres, ratatui, rust, self-hosted, sqlx, tokio, tui
- Language: Rust
- Size: 128 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# infrasaas
[](https://github.com/crodorg/infrasaas/actions/workflows/ci.yml)
[](LICENSE)
[](Cargo.toml)
[](https://doc.rust-lang.org/edition-guide/rust-2024/)
Agnostic outreach + CRM + automation infrastructure. Lead sourcing, enrichment, outreach, terminal CRM, and orchestration in one Rust workspace, exposed over an HTTP REST API. Build once, plug any SaaS in via HTTP.
**Status:** early Phase 0. Scaffolding only. APIs unstable.
## Design
- **Anti-fragility primary.** Direct dependencies are MIT or MIT/Apache-2.0 (true OSI). No "sustainable use" or source-available licenses. Every third-party wrapped behind a trait so it is swappable.
- **Privacy by default.** Self-hosted: own VPS, own Postgres, single static binaries, no telemetry.
- **Project-agnostic.** A `source` discriminator on the `leads` row routes data per consumer. No domain logic baked into the infra.
- **Terminal-native CRM.** ratatui TUI binary. No web UI in the critical path.
- **`unsafe_code = forbid`** at the workspace level.
- **Pure-Rust crypto.** TLS via `rustls` + `ring` (no OpenSSL, no `aws-lc-sys` C/asm). Avoids the C build-system blast radius on OpenBSD and minimizes supply-chain surface.
- **Secrets are never plain `String`.** API keys and connection URLs are wrapped in `secrecy::SecretString` to block accidental `Debug` / log leaks.
## Workspace
| Crate | Purpose |
|-------|---------|
| [`core`](crates/core) | Shared types: `Lead`, `Campaign`, `Integration` trait, errors |
| [`api`](crates/api) | axum HTTP server. REST endpoints for leads CRUD |
| [`crm`](crates/crm) | ratatui TUI. 4-screen CRM (list / detail / action / template-picker) |
| [`integrations/claude_enrich`](crates/integrations/claude_enrich) | Claude API enrichment |
Phase 0 ships the four crates above. Orchestrator, scrapers, email, and WhatsApp integrations are deferred to Phase 1.
## Stack
Rust 2024 · tokio · axum · sqlx · ratatui · reqwest · serde · tracing · jiff. Postgres 16+. OpenBSD-friendly single static binaries.
## Quickstart
Requires Rust 1.85+ and a local Postgres 16+.
```sh
git clone https://github.com/crodorg/infrasaas.git
cd infrasaas
cp .env.example .env
./scripts/dev-up.sh # boots a user-local Postgres on :5433, applies migrations, starts the API on :8080
```
In another terminal:
```sh
cargo run -q -p infrasaas-crm # ratatui CRM
```
Insert a lead:
```sh
curl -sX POST http://127.0.0.1:8080/leads \
-H 'content-type: application/json' \
-d '{"source":"example","name":"Ada Lovelace","email":"ada@example.com","tags":["beta"]}'
```
List leads:
```sh
curl -s http://127.0.0.1:8080/leads | jq
```
Tear down:
```sh
./scripts/dev-down.sh
```
## Build
```sh
cargo check --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build --workspace --release
```
The `claude_enrich` crate ships a live integration test (`cargo test -p infrasaas-claude-enrich --test live`) that self-skips when `ANTHROPIC_API_KEY` is unset. CI runs it with the secret on pushes to `main`; forks and untrusted PRs see an empty value and skip cleanly.
## API
Phase 0 endpoints (more in subsequent phases):
| Method | Path | Body / Query | Returns |
|--------|------|--------------|---------|
| `POST` | `/leads` | `NewLead` JSON | `Lead` |
| `GET` | `/leads` | `?source=&status=&limit=` | `Vec` |
| `GET` | `/leads/{id}` | — | `Lead` |
| `PATCH` | `/leads/{id}` | `LeadPatch` JSON | `Lead` |
| `POST` | `/leads/{id}/enrich` | `{prompt_template, max_tokens?, model?}` | `Lead` (with `enriched_data`) |
| `GET` | `/healthz` | — | `200 OK` (process liveness) |
| `GET` | `/readyz` | — | `{"db":bool,"enrich":bool}` (200 ready / 503 not) |
### Enrichment
Enrichment requires `ANTHROPIC_API_KEY` in the API server's environment. The endpoint is project-agnostic — caller supplies the prompt template per-request, with `{{context}}` interpolated to the serialized lead. Without the key set, the API boots cleanly but `/enrich` returns `503 Service Unavailable`.
Default model is `claude-haiku-4-5-20251001` (cheap, fast — typical enrich call ~$0.003). Override globally via `ANTHROPIC_MODEL` env, or per-request via the `model` field for high-value leads where you want Sonnet 4.6 (`claude-sonnet-4-6`) or Opus 4.7 (`claude-opus-4-7`).
The CRM ships a small set of built-in prompt templates (`summary`, `opener`, `research`). To customize, copy [`templates.toml.example`](templates.toml.example) to `templates.toml` (gitignored), `$XDG_CONFIG_HOME/infrasaas/templates.toml`, or any path pointed at by the `INFRASAAS_TEMPLATES` env var. The CRM picker (Action screen → `e`) lists whatever it loads at boot.
### CRM keymap
Vim-style modal TUI. Three modes: NORMAL (default), CMD (`:`), SEARCH (`/`).
**Movement**
| key | effect |
|-----|--------|
| `j` / `k` | next / previous lead in current view |
| `gg` / `G` | jump to top / bottom |
| `Ctrl-d` / `Ctrl-u` | half-page down / up |
| `h` / `Esc` | back one screen |
| `l` / `Enter` | forward one screen |
| `q` | quit |
**List screen — quick status flip** (selection stays put, sweep through a batch)
| key | sets status to |
|-----|----------------|
| `c` | contacted |
| `R` | replied |
| `Q` | qualified |
| `D` | dead |
**Detail screen**
| key | effect |
|-----|--------|
| `i` | edit notes — suspends ratatui, runs `$VISUAL` / `$EDITOR` / `vi` on a tmpfile, PATCHes if changed |
| `a` | drop into the Action screen |
**Search**
`/term` then `Enter` saves the query; `n` / `N` jumps forward / backward through matches across `source`, `name`, `email`, `phone`, `source_external_id`. Matches are tinted yellow in the list.
**Cmdline (`:`)**
| command | effect |
|---------|--------|
| `:q` / `:quit` | quit |
| `:r` / `:refresh` | reload leads from the API |
| `:filter source=fb` | narrow view to a source |
| `:filter status=new` | narrow view to a status |
| `:filter tag=hot` | narrow view to a tag |
| `:filter clear` | drop filters |
| `:sort created` / `created-asc` / `next` | reorder view |
| `:reset` | clear filters, sort, search query |
| `:next +3d` / `+2h` / `+30m` / `+1w` / `+45s` | set selected lead's `next_action_at` relative to now |
| `:next 2026-12-31T23:59:00Z` | set `next_action_at` to RFC 3339 absolute |
| `:next clear` | unset `next_action_at` (cancel a booked follow-up) |
| `:tag +hot +warm` | add tags (idempotent) |
| `:tag -cold` | remove tag |
| `:tag clear` | drop all tags |
| `:export /tmp/leads.csv` | dump current view (filter+sort applied) to RFC 4180 CSV |
### Auth
Set `INFRASAAS_API_TOKEN` to require `Authorization: Bearer ` on `/leads/*`. `/healthz` always stays open for load-balancer probes. Comparison is constant-time via `subtle::ConstantTimeEq`. With the variable unset (the dev default), `/leads/*` is open — fine for `127.0.0.1`-bound development, **not safe for public exposure**.
```sh
export INFRASAAS_API_TOKEN=$(openssl rand -hex 32)
curl -H "Authorization: Bearer $INFRASAAS_API_TOKEN" http://127.0.0.1:8080/leads
```
The CRM picks up the same env var and attaches it on every request.
### Rate limiting
Per-IP rate limiting via [`tower_governor`](https://crates.io/crates/tower_governor). Defaults: 5 req/s, burst 20. Override with `INFRASAAS_RATE_PER_SEC` and `INFRASAAS_RATE_BURST`. Excess requests get `429 Too Many Requests`. The bucket key uses `SmartIpKeyExtractor`, which honors `Forwarded` / `X-Forwarded-For` / `X-Real-IP` and falls back to the TCP peer — safe behind a reverse proxy that strips/sets these headers (e.g. relayd) **only as long as the API binds `127.0.0.1`** so untrusted clients can't set them directly.
### Public inbound webhook
`POST /leads/inbound` is a public, no-auth endpoint for ingesting leads from third-party signup forms (landing page, FB DM responder, referral form). Allowed `source` values are configured via the comma-separated `INFRASAAS_INBOUND_SOURCES` env var; if unset, every inbound request is rejected with `400`. Body shape:
```json
{ "source": "landing-page", "email": "x@example.com", "name": "X", "phone": "...", "message": "..." }
```
Server stamps `tags=['inbound']` and `status='new'`. Rate-limiting is the same global governor as authed routes. Server logs only the email *domain*, never the full address — keeps `/var/log/daemon` PII-free.
### OpenBSD hardening
On `target_os = "openbsd"` the API server, after `bind(2)`:
1. `unveil(2)`s a tiny read-only allowlist (`/etc/ssl/cert.pem`, `/etc/resolv.conf`, `/etc/hosts`, `/etc/services`) and then calls `unveil("", "")` to lock further changes. The rest of the filesystem becomes invisible to the process.
2. `pledge("stdio rpath inet dns", None)` drops syscall surface to the bare minimum needed for steady-state operation.
Both calls are no-ops on Linux/macOS so the same binary is portable across dev and prod.
See [`crates/core/src/lead.rs`](crates/core/src/lead.rs) for type definitions.
## Deploy
Production deployment is intentionally minimal: a single static binary, a
local Postgres, and an `rc.d` script. No containers, no orchestrator, no
systemd. Tested against OpenBSD; the same binary runs on Linux with `pledge`
and `unveil` becoming no-ops.
```sh
# 1. apply migrations against a fresh DB
DATABASE_URL=postgres://... ./scripts/migrate.sh
# 2. on OpenBSD: drop the rc.d script in place, install env file + binary
doas install -m 0755 scripts/openbsd/infrasaas /etc/rc.d/infrasaas
doas useradd -d /var/empty -L daemon -s /sbin/nologin _infrasaas
doas mkdir -p /etc/infrasaas
doas install -m 0600 -o _infrasaas .env.example /etc/infrasaas/env
doas $EDITOR /etc/infrasaas/env # fill in secrets
doas install -m 0755 target/release/infrasaas-api /usr/local/bin/
doas rcctl enable infrasaas && doas rcctl start infrasaas
```
`scripts/migrate.sh` is idempotent — it tracks applied files in a
`schema_migrations` table and is safe to re-run on every deploy. The OpenBSD
`rc.d` script sources `/etc/infrasaas/env` at start-time so secrets stay out
of `ps aux` and process-listing tools.
## Roadmap
- **Phase 0** — `core` + `api` + `crm` + `claude_enrich`. (current)
- **Phase 1** — `orchestrator` (cron + queue), email + WhatsApp + scraper integrations.
- **Phase 2** — second consumer onboarded; partner-facing CRM affordances if needed.
- **Phase ∞** — productize as SaaS only if internal usage proves the engine works.
## License
[MIT](LICENSE) © Chris Rodriguez