From 4afd4fb074dffdf10ecfc87e8991eceac882c683 Mon Sep 17 00:00:00 2001 From: vigneshwar Date: Sat, 24 Oct 2026 15:26:36 +0530 Subject: [PATCH] notify/email: honour the context when dialing with implicit TLS The plain SMTP path dials with DialContext, but the implicit TLS path used tls.Dial, which has no timeout and ignores the context. A smarthost that accepts the TCP connection but never completes the TLS handshake blocked the notification with no limit. Use tls.Dialer.DialContext so both the dial and the handshake stop when the notification context is done. Signed-off-by: vigneshwar --- notify/email/email.go | 3 +- notify/email/email_test.go | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/notify/email/email.go b/notify/email/email.go index 6d55576b4c..22afffd2b6 100644 --- a/notify/email/email.go +++ b/notify/email/email.go @@ -147,7 +147,8 @@ func (n *Email) Notify(ctx context.Context, as ...*alert.Alert) notify.NotifyVer tlsConfig.ServerName = n.conf.Smarthost.Host } - conn, err = tls.Dial("tcp", n.conf.Smarthost.String(), tlsConfig) + d := tls.Dialer{Config: tlsConfig} + conn, err = d.DialContext(ctx, "tcp", n.conf.Smarthost.String()) if err != nil { return notify.Retry(0, fmt.Errorf("establish TLS connection to server: %w", err), notify.DefaultReason) } diff --git a/notify/email/email_test.go b/notify/email/email_test.go index a407a46a6f..c5f5a0eebc 100644 --- a/notify/email/email_test.go +++ b/notify/email/email_test.go @@ -365,6 +365,68 @@ func TestEmailNotifyWithDoneContext(t *testing.T) { require.Contains(t, err.Error(), "establish connection to server") } +// TestEmailNotifyImplicitTLSWithDoneContext checks that the implicit TLS dial +// honours the context when the server accepts the connection but never answers +// the TLS handshake. +func TestEmailNotifyImplicitTLSWithDoneContext(t *testing.T) { + t.Parallel() + + l, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + var conns []net.Conn + accepted := make(chan struct{}) + go func() { + defer close(accepted) + for { + conn, err := l.Accept() + if err != nil { + return + } + // Keep the connection open without ever writing to it. + conns = append(conns, conn) + } + }() + t.Cleanup(func() { + l.Close() + <-accepted + for _, c := range conns { + c.Close() + } + }) + + host, port, err := net.SplitHostPort(l.Addr().String()) + require.NoError(t, err) + + forceImplicitTLS := true + cfg := &EmailConfig{ + Smarthost: amcommoncfg.HostPort{Host: host, Port: port}, + To: emailTo, + From: emailFrom, + HTML: "HTML body", + Text: "Text body", + ForceImplicitTLS: &forceImplicitTLS, + TLSConfig: &commoncfg.TLSConfig{}, + } + tmpl, firingAlert, err := prepare(cfg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + done := make(chan notify.NotifyVerdict, 1) + go func() { + done <- New(cfg, tmpl, promslog.NewNopLogger()).Notify(ctx, firingAlert) + }() + + select { + case verdict := <-done: + require.ErrorContains(t, verdict.Err(), "establish TLS connection to server") + case <-time.After(5 * time.Second): + t.Fatal("Notify did not return after the context was done") + } +} + // TestEmailNotifyWithoutAuthentication sends an email to an instance of // MailDev configured with no authentication then it checks that the server has // successfully processed the email.