* feat: give a cold mailbox a rotation lifecycle so a tired one can rest and come back, instead of running at full volume until a hard band trips: send_lifecycle is warming, active, resting or reserve and decides whether cold sender resolution offers the mailbox at all, which is a different axis from risk_band deciding which worker and IP host it, so a resting mailbox is still a clean-band mailbox that keeps its warmup traffic and its reputation; the hourly rebalancer rests a mailbox at throttled and worse but never at watch, since watch is defined as the band that changes nothing a customer can feel and leaving cold rotation is very much something they feel, and a rested mailbox returns only after three clean days so one good hour cannot bounce it back to full volume; reserve is the owner's hold and is never overridden, the default is active so no existing mailbox changes on deploy, and the state never travels in a workspace archive because it is this instance's decision about sending it watched
* feat: stop a query error re-admitting rested mailboxes, make probation measure healthy time, and rotate the candidate window so no mailbox starves: sendLifecycles returned a nil map on failure and an unresolved state reads as active, so one bad query quietly put every resting and reserved mailbox back into cold rotation, and the gate is now applied only when the states were actually read, with the skip logged rather than silent; ReadyToResume measured total time resting, so a mailbox that sat unhealthy for three days resumed on its first healthy tick having served no clean time, and an unhealthy evaluation now restarts the streak; and ordering candidates by send_lifecycle_since put every never-moved mailbox equal-first, so on an install with more than one page of them the same page was re-examined forever, which a checked-at stamp and its index fix
* feat: close the two halves of the delivery-signal loop that were still open, complaints and domain-auth refusals: a spam complaint arrives as mail long after the send succeeded, and nothing read those reports, so the strongest negative signal a sender gets never reached the complaint rate, the suppression list or the breaker; internal/pkg/arf parses RFC 5965 feedback reports worker-side alongside the existing DSN path, takes the LAST Message-ID because the reported mail's headers follow the report's own, and records only abuse-type reports so a not-spam report cannot be inverted into a complaint; separately a receiving server refusing mail because the SENDING DOMAIN failed its authentication (5.7.515, 5.7.26) was classified as SERVER_UNREACHABLE and retried forever, and it is now its own hard code that blames the domain rather than the address, leaves the recipient unsuppressed, and brings that domain's DNS re-check forward so the sweep confirms the verdict the send gate acts on
* feat: make the complaint path actually reachable, and stop it trusting the report: selectTextParts only ever picked text/plain and text/html, so an RFC 5965 report's message/feedback-report and message/rfc822 parts never reached the worker and both Feedback-Type and the reported Message-ID were invisible, which would have left this feature inert and has been quietly weakening DSN parsing too; report parts are now selected as plain text; the complainer is taken from the RESOLVED SEND rather than the report body, because a report is unauthenticated mail anyone able to reach the mailbox could forge and honouring the address it names would let a forger suppress a contact the send never went to; Original-Mail-From is no longer read as the complainer since that address is the sender; and a domain-auth refusal now releases the reservation instead of spending one of the lead's attempts, because the recipient received nothing and the problem is the mailbox's domain, so another mailbox in the pool picks the lead up
* feat: fix the same missing-parts gap in the Gmail adapter, and say plainly that Microsoft Graph cannot see reports at all: goog.extractBody took only text/plain and text/html exactly as the IMAP path did, so a feedback report synced from Gmail was as invisible as one synced over IMAP, and the tests that would have caught either called the parser directly rather than going through the adapter where it actually broke; the new tests exercise that seam, and Graph returns one rendered body with no parts so reports there are undetectable without a MIME fetch that is not built, which the docs now state rather than implying full coverage
* 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
* feat: let a warmup recipient answer the mailbox that just wrote to it, so a thread reads as a conversation rather than two mailboxes monologuing on their own ramps: warmup_tasks.target_account_id was written as nil and never read by anything, so a reply only happened when the recipient's own ramp fired AND the draw happened to land on that partner; a verified receipt now sometimes re-points the recipient's pending warmup task at the sender 25 minutes to 5 hours later inside its own warmup hours, which is a re-pointing rather than new work because only one warmup task may be pending per mailbox, it can never delay a send the mailbox had planned sooner, and it stops before the thread cap so replies cannot answer replies forever; the clock parser also moves into models.ClockMinutes so a second copy of the HH:MM parsing that silently disabled every sending window cannot drift back in
* feat: stop the reply-back drawing the reply rate twice, and stop its jitter escaping a short warmup window: the scheduler drew the recipient's reply rate to decide whether to answer at all, then the task handler drew it again to decide reply-versus-new, so a 30 percent reply rate produced a 9 percent answer rate and a directed task could send a fresh message to the mailbox it was meant to be answering; a directed task now IS the reply, and the opening-time jitter is capped to the window width so a mailbox warming 09:00 to 09:20 is not scheduled past its own close
Store delayed warmup engagement actions in Postgres and drain them from the consumer so read, important, and star actions survive worker restarts.
Keep foldering and spam rescue immediate while routing delayed actions to the mailbox's current worker at fire time.
Wire warmup health webhooks in the consumer and log skipped warmup actions when a recipient mailbox has no assigned worker.
Run foldering and spam rescue immediately while keeping only low-stakes engagement signals behind the dwell delay.