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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -244,7 +246,6 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
|
|
||||||
to := sanitizeHeader(e.To)
|
to := sanitizeHeader(e.To)
|
||||||
from := sanitizeHeader(e.From)
|
from := sanitizeHeader(e.From)
|
||||||
subject := sanitizeHeader(title)
|
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" +
|
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||||
"\r\n" +
|
"\r\n" +
|
||||||
body + "\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 {
|
type NtfyProvider struct {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
@@ -330,3 +334,116 @@ func TestSanitizeError(t *testing.T) {
|
|||||||
t.Error("nil should stay nil")
|
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