From 7dbcb5461cd90ffda10d8251a3e75d614904de4a Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Tue, 12 Aug 2025 08:04:04 +0000 Subject: [PATCH] fix: addresses #299 - email notifications not working Fixed two critical issues with email notifications: 1. Test email API now returns errors properly instead of always showing success 2. Added timeouts to SMTP connections to prevent hanging (10s dial, 30s overall) The root cause of users not receiving emails was that errors were being silently logged instead of returned to the API, making it appear successful when it wasn't. SMTP connections could also hang indefinitely on unreachable servers. Note: API uses "server" and "port" JSON fields, not "smtpHost"/"smtpPort" --- internal/notifications/email_enhanced.go | 70 +++++++++++++++++++++++- internal/notifications/notifications.go | 66 +++++++++++++++++++++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/internal/notifications/email_enhanced.go b/internal/notifications/email_enhanced.go index 83b5bfda0..e831390d8 100644 --- a/internal/notifications/email_enhanced.go +++ b/internal/notifications/email_enhanced.go @@ -176,7 +176,8 @@ func (e *EnhancedEmailManager) sendViaProvider(msg []byte) error { } else if e.config.StartTLS { return e.sendStartTLS(addr, auth, msg) } else { - return smtp.SendMail(addr, auth, e.config.From, e.config.To, msg) + // Use sendPlain for non-TLS connections with timeout + return e.sendPlain(addr, auth, msg) } } @@ -187,12 +188,19 @@ func (e *EnhancedEmailManager) sendTLS(addr string, auth smtp.Auth, msg []byte) InsecureSkipVerify: e.config.SkipTLSVerify, } - conn, err := tls.Dial("tcp", addr, tlsConfig) + // Use DialWithDialer with timeout + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + } + conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("TLS dial failed: %w", err) } defer conn.Close() + // Set overall connection timeout + conn.SetDeadline(time.Now().Add(30 * time.Second)) + client, err := smtp.NewClient(conn, e.config.SMTPHost) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) @@ -235,12 +243,16 @@ func (e *EnhancedEmailManager) sendTLS(addr string, auth smtp.Auth, msg []byte) // sendStartTLS sends email using STARTTLS func (e *EnhancedEmailManager) sendStartTLS(addr string, auth smtp.Auth, msg []byte) error { - conn, err := net.Dial("tcp", addr) + // Use DialTimeout to prevent hanging on unreachable servers + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) if err != nil { return fmt.Errorf("TCP dial failed: %w", err) } defer conn.Close() + // Set overall connection timeout + conn.SetDeadline(time.Now().Add(30 * time.Second)) + client, err := smtp.NewClient(conn, e.config.SMTPHost) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) @@ -339,5 +351,57 @@ func (e *EnhancedEmailManager) TestConnection() error { } } + return client.Quit() +} + +// sendPlain sends email over plain SMTP connection with timeout +func (e *EnhancedEmailManager) sendPlain(addr string, auth smtp.Auth, msg []byte) error { + // Use DialTimeout to prevent hanging on unreachable servers + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("TCP dial failed: %w", err) + } + defer conn.Close() + + // Set overall connection timeout + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + client, err := smtp.NewClient(conn, e.config.SMTPHost) + if err != nil { + return fmt.Errorf("SMTP client creation failed: %w", err) + } + defer client.Close() + + if auth != nil { + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP auth failed: %w", err) + } + } + + if err = client.Mail(e.config.From); err != nil { + return fmt.Errorf("MAIL FROM failed: %w", err) + } + + for _, to := range e.config.To { + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO failed for %s: %w", to, err) + } + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("DATA command failed: %w", err) + } + + _, err = w.Write(msg) + if err != nil { + return fmt.Errorf("message write failed: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("message close failed: %w", err) + } + return client.Quit() } \ No newline at end of file diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 6316af7fb..14fa97da6 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -279,6 +279,64 @@ func (n *NotificationManager) sendEmail(alert *alerts.Alert) { n.sendHTMLEmail(subject, htmlBody, textBody, config) } +// sendHTMLEmailWithError sends an HTML email with multipart content and returns any error +func (n *NotificationManager) sendHTMLEmailWithError(subject, htmlBody, textBody string, config EmailConfig) error { + // Use From address as recipient if To is empty + recipients := config.To + if len(recipients) == 0 && config.From != "" { + recipients = []string{config.From} + log.Info(). + Str("from", config.From). + Msg("Using From address as recipient since To is empty") + } + + // Create enhanced email configuration with proper STARTTLS support + enhancedConfig := EmailProviderConfig{ + EmailConfig: EmailConfig{ + From: config.From, + To: recipients, + SMTPHost: config.SMTPHost, + SMTPPort: config.SMTPPort, + Username: config.Username, + Password: config.Password, + }, + StartTLS: config.StartTLS, // Use the configured StartTLS setting + MaxRetries: 2, + RetryDelay: 3, + RateLimit: 60, + SkipTLSVerify: false, + AuthRequired: config.Username != "" && config.Password != "", + } + + // Use enhanced email manager for better compatibility + enhancedManager := NewEnhancedEmailManager(enhancedConfig) + + log.Info(). + Str("smtp", fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)). + Str("from", config.From). + Strs("to", recipients). + Bool("hasAuth", config.Username != "" && config.Password != ""). + Bool("startTLS", enhancedConfig.StartTLS). + Msg("Attempting to send email via SMTP with enhanced support") + + err := enhancedManager.SendEmailWithRetry(subject, htmlBody, textBody) + + if err != nil { + log.Error(). + Err(err). + Str("smtp", fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)). + Strs("recipients", recipients). + Msg("Failed to send email notification") + return fmt.Errorf("failed to send email: %w", err) + } + + log.Info(). + Strs("recipients", recipients). + Int("recipientCount", len(recipients)). + Msg("Email notification sent successfully") + return nil +} + // sendHTMLEmail sends an HTML email with multipart content func (n *NotificationManager) sendHTMLEmail(subject, htmlBody, textBody string, config EmailConfig) { // Use From address as recipient if To is empty @@ -809,6 +867,9 @@ func (n *NotificationManager) SendTestNotificationWithConfig(method string, conf Int("port", config.SMTPPort). Str("from", config.From). Int("toCount", len(config.To)). + Strs("to", config.To). + Bool("smtpEmpty", config.SMTPHost == ""). + Bool("fromEmpty", config.From == ""). Msg("Testing email notification with provided config") if !config.Enabled { @@ -822,9 +883,8 @@ func (n *NotificationManager) SendTestNotificationWithConfig(method string, conf // Generate email using template subject, htmlBody, textBody := EmailTemplate([]*alerts.Alert{testAlert}, true) - // Send using provided config - n.sendHTMLEmail(subject, htmlBody, textBody, *config) - return nil + // Send using provided config and return any error + return n.sendHTMLEmailWithError(subject, htmlBody, textBody, *config) default: return fmt.Errorf("unsupported method for config-based testing: %s", method)