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:
1 parent
bf0d68e7e1
commit
ce321ce579
15 files changed
+477
-96
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-79
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user