From 023234f4c398e4d42f0ef42faf9661232e25816c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 12 Jun 2026 12:46:45 -0400 Subject: [PATCH] fix(alert): email send respects context deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smtp.SendMail ignores context entirely — a blackholed SMTP server hangs the alert goroutine for the OS TCP timeout (minutes), while the 30s context from the engine does nothing. Replace with sendMailContext: dials with ctx deadline, sets connection deadlines, handles STARTTLS and AUTH when advertised. Behavioral parity with smtp.SendMail but cancellation works throughout. --- internal/alert/alert.go | 65 ++++++++++++++++++- internal/alert/alert_test.go | 117 +++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 6e4bf03..4e8e348 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -3,9 +3,11 @@ package alert import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" + "net" "net/http" "net/smtp" "net/url" @@ -244,7 +246,6 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error { return ctx.Err() default: } - auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) to := sanitizeHeader(e.To) from := sanitizeHeader(e.From) subject := sanitizeHeader(title) @@ -256,7 +257,67 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error { "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + body + "\r\n") - return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg) + return sendMailContext(ctx, e.Host, e.Port, e.User, e.Pass, from, []string{to}, msg) +} + +// sendMailContext is a ctx-aware replacement for smtp.SendMail. +// smtp.SendMail ignores context entirely — a blackholed SMTP server hangs for +// the OS TCP timeout (minutes). This dials with the context deadline and sets +// connection deadlines so cancellation is respected throughout. +func sendMailContext(ctx context.Context, host, port, user, pass, from string, rcpt []string, msg []byte) error { + addr := host + ":" + port + + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("smtp dial: %w", err) + } + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + c, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return fmt.Errorf("smtp client: %w", err) + } + defer c.Close() + + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil { + return fmt.Errorf("smtp starttls: %w", err) + } + } + + if user != "" || pass != "" { + auth := smtp.PlainAuth("", user, pass, host) + if err := c.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + + if err := c.Mail(from); err != nil { + return fmt.Errorf("smtp mail: %w", err) + } + for _, r := range rcpt { + if err := c.Rcpt(r); err != nil { + return fmt.Errorf("smtp rcpt: %w", err) + } + } + + w, err := c.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp data close: %w", err) + } + + return c.Quit() } type NtfyProvider struct { diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index e4bdd9d..69ffccd 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -1,14 +1,18 @@ package alert import ( + "bufio" "context" "encoding/json" "errors" + "fmt" + "net" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) @@ -330,3 +334,116 @@ func TestSanitizeError(t *testing.T) { t.Error("nil should stay nil") } } + +func TestEmailProvider_ContextTimeout(t *testing.T) { + // Listener that accepts but never speaks — simulates a blackholed SMTP server. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + // Hold connection open, never send banner. + go func(c net.Conn) { + time.Sleep(30 * time.Second) + c.Close() + }(conn) + } + }() + + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + provider := &EmailProvider{ + Host: "127.0.0.1", Port: portStr, + From: "test@test.com", To: "dest@test.com", + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + start := time.Now() + err = provider.Send(ctx, "test", "body") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected error from stalled SMTP") + } + if elapsed > 2*time.Second { + t.Errorf("Send took %v — context deadline not respected", elapsed) + } +} + +func TestSendMailContext_HappyPath(t *testing.T) { + // Minimal fake SMTP server that accepts one message. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + received := make(chan string, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + + fmt.Fprintf(conn, "220 localhost ESMTP\r\n") + scanner := bufio.NewScanner(conn) + var dataMode bool + var body strings.Builder + for scanner.Scan() { + line := scanner.Text() + if dataMode { + if line == "." { + dataMode = false + fmt.Fprintf(conn, "250 OK\r\n") + continue + } + body.WriteString(line + "\n") + continue + } + switch { + case strings.HasPrefix(line, "EHLO"): + fmt.Fprintf(conn, "250-localhost\r\n250 OK\r\n") + case strings.HasPrefix(line, "MAIL FROM"): + fmt.Fprintf(conn, "250 OK\r\n") + case strings.HasPrefix(line, "RCPT TO"): + fmt.Fprintf(conn, "250 OK\r\n") + case line == "DATA": + fmt.Fprintf(conn, "354 Go ahead\r\n") + dataMode = true + case line == "QUIT": + fmt.Fprintf(conn, "221 Bye\r\n") + received <- body.String() + return + default: + fmt.Fprintf(conn, "250 OK\r\n") + } + } + }() + + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = sendMailContext(ctx, "127.0.0.1", portStr, "", "", "from@test.com", []string{"to@test.com"}, []byte("Subject: test\r\n\r\nhello")) + if err != nil { + t.Fatalf("sendMailContext: %v", err) + } + + select { + case body := <-received: + if !strings.Contains(body, "hello") { + t.Errorf("expected body to contain 'hello', got: %s", body) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for fake SMTP to receive message") + } +}