package account import ( "context" "fmt" "net" "net/smtp" "go.uber.org/zap" ) // Mailer delivers a transactional email. It is the seam behind which the email // confirm-code flow sends codes, so the relay is swappable and unit tests use a // fixture (see docs/TESTING.md: no real network in tests). The context is offered // for cancellation; the standard-library SMTP implementation sends synchronously // and ignores it. type Mailer interface { Send(ctx context.Context, to, subject, body string) error } // SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer // instead, so a deployment without a relay still runs (the code lands in the log). type SMTPConfig struct { Host string Port string Username string Password string From string } // SMTPMailer sends mail through an SMTP relay using the standard library. When a // username is set it authenticates with PLAIN; otherwise it relays unauthenticated. type SMTPMailer struct { cfg SMTPConfig } // NewSMTPMailer constructs an SMTPMailer for cfg. func NewSMTPMailer(cfg SMTPConfig) SMTPMailer { return SMTPMailer{cfg: cfg} } // Send delivers a plain-text UTF-8 message to to via the configured relay. func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error { addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) var auth smtp.Auth if m.cfg.Username != "" { auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) } if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil { return fmt.Errorf("account: send mail to %s: %w", to, err) } return nil } // message renders a minimal RFC 5322 plain-text email. func message(from, to, subject, body string) []byte { return []byte("From: " + from + "\r\n" + "To: " + to + "\r\n" + "Subject: " + subject + "\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n" + "\r\n" + body + "\r\n") } // LogMailer logs the message instead of sending it. It is the default when no // SMTP relay is configured and is intended for development only: it logs the body, // which carries the confirm-code, so it must not be used in production. type LogMailer struct { log *zap.Logger } // NewLogMailer constructs a LogMailer that logs through log. func NewLogMailer(log *zap.Logger) LogMailer { return LogMailer{log: log} } // Send logs the message at info level and reports success. func (m LogMailer) Send(_ context.Context, to, subject, body string) error { if m.log != nil { m.log.Info("email not sent (log mailer)", zap.String("to", to), zap.String("subject", subject), zap.String("body", body)) } return nil }