From ce321ce579b112358bf28df20dda4cf67d10066d Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 22:28:26 +0100 Subject: [PATCH] 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 " 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 --- .env.example | 2 + Doku/architecture.md | 1 + Doku/modules/tasks.md | 10 +- README.md | 1 + cmd/server/main.go | 2 +- compose.yaml | 1 + docker-compose.yml | 1 + .../features/settings/AdminSettingsView.tsx | 39 +++- frontend/src/lib/types.ts | 1 + internal/api/mail.go | 189 +++++++++++++++ internal/api/mail_test.go | 215 ++++++++++++++++++ internal/api/server.go | 90 +------- internal/config/config.go | 4 + internal/domain/models.go | 1 + internal/storage/sqlite.go | 16 +- 15 files changed, 477 insertions(+), 96 deletions(-) create mode 100644 internal/api/mail.go create mode 100644 internal/api/mail_test.go diff --git a/.env.example b/.env.example index 3a9ac3e..029e508 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,5 @@ SMTP_USER= SMTP_PASS= SMTP_FROM=noreply@mindsift.local SMTP_TLS=true +# Only for internal mail servers with self-signed certificates +SMTP_SKIP_VERIFY=false diff --git a/Doku/architecture.md b/Doku/architecture.md index 76dc387..01ddc03 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -64,6 +64,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` | | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | +| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. diff --git a/Doku/modules/tasks.md b/Doku/modules/tasks.md index d9fbd19..8e0977c 100644 --- a/Doku/modules/tasks.md +++ b/Doku/modules/tasks.md @@ -33,10 +33,18 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe | Kanal | Ziel | Konfiguration | |---|---|---| -| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) | +| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung, siehe unten | | Discord | Webhook-URL | Benutzereinstellungen | | Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen | Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. +**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt: + +- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten. +- `SMTP_FROM` darf einen Namen enthalten (`MindSift `): im Umschlag steht nur die Adresse, im Header Name und Adresse. +- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text. +- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt). +- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451). + Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)). diff --git a/README.md b/README.md index 223010d..7d85945 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | +| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). diff --git a/cmd/server/main.go b/cmd/server/main.go index 6ef54bd..9333166 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -38,7 +38,7 @@ func main() { defer store.Close() // Seed SMTP defaults from environment variables into DB if not yet configured in DB - store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) + store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify) if cfg.DemoMode { slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") diff --git a/compose.yaml b/compose.yaml index c57c511..8c45312 100644 --- a/compose.yaml +++ b/compose.yaml @@ -26,3 +26,4 @@ services: - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} + - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} diff --git a/docker-compose.yml b/docker-compose.yml index c57c511..8c45312 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,3 +26,4 @@ services: - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} + - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index a6f4d10..052f10b 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -100,6 +100,7 @@ export const AdminSettingsView: React.FC = ({ smtpPass: '', smtpFrom: '', smtpTls: true, + smtpSkipVerify: false, smtpConfiguredViaEnv: false, }); const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || ''); @@ -129,6 +130,7 @@ export const AdminSettingsView: React.FC = ({ smtpPass: sys.smtpPass || '', smtpFrom: sys.smtpFrom || '', smtpTls: sys.smtpTls ?? true, + smtpSkipVerify: Boolean(sys.smtpSkipVerify), smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv), }); } @@ -1202,20 +1204,33 @@ export const AdminSettingsView: React.FC = ({ /> -
-
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 14092bc..bc54175 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -159,6 +159,7 @@ export interface SystemSettings { smtpPass?: string; smtpFrom?: string; smtpTls?: boolean; + smtpSkipVerify?: boolean; smtpConfiguredViaEnv?: boolean; } diff --git a/internal/api/mail.go b/internal/api/mail.go new file mode 100644 index 0000000..36e0123 --- /dev/null +++ b/internal/api/mail.go @@ -0,0 +1,189 @@ +package api + +import ( + "bytes" + "crypto/rand" + "crypto/tls" + "encoding/hex" + "fmt" + "mime" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "os" + "strings" + "time" +) + +// smtpSettings is the effective mail server configuration (admin settings, else environment). +type smtpSettings struct { + Host, Port, User, Pass, From string + TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set) + SkipVerify bool // accept self-signed / mismatching certificates +} + +// smtpStepError names the protocol step at which the mail server refused, e.g. "RCPT TO". +type smtpStepError struct { + Step string + Err error +} + +func (e *smtpStepError) Error() string { + return fmt.Sprintf("SMTP-Schritt %s fehlgeschlagen: %v", e.Step, e.Err) +} + +func (e *smtpStepError) Unwrap() error { return e.Err } + +const smtpTimeout = 30 * time.Second + +func (s *Server) smtpSettings() (smtpSettings, error) { + cfg := smtpSettings{Port: "587", TLS: true} + if sys, _ := s.store.GetSystemSettings(); sys != nil && strings.TrimSpace(sys.SMTPHost) != "" { + cfg = smtpSettings{ + Host: strings.TrimSpace(sys.SMTPHost), Port: strings.TrimSpace(sys.SMTPPort), + User: strings.TrimSpace(sys.SMTPUser), Pass: sys.SMTPPass, From: strings.TrimSpace(sys.SMTPFrom), + TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify, + } + } else if strings.TrimSpace(s.cfg.SMTPHost) != "" { + cfg = smtpSettings{ + 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), + TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify, + } + } + if cfg.Host == "" { + return cfg, fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)") + } + if cfg.Port == "" { + cfg.Port = "587" + } + if cfg.From == "" { + if strings.Contains(cfg.User, "@") { + cfg.From = cfg.User + } else { + cfg.From = "noreply@mindsift.local" + } + } + return cfg, nil +} + +// ehloName is how MindSift introduces itself (EHLO). It must name the sending side, never "localhost", +// which strict servers (Exchange/O365, Postfix with HELO checks) refuse: the sender's mail domain, +// else the machine's hostname. +func ehloName(from string) string { + if at := strings.LastIndex(from, "@"); at >= 0 { + if domain := strings.Trim(from[at+1:], " >"); strings.Contains(domain, ".") { + return domain + } + } + if h, err := os.Hostname(); err == nil && strings.Contains(h, ".") { + return h + } + return "mindsift.local" +} + +// splitAddress turns "MindSift " into the bare address for the SMTP envelope +// (MAIL FROM / RCPT TO only accept that) and the header form with an encoded display name. +func splitAddress(raw string) (envelope, header string, err error) { + addr, err := mail.ParseAddress(strings.TrimSpace(raw)) + if err != nil { + return "", "", fmt.Errorf("ungültige E-Mail-Adresse %q", raw) + } + return addr.Address, addr.String(), nil +} + +// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject +// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings. +func buildMessage(from, to, subject, body string, now time.Time) []byte { + idBytes := make([]byte, 12) + _, _ = rand.Read(idBytes) + domain := ehloName(from) + + var buf bytes.Buffer + fmt.Fprintf(&buf, "From: %s\r\n", from) + fmt.Fprintf(&buf, "To: %s\r\n", to) + fmt.Fprintf(&buf, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)) + fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z)) + fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain) + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qp := quotedprintable.NewWriter(&buf) + _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))) + _ = qp.Close() + buf.WriteString("\r\n") + return buf.Bytes() +} + +// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself +// properly, honour SkipVerify and report the exact step a server refuses. +func sendMail(cfg smtpSettings, to, subject, body string) error { + fromAddr, fromHeader, err := splitAddress(cfg.From) + if err != nil { + return &smtpStepError{"Absender", err} + } + toAddr, toHeader, err := splitAddress(to) + if err != nil { + return &smtpStepError{"Empfänger", err} + } + addr := net.JoinHostPort(cfg.Host, cfg.Port) + tlsCfg := &tls.Config{ServerName: cfg.Host, InsecureSkipVerify: cfg.SkipVerify} //nolint:gosec // admin opt-in for self-signed certificates + dialer := &net.Dialer{Timeout: smtpTimeout} + implicitTLS := cfg.Port == "465" && cfg.TLS + + var conn net.Conn + if implicitTLS { + conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg) + } else { + conn, err = dialer.Dial("tcp", addr) + } + if err != nil { + return &smtpStepError{"Verbindung", err} + } + _ = conn.SetDeadline(time.Now().Add(2 * smtpTimeout)) + + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + _ = conn.Close() + return &smtpStepError{"Begrüßung", err} + } + defer client.Close() + + if err := client.Hello(ehloName(cfg.From)); err != nil { + return &smtpStepError{"EHLO", err} + } + if cfg.TLS && !implicitTLS { + if ok, _ := client.Extension("STARTTLS"); !ok { + return &smtpStepError{"STARTTLS", fmt.Errorf("der Server bietet kein STARTTLS an (TLS deaktivieren oder Port 465 nutzen)")} + } + if err := client.StartTLS(tlsCfg); err != nil { + return &smtpStepError{"STARTTLS", err} + } + } + if cfg.User != "" || cfg.Pass != "" { + if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)); err != nil { + return &smtpStepError{"AUTH", err} + } + } + if err := client.Mail(fromAddr); err != nil { + return &smtpStepError{"MAIL FROM", err} + } + if err := client.Rcpt(toAddr); err != nil { + return &smtpStepError{"RCPT TO", err} + } + wc, err := client.Data() + if err != nil { + return &smtpStepError{"DATA", err} + } + if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil { + _ = wc.Close() + return &smtpStepError{"DATA", err} + } + if err := wc.Close(); err != nil { + // The server answers after the final "." – content filters reject here + return &smtpStepError{"Nachricht annehmen", err} + } + _ = client.Quit() + return nil +} diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go new file mode 100644 index 0000000..4be8459 --- /dev/null +++ b/internal/api/mail_test.go @@ -0,0 +1,215 @@ +package api + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" +) + +// fakeSMTP is a minimal ESMTP server with STARTTLS (self-signed) and AUTH PLAIN. +type fakeSMTP struct { + addr string + rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO + + mu sync.Mutex + ehlo string + mailFrom string + rcptTo string + data string +} + +func selfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "mail.intern"}, + DNSNames: []string{"mail.intern"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("cert: %v", err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode} + tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}} + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go f.serve(conn, tlsCfg) + } + }() + return f +} + +func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) { + defer conn.Close() + r, w := bufio.NewReader(conn), bufio.NewWriter(conn) + reply := func(s string) { _, _ = w.WriteString(s + "\r\n"); _ = w.Flush() } + reply("220 fake ESMTP") + secure := false + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + cmd := strings.TrimRight(line, "\r\n") + upper := strings.ToUpper(cmd) + switch { + case strings.HasPrefix(upper, "EHLO "): + f.mu.Lock() + f.ehlo = cmd[5:] + f.mu.Unlock() + if secure { + reply("250-fake\r\n250 AUTH PLAIN") + } else { + reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN") + } + case upper == "STARTTLS": + reply("220 go ahead") + tlsConn := tls.Server(conn, tlsCfg) + if err := tlsConn.Handshake(); err != nil { + return + } + conn, secure = tlsConn, true + r, w = bufio.NewReader(conn), bufio.NewWriter(conn) + case strings.HasPrefix(upper, "AUTH "): + reply("235 ok") + case strings.HasPrefix(upper, "MAIL FROM"): + f.mu.Lock() + f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):]) + f.mu.Unlock() + reply("250 ok") + case strings.HasPrefix(upper, "RCPT TO"): + f.mu.Lock() + f.rcptTo = strings.TrimSpace(cmd[len("RCPT TO:"):]) + f.mu.Unlock() + if f.rcptCode != "" { + reply(f.rcptCode) + } else { + reply("250 ok") + } + case upper == "DATA": + reply("354 go") + var sb strings.Builder + for { + l, err := r.ReadString('\n') + if err != nil { + return + } + if l == ".\r\n" { + break + } + sb.WriteString(l) + } + f.mu.Lock() + f.data = sb.String() + f.mu.Unlock() + reply("250 queued") + case upper == "QUIT": + reply("221 bye") + return + default: + reply("250 ok") + } + } +} + +func (f *fakeSMTP) settings(skipVerify bool) smtpSettings { + host, port, _ := net.SplitHostPort(f.addr) + return smtpSettings{Host: host, Port: port, User: "alarm@firma.de", Pass: "geheim", From: "alarm@firma.de", TLS: true, SkipVerify: skipVerify} +} + +func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { + f := startFakeSMTP(t, "") + + // The certificate is self-signed: refused unless verification is switched off + err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo") + var stepErr *smtpStepError + if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" { + t.Fatalf("expected a STARTTLS error with verification on, got %v", err) + } + + if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil { + t.Fatalf("send with SkipVerify failed: %v", err) + } + f.mu.Lock() + ehlo, data := f.ehlo, f.data + f.mu.Unlock() + + if ehlo != "firma.de" { + t.Errorf("EHLO should name the sender domain, got %q", ehlo) + } + for _, header := range []string{"Date: ", "Message-ID: <", "Content-Transfer-Encoding: quoted-printable"} { + if !strings.Contains(data, header) { + t.Errorf("message lacks %q:\n%s", header, data) + } + } + if !strings.Contains(data, "Subject: =?utf-8?q?") { + t.Errorf("subject with emoji/umlauts must be RFC 2047 encoded:\n%s", data) + } + if strings.Contains(data, "fällig") { + t.Errorf("raw non-ASCII must not appear in the message:\n%s", data) + } +} + +func TestSendMailReportsTheRefusingStep(t *testing.T) { + f := startFakeSMTP(t, "451 4.3.2 Internal server error") + err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo") + var stepErr *smtpStepError + if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") { + t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err) + } +} + +// The admin UI suggests "MindSift " as sender; the envelope must carry the bare address +func TestSendMailWithDisplayNameSender(t *testing.T) { + f := startFakeSMTP(t, "") + cfg := f.settings(true) + cfg.From = "MindSift Alarme " + if err := sendMail(cfg, "Anna ", "Test", "Hallo"); err != nil { + t.Fatalf("send failed: %v", err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.mailFrom != "" || f.rcptTo != "" { + t.Errorf("envelope must hold bare addresses, got MAIL FROM %q, RCPT TO %q", f.mailFrom, f.rcptTo) + } + if !strings.Contains(f.data, `From: "MindSift Alarme" `) { + t.Errorf("header From should keep the display name:\n%s", f.data) + } +} + +func TestEHLONameNeverLocalhost(t *testing.T) { + if got := ehloName("Alarm "); got != "mail.example.org" { + t.Errorf("got %q", got) + } + if got := ehloName("noreply"); got == "localhost" || got == "" { + t.Errorf("fallback must not be localhost, got %q", got) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 510d052..146d355 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 0eab1c4..915ce29 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,8 @@ type Config struct { SMTPPass string SMTPFrom string SMTPTLS bool + // SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers) + SMTPSkipVerify bool } func Load() *Config { @@ -60,6 +62,7 @@ func Load() *Config { smtpPass := getEnv("SMTP_PASS", "") smtpFrom := getEnv("SMTP_FROM", "") smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false" + smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true" return &Config{ Port: port, @@ -77,6 +80,7 @@ func Load() *Config { SMTPPass: smtpPass, SMTPFrom: smtpFrom, SMTPTLS: smtpTLS, + SMTPSkipVerify: smtpSkipVerify, } } diff --git a/internal/domain/models.go b/internal/domain/models.go index bc14359..0b21eb2 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -285,6 +285,7 @@ type SystemSettings struct { SMTPPass string `json:"smtpPass"` SMTPFrom string `json:"smtpFrom"` SMTPTLS bool `json:"smtpTls"` + SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 4317405..4d026f4 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -493,7 +493,8 @@ func (s *Store) migrate() error { ('smtp_user', ''), ('smtp_pass', ''), ('smtp_from', ''), - ('smtp_tls', 'true')`) + ('smtp_tls', 'true'), + ('smtp_skip_verify', 'false')`) // Clean up legacy roles in team_members and users _, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`) @@ -5037,7 +5038,7 @@ func (s *Store) DeleteUserInvite(inviteID string) error { // --- SYSTEM SETTINGS --- -func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls bool) { +func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) { cur, err := s.GetSystemSettings() if err != nil || cur == nil { return @@ -5052,6 +5053,7 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls cur.SMTPPass = pass cur.SMTPFrom = strings.TrimSpace(from) cur.SMTPTLS = tls + cur.SMTPSkipVerify = skipVerify _ = s.UpdateSystemSettings(cur) } } @@ -5095,6 +5097,8 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings.SMTPFrom = v case "smtp_tls": settings.SMTPTLS = (v != "false" && v != "0") + case "smtp_skip_verify": + settings.SMTPSkipVerify = v == "true" } } } @@ -5110,6 +5114,10 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { if settings.SMTPTLS { tlsStr = "true" } + skipVerifyStr := "false" + if settings.SMTPSkipVerify { + skipVerifyStr = "true" + } if settings.SMTPPort == "" { settings.SMTPPort = "587" } @@ -5126,7 +5134,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { ('smtp_user', ?), ('smtp_pass', ?), ('smtp_from', ?), - ('smtp_tls', ?) + ('smtp_tls', ?), + ('smtp_skip_verify', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settings.SharingMode, regStr, @@ -5137,6 +5146,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { settings.SMTPPass, strings.TrimSpace(settings.SMTPFrom), tlsStr, + skipVerifyStr, ) return err }