- 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>
190 lines
6.3 KiB
Go
190 lines
6.3 KiB
Go
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
|
||
}
|