"Which proxy does this support?" has a narrower answer than the docs
gave. `golang.org/x/net/proxy.FromURL` resolves `socks5` and `socks5h`
and nothing else, yet the README and `Proxy`'s doc comment both offered
"socks5, socks4 and socks4a". A `socks4://` URI fails every dial with
`proxy: unknown scheme: socks4`, which is what taking the docs at their
word looks like from the caller's side.
Corrected both, and pointed at `proxy.RegisterDialerType` -- the way
x/net/proxy expects a caller to add a scheme it does not carry.
That escape hatch had a second problem waiting. `establishProxyConnection`
asserted the dialer to `proxy.ContextDialer` unconditionally, but a
registered dialer need only implement `Dial`, and the assertion panicked
on those:
interface conversion: T is not proxy.ContextDialer: missing method
DialContext
It now falls back to dialing on a goroutine and abandoning the attempt
when the context expires, which is what x/net/proxy itself does for the
same case.
Closes: #95
304 lines
11 KiB
Go
304 lines
11 KiB
Go
package emailverifier
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"time"
|
|
)
|
|
|
|
// Verifier is an email verifier. Create one by calling NewVerifier
|
|
type Verifier struct {
|
|
smtpCheckEnabled bool // SMTP check enabled or disabled (disabled by default)
|
|
catchAllCheckEnabled bool // SMTP catchAll check enabled or disabled (enabled by default)
|
|
domainSuggestEnabled bool // whether suggest a most similar correct domain or not (disabled by default)
|
|
gravatarCheckEnabled bool // gravatar check enabled or disabled (disabled by default)
|
|
fromEmail string // name to use in the `EHLO:` SMTP command, defaults to "user@example.org"
|
|
helloName string // email to use in the `MAIL FROM:` SMTP command. defaults to `localhost`
|
|
schedule *schedule // schedule represents a job schedule
|
|
proxyURI string // use a SOCKS5 proxy to verify the email,
|
|
apiVerifiers map[string]smtpAPIVerifier // per-vendor API verifiers; no built-in vendors currently, contributions are welcomed.
|
|
resolver *net.Resolver // resolver used to perform MX and SMTP DNS lookups; nil means net.DefaultResolver, read at call time
|
|
|
|
// Timeouts
|
|
connectTimeout time.Duration // Timeout for establishing connections
|
|
operationTimeout time.Duration // Timeout for SMTP operations (e.g., EHLO, MAIL FROM, etc.)
|
|
}
|
|
|
|
// Result is the result of Email Verification
|
|
type Result struct {
|
|
Email string `json:"email"` // passed email address
|
|
Reachable string `json:"reachable"` // an enumeration to describe whether the recipient address is real
|
|
Syntax Syntax `json:"syntax"` // details about the email address syntax
|
|
SMTP *SMTP `json:"smtp"` // details about the SMTP response of the email
|
|
Gravatar *Gravatar `json:"gravatar"` // whether or not have gravatar for the email
|
|
Suggestion string `json:"suggestion"` // domain suggestion when domain is misspelled
|
|
Disposable bool `json:"disposable"` // is this a DEA (disposable email address)
|
|
RoleAccount bool `json:"role_account"` // is account a role-based account
|
|
Free bool `json:"free"` // is domain a free email domain
|
|
HasMxRecords bool `json:"has_mx_records"` // whether or not MX-Records for the domain
|
|
}
|
|
|
|
// init loads disposable_domain meta data to disposableSyncDomains which are safe for concurrent use
|
|
func init() {
|
|
for d := range disposableDomains {
|
|
disposableSyncDomains.Store(d, struct{}{})
|
|
}
|
|
}
|
|
|
|
// NewVerifier creates a new email verifier
|
|
func NewVerifier() *Verifier {
|
|
return &Verifier{
|
|
fromEmail: defaultFromEmail,
|
|
helloName: defaultHelloName,
|
|
catchAllCheckEnabled: true,
|
|
apiVerifiers: map[string]smtpAPIVerifier{},
|
|
connectTimeout: 10 * time.Second,
|
|
operationTimeout: 10 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Verify performs address, misc, mx and smtp checks
|
|
func (v *Verifier) Verify(email string) (*Result, error) {
|
|
|
|
ret := Result{
|
|
Email: email,
|
|
Reachable: reachableUnknown,
|
|
}
|
|
|
|
syntax := v.ParseAddress(email)
|
|
ret.Syntax = syntax
|
|
if !syntax.Valid {
|
|
return &ret, nil
|
|
}
|
|
|
|
ret.Free = v.IsFreeDomain(syntax.Domain)
|
|
ret.RoleAccount = v.IsRoleAccount(syntax.Username)
|
|
ret.Disposable = v.IsDisposable(syntax.Domain)
|
|
if v.domainSuggestEnabled {
|
|
ret.Suggestion = v.SuggestDomain(syntax.Domain)
|
|
}
|
|
|
|
// If the domain name is disposable, mx and smtp are not checked.
|
|
if ret.Disposable {
|
|
return &ret, nil
|
|
}
|
|
|
|
mx, err := v.CheckMX(syntax.Domain)
|
|
if err != nil {
|
|
errStr := err.Error()
|
|
if insContains(errStr, "no such host") {
|
|
ret.Reachable = reachableNo
|
|
return &ret, newLookupError(ErrNoSuchHost, errStr).withCause(err)
|
|
}
|
|
return &ret, err
|
|
}
|
|
ret.HasMxRecords = mx.HasMXRecord
|
|
|
|
smtp, catchAll, err := v.checkSMTP(syntax.Domain, syntax.Username)
|
|
|
|
// Kept whether or not the check succeeded. A failed exchange still says how
|
|
// far it got, which is the difference between a host that never answered and
|
|
// one that answered and refused our sender.
|
|
ret.SMTP = smtp
|
|
|
|
if err != nil {
|
|
return &ret, err
|
|
}
|
|
ret.Reachable = v.calculateReachable(smtp, catchAll)
|
|
|
|
if v.gravatarCheckEnabled {
|
|
gravatar, err := v.CheckGravatar(email)
|
|
if err != nil {
|
|
return &ret, err
|
|
}
|
|
ret.Gravatar = gravatar
|
|
}
|
|
|
|
return &ret, nil
|
|
}
|
|
|
|
// AddDisposableDomains adds additional domains as disposable domains.
|
|
func (v *Verifier) AddDisposableDomains(domains []string) *Verifier {
|
|
for _, d := range domains {
|
|
additionalDisposableDomains.Store(d, struct{}{})
|
|
disposableSyncDomains.Store(d, struct{}{})
|
|
}
|
|
return v
|
|
}
|
|
|
|
// EnableGravatarCheck enables check gravatar,
|
|
// we don't check gravatar by default
|
|
func (v *Verifier) EnableGravatarCheck() *Verifier {
|
|
v.gravatarCheckEnabled = true
|
|
return v
|
|
}
|
|
|
|
// DisableGravatarCheck disables check gravatar,
|
|
func (v *Verifier) DisableGravatarCheck() *Verifier {
|
|
v.gravatarCheckEnabled = false
|
|
return v
|
|
}
|
|
|
|
// EnableSMTPCheck enables check email by smtp,
|
|
// for most ISPs block outgoing SMTP requests through port 25, to prevent spam,
|
|
// we don't check smtp by default
|
|
func (v *Verifier) EnableSMTPCheck() *Verifier {
|
|
v.smtpCheckEnabled = true
|
|
return v
|
|
}
|
|
|
|
// EnableAPIVerifier activates an API-based existence check for the given vendor.
|
|
// ** Please know ** that this is a tricky way (but relatively stable) to check if target vendor's email exists.
|
|
// If you use this feature in a production environment, please ensure that you have sufficient backup measures in place, as this may encounter rate limiting or other API issues.
|
|
//
|
|
// There are currently no built-in vendors: the Yahoo verifier was removed once its
|
|
// endpoint stopped existing (see https://github.com/AfterShip/email-verifier/issues/195),
|
|
// following the same fate as the Gmail verifier removed in #113. This remains an
|
|
// extension point; contributions adding a working vendor are welcome.
|
|
func (v *Verifier) EnableAPIVerifier(name string) error {
|
|
return fmt.Errorf("unsupported to enable the API verifier for vendor: %s", name)
|
|
}
|
|
|
|
func (v *Verifier) DisableAPIVerifier(name string) {
|
|
delete(v.apiVerifiers, name)
|
|
}
|
|
|
|
// DisableSMTPCheck disables check email by smtp
|
|
func (v *Verifier) DisableSMTPCheck() *Verifier {
|
|
v.smtpCheckEnabled = false
|
|
return v
|
|
}
|
|
|
|
// EnableCatchAllCheck enables the catchAll check by smtp. It is on by default;
|
|
// this undoes a previous DisableCatchAllCheck.
|
|
func (v *Verifier) EnableCatchAllCheck() *Verifier {
|
|
v.catchAllCheckEnabled = true
|
|
return v
|
|
}
|
|
|
|
// DisableCatchAllCheck disables catchAll check by smtp
|
|
func (v *Verifier) DisableCatchAllCheck() *Verifier {
|
|
v.catchAllCheckEnabled = false
|
|
return v
|
|
}
|
|
|
|
// EnableDomainSuggest will suggest a most similar correct domain when domain misspelled
|
|
func (v *Verifier) EnableDomainSuggest() *Verifier {
|
|
v.domainSuggestEnabled = true
|
|
return v
|
|
}
|
|
|
|
// DisableDomainSuggest will not suggest anything
|
|
func (v *Verifier) DisableDomainSuggest() *Verifier {
|
|
v.domainSuggestEnabled = false
|
|
return v
|
|
}
|
|
|
|
// EnableAutoUpdateDisposable enables update disposable domains automatically
|
|
func (v *Verifier) EnableAutoUpdateDisposable() *Verifier {
|
|
v.stopCurrentSchedule()
|
|
// fetch latest disposable domains before next schedule
|
|
_ = updateDisposableDomains(disposableDataURL)
|
|
// update disposable domains records daily
|
|
v.schedule = newSchedule(24*time.Hour, updateDisposableDomains, disposableDataURL)
|
|
v.schedule.start()
|
|
return v
|
|
}
|
|
|
|
// DisableAutoUpdateDisposable stops previously started schedule job
|
|
func (v *Verifier) DisableAutoUpdateDisposable() *Verifier {
|
|
v.stopCurrentSchedule()
|
|
return v
|
|
|
|
}
|
|
|
|
// FromEmail sets the emails to use in the `MAIL FROM:` smtp command
|
|
func (v *Verifier) FromEmail(email string) *Verifier {
|
|
v.fromEmail = email
|
|
return v
|
|
}
|
|
|
|
// HelloName sets the name to use in the `EHLO:` SMTP command
|
|
func (v *Verifier) HelloName(domain string) *Verifier {
|
|
v.helloName = domain
|
|
return v
|
|
}
|
|
|
|
// Proxy sets a SOCKS5 proxy to verify the email,
|
|
// proxyURI should be in the format: "socks5://user:password@127.0.0.1:1080".
|
|
//
|
|
// The scheme must be socks5 or socks5h -- the only two golang.org/x/net/proxy
|
|
// resolves. Anything else, socks4 and socks4a included, fails the dial with
|
|
// "proxy: unknown scheme"; add it with proxy.RegisterDialerType.
|
|
//
|
|
// Only the scheme, credentials and host are read; a query string is ignored, so
|
|
// a "?timeout=5s" has no effect. Use ConnectTimeout and OperationTimeout.
|
|
//
|
|
// The proxy carries the connection to the mail server, not DNS: MX lookups
|
|
// still go out from the local machine. See Resolver for routing those through
|
|
// the proxy too.
|
|
func (v *Verifier) Proxy(proxyURI string) *Verifier {
|
|
v.proxyURI = proxyURI
|
|
return v
|
|
}
|
|
|
|
// Resolver sets a custom DNS resolver to use for MX and SMTP host lookups,
|
|
// allowing users to configure custom DNS servers instead of relying on the
|
|
// system's default resolver (e.g. /etc/resolv.conf). Passing nil restores
|
|
// the default resolver (net.DefaultResolver).
|
|
func (v *Verifier) Resolver(resolver *net.Resolver) *Verifier {
|
|
v.resolver = resolver
|
|
return v
|
|
}
|
|
|
|
// dnsResolver returns the resolver to use for lookups. net.DefaultResolver is
|
|
// read here rather than captured in NewVerifier so that callers who replace the
|
|
// global keep the behaviour they had before this option existed -- the previous
|
|
// code called net.LookupMX, which reads net.DefaultResolver on every call.
|
|
func (v *Verifier) dnsResolver() *net.Resolver {
|
|
if v.resolver == nil {
|
|
return net.DefaultResolver
|
|
}
|
|
return v.resolver
|
|
}
|
|
|
|
// ConnectTimeout sets the timeout for establishing connections.
|
|
func (v *Verifier) ConnectTimeout(timeout time.Duration) *Verifier {
|
|
v.connectTimeout = timeout
|
|
return v
|
|
}
|
|
|
|
// OperationTimeout sets the timeout for SMTP operations (e.g., EHLO, MAIL FROM, etc.).
|
|
func (v *Verifier) OperationTimeout(timeout time.Duration) *Verifier {
|
|
v.operationTimeout = timeout
|
|
return v
|
|
}
|
|
|
|
// calculateReachable turns the SMTP observations into a verdict. Callers reach
|
|
// it only from Verify, which always has a username: a syntactically valid
|
|
// address cannot have an empty local part, so the address itself was always
|
|
// probed unless the catch-all probe returned first.
|
|
func (v *Verifier) calculateReachable(s *SMTP, catchAll catchAllOutcome) string {
|
|
if !v.smtpCheckEnabled {
|
|
return reachableUnknown
|
|
}
|
|
if s.Deliverable {
|
|
return reachableYes
|
|
}
|
|
// Deliberately not s.CatchAll, which is also true when the probe never ran
|
|
// or could not conclude. Only a probe that established a catch-all domain,
|
|
// or one that returned before the address was probed, leaves the address
|
|
// unverifiable; otherwise the address was refused and that is a definite no.
|
|
if catchAll == catchAllAccepted || catchAll == catchAllInconclusive {
|
|
return reachableUnknown
|
|
}
|
|
return reachableNo
|
|
}
|
|
|
|
// stopCurrentSchedule stops current running schedule (if exists)
|
|
func (v *Verifier) stopCurrentSchedule() {
|
|
if v.schedule != nil {
|
|
v.schedule.stop()
|
|
}
|
|
}
|