Files
warmbly/internal/pkg/emailverify/score.go
T

288 lines
8.3 KiB
Go

package emailverify
import (
"fmt"
"math"
"sort"
"strings"
"time"
)
// Evidence kinds. Positive kinds prove the mailbox is live; the recipient
// bounce proves it is not. Nothing here describes interest: a contact who
// never opens or replies has told us nothing about their address.
const (
EvidenceDelivered = "delivered"
EvidenceOpened = "opened"
EvidenceClicked = "clicked"
EvidenceReplied = "replied"
EvidenceAutoReplied = "auto_replied"
EvidenceBouncedRecipient = "bounced_recipient"
EvidenceBouncedOther = "bounced_other"
)
// Evidence is one observed fact about a mailbox.
type Evidence struct {
Kind string
Detail string
ObservedAt time.Time
}
// Verdict is the last probe, provider, import or manual verdict on the
// contact row.
type Verdict struct {
Status Status
// Source is the contacts.verification_source value.
Source string
CheckedAt time.Time
}
// Scored is the derived state of an address.
type Scored struct {
Status Status
// Confidence is how sure the platform is of Status, 0 to 100.
Confidence int
// Reasons are the sentences a member reads, strongest first.
Reasons []string
// Decisive reports whether real mail (not a check) decided the status.
Decisive bool
// LastPositiveAt is the most recent positive observation.
LastPositiveAt time.Time
}
// Evidence weights. Weights decay with a half-life so last week's reply
// outranks last year's, but nothing ever reaches zero on its own.
var evidenceWeights = map[string]struct {
weight float64
halfLife time.Duration
positive bool
// cap bounds how many observations of this kind count.
cap int
}{
EvidenceReplied: {45, 365 * 24 * time.Hour, true, 3},
EvidenceAutoReplied: {30, 180 * 24 * time.Hour, true, 2},
EvidenceClicked: {35, 270 * 24 * time.Hour, true, 3},
EvidenceOpened: {25, 180 * 24 * time.Hour, true, 3},
EvidenceDelivered: {14, 180 * 24 * time.Hour, true, 4},
EvidenceBouncedRecipient: {70, 365 * 24 * time.Hour, false, 2},
}
// verdictBase is how much a check is trusted on its own, by source.
func verdictBase(v Verdict) (int, string) {
switch v.Source {
case "manual":
return 95, "marked by a teammate"
case "provider":
switch v.Status {
case StatusValid, StatusInvalid:
return 88, "checked by the verification service"
case StatusRisky:
return 55, "flagged by the verification service"
}
return 30, "the verification service could not decide"
case "imported":
switch v.Status {
case StatusValid, StatusInvalid:
return 80, "verified before it was imported"
case StatusRisky:
return 50, "flagged before it was imported"
}
return 25, "imported without a decisive result"
case "probe":
switch v.Status {
case StatusValid:
return 75, "the mail server accepted the address"
case StatusInvalid:
return 80, "the mail server rejected the address"
case StatusRisky:
return 45, "the domain accepts any address, so the check proves nothing"
}
return 20, "the check was inconclusive"
}
return 0, "never checked"
}
// PositiveDecisiveScore is the evidence score above which real mail decides
// the address is deliverable regardless of what a check said.
const PositiveDecisiveScore = 20.0
// Score derives an address's status and confidence from its last verdict and
// the evidence ledger. Rules, in order:
//
// 1. A manual verdict wins outright.
// 2. A bounce naming the recipient, newer than every positive observation,
// makes the address undeliverable.
// 3. Enough positive evidence (a reply, a click, a human open, or repeated
// clean deliveries) makes it deliverable, whatever a probe said.
// 4. Otherwise the verdict stands, with the evidence nudging confidence.
//
// Absence of engagement never appears here: nothing in the ledger says "did
// not open", and Score has no input for it.
func Score(v Verdict, evidence []Evidence, now time.Time) Scored {
base, baseReason := verdictBase(v)
out := Scored{Status: v.Status, Confidence: base}
if out.Status == "" {
out.Status = StatusUnknown
}
sort.Slice(evidence, func(i, j int) bool { return evidence[i].ObservedAt.After(evidence[j].ObservedAt) })
counts := map[string]int{}
var positive, negative float64
var lastPositive, lastNegative time.Time
var reasons []string
for _, e := range evidence {
w, ok := evidenceWeights[e.Kind]
if !ok {
continue
}
if counts[e.Kind] >= w.cap {
continue
}
counts[e.Kind]++
age := now.Sub(e.ObservedAt)
if age < 0 {
age = 0
}
value := w.weight * math.Pow(0.5, age.Hours()/w.halfLife.Hours())
if w.positive {
positive += value
if e.ObservedAt.After(lastPositive) {
lastPositive = e.ObservedAt
}
} else {
negative += value
if e.ObservedAt.After(lastNegative) {
lastNegative = e.ObservedAt
}
}
if counts[e.Kind] == 1 {
reasons = append(reasons, evidenceReason(e, counts, evidence, now))
}
}
out.LastPositiveAt = lastPositive
switch {
case v.Source == "manual":
out.Reasons = append([]string{baseReason}, reasons...)
out.Confidence = clamp(base + int(positive/4))
return out
case !lastNegative.IsZero() && lastNegative.After(lastPositive):
out.Status = StatusInvalid
out.Decisive = true
out.Confidence = clamp(60 + int(negative/2))
out.Reasons = prepend(reasons, EvidenceBouncedRecipient)
return out
case positive >= PositiveDecisiveScore:
out.Status = StatusValid
out.Decisive = true
out.Confidence = clamp(70 + int(positive/2))
out.Reasons = reasons
if v.Status == StatusRisky || v.Status == StatusInvalid {
out.Reasons = append(out.Reasons, "real mail outranks the earlier check ("+baseReason+")")
}
return out
}
// The verdict stands; a little evidence either way moves confidence.
switch out.Status {
case StatusValid:
out.Confidence = clamp(base + int(positive/2) - int(negative))
case StatusInvalid:
out.Confidence = clamp(base + int(negative/2) - int(positive))
default:
out.Confidence = clamp(base + int(positive/2))
}
out.Reasons = append([]string{baseReason}, reasons...)
return out
}
func prepend(reasons []string, kind string) []string {
out := make([]string, 0, len(reasons))
for _, r := range reasons {
if strings.HasPrefix(r, "bounced") {
out = append([]string{r}, out...)
continue
}
out = append(out, r)
}
return out
}
func evidenceReason(e Evidence, counts map[string]int, all []Evidence, now time.Time) string {
n := 0
for _, x := range all {
if x.Kind == e.Kind {
n++
}
}
when := humanAge(now.Sub(e.ObservedAt))
switch e.Kind {
case EvidenceReplied:
return "replied " + when
case EvidenceAutoReplied:
return "sent an automatic reply " + when + " (the mailbox is live)"
case EvidenceClicked:
return "clicked a link " + when
case EvidenceOpened:
return "opened an email " + when
case EvidenceDelivered:
if n > 1 {
return fmt.Sprintf("delivered %d times without a bounce, last %s", n, when)
}
return "delivered without a bounce " + when
case EvidenceBouncedRecipient:
d := strings.TrimSpace(e.Detail)
if d != "" {
return "bounced " + when + ": " + d
}
return "bounced " + when + ": the server said the mailbox does not exist"
}
return e.Kind + " " + when
}
func humanAge(d time.Duration) string {
days := int(d.Hours() / 24)
switch {
case days <= 0:
return "today"
case days == 1:
return "yesterday"
case days < 30:
return fmt.Sprintf("%d days ago", days)
case days < 365:
return fmt.Sprintf("%d months ago", days/30)
}
return fmt.Sprintf("%d years ago", days/365)
}
func clamp(n int) int {
if n < 0 {
return 0
}
if n > 100 {
return 100
}
return n
}
// NamesRecipient reports whether a bounce or rejection text says the
// RECIPIENT does not exist, as opposed to a full mailbox, a policy block, a
// reputation rejection or a greeting the server disliked. Only the former is
// evidence against the address.
func NamesRecipient(text string) bool {
lower := strings.ToLower(text)
if containsAny(lower, recipientMarkers) {
return true
}
if containsAny(lower, probeMarkers) {
return false
}
// Enhanced status anywhere in the text: 5.1.1 / 5.1.2 / 5.1.3 / 5.1.6 /
// 5.1.10 address errors and 5.2.1 disabled mailbox.
for _, code := range []string{"5.1.1", "5.1.2", "5.1.3", "5.1.6", "5.1.10", "5.2.1"} {
if strings.Contains(lower, code+" ") || strings.HasSuffix(lower, code) || strings.Contains(lower, code+":") {
return true
}
}
return false
}