mirror of
https://github.com/hanzoai/email-verifier.git
synced 2026-10-30 15:04:29 +00:00
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]>
390 lines
9.4 KiB
Go
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))
|
|
}
|