diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 99fabd0b..632b9a70 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -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 diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index 178500ef..49936944 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -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. diff --git a/docs/content/docs/guides/contacts-crm.mdx b/docs/content/docs/guides/contacts-crm.mdx index 3a389570..9c5f2f8d 100644 --- a/docs/content/docs/guides/contacts-crm.mdx +++ b/docs/content/docs/guides/contacts-crm.mdx @@ -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. diff --git a/internal/app/contact/import.go b/internal/app/contact/import.go index 45401d12..54a42f00 100644 --- a/internal/app/contact/import.go +++ b/internal/app/contact/import.go @@ -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 +} diff --git a/internal/app/contact/import_live_test.go b/internal/app/contact/import_live_test.go index e421bb50..b8ee01f5 100644 --- a/internal/app/contact/import_live_test.go +++ b/internal/app/contact/import_live_test.go @@ -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) + } +} diff --git a/internal/app/contact/service.go b/internal/app/contact/service.go index 9584732f..0d604885 100644 --- a/internal/app/contact/service.go +++ b/internal/app/contact/service.go @@ -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( diff --git a/internal/app/listgate/listgate.go b/internal/app/listgate/listgate.go index df772447..46ee654f 100644 --- a/internal/app/listgate/listgate.go +++ b/internal/app/listgate/listgate.go @@ -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 diff --git a/internal/models/contact_import.go b/internal/models/contact_import.go index b62aa442..c3acee69 100644 --- a/internal/models/contact_import.go +++ b/internal/models/contact_import.go @@ -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"` } diff --git a/internal/pkg/listquality/listquality.go b/internal/pkg/listquality/listquality.go new file mode 100644 index 00000000..45e615ce --- /dev/null +++ b/internal/pkg/listquality/listquality.go @@ -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 +} diff --git a/internal/pkg/listquality/listquality_test.go b/internal/pkg/listquality/listquality_test.go new file mode 100644 index 00000000..8d45d7b6 --- /dev/null +++ b/internal/pkg/listquality/listquality_test.go @@ -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) + } +} diff --git a/internal/pkg/signuprisk/signuprisk.go b/internal/pkg/signuprisk/signuprisk.go index 8cbcc2fc..a15ef4b3 100644 --- a/internal/pkg/signuprisk/signuprisk.go +++ b/internal/pkg/signuprisk/signuprisk.go @@ -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{}} diff --git a/internal/repository/campaign_audience_live_test.go b/internal/repository/campaign_audience_live_test.go index 455ee552..9aa18c75 100644 --- a/internal/repository/campaign_audience_live_test.go +++ b/internal/repository/campaign_audience_live_test.go @@ -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) + } +} diff --git a/internal/repository/pg_campaign_audience.go b/internal/repository/pg_campaign_audience.go index 25d4ad3d..9ba3b1af 100644 --- a/internal/repository/pg_campaign_audience.go +++ b/internal/repository/pg_campaign_audience.go @@ -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 diff --git a/web/src/components/app/contacts/ImportWizard.tsx b/web/src/components/app/contacts/ImportWizard.tsx index 1d9c1d31..a2061680 100644 --- a/web/src/components/app/contacts/ImportWizard.tsx +++ b/web/src/components/app/contacts/ImportWizard.tsx @@ -753,6 +753,19 @@ export function ResultStep({ result, filename }: { result: ImportResult; filenam 0 ? "red" : "slate"} /> + {result.quality?.flagged && ( +
+ +
+

This list looks low quality

+

+ {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. +

+
+
+ )} + {result.errors && result.errors.length > 0 && (
diff --git a/web/src/lib/api/client/app/contacts/importContacts.ts b/web/src/lib/api/client/app/contacts/importContacts.ts index 22790a33..bb9d67d5 100644 --- a/web/src/lib/api/client/app/contacts/importContacts.ts +++ b/web/src/lib/api/client/app/contacts/importContacts.ts @@ -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> {