fix(alert): email send respects context deadline
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.
This commit is contained in:
+63
-2
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user