// 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()) }