diff --git a/cmd/backend/main.go b/cmd/backend/main.go index a5107d89..b838e376 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1152,8 +1152,7 @@ func main() { if aware, ok := emailService.(email.OrgRiskAware); ok { aware.WireOrgRisk(orgRiskRepository) } - // The owner's hold on campaign sending lives on the same lifecycle the - // rebalancer moves. + // The owner's hold shares the lifecycle the rebalancer moves. if aware, ok := emailService.(email.LifecycleAware); ok { aware.WireLifecycle(repository.NewSendLifecycleRepository(primaryDB)) } diff --git a/docs/content/docs/api/reference/mailboxes.mdx b/docs/content/docs/api/reference/mailboxes.mdx index 8b9b6618..09c5a791 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 campaign sending. It lands in `active`; if its warmup health is still throttled or worse, the next rebalancer pass moves it to `resting` until it recovers. Bodyless and idempotent. +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. Auth: **Scope** `WRITE_EMAILS` ยท **Org permission** `manage_emails` diff --git a/internal/app/email/handler.go b/internal/app/email/handler.go index 35d5c661..15a908b9 100644 --- a/internal/app/email/handler.go +++ b/internal/app/email/handler.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/app/lifecycle" "github.com/warmbly/warmbly/internal/app/worker" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" @@ -106,10 +107,7 @@ func (s *emailService) SetWarmupLifecycle(ctx context.Context, userID, emailAcco return account, nil } -// SetSendHold moves a mailbox into or out of reserve. force is set because -// this IS the owner's decision the rebalancer's guard exists to protect; -// releasing lands on active and the next rebalancer pass rests it again if -// its health still warrants that. +// SetSendHold: force is set because this is the owner's decision the rebalancer's guard protects. 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.") @@ -118,9 +116,19 @@ func (s *emailService) SetSendHold(ctx context.Context, orgID, emailAccountID st if xerr != nil { return nil, xerr } - next, reason := models.SendLifecycleActive, "released by its owner" - if hold { - next, reason = models.SendLifecycleReserve, "held back by its owner" + next, reason := models.SendLifecycleReserve, "held back by its owner" + if !hold { + // A release lands where the rebalancer would put the mailbox now. + candidate, err := s.lifecycleRepo.GetLifecycleCandidate(ctx, account.ID) + if err != nil { + log.Error().Err(err).Str("email_account_id", account.ID.String()).Msg("could not read send lifecycle") + return nil, errx.InternalError() + } + d := lifecycle.Decide(models.SendLifecycleActive, nil, candidate.HealthState, time.Now()) + next, reason = d.Next, "released by its owner" + if d.Reason != "" { + reason = "released by its owner; " + d.Reason + } } if _, err := s.lifecycleRepo.SetSendLifecycle(ctx, account.ID, next, reason, true); err != nil { log.Error().Err(err).Str("email_account_id", account.ID.String()).Msg("could not set send hold") diff --git a/internal/app/email/service.go b/internal/app/email/service.go index ffa50c5c..62b7b88f 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -32,8 +32,8 @@ type EmailService interface { // SetWarmupLifecycle starts, pauses, resumes, or disables warmup for a // mailbox. start/resume preserve ramp progress; disable turns warmup off. SetWarmupLifecycle(ctx context.Context, userID, emailAccountID, action string) (*models.Email, *errx.Error) - // SetSendHold puts a mailbox in reserve (held out of campaign sending by - // its owner) or releases it back into rotation. Idempotent. + // SetSendHold holds a mailbox in reserve or releases it; a release lands + // wherever its warmup health says, so an unhealthy mailbox rests. SetSendHold(ctx context.Context, orgID, emailAccountID string, hold bool) (*models.SendLifecycleState, *errx.Error) // UpdateTrackingDomain sets or clears the custom open/click tracking // domain and resolves it once, persisting the verdict. diff --git a/internal/repository/pg_send_lifecycle.go b/internal/repository/pg_send_lifecycle.go index c52dc550..510386f9 100644 --- a/internal/repository/pg_send_lifecycle.go +++ b/internal/repository/pg_send_lifecycle.go @@ -23,6 +23,8 @@ type SendLifecycleRepository interface { // the warmup health that decides where they go, oldest-checked first so // every mailbox is reached rather than the same page every pass. ListLifecycleCandidates(ctx context.Context, limit int) ([]LifecycleCandidate, error) + // GetLifecycleCandidate is ListLifecycleCandidates for one mailbox. + GetLifecycleCandidate(ctx context.Context, accountID uuid.UUID) (*LifecycleCandidate, error) // MarkLifecycleChecked records that the rebalancer looked at these // mailboxes, which is what rotates the candidate window. MarkLifecycleChecked(ctx context.Context, accountIDs []uuid.UUID) error @@ -136,6 +138,26 @@ func (r *sendLifecycleRepository) ListLifecycleCandidates(ctx context.Context, l return out, rows.Err() } +func (r *sendLifecycleRepository) GetLifecycleCandidate(ctx context.Context, accountID uuid.UUID) (*LifecycleCandidate, error) { + var c LifecycleCandidate + var state string + var health *string + err := r.DB.Pool.QueryRow(ctx, ` + SELECT ea.id, ea.send_lifecycle, ea.send_lifecycle_since, wpp.health_state + FROM email_accounts ea + LEFT JOIN warmup_pool_participants wpp ON wpp.email_account_id = ea.id + WHERE ea.id = $1 + `, accountID).Scan(&c.EmailAccountID, &state, &c.Since, &health) + if err != nil { + return nil, err + } + c.Current = models.SendLifecycle(state) + if health != nil { + c.HealthState = models.WarmupHealthState(*health) + } + return &c, nil +} + func (r *sendLifecycleRepository) MarkLifecycleChecked(ctx context.Context, accountIDs []uuid.UUID) error { if len(accountIDs) == 0 { return nil diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 2a8347ad..183eabb6 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -117,8 +117,6 @@ function RampHoldNotice({ hold }: { hold: import("@/lib/api/models/app/analytics } // A mailbox that has quietly stopped receiving campaign sends looks broken. -// The reserve state is the owner's own hold, so it explains itself without the -// stored reason and points at the control that undoes it. function LifecycleNotice({ state }: { state: import("@/lib/api/models/app/analytics/AccountStatus").SendLifecycleState }) { const reserve = state.state === "reserve"; const copy = reserve @@ -142,14 +140,20 @@ function LifecycleNotice({ state }: { state: import("@/lib/api/models/app/analyt ); } -// The owner's switch for the reserve lifecycle state. Off means the rebalancer -// decides (active, or resting while health is poor); on overrides it. +// The owner's switch for the reserve lifecycle state. function SendHoldControl({ mailboxId, state }: { mailboxId: string; state?: import("@/lib/api/models/app/analytics/AccountStatus").SendLifecycleState }) { const hold = useSendHold(mailboxId); const held = state?.state === "reserve"; const toggle = (v: boolean) => hold.mutate(v, { - onSuccess: () => toast.success(v ? "Mailbox held out of campaigns" : "Mailbox back in campaign rotation"), + onSuccess: (data) => + toast.success( + v + ? "Mailbox held out of campaigns" + : data.state === "resting" + ? "Hold released. The mailbox rests until its warmup health recovers" + : "Mailbox back in campaign rotation", + ), onError: (e) => toast.error(buildError(e as unknown as AppError)), }); return ( diff --git a/web/src/lib/api/client/app/emails/sendHold.ts b/web/src/lib/api/client/app/emails/sendHold.ts index 5919d22d..16cca668 100644 --- a/web/src/lib/api/client/app/emails/sendHold.ts +++ b/web/src/lib/api/client/app/emails/sendHold.ts @@ -1,8 +1,7 @@ import type { SendLifecycleState } from "@/lib/api/models/app/analytics/AccountStatus"; import Request from "../../Request"; -// Hold a mailbox out of campaign sending (reserve) or release it back into -// rotation. Bodyless and idempotent; returns the mailbox's lifecycle state. +// Bodyless and idempotent; returns the mailbox's lifecycle state. export default async function sendHold(id: string, hold: boolean): Promise { return await Request({ method: "POST", diff --git a/web/src/lib/api/hooks/app/emails/useSendHold.ts b/web/src/lib/api/hooks/app/emails/useSendHold.ts index db00c957..e4004fc4 100644 --- a/web/src/lib/api/hooks/app/emails/useSendHold.ts +++ b/web/src/lib/api/hooks/app/emails/useSendHold.ts @@ -1,8 +1,7 @@ import sendHold from "@/lib/api/client/app/emails/sendHold"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -// Drives the drawer's "hold from campaigns" control. The lifecycle is read off -// the account-status query, so that is what refreshes. +// The lifecycle is read off the account-status query, so that is what refreshes. export default function useSendHold(id: string) { const queryClient = useQueryClient();