Files
0c476abbc2 fix: report a refused address as unreachable when the catch-all probe is off (#220)
SMTP.CatchAll carries three different meanings and calculateReachable
treated all three as "we could not tell":

  1. the probe ran and a random address was accepted -- a real catch-all
  2. the probe ran but the reply was about the server, not the address
  3. the probe never ran

Only (1) makes an address unverifiable. smtp.go set the flag before any
probe and only a 550-class refusal ever cleared it, so under
DisableCatchAllCheck() it was always true. The address itself was still
probed and correctly refused, but the verdict never got that far: every
refusal came back as reachable "unknown" instead of "no".

Track what the probe concluded -- accepted / refused / inconclusive /
not-run -- and let that decide, rather than a bool that cannot tell the
three apart. The exported signature does not move: checkSMTP carries the
extra value and CheckSMTP drops it. Not a new field on SMTP, because #202
is the precedent for that being a breaking change: whole-struct
comparison against a literal stops matching.

Of the sixteen combinations of smtpCheckEnabled, Deliverable and the probe
outcome only seven are reachable, the outcome and SMTP.CatchAll being
coupled, and exactly one of them changes answer -- confirmed by reverting
the fix under the new table test, where only "address refused, probe
disabled" fails. Case (2) keeps returning
"unknown", which is correct: the early return means the address was never
probed. Making the exchange continue past an inconclusive probe is #72's
question and is not attempted here.

The API-verifier branch needed its own handling. It returns before our
probe runs, so leaving the outcome at not-run would read a verifier's
CatchAll: true as a refusal of the address and answer "no" where it used
to answer "unknown". catchAllFromResult takes the verifier's bool at face
value instead, which keeps that path's behaviour identical. No caller can
reach it today -- smtpAPIVerifier is unexported and EnableAPIVerifier
registers nothing -- but the doc comment invites vendors, so the trap is
worth closing now rather than leaving for whoever adds one.

Also drops a doc comment on EnableCatchAllCheck that contradicted the
code -- it claimed catchAll is off by default, while NewVerifier sets
catchAllCheckEnabled to true.

Verified: golangci-lint run reports 0, go build and go vet are clean, 74
offline tests pass under -race. The invariant the new doc comment relies
on -- that a syntactically valid address never has an empty local part,
so Verify always probes the address -- was checked against ParseAddress.
The network-dependent tests are left to CI; they open real SMTP
connections.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-11 11:15:39 +08:00

390 lines
9.4 KiB
Go

package emailverifier
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckEmailOK_SMTPHostNotExists(t *testing.T) {
var (
// trueVal = true
username = "email_username"
domain = "domainnotexists.com"
address = username + "@" + domain
email = address
)
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: false,
Disposable: false,
RoleAccount: false,
Reachable: reachableNo,
Free: false,
SMTP: nil,
}
require.ErrorContains(t, err, ErrNoSuchHost)
assert.Equal(t, &expected, ret)
}
func TestCheckEmailOK_SMTPHostExists_NotCatchAll(t *testing.T) {
var (
// trueVal = true
username = "email_username"
domain = "github.com"
address = username + "@" + domain
email = address
)
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: true,
Reachable: reachableUnknown,
Disposable: false,
RoleAccount: false,
Free: false,
SMTP: &SMTP{
HostExists: true,
FullInbox: false,
CatchAll: true,
Deliverable: false,
Disabled: false,
},
}
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestCheckEmailOK_SMTPHostExists_FreeDomain(t *testing.T) {
var (
// trueVal = true
username = "email_username"
domain = "gmail.com"
address = username + "@" + domain
email = address
)
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: true,
Reachable: reachableNo,
Disposable: false,
RoleAccount: false,
Free: true,
SMTP: &SMTP{
HostExists: true,
FullInbox: false,
CatchAll: false,
Deliverable: false,
Disabled: false,
},
}
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestCheckEmail_ErrorSyntax(t *testing.T) {
var (
// trueVal = true
username = ""
domain = "yahoo.com"
address = username + "@" + domain
email = address
)
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: "",
Valid: false,
},
HasMxRecords: false,
Reachable: reachableUnknown,
Disposable: false,
RoleAccount: false,
Free: false,
SMTP: nil,
}
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestCheckEmail_Disposable(t *testing.T) {
var (
// trueVal = true
username = "exampleuser"
// Listed by six of the upstreams behind the disposable list and with
// no DNS records, which is what the rest of this expectation assumes.
// The previous fixture, zzjbfwqi.shop, was only in the list this repo
// used to build from, and passed after that change only because an
// earlier test left it in the package-level set.
domain = "spambox.us"
address = username + "@" + domain
email = address
)
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: false,
Reachable: reachableUnknown,
Disposable: true,
RoleAccount: false,
Free: false,
SMTP: nil,
}
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestCheckEmail_Disposable_override(t *testing.T) {
var (
username = "exampleuser"
domain = "iamdisposableemail.test"
address = username + "@" + domain
email = address
)
verifier := NewVerifier().EnableSMTPCheck().AddDisposableDomains([]string{"iamdisposableemail.test"})
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: false,
Reachable: reachableUnknown,
Disposable: true,
RoleAccount: false,
Free: false,
SMTP: nil,
}
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestCheckEmail_RoleAccount(t *testing.T) {
var (
// trueVal = true
username = "admin"
domain = "github.com"
address = username + "@" + domain
email = address
)
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: true,
Reachable: reachableUnknown,
Disposable: false,
RoleAccount: true,
Free: false,
SMTP: &SMTP{
HostExists: true,
FullInbox: false,
CatchAll: true,
Deliverable: false,
Disabled: false,
},
}
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestCheckEmail_DisabledSMTPCheck(t *testing.T) {
var (
// trueVal = true
username = "email_username"
domain = "randomain.com"
address = username + "@" + domain
email = address
)
verifier.DisableSMTPCheck()
ret, err := verifier.Verify(email)
expected := Result{
Email: email,
Syntax: Syntax{
Username: username,
Domain: domain,
Valid: true,
},
HasMxRecords: true,
Disposable: false,
RoleAccount: false,
Reachable: reachableUnknown,
Free: false,
SMTP: nil,
}
verifier.EnableSMTPCheck()
require.NoError(t, err)
assert.Equal(t, &expected, ret)
}
func TestNewVerifierOK_AutoUpdateDisposable(t *testing.T) {
verifier.EnableAutoUpdateDisposable()
}
func TestNewVerifierOK_EnableAutoUpdateDisposable(t *testing.T) {
verifier.EnableAutoUpdateDisposable()
}
func TestNewVerifierOK_AutoUpdateDisposableDuplicate(t *testing.T) {
verifier.DisableAutoUpdateDisposable()
verifier.EnableAutoUpdateDisposable()
verifier.DisableAutoUpdateDisposable()
verifier.EnableAutoUpdateDisposable()
verifier.DisableAutoUpdateDisposable()
verifier.EnableAutoUpdateDisposable()
}
func TestStopCurrentSchedule_ScheduleIsNil(t *testing.T) {
verifier.schedule = nil
verifier.stopCurrentSchedule()
}
func TestStopCurrentScheduleOK(t *testing.T) {
verifier.EnableAutoUpdateDisposable()
verifier.stopCurrentSchedule()
}
func TestCheckEmail_EnableDomainSuggest(t *testing.T) {
var (
// trueVal = true
username = "email_username"
domain = "hotmail.com"
address = username + "@" + domain
email = address
)
ret, _ := verifier.Verify(email)
assert.Empty(t, ret.Suggestion)
}
func TestCheckEmail_EnableDomainSuggest_Gmail(t *testing.T) {
var (
// trueVal = true
username = "email_username"
domain = "gmai.com"
address = username + "@" + domain
email = address
)
ret, _ := verifier.EnableDomainSuggest().Verify(email)
assert.Equal(t, "gmail.com", ret.Suggestion)
}
func TestNewVerifierOK_DefaultResolver(t *testing.T) {
v := NewVerifier()
assert.Nil(t, v.resolver)
assert.Same(t, net.DefaultResolver, v.dnsResolver())
}
func TestVerifier_Resolver(t *testing.T) {
customResolver := &net.Resolver{PreferGo: true}
v := NewVerifier().Resolver(customResolver)
assert.Same(t, customResolver, v.resolver)
assert.Same(t, customResolver, v.dnsResolver())
}
func TestVerifier_Resolver_NilRestoresDefault(t *testing.T) {
customResolver := &net.Resolver{PreferGo: true}
v := NewVerifier().Resolver(customResolver)
assert.Same(t, customResolver, v.dnsResolver())
v.Resolver(nil)
assert.Same(t, net.DefaultResolver, v.dnsResolver())
}
// net.DefaultResolver must be read at lookup time, not captured when the
// Verifier is built. Callers who replace the global -- the only way to redirect
// lookups before Resolver() existed -- would otherwise be silently ignored,
// since NewVerifier is commonly called during package variable initialisation,
// before any init() that installs the replacement.
func TestVerifier_DefaultResolverReadAtCallTime(t *testing.T) {
original := net.DefaultResolver
t.Cleanup(func() { net.DefaultResolver = original })
v := NewVerifier()
replacement := &net.Resolver{PreferGo: true}
net.DefaultResolver = replacement
assert.Same(t, replacement, v.dnsResolver())
}
// The catch-all probe's outcome, not SMTP.CatchAll, decides whether an address
// is unverifiable. CatchAll is also true when the probe never ran and when it
// could not conclude, which used to turn a definite refusal into "unknown".
func TestCalculateReachable(t *testing.T) {
v := NewVerifier().EnableSMTPCheck()
cases := []struct {
name string
smtp SMTP
catchAll catchAllOutcome
want string
}{
{"address accepted", SMTP{HostExists: true, Deliverable: true}, catchAllRefused, reachableYes},
{"address refused, probe ruled a catch-all out", SMTP{HostExists: true}, catchAllRefused, reachableNo},
{"address refused, probe disabled", SMTP{HostExists: true, CatchAll: true}, catchAllNotRun, reachableNo},
{"address accepted, probe disabled", SMTP{HostExists: true, CatchAll: true, Deliverable: true}, catchAllNotRun, reachableYes},
{"probe established a catch-all domain", SMTP{HostExists: true, CatchAll: true}, catchAllAccepted, reachableUnknown},
{"probe inconclusive, address never reached", SMTP{HostExists: true, CatchAll: true}, catchAllInconclusive, reachableUnknown},
}
for _, c := range cases {
t.Run(c.name, func(tt *testing.T) {
assert.Equal(tt, c.want, v.calculateReachable(&c.smtp, c.catchAll))
})
}
}
func TestCalculateReachable_SMTPCheckDisabled(t *testing.T) {
assert.Equal(t, reachableUnknown, NewVerifier().calculateReachable(nil, catchAllNotRun))
}