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:
Matthew Meszaros
2026-08-28 11:50:48 -07:00
committed by GitHub
parent 4b5686111e
commit 8ef6ee5545
15 changed files with 446 additions and 14 deletions
+5
View File
@@ -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
+10 -1
View File
@@ -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.
+10
View File
@@ -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.
+66
View File
@@ -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
}
+82
View File
@@ -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)
}
}
+12
View File
@@ -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(
+2 -2
View File
@@ -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
+17
View File
@@ -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"`
}
+90
View File
@@ -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)
}
}
+7
View File
@@ -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)
}
}
+16 -11
View File
@@ -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>> {