2bab61209c
Go-Modul in go.mod und allen 45 Go-Dateien umbenannt.
168 lines
4.4 KiB
Go
168 lines
4.4 KiB
Go
// Package mailer sends transactional emails via an outbound SMTP relay.
|
|
// It uses only the Go standard library (net/smtp + crypto/tls) to avoid
|
|
// adding external dependencies.
|
|
package mailer
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"archivmail/config"
|
|
)
|
|
|
|
// Mailer sends transactional emails via the configured SMTP-Out relay.
|
|
type Mailer struct {
|
|
mu sync.RWMutex
|
|
cfg config.SMTPOutConfig
|
|
}
|
|
|
|
// New creates a Mailer from the smtp_out config section.
|
|
func New(cfg config.SMTPOutConfig) *Mailer {
|
|
return &Mailer{cfg: cfg}
|
|
}
|
|
|
|
// Reload replaces the runtime configuration without restarting the process.
|
|
func (m *Mailer) Reload(cfg config.SMTPOutConfig) {
|
|
m.mu.Lock()
|
|
m.cfg = cfg
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// IsConfigured returns true when the smtp_out config is usable (host + from set).
|
|
func (m *Mailer) IsConfigured() bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.cfg.Host != "" && m.cfg.From != ""
|
|
}
|
|
|
|
// Send sends an HTML + plaintext email to a single recipient.
|
|
func (m *Mailer) Send(to, subject, htmlBody, textBody string) error {
|
|
m.mu.RLock()
|
|
cfg := m.cfg
|
|
m.mu.RUnlock()
|
|
|
|
if cfg.Host == "" || cfg.From == "" {
|
|
return fmt.Errorf("mailer: smtp_out not configured")
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.Host, port(cfg.Port))
|
|
|
|
msg := buildMIME(cfg.From, to, subject, htmlBody, textBody)
|
|
|
|
var auth smtp.Auth
|
|
if cfg.User != "" {
|
|
auth = smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
|
|
}
|
|
|
|
if cfg.TLS {
|
|
return sendTLS(addr, cfg.Host, auth, cfg.From, to, msg)
|
|
}
|
|
return sendSTARTTLS(addr, auth, cfg.From, to, msg)
|
|
}
|
|
|
|
func port(p int) int {
|
|
if p == 0 {
|
|
return 587
|
|
}
|
|
return p
|
|
}
|
|
|
|
// sendTLS connects directly via TLS (port 465).
|
|
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
|
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsCfg)
|
|
if err != nil {
|
|
return fmt.Errorf("mailer: tls dial: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
c, err := smtp.NewClient(conn, host)
|
|
if err != nil {
|
|
return fmt.Errorf("mailer: smtp client: %w", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if auth != nil {
|
|
if err := c.Auth(auth); err != nil {
|
|
return fmt.Errorf("mailer: auth: %w", err)
|
|
}
|
|
}
|
|
return send(c, from, to, msg)
|
|
}
|
|
|
|
// sendSTARTTLS connects plain and upgrades via STARTTLS (port 587).
|
|
func sendSTARTTLS(addr string, auth smtp.Auth, from, to string, msg []byte) error {
|
|
c, err := smtp.Dial(addr)
|
|
if err != nil {
|
|
return fmt.Errorf("mailer: dial: %w", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
host, _, _ := net.SplitHostPort(addr)
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
|
if err := c.StartTLS(tlsCfg); err != nil {
|
|
return fmt.Errorf("mailer: starttls: %w", err)
|
|
}
|
|
}
|
|
if auth != nil {
|
|
if err := c.Auth(auth); err != nil {
|
|
return fmt.Errorf("mailer: auth: %w", err)
|
|
}
|
|
}
|
|
return send(c, from, to, msg)
|
|
}
|
|
|
|
func send(c *smtp.Client, from, to string, msg []byte) error {
|
|
if err := c.Mail(from); err != nil {
|
|
return fmt.Errorf("mailer: MAIL FROM: %w", err)
|
|
}
|
|
if err := c.Rcpt(to); err != nil {
|
|
return fmt.Errorf("mailer: RCPT TO: %w", err)
|
|
}
|
|
wc, err := c.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("mailer: DATA: %w", err)
|
|
}
|
|
defer wc.Close()
|
|
if _, err := wc.Write(msg); err != nil {
|
|
return fmt.Errorf("mailer: write: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildMIME constructs a multipart/alternative MIME message.
|
|
func buildMIME(from, to, subject, htmlBody, textBody string) []byte {
|
|
boundary := "----=archivmail_boundary_20260101"
|
|
var b strings.Builder
|
|
|
|
b.WriteString("From: " + from + "\r\n")
|
|
b.WriteString("To: " + to + "\r\n")
|
|
b.WriteString("Subject: " + subject + "\r\n")
|
|
b.WriteString("MIME-Version: 1.0\r\n")
|
|
b.WriteString(`Content-Type: multipart/alternative; boundary="` + boundary + `"` + "\r\n")
|
|
b.WriteString("\r\n")
|
|
|
|
// Plaintext part
|
|
b.WriteString("--" + boundary + "\r\n")
|
|
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
|
b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
|
b.WriteString("\r\n")
|
|
b.WriteString(textBody + "\r\n")
|
|
|
|
// HTML part
|
|
b.WriteString("--" + boundary + "\r\n")
|
|
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
|
b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
|
b.WriteString("\r\n")
|
|
b.WriteString(htmlBody + "\r\n")
|
|
|
|
b.WriteString("--" + boundary + "--\r\n")
|
|
return []byte(b.String())
|
|
}
|