MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
A bare SMTP_FROM address gets the display name in the From header; a name already in SMTP_FROM wins, the envelope keeps the bare address. Also a field in the admin mail settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
10 KiB
Go
292 lines
10 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"crypto/tls"
|
||
"encoding/hex"
|
||
"errors"
|
||
"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
|
||
FromName string // display name when From is a bare address
|
||
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
|
||
SkipVerify bool // accept self-signed / mismatching certificates
|
||
|
||
authMech string // "" = automatic; "LOGIN" forces AUTH LOGIN (fallback after a refused PLAIN)
|
||
}
|
||
|
||
// 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),
|
||
FromName: strings.TrimSpace(sys.SMTPFromName),
|
||
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),
|
||
FromName: strings.TrimSpace(s.cfg.SMTPFromName),
|
||
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.FromName == "" {
|
||
cfg.FromName = "MindSift"
|
||
}
|
||
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
|
||
}
|
||
|
||
// senderAddress is splitAddress for the sender: a bare address gets the display name, so mail clients
|
||
// show "MindSift" instead of only the address. A name already in SMTP_FROM wins.
|
||
func senderAddress(raw, name 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)
|
||
}
|
||
if addr.Name == "" {
|
||
addr.Name = strings.TrimSpace(name)
|
||
}
|
||
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()
|
||
}
|
||
|
||
// loginAuth implements AUTH LOGIN (username and password as separate Base64 challenges). Some servers,
|
||
// e.g. Postfix with Dovecot SASL or Exchange, fail on PLAIN but accept LOGIN.
|
||
type loginAuth struct{ user, pass, host string }
|
||
|
||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||
// Same rule as smtp.PlainAuth: never send credentials unencrypted to a remote host
|
||
if !server.TLS && a.host != "localhost" && a.host != "127.0.0.1" && a.host != "::1" {
|
||
return "", nil, fmt.Errorf("unverschlüsselte Verbindung – Anmeldung verweigert")
|
||
}
|
||
return "LOGIN", nil, nil
|
||
}
|
||
|
||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||
if !more {
|
||
return nil, nil
|
||
}
|
||
switch prompt := strings.ToLower(strings.TrimSpace(string(fromServer))); {
|
||
case strings.Contains(prompt, "username"), strings.Contains(prompt, "user"):
|
||
return []byte(a.user), nil
|
||
case strings.Contains(prompt, "password"), strings.Contains(prompt, "pass"):
|
||
return []byte(a.pass), nil
|
||
default:
|
||
return nil, fmt.Errorf("unerwartete AUTH-LOGIN-Frage %q", prompt)
|
||
}
|
||
}
|
||
|
||
// authAttemptError records which mechanism failed and whether LOGIN would have been an alternative.
|
||
type authAttemptError struct {
|
||
mech string
|
||
loginOffered bool
|
||
err error
|
||
}
|
||
|
||
func (e *authAttemptError) Error() string { return e.mech + ": " + e.err.Error() }
|
||
func (e *authAttemptError) Unwrap() error { return e.err }
|
||
|
||
// authenticate uses PLAIN, or LOGIN when the server offers only that (or when cfg.authMech forces it).
|
||
// net/smtp closes the connection after a failed AUTH, so a fallback needs a new connection (sendMail).
|
||
func authenticate(client *smtp.Client, cfg smtpSettings) error {
|
||
_, mechs := client.Extension("AUTH")
|
||
offered := " " + strings.ToUpper(mechs) + " "
|
||
hasLogin := strings.Contains(offered, " LOGIN ")
|
||
mech := cfg.authMech
|
||
if mech == "" {
|
||
mech = "PLAIN"
|
||
if hasLogin && !strings.Contains(offered, " PLAIN ") {
|
||
mech = "LOGIN"
|
||
}
|
||
}
|
||
var a smtp.Auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
|
||
if mech == "LOGIN" {
|
||
a = &loginAuth{user: cfg.User, pass: cfg.Pass, host: cfg.Host}
|
||
}
|
||
if err := client.Auth(a); err != nil {
|
||
return &authAttemptError{mech: mech, loginOffered: hasLogin, err: err}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
|
||
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
|
||
func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||
err := sendMailOnce(cfg, to, subject, body)
|
||
var first *authAttemptError
|
||
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
|
||
return err
|
||
}
|
||
cfg.authMech = "LOGIN"
|
||
retryErr := sendMailOnce(cfg, to, subject, body)
|
||
var second *authAttemptError
|
||
if retryErr == nil || !errors.As(retryErr, &second) {
|
||
return retryErr
|
||
}
|
||
combined := fmt.Errorf("%v; %v", first, second)
|
||
if strings.Contains(first.err.Error(), "451") && strings.Contains(second.err.Error(), "451") {
|
||
combined = fmt.Errorf("%w (der Mailserver meldet einen internen Fehler bei der Anmeldung – bitte dessen SASL/Dovecot-Logs prüfen)", combined)
|
||
}
|
||
return &smtpStepError{"AUTH", combined}
|
||
}
|
||
|
||
// sendMailOnce 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 sendMailOnce(cfg smtpSettings, to, subject, body string) error {
|
||
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
|
||
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 := authenticate(client, cfg); 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
|
||
}
|