feat: make a release from the mailbox hold land where the rebalancer would put it by running lifecycle.Decide against the mailbox's current warmup health through a new GetLifecycleCandidate repository read, so an unhealthy mailbox goes straight to resting instead of sending cold for up to an hour before the hourly pass rests it again, report that outcome in the drawer toast and the API reference, and condense the new hold comments in the email service, backend wiring, repository, hook, client and drawer to the one-line form the repo convention asks for

This commit is contained in:
Matthew Meszaros
2026-08-28 23:30:15 -07:00
parent 5c832461b0
commit 35e8f95f2a
8 changed files with 52 additions and 21 deletions
+1 -2
View File
@@ -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))
}
@@ -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`
+15 -7
View File
@@ -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")
+2 -2
View File
@@ -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.
+22
View File
@@ -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
@@ -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 (
@@ -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<SendLifecycleState> {
return await Request<SendLifecycleState>({
method: "POST",
@@ -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();