// Package mailer verschickt E-Mails (Text + HTML) über SMTP, nur mit der Standardbibliothek.
package mailer
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
"forge.gpl-nas.de/physiobase/internal/config"
)
type Message struct {
To, Subject, Text, HTML string
FromName string
}
// ValidAddress prüft eine einzelne Empfängeradresse (keine Listen, keine Header-Injektion).
func ValidAddress(s string) bool {
if strings.ContainsAny(s, "\r\n,;") {
return false
}
a, err := mail.ParseAddress(s)
return err == nil && a.Address == s
}
// Build erzeugt die komplette MIME-Nachricht (multipart/alternative).
func Build(cfg config.SMTP, m Message, now time.Time) ([]byte, error) {
if !ValidAddress(m.To) {
return nil, fmt.Errorf("ungültige Empfängeradresse")
}
from := (&mail.Address{Name: m.FromName, Address: cfg.From}).String()
domain := cfg.From[strings.LastIndex(cfg.From, "@")+1:]
id := make([]byte, 12)
if _, err := rand.Read(id); err != nil {
return nil, err
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
for _, part := range []struct{ ctype, content string }{{"text/plain", m.Text}, {"text/html", m.HTML}} {
if part.content == "" {
continue
}
pw, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {part.ctype + "; charset=utf-8"},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
return nil, err
}
qp := quotedprintable.NewWriter(pw)
if _, err := qp.Write([]byte(part.content)); err != nil {
return nil, err
}
if err := qp.Close(); err != nil {
return nil, err
}
}
if err := mw.Close(); err != nil {
return nil, err
}
var msg bytes.Buffer
h := func(k, v string) { fmt.Fprintf(&msg, "%s: %s\r\n", k, v) }
h("From", from)
h("To", m.To)
h("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
h("Date", now.Format(time.RFC1123Z))
h("Message-ID", "<"+hex.EncodeToString(id)+"@"+domain+">")
h("MIME-Version", "1.0")
h("Content-Type", "multipart/alternative; boundary="+mw.Boundary())
msg.WriteString("\r\n")
msg.Write(body.Bytes())
return msg.Bytes(), nil
}
// Send verbindet sich mit dem SMTP-Server und liefert die Nachricht aus.
func Send(ctx context.Context, cfg config.SMTP, m Message) error {
if !cfg.Enabled() {
return errors.New("E-Mail-Versand ist nicht eingerichtet (PHYSIOBASE_SMTP_*)")
}
raw, err := Build(cfg, m, time.Now())
if err != nil {
return err
}
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
d := &net.Dialer{}
tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
var conn net.Conn
if cfg.Security == "ssl" {
conn, err = (&tls.Dialer{NetDialer: d, Config: tlsCfg}).DialContext(ctx, "tcp", addr)
} else {
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return fmt.Errorf("verbindung zu %s: %w", addr, err)
}
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
_ = conn.Close()
return err
}
defer func() { _ = c.Close() }()
if cfg.Security == "starttls" {
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if cfg.User != "" {
// PlainAuth verweigert unverschlüsselte Verbindungen außer zu localhost.
if err := c.Auth(smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)); err != nil {
return fmt.Errorf("anmeldung: %w", err)
}
}
if err := c.Mail(cfg.From); err != nil {
return fmt.Errorf("absender: %w", err)
}
if err := c.Rcpt(m.To); err != nil {
return fmt.Errorf("empfänger: %w", err)
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("versand: %w", err)
}
return c.Quit()
}