// Package mailer is a thin SMTP wrapper used by the auth flows. // // The stdlib `net/smtp` is sufficient for our needs (password reset + // email verification — low volume, plain-MIME). We keep one interface // (Mailer) so handlers can be tested with an in-memory implementation // and so we can swap to a transactional provider later (Resend, SES) // without touching the call sites. package mailer import ( "fmt" "net/smtp" "strings" "go.uber.org/zap" ) // Mailer is the small surface auth services depend on. type Mailer interface { Send(to, subject, htmlBody string) error } // Config carries the per-environment SMTP knobs. type Config struct { Host string Port int Username string Password string From string // "Name " or just "addr@host" } // Configured returns true if enough SMTP creds are present to send mail. // Used by the auth service to decide whether to send-or-log. func (c Config) Configured() bool { return c.Host != "" && c.From != "" } // SMTP is the production Mailer. Connects per call (low volume → fine). type SMTP struct { cfg Config logger *zap.Logger } // New returns an SMTP mailer if creds are present, otherwise a NoopLogger // that logs the message body to zap (useful in dev — the password-reset // link shows up in your server logs). func New(cfg Config, logger *zap.Logger) Mailer { if !cfg.Configured() { logger.Warn("SMTP not configured — mailer will log messages instead of sending") return &NoopLogger{logger: logger} } return &SMTP{cfg: cfg, logger: logger} } // Send delivers a single HTML email. Plain-text alternative is omitted — // the templates are short and modern clients render HTML fine. func (m *SMTP) Send(to, subject, htmlBody string) error { addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port) auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) var msg strings.Builder msg.WriteString("From: ") msg.WriteString(m.cfg.From) msg.WriteString("\r\n") msg.WriteString("To: ") msg.WriteString(to) msg.WriteString("\r\n") msg.WriteString("Subject: ") msg.WriteString(subject) msg.WriteString("\r\n") msg.WriteString("MIME-Version: 1.0\r\n") msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") msg.WriteString(htmlBody) // Most relays on port 587 want STARTTLS + AUTH. Port 465 = implicit TLS, // not handled here — add only if needed. if err := smtp.SendMail(addr, auth, fromAddr(m.cfg.From), []string{to}, []byte(msg.String())); err != nil { return fmt.Errorf("smtp send: %w", err) } return nil } // fromAddr extracts the bare address from a "Name " string. SendMail's // `from` argument is the envelope-from, which must be the bare address. func fromAddr(s string) string { if i := strings.LastIndex(s, "<"); i >= 0 { if j := strings.LastIndex(s, ">"); j > i { return s[i+1 : j] } } return s } // NoopLogger satisfies Mailer by writing the message into the application // logger. Use it in development so you can copy reset links from the console. type NoopLogger struct{ logger *zap.Logger } func (n *NoopLogger) Send(to, subject, htmlBody string) error { n.logger.Info("📧 mailer (not sent — SMTP unconfigured)", zap.String("to", to), zap.String("subject", subject), zap.String("body", htmlBody)) return nil }