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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 16:27:26 +02:00
parent f592464f61
commit 7ac4e09b04

View file

@ -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 <email@example.com>" 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}
}