veylant/internal/notifications/mailer.go
2026-03-10 12:01:34 +01:00

103 lines
2.5 KiB
Go

// Package notifications provides SMTP email delivery for budget alert notifications.
package notifications
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
)
// Config holds SMTP relay configuration.
type Config struct {
Host string
Port int
Username string
Password string
From string
FromName string
}
// Mailer sends transactional emails via SMTP (STARTTLS, PLAIN auth).
// Tested against Mailtrap sandbox; compatible with any RFC 4954 relay.
type Mailer struct {
cfg Config
}
// New creates a Mailer. Returns an error if the config is obviously invalid.
func New(cfg Config) (*Mailer, error) {
if cfg.Host == "" {
return nil, fmt.Errorf("smtp host is required")
}
if cfg.Port == 0 {
cfg.Port = 587
}
return &Mailer{cfg: cfg}, nil
}
// Message is a single outbound email.
type Message struct {
To string // single recipient address
Subject string
HTML string // HTML body
}
// Send delivers msg via SMTP with STARTTLS + PLAIN auth.
func (m *Mailer) Send(msg Message) error {
from := m.cfg.From
if m.cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", m.cfg.FromName, m.cfg.From)
}
headers := strings.Join([]string{
fmt.Sprintf("From: %s", from),
fmt.Sprintf("To: %s", msg.To),
fmt.Sprintf("Subject: %s", msg.Subject),
"MIME-Version: 1.0",
`Content-Type: text/html; charset="UTF-8"`,
"",
"",
}, "\r\n")
body := []byte(headers + msg.HTML)
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
conn, err := net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial %s: %w", addr, err)
}
client, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer client.Close() //nolint:errcheck
// Upgrade to TLS via STARTTLS if the server supports it (all Mailtrap ports do).
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if err = client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err = client.Mail(m.cfg.From); err != nil {
return fmt.Errorf("smtp MAIL FROM: %w", err)
}
if err = client.Rcpt(msg.To); err != nil {
return fmt.Errorf("smtp RCPT TO: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("smtp DATA: %w", err)
}
if _, err = wc.Write(body); err != nil {
return fmt.Errorf("smtp write body: %w", err)
}
return wc.Close()
}