mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 20:59:41 +02:00
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:
parent
f592464f61
commit
7ac4e09b04
1 changed files with 100 additions and 65 deletions
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue