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.
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.
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.
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.