diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index 7a7535cb..123df05e 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -37,6 +37,7 @@ type CampaignRepository interface { ValidateCampaignReady(ctx context.Context, campaignID uuid.UUID) error GetPendingCampaignTasks(ctx context.Context, campaignID uuid.UUID) ([]Task, error) CountActiveForOrganization(ctx context.Context, orgID uuid.UUID) (int, error) + AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) } type campaignRepository struct { @@ -1020,6 +1021,24 @@ func (r *campaignRepository) CountActiveForOrganization(ctx context.Context, org return count, err } +func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) { + query := ` + SELECT EXISTS ( + SELECT 1 + FROM email_accounts ea + JOIN email_tags et ON et.email_id = ea.id + JOIN campaign_email_tags cet ON cet.tag_id = et.tag_id + JOIN campaigns c ON c.id = cet.campaign_id + WHERE ea.id = $1 + AND ea.status = 'active' + AND c.status = 'active' + ) + ` + var exists bool + err := r.DB.QueryRow(ctx, query, accountID).Scan(&exists) + return exists, err +} + // UpdateStatusWithLock updates campaign status using a PostgreSQL advisory lock to prevent concurrent updates. // The WHERE clause guards against races: only updates if the campaign is currently 'active'. func (r *campaignRepository) UpdateStatusWithLock(ctx context.Context, campaignID uuid.UUID, status string) error { diff --git a/internal/scheduler/warmup_scheduler.go b/internal/scheduler/warmup_scheduler.go index e2877b09..7a60ac6c 100644 --- a/internal/scheduler/warmup_scheduler.go +++ b/internal/scheduler/warmup_scheduler.go @@ -15,6 +15,7 @@ var poolTypesForHealthLookup = []string{"premium", "free"} const ( minWarmupRecipientRecheck = 4 * time.Hour maxWarmupRecipientRecheck = 8 * time.Hour + activeCampaignWarmupCap = 5 ) // healthAdjustment captures how the throttled/watch health state should @@ -112,6 +113,16 @@ func (s *schedulerService) CalculateNextWarmupTime(ctx context.Context, accountI account.WarmupMax, ) + // Active campaign mailboxes already have production sending pressure. + // Keep warmup as a lightweight reputation heartbeat instead of stacking + // the full warmup ramp on top of campaign traffic. + if s.campaignRepo != nil { + hasActiveCampaign, err := s.campaignRepo.AccountHasActiveCampaign(ctx, accountID) + if err == nil && hasActiveCampaign && targetVolume > activeCampaignWarmupCap { + targetVolume = activeCampaignWarmupCap + } + } + // STEP 2.1: Cap per-mailbox volume to actual recipient capacity. The // sender should not send multiple warmup messages to the same recipient // in a single day just to hit an arbitrary target; that creates obvious