Files
MindSift/internal/api/mail.go
T
sandrodachandClaude Opus 5.5 46e1137f18
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
feat(mail): one layout for all mails with logo, greeting, cards and footer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:32:33 +01:00

327 lines
12 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/base64"
"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, html 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")
part := func(contentType, text string) {
fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType)
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf)
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n")))
_ = qp.Close()
buf.WriteString("\r\n")
}
if html == "" {
part("text/plain", body)
return buf.Bytes()
}
// Text for simple clients, HTML for the rest
boundary := "ms-" + hex.EncodeToString(idBytes)
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
part("text/plain", body)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
if strings.Contains(html, "cid:"+mailLogoCID) {
// The HTML shows the logo from inside the mail: HTML and image together as multipart/related
related := boundary + "-rel"
fmt.Fprintf(&buf, "Content-Type: multipart/related; boundary=\"%s\"\r\n\r\n", related)
fmt.Fprintf(&buf, "--%s\r\n", related)
part("text/html", html)
fmt.Fprintf(&buf, "--%s\r\n", related)
buf.WriteString("Content-Type: image/png; name=\"mindsift.png\"\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
fmt.Fprintf(&buf, "Content-ID: <%s>\r\n", mailLogoCID)
buf.WriteString("Content-Disposition: inline; filename=\"mindsift.png\"\r\n\r\n")
enc := base64.StdEncoding.EncodeToString(mailLogoPNG)
for len(enc) > 76 {
buf.WriteString(enc[:76] + "\r\n")
enc = enc[76:]
}
buf.WriteString(enc + "\r\n")
fmt.Fprintf(&buf, "--%s--\r\n", related)
} else {
part("text/html", html)
}
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
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, html string) error {
err := sendMailOnce(cfg, to, subject, body, html)
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, html)
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, html 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, html, 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
}