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
+474 -93

No files matched your search

+2
View File
@@ -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
+1
View File
@@ -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.
+9 -1
View File
@@ -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 <alarm@domain>`): 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)).
+1
View File
@@ -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).
+1 -1
View File
@@ -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...")
+1
View File
@@ -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}
+1
View File
@@ -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}
@@ -100,6 +100,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpPass: '',
smtpFrom: '',
smtpTls: true,
smtpSkipVerify: false,
smtpConfiguredViaEnv: false,
});
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
@@ -129,6 +130,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
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<AdminSettingsViewProps> = ({
/>
</div>
<div className="flex items-center pt-5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
<p className="text-[11px] text-muted">
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
</p>
</div>
<ToggleSwitch
checked={settings.smtpTls ?? true}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
}
className="w-4 h-4 checkbox-square cursor-pointer"
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
/>
<span className="text-main font-medium">
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
</span>
</label>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
<p className="text-[11px] text-muted">
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
</p>
</div>
<ToggleSwitch
checked={settings.smtpSkipVerify ?? false}
disabled={!(settings.smtpTls ?? true)}
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
/>
</div>
</div>
</div>
+1
View File
@@ -159,6 +159,7 @@ export interface SystemSettings {
smtpPass?: string;
smtpFrom?: string;
smtpTls?: boolean;
smtpSkipVerify?: boolean;
smtpConfiguredViaEnv?: boolean;
}
+189
View File
@@ -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 <alarm@firma.de>" 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
}
+215
View File
@@ -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 <alerts@domain>" 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 <alarm@firma.de>"
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil {
t.Fatalf("send failed: %v", err)
}
f.mu.Lock()
defer f.mu.Unlock()
if f.mailFrom != "<alarm@firma.de>" || f.rcptTo != "<anna@example.com>" {
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" <alarm@firma.de>`) {
t.Errorf("header From should keep the display name:\n%s", f.data)
}
}
func TestEHLONameNeverLocalhost(t *testing.T) {
if got := ehloName("Alarm <alarm@mail.example.org>"); 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)
}
}
+8 -76
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)
cfg, err := s.smtpSettings()
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
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
}
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()
return err
}
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
return nil
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
+4
View File
@@ -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,
}
}
+1
View File
@@ -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"`
}
+13 -3
View File
@@ -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
}