package placement import ( "context" "math" "math/rand/v2" "slices" "sort" "strconv" "strings" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/mailhost" "github.com/warmbly/warmbly/internal/repository" ) // BatchInput is one request to run a placement test from many senders. // Exactly one of SenderAccountIDs and Scope chooses the senders. type BatchInput struct { OrgID uuid.UUID UserID *uuid.UUID SenderAccountIDs []uuid.UUID Scope *models.PlacementSenderScope Sample models.PlacementSample // AllowedSenders is an API key's mailbox restriction; nil allows every one. AllowedSenders []uuid.UUID CampaignID *uuid.UUID SequenceID *uuid.UUID ContactID *uuid.UUID Subject string BodyHTML string BodyPlain string Tracking string Panel string Pace string Families []string SeedIDs []uuid.UUID OnUnavailable string // MaxCredits is the most the caller agreed to pay across the whole batch // for tests past the monthly free allowance. MaxCredits int } // BatchGroupCount is how many selected senders share a provider. type BatchGroupCount struct { Key string `json:"key"` Label string `json:"label"` Senders int `json:"senders"` } // BatchPreview is what a batch would do, before it is started. type BatchPreview struct { // Matched is how many senders the scope resolved to, Selected how many // the sample kept. Matched int `json:"matched"` Selected int `json:"selected"` Inactive int `json:"inactive"` Domains int `json:"domains"` Providers []BatchGroupCount `json:"providers"` // Variants is two for a tracking comparison, which doubles every count. Variants int `json:"variants"` Tests int `json:"tests"` SeedsPerTest int `json:"seeds_per_test"` // MaxSends is the most probes the batch sends; each sender's own daily // limit can only make it fewer. MaxSends int `json:"max_sends"` // FreeTests and PaidTests split Tests against the monthly allowance, and // Credits is the most the paid ones cost. Unmetered panels leave all three // zero. Metered bool `json:"metered"` FreeTests int `json:"free_tests"` PaidTests int `json:"paid_tests"` Credits int `json:"credits"` Usage models.PlacementUsage `json:"usage"` SendersMax int `json:"senders_max"` // Concurrency is how many senders send at once in this workspace. Concurrency int `json:"concurrency"` } // BatchView is a batch with its progress and headline placement. type BatchView struct { models.PlacementBatch Progress models.PlacementBatchProgress `json:"progress"` Summary models.PlacementCounts `json:"summary"` } // BatchGroup is a batch's placement for one sending domain or provider. type BatchGroup struct { Key string `json:"key"` Label string `json:"label"` Senders int `json:"senders"` Tested int `json:"tested"` Counts models.PlacementCounts `json:"counts"` } // BatchMatrixRow is one sending domain's placement per recipient provider. type BatchMatrixRow struct { Domain string `json:"domain"` Recipients []models.PlacementFamilyCounts `json:"recipients"` } // BatchDetail is one batch in full. type BatchDetail struct { BatchView // Untracked is the untracked half of a tracking comparison; Summary is // the tracked half, the copy the campaign really sends. Untracked *models.PlacementCounts `json:"untracked,omitempty"` Domains []BatchGroup `json:"domains"` Providers []BatchGroup `json:"providers"` Recipients []models.PlacementFamilyCounts `json:"recipients"` Matrix []BatchMatrixRow `json:"matrix"` Content ContentCheck `json:"content"` } // BatchSenderView is one sender of a batch with where its copies landed. type BatchSenderView struct { models.PlacementBatchSender SenderFamilyLabel string `json:"sender_family_label"` Summary models.PlacementCounts `json:"summary"` TestIDs []uuid.UUID `json:"test_ids"` } // batchCandidate is a resolved sender with the groups sampling reads. type batchCandidate struct { repository.PlacementBatchCandidate family string domain string } func (s *service) batchesReady() *errx.Error { if s.Batches == nil { return errx.New(errx.NotImplemented, "placement batches are not configured") } return nil } // validateBatch normalizes everything a batch request carries except the // senders, and resolves its copy once so every sender tests the same email. func (s *service) validateBatch(ctx context.Context, in *BatchInput, copyGiven bool) ([]variant, *errx.Error) { if in.Panel == "" { in.Panel = models.PlacementPanelInstance } if !models.ValidPlacementPanel(in.Panel) { return nil, errx.New(errx.BadRequest, "panel must be instance, workspace or cloud") } if len(in.SeedIDs) > 0 && in.Panel != models.PlacementPanelWorkspace { return nil, errx.New(errx.BadRequest, "seed_ids needs panel workspace") } if len(in.SeedIDs) > config.PlacementSeedsPerWorkspaceMax { return nil, errx.New(errx.BadRequest, "seed_ids has more entries than a workspace can have seed inboxes") } // A batch runs for hours anyway; quick copies from many senders at once // would only reach the shared seeds as a burst. if in.Pace == "" { in.Pace = models.PlacementPaceSpaced } if in.Pace != models.PlacementPaceSpaced { return nil, errx.New(errx.BadRequest, "a batch always sends spaced; pace quick is for a single test") } if in.Tracking == "" { in.Tracking = models.PlacementTrackingCampaign } switch in.Tracking { case models.PlacementTrackingCampaign, models.PlacementTrackingOn, models.PlacementTrackingOff, models.PlacementTrackingCompare: default: return nil, errx.New(errx.BadRequest, "tracking must be campaign, on, off or compare") } if in.OnUnavailable == "" { in.OnUnavailable = models.PlacementUnavailableDefer } if in.OnUnavailable != models.PlacementUnavailableDefer && in.OnUnavailable != models.PlacementUnavailableSkip { return nil, errx.New(errx.BadRequest, "on_unavailable must be skip or defer") } if in.MaxCredits < 0 { return nil, errx.New(errx.BadRequest, "max_credits cannot be negative") } families, xerr := normalizeFamilies(in.Families) if xerr != nil { return nil, xerr } in.Families = families if s.Gate != nil && !config.SelfHosted() { if ok, _ := s.Gate.CanSendCampaignEmail(ctx, in.OrgID); !ok { return nil, placementErr(errx.PaymentRequired, "placement_not_entitled", "Placement tests need an active trial or subscription.") } } // A preview asked before the copy is chosen counts senders alone; the // copy is checked once any of it is given, and always on create. var variants []variant if copyGiven || in.CampaignID != nil || in.SequenceID != nil || strings.TrimSpace(in.Subject) != "" || in.BodyHTML != "" || in.BodyPlain != "" { spec := copySpec{ CampaignID: in.CampaignID, SequenceID: in.SequenceID, ContactID: in.ContactID, Subject: in.Subject, BodyHTML: in.BodyHTML, BodyPlain: in.BodyPlain, Panel: in.Panel, } if xerr := s.resolveCopy(ctx, in.OrgID, &spec); xerr != nil { return nil, xerr } in.ContactID, in.Subject, in.BodyHTML, in.BodyPlain = spec.ContactID, spec.Subject, spec.BodyHTML, spec.BodyPlain if variants, xerr = trackingVariants(in.Tracking, spec.campaign); xerr != nil { return nil, xerr } } else if variants, xerr = trackingVariants(in.Tracking, nil); xerr != nil { return nil, xerr } // A panel no sender could test on refuses the batch, not every sender. switch in.Panel { case models.PlacementPanelCloud: if s.Cloud == nil { return nil, placementErr(errx.Conflict, "placement_panel_unavailable", "Link this instance to Warmbly Cloud to test on its seed panel.") } default: scope, orgFilter := models.SeedScopeInstance, (*uuid.UUID)(nil) if in.Panel == models.PlacementPanelWorkspace { scope, orgFilter = models.SeedScopeWorkspace, &in.OrgID } all, err := s.Repo.ListSeeds(ctx, scope, orgFilter, false) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } if len(in.SeedIDs) > 0 { known := map[uuid.UUID]bool{} for _, r := range all { known[r.ID] = true } for _, id := range in.SeedIDs { if !known[id] { return nil, placementErr(errx.BadRequest, "placement_invalid_seeds", "Every chosen seed inbox has to be a seed inbox of this workspace.") } } } if len(inFamilies(all, families)) == 0 { return nil, placementErr(errx.Conflict, "placement_no_seeds", noSeedsMessage(in.Panel)) } } return variants, nil } // resolveSenders turns the request's senders into the candidates the batch // will snapshot, before sampling, and reports how many matched. func (s *service) resolveSenders(ctx context.Context, in BatchInput) ([]batchCandidate, *errx.Error) { if (len(in.SenderAccountIDs) > 0) == (in.Scope != nil) { return nil, errx.New(errx.BadRequest, "choose the senders with either sender_account_ids or sender_scope") } allowed := func(id uuid.UUID) bool { return in.AllowedSenders == nil || slices.Contains(in.AllowedSenders, id) } var filter repository.PlacementCandidateFilter var providers, domains []string if len(in.SenderAccountIDs) > 0 { ids := uniqueUUIDs(in.SenderAccountIDs) if len(ids) > config.PlacementBatchSendersMaxCeiling { return nil, placementErr(errx.BadRequest, "placement_batch_too_large", "sender_account_ids names more mailboxes than any batch may hold.") } for _, id := range ids { if !allowed(id) { return nil, errx.New(errx.Forbidden, "this API key cannot send from one of those mailboxes") } } // Chosen by hand, so a disconnected one is kept and reported by name // when its turn comes. filter = repository.PlacementCandidateFilter{IDs: ids, IncludeInactive: true} rows, err := s.Batches.ListBatchCandidates(ctx, in.OrgID, filter) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } if len(rows) != len(ids) { return nil, errx.New(errx.NotFound, "a sending mailbox was not found in this workspace, or is a seed inbox") } return candidates(rows), nil } sc := in.Scope if len(sc.Providers) > config.PlacementFamiliesMax { return nil, errx.New(errx.BadRequest, "sender_scope.providers names too many providers") } if len(sc.Domains) > 1000 { return nil, errx.New(errx.BadRequest, "sender_scope.domains names too many domains") } if len(sc.TagIDs) > 200 { return nil, errx.New(errx.BadRequest, "sender_scope.tag_ids names too many tags") } if sc.UntestedDays < 0 || sc.UntestedDays > 365 { return nil, errx.New(errx.BadRequest, "sender_scope.untested_days must be between 0 and 365") } for _, p := range sc.Providers { p = strings.ToLower(strings.TrimSpace(p)) if p != "" { providers = append(providers, p) } } for _, d := range sc.Domains { d = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d), "@"))) if d != "" { domains = append(domains, d) } } filter = repository.PlacementCandidateFilter{TagIDs: sc.TagIDs, IncludeInactive: sc.IncludeInactive} if sc.UntestedDays > 0 { since := s.now().AddDate(0, 0, -sc.UntestedDays) filter.UntestedSince = &since } switch sc.Type { case models.PlacementScopeWorkspace: case models.PlacementScopeCampaign: if sc.CampaignID == nil { return nil, errx.New(errx.BadRequest, "sender_scope.campaign_id is required for a campaign scope") } campaign, xerr := s.ownedCampaign(ctx, in.OrgID, *sc.CampaignID) if xerr != nil { return nil, xerr } pool, xerr := repository.ResolveCampaignSenderPool(ctx, s.Emails, campaign) if xerr != nil { return nil, xerr } filter.IDs = make([]uuid.UUID, 0, len(pool.Accounts)) for _, a := range pool.Accounts { filter.IDs = append(filter.IDs, a.ID) } default: return nil, errx.New(errx.BadRequest, "sender_scope.type must be campaign or workspace") } rows, err := s.Batches.ListBatchCandidates(ctx, in.OrgID, filter) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } out := make([]batchCandidate, 0, len(rows)) for _, c := range candidates(rows) { if !allowed(c.ID) { continue } if len(providers) > 0 && !slices.Contains(providers, c.family) { continue } if len(domains) > 0 && !slices.Contains(domains, c.domain) { continue } out = append(out, c) } return out, nil } func candidates(rows []repository.PlacementBatchCandidate) []batchCandidate { out := make([]batchCandidate, 0, len(rows)) for _, r := range rows { out = append(out, batchCandidate{ PlacementBatchCandidate: r, family: string(mailhost.ForMailbox(r.MailHost, r.Provider, r.Email)), domain: domainOf(r.SendFrom()), }) } return out } func uniqueUUIDs(ids []uuid.UUID) []uuid.UUID { seen := make(map[uuid.UUID]bool, len(ids)) out := make([]uuid.UUID, 0, len(ids)) for _, id := range ids { if id != uuid.Nil && !seen[id] { seen[id] = true out = append(out, id) } } return out } // validateSample checks a sampling request. func validateSample(sp *models.PlacementSample) *errx.Error { if sp.Mode == "" { sp.Mode = models.PlacementSampleAll } switch sp.Mode { case models.PlacementSampleAll: case models.PlacementSampleRandom, models.PlacementSamplePerDomain, models.PlacementSamplePerProvider: if sp.Count < 1 || sp.Count > config.PlacementBatchSendersMaxCeiling { return errx.New(errx.BadRequest, "sample.count must be at least 1") } case models.PlacementSamplePercent: if sp.Percent < 1 || sp.Percent > 100 { return errx.New(errx.BadRequest, "sample.percent must be between 1 and 100") } default: return errx.New(errx.BadRequest, "sample.mode must be all, random, percent, per_domain or per_provider") } switch sp.Stratify { case "", "provider", "domain": default: return errx.New(errx.BadRequest, "sample.stratify must be provider or domain") } if sp.Stratify != "" && sp.Mode != models.PlacementSampleRandom && sp.Mode != models.PlacementSamplePercent { return errx.New(errx.BadRequest, "sample.stratify applies to a random or percent sample") } return nil } // sampleSenders keeps part of the candidates. A stratified sample splits its // size across providers or domains in proportion to each one's share, largest // remainder first, so a small group is not rounded away. func sampleSenders(cands []batchCandidate, sp models.PlacementSample, rng *rand.Rand) []batchCandidate { shuffled := slices.Clone(cands) rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) groupOf := func(c batchCandidate, by string) string { if by == "domain" { return c.domain } return c.family } switch sp.Mode { case models.PlacementSampleRandom, models.PlacementSamplePercent: n := min(sp.Count, len(shuffled)) if sp.Mode == models.PlacementSamplePercent { n = int(math.Ceil(float64(len(shuffled)) * float64(sp.Percent) / 100)) } if sp.Stratify == "" { return shuffled[:n] } groups, keys := groupCandidates(shuffled, func(c batchCandidate) string { return groupOf(c, sp.Stratify) }) sizes := make([]int, len(keys)) for i, k := range keys { sizes[i] = len(groups[k]) } quota := allocate(n, sizes) var out []batchCandidate for i, k := range keys { out = append(out, groups[k][:quota[i]]...) } return out case models.PlacementSamplePerDomain, models.PlacementSamplePerProvider: by := "provider" if sp.Mode == models.PlacementSamplePerDomain { by = "domain" } groups, keys := groupCandidates(shuffled, func(c batchCandidate) string { return groupOf(c, by) }) var out []batchCandidate for _, k := range keys { g := groups[k] out = append(out, g[:min(sp.Count, len(g))]...) } return out } return shuffled } // groupCandidates buckets candidates by key, keeping their order, and returns // the keys sorted. func groupCandidates(cands []batchCandidate, key func(batchCandidate) string) (map[string][]batchCandidate, []string) { groups := map[string][]batchCandidate{} var keys []string for _, c := range cands { k := key(c) if _, ok := groups[k]; !ok { keys = append(keys, k) } groups[k] = append(groups[k], c) } sort.Strings(keys) return groups, keys } // allocate splits n across groups of the given sizes in proportion, by the // largest remainder, never giving a group more than it has. func allocate(n int, sizes []int) []int { total := 0 for _, s := range sizes { total += s } out := make([]int, len(sizes)) if total == 0 || n <= 0 { return out } n = min(n, total) type rem struct { i int frac float64 } rems := make([]rem, len(sizes)) given := 0 for i, size := range sizes { exact := float64(n) * float64(size) / float64(total) out[i] = int(math.Floor(exact)) given += out[i] rems[i] = rem{i, exact - float64(out[i])} } sort.SliceStable(rems, func(a, b int) bool { if rems[a].frac != rems[b].frac { return rems[a].frac > rems[b].frac } return sizes[rems[a].i] > sizes[rems[b].i] }) for k := 0; given < n; k = (k + 1) % len(rems) { i := rems[k].i if out[i] < sizes[i] { out[i]++ given++ } } return out } // staggerSenders orders a batch so consecutive starts rotate across sending // providers, and within a provider across domains, rather than running one // provider's mailboxes back to back. func staggerSenders(cands []batchCandidate, rng *rand.Rand) []batchCandidate { shuffled := slices.Clone(cands) rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) byFamily, families := groupCandidates(shuffled, func(c batchCandidate) string { return c.family }) queues := make([][]batchCandidate, len(families)) for i, f := range families { queues[i] = roundRobin(groupCandidates(byFamily[f], func(c batchCandidate) string { return c.domain })) } out := make([]batchCandidate, 0, len(cands)) for len(out) < len(cands) { for i := range queues { if len(queues[i]) > 0 { out = append(out, queues[i][0]) queues[i] = queues[i][1:] } } } return out } func roundRobin(groups map[string][]batchCandidate, keys []string) []batchCandidate { var out []batchCandidate for { added := false for _, k := range keys { if len(groups[k]) > 0 { out = append(out, groups[k][0]) groups[k] = groups[k][1:] added = true } } if !added { return out } } } // batchCost is what a batch of tests costs against the monthly allowance. type batchCost struct { metered bool usage models.PlacementUsage free int paid int credits int } func (s *service) batchCost(ctx context.Context, orgID uuid.UUID, panel string, tests int) (batchCost, *errx.Error) { var c batchCost switch panel { case models.PlacementPanelInstance: usage, xerr := s.usage(ctx, orgID) if xerr != nil { return c, xerr } c.usage = usage case models.PlacementPanelCloud: if s.Cloud != nil { if panel, xerr := s.Cloud.PlacementPanel(ctx); xerr == nil && panel != nil { c.usage = panel.Usage } } default: return c, nil } if c.usage.Limit == nil { return c, nil } c.metered = true c.free = min(tests, c.usage.Remaining()) c.paid = tests - c.free c.credits = c.paid * c.usage.CreditsPerTest return c, nil } // planBatch validates a request and resolves the senders it would run. func (s *service) planBatch(ctx context.Context, in *BatchInput, requireCopy bool) ([]batchCandidate, int, []variant, *errx.Error) { if xerr := s.batchesReady(); xerr != nil { return nil, 0, nil, xerr } if xerr := validateSample(&in.Sample); xerr != nil { return nil, 0, nil, xerr } variants, xerr := s.validateBatch(ctx, in, requireCopy) if xerr != nil { return nil, 0, nil, xerr } cands, xerr := s.resolveSenders(ctx, *in) if xerr != nil { return nil, 0, nil, xerr } matched := len(cands) return sampleSenders(cands, in.Sample, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))), matched, variants, nil } // PreviewBatch reports how many senders, tests, sends and credits a batch // request comes to, without starting anything. The copy may be left out. func (s *service) PreviewBatch(ctx context.Context, in BatchInput) (*BatchPreview, *errx.Error) { selected, matched, variants, xerr := s.planBatch(ctx, &in, false) if xerr != nil { return nil, xerr } pol := s.policy(ctx) p := &BatchPreview{ Matched: matched, Selected: len(selected), Variants: len(variants), Tests: len(selected) * len(variants), SeedsPerTest: pol.SeedsPerTest, SendersMax: pol.BatchSendersMax, Concurrency: pol.BatchSenderConcurrency, Providers: []BatchGroupCount{}, } perTest := pol.SeedsPerTest if len(in.SeedIDs) > 0 { perTest = len(in.SeedIDs) } p.SeedsPerTest = perTest p.MaxSends = p.Tests * perTest families := map[string]int{} domains := map[string]bool{} for _, c := range selected { families[c.family]++ domains[c.domain] = true if c.Status != "active" || c.WorkerID == nil { p.Inactive++ } } p.Domains = len(domains) for f, n := range families { p.Providers = append(p.Providers, BatchGroupCount{Key: f, Label: familyLabel(f), Senders: n}) } sort.Slice(p.Providers, func(i, j int) bool { return p.Providers[i].Senders > p.Providers[j].Senders }) cost, xerr := s.batchCost(ctx, in.OrgID, in.Panel, p.Tests) if xerr != nil { return nil, xerr } p.Metered, p.Usage, p.FreeTests, p.PaidTests, p.Credits = cost.metered, cost.usage, cost.free, cost.paid, cost.credits return p, nil } // CreateBatch snapshots the senders and queues the batch. Nothing is sent // here; the runner starts senders a few at a time. func (s *service) CreateBatch(ctx context.Context, in BatchInput) (*BatchView, *errx.Error) { selected, matched, variants, xerr := s.planBatch(ctx, &in, true) if xerr != nil { return nil, xerr } pol := s.policy(ctx) if len(selected) == 0 { return nil, placementErr(errx.BadRequest, "placement_batch_empty", "No sending mailbox matches this selection.") } if len(selected) > pol.BatchSendersMax { return nil, placementErr(errx.BadRequest, "placement_batch_too_large", "This batch has "+strconv.Itoa(len(selected))+" senders and this instance allows up to "+ strconv.Itoa(pol.BatchSendersMax)+" in one batch. Narrow the selection or take a sample.") } open, err := s.Batches.CountOpenBatches(ctx, in.OrgID) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } if open >= config.PlacementBatchOpenPerOrgMax { return nil, placementErr(errx.TooManyRequests, "placement_too_many_batches", "This workspace already has "+strconv.Itoa(open)+" placement batches running. Wait for one to finish, or cancel one.") } tests := len(selected) * len(variants) cost, xerr := s.batchCost(ctx, in.OrgID, in.Panel, tests) if xerr != nil { return nil, xerr } if cost.paid > 0 { if cost.usage.CreditsPerTest == 0 { return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded", "This batch needs "+strconv.Itoa(tests)+" tests and the workspace has "+strconv.Itoa(cost.free)+ " free this month. Take a smaller sample or test on your own seed inboxes.") } if in.MaxCredits < cost.credits { return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded", "This batch can cost up to "+strconv.Itoa(cost.credits)+" credits past the free tests; start it again agreeing to pay that many.") } if bal := cost.usage.CreditBalance; bal != nil && *bal < cost.credits { return nil, placementErr(errx.PaymentRequired, "insufficient_credits", "This batch can cost up to "+strconv.Itoa(cost.credits)+" credits and the workspace has "+strconv.Itoa(*bal)+".") } } else { // Nothing agreed is ever charged when the batch fits the free tests. in.MaxCredits = 0 } now := s.now() ordered := staggerSenders(selected, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) b := models.PlacementBatch{ ID: uuid.New(), OrganizationID: in.OrgID, CreatedBy: in.UserID, CampaignID: in.CampaignID, SequenceID: in.SequenceID, ContactID: in.ContactID, Subject: in.Subject, BodyHTML: in.BodyHTML, BodyPlain: in.BodyPlain, Tracking: in.Tracking, Panel: in.Panel, Pace: in.Pace, Families: in.Families, SeedIDs: in.SeedIDs, OnUnavailable: in.OnUnavailable, Selection: models.PlacementBatchSelection{ SenderAccountIDs: len(in.SenderAccountIDs), Scope: in.Scope, Sample: in.Sample, Matched: matched, }, SenderCount: len(ordered), MaxCredits: in.MaxCredits, Status: models.PlacementBatchQueued, RetryUntil: now.AddDate(0, 0, config.PlacementBatchRetryDays), } senders := make([]models.PlacementBatchSender, len(ordered)) for i, c := range ordered { id := c.ID senders[i] = models.PlacementBatchSender{ ID: uuid.New(), EmailAccountID: &id, SenderEmail: c.SendFrom(), SenderDomain: c.domain, SenderFamily: c.family, Position: i, Status: models.PlacementSenderQueued, } } if err := s.Batches.CreateBatch(ctx, &b, senders); err != nil { errs.CaptureException(err) return nil, errx.InternalError() } s.publishBatch(ctx, &b) v := BatchView{PlacementBatch: b, Summary: models.PlacementCounts{}} v.Progress.Add(models.PlacementSenderQueued, len(senders)) v.Summary.Finish() stripBody(&v.PlacementBatch) return &v, nil } func stripBody(b *models.PlacementBatch) { b.BodyHTML, b.BodyPlain = "", "" } func (s *service) publishBatch(ctx context.Context, b *models.PlacementBatch) { if s.Publisher != nil { s.Publisher.PublishPlacementBatch(ctx, b.OrganizationID, b.ID, b.Status) } } // ListBatches lists a workspace's batches, newest first. func (s *service) ListBatches(ctx context.Context, orgID uuid.UUID, limit, offset int) ([]BatchView, int, *errx.Error) { if xerr := s.batchesReady(); xerr != nil { return nil, 0, xerr } batches, total, err := s.Batches.ListBatches(ctx, orgID, limit, offset) if err != nil { errs.CaptureException(err) return nil, 0, errx.InternalError() } views, xerr := s.batchViews(ctx, batches) if xerr != nil { return nil, 0, xerr } return views, total, nil } func (s *service) batchViews(ctx context.Context, batches []models.PlacementBatch) ([]BatchView, *errx.Error) { ids := make([]uuid.UUID, len(batches)) for i, b := range batches { ids[i] = b.ID } progress, err := s.Batches.BatchProgress(ctx, ids) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } summaries, err := s.Batches.BatchSummaries(ctx, ids) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } out := make([]BatchView, 0, len(batches)) for _, b := range batches { stripBody(&b) sum := summaries[b.ID] sum.Finish() out = append(out, BatchView{PlacementBatch: b, Progress: progress[b.ID], Summary: sum}) } return out, nil } // GetBatch is one batch with its placement overall, by sending domain and // provider, by recipient provider, and as a domain by recipient matrix. func (s *service) GetBatch(ctx context.Context, orgID, id uuid.UUID) (*BatchDetail, *errx.Error) { if xerr := s.batchesReady(); xerr != nil { return nil, xerr } b, err := s.Batches.GetBatch(ctx, orgID, id) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } if b == nil { return nil, errx.New(errx.NotFound, "placement batch not found") } content := contentCheck(models.PlacementTest{Subject: b.Subject, BodyHTML: b.BodyHTML, BodyPlain: b.BodyPlain}) views, xerr := s.batchViews(ctx, []models.PlacementBatch{*b}) if xerr != nil { return nil, xerr } rows, err := s.Batches.BatchBreakdown(ctx, id) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } sizes, err := s.Batches.BatchGroupSizes(ctx, id) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } d := buildBatchDetail(views[0], rows, sizes) d.Content = content return d, nil } // buildBatchDetail folds the breakdown rows into the detail's groups. func buildBatchDetail(v BatchView, rows []repository.PlacementBreakdownRow, sizes []repository.PlacementBatchGroupSize) *BatchDetail { d := &BatchDetail{BatchView: v, Domains: []BatchGroup{}, Providers: []BatchGroup{}, Recipients: []models.PlacementFamilyCounts{}, Matrix: []BatchMatrixRow{}} compare := v.Tracking == models.PlacementTrackingCompare var headline, untracked models.PlacementCounts domains := map[string]*BatchGroup{} providers := map[string]*BatchGroup{} recipients := map[string]*models.PlacementCounts{} matrix := map[string]map[string]*models.PlacementCounts{} group := func(m map[string]*BatchGroup, key, label string) *BatchGroup { g, ok := m[key] if !ok { g = &BatchGroup{Key: key, Label: label} m[key] = g } return g } for _, r := range rows { switch r.Set { case "overall": if compare && !r.Tracked { untracked.AddN(r.Folder, r.Count) } else { headline.AddN(r.Folder, r.Count) } case "domain": group(domains, r.SenderDomain, r.SenderDomain).Counts.AddN(r.Folder, r.Count) case "provider": group(providers, r.SenderFamily, familyLabel(r.SenderFamily)).Counts.AddN(r.Folder, r.Count) case "recipient": c, ok := recipients[r.RecipientFamily] if !ok { c = &models.PlacementCounts{} recipients[r.RecipientFamily] = c } c.AddN(r.Folder, r.Count) case "matrix": row, ok := matrix[r.SenderDomain] if !ok { row = map[string]*models.PlacementCounts{} matrix[r.SenderDomain] = row } c, ok := row[r.RecipientFamily] if !ok { c = &models.PlacementCounts{} row[r.RecipientFamily] = c } c.AddN(r.Folder, r.Count) } } for _, sz := range sizes { if sz.ByDomain { g := group(domains, sz.Domain, sz.Domain) g.Senders, g.Tested = sz.Senders, sz.Completed } else { g := group(providers, sz.Family, familyLabel(sz.Family)) g.Senders, g.Tested = sz.Senders, sz.Completed } } headline.Finish() d.Summary = headline if compare { untracked.Finish() d.Untracked = &untracked } flatten := func(m map[string]*BatchGroup) []BatchGroup { out := make([]BatchGroup, 0, len(m)) for _, g := range m { g.Counts.Finish() out = append(out, *g) } sort.Slice(out, func(i, j int) bool { return worseGroup(out[i], out[j]) }) return out } d.Domains = flatten(domains) d.Providers = flatten(providers) for fam, c := range recipients { c.Finish() d.Recipients = append(d.Recipients, models.PlacementFamilyCounts{Family: fam, Label: familyLabel(fam), Counts: *c}) } sort.Slice(d.Recipients, func(i, j int) bool { return d.Recipients[i].Label < d.Recipients[j].Label }) for _, g := range d.Domains { row, ok := matrix[g.Key] if !ok { continue } mr := BatchMatrixRow{Domain: g.Key, Recipients: make([]models.PlacementFamilyCounts, 0, len(d.Recipients))} for _, rc := range d.Recipients { c := models.PlacementCounts{} if got, ok := row[rc.Family]; ok { c = *got } c.Finish() mr.Recipients = append(mr.Recipients, models.PlacementFamilyCounts{Family: rc.Family, Label: rc.Label, Counts: c}) } d.Matrix = append(d.Matrix, mr) } return d } // worseGroup orders groups lowest inbox rate first, untested last, then the // larger group first. func worseGroup(a, b BatchGroup) bool { ra, rb := a.Counts.InboxRate, b.Counts.InboxRate switch { case ra != nil && rb == nil: return true case ra == nil && rb != nil: return false case ra != nil && rb != nil && *ra != *rb: return *ra < *rb } if a.Senders != b.Senders { return a.Senders > b.Senders } return a.Key < b.Key } // ListBatchSenders lists a batch's senders with where each one's copies // landed, worst inbox rate first by default. func (s *service) ListBatchSenders(ctx context.Context, orgID, id uuid.UUID, f repository.PlacementBatchSenderFilter) ([]BatchSenderView, int, *errx.Error) { if xerr := s.batchesReady(); xerr != nil { return nil, 0, xerr } switch f.Sort { case "", "worst", "best", "email", "status": default: return nil, 0, errx.New(errx.BadRequest, "sort must be worst, best, email or status") } switch f.Status { case "", models.PlacementSenderQueued, models.PlacementSenderDeferred, models.PlacementSenderRunning, models.PlacementSenderCompleted, models.PlacementSenderSkipped, models.PlacementSenderFailed, models.PlacementSenderCancelled: default: return nil, 0, errx.New(errx.BadRequest, "invalid status") } if len(f.Search) > 200 { return nil, 0, errx.New(errx.BadRequest, "search is too long") } b, err := s.Batches.GetBatch(ctx, orgID, id) if err != nil { errs.CaptureException(err) return nil, 0, errx.InternalError() } if b == nil { return nil, 0, errx.New(errx.NotFound, "placement batch not found") } rows, total, err := s.Batches.ListBatchSenders(ctx, orgID, id, f) if err != nil { errs.CaptureException(err) return nil, 0, errx.InternalError() } out := make([]BatchSenderView, 0, len(rows)) for _, r := range rows { out = append(out, BatchSenderView{ PlacementBatchSender: r.PlacementBatchSender, SenderFamilyLabel: familyLabel(r.SenderFamily), Summary: r.Counts, TestIDs: r.TestIDs, }) } return out, total, nil } // CancelBatch stops a batch: no sender starts again, copies not sent yet are // cancelled, and copies already sent keep being classified. func (s *service) CancelBatch(ctx context.Context, orgID, id uuid.UUID) (*BatchView, *errx.Error) { if xerr := s.batchesReady(); xerr != nil { return nil, xerr } ok, running, err := s.Batches.CancelBatch(ctx, orgID, id) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } for _, testID := range running { if _, err := s.Repo.CancelTest(ctx, orgID, testID); err != nil { errs.CaptureException(err) } } b, err := s.Batches.GetBatch(ctx, orgID, id) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } if b == nil { return nil, errx.New(errx.NotFound, "placement batch not found") } if !ok { return nil, placementErr(errx.Conflict, "placement_batch_not_running", "This placement batch is no longer running.") } s.publishBatch(ctx, b) views, xerr := s.batchViews(ctx, []models.PlacementBatch{*b}) if xerr != nil { return nil, xerr } return &views[0], nil } // Coverage is how much of the workspace's connected fleet delivered a // placement test recently. func (s *service) Coverage(ctx context.Context, orgID uuid.UUID) (*repository.PlacementCoverage, *errx.Error) { if xerr := s.batchesReady(); xerr != nil { return nil, xerr } c, err := s.Batches.Coverage(ctx, orgID, s.now()) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } return &c, nil }