Commit Graph
89 Commits
Author SHA1 Message Date
Matthew Meszaros 2ba16640c1 feat(admin-ui): persistent worker health banner across admin pages
WorkerHealthAlert lives in the admin layout and renders on every admin
page when any worker needs attention. Polls the workers list every 30s
in the background. Hidden on the workers page itself (which has its own
richer banner with filter chips).

Surfaces three categories:
  - errored: install_state == "error"
  - offline: installed + no heartbeat for 5+ minutes
  - in progress: pending / provisioning / uninstalling

Tone goes red when there are errored or offline workers, amber otherwise.
A "review →" link deep-jumps to /app/admin/workers.
2026-05-19 05:21:44 +00:00
Matthew Meszaros 0e9b0c7799 feat(admin-ui): audit log viewer with full filtering
New /app/admin/audit page browses admin_audit_log with:

  - Filter by action, target_type, target_id, admin_user_id, date range
  - Action + target_type dropdowns are populated from a baseline set
    AND from whatever appears in the current result (so new actions
    surface automatically without code changes)
  - Auto-refresh toggle (5s) for live tailing during operations
  - Cursor-based pagination with Prev/Next + page counter
  - Expandable rows show the details JSON + user-agent
  - Color-coded actions (red for destructive, green for create/install,
    amber for system auto-actions)
  - Renders system actions (admin_user_id = uuid.Nil) as "system"
  - Renders the admin's name + email when joined data is present
2026-05-19 05:21:36 +00:00
Matthew Meszaros 2aefb7da02 feat(consumer): log auto-reassignment events to admin_audit_log
When the dead-worker job reassigns email accounts from a worker whose
heartbeat expired, write a row into admin_audit_log so the dashboard's
audit viewer surfaces these system actions alongside admin-driven ones.

admin_user_id is uuid.Nil (the platform identity), so admins searching
the log can distinguish "system did this" from "an admin did this" by
filtering on that ID. Details include the replacement worker, account
count, and reason.

JobsService gets an optional AdminRepo dep. Nil disables logging — keeps
the contract loose for any other call site that doesn't have one.
2026-05-19 05:21:26 +00:00
Matthew Meszaros 950965fe90 fix(audit): route new admin actions to admin_audit_log
The audit calls added in the last commit went to AuditService.LogAction,
which writes to the general user-facing audit log (Cassandra). The admin
audit-log viewer at /admin/audit-logs queries the admin_audit_log table
in Postgres, so worker / credentials / release actions never showed up.

Add a public AdminService.LogAdminAction that wraps the existing private
logAction (writes to admin_audit_log with the same shape as ban_user /
update_worker / etc.). Repoint h.audit() at it.

Actions now visible in the audit viewer:
  test, install, restart, upgrade, uninstall, rotate_keys, apply,
  assign, system_update, reboot, check_releases (plus the existing
  create/update/delete across workers, AWS creds, and profiles).
2026-05-19 05:21:18 +00:00
Matthew Meszaros aaa77eecb2 test(seed): cover seedBaseline + seedRich for shape and idempotency
The seeder is one of the few things every developer runs on every new
checkout, but it had zero tests. With three migrations added in the last
few days and the rich-fixture path now creating 30+ rows, the chance of
silently breaking a schema migration without noticing was non-trivial.

cmd/seed/main_test.go connects to SEED_TEST_DB (skips otherwise — keeps
unit tests in CI fast and prevents accidentally clobbering a dev
database), runs migrations, wipes only the fixture rows, then:

  1. Runs seedBaseline twice, verifies row count stays at 1.
  2. Runs seedRich, asserts 9 different row counts match expectations
     (users, orgs, workers, accounts, campaign, sequences, contacts,
     unsubscribed contacts, campaign leads).
  3. Re-runs seedRich, asserts every count is unchanged — the most
     important guarantee the seeder makes.
  4. Verifies warmup pool membership: 2 free, 4 premium, with the
     correct accounts in each.

Plain testing package, table-driven, matches the existing style in
internal/app/warmup/service_test.go.

`make test-seed` brings up the docker-compose Postgres and runs the
suite against it.
2026-05-18 14:56:06 +00:00
Matthew Meszaros 3b9d2f3b07 feat(admin-ui): worker health banner with filter chips
Workers list now computes a health summary client-side from the existing
queries and shows a yellow banner with a count when any worker needs
attention (errored, offline, or mid-install). Filter chips below the
banner narrow the table by problem category so the admin can drill in
without opening each worker.

Categories surfaced:
  - errored: install_state = "error"
  - offline: installed, but no heartbeat in 5+ minutes
  - in progress: pending / provisioning / uninstalling
  - stale config: profile.updated_at > worker.config_applied_at
  - update available: profile.resolved_image_tag != worker.image_version

Live polling stays at 15s, so a worker going offline shows up within
~75s of the last missed heartbeat. The list also fetches profiles so the
stale-config and update-available chips can compute without extra
round-trips.
2026-05-18 14:55:54 +00:00
Matthew Meszaros 126114b34f feat(admin): audit-log every mutating worker / credentials / release action
The new admin endpoints for SSH worker management, AWS credentials,
worker profiles, releases, and system operations bypassed the existing
audit-logging pattern. Now every mutating action records a row in the
audit log with adminID, IP, user-agent, and operation-specific metadata
(never secret values — for credential updates we record which fields
were rotated, not what they became).

New action constants:
  test, install, restart, upgrade, uninstall, rotate_keys, apply,
  assign, system_update, reboot, check_releases

New entity types:
  worker, aws_credentials, worker_profile, release

Read-only endpoints (list/get/status/logs) intentionally not audited —
they don't change state and would flood the log.

Each handler calls a small h.audit(c, action, entity, &id, metadata)
helper that pulls adminID from the admin middleware and fires the
existing AuditService.LogAction (fire-and-forget, never blocks the
response).
2026-05-18 14:55:44 +00:00
Matthew Meszaros 8ae7759964 feat(consumer): sync worker heartbeats from Redis to workers.last_seen_at
Workers heartbeat into Redis every 90s as RFC3339 timestamp values with a
3-min TTL. The dashboard surfaces liveness based on workers.last_seen_at,
but until now nothing populated that column — the "Live" badge was always
red.

New 60s job in the consumer reads each active worker's Redis heartbeat
value, parses the timestamp, and writes it to workers.last_seen_at. Runs
on its own interval (separate from the 5-min dead-worker detection job,
which does heavier reassignment work) so the UI sees fresh data within a
minute.
2026-05-18 14:55:34 +00:00
Matthew Meszaros f4345c3c54 docs: rewrite README and resources for current architecture
Old docs described a k8s/ArgoCD/Terraform deployment that no longer
exists, with ASCII-art system diagrams that hadn't aged well. Rewritten
to match how the project actually ships:

- README: control plane (Railway) + execution plane (per-VPS workers)
  split, dashboard-driven worker management, credentials/profiles,
  auto-update from GitHub releases, OS package updates, self-hosting
  knobs. Removed all ASCII art.

- resources/architecture.md: control vs execution plane, encryption
  model (worker SSH keys + platform secrets under the same KMS-envelope
  cipher as user secrets), worker identity from public IPv4, credentials
  model, push-driven release flow, anti-abuse layers, source anchors.

- resources/deployment-guide.md: end-to-end from "provision a VPS" to
  "auto-update on release". No more k8s, ArgoCD, kubectl, or Terraform.
  Step-by-step backend env, webhook setup, worker add flow, day-2 ops,
  rollback per plane.

- resources/local-development.md: the five make targets (dev / sim /
  seed / tools / reset), what each profile runs, LocalStack bootstrap,
  rich seed contents, native dev against containerized infra, the
  offset-port URL table.

- resources/cicd.md: the two-plane build/release flow, image tag scheme
  ({sha} / dev / vX.Y.Z / vX.Y / vX / prod), webhook setup, release
  process, security notes around HMAC and least-privilege worker AWS
  keys.

- deploy/README.md: tight version of the same.
2026-05-18 13:09:31 +00:00
Matthew Meszaros 261cc439ad feat(admin): manage worker fleet from dashboard with encrypted credentials and GitHub release auto-update
Workers are no longer curl|sh-only. Admins add and manage them from the
dashboard over SSH, with all runtime config (Kafka, Schema Registry,
Redis, AWS keys) stored encrypted via the existing KMS-envelope cipher
service.

Worker lifecycle:
  1. Admin POSTs host/port/user. Backend generates an ed25519 keypair,
     encrypts the private key under uuid.Nil (platform identity), and
     stores the row in 'pending' state.
  2. Admin pastes the returned public key into the VPS's authorized_keys.
  3. Test connection — runs `true` over SSH, pins the host SHA256
     fingerprint on first success (TOFU).
  4. Install — backend scp's install-worker.sh + a per-worker env file
     and runs it. State moves pending → provisioning → installed.
  5. From then on: restart, update image, apply config, uninstall,
     rotate keys, tail logs, live status, OS package update, reboot —
     all dashboard buttons backed by SSH operations.

Credentials are reusable entities:
  - aws_credentials: named keypair, secret encrypted at rest
  - worker_profiles: bundles Kafka + Schema Registry + Redis + image +
    release channel, references one AWS credentials row
  - workers.profile_id links a worker to a profile; many workers can
    share one profile

Saving a profile doesn't restart anything. The dashboard compares
profile.updated_at to each worker's config_applied_at and shows a
"stale config" badge; Apply rewrites /etc/warmbly/worker.env over SSH
and restarts the unit.

Auto-update on GitHub release:
  - profile.release_channel ∈ {pinned, stable, dev}
  - profile.auto_update toggles automatic rollout
  - Trigger model is push, not poll: one check on backend boot, then
    the /webhooks/github/releases endpoint (HMAC-validated with
    RELEASES_WEBHOOK_SECRET) on every release event. Manual "Check now"
    button as fallback.
  - When a new tag resolves, the orchestrator SSHes into each assigned
    worker, runs install-worker.sh --update --image <new>, which now
    rewrites the systemd unit (not just `docker pull`) so the image
    actually changes. workers.image_version captures the running tag
    for the UI's "v1.2.3 → v1.2.4" diff.

Self-hostable: every release knob is env-driven —
RELEASES_GITHUB_REPO, RELEASES_WORKER_IMAGE_REPO,
RELEASES_WEBHOOK_SECRET, RELEASES_GITHUB_TOKEN, RELEASES_ENABLED. Set
RELEASES_ENABLED=false to disable the feature entirely.

OS-level updates and reboot are also exposed: detect apt / dnf / yum /
pacman / apk, run the right upgrade noninteractively, return the full
output and a reboot-required flag. Reboots are never automatic.

Migrations:
  000028_worker_ssh        — ssh fields, install_state enum, last_seen,
                              host fingerprint
  000029_worker_credentials — aws_credentials + worker_profiles +
                              workers.profile_id + workers.config_applied_at
  000030_worker_releases   — release_channel enum, auto_update,
                              resolved_image_tag, workers.image_version

Endpoints added:
  POST   /admin/workers                        (create + keypair)
  GET    /admin/workers/managed
  GET    /admin/workers/:id/managed
  POST   /admin/workers/:id/{test,install,restart,upgrade,uninstall,rotate-keys,apply,system-update,reboot}
  PUT    /admin/workers/:id/profile
  GET    /admin/workers/:id/{live-status,logs}
  DELETE /admin/workers/:id
  GET    /admin/aws-credentials                CRUD
  GET    /admin/worker-profiles                CRUD + /workers + /apply + /release
  GET    /admin/releases/state
  POST   /admin/releases/check
  POST   /webhooks/github/releases             public, HMAC-validated

Admin UI:
  /app/admin/workers           list with status + version columns
  /app/admin/workers/new       add form with profile dropdown
  /app/admin/workers/:id       detail with all actions + logs + system update
  /app/admin/credentials       tabs: AWS credentials + worker profiles,
                                Releases panel, channel selector +
                                auto-update toggle in profile form
2026-05-18 13:09:11 +00:00
Matthew Meszaros d25eed3eb6 feat(dev): root docker-compose with profiles, LocalStack, richer seed
Hoist the dev/sim stack to a single docker-compose.yml at the repo root.
Adds profiles (default / sim / seed / tools) so you can opt into heavier
setups, and bundles dependencies that were previously missing:

- LocalStack (KMS + DynamoDB + S3) with a localstack-init one-shot that
  idempotently creates alias/master-key-dev, the UserEncryptedKeys and
  EmailMessageData tables, and the main S3 bucket. Backend and workers
  wait on it via service_completed_successfully.
- stripe-mock for billing flows
- kafka-ui under the tools profile

Three workers with deterministic UUIDv5 hostnames (shared / premium /
dedicated) so assignment, rebalancing, and per-pool routing all have
real targets to exercise.

Richer seed (cmd/seed/main.go) loads 3 orgs across tiers, 6 mailboxes
joined to free/premium warmup pools, a Beta campaign with a 2-step
sequence, and 10 contacts (2 unsubscribed) so suppression behaviour is
visible in the UI. Idempotent — safe to re-run.

Makefile targets:
  make dev    — infra + app + one worker
  make sim    — adds premium + dedicated workers
  make seed   — rich fixtures
  make tools  — kafka-ui at :18090
  make reset  — nuke volumes
2026-05-18 13:08:34 +00:00
Matthew Meszaros 89e5533dd0 feat(worker): add one-command VPS installer with IP-derived identity
scripts/install-worker.sh is a single bash script any Debian/Ubuntu/RHEL/
Fedora/Arch/Alpine VPS can curl|sh to add a worker to the fleet.

Identity is bound to the VPS's public IPv4 via UUIDv5 (URL namespace):

  same IP  → same worker  (reputation persists across reinstalls)
  new IP   → new worker   (fresh identity, no inherited reputation)

The installer detects the public IP via api.ipify.org / ifconfig.me /
checkip.amazonaws.com, derives the deterministic UUID, installs Docker if
missing, writes /etc/warmbly/worker.env (0600) and /etc/warmbly/worker.id,
installs a systemd unit that runs the worker container with --hostname
<uuid>, and starts the service.

Supports --install/--update/--uninstall/--purge/--status, --env-file for
non-interactive config, --ip override, --image override, and a full set
of per-credential flags.

Worker reads its UUID from os.Hostname() at startup, so the systemd
hostname value becomes the worker identity — no separate registration
step needed.
2026-05-18 13:08:16 +00:00
Matthew Meszaros c229431b8c chore(deploy): drop kubernetes manifests
Workers need IP diversity, but k8s nodes typically NAT all pods through a
small set of egress IPs — defeating the point of a DaemonSet for cold mail.
Plus, the control plane is moving to Railway and workers will be managed
per-VPS, so the kustomize tree no longer reflects how anything actually
ships.
2026-05-18 13:08:06 +00:00
Matthew Meszaros 1ad8e7e995 feat: implement dead worker detection with heartbeat TTL and auto-reassignment 2026-04-12 11:14:24 +00:00
Matthew Meszaros 2f2ccb41dd feat: add exponential backoff retry for transient email send failures 2026-04-12 11:10:28 +00:00
Matthew Meszaros 3e556825d8 fix: add org scoping to contact notes and activities to prevent IDOR 2026-04-12 11:09:40 +00:00
Matthew Meszaros 562752d20e feat: skip bounced and suppressed contacts in campaign scheduling 2026-04-12 11:06:04 +00:00
Matthew Meszaros 0f883e38ac fix: record bounces in campaign progress so analytics and auto-pause work 2026-04-12 11:05:10 +00:00
Matthew Meszaros e7933ab6c5 feat: publish auth error events on critical mail errors and register token update handler 2026-04-10 07:05:39 +00:00
Matthew Meszaros f9d9ed6dc0 feat: implement IMAP sync worker and unified mail sync loop for all providers 2026-04-10 07:02:25 +00:00
Matthew Meszaros 00dac953ae fix: add nil checks on SmtpImapData and avoid slice modification during iteration 2026-04-10 06:58:41 +00:00
Matthew Meszaros 6766696ac4 fix: add 2-minute timeout context to task handlers to prevent hangs 2026-04-10 06:57:17 +00:00
Matthew Meszaros a741b4939a feat: implement worker HandleAddEmail and HandleRemoveEmail handlers 2026-04-10 06:56:35 +00:00
Matthew Meszaros fbd62ea20c fix: invert nil checks in dynamo email message Del so non-nil keys are added 2026-04-10 06:54:58 +00:00
Matthew Meszaros 58d41f3668 fix: store correct email account ID and message ID in dynamo email map 2026-04-10 06:54:31 +00:00
Matthew Meszaros b9797e9e7b chore: ignore seed binary build artifact 2026-04-10 06:32:52 +00:00
Matthew Meszaros 7f90323d55 chore: remove metrics endpoint, prometheus instrumentation, and IP rate limiter 2026-04-10 06:32:30 +00:00
Matthew Meszaros 7e123a2dac fix: remove deep health endpoint and add IP rate limiting to public endpoints 2026-04-09 16:03:37 +00:00
Matthew Meszaros 80358168ae test: add unit tests for warmup health evaluation, campaign state machine, and templates 2026-04-09 15:59:27 +00:00
Matthew Meszaros 23b5c925e4 feat: add scheduled warmup health sweep, pool health summary endpoint, and admin overview 2026-04-09 15:57:04 +00:00
Matthew Meszaros 65641ec1f2 feat: add send test email endpoint for campaign preview before launch 2026-04-09 15:52:59 +00:00
Matthew Meszaros 33092fa56e fix: add 10MB request body size limit to prevent OOM from large payloads 2026-04-09 15:50:08 +00:00
Matthew Meszaros 816c432e71 feat: add deep health check endpoint with PostgreSQL connectivity check 2026-04-09 15:49:28 +00:00
Matthew Meszaros dd1fc3d41d feat: generate List-Unsubscribe URL for campaign emails when unsubscribe header is enabled 2026-04-09 15:17:39 +00:00
Matthew Meszaros ffe6bd31ca fix: sanitize Stripe error messages to prevent leaking internal details to clients 2026-04-09 15:15:18 +00:00
Matthew Meszaros 3699774f7d fix: use timeout context for Stripe webhook migration goroutines to prevent leaks 2026-04-09 15:13:47 +00:00
Matthew Meszaros 9c4b6d21fd fix: add contact email deduplication with unique index and upsert on conflict 2026-04-09 15:12:41 +00:00
Matthew Meszaros 41aa183be2 fix: add campaign state machine to prevent invalid status transitions 2026-04-09 15:11:58 +00:00
Matthew Meszaros 36f4a94814 fix: prevent race condition in dedicated worker assignment with atomic insert 2026-04-09 14:54:22 +00:00
Matthew Meszaros 306690db0a feat: add explicit position column to sequences for deterministic ordering 2026-04-09 14:52:17 +00:00
Matthew Meszaros 421a3fb3dc fix: add campaign ownership validation to prevent IDOR in bulk campaign operations 2026-04-09 14:48:29 +00:00
Matthew Meszaros c2948d464b fix: add hard limit to pipeline list query to prevent unbounded results 2026-04-09 14:47:14 +00:00
Matthew Meszaros d903c26255 fix: add hard limit of 500 to template list query to prevent unbounded results 2026-04-09 14:46:20 +00:00
Matthew Meszaros 4f16944ff8 fix: add size limits to bulk contact operations to prevent resource exhaustion 2026-04-09 14:45:10 +00:00
Matthew Meszaros d417b1f0a4 perf: add missing index on campaign_leads(contact_id) for reverse lookups 2026-04-09 14:43:40 +00:00
Matthew Meszaros 4b8ff94201 fix: use correct column names contact_id and campaign_id in campaign_leads queries 2026-04-09 14:43:10 +00:00
Matthew Meszaros 0799020dac feat: add metrics, warmup content variety, tz-aware scheduling, org budget, admin stubs, and bug fixes 2026-04-09 14:33:45 +00:00
Matthew Meszaros 19d20e72b4 feat: add warmup health throttled state, complaint/bounce metrics, A/B analysis, and rate limiting 2026-04-09 12:51:30 +00:00
Matthew Meszaros ba9e3c096a fix: replace context.TODO with proper context in cipher and Google client
- cipher: use the already-available ctx parameter for DynamoDB Put
- goog: use context.Background for OAuth token refresh callback since
  it runs asynchronously outside any request lifecycle
2026-04-09 12:10:42 +00:00
Matthew Meszaros 91ec703093 fix: increment verification code tries on failed attempts
The Tries counter on login and registration sessions was checked but
never incremented, making the brute-force protection dead code. An
attacker could retry verification codes indefinitely within the session
TTL. Now each failed attempt increments and persists the counter.
2026-04-09 12:10:26 +00:00