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.
Replace the AnalyticsPage stub with the full chart pack over the
existing /admin/analytics/* endpoints. Four-up trend cards on top
(users / emails / campaigns / revenue growth vs. previous period),
then a 30-day stacked bar chart for daily email volume (delivered /
replied / bounced), and a two-up row with hourly-by-today plus a
sorted worker-load list that links into each worker's detail page.
No chart library — bars are CSS divs so the admin bundle doesn't pay
for recharts/d3 for this one screen. Hover tooltips on the bars carry
the per-day breakdown.
User-growth strip lives below the email charts for symmetry with the
Overview's "platform pulse" framing.
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).
Three surfaces close the loop on the limit-increase workflow:
- admin/dashboard/LimitRequestsPage.tsx queues every pending request
with full context (org → users → field → current vs requested →
+delta) and one-click approve/reject. Both actions open a review
dialog; approve notes are optional, reject notes are required and
surface to the customer.
- web/settings/limits/page.tsx is the customer-facing form. Resource
selector, requested value, reason textarea, plus a list of every
past request with its status (pending/approved/rejected/cancelled)
and the reviewer's notes when present. Pending rows expose a
cancel link. Footer links to the ToS limits clause.
- site/terms.astro grows a new section 07 ("Usage limits and
increase requests"). Explicit: "unlimited" means no plan-tier cap
but a product-wide hard ceiling still applies, increases are at
Warmbly's sole discretion, and previously granted increases can be
revoked when reputation signals deteriorate. Bumps every existing
section heading and id from 07 onward.
Admin sidebar grows a "Limit requests" entry under Accounts (Gauge
icon). Web settings layout grows a "Limits" section under owner-only
sections.
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.
Replace the PlansPage stub with the real plan catalog over /admin/plans.
The list view surfaces visibility (public/private with a colored badge),
price + discounted price, and the four limit columns most often
touched: mailboxes, campaigns, members, contacts, plus daily-email
budget.
Edit dialog covers every safely-editable field — name, price, the four
org limits, daily caps, account limit, dedicated worker count, and the
public flag. Stripe price/product IDs are intentionally read-only;
those must be managed in Stripe and flow back via webhook.
Add /enterprise page wired to /admin/enterprise/inquiries. Sales-style
pipeline: pending → contacted → converted | declined, with an inline
<select> on each row so triage is one click per inquiry rather than a
detail-page round-trip.
Each row shows company, contact, estimated volume, team size, and the
free-form notes from the marketing-site form. Pending is the default
filter so the queue surfaces first; "All" reveals historical decisions.
Added a sidebar entry under Accounts (Briefcase icon) so the inquiry
queue is one click away from the rest of the customer-facing admin.
Replace the CampaignsPage stub with a real list backed by
/admin/campaigns. Search by name, filter by status (active/paused/done/all),
inline engagement counters (contacts, sent, opens, reply %, bounce %).
Bounce rates >5% are tinted red so abuse review is one glance.
Force-stop opens a dialog requiring a reason; the reason is written to
the admin audit log. Stop is disabled on completed/draft campaigns to
prevent accidental clicks.
Org name in each row links into the workspace admin page so an
investigator can pivot from "this campaign looks bad" to "who is
sending it, on what plan, with what override history" without leaving
the admin surface.
No backend changes — every endpoint already existed.
Replace the WarmupPage stub with the real safety surface CLAUDE.md
treats as the platform's most critical. Three layers, all wired to
existing /admin/warmup/* endpoints:
- Health summary: four cards (total participants by state, at-risk
count, avg spam-folder placement rate with green/amber/red tone,
blocked count). Refetches every 30s so an investigator sees pool
drift in near-real time.
- Per-pool table: free + premium with total/active/blocked counts.
Premium gets the purple badge to make the policy-isolation point
obvious at a glance.
- Blocked mailboxes: list with one-click unblock action. Appeals
state surfaces inline as an amber badge when present.
- Appeals queue: pending only by default with an approve/reject pair
that opens a review dialog requiring notes (notes land in the
audit log and may be shown to the appealing user).
No backend changes — every endpoint already existed.
The animated fanout SVG was not landing. Removes the entire diagram
panel so the page goes hero, then How it works, then categories.
Tightens the hero bottom padding now that nothing overlaps it.
Drops the fake floating dashboard. The new hero panel shows a real
story: a single campaign.reply_received event lands on the Warmbly hub
and fans out to five concrete provider channels (Calendly meeting
booked, HubSpot activity logged, Slack #sales notified, Cal.com demo
booked, Pipedrive deal advanced). Each channel has a traveling SVG
packet animation. Subtle pulsing hub ring and per-card receive pulses.
The diagram tells the integrations value proposition directly instead
of showing generic dashboard rows.
Adds /users/:id with the full preview payload (profile, orgs the user
belongs to, connected mailboxes with provider/status/warmup state, and
ban history) plus two action surfaces:
- UserBanDialog: ban or unban from the same component, reason field
required so the audit trail always carries it. Banning is disabled
for admin accounts at the button level mirroring the backend's
"cannot ban admin users" guard.
- UserRateLimitsDialog: per-user override editor for daily emails,
max concurrent connections, and the three websocket per-minute
caps. Same "blank = no change, 0 = clear override, positive =
explicit cap" convention as the org limit overrides for consistency.
Organization rows on the detail page link straight into the
organizations admin so an ops investigator can pivot user → workspace
→ workspace owner without ever leaving the admin surface.
Replace the UsersPage stub with a real list view. Search covers name and
email, status toggle splits active / banned / all, and an "admins only"
checkbox filters down to accounts with admin_permissions > 0. Each row
shows orgs, mailboxes, and campaign counts inline so abuse review is
one glance; admin accounts get the amber ADMIN badge from the same
visual vocabulary as the app shell.
Backend endpoints already exist — this commit only adds the typed API
client (admin/users.ts) and the page itself. Drill-in to the detail
page lands in the next commit alongside ban/unban and rate-limit
override editors.
Replaces deliverability + DNS sections with CRM, Automation,
Notifications, Meetings, and Data. Mock dashboard reflects the new
provider mix. Number-strip and copy adjusted to match the
twelve-provider catalog.
Replace the two-column "Plan limit + Headroom" usage table with a
five-column view showing Used / Plan / Override / Effective / Headroom.
The override column highlights non-zero entries in the amber accent so
admin-set caps stand out from plan defaults. Headroom bars compute
against effective, not plan, so they reflect what the runtime actually
enforces.
Add OrganizationOverridesDialog — a modal launched from the detail page
that does a partial PUT against /admin/organizations/:id/overrides.
Each numeric field is blank by default (no change on submit), 0
explicitly removes that column's override, and a positive value sets a
new ceiling. The plan default and current effective value are shown
alongside each input so the admin can see what they're about to change
before saving. Notes field is required when the override is granted so
other admins can read why.
When a save succeeds the org detail query is invalidated so the
plan/override/effective columns refresh in place, and the timestamp +
notes footer below the usage table updates accordingly.
Removes DMARC reports, DNS verifier, Postmaster snapshots, and DNS
verifications from the dashboard. Adds connect-drawer fields for the
new catalog set (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make,
n8n, Slack, Discord), keeps Calendly, Cal.com, and Google Sheets.
Category order is now CRM, Automation, Notifications, Meetings, Data.
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.
Replace the OrganizationsPage stub with a real list view over
/admin/organizations: search by name/slug/owner email, status filter
(active / pending deletion / all), and inline counts (members, mailboxes,
campaigns + active) per row. Owner banned status surfaces as a red badge
on the row so abuse review is one glance.
Add OrganizationDetailPage at /organizations/:id that composes
/admin/organizations/:id with /admin/organizations/:id/members. Header
summarises owner / plan / lifecycle in three cards; body renders
usage-vs-plan-limits with green/amber/red bars (over-limit shown in red,
"no cap" for unlimited plans) and a members table tagged with role icons.
When the per-org override layer lands the usage table will gain a
"source" column (plan default vs admin override) so a 0 in an override
column reads as "no admin change" by design.
Keeps the page structure (hero, floating dashboard, three-step connect
diagram, four category sections, webhooks + API, security, at-a-glance,
FAQ, CTA). Rewrites every headline and body in straightforward
descriptive prose. Drops the editorial "Stance" critique section.
Removes em dashes throughout in favor of periods, commas, and
parentheses.
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.
The admin and marketing site sit outside the compose stack, so `make app`
never started them. Add `make admin` and `make site` to launch each
workspace's dev server (Vite on 5174, Astro on 4321), and update the root
README and admin/README to point at them instead of the old "open
localhost:5174" line that implied `make app` was enough.
Also add `make grant-admin EMAIL=... [ROLE=super|support|ops|analyst]`
plus `make revoke-admin` so the first super-admin can be seeded without
hand-writing SQL. Role bitmasks mirror AdminRolePermissions in
internal/models/admin_permission.go.
Strips the stance section, mock dashboard, 3-step model, "by the numbers"
strip, and FAQ — leaves a clean catalog: hero, four category grids
(Deliverability, DNS, Meetings, Data), and a Webhooks + API block with
the real event types. Each integration card now shows its auth method
explicitly.
Full rebuild matching the deliverability/warmup/developers pages:
HeroAtmosphere, floating dashboard mock, opinionated stance section,
3-step connection model, per-category sections (deliverability, DNS,
meetings, data), webhook event surface with sample payload, security
strip, by-the-numbers footer, FAQ accordion, CTA.
Content frames the tier 1/2 prioritization decided in the planning
session — short catalog (nine providers, all directly load-bearing)
plus an open webhook stream that anyone can build on top of.
Mirrors the backend models in TypeScript and adds React-Query hooks for
catalog, connections, DMARC reports, meeting bookings, DNS verifications,
and the connect / disconnect / verify mutations.
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.