From 45178d455eba30c8f322815595a6a4beac5887fe Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 14:46:15 +0100 Subject: [PATCH] fix(server): SMTP dispatch dial timeout + implicit/STARTTLS handling --- server/internal/notify/smtp.go | 79 ++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/server/internal/notify/smtp.go b/server/internal/notify/smtp.go index 481799b..e8d90d9 100644 --- a/server/internal/notify/smtp.go +++ b/server/internal/notify/smtp.go @@ -1,15 +1,25 @@ package notify import ( + "crypto/tls" "fmt" + "net" "net/smtp" "strings" + "time" "github.com/mrhid6/vantage/server/internal/models" ) +const smtpTimeout = 15 * time.Second + // dispatchSMTP sends the alert as a plain-text email. Config keys: host, port, -// username, password, from, to. Auth is skipped when username is empty. +// username, password, from, to. Auth is skipped when username is empty. Port 465 +// uses implicit TLS; other ports use STARTTLS when the server advertises it. +// +// It dials with a timeout and sets a connection deadline so an unreachable or +// misconfigured SMTP host fails fast instead of hanging the request until the OS +// TCP timeout (which resets the upstream proxy connection). func dispatchSMTP(ch models.NotificationChannel, ev Event) error { host := ch.Config["host"] port := ch.Config["port"] @@ -19,6 +29,56 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error { return fmt.Errorf("smtp: missing host/port/from/to") } + addr := net.JoinHostPort(host, port) + conn, err := net.DialTimeout("tcp", addr, smtpTimeout) + if err != nil { + return fmt.Errorf("smtp: dial %s: %w", addr, err) + } + _ = conn.SetDeadline(time.Now().Add(smtpTimeout)) + + // Implicit TLS on 465; otherwise start plain and upgrade via STARTTLS. + if port == "465" { + conn = tls.Client(conn, &tls.Config{ServerName: host}) + } + + c, err := smtp.NewClient(conn, host) + if err != nil { + conn.Close() + return fmt.Errorf("smtp: client: %w", err) + } + defer c.Close() + + if port != "465" { + 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 := ch.Config["username"]; user != "" { + if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil { + return fmt.Errorf("smtp: auth: %w", err) + } + } + + recipients := strings.Split(to, ",") + for i := range recipients { + recipients[i] = strings.TrimSpace(recipients[i]) + } + + if err := c.Mail(from); err != nil { + return fmt.Errorf("smtp: mail from: %w", err) + } + for _, rcpt := range recipients { + if rcpt == "" { + continue + } + if err := c.Rcpt(rcpt); err != nil { + return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err) + } + } + title := ev.title() msg := strings.Join([]string{ "From: " + from, @@ -32,14 +92,15 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error { "Time: " + ev.Time.String(), }, "\r\n") - var auth smtp.Auth - if user := ch.Config["username"]; user != "" { - auth = smtp.PlainAuth("", user, ch.Config["password"], host) + w, err := c.Data() + if err != nil { + return fmt.Errorf("smtp: data: %w", err) } - addr := host + ":" + port - recipients := strings.Split(to, ",") - for i := range recipients { - recipients[i] = strings.TrimSpace(recipients[i]) + if _, err := w.Write([]byte(msg)); err != nil { + return fmt.Errorf("smtp: write: %w", err) } - return smtp.SendMail(addr, auth, from, recipients, []byte(msg)) + if err := w.Close(); err != nil { + return fmt.Errorf("smtp: close data: %w", err) + } + return c.Quit() }