mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 08:01:23 +00:00
feat: measure an uploaded list at import, and say what it looks like (#236)
* feat: refuse a launch whose list is known to be largely undeliverable * feat: count the deliverable audience in SQL instead of subtracting two overlapping totals, surface the unverified advice, and stop a query error skipping the gate silently: a contact can be both suppressed and unsubscribed, so Total minus both removed it twice and inflated every share computed against the remainder, which for a fully overlapping list produced a negative deliverable clamped to zero; the unverified-list branch set a summary and remediation but neither Block nor Warn, and a preflight report only surfaces checks that did not pass, so that advice could never be displayed; and a failed audience query was treated as a pass, which is fail-open on a safety gate without even a log line saying the check did not run * feat: measure an uploaded list at import time and report what it looks like * feat: document the import assessment and show it in the wizard * feat: count only sendable leads as invalid, and stop guessing which column held a malformed address: an invalid lead that was also suppressed sat in the numerator while Deliverable excluded it from the denominator, so a campaign whose sendable list was clean could project above 100% and be refused; every verification count now shares the deliverable predicate, and a row whose MAPPED address will not parse is recorded as malformed directly rather than scanning other cells for an at sign, which could pick up a notes field
This commit is contained in:
@@ -1151,6 +1151,11 @@ func main() {
|
||||
rateLimitService = ratelimit.NewService(cache, rateLimitRepository)
|
||||
sequenceService = sequence.NewService(sequenceRepostory)
|
||||
contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository, streamingPublisher)
|
||||
// A visibly bad import is filed on the workspace's posture. On its own
|
||||
// it can only reach `watch`, which changes nothing.
|
||||
if aware, ok := contactService.(contact.OrgRiskAware); ok && orgRiskService != nil {
|
||||
aware.WireOrgRisk(orgRiskService)
|
||||
}
|
||||
|
||||
// On-demand Google Sheets -> leads sync (backend-only / control plane).
|
||||
// Reuses the integration service for the Google token + sheet reads and
|
||||
|
||||
@@ -382,10 +382,19 @@ Send `multipart/form-data` with a `file` field and an `options` field containing
|
||||
"ended_at": "2026-06-11T10:10:07Z",
|
||||
"errors": [
|
||||
{ "line": 57, "email": "not-an-email", "reason": "invalid email" }
|
||||
]
|
||||
],
|
||||
"quality": {
|
||||
"malformed": 4,
|
||||
"disposable": 0,
|
||||
"role": 62,
|
||||
"bad_share_pct": 0.3,
|
||||
"flagged": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`quality` describes the addresses in the file: `malformed` are not addresses at all, `disposable` are on known throwaway domains, and `role` counts shared inboxes such as `info@`. `bad_share_pct` is malformed plus disposable, as a percentage of the file; role addresses are deliberately excluded from it, since mailing a shared inbox is a choice rather than a defect. `flagged` is set above 25% on files of at least 20 rows, and carries a `summary` sentence. It is advisory: a flagged import still stores every row it could parse. A list bad enough to matter is refused at campaign launch instead.
|
||||
|
||||
Imports are capped at 50,000 rows. `errors` carries at most the first 1,000 entries; past that `errors_truncated` is `true` and the counters, not the list, are the real totals. Every row lands in exactly one of `imported`, `updated`, `skipped`, or `failed`, so those four always sum to `total`.
|
||||
|
||||
A custom-field name may use letters, numbers, underscores, spaces, and dashes (`Company Mobile`, `first-name`, `plan_tier`). Anything else is a `400` on the whole request, raised before any row is written, along with a mapping that names no `email` column or a `custom` column with no `custom_key`. Per-row `errors` are reserved for problems with the data itself.
|
||||
|
||||
@@ -35,6 +35,16 @@ Problems with the mapping itself, an unnamed custom field or a name Warmbly cann
|
||||
|
||||
Saved sources live in your Sync sources list to re-run, edit, or remove. Each sync dedupes on lowercased email using your chosen duplicate handling, so re-syncing a sheet with new rows is safe.
|
||||
|
||||
### What the result tells you about the list
|
||||
|
||||
The result step also reports what the addresses themselves look like: how many were malformed, how many are on known throwaway domains, and how many are shared inboxes like `info@`. When a quarter or more of the file is malformed or throwaway, the result says so.
|
||||
|
||||
- **Shared inboxes are counted but not held against the list.** Mailing `info@` is a choice, and plenty of legitimate B2B lists are mostly role addresses.
|
||||
- **The import still happens.** These are your records, so nothing is refused here. A list bad enough to matter is stopped when you try to launch a campaign with it, which is where the damage would actually occur. See [the launch check](/guides/campaigns/).
|
||||
- **Lists under 20 rows are not judged**, since a share of a handful of rows means nothing.
|
||||
|
||||
This is not address verification. It reads the addresses; verification asks the receiving server whether they exist, and runs separately in the background.
|
||||
|
||||
## Custom fields
|
||||
|
||||
For anything beyond identity: industry, plan tier, account owner. Create them by mapping a column to "Use as custom field" during import, or on a contact's Details tab.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -11,9 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/app/orgrisk"
|
||||
"github.com/warmbly/warmbly/internal/email"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/listquality"
|
||||
"github.com/warmbly/warmbly/internal/utils"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
@@ -318,10 +321,27 @@ func (s *contactService) ImportCommit(
|
||||
return nil, xerr
|
||||
}
|
||||
|
||||
// Measure the list the customer actually uploaded, malformed rows included:
|
||||
// those are exactly what this is counting. Synchronous and address-only, so
|
||||
// they learn something now rather than when verification catches up.
|
||||
allAddresses := make([]string, 0, len(parsed))
|
||||
for i := range parsed {
|
||||
if addr := strings.TrimSpace(parsed[i].contact.Email); addr != "" {
|
||||
allAddresses = append(allAddresses, addr)
|
||||
continue
|
||||
}
|
||||
// The mapped address would not parse. That is exactly what malformed
|
||||
// means, so it is recorded as such rather than hunting other columns
|
||||
// for something with an @ in it, which could pick up a notes field.
|
||||
allAddresses = append(allAddresses, unparseableAddress)
|
||||
}
|
||||
quality := listquality.Assess(allAddresses)
|
||||
|
||||
res := &models.ContactImportResult{
|
||||
Total: len(parsed),
|
||||
StartedAt: startedAt,
|
||||
Errors: make([]models.ContactImportRowError, 0),
|
||||
Quality: toImportQuality(quality),
|
||||
}
|
||||
// warn records a row-level note without counting the row as failed, so
|
||||
// Total always equals imported + updated + skipped + failed.
|
||||
@@ -485,6 +505,20 @@ func (s *contactService) ImportCommit(
|
||||
}
|
||||
|
||||
res.EndedAt = time.Now().UTC()
|
||||
|
||||
// File the finding on the workspace's posture. Import quality alone can
|
||||
// only reach `watch`, which changes nothing a customer can feel; it takes
|
||||
// several detectors agreeing to restrict anything.
|
||||
if quality.Flagged && s.orgRisk != nil {
|
||||
if _, err := s.orgRisk.RecordSignal(ctx, orgID, orgrisk.Signal{
|
||||
Key: "list_quality",
|
||||
Weight: importRiskWeight(quality.BadSharePct),
|
||||
Detail: quality.Summary,
|
||||
}); err != nil {
|
||||
log.Warn().Str("organization_id", orgID.String()).Msg("could not record the import quality signal")
|
||||
}
|
||||
}
|
||||
|
||||
if res.Imported > 0 || res.Updated > 0 || len(skippedLinks) > 0 {
|
||||
s.publishContactsReload(ctx, userID, "contacts:import")
|
||||
// Covers the Google Sheets sync too: it commits through this path.
|
||||
@@ -813,3 +847,35 @@ func parseIDList(raw []string) ([]string, *errx.Error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// unparseableAddress stands in for a row whose mapped email would not parse.
|
||||
// It is deliberately not an address, so the assessment counts it as malformed.
|
||||
const unparseableAddress = "(unparseable)"
|
||||
|
||||
// toImportQuality maps the assessment onto the API shape.
|
||||
func toImportQuality(q listquality.Summary) *models.ContactImportQuality {
|
||||
if q.Total == 0 {
|
||||
return nil
|
||||
}
|
||||
return &models.ContactImportQuality{
|
||||
Malformed: q.Malformed,
|
||||
Disposable: q.Disposable,
|
||||
Role: q.Role,
|
||||
BadSharePct: q.BadSharePct,
|
||||
Flagged: q.Flagged,
|
||||
Summary: q.Summary,
|
||||
}
|
||||
}
|
||||
|
||||
// importRiskWeight scales the org-risk contribution with how bad the list is,
|
||||
// capped so one import can never restrict a workspace on its own.
|
||||
func importRiskWeight(badPct float64) int {
|
||||
w := int(badPct / 2)
|
||||
if w > 30 {
|
||||
w = 30
|
||||
}
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -500,3 +500,85 @@ func firstN[T any](in []T, n int) []T {
|
||||
}
|
||||
return in[:n]
|
||||
}
|
||||
|
||||
// simpleCSV builds an email-only file from the given addresses.
|
||||
func simpleCSV(emails []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("email\n")
|
||||
for _, e := range emails {
|
||||
b.WriteString(e)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func emailOnlyMapping() []models.ContactImportColumnMapping {
|
||||
return []models.ContactImportColumnMapping{{Index: 0, Target: "email"}}
|
||||
}
|
||||
|
||||
func repeatEmails(pattern string, n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, fmt.Sprintf(pattern, i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Issue #145: the assessment has to reach the RESULT, not just exist as a
|
||||
// package. Testing listquality directly could never catch a call site that
|
||||
// never runs, which is the failure this codebase keeps producing.
|
||||
func TestLiveImportReportsListQuality(t *testing.T) {
|
||||
f := newImportFixture(t)
|
||||
|
||||
emails := append(repeatEmails("junk%d-not-an-email", 30), repeatEmails("throwaway%d@mailinator.com", 30)...)
|
||||
emails = append(emails, repeatEmails("real%d@acme.test", 40)...)
|
||||
|
||||
res, msg := f.commit(t, simpleCSV(emails), &models.ContactImportCommit{
|
||||
Mapping: emailOnlyMapping(),
|
||||
Dedup: models.ContactImportDedupSkip,
|
||||
HasHeader: true,
|
||||
})
|
||||
if msg != "" {
|
||||
t.Fatalf("import rejected: %s", msg)
|
||||
}
|
||||
if res.Quality == nil {
|
||||
t.Fatal("no quality assessment on the result; the import path never ran it")
|
||||
}
|
||||
if !res.Quality.Flagged {
|
||||
t.Errorf("a 60%% unusable list was not flagged: %+v", res.Quality)
|
||||
}
|
||||
if res.Quality.Disposable != 30 {
|
||||
t.Errorf("disposable = %d, want the 30 throwaway addresses", res.Quality.Disposable)
|
||||
}
|
||||
if res.Quality.Summary == "" {
|
||||
t.Error("a flagged list must say what is wrong with it")
|
||||
}
|
||||
// The import still happened: these are the customer's own records, and the
|
||||
// launch gate is where a bad list is actually stopped.
|
||||
if res.Imported == 0 {
|
||||
t.Error("a flagged import stored nothing; it should report, not refuse")
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary list must come back with nothing to say.
|
||||
func TestLiveImportOfAnOrdinaryListIsQuiet(t *testing.T) {
|
||||
f := newImportFixture(t)
|
||||
|
||||
res, msg := f.commit(t, simpleCSV(repeatEmails("person%d@acme.test", 60)), &models.ContactImportCommit{
|
||||
Mapping: emailOnlyMapping(),
|
||||
Dedup: models.ContactImportDedupSkip,
|
||||
HasHeader: true,
|
||||
})
|
||||
if msg != "" {
|
||||
t.Fatalf("import rejected: %s", msg)
|
||||
}
|
||||
if res.Quality == nil {
|
||||
t.Fatal("no quality assessment on the result")
|
||||
}
|
||||
if res.Quality.Flagged {
|
||||
t.Errorf("an ordinary list was flagged: %+v", res.Quality)
|
||||
}
|
||||
if res.Imported != 60 {
|
||||
t.Errorf("imported = %d, want 60", res.Imported)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/warmbly/warmbly/internal/app/orgrisk"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
@@ -88,6 +89,17 @@ type contactService struct {
|
||||
planRepo repository.PlanRepository
|
||||
streamingPublisher *pubsub.StreamingPublisher
|
||||
campaignWaker CampaignWaker
|
||||
// orgRisk files import-quality findings on the workspace's posture.
|
||||
// Optional/nil-safe: without it a bad import is reported but not fused.
|
||||
orgRisk orgrisk.Service
|
||||
}
|
||||
|
||||
// WireOrgRisk attaches the organization risk posture.
|
||||
func (s *contactService) WireOrgRisk(r orgrisk.Service) { s.orgRisk = r }
|
||||
|
||||
// OrgRiskAware is the optional capability the caller uses to attach it.
|
||||
type OrgRiskAware interface {
|
||||
WireOrgRisk(r orgrisk.Service)
|
||||
}
|
||||
|
||||
func NewService(
|
||||
|
||||
@@ -49,8 +49,8 @@ type Verdict struct {
|
||||
// Project estimates the audience's bounce rate. A list too small to judge, or
|
||||
// with nothing deliverable, is never blocked.
|
||||
func Project(a repository.CampaignAudience) Verdict {
|
||||
// Counted in SQL, not derived: a contact can be both suppressed and
|
||||
// unsubscribed, and subtracting both counts would remove it twice.
|
||||
// Counted in SQL: a contact can be both suppressed and unsubscribed, so
|
||||
// subtracting both counts would remove it twice.
|
||||
deliverable := a.Deliverable
|
||||
if deliverable < 0 {
|
||||
deliverable = 0
|
||||
|
||||
@@ -132,4 +132,21 @@ type ContactImportResult struct {
|
||||
// ErrorsTruncated is true when that cap was reached, so the UI can say
|
||||
// "showing the first N of M" instead of implying it listed everything.
|
||||
ErrorsTruncated bool `json:"errors_truncated,omitempty"`
|
||||
|
||||
// Quality is what the uploaded addresses look like, measured at import.
|
||||
// Advisory: a bad list is reported here and stopped at launch, never
|
||||
// refused here, because these are the customer's own records.
|
||||
Quality *ContactImportQuality `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
// ContactImportQuality is an import's address-level assessment.
|
||||
type ContactImportQuality struct {
|
||||
Malformed int `json:"malformed"`
|
||||
Disposable int `json:"disposable"`
|
||||
// Role counts shared inboxes. Reported, not counted as bad: mailing info@
|
||||
// is a choice, and many legitimate B2B lists are mostly role addresses.
|
||||
Role int `json:"role"`
|
||||
BadSharePct float64 `json:"bad_share_pct"`
|
||||
Flagged bool `json:"flagged"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package listquality measures a batch of addresses at import time.
|
||||
//
|
||||
// This is deliberately NOT verification. Verification asks a mail server
|
||||
// whether an address exists and runs asynchronously; this reads the addresses
|
||||
// themselves, synchronously, so a customer learns something about a list the
|
||||
// moment they upload it rather than hours later.
|
||||
package listquality
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/pkg/signuprisk"
|
||||
)
|
||||
|
||||
// rolePrefixes are shared-inbox local parts. Same vocabulary the advisor and
|
||||
// the launch gate use, so one list is never described three different ways.
|
||||
var rolePrefixes = map[string]bool{
|
||||
"info": true, "sales": true, "support": true, "contact": true,
|
||||
"admin": true, "hello": true, "help": true, "office": true, "team": true,
|
||||
"billing": true, "careers": true, "jobs": true, "marketing": true,
|
||||
"noreply": true, "no-reply": true, "webmaster": true,
|
||||
"enquiries": true, "enquiry": true,
|
||||
}
|
||||
|
||||
// Summary is what one import's addresses look like.
|
||||
type Summary struct {
|
||||
Total int
|
||||
// Malformed are addresses that are not addresses at all.
|
||||
Malformed int
|
||||
// Disposable are throwaway-domain addresses.
|
||||
Disposable int
|
||||
// Role are shared inboxes, which reply rarely and complain more.
|
||||
Role int
|
||||
// BadSharePct is malformed plus disposable, as a percentage. Role
|
||||
// addresses are deliberately excluded: mailing info@ is a choice, not a
|
||||
// defect, and plenty of legitimate B2B lists are full of them.
|
||||
BadSharePct float64
|
||||
// Flagged is true when the list is bad enough to tell the customer about.
|
||||
Flagged bool
|
||||
// Summary is the sentence they read.
|
||||
Summary string
|
||||
}
|
||||
|
||||
const (
|
||||
// FlagSharePct is where a list is called out. Below this a few bad
|
||||
// addresses in a big list are just normal data entry.
|
||||
FlagSharePct = 25.0
|
||||
// MinSample is the size below which a share means nothing.
|
||||
MinSample = 20
|
||||
)
|
||||
|
||||
// Assess scores a batch. It never rejects an import: the addresses are the
|
||||
// customer's own data, and refusing to store them is a different and much
|
||||
// larger decision than refusing to send to them. The launch gate is where a
|
||||
// bad list is actually stopped.
|
||||
func Assess(emails []string) Summary {
|
||||
s := Summary{Total: len(emails)}
|
||||
for _, raw := range emails {
|
||||
addr := strings.TrimSpace(raw)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := mail.ParseAddress(addr); err != nil {
|
||||
s.Malformed++
|
||||
continue
|
||||
}
|
||||
if signuprisk.IsDisposable(addr) {
|
||||
s.Disposable++
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(addr, "@"); at > 0 && rolePrefixes[strings.ToLower(addr[:at])] {
|
||||
s.Role++
|
||||
}
|
||||
}
|
||||
|
||||
if s.Total == 0 {
|
||||
return s
|
||||
}
|
||||
s.BadSharePct = float64(s.Malformed+s.Disposable) / float64(s.Total) * 100
|
||||
|
||||
if s.Total >= MinSample && s.BadSharePct >= FlagSharePct {
|
||||
s.Flagged = true
|
||||
s.Summary = fmt.Sprintf(
|
||||
"%.0f%% of this list is unusable: %d malformed and %d throwaway addresses out of %d.",
|
||||
s.BadSharePct, s.Malformed, s.Disposable, s.Total)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package listquality
|
||||
|
||||
import "testing"
|
||||
|
||||
func repeat(email string, n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The case that decides whether this is usable: an ordinary business list must
|
||||
// pass silently.
|
||||
func TestAssessOrdinaryListIsQuiet(t *testing.T) {
|
||||
emails := append(repeat("ada@acme.com", 50), repeat("someone@gmail.com", 50)...)
|
||||
s := Assess(emails)
|
||||
if s.Flagged {
|
||||
t.Errorf("an ordinary list was flagged: %+v", s)
|
||||
}
|
||||
if s.BadSharePct != 0 {
|
||||
t.Errorf("bad share = %.1f%%, want 0", s.BadSharePct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessFlagsAScrapedList(t *testing.T) {
|
||||
emails := append(repeat("not-an-email", 30), repeat("x@mailinator.com", 30)...)
|
||||
emails = append(emails, repeat("ada@acme.com", 40)...)
|
||||
s := Assess(emails)
|
||||
if !s.Flagged {
|
||||
t.Errorf("a 60%% unusable list was not flagged: %+v", s)
|
||||
}
|
||||
if s.Malformed != 30 || s.Disposable != 30 {
|
||||
t.Errorf("malformed = %d, disposable = %d, want 30 each", s.Malformed, s.Disposable)
|
||||
}
|
||||
if s.Summary == "" {
|
||||
t.Error("a flagged list must say what is wrong with it")
|
||||
}
|
||||
}
|
||||
|
||||
// Role addresses are a choice, not a defect. Plenty of legitimate B2B lists are
|
||||
// mostly info@ and sales@, and flagging those would be wrong.
|
||||
func TestAssessDoesNotCountRoleAddressesAsBad(t *testing.T) {
|
||||
s := Assess(repeat("info@acme.com", 100))
|
||||
if s.Flagged {
|
||||
t.Errorf("an all-role list was flagged as unusable: %+v", s)
|
||||
}
|
||||
if s.Role != 100 {
|
||||
t.Errorf("role = %d, want 100 counted but not penalised", s.Role)
|
||||
}
|
||||
if s.BadSharePct != 0 {
|
||||
t.Errorf("bad share = %.1f%%, want 0", s.BadSharePct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessIgnoresASampleTooSmallToJudge(t *testing.T) {
|
||||
for _, n := range []int{1, 5, MinSample - 1} {
|
||||
if s := Assess(repeat("not-an-email", n)); s.Flagged {
|
||||
t.Errorf("a list of %d was flagged; too small to judge", n)
|
||||
}
|
||||
}
|
||||
if s := Assess(repeat("not-an-email", MinSample)); !s.Flagged {
|
||||
t.Errorf("a fully malformed list of %d should flag", MinSample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessHandlesEmptyAndBlank(t *testing.T) {
|
||||
if s := Assess(nil); s.Flagged || s.Total != 0 {
|
||||
t.Errorf("empty input: %+v", s)
|
||||
}
|
||||
// Blank rows are skipped rather than counted as malformed, or a file with
|
||||
// trailing newlines would look like a bad list.
|
||||
s := Assess([]string{"ada@acme.com", "", " "})
|
||||
if s.Malformed != 0 {
|
||||
t.Errorf("blank rows counted as malformed: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,13 @@ var freeProviders = map[string]bool{
|
||||
"aol.com": true, "gmx.com": true, "mail.com": true, "zoho.com": true,
|
||||
}
|
||||
|
||||
// IsDisposable reports whether an address is on a known throwaway domain.
|
||||
// Exported so the contact-import gate scores against the same list rather than
|
||||
// keeping a second copy that drifts.
|
||||
func IsDisposable(email string) bool {
|
||||
return disposableDomains[domainOf(email)]
|
||||
}
|
||||
|
||||
// Score assesses one signup.
|
||||
func Score(email, ipaddr string) Result {
|
||||
res := Result{Reasons: []string{}}
|
||||
|
||||
@@ -106,3 +106,29 @@ func TestLiveCampaignAudienceIsScoped(t *testing.T) {
|
||||
t.Errorf("an unknown campaign returned %d leads, want 0", got.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this guards: an invalid address that is also suppressed was counted
|
||||
// in Invalid while excluded from Deliverable, so the projected rate could
|
||||
// exceed 100% and block a launch whose sendable list was clean.
|
||||
func TestLiveCampaignAudienceCountsOnlySendableAsInvalid(t *testing.T) {
|
||||
handle, pool := liveContactDB(t)
|
||||
f := newSharedOrgFixture(t, pool)
|
||||
repo := NewCampaignAudienceRepository(handle)
|
||||
|
||||
// 9 invalid leads, all unsubscribed, plus 1 clean sendable lead.
|
||||
for i := 0; i < 9; i++ {
|
||||
addLead(t, f, "dead"+uuid.New().String()[:6]+"@test.local", "invalid", false)
|
||||
}
|
||||
addLead(t, f, "live"+uuid.New().String()[:6]+"@test.local", "valid", true)
|
||||
|
||||
got, err := repo.GetCampaignAudience(context.Background(), f.org, f.campaign)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCampaignAudience: %v", err)
|
||||
}
|
||||
if got.Invalid != 0 {
|
||||
t.Errorf("invalid = %d, want 0: every invalid lead is unsendable", got.Invalid)
|
||||
}
|
||||
if got.Deliverable != 1 {
|
||||
t.Errorf("deliverable = %d, want the 1 sendable lead", got.Deliverable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
// it is now, not as it was at the last sweep.
|
||||
type CampaignAudience struct {
|
||||
Total int
|
||||
// Verification counts come from the address-verification service. Unknown
|
||||
// means unverified, which is not the same as bad.
|
||||
// Verification counts, restricted to DELIVERABLE leads. Counting an
|
||||
// invalid address that is also suppressed would put it in the numerator
|
||||
// while Deliverable excludes it from the denominator, which can push the
|
||||
// projected rate above 100%.
|
||||
Invalid int
|
||||
Risky int
|
||||
Unknown int
|
||||
@@ -48,26 +50,29 @@ func (r *campaignAudienceRepository) GetCampaignAudience(ctx context.Context, or
|
||||
var a CampaignAudience
|
||||
// Same role and free-mail vocabularies the advisor uses, so a customer is
|
||||
// not told two different numbers for the same list.
|
||||
// sendable is the same predicate Deliverable counts, reused so every
|
||||
// verification count shares one denominator.
|
||||
const sendable = `ct.subscribed IS NOT FALSE AND NOT EXISTS (
|
||||
SELECT 1 FROM suppressed_recipients sr
|
||||
WHERE sr.organization_id = $1 AND lower(sr.email) = lower(ct.email)
|
||||
AND (sr.expires_at IS NULL OR sr.expires_at > NOW())
|
||||
)`
|
||||
err := r.DB.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE ct.verification_status = 'invalid'),
|
||||
COUNT(*) FILTER (WHERE ct.verification_status = 'risky'),
|
||||
COUNT(*) FILTER (WHERE `+sendable+` AND ct.verification_status = 'invalid'),
|
||||
COUNT(*) FILTER (WHERE `+sendable+` AND ct.verification_status = 'risky'),
|
||||
-- 'unknown' is the column default, so this is every address nobody
|
||||
-- has checked. NOT NULL, so no null branch is needed.
|
||||
COUNT(*) FILTER (WHERE ct.verification_status NOT IN ('valid','invalid','risky')),
|
||||
COUNT(*) FILTER (WHERE ct.is_catch_all),
|
||||
COUNT(*) FILTER (WHERE `+sendable+` AND ct.verification_status NOT IN ('valid','invalid','risky')),
|
||||
COUNT(*) FILTER (WHERE `+sendable+` AND ct.is_catch_all),
|
||||
COUNT(*) FILTER (WHERE EXISTS (
|
||||
SELECT 1 FROM suppressed_recipients sr
|
||||
WHERE sr.organization_id = $1 AND lower(sr.email) = lower(ct.email)
|
||||
AND (sr.expires_at IS NULL OR sr.expires_at > NOW())
|
||||
)),
|
||||
COUNT(*) FILTER (WHERE ct.subscribed IS FALSE),
|
||||
COUNT(*) FILTER (WHERE ct.subscribed IS NOT FALSE AND NOT EXISTS (
|
||||
SELECT 1 FROM suppressed_recipients sr
|
||||
WHERE sr.organization_id = $1 AND lower(sr.email) = lower(ct.email)
|
||||
AND (sr.expires_at IS NULL OR sr.expires_at > NOW())
|
||||
)),
|
||||
COUNT(*) FILTER (WHERE `+sendable+`),
|
||||
COUNT(*) FILTER (WHERE split_part(lower(ct.email), '@', 1) IN (`+rolePrefixesSQL+`)),
|
||||
COUNT(*) FILTER (WHERE split_part(lower(ct.email), '@', 2) IN (`+freeMailDomainsSQL+`))
|
||||
FROM campaign_leads cl
|
||||
|
||||
@@ -753,6 +753,19 @@ export function ResultStep({ result, filename }: { result: ImportResult; filenam
|
||||
<StatCard label="Failed" value={result.failed} accent={result.failed > 0 ? "red" : "slate"} />
|
||||
</div>
|
||||
|
||||
{result.quality?.flagged && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 flex items-start gap-2">
|
||||
<AlertTriangleIcon className="w-3.5 h-3.5 mt-px shrink-0 text-amber-600" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12.5px] font-medium text-amber-900">This list looks low quality</p>
|
||||
<p className="text-[11.5px] text-amber-800/90 leading-relaxed mt-0.5">
|
||||
{result.quality.summary} They are imported, but sending to them risks the reputation of
|
||||
every mailbox in this workspace. Clean the list before launching a campaign with it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="rounded-md border border-slate-200 overflow-hidden">
|
||||
<div className="px-3 h-9 border-b border-slate-200 bg-slate-50/60 flex items-center gap-2">
|
||||
|
||||
@@ -63,6 +63,19 @@ export interface ImportResult {
|
||||
// Set when more rows failed than the API reports back; `errors` then holds
|
||||
// the first slice of them and `failed` is the true count.
|
||||
errors_truncated?: boolean;
|
||||
// What the uploaded addresses look like. Advisory: a bad list is reported
|
||||
// here and stopped at launch, never refused here.
|
||||
quality?: ImportQuality;
|
||||
}
|
||||
|
||||
export interface ImportQuality {
|
||||
malformed: number;
|
||||
disposable: number;
|
||||
/** Shared inboxes. Reported, not counted against the list. */
|
||||
role: number;
|
||||
bad_share_pct: number;
|
||||
flagged: boolean;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
async function authHeader(): Promise<Record<string, string>> {
|
||||
|
||||
Reference in New Issue
Block a user