// Package mailer sends transactional email for the user-management // flows (password reset, etc). The Sender interface lets handlers // inject a fake in tests; SMTPSender is the production implementation // that reads SMTP config from the smtp_config singleton at send time // so admin edits take effect without a server restart. package mailer import ( "bytes" "context" "crypto/tls" "errors" "fmt" "log/slog" "net" "net/smtp" "strconv" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // ErrNotConfigured is returned when smtp_config.enabled is false or // required fields are empty. Handlers that depend on email send // (forgot-password) treat this as a soft failure — log it and pretend // the send succeeded so users don't enumerate which emails are on // file. var ErrNotConfigured = errors.New("mailer: SMTP not configured") // Sender is the interface every caller of the mailer uses. // Implementations: SMTPSender (production), FakeSender (tests). type Sender interface { Send(ctx context.Context, to, subject, textBody, htmlBody string) error } // SMTPSender is the production implementation. Reads smtp_config from // the database at send time so config changes apply without restart. type SMTPSender struct { pool *pgxpool.Pool logger *slog.Logger } func NewSMTPSender(pool *pgxpool.Pool, logger *slog.Logger) *SMTPSender { return &SMTPSender{pool: pool, logger: logger} } // Send composes a multipart/alternative message with text + html and // dispatches it via SMTP. Returns ErrNotConfigured when smtp_config // is disabled or missing required fields; returns the underlying // network/auth error otherwise. func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody string) error { q := dbq.New(s.pool) cfg, err := q.GetSMTPConfig(ctx) if err != nil { return fmt.Errorf("mailer: load config: %w", err) } if !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" { return ErrNotConfigured } msg := composeMessage(cfg, to, subject, textBody, htmlBody) addr := net.JoinHostPort(cfg.Host, strconv.Itoa(int(cfg.Port))) var auth smtp.Auth if cfg.Username != "" { auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) } // We respect the use_tls flag: if true, we require STARTTLS via // smtp.SendMail's default behavior (which negotiates STARTTLS when // the server advertises it). For implicit-TLS (port 465 style), // we'd need a custom dial — skip that complexity for v1 and rely on // STARTTLS via port 587 (the default). dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() _ = dialCtx // smtp.SendMail doesn't accept ctx; the timeout is enforced via Dialer below if err := sendMail(addr, cfg, auth, []string{to}, msg); err != nil { s.logger.Error("mailer: send failed", "to", to, "host", cfg.Host, "err", err) return fmt.Errorf("mailer: send: %w", err) } return nil } // sendMail wraps net/smtp's SendMail with optional TLS verification. // Mostly identical to smtp.SendMail but explicitly handles the // use_tls flag. func sendMail(addr string, cfg dbq.SmtpConfig, auth smtp.Auth, to []string, msg []byte) error { c, err := smtp.Dial(addr) if err != nil { return err } defer func() { _ = c.Close() }() if cfg.UseTls { if ok, _ := c.Extension("STARTTLS"); ok { tlsConfig := &tls.Config{ServerName: cfg.Host} if err := c.StartTLS(tlsConfig); err != nil { return err } } } if auth != nil { if ok, _ := c.Extension("AUTH"); ok { if err := c.Auth(auth); err != nil { return err } } } if err := c.Mail(cfg.FromAddress); err != nil { return err } for _, addr := range to { if err := c.Rcpt(addr); err != nil { return err } } w, err := c.Data() if err != nil { return err } if _, err := w.Write(msg); err != nil { return err } if err := w.Close(); err != nil { return err } return c.Quit() } // composeMessage builds an RFC 5322 multipart/alternative message // with the from/subject headers and both text + html bodies. Boundary // is timestamp-based to keep the implementation dependency-free. func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string) []byte { var buf bytes.Buffer boundary := fmt.Sprintf("minstrel-%d", time.Now().UnixNano()) from := cfg.FromAddress if cfg.FromName != "" { from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromAddress) } fmt.Fprintf(&buf, "From: %s\r\n", from) fmt.Fprintf(&buf, "To: %s\r\n", to) fmt.Fprintf(&buf, "Subject: %s\r\n", subject) fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n") fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary) fmt.Fprintf(&buf, "--%s\r\n", boundary) fmt.Fprintf(&buf, "Content-Type: text/plain; charset=\"utf-8\"\r\n\r\n") buf.WriteString(textBody) buf.WriteString("\r\n") fmt.Fprintf(&buf, "--%s\r\n", boundary) fmt.Fprintf(&buf, "Content-Type: text/html; charset=\"utf-8\"\r\n\r\n") buf.WriteString(htmlBody) buf.WriteString("\r\n") fmt.Fprintf(&buf, "--%s--\r\n", boundary) return buf.Bytes() } // SentEmail is the in-memory record FakeSender keeps. Tests assert // against these. type SentEmail struct { To string Subject string TextBody string HTMLBody string } // FakeSender records calls instead of sending. Safe for concurrent use. type FakeSender struct { mu sync.Mutex Sent []SentEmail // FailNext, when true, makes the next Send call return an error // instead of recording. Useful for testing send-failure paths. FailNext error } func (f *FakeSender) Send(_ context.Context, to, subject, textBody, htmlBody string) error { f.mu.Lock() defer f.mu.Unlock() if f.FailNext != nil { err := f.FailNext f.FailNext = nil return err } f.Sent = append(f.Sent, SentEmail{ To: to, Subject: subject, TextBody: textBody, HTMLBody: htmlBody, }) return nil } // LastSent returns the most recently recorded email, or zero value // if nothing has been sent. func (f *FakeSender) LastSent() SentEmail { f.mu.Lock() defer f.mu.Unlock() if len(f.Sent) == 0 { return SentEmail{} } return f.Sent[len(f.Sent)-1] }