Files
warmbly/internal/app/listgate/listgate.go
T
Matthew Meszaros 8ef6ee5545 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
2026-08-28 11:50:48 -07:00

103 lines
4.1 KiB
Go

// Package listgate projects a campaign's bounce rate before it sends anything.
// Per-recipient verification only drops a bad address at send time, so a
// scraped list otherwise reveals itself through the damage it does.
package listgate
import (
"fmt"
"github.com/warmbly/warmbly/internal/repository"
)
// Thresholds, in percent of the deliverable audience.
const (
// BlockBouncePct sits below the 5% at which providers put a sender under
// review, so the platform acts first.
BlockBouncePct = 4.0
// WarnBouncePct is where a launch is allowed but flagged.
WarnBouncePct = 2.0
// MinAudience is the size below which a share means nothing. Two bad
// addresses out of five is not a 40% bounce rate.
MinAudience = 50
// UnverifiedAdvisePct is the share of never-checked addresses above which
// the customer is advised to verify. Advice only: see below.
UnverifiedAdvisePct = 50.0
)
// Verdict is a projection over one campaign's audience.
type Verdict struct {
// Deliverable excludes suppressed and unsubscribed leads: they are skipped
// at send time, so counting them would understate every share.
Deliverable int
// ProjectedBouncePct is the estimated hard-bounce rate at launch.
ProjectedBouncePct float64
// Block is true when the launch should be refused.
Block bool
// Warn is true when it should be flagged but allowed. Set for an
// unverified list too, so advice about it is actually shown: a preflight
// report only surfaces checks that did not pass.
Warn bool
// UnverifiedPct is the share of the audience nobody has checked. Reported,
// never blocked on: see Project.
UnverifiedPct float64
// Summary is the sentence the customer reads.
Summary string
// Remediation is what to do about it.
Remediation string
}
// 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: a contact can be both suppressed and unsubscribed, so
// subtracting both counts would remove it twice.
deliverable := a.Deliverable
if deliverable < 0 {
deliverable = 0
}
v := Verdict{Deliverable: deliverable}
if deliverable == 0 {
v.Summary = "No deliverable recipients: every lead is suppressed or unsubscribed."
v.Remediation = "Add recipients, or remove the suppressed ones from this campaign."
return v
}
// The projection counts KNOWN-invalid addresses only.
//
// Assuming a fraction of unverified addresses will bounce is tempting and
// wrong: most customers never run verification, so their lists are entirely
// unverified, and any non-zero weight would refuse essentially every launch.
// A list nobody has checked is not evidence of a bad list. It is reported
// as unverified and the customer is advised to check it.
v.ProjectedBouncePct = float64(a.Invalid) / float64(deliverable) * 100
v.UnverifiedPct = float64(a.Unknown) / float64(deliverable) * 100
if deliverable < MinAudience {
v.Summary = fmt.Sprintf("Audience of %d is too small to judge; sending anyway.", deliverable)
return v
}
switch {
case v.ProjectedBouncePct >= BlockBouncePct:
v.Block = true
v.Summary = fmt.Sprintf(
"About %.1f%% of this list is likely to hard bounce (%d known-invalid of %d deliverable).",
v.ProjectedBouncePct, a.Invalid, deliverable)
v.Remediation = "Verify or clean the list before launching. Mailbox providers put a sender under review at 5%."
case v.ProjectedBouncePct >= WarnBouncePct:
v.Warn = true
v.Summary = fmt.Sprintf(
"About %.1f%% of this list is likely to hard bounce (%d known-invalid of %d deliverable).",
v.ProjectedBouncePct, a.Invalid, deliverable)
v.Remediation = "Consider verifying the list before sending at volume."
case v.UnverifiedPct >= UnverifiedAdvisePct:
v.Warn = true
v.Summary = fmt.Sprintf("%.0f%% of this list has never been verified, so its bounce rate is unknown.", v.UnverifiedPct)
v.Remediation = "Verify the list to see its real bounce risk before sending at volume."
default:
v.Summary = fmt.Sprintf("Projected hard bounce is about %.1f%%.", v.ProjectedBouncePct)
}
return v
}