From 3552fffae12823c1e205f4220c37f2ff0d785354 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 02:04:18 -0700 Subject: [PATCH 1/2] feat: stop stranding a resting mailbox whose warmup stopped by letting lifecycle.Decide run the rest clock on an unknown health signal and resume the mailbox after the 72h window instead of re-stamping probation on every tick, make POST /emails/:id/release the manual exit from resting with its own reason, add a Put back into campaigns action and warmup-aware copy to the resting notice in the mailbox drawer, and document the automatic and manual exits in the mailboxes guide, API reference and endpoint map (issue #243) --- docs/content/docs/api/endpoints.mdx | 2 +- docs/content/docs/api/reference/mailboxes.mdx | 2 +- docs/content/docs/guides/mailboxes.mdx | 10 ++++- internal/api/handler/email.go | 4 +- internal/app/email/handler.go | 4 ++ internal/app/lifecycle/lifecycle.go | 11 +++-- internal/app/lifecycle/lifecycle_test.go | 37 +++++++++++++---- .../components/app/emails/InboxDetails.tsx | 41 +++++++++++++++++-- 8 files changed, 90 insertions(+), 21 deletions(-) diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 168933d9..05fdade1 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -150,7 +150,7 @@ The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the call `GET /emails/:id/track` reports the mailbox's stored tracking domain plus the `CNAME` value this deployment expects (`cname_target`, taken from its `TRACKING_DOMAIN`), and does no DNS work. `PATCH /emails/:id/track` sets the domain and resolves it once; `POST /emails/:id/track/verify` re-resolves the saved one and records the verdict, which is what makes a record that has finished propagating start being used. Both writes are derived from public DNS with no request body, so they take no `Idempotency-Key`. Only a verified domain is used at send time; until then opens and clicks go through the shared tracking host. See [custom tracking domain](/guides/mailboxes/#custom-tracking-domain). -`POST /emails/:id/hold` keeps a mailbox out of campaign sending until `POST /emails/:id/release` puts it back; warmup is unaffected and the automatic rest logic never releases a hold. Both are bodyless and idempotent, so they take no `Idempotency-Key`. See [holding a mailbox yourself](/guides/mailboxes/#holding-a-mailbox-yourself). +`POST /emails/:id/hold` keeps a mailbox out of campaign sending until `POST /emails/:id/release` puts it back; warmup is unaffected and the automatic rest logic never releases a hold. `release` is also the manual exit for a mailbox that is `resting` automatically. Both are bodyless and idempotent, so they take no `Idempotency-Key`. See [holding a mailbox yourself](/guides/mailboxes/#holding-a-mailbox-yourself). `PATCH /emails/:id` accepts `save_to_sent` (boolean) on SMTP/IMAP mailboxes: when true, which is the default, the worker files a copy of each outbound message in the mailbox's Sent folder. It has no effect on Gmail and Outlook mailboxes, whose APIs file their own copy. See [keeping a copy of sent mail](/guides/mailboxes/#keeping-a-copy-of-sent-mail). diff --git a/docs/content/docs/api/reference/mailboxes.mdx b/docs/content/docs/api/reference/mailboxes.mdx index 09c5a791..624e2b5a 100644 --- a/docs/content/docs/api/reference/mailboxes.mdx +++ b/docs/content/docs/api/reference/mailboxes.mdx @@ -385,7 +385,7 @@ The mailbox's cold-rotation state. `POST /emails/:id/release` -Puts a held mailbox back into automatic management. It lands in `active`, or straight in `resting` when its warmup health is still throttled or worse, so a release never sends cold mail from a mailbox that should be recovering. Bodyless and idempotent. +Puts a held or resting mailbox back into automatic management. It lands in `active`, or straight in `resting` when warmup is running and still reports the mailbox as throttled or worse, so a release never sends cold mail from a mailbox that warmup can see is struggling. A resting mailbox with no warmup running has no signal to wait for and lands in `active`. Bodyless and idempotent; releasing a mailbox that is already active returns its current state. Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 843c1c86..b22a7c34 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -144,13 +144,21 @@ A mailbox now has a cold-rotation state, separate from its health: A mailbox rests when its warmup health reaches `throttled` or worse, and returns on its own once it is healthy again **and** has held steady for three days. One good hour does not put it back at full cold volume. +Resting only makes sense while warmup is running, because warmup traffic is what it recovers on. If warmup stops on a resting mailbox (you switch it off, or the workspace loses warmup when a trial ends or a plan changes), there is no health signal to wait for, so the mailbox returns on its own three days after its last rest stamp rather than staying out indefinitely. + +### Putting a resting mailbox back yourself + +You do not have to wait. A resting mailbox's notice in the drawer has a **Put back into campaigns** button. It lands in `active` unless warmup is running and still reports the mailbox as `throttled` or worse, in which case it stays resting and the drawer says so: a mailbox that warmup can see is struggling should not send cold mail. To override that, turn warmup off for the mailbox first, or hold it in `reserve` and release it. + +API keys get the same exit through `POST /emails/:id/release`, which works on a resting mailbox as well as a held one. + It does not rest on the `watch` band. That band is deliberately the one that changes nothing you can feel, and leaving cold rotation is very much something you feel. ### Holding a mailbox yourself To keep a mailbox out of campaigns on your own terms (a domain you are moving, an inbox you want to keep for replies only, a sender you are about to retire), open its drawer and turn on **Hold from campaigns** on the Overview tab. The mailbox goes into `reserve`: campaigns stop picking it, warmup carries on as before, and nothing automatic ever releases it. Turn the hold off to put it back; if its warmup health is still poor at that point, it rests until it recovers rather than returning straight to full volume. -The same hold is available to API keys as `POST /emails/:id/hold` and `POST /emails/:id/release`. +The same hold is available to API keys as `POST /emails/:id/hold` and `POST /emails/:id/release`. Releasing a mailbox that is `resting` rather than held works the same way and is the manual exit from a rest. This is not the same as the risk band that decides which sending worker and IP host a mailbox. A resting mailbox is usually still on a clean worker; it is simply not being offered campaign sends. The mailbox drawer says which state it is in and why. diff --git a/internal/api/handler/email.go b/internal/api/handler/email.go index 8c668125..16ecec2d 100644 --- a/internal/api/handler/email.go +++ b/internal/api/handler/email.go @@ -189,8 +189,8 @@ func (h *Handler) warmupLifecycle(c *gin.Context, action string) { // campaign sending until they release it. Warmup is untouched. func (h *Handler) HoldEmail(c *gin.Context) { h.sendHold(c, true) } -// ReleaseEmail serves POST /emails/:id/release, putting a held mailbox back -// into campaign rotation. +// ReleaseEmail serves POST /emails/:id/release, putting a held or resting +// mailbox back into campaign rotation. func (h *Handler) ReleaseEmail(c *gin.Context) { h.sendHold(c, false) } func (h *Handler) sendHold(c *gin.Context, hold bool) { diff --git a/internal/app/email/handler.go b/internal/app/email/handler.go index 15a908b9..6a71cda9 100644 --- a/internal/app/email/handler.go +++ b/internal/app/email/handler.go @@ -108,6 +108,7 @@ func (s *emailService) SetWarmupLifecycle(ctx context.Context, userID, emailAcco } // SetSendHold: force is set because this is the owner's decision the rebalancer's guard protects. +// A release also serves as the manual exit from resting (issue #243). func (s *emailService) SetSendHold(ctx context.Context, orgID, emailAccountID string, hold bool) (*models.SendLifecycleState, *errx.Error) { if s.lifecycleRepo == nil { return nil, errx.New(errx.Conflict, "Holding a mailbox is not available on this install.") @@ -126,6 +127,9 @@ func (s *emailService) SetSendHold(ctx context.Context, orgID, emailAccountID st } d := lifecycle.Decide(models.SendLifecycleActive, nil, candidate.HealthState, time.Now()) next, reason = d.Next, "released by its owner" + if candidate.Current == models.SendLifecycleResting { + reason = "put back by its owner" + } if d.Reason != "" { reason = "released by its owner; " + d.Reason } diff --git a/internal/app/lifecycle/lifecycle.go b/internal/app/lifecycle/lifecycle.go index a731db01..64851782 100644 --- a/internal/app/lifecycle/lifecycle.go +++ b/internal/app/lifecycle/lifecycle.go @@ -42,11 +42,16 @@ func Decide(current models.SendLifecycle, since *time.Time, health models.Warmup } // No warmup pool means no health signal. Nothing justifies resting an - // active mailbox, and nothing justifies resuming a resting one: warmup is - // not running, so it is not recovering either. + // active mailbox. A resting one is not recovering either, so holding it + // would strand it (issue #243): the clock runs and it returns after the + // rest window, since staying out any longer helps nobody. if health == "" { if current == models.SendLifecycleResting { - return Decision{Next: current, RestartProbation: true} + state := models.SendLifecycleState{State: current, Since: since} + if state.ReadyToResume(now) { + return Decision{Next: models.SendLifecycleActive, + Reason: "warmup is not running, so there was nothing to recover on; returned after the rest window"} + } } return Decision{Next: current} } diff --git a/internal/app/lifecycle/lifecycle_test.go b/internal/app/lifecycle/lifecycle_test.go index 3c02a026..f27add8a 100644 --- a/internal/app/lifecycle/lifecycle_test.go +++ b/internal/app/lifecycle/lifecycle_test.go @@ -107,20 +107,39 @@ func TestDecideDoesNotRestartProbationForOtherStates(t *testing.T) { // A mailbox that is in no warmup pool reports no health at all. Reading that // as healthy would let a rested mailbox resume on the strength of having left -// the pool, which is the opposite of evidence. -func TestUnknownHealthNeitherRestsNorResumes(t *testing.T) { - now := time.Now() - long := now.Add(-30 * 24 * time.Hour) +// the pool, so it serves the rest window first. It must not restart the +// window either: with no warmup running it is not recovering, and holding it +// until a signal that never comes strands it (issue #243). +func TestUnknownHealthResumesOnlyAfterTheRestWindow(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) - d := Decide(models.SendLifecycleResting, &long, "", now) + fresh := now.Add(-time.Hour) + d := Decide(models.SendLifecycleResting, &fresh, "", now) if d.Next != models.SendLifecycleResting { - t.Fatalf("resting mailbox with no pool resumed: %v", d.Next) + t.Fatalf("resting mailbox with no pool resumed after an hour: %v", d.Next) } - if !d.RestartProbation { - t.Fatal("probation must not accrue while health is unknown") + if d.RestartProbation { + t.Fatal("no health signal must let the clock run, not restart it") } - if d := Decide(models.SendLifecycleActive, &long, "", now); d.Next != models.SendLifecycleActive { + served := now.Add(-models.RestProbation) + d = Decide(models.SendLifecycleResting, &served, "", now) + if d.Next != models.SendLifecycleActive { + t.Fatalf("resting mailbox with no pool stayed %v after the rest window", d.Next) + } + if d.Reason == "" { + t.Fatal("resuming with no signal needs a reason the owner can read") + } + + if d := Decide(models.SendLifecycleActive, &served, "", now); d.Next != models.SendLifecycleActive { t.Fatalf("active mailbox with no pool moved to %v", d.Next) } } + +// A resting mailbox with no clock cannot serve a window, so it stays put +// rather than resuming on a missing stamp. +func TestUnknownHealthWithNoClockStaysResting(t *testing.T) { + if d := Decide(models.SendLifecycleResting, nil, "", time.Now()); d.Next != models.SendLifecycleResting { + t.Fatalf("resting mailbox with no since resumed: %v", d.Next) + } +} diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 183eabb6..dfb06d47 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -117,16 +117,39 @@ function RampHoldNotice({ hold }: { hold: import("@/lib/api/models/app/analytics } // A mailbox that has quietly stopped receiving campaign sends looks broken. -function LifecycleNotice({ state }: { state: import("@/lib/api/models/app/analytics/AccountStatus").SendLifecycleState }) { +// A resting one can be put back by hand: with warmup off there is nothing to +// recover on, and with warmup on the owner may still decide the wait is over. +function LifecycleNotice({ + mailboxId, + state, + warmupRunning, +}: { + mailboxId: string; + state: import("@/lib/api/models/app/analytics/AccountStatus").SendLifecycleState; + warmupRunning: boolean; +}) { + const hold = useSendHold(mailboxId); const reserve = state.state === "reserve"; const copy = reserve ? "You are holding this mailbox out of campaigns. Warmup keeps running. Turn the hold off below to put it back into rotation." - : "This mailbox is resting: it keeps its warmup traffic to rebuild reputation, but campaigns are not sending from it. It returns on its own once it has recovered and held steady for three days."; + : warmupRunning + ? "This mailbox is resting: it keeps its warmup traffic to rebuild reputation, but campaigns are not sending from it. It returns on its own once it has recovered and held steady for three days." + : "This mailbox is resting, but warmup is not running on it, so there is nothing to recover on. It returns on its own after three days of rest, or now if you put it back."; + const resume = () => + hold.mutate(false, { + onSuccess: (data) => + toast.success( + data.state === "resting" + ? "Still resting: its warmup health is throttled or worse, so it stays out until that recovers" + : "Mailbox back in campaign rotation", + ), + onError: (e) => toast.error(buildError(e as unknown as AppError)), + }); return (
-
+

Not sending campaigns ({reserve ? "held" : state.state})

@@ -134,6 +157,16 @@ function LifecycleNotice({ state }: { state: import("@/lib/api/models/app/analyt {copy} {!reserve && state.reason ? ` ${state.reason}.` : ""}

+ {!reserve && ( + + )}
@@ -539,7 +572,7 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/
{ws?.ramp_hold && } - {status?.send_lifecycle && } + {status?.send_lifecycle && } {status?.cold_ramp && } {status && } From d9b4220bf018797927ce9a9003f4902188e229eb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 29 Aug 2026 02:09:49 -0700 Subject: [PATCH 2/2] feat: drive the resting notice in the mailbox drawer off warmup_health presence, which is the pool row the rebalancer reads, instead of warmup_status, which stays present for paused or unentitled warmup, condense the new lifecycle and drawer comments to one line, and correct the mailboxes guide so it says a rest ends without a signal only when the mailbox leaves its pool, since pausing warmup keeps it there --- docs/content/docs/guides/mailboxes.mdx | 4 ++-- internal/app/lifecycle/lifecycle.go | 5 +---- web/src/components/app/emails/InboxDetails.tsx | 15 +++++++-------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index b22a7c34..dfba1a92 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -144,11 +144,11 @@ A mailbox now has a cold-rotation state, separate from its health: A mailbox rests when its warmup health reaches `throttled` or worse, and returns on its own once it is healthy again **and** has held steady for three days. One good hour does not put it back at full cold volume. -Resting only makes sense while warmup is running, because warmup traffic is what it recovers on. If warmup stops on a resting mailbox (you switch it off, or the workspace loses warmup when a trial ends or a plan changes), there is no health signal to wait for, so the mailbox returns on its own three days after its last rest stamp rather than staying out indefinitely. +Resting only makes sense while the mailbox is in a warmup pool, because pool health is the signal it recovers on. If a resting mailbox leaves its pool (the workspace loses warmup when a trial ends or a plan changes), there is no health signal to wait for, so the mailbox returns on its own three days after its last rest stamp rather than staying out indefinitely. Pausing warmup on the mailbox does not remove it from the pool, so its health keeps being tracked and the normal three healthy days still apply. ### Putting a resting mailbox back yourself -You do not have to wait. A resting mailbox's notice in the drawer has a **Put back into campaigns** button. It lands in `active` unless warmup is running and still reports the mailbox as `throttled` or worse, in which case it stays resting and the drawer says so: a mailbox that warmup can see is struggling should not send cold mail. To override that, turn warmup off for the mailbox first, or hold it in `reserve` and release it. +You do not have to wait. A resting mailbox's notice in the drawer has a **Put back into campaigns** button. It lands in `active` unless its warmup health is still `throttled` or worse, in which case it stays resting and the drawer says so: a mailbox that warmup can see is struggling should not send cold mail, and there is no override for that short of the health recovering. API keys get the same exit through `POST /emails/:id/release`, which works on a resting mailbox as well as a held one. diff --git a/internal/app/lifecycle/lifecycle.go b/internal/app/lifecycle/lifecycle.go index 64851782..4c0576fd 100644 --- a/internal/app/lifecycle/lifecycle.go +++ b/internal/app/lifecycle/lifecycle.go @@ -41,10 +41,7 @@ func Decide(current models.SendLifecycle, since *time.Time, health models.Warmup Reason: "warmup health is " + string(health) + "; out of cold rotation until it recovers"} } - // No warmup pool means no health signal. Nothing justifies resting an - // active mailbox. A resting one is not recovering either, so holding it - // would strand it (issue #243): the clock runs and it returns after the - // rest window, since staying out any longer helps nobody. + // No pool, no signal: never rest, and let a resting clock run rather than strand the mailbox. if health == "" { if current == models.SendLifecycleResting { state := models.SendLifecycleState{State: current, Since: since} diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index dfb06d47..75a2abe3 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -117,24 +117,23 @@ function RampHoldNotice({ hold }: { hold: import("@/lib/api/models/app/analytics } // A mailbox that has quietly stopped receiving campaign sends looks broken. -// A resting one can be put back by hand: with warmup off there is nothing to -// recover on, and with warmup on the owner may still decide the wait is over. +// hasHealthSignal is warmup_health presence: the pool row the rebalancer reads, not the warmup setting. function LifecycleNotice({ mailboxId, state, - warmupRunning, + hasHealthSignal, }: { mailboxId: string; state: import("@/lib/api/models/app/analytics/AccountStatus").SendLifecycleState; - warmupRunning: boolean; + hasHealthSignal: boolean; }) { const hold = useSendHold(mailboxId); const reserve = state.state === "reserve"; const copy = reserve ? "You are holding this mailbox out of campaigns. Warmup keeps running. Turn the hold off below to put it back into rotation." - : warmupRunning - ? "This mailbox is resting: it keeps its warmup traffic to rebuild reputation, but campaigns are not sending from it. It returns on its own once it has recovered and held steady for three days." - : "This mailbox is resting, but warmup is not running on it, so there is nothing to recover on. It returns on its own after three days of rest, or now if you put it back."; + : hasHealthSignal + ? "This mailbox is resting: campaigns are not sending from it while its warmup health recovers. It returns on its own once that health is back and has held steady for three days." + : "This mailbox is resting, but it is in no warmup pool, so there is no health signal to recover on. It returns on its own after three days of rest, or now if you put it back."; const resume = () => hold.mutate(false, { onSuccess: (data) => @@ -572,7 +571,7 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/ {ws?.ramp_hold && } - {status?.send_lifecycle && } + {status?.send_lifecycle && } {status?.cold_ramp && } {status && }