* fix: retire the warmup spam score, a ratchet that grew with volume rather than misbehaviour and that no band ever read, dropping the column from the pool row and the reputation ledger and explaining a pool finding with the band's own reason instead (#491)
* test: pin the advisor snapshot's pool columns against the scan, since the band's reason now reaches the finding through that select alone (#491)
* fix: hold a warmup sentence's score and reason with the sentence itself, keep the retired spam_score key on the published analytics payload as a deprecated zero, seed the sandbox with severity-shaped scores, and record the raw spam report when the warmup service is absent (#491)
* fix: gate a warmup partner borrowed from the other tier against the pool it is in rather than the sender's, since the thin-tier fallback had rejected every borrowed candidate and a thin tier failed instead of borrowing
* fix: make cross-tier warmup borrowing one-directional and gate borrowed partners in their own pool, so a thin premium tier can actually borrow proven free mailboxes (#495)
* fix: pin the borrow floor at the exact boundary so a premium tier at the floor including its sender still borrows (#495)
* fix: put the warmup borrowing rule in one repository method (direction, floor, proven age, workspace standing) that the selector and scheduler both read, pin every drawn partner's gate to its own pool, fall through buckets when a stale row fails the gate, and allow only reply-backs across tiers (#495)
* fix: end the warmup partner draw by candidate exhaustion instead of a fixed attempt cap, and fail closed when a free mailbox's workspace standing cannot be read before it answers into a paid inbox (#495)
* fix: end the warmup partner draw by candidate exhaustion instead of a fixed attempt cap, and fail closed when a free mailbox's workspace standing cannot be read before it answers into a paid inbox (#495)
* feat: make the self-hosted pool plan buyable by resolving its Stripe price server-side behind a new /pool-link/offer and /pool-link/checkout pair, adding the plans.price_yearly column the yearly price id never had, and landing the instance's Unlimited button on a dialog that names the workspace and the billing period instead of a plans grid the non-public plan never appears in
* feat: apply the pool dialog's yearly default once per opening rather than on every offer result, so a background refetch cannot move the billing period out from under someone who already chose monthly
* fix: make the shortcuts modal and the key dispatcher one registry so a row that runs nothing cannot be written, wire j/k/gg/G/Enter/Escape and / to the screen that owns them instead of to store fields nobody wrote, unshadow g k, and give the assistant panel's resize handle the pointer capture, single store write, bounds and separator keyboard the unibox splitter already had
* fix: lock text selection for the assistant window's move and corner drags instead of cancelling their pointerdown, which took the compatibility mousedown with it and left every open popover on screen
* fix: keep a half-typed g sequence from swallowing a modifier combo, so g followed by Ctrl+K opens the command palette instead of navigating, and end the sequence when any other shortcut fires
* fix: word the inbox keyboard docs so they hold whichever way the conversation list is sorted
* fix: list the command palette combo at the end of the actions group in the shortcuts modal, where a modifier combo reads as a footnote rather than the first thing a bare-key list shows
* fix: release the page-wide selection lock from a window listener as well, so a floating-window drag interrupted by the panel unmounting cannot leave the whole app stuck at user-select none
* fix: drop the unused test-seam export from the shortcut action registry, which is the same unreferenced-helper shape this branch is deleting everywhere else
* feat: excuse one named account from the emailed login code, so a vendor reviewer who cannot read this instance's mail can sign in without turning codes off for everyone, with the reason recorded beside it and every run of warmblyctl status naming the accounts that hold one
* feat: create and manage tester accounts from the admin panel, so letting a reviewer in is a form rather than a shell, with the password shown once and every live exemption listed on one page because forgetting one is the way this goes wrong
* fix: give tester management its own permission bit rather than borrowing ban_users, create the account and its exemption in one transaction so no invisible orphan survives a failure, require an accountable operator on the CLI grant, stop a halted row scan reading as the whole exempt list, and show a failed query as an error instead of as no testers
* chore: re-run CI after the aggregator tripped on a cancelled job from the branch update, with every underlying job green
* chore: retrigger CI, the previous run sat queued indefinitely while other branches ran
* feat: roll back a half-created tester when its workspace step fails and backfill the manage-testers bit onto admins already holding every other permission, so the address is not left taken by an unusable account and the new routes are not 403 for the existing admin
* feat: make the 000151 manage-testers backfill one-way, because clearing bit 22 on the way down would also revoke it from an admin granted it explicitly afterwards and the up migration would not restore that
* fix: let a granted plan unlock the dashboard, since the client decided paid from the Stripe status a managed subscription never touches, and replace the Turnstile size Cloudflare removed so the widget renders and can issue a token at all
* feat: tell people on a preview deployment that it is a public beta, once in a dialog and thereafter as a header pill they can reopen, driven by a config value rather than a hostname so one image stays reusable, and bind both Turnstile modals through onLoad because the component is not forwardRef and execution=execute never fires without the widget instance
* fix: keep the beta pill outside the desktop-only header group so the notice stays reopenable on a phone, and say in the docs that the value is baked into config.js at container start rather than read per load
* feat: add operator-granted plans so a workspace can be paid without Stripe, surfaced in the admin panel as a badge, a filter and a card carrying who granted it and why, because the only alternative was writing a fake stripe subscription id into the database
* fix: hold a granted plan beside the paid one rather than over it so a Stripe workspace returns to the plan it pays for when the grant ends, route entitlement lookups through EffectivePlanID, separate a repository failure from an unknown plan, end a grant at local end of day, and drop an index that served no query
* feat: take AI off the free plan, zeroing the free allowance and gating the writing assistant to paid like the inbox agent already was, because a trial that can only ever be told it has no credits reads as a broken feature rather than a locked one
* feat: state in the down migration which case does not round-trip, a free plan already at zero before 000148 ran, since the up migration recorded no prior value and a zero it did not write cannot be told from one it did
* feat: fix the six defects reported in issue #439 by mapping the IMAP UNAVAILABLE, INUSE and NONEXISTENT response codes to retry-level errors instead of a critical reconnect prompt, synthesising a stable no-msgid key so one message with no Message-ID header can no longer 400 the internal map endpoint and wedge every later sync pass with its cursors held, adding mailhtml.FromText and HasContent so an API or agent-created step with a plain body stops shipping the composer's empty div placeholder as its text/html part (derived on create and plain-only update, exposed as body_html on update_campaign_step, dropped at send and preview time, and refused at campaign start with empty_step_body), honouring sender_strategy='explicit' in ResolveCampaignSenderPool and ValidateCampaignReady so an emptied explicit pool parks the campaign instead of widening it to every mailbox in the workspace, making the paused_no_accounts auto-pause loud with an error log line, an error-level activity-feed entry and an org-scoped CAMPAIGN_PAUSED realtime pulse, gating the admin sign-in's Turnstile widget on GET /v1/auth/config so a self-host with CAPTCHA_PROVIDER=none is not locked out, and parsing NATS_URL down to its host:port so a credentialed bus URL no longer reports NATS down
* feat: act on the self-review of the issue #439 fixes by dropping the campaign wizard's own escapeHtml body_html builder, which entity-escaped the quotes in a conditional and made the template fail to parse at send time, and letting the backend's FromText render that part instead so wizard-written steps also get their bare URLs linked for click tracking, correcting the docs and openapi description that claimed an explicit sender pool never falls back when it still unions its tags as migration 000013 designed, extracting the duplicated blank-HTML-part guard into dropBlankHTMLPart shared by the send path and the preview, and recording why the no-msgid key keeps the folder name despite a RENAME changing it
* feat: address the CodeRabbit review on the issue #439 fixes by holding the admin sign-in's Turnstile widget unmounted until /v1/auth/config resolves so an instance with no route to Cloudflare cannot raise a widget error on a screen nobody submitted, failing StartCampaign closed when the sequence read errors rather than skipping both the malformed-template and empty-body refusals, giving TCPCheck the default port its protocol assumes so a portless NATS_URL is no longer reported down, leaving a URL that carries a merge field unanchored because the send path renders bodies with text/template and a quoted contact value would break out of the href, and correcting the sequences guide and the Campaign and CampaignUpdate openapi descriptions that named the wrong tag field
* fix: let the realtime service actually establish its TLS connections, adding the wildcard hostname match fun Redix needs for every managed Redis and a CA bundle option for an RDS Postgres that the system trust store cannot verify
* feat: drop the redundant wget package from the realtime image since busybox already provides it, and fix a comment that read as literal interpolation
* fix: treat a blank DATABASE_SSL_CA_FILE as unset in the realtime config, because compose passes every optional variable through as empty and cacertfile with an empty path is no CA source at all rather than a fallback to the system store
* feat: make the Rust tracking publisher honour CODEC_PROVIDER on Kafka instead of always writing Avro, so a json consumer stops silently dropping every open and click, and refuse avro at boot when no Schema Registry is configured
* feat: build the tracking service's kafka feature in CI, because clippy on the default build never opens kafka.rs and that file now ships as the published tracking -kafka image
* feat: install libcurl and the rest of the librdkafka build dependencies for the tracking kafka clippy step, which fails at the first object without curl headers even with WITH_CURL=0
* feat: publish Kafka-linked images for backend, consumer, worker and tracking as -kafka tag variants built per-arch on native runners, because the cgo librdkafka link cannot cross-compile on the build-go path
* feat: carry BUILT_AT inside the Go Kafka targets' build-args instead of the shared build-native block, so tracking and realtime stop warning about an unconsumed arg, and emit the matrix with printf because echo expands the separating backslash-n in some shells