feat: ease a graduating mailbox into cold volume (#231)

* feat: ease a graduating mailbox into cold volume instead of handing it the full cap the day it joins a campaign: cold sending read warmup HEALTH but never whether the mailbox had actually warmed enough, so one at its 40/day warmup ceiling could send 50 cold the next morning, which is the post-warmup spike providers penalise; effectiveCap gains a min() term that starts the mailbox at 5, 10 or 20 a day by how long it warmed and adds 5 per clean day toward its own cap, freezing on a spam placement through the same union-of-freezes the warmup ramp uses, anchored by a new cold_ramp_started_at stamped idempotently on the first cold send and reset on org import because it raises a ceiling the destination never watched being earned; mailboxes that never warmed are not gated, since capping senders who never opted into warmup is a different decision from smoothing the transition out of it

* feat: anchor the graduation ramp on a CONFIRMED send, and make the hold the drawer reports the hold the scheduler applies: stamping cold_ramp_started_at at dispatch started the clock on a send the worker then failed, so a mailbox climbed on days it had not proven anything, and the stamp moves to the worker's EMAIL_SENT; separately the drawer computed its hold over every placement while ColdCeiling only counts placements after the first cold send, so a placement predating the ramp read as paused while the scheduler kept climbing, and both now go through one ColdHeldUntil

* fix: restore the Callout closing tag my conflict resolution dropped, which types:check and lint both pass and only pnpm build catches
This commit is contained in:
Matthew Meszaros
2026-08-28 10:47:03 -07:00
committed by GitHub
parent d47d31b7c4
commit b9cce2fbdc
17 changed files with 523 additions and 1 deletions
+19
View File
@@ -150,6 +150,25 @@ That last one catches what the first two cannot. The editor scores the template;
A low score never blocks or delays a send. It is a signal to rewrite, not a verdict: a legitimate email can score badly, and a well-scoring one sent to a bad list will still fail.
</Callout>
## Easing out of warmup
A mailbox that has been warming does not jump straight to its full cold cap. Going from 40 warmup emails a day to 50 cold emails the next morning is the volume jump mailbox providers penalise, so Warmbly ramps into it.
The mailbox starts at a volume set by how long it warmed, then adds `5` a day until it reaches the cap you configured:
| Warmed for | Starts cold at |
|------------|----------------|
| under a week | `5`/day |
| one to two weeks | `10`/day |
| two weeks or more | `20`/day |
The ramp only ever **lowers** a cap, never raises one, so your configured limit and the campaign's daily limit still bind. A spam placement pauses the climb for the same three days it pauses the warmup ramp, and the paused days are subtracted rather than made up. The mailbox drawer shows today's allowance and roughly when it reaches your cap.
<Callout type="info" title="Only for mailboxes that warmed">
A mailbox that never used warmup is not gated. This exists to smooth the warmup-to-cold transition, not to cap senders who never opted into warmup.
</Callout>
## Safety posture
Defaults are deliberately conservative, aiming at a low complaint rate rather than maximum throughput:
+4
View File
@@ -120,6 +120,10 @@ Quarantined and blocked mailboxes are selected as neither sender nor recipient.
Getting back in requires requalifying, not just waiting: healthy authentication, no recent complaints or hard-bounce spikes, no recent invalid-token attempts, and spam placement back to a low level. Return is gradual, not a jump back to the old ceiling.
## Graduating to cold sending
Warmup ending does not mean full cold volume the next day. A mailbox joining a campaign starts at a cold volume set by how long it warmed and adds `5` a day from there. See [Easing out of warmup](/guides/campaigns/) for the bands.
## Recommended posture
- Start new mailboxes around `10`/day and let the ramp climb slowly.
@@ -86,6 +86,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap
| Mailbox sync checkpoints | Replaying a checkpoint would make the destination skip everything that arrived between export and import, so it re-syncs from scratch |
| Warmup pool membership | Pools are shared across every workspace on an instance, so membership is re-earned rather than asserted by a file |
| Domain authentication timings | The verdict travels (public DNS reads the same anywhere), but the destination re-checks before it can stop any sending, so a mailbox is never blocked on an observation the new instance never made |
| Cold sending ramps | How far a mailbox had eased into cold volume raises its cap, and the destination never watched it send. Mailboxes re-graduate from their warmup maturity, which costs a few days and errs toward sending less |
| Scheduled deletions | A pending deletion from the source must never follow the workspace to its new home |
| Failure and delivery counters | A webhook endpoint's failure streak and auto-disable state, and whether a notification's email already went out, describe what happened on the source. They start fresh, so an endpoint is not pre-disabled on the new instance and a notification is not re-sent |
| Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history |
+47
View File
@@ -220,6 +220,8 @@ func (s *analyticsService) GetAccountStatus(ctx context.Context, orgID, accountI
}
}
coldRamp := s.coldRampInfo(ctx, email)
return &models.EmailAccountStatus{
ID: email.ID,
Email: email.Email,
@@ -232,6 +234,7 @@ func (s *analyticsService) GetAccountStatus(ctx context.Context, orgID, accountI
WarmupStatus: warmupStatus,
WarmupHealth: warmupHealth,
InCampaign: inCampaign,
ColdRamp: coldRamp,
}, nil
}
@@ -527,3 +530,47 @@ func (s *analyticsService) CompareCampaigns(ctx context.Context, userID uuid.UUI
return s.analyticsRepo.CompareCampaigns(ctx, userID, campaignIDs, from, to)
}
// coldRampInfo explains a graduation ceiling holding this mailbox below its own
// cold cap. Nil when nothing is holding it, so the drawer stays quiet.
func (s *analyticsService) coldRampInfo(ctx context.Context, email *models.Email) *models.ColdRampInfo {
if email.Warmup == nil || s.warmupRepo == nil || email.CampaignLimit <= 0 {
return nil
}
states, err := s.warmupRepo.ColdRampStateForAccounts(ctx,
[]uuid.UUID{email.ID}, time.Now().Add(-warmupramp.LookbackWindow))
if err != nil {
return nil
}
state, ok := states[email.ID]
if !ok || state.WarmupStartedAt == nil {
return nil
}
now := time.Now()
warmupDays := int(now.Sub(*state.WarmupStartedAt).Hours() / 24)
if warmupDays < 0 {
warmupDays = 0
}
var rampStart time.Time
if state.ColdRampStartedAt != nil {
rampStart = *state.ColdRampStartedAt
}
ceiling := warmupramp.ColdCeiling(warmupDays, rampStart, state.Placements, now, email.CampaignLimit)
if ceiling >= email.CampaignLimit {
return nil
}
remaining := email.CampaignLimit - ceiling
days := remaining / warmupramp.ColdRampIncrement
if remaining%warmupramp.ColdRampIncrement != 0 {
days++
}
return &models.ColdRampInfo{
Ceiling: ceiling,
MailboxCap: email.CampaignLimit,
DaysToFullCap: days,
Held: warmupramp.ColdHeldUntil(rampStart, state.Placements, now, warmupramp.FreezeWindow) != nil,
}
}
@@ -54,6 +54,15 @@ func (s *JobsService) HandleEmailSent(ctx context.Context, result models.SendEma
switch task.TaskType {
case "campaign":
s.repairCampaignSendStamp(ctx, task)
// Anchor the graduation ramp on the mailbox's first CONFIRMED cold
// send. Anchoring at dispatch would start the clock on a send the
// worker then failed, and the ramp is a proxy for days of proven
// sending. Idempotent in SQL, so every later send is a no-op.
if s.WarmupRepo != nil {
if err := s.WarmupRepo.StampColdRampStart(ctx, task.EmailAccountID); err != nil {
log.Warn().Err(err).Str("email_account_id", task.EmailAccountID.String()).Msg("could not anchor the cold ramp")
}
}
case "warmup":
// Without this the recipient has nothing to match a warmup email
// against when the verify header did not survive delivery.
+5 -1
View File
@@ -153,7 +153,11 @@ var Tables = []Table{
// mailbox on an observation it never made. Cleared, the mailbox sorts
// to the head of the destination's own sweep (NULLS FIRST) and cannot
// be blocked until that sweep confirms the failure itself.
ResetOnImport: []string{"worker_id", "auth_checked_at", "auth_failing_since"},
// cold_ramp_started_at is the same shape of claim: it RAISES a
// mailbox's cold ceiling, and the destination never watched it send.
// Cleared, the mailbox re-graduates from its warmup-maturity band,
// which costs a few days and is the safe direction to be wrong in.
ResetOnImport: []string{"worker_id", "auth_checked_at", "auth_failing_since", "cold_ramp_started_at"},
},
{
Name: "email_accounts_smtp_imap", Group: models.OrgDataGroupCore,
+61
View File
@@ -0,0 +1,61 @@
package warmupramp
import "time"
const (
// ColdRampIncrement is how much a graduating mailbox may add per clean day.
ColdRampIncrement = 5
// Starting volumes by warmup maturity, following the documented cold
// posture: a recently connected mailbox belongs near 10-20/day.
coldStartUnproven = 5
coldStartWarmed = 10
coldStartMature = 20
coldWarmedDays = 7
coldMatureDays = 14
)
// ColdStart is the cold volume a mailbox graduates at, from how long it warmed.
func ColdStart(warmupDays int) int {
switch {
case warmupDays >= coldMatureDays:
return coldStartMature
case warmupDays >= coldWarmedDays:
return coldStartWarmed
default:
return coldStartUnproven
}
}
// ColdCeiling is a graduating mailbox's cold cap for today: its starting volume
// plus ColdRampIncrement per clean day since its first cold send, clamped to
// the mailbox's own cap. Placements freeze the climb through the same Days()
// union the warmup ramp uses. A zero rampStart means it has not sent cold mail
// yet, so it gets its starting volume.
func ColdCeiling(warmupDays int, rampStart time.Time, placements []time.Time, now time.Time, mailboxCap int) int {
ceiling := ColdStart(warmupDays)
if !rampStart.IsZero() {
ceiling += Days(rampStart, placements, now, FreezeWindow) * ColdRampIncrement
}
if ceiling > mailboxCap {
return mailboxCap
}
return ceiling
}
// ColdHeldUntil is when the cold ramp resumes climbing, or nil when it already
// is. It filters placements the same way ColdCeiling does, so the number the
// dashboard shows and the reason it gives cannot disagree.
func ColdHeldUntil(rampStart time.Time, placements []time.Time, now time.Time, freeze time.Duration) *time.Time {
if rampStart.IsZero() {
return nil
}
counted := make([]time.Time, 0, len(placements))
for _, p := range placements {
if !p.Before(rampStart) {
counted = append(counted, p)
}
}
return FrozenUntil(counted, now, freeze)
}
+98
View File
@@ -0,0 +1,98 @@
package warmupramp
import (
"testing"
"time"
)
func TestColdStartBandsOnWarmupMaturity(t *testing.T) {
for _, tt := range []struct{ days, want int }{
{0, coldStartUnproven}, {6, coldStartUnproven},
{7, coldStartWarmed}, {13, coldStartWarmed},
{14, coldStartMature}, {90, coldStartMature},
} {
if got := ColdStart(tt.days); got != tt.want {
t.Errorf("ColdStart(%d) = %d, want %d", tt.days, got, tt.want)
}
}
}
func TestColdCeilingClimbsAndClamps(t *testing.T) {
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
at := func(d int) time.Time { return start.AddDate(0, 0, d) }
// A mature mailbox on its first cold day: its starting volume, not the cap.
// This is the overnight 40 -> 50 jump the gate exists to stop.
if got := ColdCeiling(30, time.Time{}, nil, at(0), 50); got != 20 {
t.Errorf("first cold day = %d, want the 20 start", got)
}
if got := ColdCeiling(30, start, nil, at(0), 50); got != 20 {
t.Errorf("day 0 = %d, want 20", got)
}
if got := ColdCeiling(30, start, nil, at(2), 50); got != 30 {
t.Errorf("day 2 = %d, want 20 + 2*5", got)
}
// Clamped to the mailbox's own cap, never above it.
if got := ColdCeiling(30, start, nil, at(90), 50); got != 50 {
t.Errorf("day 90 = %d, want the cap of 50", got)
}
if got := ColdCeiling(30, start, nil, at(90), 12); got != 12 {
t.Errorf("a low mailbox cap must still bind: got %d, want 12", got)
}
// An unproven mailbox starts lower and takes longer to arrive.
if got := ColdCeiling(1, start, nil, at(0), 50); got != 5 {
t.Errorf("unproven day 0 = %d, want 5", got)
}
}
func TestColdCeilingFreezesOnPlacement(t *testing.T) {
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
at := func(d float64) time.Time {
return start.Add(time.Duration(d * float64(24*time.Hour)))
}
clean := ColdCeiling(30, start, nil, at(6), 50)
held := ColdCeiling(30, start, []time.Time{at(4)}, at(6), 50)
if held >= clean {
t.Errorf("a placement did not hold the cold ramp: held %d vs clean %d", held, clean)
}
// Same rule as the warmup ramp: the frozen days are subtracted, not made up.
if later := ColdCeiling(30, start, []time.Time{at(4)}, at(20), 50); later > clean+80 {
t.Errorf("cold ramp caught up after the freeze: %d", later)
}
// And it can never exceed the clean ramp at the same moment.
if got := ColdCeiling(30, start, []time.Time{at(4)}, at(20), 50); got > ColdCeiling(30, start, nil, at(20), 50) {
t.Error("a placement raised the cold ceiling")
}
}
// The hold the dashboard reports must count exactly the placements the ceiling
// counts, or the drawer says "paused" while the scheduler is still climbing.
func TestColdHeldUntilMatchesWhatTheCeilingCounts(t *testing.T) {
rampStart := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
freeze := 72 * time.Hour
now := rampStart.Add(24 * time.Hour)
// A placement from BEFORE the mailbox ever sent cold mail is not part of
// the cold ramp, so it must not be reported as holding it.
before := []time.Time{rampStart.Add(-48 * time.Hour)}
if got := ColdHeldUntil(rampStart, before, now, freeze); got != nil {
t.Errorf("a pre-ramp placement reported as holding the cold ramp: %v", got)
}
if ColdCeiling(30, rampStart, before, now, 50) != ColdCeiling(30, rampStart, nil, now, 50) {
t.Error("a pre-ramp placement changed the ceiling")
}
// One after it does hold, and the ceiling agrees.
after := []time.Time{rampStart.Add(12 * time.Hour)}
if got := ColdHeldUntil(rampStart, after, now, freeze); got == nil {
t.Error("a placement during the ramp is not reported as holding it")
}
if ColdCeiling(30, rampStart, after, now, 50) >= ColdCeiling(30, rampStart, nil, now, 50) {
t.Error("a placement during the ramp did not lower the ceiling")
}
// An unanchored mailbox has no ramp to hold.
if got := ColdHeldUntil(time.Time{}, after, now, freeze); got != nil {
t.Errorf("an unanchored mailbox reported a hold: %v", got)
}
}
@@ -0,0 +1,2 @@
ALTER TABLE public.email_accounts
DROP COLUMN IF EXISTS cold_ramp_started_at;
@@ -0,0 +1,14 @@
-- Warmup-to-cold graduation (issue #147).
--
-- A mailbox at its warmup ceiling could join a campaign and send at the full
-- cold cap the same day: 40 warmup/day one morning, 50 cold/day the next. That
-- overnight jump is the post-warmup spike providers warn about.
--
-- cold_ramp_started_at is stamped on the mailbox's first cold send and anchors
-- a per-mailbox ceiling that climbs toward its cold cap over days. NULL means
-- the mailbox has not sent cold mail yet.
ALTER TABLE public.email_accounts
ADD COLUMN cold_ramp_started_at timestamptz;
COMMENT ON COLUMN public.email_accounts.cold_ramp_started_at IS
'First cold send; anchors the graduation ramp toward campaign_limit. NULL until the mailbox sends cold mail.';
+16
View File
@@ -107,6 +107,22 @@ type EmailAccountStatus struct {
// When true a low-volume health-check warmup keeps running even if the
// user has warmup paused/off.
InCampaign bool `json:"in_campaign"`
// ColdRamp is the warmup-to-cold graduation ceiling, present only while it
// is below the mailbox's own cap. Without it the cap just reads lower than
// the number the owner configured.
ColdRamp *ColdRampInfo `json:"cold_ramp,omitempty"`
}
// ColdRampInfo explains a cold cap held below the mailbox's configured limit.
type ColdRampInfo struct {
// Ceiling is today's allowance; MailboxCap is what the owner configured.
Ceiling int `json:"ceiling"`
MailboxCap int `json:"mailbox_cap"`
// DaysToFullCap is how many clean days remain before Ceiling reaches
// MailboxCap, 0 when it arrives today.
DaysToFullCap int `json:"days_to_full_cap"`
// Held is set when a recent spam placement is pausing the climb.
Held bool `json:"held"`
}
type WarmupHealthInfo struct {
+75
View File
@@ -118,6 +118,13 @@ type WarmupRepository interface {
CountSpamReportsSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
CountUserComplaintsSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
CountSpamPlacementsSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
// ColdRampStateForAccounts returns a whole candidate pool's graduation
// inputs in one round trip. The scheduler reads this per pass, so it must
// not be per-account.
ColdRampStateForAccounts(ctx context.Context, accountIDs []uuid.UUID, since time.Time) (map[uuid.UUID]ColdRampState, error)
// StampColdRampStart records a mailbox's first cold send. Idempotent: a
// mailbox that already has an anchor keeps it.
StampColdRampStart(ctx context.Context, accountID uuid.UUID) error
// SpamPlacementsSince lists when this sender's warmup mail was found in a
// recipient's junk folder. The ramp subtracts a freeze window per
// placement, so it needs all of them, not just the newest.
@@ -644,6 +651,74 @@ func (r *warmupRepository) CountSpamPlacementsSince(ctx context.Context, account
return count, err
}
// ColdRampState is one mailbox's warmup-to-cold graduation inputs.
type ColdRampState struct {
WarmupStartedAt *time.Time
ColdRampStartedAt *time.Time
Placements []time.Time
}
func (r *warmupRepository) ColdRampStateForAccounts(ctx context.Context, accountIDs []uuid.UUID, since time.Time) (map[uuid.UUID]ColdRampState, error) {
out := make(map[uuid.UUID]ColdRampState, len(accountIDs))
if len(accountIDs) == 0 {
return out, nil
}
rows, err := r.db.Query(ctx, `
SELECT id, warmup, cold_ramp_started_at
FROM email_accounts
WHERE id = ANY($1::uuid[])
`, accountIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var state ColdRampState
if err := rows.Scan(&id, &state.WarmupStartedAt, &state.ColdRampStartedAt); err != nil {
return nil, err
}
out[id] = state
}
if err := rows.Err(); err != nil {
return nil, err
}
placementRows, err := r.db.Query(ctx, `
SELECT reported_account_id, created_at
FROM warmup_spam_reports
WHERE reported_account_id = ANY($1::uuid[])
AND report_type = 'spam_placement'
AND created_at >= $2
ORDER BY created_at
`, accountIDs, since)
if err != nil {
return nil, err
}
defer placementRows.Close()
for placementRows.Next() {
var id uuid.UUID
var at time.Time
if err := placementRows.Scan(&id, &at); err != nil {
return nil, err
}
state := out[id]
state.Placements = append(state.Placements, at)
out[id] = state
}
return out, placementRows.Err()
}
func (r *warmupRepository) StampColdRampStart(ctx context.Context, accountID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE email_accounts
SET cold_ramp_started_at = NOW()
WHERE id = $1 AND cold_ramp_started_at IS NULL
`, accountID)
return err
}
func (r *warmupRepository) SpamPlacementsSince(ctx context.Context, accountID uuid.UUID, since time.Time) ([]time.Time, error) {
rows, err := r.db.Query(ctx, `
SELECT created_at
+8
View File
@@ -249,11 +249,19 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai
// clamp. It is min(per-mailbox cold cap, campaign daily limit) further min()'d
// with the day's ramp ceiling. Applied via min() only — it can never RAISE a
// mailbox above its cold cap (the mailbox-first safety invariant).
// Graduation state for the whole pool in one round trip, so the per-mailbox
// ceiling below costs no query inside the candidate loop.
coldRamp := s.coldRampStates(ctx, accounts)
effectiveCap := func(acct models.Email) int {
lim := min(acct.CampaignLimit, campaign.DailyLimit)
if campaign.RampEnabled {
lim = min(lim, campaignRampCeiling(true, campaign.RampStart, campaign.RampIncrement, campaign.RampCeiling, campaign.RampLevel))
}
// Graduation ceiling: a mailbox at its warmup ceiling must not reach the
// full cold cap the day it joins a campaign. min() only, so it can lower
// a mailbox but never raise one.
lim = min(lim, coldCeilingFor(coldRamp[acct.ID], lim))
return lim
}
+51
View File
@@ -0,0 +1,51 @@
package scheduler
import (
"context"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/warmupramp"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// coldRampStates loads the pool's graduation inputs. Fails open to an empty
// map: a lookup error must not cap a customer's sending.
func (s *schedulerService) coldRampStates(ctx context.Context, accounts []models.Email) map[uuid.UUID]repository.ColdRampState {
if s.warmupRepo == nil || len(accounts) == 0 {
return nil
}
ids := make([]uuid.UUID, 0, len(accounts))
for _, a := range accounts {
ids = append(ids, a.ID)
}
states, err := s.warmupRepo.ColdRampStateForAccounts(ctx, ids, time.Now().Add(-warmupramp.LookbackWindow))
if err != nil {
return nil
}
return states
}
// coldCeilingFor is the graduation ceiling for one mailbox, or mailboxCap when
// the gate does not apply.
//
// It applies only to mailboxes that have warmed. Gating one that never used
// warmup would cap customers who never opted into it, which is a different
// decision from stopping the warmup-to-cold spike this exists to stop.
func coldCeilingFor(state repository.ColdRampState, mailboxCap int) int {
if state.WarmupStartedAt == nil {
return mailboxCap
}
now := time.Now()
warmupDays := int(now.Sub(*state.WarmupStartedAt).Hours() / 24)
if warmupDays < 0 {
warmupDays = 0
}
var rampStart time.Time
if state.ColdRampStartedAt != nil {
rampStart = *state.ColdRampStartedAt
}
return warmupramp.ColdCeiling(warmupDays, rampStart, state.Placements, now, mailboxCap)
}
@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/app/warmupramp"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/encrypt"
@@ -945,3 +946,74 @@ func TestLiveReachableRecipientHourIsPreferred(t *testing.T) {
t.Errorf("slot %s is %02d:00 Denver, want the recipient's 9am", at.In(loc), h)
}
}
// graduateMailbox marks the fixture's mailbox as having warmed for daysWarming,
// and optionally anchors its cold ramp coldDaysAgo in the past.
func (f *liveFixture) graduateMailbox(t *testing.T, daysWarming int, coldDaysAgo *int) {
t.Helper()
var coldStart any
if coldDaysAgo != nil {
coldStart = time.Now().Add(-time.Duration(*coldDaysAgo) * 24 * time.Hour)
}
if _, err := f.pool.Exec(context.Background(),
`UPDATE email_accounts
SET warmup = $2, warmup_paused_at = NULL, cold_ramp_started_at = $3
WHERE id = $1`,
f.mailbox, time.Now().Add(-time.Duration(daysWarming)*24*time.Hour), coldStart); err != nil {
t.Fatalf("graduate mailbox: %v", err)
}
}
// dailyCapacity reports how many cold sends the scheduler will allow the
// fixture's mailbox today, which is what the graduation ceiling gates.
func (f *liveFixture) dailyCapacity(t *testing.T, handle *db.DB) int {
t.Helper()
repo := repository.NewWarmupRepository(f.pool)
states, err := repo.ColdRampStateForAccounts(context.Background(),
[]uuid.UUID{f.mailbox}, time.Now().Add(-warmupramp.LookbackWindow))
if err != nil {
t.Fatalf("cold ramp state: %v", err)
}
_ = handle
return coldCeilingFor(states[f.mailbox], 50)
}
// TestLiveGraduationStopsTheOvernightJumpToFullCap is issue #147: a mailbox at
// its warmup ceiling joining a campaign must not reach the cold cap that day.
func TestLiveGraduationStopsTheOvernightJumpToFullCap(t *testing.T) {
handle, pool := liveDB(t)
f := newLiveFixture(t, pool, "UTC")
f.graduateMailbox(t, 30, nil) // a month of warmup, first cold day
if got := f.dailyCapacity(t, handle); got != 20 {
t.Errorf("first cold day allows %d, want the graduation start of 20 rather than the 50 cap", got)
}
}
func TestLiveGraduationClimbsToTheCap(t *testing.T) {
handle, pool := liveDB(t)
f := newLiveFixture(t, pool, "UTC")
three := 3
f.graduateMailbox(t, 30, &three)
if got := f.dailyCapacity(t, handle); got != 35 {
t.Errorf("cold day 3 allows %d, want 20 + 3*5", got)
}
long := 60
f.graduateMailbox(t, 30, &long)
if got := f.dailyCapacity(t, handle); got != 50 {
t.Errorf("a long-graduated mailbox allows %d, want the full 50 cap", got)
}
}
// A mailbox that never used warmup keeps its cap: gating it would cap customers
// who never opted into warmup, which is not what this gate is for.
func TestLiveGraduationDoesNotGateAMailboxThatNeverWarmed(t *testing.T) {
handle, pool := liveDB(t)
f := newLiveFixture(t, pool, "UTC")
if got := f.dailyCapacity(t, handle); got != 50 {
t.Errorf("a never-warmed mailbox allows %d, want its full 50 cap", got)
}
}
@@ -115,6 +115,36 @@ function RampHoldNotice({ hold }: { hold: import("@/lib/api/models/app/analytics
);
}
// A cold cap below the configured one reads as a bug unless it says why.
function ColdRampNotice({ ramp }: { ramp: import("@/lib/api/models/app/analytics/AccountStatus").ColdRampInfo }) {
return (
<div className="px-5 pb-4">
<div className="rounded-md border border-sky-200 bg-sky-50 px-3 py-2.5 flex items-start gap-2">
<GaugeIcon className="w-3.5 h-3.5 mt-px shrink-0 text-sky-600" />
<div className="min-w-0">
<p className="text-[12.5px] font-medium text-sky-900">
Easing into cold sending: {ramp.ceiling} of {ramp.mailbox_cap} a day
</p>
<p className="text-[11.5px] text-sky-800/90 leading-relaxed mt-0.5">
{ramp.held ? (
<>
The climb is paused after a recent spam placement. It resumes on its own, then adds 5 a
day until it reaches {ramp.mailbox_cap}.
</>
) : (
<>
Going straight from warmup to a full cold cap is the volume jump mailbox providers
penalise, so this adds 5 a day instead. At this rate it reaches {ramp.mailbox_cap} in
about {ramp.days_to_full_cap} {ramp.days_to_full_cap === 1 ? "day" : "days"}.
</>
)}
</p>
</div>
</div>
</div>
);
}
function StatCard({ label, value, sub, accent }: { label: string; value: React.ReactNode; sub?: string; accent?: boolean }) {
return (
<div className="px-4 py-3.5">
@@ -453,6 +483,7 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/
</div>
{ws?.ramp_hold && <RampHoldNotice hold={ws.ramp_hold} />}
{status?.cold_ramp && <ColdRampNotice ramp={status.cold_ramp} />}
{/* Errors */}
{status?.errors && status.errors.length > 0 && (
@@ -70,8 +70,18 @@ export default interface AccountStatus {
errors: AccountError[];
daily_usage: AccountDailyUsage;
warmup_status?: WarmupStatusInfo;
/** Present only while graduation holds the cold cap below the configured one. */
cold_ramp?: ColdRampInfo;
warmup_health?: WarmupHealthInfo;
// True when the mailbox backs a live campaign — a low-volume health-check
// warmup keeps running even if the user has warmup paused/off.
in_campaign: boolean;
}
// Why the cold cap is below the number configured on the mailbox.
export interface ColdRampInfo {
ceiling: number;
mailbox_cap: number;
days_to_full_cap: number;
held: boolean;
}