Files
MindSift/internal/api/mail.go
T
sandrodachandClaude Opus 5.5 ce321ce579 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>
2026-10-28 22:28:26 +01:00

190 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}