mailer.go
⎇
Raw
1// Package mailer verschickt E-Mails (Text + HTML) über SMTP, nur mit der Standardbibliothek.
2package mailer
3
4import (
5 "bytes"
6 "context"
7 "crypto/rand"
8 "crypto/tls"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "mime"
13 "mime/multipart"
14 "mime/quotedprintable"
15 "net"
16 "net/mail"
17 "net/smtp"
18 "net/textproto"
19 "strconv"
20 "strings"
21 "time"
22
23 "forge.gpl-nas.de/physiobase/internal/config"
24)
25
26type Message struct {
27 To, Subject, Text, HTML string
28 FromName string
29}
30
31// ValidAddress prüft eine einzelne Empfängeradresse (keine Listen, keine Header-Injektion).
32func ValidAddress(s string) bool {
33 if strings.ContainsAny(s, "\r\n,;") {
34 return false
35 }
36 a, err := mail.ParseAddress(s)
37 return err == nil && a.Address == s
38}
39
40// Build erzeugt die komplette MIME-Nachricht (multipart/alternative).
41func Build(cfg config.SMTP, m Message, now time.Time) ([]byte, error) {
42 if !ValidAddress(m.To) {
43 return nil, fmt.Errorf("ungültige Empfängeradresse")
44 }
45 from := (&mail.Address{Name: m.FromName, Address: cfg.From}).String()
46 domain := cfg.From[strings.LastIndex(cfg.From, "@")+1:]
47 id := make([]byte, 12)
48 if _, err := rand.Read(id); err != nil {
49 return nil, err
50 }
51
52 var body bytes.Buffer
53 mw := multipart.NewWriter(&body)
54 for _, part := range []struct{ ctype, content string }{{"text/plain", m.Text}, {"text/html", m.HTML}} {
55 if part.content == "" {
56 continue
57 }
58 pw, err := mw.CreatePart(textproto.MIMEHeader{
59 "Content-Type": {part.ctype + "; charset=utf-8"},
60 "Content-Transfer-Encoding": {"quoted-printable"},
61 })
62 if err != nil {
63 return nil, err
64 }
65 qp := quotedprintable.NewWriter(pw)
66 if _, err := qp.Write([]byte(part.content)); err != nil {
67 return nil, err
68 }
69 if err := qp.Close(); err != nil {
70 return nil, err
71 }
72 }
73 if err := mw.Close(); err != nil {
74 return nil, err
75 }
76
77 var msg bytes.Buffer
78 h := func(k, v string) { fmt.Fprintf(&msg, "%s: %s\r\n", k, v) }
79 h("From", from)
80 h("To", m.To)
81 h("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
82 h("Date", now.Format(time.RFC1123Z))
83 h("Message-ID", "<"+hex.EncodeToString(id)+"@"+domain+">")
84 h("MIME-Version", "1.0")
85 h("Content-Type", "multipart/alternative; boundary="+mw.Boundary())
86 msg.WriteString("\r\n")
87 msg.Write(body.Bytes())
88 return msg.Bytes(), nil
89}
90
91// Send verbindet sich mit dem SMTP-Server und liefert die Nachricht aus.
92func Send(ctx context.Context, cfg config.SMTP, m Message) error {
93 if !cfg.Enabled() {
94 return errors.New("E-Mail-Versand ist nicht eingerichtet (PHYSIOBASE_SMTP_*)")
95 }
96 raw, err := Build(cfg, m, time.Now())
97 if err != nil {
98 return err
99 }
100 addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
101 ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
102 defer cancel()
103 d := &net.Dialer{}
104 tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
105
106 var conn net.Conn
107 if cfg.Security == "ssl" {
108 conn, err = (&tls.Dialer{NetDialer: d, Config: tlsCfg}).DialContext(ctx, "tcp", addr)
109 } else {
110 conn, err = d.DialContext(ctx, "tcp", addr)
111 }
112 if err != nil {
113 return fmt.Errorf("verbindung zu %s: %w", addr, err)
114 }
115 if dl, ok := ctx.Deadline(); ok {
116 _ = conn.SetDeadline(dl)
117 }
118 c, err := smtp.NewClient(conn, cfg.Host)
119 if err != nil {
120 _ = conn.Close()
121 return err
122 }
123 defer func() { _ = c.Close() }()
124 if cfg.Security == "starttls" {
125 if err := c.StartTLS(tlsCfg); err != nil {
126 return fmt.Errorf("starttls: %w", err)
127 }
128 }
129 if cfg.User != "" {
130 // PlainAuth verweigert unverschlüsselte Verbindungen außer zu localhost.
131 if err := c.Auth(smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)); err != nil {
132 return fmt.Errorf("anmeldung: %w", err)
133 }
134 }
135 if err := c.Mail(cfg.From); err != nil {
136 return fmt.Errorf("absender: %w", err)
137 }
138 if err := c.Rcpt(m.To); err != nil {
139 return fmt.Errorf("empfänger: %w", err)
140 }
141 w, err := c.Data()
142 if err != nil {
143 return err
144 }
145 if _, err := w.Write(raw); err != nil {
146 return err
147 }
148 if err := w.Close(); err != nil {
149 return fmt.Errorf("versand: %w", err)
150 }
151 return c.Quit()
152}
153