package account import ( "context" "fmt" "strconv" "time" "github.com/wneessen/go-mail" "go.uber.org/zap" ) // Message is a transactional email to send through a Mailer. Text is the // required plain-text body and doubles as the multipart/alternative fallback; // HTML, when non-empty, is the preferred body a capable client renders instead. type Message struct { To string Subject string Text string HTML string } // 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 bounds the // delivery and is honoured by the SMTP implementation. type Mailer interface { Send(ctx context.Context, msg Message) 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). // TLS is always used and no client certificate is required — only the server // certificate is validated against the system roots. The Port selects the TLS // mode: smtpImplicitTLSPort (465) uses implicit TLS (SSL); any other port uses // mandatory STARTTLS. type SMTPConfig struct { Host string Port string Username string Password string From string } const ( // smtpImplicitTLSPort is the port on which the relay is dialled with implicit // TLS (SSL). Any other port negotiates mandatory STARTTLS instead. smtpImplicitTLSPort = 465 // smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send // is synchronous on the request path, so an unreachable relay must fail fast // rather than hold the request open. smtpDialTimeout = 15 * time.Second ) // SMTPMailer sends mail through an SMTP relay using go-mail. When a username is // set it authenticates, auto-discovering the strongest mechanism the relay // advertises; 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 UTF-8 message to msg.To via the configured relay. When msg.HTML // is set the message is multipart/alternative (plain text plus HTML); otherwise // it is plain text only. func (m SMTPMailer) Send(ctx context.Context, msg Message) error { port, err := strconv.Atoi(m.cfg.Port) if err != nil { return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err) } opts := []mail.Option{mail.WithTimeout(smtpDialTimeout)} if port == smtpImplicitTLSPort { opts = append(opts, mail.WithSSLPort(false)) } else { opts = append(opts, mail.WithPort(port), mail.WithTLSPortPolicy(mail.TLSMandatory)) } if m.cfg.Username != "" { opts = append(opts, mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithUsername(m.cfg.Username), mail.WithPassword(m.cfg.Password), ) } client, err := mail.NewClient(m.cfg.Host, opts...) if err != nil { return fmt.Errorf("account: build mail client: %w", err) } out := mail.NewMsg() if err := out.From(m.cfg.From); err != nil { return fmt.Errorf("account: set From %q: %w", m.cfg.From, err) } if err := out.To(msg.To); err != nil { return fmt.Errorf("account: set To %q: %w", msg.To, err) } out.Subject(msg.Subject) out.SetBodyString(mail.TypeTextPlain, msg.Text) if msg.HTML != "" { out.AddAlternativeString(mail.TypeTextHTML, msg.HTML) } if err := client.DialAndSendWithContext(ctx, out); err != nil { return fmt.Errorf("account: send mail to %s: %w", msg.To, err) } return nil } // 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. It logs the plain-text // body only (which carries the confirm-code); the HTML alternative is omitted. func (m LogMailer) Send(_ context.Context, msg Message) error { if m.log != nil { m.log.Info("email not sent (log mailer)", zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text)) } return nil }