Matthew Meszaros 30eff698c0 feat(web): contacts + unibox browsers, dropdown + field primitives, ErrorBoundary
Reliability:
- ErrorBoundary wraps every route. Silent white pages are gone — any
  uncaught render error now surfaces inside the panel with name,
  message, stack toggle, and back/retry buttons.
- Boundary keys on pathname so navigating away clears the error.

Campaigns blank fix:
- Drop the legacy HeadSelectMenu + Search components from the page
  (suspected layout/click-outside collisions inside the slim SectionBar).
- Rewrite using the new dropdown + SearchInput primitives.
- Add StatStrip with clickable filters (All / Active / Paused / Draft).
- Loading shows skeleton rows. Empty splits between "no campaigns at
  all" vs "no campaigns matching the current filter".

Dropdown primitive (web/src/components/ui/popover-menu.tsx):
- Brae-density popover menu — slim trigger, hairline border content
  surface, h-7 items, mono kbd accents. Built from scratch rather than
  via Radix so styles are authoritative and bundle stays small.
- Click-outside + Esc handling, controlled/uncontrolled open state,
  side ("bottom"|"top") + align ("start"|"center"|"end") positioning
  with viewport-aware clamping.
- SelectButton helper styled as a brae trigger pill.

Field primitive (web/src/components/ui/field.tsx):
- SearchInput + TextInput + Label. 28px tall, hairline border,
  sky focus ring. Replaces the half-dozen ad-hoc inputs across pages.
- SearchInput supports Enter onSubmit and inline clear button.

Contacts browser (ContactsTable.tsx, rewritten in place):
- Standalone view: PageTopbar (Import / Export / New) + StatStrip
  (All / Subscribed / Unsubscribed / In campaigns, clickable filters)
  + SectionBar (search + sort dropdown + filters).
- Embedded view: skips topbar/strip, drops into SectionBar — used
  inside /app/campaigns/[id]/leads.
- Dense table with avatar + name + email-mono, optional company /
  phone columns (hidden on smaller widths), subscribed/unsubscribed
  pill, campaigns count, created date.
- Bulk selection floats a footer bar with Edit / Delete / Clear.
- Load-more button for infinite scroll (preserves the existing
  useInfiniteQuery hook).
- Sort dropdown wired to the existing SearchContacts API params.

Unibox email browser:
- ConversationList: SectionBar header with count, SearchInput, all /
  unread tabs with unread count badge, dense rows with avatar +
  bold-when-unread sender + subject + preview + relative time.
  Unread items get a thin sky rail on the left margin.
- ConversationItem: relative time formatter, name extraction from
  "Name <email>" headers.
- ThreadView: 48px topbar (subject + mark-unread/archive/delete
  actions) + section bar (n messages / k participants) + a divided
  message stream + composer pinned to bottom.
- MessageBubble: no card chrome; just hairlines between messages.
  Sender avatar + bold name + mono email + recipient line + mono
  timestamp; prose-rendered body.
- ReplyComposer: edge-to-edge textarea with footer bar (Send,
  Schedule popover with "in 1h / tomorrow 9 / next Mon 9", Discard,
  char counter). ⌘+Enter to send.
2026-05-23 04:17:42 +00:00
2026-02-14 05:49:49 +01:00
2026-02-14 05:49:49 +01:00
2026-01-30 04:29:07 +01:00
2026-01-17 09:19:43 +00:00

Warmbly

Open-source email warmup and cold outreach platform.

Overview

Warmbly is split into a control plane (API, consumer, tracking, realtime, web) and a worker fleet (distributed sender processes running on VPSes around the world). The control plane runs in one place; workers run on as many machines as you want so cold mail flows through many distinct IPs.

Workers are added and managed from the admin dashboard over SSH. Credentials are stored encrypted (KMS envelope encryption) and live-editable. Worker images can auto-update from GitHub Releases when a new tag is published.

The project is open source and self-hostable end-to-end.

Architecture

The frontend (React) talks to three control-plane services: the Backend API (Go), Realtime (Elixir/Phoenix), and Tracking (Rust). All three publish events to Kafka (Avro + Schema Registry). The Consumer (Go) reads those events and updates Postgres state. Workers (Go) execute sends and mailbox sync, subscribing to per-worker Kafka topics; they never touch Postgres directly.

Component Technology
Backend API Go 1.25, Gin
Consumer Go, Kafka consumer
Worker Go, distributed across VPSes
Tracking Rust, Axum
Realtime Elixir 1.18, Phoenix Channels
Primary DB PostgreSQL 16
Cache Redis 7
Message bus Kafka + Schema Registry
Object store S3 (or compatible)
Encryption root AWS KMS (or compatible)
Per-user secrets DynamoDB (or compatible)

Services

Service Port (local) Plane Description
Backend 8080 control REST API, auth, business logic, worker orchestration
Tracking 3000 control Open/click pixel + redirect service
Realtime 4000 control WebSocket gateway
Consumer control Kafka event processor
Worker execution SSH-managed sender process, one per VPS
Web 5173 Vite dev server (frontend)

Quick Start (local dev / simulation)

There's a single docker-compose.yml at the repo root that runs everything for local development, including LocalStack for KMS/DynamoDB/S3, stripe-mock, mailpit, and the optional kafka-ui debugger.

# Default: infra + app + one worker
make dev

# Full simulation: adds premium + dedicated workers
make sim

# Load rich fixtures (3 orgs, 6 mailboxes, a campaign, suppressed contacts)
make seed

# Optional debugging UIs (kafka-ui at :18090)
make tools

Service URLs (all ports offset to avoid clashes with locally-installed daemons):

See resources/local-development.md for the full setup.

Project Structure

  • cmd/ — service entrypoints: backend/, consumer/, worker/, seed/
  • internal/ — Go code:
    • api/ — HTTP handlers and routes
    • app/ — application services (auth, email, campaign, worker_orchestrator, releases, etc.)
    • config/ — env-first config with optional AWS Secrets Manager
    • events/ — Kafka schemas
    • infrastructure/ — database, cache, queue, KMS, S3, Dynamo clients + SQL migrations
    • models/ — domain types
    • repository/ — data access
  • tracking/ — Rust tracking service
  • realtime/ — Elixir WebSocket service
  • web/ — React frontend (Vite + React Router + Tailwind)
  • scripts/ — VPS install script + LocalStack bootstrap
  • deploy/docker/ — Dockerfiles
  • resources/ — technical documentation

Worker Deployment

Workers run on real machines so cold-mail traffic spreads across many IPs. There are two ways to bring one up:

From the admin dashboard (recommended). Admin opens /app/admin/workers, fills in the host + port + user, gets back an ed25519 public key. Admin pastes it into ~/.ssh/authorized_keys on the VPS, clicks Test, then Install. The backend SSHes in, runs the installer, configures systemd, and starts the worker container. From then on, all lifecycle ops (restart, update, rotate keys, system updates, logs, reboot) happen from the dashboard.

From the VPS itself. Same installer, run by hand:

curl -fsSL https://get.warmbly.com/worker | sudo bash -s -- \
  --kafka kafka.example.com:9092 \
  --schema-registry https://schema.example.com \
  --redis redis://cache.example.com:6379 \
  --aws-region us-east-1 --aws-key ... --aws-secret ...

Worker identity is derived deterministically from the VPS's public IPv4 (UUIDv5), so the same IP always resolves to the same worker. Reputation persists across reinstalls.

See resources/deployment-guide.md for the full flow.

Credentials and Profiles

Workers don't carry hardcoded credentials. Two reusable entities, both editable from the dashboard:

  • AWS Credentials — named keypair; secret encrypted via KMS-wrapped DEK.
  • Worker Profile — bundles Kafka + Schema Registry + Redis + image tag + AWS reference. One profile, many workers.

Workers reference a profile. When you edit the profile, assigned workers show a "stale config" banner; one click rewrites /etc/warmbly/worker.env and restarts each one.

Auto-Update from GitHub Releases

Each worker profile picks a release channel:

  • pinned — manual image tag
  • stable — latest non-prerelease GitHub Release
  • dev — latest release (including prereleases)

When auto_update is on and a new release fires the webhook, the backend resolves the channel, updates each assigned worker over SSH (regenerates the systemd unit with the new image, pulls, restarts), and records the running version.

Push-driven, not poll-driven: one check on backend boot, then the GitHub webhook (POST /webhooks/github/releases, HMAC-validated). Admin can also click "Check now."

All configuration is env-driven so self-hosters can point at their own fork:

RELEASES_ENABLED=true
RELEASES_GITHUB_REPO=youruser/yourfork
RELEASES_WORKER_IMAGE_REPO=ghcr.io/youruser/yourfork/worker
RELEASES_WEBHOOK_SECRET=<shared secret>
RELEASES_GITHUB_TOKEN=<optional, raises API limits>

System Updates

The dashboard can also run OS package upgrades on each VPS (apt / dnf / pacman / apk), detect whether a reboot is needed, and reboot on demand. Reboots are never automatic.

Building

go build -o bin/backend  ./cmd/backend
go build -o bin/consumer ./cmd/consumer
go build -o bin/worker   ./cmd/worker

cd tracking && cargo build --release
cd realtime && mix deps.get && mix release
cd web      && pnpm install && pnpm build

CI / Release Flow

GitHub Actions builds + pushes images to GHCR:

Workflow Trigger Result
ci.yml PR/push Tests, linting, security scan
build-push.yml Push to main :{sha} and :dev tags
release.yml Tag vX.Y.Z :vX.Y.Z, :vX.Y, :vX, :prod tags + GitHub Release

The control plane (backend / consumer / tracking / realtime / web) auto-deploys via Railway. Workers update via the dashboard or auto-update flow described above — they're the only service that needs in-band update orchestration.

See resources/cicd.md.

Documentation

License

Licensed under the Apache License 2.0. Copyright 2026 Mindroot Ltd. See LICENSE.

Languages
Go 41.3%
TypeScript 36.5%
Swift 9.9%
Astro 9.3%
Elixir 0.9%
Other 2%