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..dfba1a92 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 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 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. + 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..4c0576fd 100644 --- a/internal/app/lifecycle/lifecycle.go +++ b/internal/app/lifecycle/lifecycle.go @@ -41,12 +41,14 @@ 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, and nothing justifies resuming a resting one: warmup is - // not running, so it is not recovering either. + // No pool, no signal: never rest, and let a resting clock run rather than strand the mailbox. 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..75a2abe3 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -117,16 +117,38 @@ 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 }) { +// hasHealthSignal is warmup_health presence: the pool row the rebalancer reads, not the warmup setting. +function LifecycleNotice({ + mailboxId, + state, + hasHealthSignal, +}: { + mailboxId: string; + state: import("@/lib/api/models/app/analytics/AccountStatus").SendLifecycleState; + 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." - : "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."; + : 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) => + 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 +156,16 @@ function LifecycleNotice({ state }: { state: import("@/lib/api/models/app/analyt {copy} {!reserve && state.reason ? ` ${state.reason}.` : ""}

+ {!reserve && ( + + )}
@@ -539,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 && }