Files
warmbly/internal/scheduler/errors.go
T
Matthew Meszaros efa914025c feat: stop an active campaign sitting at "Queued / Not started" with nothing sending: a campaign is one self-perpetuating task, so a tick that found nothing due parked its successor at the literal next-due moment (three days out for a "wait 3 days" step) and that parked task was also the next time anything re-read the campaign, so leads imported meanwhile stayed invisible until it fired and the reconciler never noticed because it only re-seeds chains with no pending task; deferral parks are now capped at config.CampaignMaxDeferMinutes via scheduler.DeferSlot at all three enqueue sites (a tick that actually sent still parks at its paced interval, so send spacing is untouched), the reconciler re-checks any active campaign parked beyond CampaignStaleParkHours and pulls its wakeup forward when the real next slot is CampaignReparkMarginMinutes sooner, attaching leads to a running campaign wakes it immediately through one CampaignWaker seam in the contact service that covers add/update/bulk-edit/import/Sheets-sync, even distribution now paces across the whole sender pool via poolRemainingOn instead of the one mailbox the tick picked (a three-mailbox campaign was sending at one mailbox's rate), the flat +/-20 minute jitter that was wider than the interval it perturbed is scaled to half the distance to the slot so it stops landing slots in the past where notBefore collapsed them onto the min-gap, and on the dashboard a full-day window renders "12am-midnight" instead of "12am-12pm", the campaign lead strip uses the server's campaign-wide lead_counts instead of counting the 50 loaded rows, and channel state moves out of a ref into React state so a live campaign's panel stops reading "Disconnected" forever
2026-08-25 07:39:18 -07:00

83 lines
4.0 KiB
Go

package scheduler
import (
"errors"
"fmt"
"time"
"github.com/warmbly/warmbly/internal/config"
)
var (
// ErrWarmupNotEnabled is returned when warmup is not enabled for an account
ErrWarmupNotEnabled = errors.New("warmup not enabled for this account")
// ErrCampaignNotActive is returned when a campaign is not active
ErrCampaignNotActive = errors.New("campaign is not active")
// ErrCampaignCompleted is returned when all emails in a campaign have been sent
ErrCampaignCompleted = errors.New("campaign completed - no more emails to send")
// ErrCampaignEnded is returned when a campaign has passed its end date
ErrCampaignEnded = errors.New("campaign ended - past end date")
// ErrNoEmailAccounts is returned when no email accounts are available for sending
ErrNoEmailAccounts = errors.New("no email accounts available for this campaign")
// ErrNoEligibleMailbox is the narrower case: the campaign HAS mailboxes,
// but every one was gated out for both today and tomorrow (daily cap
// reached, warmup health, or outside its own sending window). Reporting
// that as ErrNoEmailAccounts sent people looking at their tag configuration
// for a problem that was never there.
//
// It wraps ErrNoEmailAccounts so existing callers that pause the campaign
// on errors.Is(err, ErrNoEmailAccounts) keep behaving exactly as before.
ErrNoEligibleMailbox = fmt.Errorf(
"%w: every mailbox is outside its sending window or over its daily budget", ErrNoEmailAccounts)
// ErrDomainAuthFailing is the narrower case again: every mailbox in the
// campaign's pool was gated by the sending-domain authentication check.
// Reporting that as ErrNoEligibleMailbox would send people to check
// timezones and daily caps for a DNS problem, which is exactly the class of
// mislabelling ErrNoEligibleMailbox was introduced to fix.
//
// It wraps ErrNoEmailAccounts so existing callers that pause the campaign
// keep behaving as before; callers that want the specific reason must test
// for it BEFORE ErrNoEligibleMailbox and ErrNoEmailAccounts.
ErrDomainAuthFailing = fmt.Errorf(
"%w: every mailbox is sending from a domain that fails SPF/DMARC authentication", ErrNoEmailAccounts)
// ErrDailyLimitReached is returned when the daily limit has been reached
ErrDailyLimitReached = errors.New("daily email limit reached")
// ErrCampaignDeferred is returned when there IS a valid contact to send but
// no eligible mailbox right now — ESP-strict has no same-provider mailbox
// under budget, or the daily new-lead cap is reached. The caller must
// reschedule at the returned (defer) time WITHOUT sending. The returned pair
// is always nil on this path so it can never be mistaken for a sendable
// contact; the returned accountID is a nominal pool mailbox for the wakeup
// task only (the next invocation re-evaluates selection from scratch).
ErrCampaignDeferred = errors.New("campaign send deferred - no eligible mailbox for this contact right now")
)
// DeferSlot is the wakeup time a caller must use after CalculateNextCampaignTime
// returns ErrCampaignDeferred. The returned instant is the campaign's real
// next-due moment, which is the honest answer to "when could this send" but the
// wrong answer to "when should this chain look again": a campaign is one
// self-perpetuating task, so parking it at a next-due three days out also means
// nothing re-reads the campaign for three days. Leads imported in the meantime
// sit at "Queued / Not started" until then.
//
// So a deferral is capped at config.CampaignMaxDeferMinutes. Anything sooner is
// kept as-is, because a near-term defer is already a precise wakeup. Sends are
// unaffected: a tick that fires early and still has nothing due simply defers
// again, and a tick that DID send parks its successor at the paced interval,
// which never goes through here.
func DeferSlot(at time.Time) time.Time {
horizon := time.Now().Add(config.CampaignMaxDeferMinutes * time.Minute)
if at.IsZero() || at.After(horizon) {
return horizon
}
return at
}