From 4198564f12eda8b8dd06450157ee90b080c05ac3 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 1 Jun 2026 09:15:51 +0200 Subject: [PATCH] feat: cap warmup for active campaign senders Limit warmup volume to 5 messages per day when a mailbox is part of an active campaign sender pool. This keeps warmup as a low-volume reputation heartbeat while campaign traffic is already active. --- internal/repository/pg_campaign.go | 19 +++++++++++++++++++ internal/scheduler/warmup_scheduler.go | 11 +++++++++++ 2 files changed, 30 insertions(+) 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