From 7ac4e09b046af9c144859e9863aae50c01a5c6c0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 3 Apr 2026 16:27:26 +0200 Subject: [PATCH] fix(mana-notify): rewrite SMTP sender with LOGIN auth and better error logging Go's smtp.PlainAuth refuses to send credentials when the hostname doesn't match the TLS cert (internal Docker hostname 'stalwart' vs cert CN 'localhost'). Replace with custom LOGIN auth that works with any SMTP server. Add detailed error logging at each SMTP stage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mana-notify/internal/channel/email.go | 165 +++++++++++------- 1 file changed, 100 insertions(+), 65 deletions(-) diff --git a/services/mana-notify/internal/channel/email.go b/services/mana-notify/internal/channel/email.go index f93b82582..77423ada3 100644 --- a/services/mana-notify/internal/channel/email.go +++ b/services/mana-notify/internal/channel/email.go @@ -46,6 +46,31 @@ type EmailResult struct { Error string } +// loginAuth implements smtp.Auth for LOGIN mechanism (some servers need this). +// Also bypasses Go's PlainAuth hostname check for internal connections. +type loginAuth struct { + username, password string +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte(a.username), nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + prompt := strings.TrimSpace(string(fromServer)) + switch strings.ToLower(prompt) { + case "username:": + return []byte(a.username), nil + case "password:": + return []byte(a.password), nil + default: + return nil, fmt.Errorf("unexpected server prompt: %s", prompt) + } + } + return nil, nil +} + func (s *EmailService) IsConfigured() bool { return s.user != "" && s.password != "" } @@ -81,91 +106,101 @@ func (s *EmailService) Send(msg *EmailMessage) EmailResult { builder.WriteString(msg.Text) } - // Extract email from "Name " format fromAddr := extractEmail(from) addr := fmt.Sprintf("%s:%d", s.host, s.port) - - auth := smtp.PlainAuth("", s.user, s.password, s.host) + body := []byte(builder.String()) tlsConfig := &tls.Config{ServerName: s.host, InsecureSkipVerify: s.insecureTLS} + + // Try implicit TLS first (port 465 style) conn, err := tls.Dial("tcp", addr, tlsConfig) - if err != nil { - // Try STARTTLS fallback — use custom dialer to support insecure TLS - if s.insecureTLS { - c, dialErr := smtp.Dial(addr) - if dialErr != nil { - slog.Error("email send failed", "to", msg.To, "error", dialErr, "duration", time.Since(start)) - return EmailResult{Success: false, Error: dialErr.Error()} - } - defer c.Close() - if err := c.StartTLS(tlsConfig); err != nil { - // Continue without TLS for internal connections - slog.Warn("STARTTLS failed, continuing without TLS", "error", err) - } - if err := c.Auth(auth); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - if err := c.Mail(fromAddr); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - if err := c.Rcpt(msg.To); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - w, err := c.Data() - if err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - if _, err := w.Write([]byte(builder.String())); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - if err := w.Close(); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - c.Quit() - slog.Info("email sent via STARTTLS (insecure)", "to", msg.To, "duration", time.Since(start)) - return EmailResult{Success: true} + if err == nil { + defer conn.Close() + result := s.sendViaClient(conn, s.host, fromAddr, msg.To, body, start) + if result.Success { + slog.Info("email sent via TLS", "to", msg.To, "duration", time.Since(start)) } - err = smtp.SendMail(addr, auth, fromAddr, []string{msg.To}, []byte(builder.String())) - if err != nil { - slog.Error("email send failed", "to", msg.To, "error", err, "duration", time.Since(start)) + return result + } + + // Fallback: STARTTLS on plain connection + c, dialErr := smtp.Dial(addr) + if dialErr != nil { + slog.Error("smtp dial failed", "to", msg.To, "error", dialErr, "duration", time.Since(start)) + return EmailResult{Success: false, Error: dialErr.Error()} + } + defer c.Close() + + // Try STARTTLS + if err := c.StartTLS(tlsConfig); err != nil { + if s.insecureTLS { + slog.Warn("STARTTLS failed, continuing without TLS", "error", err) + } else { + slog.Error("STARTTLS failed", "to", msg.To, "error", err) return EmailResult{Success: false, Error: err.Error()} } - slog.Info("email sent via STARTTLS", "to", msg.To, "duration", time.Since(start)) - return EmailResult{Success: true} } - defer conn.Close() - client, err := smtp.NewClient(conn, s.host) + // Auth — use loginAuth to bypass Go's PlainAuth hostname restriction + auth := &loginAuth{username: s.user, password: s.password} + if err := c.Auth(auth); err != nil { + slog.Error("smtp auth failed", "to", msg.To, "error", err, "duration", time.Since(start)) + return EmailResult{Success: false, Error: err.Error()} + } + + if err := c.Mail(fromAddr); err != nil { + slog.Error("smtp MAIL FROM failed", "to", msg.To, "error", err) + return EmailResult{Success: false, Error: err.Error()} + } + if err := c.Rcpt(msg.To); err != nil { + slog.Error("smtp RCPT TO failed", "to", msg.To, "error", err) + return EmailResult{Success: false, Error: err.Error()} + } + + w, err := c.Data() if err != nil { return EmailResult{Success: false, Error: err.Error()} } - defer client.Close() - - if err := client.Auth(auth); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - - if err := client.Mail(fromAddr); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - if err := client.Rcpt(msg.To); err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - - w, err := client.Data() - if err != nil { - return EmailResult{Success: false, Error: err.Error()} - } - if _, err := w.Write([]byte(builder.String())); err != nil { + if _, err := w.Write(body); err != nil { return EmailResult{Success: false, Error: err.Error()} } if err := w.Close(); err != nil { return EmailResult{Success: false, Error: err.Error()} } + c.Quit() + slog.Info("email sent via STARTTLS", "to", msg.To, "duration", time.Since(start)) + return EmailResult{Success: true} +} + +func (s *EmailService) sendViaClient(conn *tls.Conn, host string, from, to string, body []byte, start time.Time) EmailResult { + client, err := smtp.NewClient(conn, host) + if err != nil { + return EmailResult{Success: false, Error: err.Error()} + } + defer client.Close() + + auth := &loginAuth{username: s.user, password: s.password} + if err := client.Auth(auth); err != nil { + return EmailResult{Success: false, Error: err.Error()} + } + if err := client.Mail(from); err != nil { + return EmailResult{Success: false, Error: err.Error()} + } + if err := client.Rcpt(to); err != nil { + return EmailResult{Success: false, Error: err.Error()} + } + w, err := client.Data() + if err != nil { + return EmailResult{Success: false, Error: err.Error()} + } + if _, err := w.Write(body); err != nil { + return EmailResult{Success: false, Error: err.Error()} + } + if err := w.Close(); err != nil { + return EmailResult{Success: false, Error: err.Error()} + } client.Quit() - - slog.Info("email sent", "to", msg.To, "duration", time.Since(start)) return EmailResult{Success: true} }