diff --git a/pkg/mailer/dialer.go b/pkg/mailer/dialer.go index b8f3aaa888..fc54216063 100644 --- a/pkg/mailer/dialer.go +++ b/pkg/mailer/dialer.go @@ -2,6 +2,9 @@ package mailer import ( "crypto/tls" + "net" + "sync" + "time" "github.com/raystack/frontier/pkg/mailer/mocks" "github.com/stretchr/testify/mock" @@ -13,6 +16,31 @@ const ( defaultFromHeader = "frontier@raystack.org" ) +var setDialOnce sync.Once + +// installGreetingDeadline makes every SMTP connection get a deadline as soon +// as it opens. gopkg.in/mail.v2 only sets the deadline after it has read the +// server greeting, so a server that accepts the connection and then stays +// silent would block the send forever. +func installGreetingDeadline() { + setDialOnce.Do(func() { + dial := mail.NetDialTimeout + mail.NetDialTimeout = func(network, address string, timeout time.Duration) (net.Conn, error) { + conn, err := dial(network, address, timeout) + if err != nil { + return nil, err + } + if timeout > 0 { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + conn.Close() + return nil, err + } + } + return conn, nil + } + }) +} + type Dialer interface { DialAndSend(m *mail.Message) error FromHeader() string @@ -32,6 +60,7 @@ type DialerImpl struct { func NewDialerImpl(SMTPHost string, SMTPPort int, SMTPUser string, SMTPPass string, SMTPInsecure bool, headers map[string]string, tlsPolicy mail.StartTLSPolicy) *DialerImpl { + installGreetingDeadline() d := mail.NewDialer(SMTPHost, SMTPPort, SMTPUser, SMTPPass) d.TLSConfig = &tls.Config{ InsecureSkipVerify: SMTPInsecure, //nolint:gosec diff --git a/pkg/mailer/dialer_test.go b/pkg/mailer/dialer_test.go new file mode 100644 index 0000000000..c9dcd53b2d --- /dev/null +++ b/pkg/mailer/dialer_test.go @@ -0,0 +1,48 @@ +package mailer + +import ( + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/mail.v2" +) + +// A server that accepts the connection but never sends its greeting must make +// the send fail after the dialer timeout instead of blocking forever. +func TestDialerImpl_DialAndSend_SilentServer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() // held open, never written to + } + }() + + addr := ln.Addr().(*net.TCPAddr) + d := NewDialerImpl("127.0.0.1", addr.Port, "", "", true, nil, mail.NoStartTLS) + d.dialer.Timeout = 200 * time.Millisecond + + msg := mail.NewMessage() + msg.SetHeader("From", "from@example.com") + msg.SetHeader("To", "to@example.com") + msg.SetBody("text/plain", "hi") + + done := make(chan error, 1) + go func() { done <- d.DialAndSend(msg) }() + + select { + case err := <-done: + assert.Error(t, err) + case <-time.After(3 * time.Second): + t.Fatal("DialAndSend still blocked after 3s: no deadline on the greeting read") + } +}