mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-08 00:02:09 +00:00
288 lines
8.3 KiB
Go
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
|
|
}
|