diff --git a/README.md b/README.md index c5d68b0..6f6bef4 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,10 @@ If you want to disable catchAll checking, use the `DisableCatchAllCheck()` switc Support setting a SOCKS5 proxy to verify the email, proxyURI should be in the format: `socks5://user:password@127.0.0.1:1080` -The protocol could be socks5, socks4 and socks4a. +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`](https://pkg.go.dev/golang.org/x/net/proxy#RegisterDialerType) +before calling `Proxy()`. ```go var ( diff --git a/smtp.go b/smtp.go index 62bac70..59c9d94 100644 --- a/smtp.go +++ b/smtp.go @@ -326,5 +326,37 @@ func establishProxyConnection(addr, proxyURI string, timeout time.Duration) (net ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - return dialer.(proxy.ContextDialer).DialContext(ctx, "tcp", addr) + 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 + } } diff --git a/smtp_test.go b/smtp_test.go index 22026b0..eb76a72 100644 --- a/smtp_test.go +++ b/smtp_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/textproto" + "net/url" "sync/atomic" "syscall" "testing" @@ -12,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/net/proxy" ) func TestCheckSMTPUnSupportedVendor(t *testing.T) { @@ -231,6 +233,43 @@ func TestDialSMTP_ProxyBypassesCustomResolver(t *testing.T) { assert.False(t, called.Load()) } +// Pins the schemes x/net/proxy actually resolves; socks4 and socks4a never worked. +func TestEstablishProxyConnection_Schemes(t *testing.T) { + // Nothing listens here: a resolved scheme reaches a refused connection. + const proxyAddr = "127.0.0.1:1" + + for _, scheme := range []string{"socks5", "socks5h"} { + t.Run(scheme+" is supported", func(tt *testing.T) { + _, err := establishProxyConnection("example.com:25", scheme+"://"+proxyAddr, time.Second) + require.Error(tt, err) + assert.NotContains(tt, err.Error(), "unknown scheme") + }) + } + + for _, scheme := range []string{"socks4", "socks4a", "http", "https"} { + t.Run(scheme+" is not supported", func(tt *testing.T) { + _, err := establishProxyConnection("example.com:25", scheme+"://"+proxyAddr, time.Second) + require.ErrorContains(tt, err, "proxy: unknown scheme: "+scheme) + }) + } +} + +type dialerWithoutContext struct{ err error } + +func (d dialerWithoutContext) Dial(_, _ string) (net.Conn, error) { return nil, d.err } + +func TestEstablishProxyConnection_DialerWithoutContext(t *testing.T) { + wantErr := errors.New("registered dialer reached") + proxy.RegisterDialerType("emailverifiertest", func(*url.URL, proxy.Dialer) (proxy.Dialer, error) { + return dialerWithoutContext{err: wantErr}, nil + }) + + // Used to panic on the proxy.ContextDialer assertion. + conn, err := establishProxyConnection("example.com:25", "emailverifiertest://127.0.0.1:1080", time.Second) + assert.Nil(t, conn) + require.ErrorIs(t, err, wantErr) +} + func TestPreferredDialError(t *testing.T) { network := errors.New("dial tcp 1.2.3.4:25: connect: connection refused") dns := &net.DNSError{Err: "no such host", Name: "mx.example.invalid", IsNotFound: true} diff --git a/verifier.go b/verifier.go index 8e1dcaf..c5d0a17 100644 --- a/verifier.go +++ b/verifier.go @@ -226,7 +226,10 @@ func (v *Verifier) HelloName(domain string) *Verifier { // Proxy sets a SOCKS5 proxy to verify the email, // proxyURI should be in the format: "socks5://user:password@127.0.0.1:1080". -// The protocol could be socks5, socks4 and socks4a. +// +// 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.