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} }