Adds a dedicated admin path for sending platform email — distinct from
the campaign emailsend service (which sends through customer mailboxes)
so the two abuse surfaces never share code paths.
Schema (000047) adds admin_outreach_messages: every send is recorded
with sent_by, the resolved to_email, the optional reply_to, subject,
body, and a queued → sent/failed status. Failed sends keep their error
column populated for the audit log.
Extends notify.EmailNotificationService with SendOutreach so both
backends (SES + SMTP) support custom Reply-To: SES via the native
ReplyToAddresses field, SMTP via a forged Reply-To header. The
existing transactional Send() remains unchanged so no other caller is
affected.
Service (internal/app/adminoutreach) resolves recipients three ways:
to_email (raw address), to_user_id (sends to the user's account email),
or to_org_id (sends to the workspace owner). Persist-then-send-then-
mark ensures the audit row exists even if the mailer hangs, and
mark-failed captures the error string verbatim.
Routes:
POST /admin/outreach manage_organizations
GET /admin/outreach view_organizations
Admin UI: composer with recipient mode picker (email / user_id / org_id),
configurable Reply-To (defaults to support@warmbly.com so customers can
actually reply), subject + HTML body editor, and an outreach log below
showing the last 50 sends with status badges and error details. Sidebar
entry under Accounts (Send icon).
Adds an integrations app module covering the providers from the tier 1/2
plan: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS,
DMARC ingestion, and Cloudflare/GoDaddy/Namecheap DNS. One unified
migration provisions integration_connections, dmarc_reports + record
rows, postmaster_snapshots, dns_verifications, and meeting_bookings.
The service exposes a generic CRUD surface for connection state with
per-provider files for parsing (calendly.go, dmarc.go), HTTP clients
(cloudflare.go, postmaster.go, google_sheets.go), and DNS verification
(dns.go). Inbound webhook routes use per-org URL-embedded secrets so
Calendly/Cal.com/DMARC providers post directly without Warmbly auth.
DNS verifier resolves SPF/DKIM/DMARC + tracking CNAME and surfaces
fixes when a record is missing.
Brings in PR #15 (email warmup process 4) plus its preceding commits:
customer-defined warmup routing on premium pool, free-trial warmup +
1 inbox for 14 days, customer webhook subscriptions with HMAC signing
+ retry, bumped default API rate limits to 100 req/s with flat per-
user/per-plan caps, plus dev-fixture additions.
One real conflict: internal/client/smtpimap/imap/client.go added
distinct imports on each side (this branch added 'net' for the
*net.TCPAddr BindIP field; main added 'sync' for a Mutex). Kept both.
Everything else auto-merged additively:
cmd/backend/main.go - imports + handler fields + DI lines
internal/api/handler/handler.go - new fields next to existing ones
internal/api/routes.go - new route group next to existing ones
Full build + test suite pass (no regressions).
/admin/cloud-credentials CRUD + /:id/test (Hetzner Verify)
/admin/cloud-providers/:p/locations
/admin/cloud-providers/:p/server-types catalog for admin form dropdowns
/admin/cloud-providers/:p/images
/admin/provisioning-templates CRUD
/admin/provisioning-jobs list, detail, create (from
template or inline custom config)
/admin/provisioning-policy list, update (per-provider budget
caps + AUTO_PROVISION toggle)
All gated by AdminPermManageSettings (jobs use AdminPermManageWorkers).
Creating a job snapshots the template into the row's config jsonb so
mutating the template later doesn't retroactively change in-flight or
historical jobs.
The catalog endpoints proxy directly to the Hetzner API client,
returning whatever Hetzner exposes — admin UI doesn't need to know
the provider-specific shape, dropdowns just render Locations/
ServerTypes/Images verbatim.
New storage_backends table is the runtime inventory of pluggable
infrastructure choices (KMS, encrypted_keys, blob, eventbus, cache).
Each kind has exactly one active row, enforced via a partial unique
index. Read-only rows are env-var driven; UI-mutable rows can be
flipped via SetActive.
settings.Registrar reflects boot-time backend choices into the table
so the admin UI sees what's actually running.
New admin endpoints under /admin/settings/backends:
GET /settings/backends?kind=...
GET /settings/backends/active/:kind
POST /settings/backends/:id/activate
New internal endpoints under /api/v1/internal:
GET /worker/config - workers fetch runtime config on boot
POST /worker/heartbeat - liveness ping
(DEK endpoints added in the encryptedkeys commit.)
handler.Handler grows EncryptedKeys + StorageBackendRepo fields.
5 registrar tests cover create / update-and-activate / skip-when-active /
lookup-error propagation / RegisterAll stop-on-first-error using a
mock repository.
- webhook_endpoints / webhook_deliveries schema (migration 42)
- service: dispatch + endpoint crud + hmac-sha256 signing
- delivery worker drains queue using FOR UPDATE SKIP LOCKED so multiple
api replicas can run safely without duplicate dispatch
- exponential backoff (30s → 1h cap, 8 attempts then abandoned)
- REST API under /webhooks: list/create/update/delete/rotate-secret/
list-deliveries. secret only returned at create + rotate
- header convention matches stripe-style: X-Warmbly-Signature: t=<unix>,v1=<hex>
- legacy header X-Warmbly-Token already renamed; new outbound webhook
headers are X-Warmbly-Signature / X-Warmbly-Event / X-Warmbly-Event-Id
- wired into email account connect/remove and warmup health transitions;
campaign/tracking/deliverability call sites will reuse the same
webhookService.Dispatch interface
new warmup_routing_rules table + repo + REST endpoints under /warmup/
routing. each rule matches a (sender, recipient) pair by domain, TLD,
provider bucket, or any wildcard, with a weight multiplier on the
selector. weight > 1 prefers the pairing, < 1 discourages, 0 excludes.
rules are evaluated in priority order (ascending) and combined with the
existing domain-diversity weighting. example use case: a customer can
say 'send Gmail-recipient warmup only from Google-classified senders'
with one rule; or 'never send to acme.com from this org's mailboxes'
with weight=0. premium pool only — free pool ignores rules.
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.
Backend:
- Fix contact-create 500 (nil custom_fields, doubled slice, bad RETURNING SQL)
- Avatar upload: migration 000033, S3 public-read, PNG/JPG only,
client-resized to 512px + server dimension cap (1024px)
- Pull avatar_url through user + organization repo queries
Frontend:
- Settings restructured into nested routes with a rail layout
(/app/settings/{profile,notifications,security,members,roles,
workspace,billing,danger}); flat Section/Row primitives replace
the per-card rectangles; Save buttons only render when dirty
- Standalone /app/billing and /app/team removed; legacy URLs
redirect to the settings sections; UserNav trimmed accordingly
- CRM rebuilt: Pipelines CRUD + stage editor, Deals kanban with
HTML5 drag/drop, Tasks bucketed by due-date with inline toggle.
Frontend models realigned with backend (Deal.name, CRMTask.status
enum, paginated list shapes)
- Avatars: AvatarUploader component, client-side canvas resize,
wired into Profile + Workspace settings; UserNav + OrgSwitcher
render the uploaded image with initials fallback
- RBAC: lib/permissions.ts mirrors organization_permission.go;
inline role picker in Members; Roles & access section shows the
permission matrix and per-role member counts
- Audit log page at /app/audit, gated to owner+admin via canManage
- Plans aligned with warmbly-web pricing: Starter/Grow/Business/
Enterprise via lib/plans.ts; PlanPill, billing page, sidebar
badges and LockedSurface all read from the same catalogue
- Header PlanPill shows current plan with status-aware coloring;
sidebar locked rows show the required-plan badge instead of a
generic lock icon
Perf:
- QueryClient defaults (staleTime: 30s, refetchOnWindowFocus: false,
retry: 1) — kills 3-5 round-trip storm on every navigation
- useSubscription, usePlans → staleTime: Infinity (only invalidate
on plan-change mutations); useUser/Timezones/Orgs get long stales
with refetchOnMount: false
- vite.config: optimizeDeps for heavy libs + server.warmup for the
most-mounted entry pages
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