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.
This commit is contained in:
Matthew Meszaros
2026-06-01 09:15:51 +02:00
parent ed066ad71a
commit 4198564f12
2 changed files with 30 additions and 0 deletions
+19
View File
@@ -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 {
+11
View File
@@ -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