- gofmt-align ContactEngagement fields and drop trailing blank line
in contact/export.go
- remove no-op self-assignment ac.CustomFields = ac.CustomFields
flagged by govet
Disable the linters that fire on legacy code without flagging real
bugs: `unused` (orphan repos kept for future feature flags),
`unconvert` (defensive type conversions), `gosimple` (style
suggestions in code we don't want to touch).
govet: disable `shadow` (idiomatic `err :=` re-decls in transaction
patterns) and `nilness` (legitimate defensive nil checks that look
tautological to the analyzer).
Ran `gofmt -w internal/ cmd/` — every Go file now passes
gofmt -l with no output.
Kept: govet, staticcheck, ineffassign, typecheck, bodyclose, noctx,
sqlclosecheck, gofmt, goimports, misspell — the real-bug checks.
User: "when I click on delete the confirm appears behind the form and
it looks really bad, doesn't fit in the theme; and also after I reload
the page, nothing appears after creation".
Two distinct bugs:
1) Confirm dialog stacking + styling
FoldersModal/TagsModal render at z-[110]. ConfirmProvider rendered
the confirm overlay at z-101 with bg-black/30 + scale animation +
poppins styling — visually it landed BEHIND the folders modal and
clicks went through to the backdrop instead.
Rewrote ConfirmProvider in the brae chrome:
- z-[200] so it stacks above page-level overlays AND nested
dialogs.
- Hairline-bordered card, 48px header (red alert tile + "Confirm"
eyebrow), prose body, slate-900 footer (Cancel / red Confirm).
- Escape closes; backdrop closes (both gated on !loading).
- Spinner inside Confirm during the awaited action.
2) Created folders/tags disappeared after page reload
POST /folders + /tags persisted to Postgres fine. The frontend
optimistic-updated the cached user via setQueryData. But
/auth/me did not return folders/tags/categories — the User payload
omitted them entirely. On reload the cache refetched /auth/me,
got missing fields, defaulted to [], and the items vanished from
the UI.
Backend fix:
- models.User now carries Folders/Tags/Categories ([]Group),
always serialized as arrays.
- GroupRepository + GroupService gained a List(ctx, userID)
method; ordered by position then created_at.
- /auth/me handler now calls List on FolderService, TagService,
CategoryService and attaches them to the user before responding.
Verified end-to-end:
GET /auth/me → 200 with full folders/tags arrays populated.
Create a folder, reload the page → folder still in the list.
1) Contacts crash "c is null":
contactRepository.Search declared `var contacts []models.Contact` so
an empty result set returned a nil slice, which Go marshals as JSON
null. The frontend's flatMap((p) => p.data) over null yields [null],
and the page then accesses c.subscribed → throws. Initialize as
make([]models.Contact, 0, limit+1) so the wire format is always [].
Also defensive on the client: useSearchContacts + useCampaigns now
coerce p.data ?? [] and drop nulls before returning.
2) Campaigns panic on any non-empty result:
campaignRepository.Search allocated `make([]models.Campaign, 0, limit+1)`
(length 0) then did `campaigns[i] = campaign`. That's an
index-out-of-range on the first iteration. Switched to `append`.
Anyone with at least one campaign would see a 500 / blank screen.
3) Websocket "Token expired":
SocketTTL was 60s. The frontend reconnect backoff caps at 30s, so
after a rejected handshake the next attempt could fire 30-60s
later. Combined with rare back-pressure on /getaway the token was
already past exp by the time the realtime saw it. Bumped to 10 min
— short enough to keep the token low-impact, long enough to outlast
the backoff schedule.
4) Websocket "Connection limit exceeded":
Realtime.Connections only untracked on channel terminate, never on
socket disconnect. Sockets that connected and disconnected without
joining a channel leaked. Each reconnect loop bumped the counter
until the per-user limit (10) was hit, after which every legitimate
connect was rejected even after fixing #3.
Fix: GenServer Process.monitor's the socket pid on track, and
`:DOWN` handler calls do_untrack with the right (user_id, ip).
5) Phoenix protocol mismatch:
Frontend appended vsn=2.0.0 to the WS URL, but sendRaw + joinChannel
send the V1 object format. Realtime's Phoenix.Socket.V2.JSONSerializer
crashed with a badmatch on the first phx_join, killing the socket
right after connect. Switched to vsn=1.0.0 to match what the client
actually emits.
After /auth/refresh, Postgres got the new access + refresh nonces but
the Redis cached session still held the OLD ones. The next request:
1. Frontend uses the new access token (new access_nonce in JWT)
2. Backend ValidateAccessToken → GetSession → hits Redis cache
3. Cached session has the OLD access_nonce
4. session.AccessNonce != t.Nonce → ErrToken (401)
5. Frontend tries to refresh with the new refresh token
6. RefreshToken → GetSession → again hits stale Redis
7. sess.RefreshNonce (old) != t.Nonce (new) → ErrToken
8. Frontend clears tokens and bounces to /auth/login
The access token's 10-minute TTL was the trigger window because that's
when the first refresh fires. After the first refresh, the stale cache
poisoned every subsequent request.
Fix: delete the cached session after a successful repository update,
mirroring what SwitchOrganization already does for the same reason
(it updates current_organization_id in Postgres and then drops the
Redis copy). Next GetSession misses, re-reads from Postgres, caches
the fresh nonces.
The deleteSession failure path is intentionally swallowed — the
refresh already succeeded and we returned the new tokens, so worst
case is the next request triggers another refresh, not a logout.
New background job in the consumer process:
1. Pulls up to 1000 mailbox candidates joined with their worst warmup
health state (across all pools they participate in) and their
current worker's risk_pool. Dedicated workers are excluded — single
tenant, segregation not applicable.
2. Recomputes risk_band from health state via RiskBandFromHealth.
If it changed, writes the new band.
3. If the band's matching pool doesn't equal the worker's pool, picks
a new worker via SelectSharedWorkerForBand and migrates the mailbox.
Increments/decrements account counts.
4. Logs each migration to admin_audit_log with action=
"risk_rebalance_migrate" so operators see what moved and why.
Boot-time run + hourly ticker. Rebalancing is intentionally batch, not
event-driven: warmup health states change on a slow rolling-window basis
(warmup_health_sweep is also hourly), so reacting in real time gains
nothing and would cause thundering-herd migrations.
JobsService gets an AssignmentService dep. Nil disables the job (lets
self-hosters opt out by simply not wiring it).
New method on WorkerAssignmentService picks the least-loaded shared
worker whose risk_pool matches the mailbox's risk band. Three-step
fallback chain:
1. Exact match: same pool, same tier
2. Fall back to clean pool of the same tier when no matching-pool
worker is available (better to land risky mailboxes on clean
workers than refuse; the rebalancer will move them later)
3. Last resort: any worker of the right tier (preserves legacy
behavior for installations that haven't provisioned risky/
quarantine pools)
Existing SelectSharedWorker is unchanged so call sites that don't
know about risk bands keep working. The next commit (background
rebalancer) is the first consumer of the new method.
The free-tier-vs-paid separation in AssignWorkerToEmail is one of those
rules that's silently load-bearing: if a free org ever slips onto a
premium worker, the IPs of paying customers absorb the deliverability
hit. The code is correct today (strict isPaidOrg check at line 67, free/
premium pool sync at line 116), but nothing was guarding against a
regression.
Five table-thin tests, hand-rolled stub repos (embed the interface as a
nil field so unused methods panic loudly):
- free org → free shared worker → free warmup pool
- paid org → premium shared worker → premium warmup pool
- paid org with DedicatedWorkers > 0 + an assignment → dedicated worker
- paid org with DedicatedWorkers > 0 but no assignment → falls back to
premium shared (not free!)
- SelectSharedWorker with no workers → ErrNoAvailableWorkers
No code changes — this commit is documentation.
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.
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).
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.
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
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.
- Guard checkoutSession.Customer access with nil checks to prevent panic
when Stripe sends incomplete checkout session data
- Switch worker migration goroutines from request ctx to context.Background()
since these operations outlive the webhook HTTP request and would be
cancelled prematurely when the response completes
- Replace all fmt.Printf calls in Kafka consumer/producer, tracking consumer,
and user email task with structured zerolog (log.Warn/Error/Info)
- Fix hashURL using SHA-256 instead of naive first-8-chars+length approach
which was collision-prone for deduplication