Store per-step branching conditions, validate branch targets, and select campaign contact steps by following the flow graph. Preserve linear progression for steps without branch conditions.
Add authenticated session listing and revocation APIs, track the auth provider on sessions, and expose active session controls in account security settings.
Add passkey enrollment and login wiring, including a Safari-safe explicit login path that prefetches the WebAuthn challenge before the click and calls the credential ceremony immediately from the user gesture.
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.
open CanUseWarmup and CanUseUnibox to free-trial orgs during the 14-day
window. add CanAddInbox feature gate and a FreeTrialInboxLimit of 1 so
trial orgs cannot seed the warmup pool with disposable accounts. enforce
the cap on both OAuthStart (avoids wasting the round-trip) and
OnboardSMTPIMAP, with distinct error messages for cap-reached vs
trial-expired.
Two real bugs surfaced from "All folders / Newest dropdowns don't open"
and "hex color must be a valid string":
1) Dropdowns silently no-op (broken across the whole dashboard)
PopoverMenuTrigger asChild uses React.cloneElement to inject
onClick / ref / aria-expanded onto the trigger child. SelectButton
was a plain function component that destructured a fixed prop set
and rendered its own <button> — so the injected props were
dropped on the floor. Click did nothing.
Fix: SelectButton is now React.forwardRef + spreads {...rest} onto
the inner button. The injected click handler reaches the real
element, the dropdown opens, the menu renders, and selection
actually applies state.
Every PopoverMenu trigger using SelectButton was affected — that's
campaigns (folders + sort), emails (tag filter), contacts (sort +
filters page rows). All now work.
2) Adding a folder/tag failed with "hex color must be a valid string"
The /folders + /tags POST landed on groupRepository.Create with
an empty color and the validator rejected. Even before the color
check, the INSERT used tx.QueryRow + Scan against an INSERT with
no RETURNING clause, which always errored with
"sql: no rows in result set" once it got past validation.
API improvements (kept the design but made it forgiving):
- Color defaults: if the request omits color, the server picks one
from an 8-swatch palette based on the new item's position. Two
consecutive creates won't end up identical. Non-empty but
invalid still 400s — that's a client bug worth surfacing.
- Title min length 3 → 1. "Q1", "VIP", short names are common
and shouldn't fail. Trimmed before validation so " " doesn't
pass.
- INSERT now uses tx.Exec instead of QueryRow.Scan — the broken
code would never reach success even when validation passed.
Verified end-to-end:
POST /folders {"title":"Q1"} → 200, color=#94a3b8 (default).
POST /folders {"title":"Q2","color":"#38bdf8"} → 200.
POST /tags {"title":"VIP","color":"#10b981"} → 200.
Frontend:
- createFolder / createTag clients accept an optional color param.
- LabelListModal now picks a default palette color when entering
add-row mode (rotating with item count) and offers a swatch
popover to override before submitting. Selected color is sent to
the backend.