package notifications import ( "crypto/tls" "fmt" "net" "net/smtp" "strconv" "strings" "sync" "time" "github.com/rs/zerolog/log" ) // EnhancedEmailManager extends email functionality with provider support type EnhancedEmailManager struct { config EmailProviderConfig rateLimit *RateLimiter } // RateLimiter implements a simple rate limiter type RateLimiter struct { mu sync.Mutex rate int lastSent time.Time sentCount int } // NewEnhancedEmailManager creates an enhanced email manager func NewEnhancedEmailManager(config EmailProviderConfig) *EnhancedEmailManager { return &EnhancedEmailManager{ config: config, rateLimit: &RateLimiter{ rate: config.RateLimit, }, } } // SendEmailWithRetry sends email with retry logic // Note: When used with the persistent queue, retry behavior is layered: // - Transport retries (this function): up to MaxRetries attempts with RetryDelay between // - Queue retries: up to MaxAttempts (default 3) with exponential backoff // Total attempts = MaxRetries * MaxAttempts (e.g., 3 * 3 = 9 SMTP calls for a single notification) // This ensures delivery even during transient failures at either layer. func (e *EnhancedEmailManager) SendEmailWithRetry(subject, htmlBody, textBody string) error { var lastErr error for attempt := 0; attempt <= e.config.MaxRetries; attempt++ { if attempt > 0 { delay := time.Duration(e.config.RetryDelay) * time.Second log.Debug(). Int("attempt", attempt). Dur("delay", delay). Msg("Retrying email send after delay") time.Sleep(delay) } // Check rate limit if err := e.checkRateLimit(); err != nil { lastErr = err continue } // Try to send err := e.sendEmailOnce(subject, htmlBody, textBody) if err == nil { if attempt > 0 { log.Info(). Int("attempt", attempt). Msg("Email sent successfully after retry") } return nil } lastErr = err log.Warn(). Err(err). Int("attempt", attempt). Str("provider", e.config.Provider). Msg("Email send attempt failed") } return fmt.Errorf("email failed after %d attempts: %w", e.config.MaxRetries+1, lastErr) } // checkRateLimit enforces rate limiting func (e *EnhancedEmailManager) checkRateLimit() error { if e.config.RateLimit <= 0 { return nil // No rate limit } e.rateLimit.mu.Lock() defer e.rateLimit.mu.Unlock() now := time.Now() if now.Sub(e.rateLimit.lastSent) >= time.Minute { // Reset counter after a minute e.rateLimit.sentCount = 0 e.rateLimit.lastSent = now } if e.rateLimit.sentCount >= e.config.RateLimit { return fmt.Errorf("rate limit exceeded: %d emails per minute", e.config.RateLimit) } e.rateLimit.sentCount++ return nil } // sendEmailOnce sends a single email func (e *EnhancedEmailManager) sendEmailOnce(subject, htmlBody, textBody string) error { // Build message with enhanced headers boundary := fmt.Sprintf("===============%d==", time.Now().UnixNano()) msg := fmt.Sprintf("From: %s\r\n", e.config.From) msg += fmt.Sprintf("To: %s\r\n", strings.Join(e.config.To, ", ")) if e.config.ReplyTo != "" { msg += fmt.Sprintf("Reply-To: %s\r\n", e.config.ReplyTo) } msg += fmt.Sprintf("Subject: %s\r\n", subject) msg += fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)) msg += fmt.Sprintf("Message-ID: <%d@pulse-monitoring>\r\n", time.Now().UnixNano()) msg += "MIME-Version: 1.0\r\n" msg += fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary) msg += "X-Mailer: Pulse Monitoring System\r\n" msg += "\r\n" // Text part msg += fmt.Sprintf("--%s\r\n", boundary) msg += "Content-Type: text/plain; charset=\"UTF-8\"\r\n" msg += "Content-Transfer-Encoding: 7bit\r\n" msg += "\r\n" msg += textBody + "\r\n" // HTML part msg += fmt.Sprintf("--%s\r\n", boundary) msg += "Content-Type: text/html; charset=\"UTF-8\"\r\n" msg += "Content-Transfer-Encoding: 7bit\r\n" msg += "\r\n" msg += htmlBody + "\r\n" // End boundary msg += fmt.Sprintf("--%s--\r\n", boundary) // Send based on provider configuration return e.sendViaProvider([]byte(msg)) } // sendViaProvider sends email using provider-specific settings func (e *EnhancedEmailManager) sendViaProvider(msg []byte) error { addr := net.JoinHostPort(e.config.SMTPHost, strconv.Itoa(e.config.SMTPPort)) // Special handling for specific providers switch e.config.Provider { case "SendGrid": // SendGrid uses "apikey" as username if e.config.Username == "" { e.config.Username = "apikey" } case "Postmark": // Postmark uses API token for both username and password if e.config.Password != "" && e.config.Username == "" { e.config.Username = e.config.Password } case "SparkPost": // SparkPost uses specific username if e.config.Username == "" { e.config.Username = "SMTP_Injection" } case "Resend": // Resend uses "resend" as username if e.config.Username == "" { e.config.Username = "resend" } } // Configure authentication var auth smtp.Auth if e.config.AuthRequired && e.config.Username != "" && e.config.Password != "" { auth = smtp.PlainAuth("", e.config.Username, e.config.Password, e.config.SMTPHost) } // Send with TLS configuration if e.config.TLS || e.config.SMTPPort == 465 { return e.sendTLS(addr, auth, msg) } else if e.config.StartTLS { return e.sendStartTLS(addr, auth, msg) } else { // Use sendPlain for non-TLS connections with timeout return e.sendPlain(addr, auth, msg) } } // sendTLS sends email over TLS connection func (e *EnhancedEmailManager) sendTLS(addr string, auth smtp.Auth, msg []byte) error { tlsConfig := &tls.Config{ ServerName: e.config.SMTPHost, InsecureSkipVerify: e.config.SkipTLSVerify, } // 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 if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { return fmt.Errorf("failed to set connection deadline: %w", err) } 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() } // sendStartTLS sends email using STARTTLS func (e *EnhancedEmailManager) sendStartTLS(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 if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { return fmt.Errorf("failed to set connection deadline: %w", err) } client, err := smtp.NewClient(conn, e.config.SMTPHost) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Close() // STARTTLS tlsConfig := &tls.Config{ ServerName: e.config.SMTPHost, InsecureSkipVerify: e.config.SkipTLSVerify, } if err = client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS failed: %w", err) } 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() } // TestConnection tests the email server connection func (e *EnhancedEmailManager) TestConnection() error { addr := net.JoinHostPort(e.config.SMTPHost, strconv.Itoa(e.config.SMTPPort)) // Try to connect var conn net.Conn var err error if e.config.TLS || e.config.SMTPPort == 465 { tlsConfig := &tls.Config{ ServerName: e.config.SMTPHost, InsecureSkipVerify: e.config.SkipTLSVerify, } conn, err = tls.Dial("tcp", addr, tlsConfig) } else { conn, err = net.DialTimeout("tcp", addr, 10*time.Second) } if err != nil { return fmt.Errorf("connection failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, e.config.SMTPHost) if err != nil { return fmt.Errorf("SMTP handshake failed: %w", err) } defer client.Close() // Test STARTTLS if configured if e.config.StartTLS && !e.config.TLS { tlsConfig := &tls.Config{ ServerName: e.config.SMTPHost, InsecureSkipVerify: e.config.SkipTLSVerify, } if err = client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS failed: %w", err) } } // Test authentication if configured if e.config.AuthRequired && e.config.Username != "" && e.config.Password != "" { auth := smtp.PlainAuth("", e.config.Username, e.config.Password, e.config.SMTPHost) if err = client.Auth(auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } } 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 if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { return fmt.Errorf("failed to set connection deadline: %w", err) } 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() }