Add a thread_id filter for scheduled Unibox sends and expose a per-thread hook for the dashboard.
Render queued sends inline in ThreadView with cancellation, refreshing the thread, scheduled list, and overview caches after cancel.
Open the reply composer only after the user chooses a message to reply to or forward, and wire per-message actions through the thread view.
Also clean up cancelled scheduled sends from Cloud Tasks on a best-effort basis while keeping the database status as the source of truth.
Create the initial per-campaign Cloud Tasks wakeup when a campaign starts, using the same idempotent task chain as subsequent sends. Pause or complete campaigns instead of leaving active campaigns with no scheduler when no work can be queued.
Limit organizations to 50 active campaigns before starting another campaign, make campaign wakeups idempotent per campaign, keep warmup wakeups idempotent per mailbox, and no-op stale non-pending campaign/warmup dispatches.
Two files were unformatted, tripping the golangci-lint gate that runs
gofmt:
- cmd/backend/main.go: dailythrottle import out of alphabetical order
- internal/repository/pg_admin_outreach.go: numbered list comment used
three-space indentation; gofmt wants two
Both fixed by running gofmt -w against the offending files.
Add a "Working In This Repo" section near the top of AGENTS.md (which
CLAUDE.md symlinks to) documenting the rules this PR violated:
- go build is not the ship signal; CI runs gofmt via golangci-lint
- always gofmt -w changed Go files before considering work done
- run the right typecheck/lint step in each frontend tree before
pushing
- commit messages do not carry Co-Authored-By or other AI/agent
attribution footers
The HardCap* constants stop "you have 5000 campaigns on this org"; the
throttles in this commit stop "you created 1000 campaigns today on a
fresh unlimited account." Different shape, different abuse, different
mechanism — Redis-backed per-(scope, resource, UTC-day) counters that
reset by key design at midnight UTC, no scheduled job needed.
New service internal/app/dailythrottle:
- CheckAndIncrement(scope, resource, ceiling) atomically bumps the
counter and returns errx.TooManyRequests when the post-increment
value exceeds the ceiling.
- 25h TTL so the key always expires after the day rolls over even
if the process restarts before midnight.
- Fail-open when the cache is absent (jobs/tests) so creation paths
that haven't been wired with a cache still work.
Caps (config.DailyThrottleNew*):
- 20 new campaigns/org/day
- 5 new mailboxes/org/day
- 3 new workspaces/owner/day
Wired into three creation paths:
- campaign.Create — scoped on the orgID when present
- email.OAuthFinish + email.OnboardSMTPIMAP — scoped on the orgID;
fires only at actual create, not OAuthStart, so retrying a failed
OAuth flow doesn't burn the day's budget.
- organization.Create — scoped on the owner uuid (the org doesn't
exist yet)
Adds errx.TooManyRequests (HTTP 429) since no caller had one before.
emailService gains WireThrottle alongside the existing WireWebhooks
pattern so jobs / tests can build the service without a cache. Same
treatment in main.go.
The bitmask landed in 000045 with schema + UI; this commit wires the
three gates the bits describe.
- BanScopeLogin → authService.LoginConfirm checks the scope after
password verification and refuses the session
with "this account has been suspended"
- BanScopeOrgCreate → organizationService.Create checks the scope
before any other validation and refuses with
"this account cannot create new workspaces"
- BanScopeSend → emailSendService.SendEmail checks the scope
before validating the email account and refuses
with "this account cannot send email"
Adds UserRepository.GetBanState(ctx, userID) → uint32 — a single-column
read so the hot paths don't have to fetch the full user row just to
check a flag. Returns 0 when no ban (the column defaults to 0); the
caller treats 0 as "allow."
Threads userRepo into emailSendService — the only constructor change
in this commit. cmd/backend/main.go updated accordingly.
Adds GET /admin/mailboxes — paginated platform-wide mailbox list that
joins email_accounts → users → organizations so the table answers
"whose mailbox is this and where does it live" without N+1 fetches.
Search covers mailbox email / owner email / org name; status filter
defaults to active so the active surface shows first ("inactive" /
"all" both available). Provider filter speeds up "show me every Gmail
mailbox" investigations. Cursor pagination matches the rest of the
admin lists.
Frontend page surfaces warmup-on/off, send budget, and last-sync time
with red-when-never / amber-when-stale-over-24h tone so an
investigator can spot dead mailboxes fast. Mailbox email links into
the owning user's detail page; org name links into the workspace
admin so the pivot path stays one click in either direction.
Gated on AdminPermViewUsers since mailbox triage is tightly coupled to
user/org context today; a dedicated bit can be carved later if
mailbox-specific actions land.
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).
Wire the customer self-serve path for asking "please give me more
mailboxes / campaigns / contacts." Migration 000046 adds
limit_increase_requests with a partial unique index ensuring only one
pending request per (org, field) so the queue can't be spammed, plus
a CHECK requiring requested > current_effective so no-op rows never
reach an admin.
Service layer:
- SubmitLimitIncreaseRequest validates membership, rejects unknown
fields, snapshots the user's current effective limit at submission
time so the queue row carries the context the admin needs.
- CancelLimitRequest lets the original submitter walk back a pending
request; approved/rejected rows are immutable as the audit record.
- ApproveLimitRequest stamps the row and writes the corresponding
column on organization_limit_overrides via SetLimitOverrides —
same write path direct admin overrides use, so granted_by and
notes carry through and the audit log treats both flows uniformly.
- RejectLimitRequest stamps the row with required review notes.
Routes:
POST /v1/organization/:orgId/limit-requests
GET /v1/organization/:orgId/limit-requests
DELETE /v1/limit-requests/:id (submitter only)
GET /admin/limit-requests?status=pending
POST /admin/limit-requests/:id/approve
POST /admin/limit-requests/:id/reject
Admin approval and rejection both fire admin audit log entries with
field + requested + notes so the decision history survives any future
reorg of the request table.
UI (admin queue page + dashboard request form) plus the ToS clause
giving Warmbly the right to refuse any increase land in the next commit.
Add users.ban_scope INT NOT NULL DEFAULT 0 in migration 000045 so admins
can describe what a ban concretely stops (login / workspace creation /
outbound send) instead of relying on a single boolean banned_at flag
that meant "everything".
Wire flags in the BanScope enum (kept in sync with the migration) plus
a CHECK constraint guaranteeing non-negative values. Existing bans
backfill to BanScopeLogin so the historical "you can't log in"
semantics is preserved exactly — no behaviour changes silently at
deployment.
BanUserRequest gains an optional scope field, BanUser threads it through
the service to the repo write, and the UserBanDialog grows a checkbox
group with one option per flag. Reason still required; at least one
scope must be picked. Audit details now include the scope bitmask.
Runtime enforcement (refusing login when BanScopeLogin is set, etc.) is
intentionally separate from this commit — the existing codebase doesn't
yet have an active ban check anywhere, so wiring that lives across the
auth middleware, org-create handler, and emailsend service. This slice
ships the schema, the audit story, and the UI vocabulary so the
enforcement PR can land without database churn.
Wire the write path for the override table from the previous migration:
GET /admin/organizations/:id/overrides view_organizations
PUT /admin/organizations/:id/overrides manage_organizations
PUT is a partial upsert — nil fields leave existing values untouched,
and 0 explicitly removes that column's override (back to plan default
or the product hard cap). Every write stamps granted_by/granted_at and
fires an admin audit log with the diff the admin asked for.
Introduce product-level hard caps in config/constants.go so plans that
advertise "unlimited" still have a real backstop: 200 mailboxes, 500
total campaigns, 100 active campaigns, 100 team members, 1M contacts,
1k daily campaign sends. GetEffectiveLimits resolves per-field as
override > 0 ?: plan ?: hard_cap and now never returns nil pointers,
so downstream limit checks compare against a concrete ceiling on every
plan tier. CanAddMember / CanAddCampaign / CanAddEmailAccount now call
GetEffectiveLimits instead of GetOrganizationLimits, so admin overrides
and the product hard cap both bite at runtime.
AdminOrgDetail surfaces three limit blocks side-by-side — plan, raw
override row (0 = inherit), and effective limits — so the UI can show
exactly where each enforced number came from. Slice 2 UI lands in the
next commit.
Daily creation throttles ("no 1000 new campaigns in one day even on an
unlimited plan") are explicitly out of scope; they need a per-day
counter, tracked as a TODO on the hard-cap block.
The deliverability-data and DNS-write integrations were over-engineered
for the cold-email segment. Postmaster and SNDS require sending volume
our base typically does not hit, and no comparable cold-email tool
exposes DMARC ingestion or native DNS writes. Replaces the catalog with
the standard set: HubSpot, Salesforce, Pipedrive, Close, Zapier, Make,
n8n, Slack, Discord, Calendly, Cal.com, Google Sheets.
Removes dmarc_reports, dmarc_record_rows, postmaster_snapshots, and
dns_verifications tables from the migration. Deletes dmarc.go, dns.go,
cloudflare.go, postmaster.go from the integration package. Prunes the
matching repository methods and HTTP handlers.
Add two new admin permission bits — view_organizations (bit 20) and
manage_organizations (bit 21) — and switch the existing read-only
/admin/organizations routes off the borrowed AdminPermViewUsers bit they
were using as a placeholder. Backfill the three predefined roles
(support, ops, analyst) with view_organizations so existing role
mappings still resolve cleanly; super continues to pick up everything
via AllAdminPermissions.
Migration 000044 adds organization_limit_overrides, the table the next
commit's write path will target. Schema follows the "0 = inherit from
plan" convention from the design discussion: each numeric column
defaults to 0 and a CHECK constraint enforces non-negative values, so
reverting an override is a write of 0 (preserving the granted_by audit
trail) rather than a DELETE.
Bumping the permission count changes every role's numeric bitmask, so
update the make grant-admin role table to match — super is now
4194303, support 1086401, ops 1062960, analyst 1055233.
Wire three GET endpoints behind the existing admin middleware so the
admin app can browse workspaces alongside users:
GET /admin/organizations list with q/cursor/limit/sort
GET /admin/organizations/:id detail + plan/sub + limits + counts
GET /admin/organizations/:id/members full member list with joined users
The list query inlines member/email-account/campaign/active-campaign
counts via subqueries so the table can render usage without an extra
fetch per row. Detail layers GetOrganizationLimits + GetOrganizationCounts
on top of the list shape, ensuring admin sees the same numbers the in-app
limit checks enforce.
Gated on AdminPermViewUsers for now since orgs are tightly coupled to
user admin context today; a dedicated ViewOrganizations/ManageOrganizations
pair will land alongside the write paths (per-org overrides, ban scope)
in the next slice.
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.
Go CI fails on golangci-lint's gofmt check. Ran gofmt -w against
every file the linter named plus a handful of others that drifted
during the autonomous-fleet work. No semantic changes — alignment
of struct field whitespace and one mis-indented import block.
gofmt -l ./... is now empty; go build + go vet are clean.
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).
Three small follow-ups that turn the fleet management system from 'all
the pieces ship green' into 'actually produces telemetry':
cmd/worker/main.go: go workerService.RunHealth(ctx, 30s) alongside
Heartbeat. The sampler snapshots rolling 1m counters into a WorkerHealth
event via the existing event bus + codec path.
cmd/backend/main.go: background goroutine refreshes
worker_capacity_view every minute via REFRESH MATERIALIZED VIEW
CONCURRENTLY. The assignment loop, Rebalancer, Scaler, and
QuarantineEvaluator all read from the view, so it's the freshness gate
for the whole system.
internal/app/worker/event_send_email.go + health_record.go: classify
every wmail.SendResult into the right counter (auth / rate-limit /
bounce-hard / bounce-soft / success) and record SMTP latency. Falls
back to free-text message classification when the error code is
generic, so signal stays useful as new error paths are added.
End-to-end: a worker that bounces 10% of sends now lands in the
'quarantined' band within 5min of the QuarantineEvaluator tick,
auto-drains via Rebalancer, and triggers a Scaler alert if its
removal drops fleet capacity below the warning threshold.
/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.
Three closed-loop background goroutines on the backend that manage the
worker fleet without operator intervention.
Rebalancer (default 5min): for each tier, drain hot workers (>80%
utilization) onto cold workers (<50%, healthy). Safety rails: per-
mailbox 24h cooldown to prevent thrashing, max 200 in-flight migrations,
destination must be healthy or watch.
Scaler (default 1h): compute fleet utilization per tier. At >=70%
sustained, emit warning. At >=85% sustained, emit critical alert. If
AUTO_PROVISION is allowed by provisioning_policy, snapshot the active
auto-template for the tier into a new provisioning_jobs row — the state
machine picks it up and provisions the box without admin click.
QuarantineEvaluator (default 5min): inspect rolling 1h bounce/complaint
rates, transition workers between health bands (healthy / watch /
throttled / quarantined / blocked) using CLAUDE.md thresholds.
Quarantined and blocked workers are auto-drained by the Rebalancer
because ListCapacityCandidates excludes them.
Every action is written to decision_log so the admin Decisions page can
answer 'why did the system do X'.
POST /api/v1/internal/worker/heartbeat now inserts a row into the
workers table the first time an unknown worker_id checks in. Tier and
egress_kind come from the heartbeat body; subsequent heartbeats just
keep ip_addr fresh.
Means provisioned workers self-register without admin clicks — the
state machine waits for all 16 expected UUIDv5(IP) workers to ping in
during the verify step, then marks the job completed.
tierToColumns collapses the higher-level (shared_free / shared_premium /
dedicated) name down into the existing (worker_type, free_tier) columns
so the rest of the assignment logic keeps working unchanged.
Workers emit a WorkerHealth event every 30s with assigned mailbox count,
IMAP IDLE connections, memory, goroutines, and rolling 1h send/bounce/
complaint/auth-error/rate-limit counters. Consumer writes them to
worker_health_samples.
Schema additions on workers: egress_kind (cold_smtp / oauth_api /
warmup_only), health_state (healthy / watch / throttled / quarantined /
blocked), load_score (weighted utilization).
worker_capacity_view aggregates the latest hour of samples into a
per-worker capacity row used by the assignment loop. Effective capacity
= base_ceiling(egress_kind) × health_multiplier × age_ramp_multiplier
so a fresh worker earns its way up to base capacity over 72h, and a
worker with rising bounces or complaints automatically gets less load.
MailboxWeight returns 1.0 for cold_smtp, 0.05 for Gmail/Graph API
(worker IP doesn't matter), 0.4 for warmup-only. AssignWorkerToEmail
resolves the mailbox's weight and SelectSharedWorker filters by
headroom + sorts by utilization, so a 200-mailbox OAuth worker and a
16-mailbox cold worker balance fairly.
UnassignWorkerFromEmail refunds the load_score symmetrically.
provisioning.Service.Run drives a provisioning_jobs row through the
full lifecycle (create_server, create_ips, assign_ips, set_rdns,
install, verify). Each step records progress to the DB before the
next transition so a backend crash mid-provision is resumable from
the current state.
JobConfig is the in-row snapshot of the template (or inline custom
config) at the time of submission — mutating the template later
doesn't retroactively change in-flight or historical jobs.
WorkerIDForIP is the canonical UUIDv5-from-IP helper, kept in sync
with cmd/worker and scripts/install-worker.sh so the state machine
can compute the expected worker_ids before they heartbeat.
Installer is a tiny SSH-driven interface (Install only) with a
StubInstaller for tests. The real impl plugs in worker_orchestrator
or any other transport (cloud-init, Ansible) without touching the
state machine.
On any step failure, rollback unassigns + deletes provider-created
IPs and deletes the server, leaving no orphaned resources at the
provider before marking the job failed.
pg_provisioning.go covers cloud_credentials, provisioning_templates,
provisioning_jobs, and provisioning_policy. Job repo exposes the
state machine helpers the orchestrator needs (UpdateState,
RecordServer, AppendIPs, AppendWorkerIDs, MarkFailed, MarkCompleted)
so the package doesn't reach through *db.DB itself.
pg_decision_log.go is a thin insert + recent-history reader for the
audit trail powering the admin Decisions page.
cloudprovider.Provider interface (Locations, ServerTypes, Images,
Verify, CreateServer/DeleteServer, CreatePrimaryIP/AssignPrimaryIP/
UnassignPrimaryIP/DeletePrimaryIP/SetReverseDNS). One impl today
(Hetzner Cloud); adding OVH or Vultr later means implementing the same
six surfaces.
hetzner.Client is a minimal idiomatic Go REST client over
https://api.hetzner.cloud/v1. Bearer-token auth. Returns provider-
native IDs as strings so the orchestration layer can persist them for
rollback.
9 tests against httptest.Server covering token transmission, error
surfacing, parsing, request-body shape, and interface conformance.
Foundation for autonomous fleet management.
cloud_credentials stores encrypted API tokens per cloud provider.
worker_profiles bundles the env vars that get rendered into
/etc/warmbly/worker.env at install time.
provisioning_templates is a customizable saved config — every Hetzner
option the admin form exposes lives here, so the cheapest-US-single-IP
setup is a one-click pick once you've saved it.
provisioning_jobs is the state machine (pending -> creating_server ->
creating_ips -> assigning_ips -> setting_rdns -> installing ->
verifying -> completed | failed -> rolling_back).
provisioning_policy is per-provider budget caps + the auto_provision
toggle the scale loop checks.
decision_log records every automated action so admins can audit what
the system did and why.