mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
* feat: give a cold mailbox a rotation lifecycle so a tired one can rest and come back, instead of running at full volume until a hard band trips: send_lifecycle is warming, active, resting or reserve and decides whether cold sender resolution offers the mailbox at all, which is a different axis from risk_band deciding which worker and IP host it, so a resting mailbox is still a clean-band mailbox that keeps its warmup traffic and its reputation; the hourly rebalancer rests a mailbox at throttled and worse but never at watch, since watch is defined as the band that changes nothing a customer can feel and leaving cold rotation is very much something they feel, and a rested mailbox returns only after three clean days so one good hour cannot bounce it back to full volume; reserve is the owner's hold and is never overridden, the default is active so no existing mailbox changes on deploy, and the state never travels in a workspace archive because it is this instance's decision about sending it watched * feat: stop a query error re-admitting rested mailboxes, make probation measure healthy time, and rotate the candidate window so no mailbox starves: sendLifecycles returned a nil map on failure and an unresolved state reads as active, so one bad query quietly put every resting and reserved mailbox back into cold rotation, and the gate is now applied only when the states were actually read, with the skip logged rather than silent; ReadyToResume measured total time resting, so a mailbox that sat unhealthy for three days resumed on its first healthy tick having served no clean time, and an unhealthy evaluation now restarts the streak; and ordering candidates by send_lifecycle_since put every never-moved mailbox equal-first, so on an install with more than one page of them the same page was re-examined forever, which a checked-at stamp and its index fix
609 lines
18 KiB
Go
609 lines
18 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/app/warmupramp"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
type AnalyticsService interface {
|
|
// Warmup analytics
|
|
GetWarmupAnalytics(ctx context.Context, userID uuid.UUID, emailAccountID *uuid.UUID, from, to time.Time) (*models.WarmupAnalytics, *errx.Error)
|
|
|
|
// Campaign analytics
|
|
GetCampaignAnalytics(ctx context.Context, userID, campaignID uuid.UUID) (*models.CampaignAnalytics, *errx.Error)
|
|
GetCampaignDailyStats(ctx context.Context, userID, campaignID uuid.UUID, from, to time.Time) ([]models.CampaignDailyStats, *errx.Error)
|
|
|
|
// Email account status
|
|
GetAccountStatus(ctx context.Context, orgID, accountID uuid.UUID) (*models.EmailAccountStatus, *errx.Error)
|
|
GetAllAccountStatuses(ctx context.Context, orgID uuid.UUID) ([]models.EmailAccountStatus, *errx.Error)
|
|
|
|
// Usage overview
|
|
GetUsageOverview(ctx context.Context, userID uuid.UUID, period string) (*models.UsageOverview, *errx.Error)
|
|
|
|
// Dashboard analytics
|
|
GetDashboardAnalytics(ctx context.Context, userID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error)
|
|
GetCampaignHourlyStats(ctx context.Context, userID, campaignID uuid.UUID, date time.Time) ([]models.CampaignHourlyStats, *errx.Error)
|
|
CompareCampaigns(ctx context.Context, userID uuid.UUID, campaignIDs []uuid.UUID, from, to time.Time) (*models.CampaignComparison, *errx.Error)
|
|
}
|
|
|
|
type analyticsService struct {
|
|
analyticsRepo repository.AnalyticsRepository
|
|
emailRepo repository.EmailRepository
|
|
campaignRepo repository.CampaignRepository
|
|
emailAccountErrorsRepo repository.EmailAccountErrorRepository
|
|
warmupRepo repository.WarmupRepository
|
|
// lifecycleRepo reads whether the mailbox is in cold rotation.
|
|
// Optional/nil-safe.
|
|
lifecycleRepo repository.SendLifecycleRepository
|
|
}
|
|
|
|
func NewService(
|
|
analyticsRepo repository.AnalyticsRepository,
|
|
emailRepo repository.EmailRepository,
|
|
campaignRepo repository.CampaignRepository,
|
|
emailAccountErrorsRepo repository.EmailAccountErrorRepository,
|
|
warmupRepo repository.WarmupRepository,
|
|
) AnalyticsService {
|
|
return &analyticsService{
|
|
analyticsRepo: analyticsRepo,
|
|
emailRepo: emailRepo,
|
|
campaignRepo: campaignRepo,
|
|
emailAccountErrorsRepo: emailAccountErrorsRepo,
|
|
warmupRepo: warmupRepo,
|
|
}
|
|
}
|
|
|
|
// warmupHealthPoolLookup lists the pools to check for a participant's health,
|
|
// premium first so paid orgs reflect their premium-pool reputation.
|
|
var warmupHealthPoolLookup = []string{"premium", "free"}
|
|
|
|
func (s *analyticsService) GetWarmupAnalytics(ctx context.Context, userID uuid.UUID, emailAccountID *uuid.UUID, from, to time.Time) (*models.WarmupAnalytics, *errx.Error) {
|
|
// Get daily stats
|
|
dailyStats, xerr := s.analyticsRepo.GetWarmupStats(ctx, userID, emailAccountID, from, to)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// Calculate summary
|
|
var totalSent, totalReplied int
|
|
for _, day := range dailyStats {
|
|
totalSent += day.EmailsSent
|
|
totalReplied += day.EmailsReplied
|
|
}
|
|
|
|
daysActive := len(dailyStats)
|
|
var averageDaily, replyRate float64
|
|
if daysActive > 0 {
|
|
averageDaily = float64(totalSent) / float64(daysActive)
|
|
}
|
|
if totalSent > 0 {
|
|
replyRate = float64(totalReplied) / float64(totalSent) * 100
|
|
}
|
|
|
|
analytics := &models.WarmupAnalytics{
|
|
DateRange: models.DateRange{
|
|
From: from,
|
|
To: to,
|
|
},
|
|
Summary: models.WarmupSummary{
|
|
TotalSent: totalSent,
|
|
TotalReplied: totalReplied,
|
|
AverageDaily: averageDaily,
|
|
ReplyRate: replyRate,
|
|
DaysActive: daysActive,
|
|
},
|
|
DailyStats: dailyStats,
|
|
}
|
|
|
|
if emailAccountID != nil {
|
|
analytics.EmailAccountID = *emailAccountID
|
|
}
|
|
|
|
return analytics, nil
|
|
}
|
|
|
|
func (s *analyticsService) GetCampaignAnalytics(ctx context.Context, userID, campaignID uuid.UUID) (*models.CampaignAnalytics, *errx.Error) {
|
|
// Get campaign details
|
|
campaign, err := s.campaignRepo.GetByID(ctx, campaignID)
|
|
if err != nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
|
|
// Verify ownership
|
|
if campaign.UserID != userID.String() {
|
|
return nil, errx.ErrForbidden
|
|
}
|
|
|
|
// Get summary
|
|
summary, xerr := s.analyticsRepo.GetCampaignSummary(ctx, userID, campaignID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// Get sequence stats
|
|
sequences, xerr := s.analyticsRepo.GetSequenceStats(ctx, campaignID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
return &models.CampaignAnalytics{
|
|
CampaignID: campaignID,
|
|
Name: campaign.Name,
|
|
Status: campaign.Status,
|
|
Summary: *summary,
|
|
Sequences: sequences,
|
|
}, nil
|
|
}
|
|
|
|
func (s *analyticsService) GetCampaignDailyStats(ctx context.Context, userID, campaignID uuid.UUID, from, to time.Time) ([]models.CampaignDailyStats, *errx.Error) {
|
|
// Verify campaign ownership
|
|
campaign, err := s.campaignRepo.GetByID(ctx, campaignID)
|
|
if err != nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
if campaign.UserID != userID.String() {
|
|
return nil, errx.ErrForbidden
|
|
}
|
|
|
|
return s.analyticsRepo.GetCampaignDailyStats(ctx, campaignID, from, to)
|
|
}
|
|
|
|
func (s *analyticsService) GetAccountStatus(ctx context.Context, orgID, accountID uuid.UUID) (*models.EmailAccountStatus, *errx.Error) {
|
|
// Get email account (org-scoped lookup)
|
|
email, xerr := s.emailRepo.Get(ctx, orgID.String(), accountID.String())
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// Get daily usage
|
|
usage, xerr := s.analyticsRepo.GetAccountDailyUsage(ctx, accountID, time.Now())
|
|
if xerr != nil {
|
|
// Non-fatal, use empty usage
|
|
usage = &models.AccountDailyUsage{
|
|
Date: time.Now().Format("2006-01-02"),
|
|
}
|
|
}
|
|
|
|
// Get errors
|
|
var errors []models.AccountError
|
|
if s.emailAccountErrorsRepo != nil {
|
|
dbErrors, err := s.emailAccountErrorsRepo.GetByAccountID(ctx, accountID, true)
|
|
if err == nil {
|
|
for _, e := range dbErrors {
|
|
errors = append(errors, models.AccountError{
|
|
ID: e.ID,
|
|
ErrorCode: e.ErrorCode,
|
|
Severity: e.Severity,
|
|
Title: e.Title,
|
|
Message: e.Message,
|
|
ActionRequired: e.ActionRequired,
|
|
CreatedAt: e.CreatedAt,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
if errors == nil {
|
|
errors = make([]models.AccountError, 0)
|
|
}
|
|
|
|
// Calculate health, then fold in warmup-pool reputation so the single
|
|
// score the user sees reflects spam placement / complaints / throttling.
|
|
health := calculateAccountHealth(email, errors)
|
|
warmupHealth := s.buildWarmupHealth(ctx, accountID)
|
|
applyWarmupHealth(&health, warmupHealth)
|
|
|
|
// Build warmup status if warmup has ever been enabled (active or paused).
|
|
var warmupStatus *models.WarmupStatusInfo
|
|
if email.Warmup != nil {
|
|
target, hold := s.warmupTargetAndHold(ctx, email, warmupHealthState(warmupHealth))
|
|
warmupStatus = &models.WarmupStatusInfo{
|
|
Enabled: true,
|
|
Paused: email.WarmupPausedAt != nil,
|
|
PausedAt: email.WarmupPausedAt,
|
|
StartedAt: *email.Warmup,
|
|
CurrentVolume: usage.WarmupSent,
|
|
TargetVolume: target,
|
|
MaxVolume: email.WarmupMax,
|
|
ReplyRate: email.WarmupReplyRate,
|
|
DaysActive: int(time.Since(*email.Warmup).Hours() / 24),
|
|
RampHold: hold,
|
|
}
|
|
}
|
|
|
|
inCampaign := false
|
|
if s.campaignRepo != nil {
|
|
if n, err := s.campaignRepo.CountActiveCampaignsForAccount(ctx, accountID); err == nil {
|
|
inCampaign = n > 0
|
|
}
|
|
}
|
|
|
|
coldRamp := s.coldRampInfo(ctx, email)
|
|
lifecycle := s.sendLifecycleInfo(ctx, email.ID)
|
|
|
|
return &models.EmailAccountStatus{
|
|
ID: email.ID,
|
|
Email: email.Email,
|
|
Provider: email.Provider,
|
|
Status: email.Status,
|
|
LastSyncedAt: &email.LastSyncedAt,
|
|
Health: health,
|
|
Errors: errors,
|
|
DailyUsage: *usage,
|
|
WarmupStatus: warmupStatus,
|
|
WarmupHealth: warmupHealth,
|
|
InCampaign: inCampaign,
|
|
ColdRamp: coldRamp,
|
|
SendLifecycle: lifecycle,
|
|
}, nil
|
|
}
|
|
|
|
// warmupHealthState reads the band off the API shape, defaulting to healthy
|
|
// for a mailbox in no pool — the same default resolveHealthState uses.
|
|
func warmupHealthState(h *models.WarmupHealthInfo) models.WarmupHealthState {
|
|
if h == nil || h.State == "" {
|
|
return models.WarmupHealthHealthy
|
|
}
|
|
return models.WarmupHealthState(h.State)
|
|
}
|
|
|
|
// buildWarmupHealth looks up the mailbox's warmup-pool health (premium pool
|
|
// first) and maps it into the API shape. Returns nil when the mailbox is not
|
|
// in a pool or the lookup fails — health surfacing must never break status.
|
|
func (s *analyticsService) buildWarmupHealth(ctx context.Context, accountID uuid.UUID) *models.WarmupHealthInfo {
|
|
if s.warmupRepo == nil {
|
|
return nil
|
|
}
|
|
for _, poolType := range warmupHealthPoolLookup {
|
|
h, err := s.warmupRepo.GetParticipantHealth(ctx, accountID, poolType)
|
|
if err != nil || h == nil {
|
|
continue
|
|
}
|
|
info := &models.WarmupHealthInfo{
|
|
State: string(h.HealthState),
|
|
Score: h.LastHealthScore,
|
|
SpamScore: h.SpamScore,
|
|
BlockedUntil: h.BlockedUntil,
|
|
EvaluatedAt: h.LastHealthEvaluatedAt,
|
|
}
|
|
if h.LastHealthReason != nil {
|
|
info.Reason = *h.LastHealthReason
|
|
}
|
|
return info
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyWarmupHealth folds warmup-pool reputation into the unified account
|
|
// health score so a throttled/quarantined mailbox reads as degraded even when
|
|
// its connection and sync are fine. Healthy/empty states leave health intact.
|
|
func applyWarmupHealth(health *models.AccountHealth, wh *models.WarmupHealthInfo) {
|
|
if wh == nil {
|
|
return
|
|
}
|
|
|
|
var penalty int
|
|
var issue string
|
|
switch models.WarmupHealthState(wh.State) {
|
|
case models.WarmupHealthWatch:
|
|
penalty, issue = 10, "Warmup reputation needs watching"
|
|
case models.WarmupHealthThrottled:
|
|
penalty, issue = 25, "Warmup throttled — spam placement elevated"
|
|
case models.WarmupHealthQuarantined:
|
|
penalty, issue = 50, "Warmup quarantined — mailbox temporarily removed from the pool"
|
|
case models.WarmupHealthBlocked:
|
|
penalty, issue = 70, "Warmup blocked — reputation requires review"
|
|
default:
|
|
return
|
|
}
|
|
|
|
if wh.Reason != "" {
|
|
issue = issue + " (" + wh.Reason + ")"
|
|
}
|
|
|
|
health.Score -= penalty
|
|
if health.Score < 0 {
|
|
health.Score = 0
|
|
}
|
|
switch models.WarmupHealthState(wh.State) {
|
|
case models.WarmupHealthWatch:
|
|
if health.Status == "healthy" {
|
|
health.Status = "warning"
|
|
}
|
|
default:
|
|
if health.Status != "error" {
|
|
health.Status = "error"
|
|
}
|
|
}
|
|
health.Issues = append(health.Issues, issue)
|
|
}
|
|
|
|
func (s *analyticsService) GetAllAccountStatuses(ctx context.Context, orgID uuid.UUID) ([]models.EmailAccountStatus, *errx.Error) {
|
|
// Get all email accounts for the organization
|
|
emailsResult, xerr := s.emailRepo.Search(ctx, orgID.String(), "", nil, nil, 1000, nil)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
statuses := make([]models.EmailAccountStatus, 0, len(emailsResult.Data))
|
|
for _, email := range emailsResult.Data {
|
|
status, xerr := s.GetAccountStatus(ctx, orgID, email.ID)
|
|
if xerr != nil {
|
|
continue // Skip failed accounts
|
|
}
|
|
statuses = append(statuses, *status)
|
|
}
|
|
|
|
return statuses, nil
|
|
}
|
|
|
|
func (s *analyticsService) GetUsageOverview(ctx context.Context, userID uuid.UUID, period string) (*models.UsageOverview, *errx.Error) {
|
|
// Get email account counts
|
|
accountsUsage, xerr := s.analyticsRepo.GetEmailAccountCounts(ctx, userID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// Get campaign counts
|
|
campaignsUsage, xerr := s.analyticsRepo.GetCampaignCounts(ctx, userID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// Get contact counts
|
|
contactsUsage, xerr := s.analyticsRepo.GetContactCounts(ctx, userID)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// API usage would come from rate limit service
|
|
apiUsage := models.APIUsage{
|
|
TotalCalls: 0, // Would be populated from rate limit tracking
|
|
DailyLimit: 50000,
|
|
TopEndpoints: make([]models.EndpointUsage, 0),
|
|
}
|
|
|
|
return &models.UsageOverview{
|
|
UserID: userID,
|
|
Period: period,
|
|
EmailAccounts: *accountsUsage,
|
|
Campaigns: *campaignsUsage,
|
|
Contacts: *contactsUsage,
|
|
API: apiUsage,
|
|
}, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func calculateAccountHealth(email *models.Email, errors []models.AccountError) models.AccountHealth {
|
|
health := models.AccountHealth{
|
|
Status: "healthy",
|
|
Score: 100,
|
|
Issues: make([]string, 0),
|
|
}
|
|
|
|
// Check status
|
|
if email.Status != "active" {
|
|
health.Status = "error"
|
|
health.Score -= 50
|
|
health.Issues = append(health.Issues, "Account is not active")
|
|
}
|
|
|
|
// Check for critical errors
|
|
for _, e := range errors {
|
|
if e.Severity == "CRITICAL" {
|
|
health.Status = "error"
|
|
health.Score -= 30
|
|
health.Issues = append(health.Issues, e.Title)
|
|
} else if e.Severity == "WARNING" {
|
|
if health.Status == "healthy" {
|
|
health.Status = "warning"
|
|
}
|
|
health.Score -= 10
|
|
health.Issues = append(health.Issues, e.Title)
|
|
}
|
|
}
|
|
|
|
// Ensure score doesn't go below 0
|
|
if health.Score < 0 {
|
|
health.Score = 0
|
|
}
|
|
|
|
return health
|
|
}
|
|
|
|
// warmupTargetAndHold reports the target the SCHEDULER will act on, plus what
|
|
// is holding it down. It runs the shared warmupramp policy rather than its own
|
|
// arithmetic: a drawer saying "target 25" for a mailbox sending 18 is worse
|
|
// than no number.
|
|
func (s *analyticsService) warmupTargetAndHold(ctx context.Context, email *models.Email, health models.WarmupHealthState) (int, *models.WarmupRampHold) {
|
|
if email.Warmup == nil {
|
|
return 0, nil
|
|
}
|
|
plan := warmupramp.Resolve(ctx, s.warmupRepo, warmupramp.Input{
|
|
AccountID: email.ID,
|
|
WarmupStart: *email.Warmup,
|
|
ActivelyWarming: email.IsWarmingActive(),
|
|
Base: email.WarmupBase,
|
|
Increase: email.WarmupIncrease,
|
|
Max: email.WarmupMax,
|
|
Health: health,
|
|
Now: time.Now(),
|
|
})
|
|
if plan.FrozenUntil == nil {
|
|
return plan.Target, nil
|
|
}
|
|
// Reported for the whole freeze, not just the shorter window that also
|
|
// cuts volume: otherwise a mailbox between 48 and 72 hours after a
|
|
// placement shows a ramp that is not climbing and no reason why.
|
|
return plan.Target, &models.WarmupRampHold{
|
|
Placements: plan.Placements,
|
|
Sends: plan.Sends,
|
|
VolumeCut: plan.Cut(),
|
|
ResumesAt: *plan.FrozenUntil,
|
|
}
|
|
}
|
|
|
|
// Dashboard Analytics implementations
|
|
|
|
func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error) {
|
|
// Calculate date range from period
|
|
var from, to time.Time
|
|
to = time.Now()
|
|
|
|
switch period {
|
|
case "7d":
|
|
from = to.AddDate(0, 0, -7)
|
|
case "30d":
|
|
from = to.AddDate(0, 0, -30)
|
|
case "90d":
|
|
from = to.AddDate(0, 0, -90)
|
|
default:
|
|
from = to.AddDate(0, 0, -7) // Default to 7 days
|
|
period = "7d"
|
|
}
|
|
|
|
// Get overall stats
|
|
overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, orgID, from, to)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
|
|
// Get recent activity
|
|
recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, orgID, 20)
|
|
if xerr != nil {
|
|
recentActivity = make([]models.RecentActivityItem, 0)
|
|
}
|
|
|
|
// Get top campaigns
|
|
topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, orgID, from, to, 5, "emails_sent")
|
|
if xerr != nil {
|
|
topCampaigns = make([]models.TopCampaignStats, 0)
|
|
}
|
|
|
|
// Get account health summary
|
|
accountHealth, xerr := s.analyticsRepo.GetAccountHealthSummary(ctx, orgID)
|
|
if xerr != nil {
|
|
accountHealth = &models.AccountHealthSummary{}
|
|
}
|
|
|
|
// Get daily trend
|
|
dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, orgID, from, to)
|
|
if xerr != nil {
|
|
dailyTrend = make([]models.DashboardDailyStats, 0)
|
|
}
|
|
|
|
return &models.DashboardAnalytics{
|
|
Period: period,
|
|
OverallStats: *overallStats,
|
|
RecentActivity: recentActivity,
|
|
TopCampaigns: topCampaigns,
|
|
AccountHealth: *accountHealth,
|
|
DailyTrend: dailyTrend,
|
|
}, nil
|
|
}
|
|
|
|
func (s *analyticsService) GetCampaignHourlyStats(ctx context.Context, userID, campaignID uuid.UUID, date time.Time) ([]models.CampaignHourlyStats, *errx.Error) {
|
|
// Verify campaign ownership
|
|
campaign, err := s.campaignRepo.GetByID(ctx, campaignID)
|
|
if err != nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
if campaign.UserID != userID.String() {
|
|
return nil, errx.ErrForbidden
|
|
}
|
|
|
|
return s.analyticsRepo.GetCampaignHourlyStats(ctx, campaignID, date)
|
|
}
|
|
|
|
func (s *analyticsService) CompareCampaigns(ctx context.Context, userID uuid.UUID, campaignIDs []uuid.UUID, from, to time.Time) (*models.CampaignComparison, *errx.Error) {
|
|
// Validate that all campaigns belong to user
|
|
for _, campaignID := range campaignIDs {
|
|
campaign, err := s.campaignRepo.GetByID(ctx, campaignID)
|
|
if err != nil {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
if campaign.UserID != userID.String() {
|
|
return nil, errx.ErrForbidden
|
|
}
|
|
}
|
|
|
|
return s.analyticsRepo.CompareCampaigns(ctx, userID, campaignIDs, from, to)
|
|
}
|
|
|
|
// coldRampInfo explains a graduation ceiling holding this mailbox below its own
|
|
// cold cap. Nil when nothing is holding it, so the drawer stays quiet.
|
|
func (s *analyticsService) coldRampInfo(ctx context.Context, email *models.Email) *models.ColdRampInfo {
|
|
if email.Warmup == nil || s.warmupRepo == nil || email.CampaignLimit <= 0 {
|
|
return nil
|
|
}
|
|
states, err := s.warmupRepo.ColdRampStateForAccounts(ctx,
|
|
[]uuid.UUID{email.ID}, time.Now().Add(-warmupramp.LookbackWindow))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
state, ok := states[email.ID]
|
|
if !ok || state.WarmupStartedAt == nil {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
warmupDays := int(now.Sub(*state.WarmupStartedAt).Hours() / 24)
|
|
if warmupDays < 0 {
|
|
warmupDays = 0
|
|
}
|
|
var rampStart time.Time
|
|
if state.ColdRampStartedAt != nil {
|
|
rampStart = *state.ColdRampStartedAt
|
|
}
|
|
|
|
ceiling := warmupramp.ColdCeiling(warmupDays, rampStart, state.Placements, now, email.CampaignLimit)
|
|
if ceiling >= email.CampaignLimit {
|
|
return nil
|
|
}
|
|
|
|
remaining := email.CampaignLimit - ceiling
|
|
days := remaining / warmupramp.ColdRampIncrement
|
|
if remaining%warmupramp.ColdRampIncrement != 0 {
|
|
days++
|
|
}
|
|
return &models.ColdRampInfo{
|
|
Ceiling: ceiling,
|
|
MailboxCap: email.CampaignLimit,
|
|
DaysToFullCap: days,
|
|
Held: warmupramp.ColdHeldUntil(rampStart, state.Placements, now, warmupramp.FreezeWindow) != nil,
|
|
}
|
|
}
|
|
|
|
// sendLifecycleInfo reports the mailbox's cold-rotation state, but only when
|
|
// it is not active: an active mailbox is the normal case and needs no notice.
|
|
func (s *analyticsService) sendLifecycleInfo(ctx context.Context, accountID uuid.UUID) *models.SendLifecycleState {
|
|
if s.lifecycleRepo == nil {
|
|
return nil
|
|
}
|
|
states, err := s.lifecycleRepo.GetSendLifecycles(ctx, []uuid.UUID{accountID})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
state, ok := states[accountID]
|
|
if !ok || state.State.SendsCold() {
|
|
return nil
|
|
}
|
|
return &state
|
|
}
|
|
|
|
// WireLifecycle attaches the cold-sending lifecycle.
|
|
func (s *analyticsService) WireLifecycle(r repository.SendLifecycleRepository) {
|
|
s.lifecycleRepo = r
|
|
}
|
|
|
|
// LifecycleAware is the optional capability the caller uses to attach it.
|
|
type LifecycleAware interface {
|
|
WireLifecycle(r repository.SendLifecycleRepository)
|
|
}
|