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
|
||||
}
|
||||
Reference in new issue
Block a user