package emailverifier import ( "context" "errors" "fmt" "math/rand" "net" "net/smtp" "net/textproto" "net/url" "strconv" "strings" "sync" "time" "golang.org/x/net/proxy" ) // SMTP stores all information for SMTP verification lookup type SMTP struct { HostExists bool `json:"host_exists"` // is the host exists? FullInbox bool `json:"full_inbox"` // is the email account's inbox full? CatchAll bool `json:"catch_all"` // does the domain have a catch-all email address? Deliverable bool `json:"deliverable"` // can send an email to the email server? Disabled bool `json:"disabled"` // is the email blocked or disabled by the provider? } // catchAllOutcome records what the catch-all probe established. SMTP.CatchAll // cannot: it is also true when the probe never ran, and when the server replied // about itself rather than about the address. type catchAllOutcome int const ( // catchAllNotRun means the probe was disabled, or the exchange ended before it. catchAllNotRun catchAllOutcome = iota // catchAllAccepted means a random address was accepted, so the domain takes anything. catchAllAccepted // catchAllRefused means a random address was refused as non-existent, so the // domain does not take just anything. catchAllRefused // catchAllInconclusive means the server replied, but about itself rather than // about the address: a rate limit, a block, a temporary failure. catchAllInconclusive ) // catchAllFromResult reads an API verifier's flat result as a probe outcome. // The verifier reports a catch-all as a plain bool with none of our probing // behind it, so take it at face value: leaving the outcome at catchAllNotRun // would make a false CatchAll read as a refusal of the address itself. func catchAllFromResult(s *SMTP) catchAllOutcome { if s != nil && s.CatchAll { return catchAllAccepted } return catchAllRefused } // CheckSMTP performs an email verification on the passed domain via SMTP // - the domain is the passed email domain // - username is used to check the deliverability of specific email address, // // if server is catch-all server, username will not be checked func (v *Verifier) CheckSMTP(domain, username string) (*SMTP, error) { res, _, err := v.checkSMTP(domain, username) return res, err } // checkSMTP is CheckSMTP plus what the catch-all probe concluded. Verify needs // that to tell a domain which accepts every address from one we could not ask. func (v *Verifier) checkSMTP(domain, username string) (*SMTP, catchAllOutcome, error) { if !v.smtpCheckEnabled { return nil, catchAllNotRun, nil } var ret SMTP var err error catchAll := catchAllNotRun email := fmt.Sprintf("%s@%s", username, domain) // Dial any SMTP server that will accept a connection client, mx, err := newSMTPClient(domain, v.proxyURI, v.dnsResolver(), v.connectTimeout, v.operationTimeout) if err != nil { return &ret, catchAll, ParseSMTPError(err) } // Defer quit the SMTP connection defer client.Close() // We opened a connection and read the server's greeting, so the host is // there whatever happens from here on. Recorded before EHLO and MAIL FROM // so that a rejection at either -- which is about our sender, not about the // host -- does not report the host as missing. ret.HostExists = true // Check by api when enabled and host recognized. for _, apiVerifier := range v.apiVerifiers { if apiVerifier.isSupported(strings.ToLower(mx.Host)) { res, err := apiVerifier.check(domain, username) return res, catchAllFromResult(res), err } } // Sets the HELO/EHLO hostname if err = client.Hello(v.helloName); err != nil { return &ret, catchAll, ParseSMTPError(err) } // Sets the from email if err = client.Mail(v.fromEmail); err != nil { return &ret, catchAll, ParseSMTPError(err) } // Default sets catch-all to true ret.CatchAll = true if v.catchAllCheckEnabled { // Checks the deliver ability of a randomly generated address in // order to verify the existence of a catch-all and etc. randomEmail := GenerateRandomEmail(domain) if err = client.Rcpt(randomEmail); err != nil { // The server said something, but not necessarily about the address. // Only a refusal of the address itself rules a catch-all out. catchAll = catchAllInconclusive if e := ParseSMTPError(err); e != nil { switch e.Message { case ErrFullInbox: ret.FullInbox = true case ErrNotAllowed: ret.Disabled = true // If The client typically receives a `550 5.1.1` code as a reply to RCPT TO command, // In most cases, this is because the recipient address does not exist. case ErrServerUnavailable: ret.CatchAll = false catchAll = catchAllRefused default: } } } else { catchAll = catchAllAccepted } // If the email server is a catch-all email server, // no need to calibrate deliverable on a specific user if ret.CatchAll { return &ret, catchAll, nil } } // If no username provided, // no need to calibrate deliverable on a specific user if username == "" { return &ret, catchAll, nil } if err = client.Rcpt(email); err == nil { ret.Deliverable = true } return &ret, catchAll, nil } // newSMTPClient generates a new available SMTP client func newSMTPClient(domain, proxyURI string, resolver *net.Resolver, connectTimeout, operationTimeout time.Duration) (*smtp.Client, *net.MX, error) { domain = domainToASCII(domain) mxRecords, err := resolver.LookupMX(context.Background(), domain) if err != nil { return nil, nil, err } if len(mxRecords) == 0 { // Capitalised on purpose: ParseSMTPError passes this string through // verbatim as LookupError.Message, which is user-facing and matches the // Err* constants in error.go. return nil, nil, errors.New("No MX records found") //nolint:staticcheck // ST1005 } // Create a channel for receiving response from ch := make(chan interface{}, 1) selectedMXCh := make(chan *net.MX, 1) // Done indicates if we're still waiting on dial responses var done bool // mutex for data race var mutex sync.Mutex // Attempt to connect to all SMTP servers concurrently for i, r := range mxRecords { addr := r.Host + smtpPort index := i go func() { c, err := dialSMTP(addr, proxyURI, resolver, connectTimeout, operationTimeout) if err != nil { if !done { ch <- err } return } // Place the client on the channel or close it mutex.Lock() switch { case !done: done = true ch <- c selectedMXCh <- mxRecords[index] default: c.Close() } mutex.Unlock() }() } // Collect errors or return a client var errs []error for { res := <-ch switch r := res.(type) { case *smtp.Client: return r, <-selectedMXCh, nil case error: errs = append(errs, r) if len(errs) == len(mxRecords) { return nil, nil, preferredDialError(errs) } default: // Capitalised on purpose; see the note on "No MX records found" above. return nil, nil, errors.New("Unexpected response dialing SMTP server") //nolint:staticcheck // ST1005 } } } // preferredDialError picks the most useful of the errors gathered while racing // the MX hosts. // // The hosts are dialled concurrently, so the first error to arrive is the one // that failed fastest -- typically a refused connection or a DNS miss. An error // carrying an SMTP reply took longer precisely because a server answered and // said why it was refusing us, which is what the caller needs. Returning the // first arrival systematically preferred the least informative one. func preferredDialError(errs []error) error { for _, err := range errs { if hasSMTPReply(err) { return err } } return errs[0] } // hasSMTPReply reports whether err carries a reply from an SMTP server, as // opposed to a network level failure. func hasSMTPReply(err error) bool { var protoErr *textproto.Error if errors.As(err, &protoErr) { return true } // Errors that have already been rendered to a string keep the reply code // at the front, so fall back to reading it from there. s := err.Error() if len(s) < 3 { return false } code, convErr := strconv.Atoi(s[:3]) return convErr == nil && code >= 200 && code < 600 } // dialSMTP is a timeout wrapper for smtp.Dial. It attempts to dial an // SMTP server (socks5 proxy supported) and fails with a timeout if timeout is reached while // attempting to establish a new connection func dialSMTP(addr, proxyURI string, resolver *net.Resolver, connectTimeout, operationTimeout time.Duration) (*smtp.Client, error) { // Dial the new smtp connection var conn net.Conn var err error if proxyURI != "" { conn, err = establishProxyConnection(addr, proxyURI, connectTimeout) } else { conn, err = establishConnection(addr, resolver, connectTimeout) } if err != nil { return nil, err } // Set specific timeouts for writing and reading err = conn.SetDeadline(time.Now().Add(operationTimeout)) if err != nil { return nil, err } host, _, _ := net.SplitHostPort(addr) return smtp.NewClient(conn, host) } // GenerateRandomEmail generates a random email address using the domain passed. Used // primarily for checking the existence of a catch-all address func GenerateRandomEmail(domain string) string { r := make([]byte, 32) for i := range 32 { r[i] = alphanumeric[rand.Intn(len(alphanumeric))] //nolint:gosec } return fmt.Sprintf("%s@%s", string(r), domain) } // establishConnection connects to the address on the named network address. func establishConnection(addr string, resolver *net.Resolver, timeout time.Duration) (net.Conn, error) { dialer := net.Dialer{Timeout: timeout, Resolver: resolver} return dialer.Dial("tcp", addr) } // establishProxyConnection connects to the address on the named network address // via proxy protocol func establishProxyConnection(addr, proxyURI string, timeout time.Duration) (net.Conn, error) { u, err := url.Parse(proxyURI) if err != nil { return nil, err } dialer, err := proxy.FromURL(u, nil) if err != nil { return nil, err } // https://github.com/golang/go/issues/37549#issuecomment-1178745487 ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if contextDialer, ok := dialer.(proxy.ContextDialer); ok { return contextDialer.DialContext(ctx, "tcp", addr) } // A dialer added with proxy.RegisterDialerType need not be a ContextDialer. return dialWithTimeout(ctx, dialer, addr) } // dialWithTimeout dials through a Dialer that cannot take a context, abandoning // the attempt -- though not the goroutine -- when ctx is done, as x/net/proxy does. func dialWithTimeout(ctx context.Context, dialer proxy.Dialer, addr string) (net.Conn, error) { type result struct { conn net.Conn err error } done := make(chan result, 1) go func() { conn, err := dialer.Dial("tcp", addr) done <- result{conn, err} }() select { case <-ctx.Done(): go func() { if r := <-done; r.conn != nil { r.conn.Close() } }() return nil, ctx.Err() case r := <-done: return r.conn, r.err } }