fix(smtp): proper SMTP dialogue, encoded mails, optional skip-verify

- Explicit SMTP flow (dial -> EHLO -> STARTTLS -> AUTH -> MAIL/RCPT/DATA)
  with errors naming the refusing step (also logged as smtp_step)
- EHLO with the sender's domain instead of "localhost"
- Envelope gets the bare address; "Name <addr>" stays in the From header
  (was rejected as invalid MAIL FROM)
- Date and Message-ID headers, RFC 2047 subject, quoted-printable body
- SMTP_SKIP_VERIFY / admin switch for self-signed certificates;
  SMTP checkboxes are switches now
- Tests against a fake SMTP server (STARTTLS, 451, display names)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 22:28:26 +01:00
1 parent bf0d68e7e1
commit ce321ce579
15 files changed
+477 -96

No files matched your search

+11 -79
View File
@@ -2,7 +2,6 @@ package api
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@@ -10,7 +9,6 @@ import (
"io/fs"
"log/slog"
"net/http"
"net/smtp"
"strings"
"time"
@@ -2923,85 +2921,19 @@ func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
sys, _ := s.store.GetSystemSettings()
host := ""
port := "587"
user := ""
pass := ""
from := ""
useTLS := true
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
host = strings.TrimSpace(sys.SMTPHost)
port = strings.TrimSpace(sys.SMTPPort)
user = strings.TrimSpace(sys.SMTPUser)
pass = sys.SMTPPass
from = strings.TrimSpace(sys.SMTPFrom)
useTLS = sys.SMTPTLS
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
host = strings.TrimSpace(s.cfg.SMTPHost)
port = strings.TrimSpace(s.cfg.SMTPPort)
user = strings.TrimSpace(s.cfg.SMTPUser)
pass = s.cfg.SMTPPass
from = strings.TrimSpace(s.cfg.SMTPFrom)
useTLS = s.cfg.SMTPTLS
}
if host == "" {
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
}
if port == "" {
port = "587"
}
if from == "" {
if user != "" && strings.Contains(user, "@") {
from = user
} else {
from = "noreply@mindsift.local"
}
}
addr := fmt.Sprintf("%s:%s", host, port)
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
var smtpAuth smtp.Auth
if user != "" || pass != "" {
smtpAuth = smtp.PlainAuth("", user, pass, host)
}
if port == "465" && useTLS {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Quit()
if smtpAuth != nil {
if err := client.Auth(smtpAuth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(toEmail); err != nil {
return err
}
wc, err := client.Data()
if err != nil {
return err
}
_, err = wc.Write([]byte(msg))
_ = wc.Close()
cfg, err := s.smtpSettings()
if err != nil {
return err
}
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
step := ""
if se, ok := err.(*smtpStepError); ok {
step = se.Step
}
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
return err
}
return nil
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {