Give go-auth mail delivery on the shared platform/email sender (same transport contract as the order service's ORDER_EMAIL_*): AUTH_EMAIL_SENDER= smtp|log with the AUTH_EMAIL_SMTP_* knobs. /forgot emails a single-use AOF-backed reset link (no enumeration, throttled) and /reset sets a new password, revoking sessions, refresh tokens and remaining reset links; admin invite + password-reset email credentials, and the create_user / set_user_password MCP tools match (revoking outstanding reset links too). Compose/k8s default to the log sender so the flows are exercisable without SMTP credentials. Committed together with the Auth MCP server, its client- registry admin UI, and the mcp-servers doc row they build on. Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
380 lines
12 KiB
Go
380 lines
12 KiB
Go
// Package email is the platform's shared transactional-mail transport: a
|
|
// small Sender interface with an SMTP implementation (implicit TLS, STARTTLS
|
|
// and plain relays; PLAIN/LOGIN auth) and a dev-only log sender. It was
|
|
// extracted from go-cart-actor's pkg/order (where the order, ledger and
|
|
// newsletter paths use it) so a second consumer — go-auth's forgot-password
|
|
// and invite mail — reuses the same transport contract instead of
|
|
// duplicating it.
|
|
//
|
|
// Pure stdlib (net/smtp, crypto/tls, mime, net/mail): no external SDK, so it
|
|
// stays usable from the stdlib-only services. The MailerSend API sender is
|
|
// intentionally not here — services that need it keep their own client.
|
|
package email
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"mime"
|
|
"mime/quotedprintable"
|
|
"net"
|
|
"net/mail"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Message is a rendered mail payload a sender delivers. HTMLBody is optional
|
|
// (a text-only message is fine); when both are set the SMTP sender emits a
|
|
// multipart/alternative body.
|
|
type Message struct {
|
|
From mail.Address
|
|
To []mail.Address
|
|
Subject string
|
|
TextBody string
|
|
HTMLBody string
|
|
// Headers holds optional extra RFC 5322 headers (e.g. List-Unsubscribe).
|
|
// Reserved headers (From/To/Subject/Date/MIME-*) are skipped, and CR/LF
|
|
// injection is rejected.
|
|
Headers map[string]string
|
|
}
|
|
|
|
// Sender delivers an already-rendered mail and exposes its default sender
|
|
// address (used when the caller has no From of its own).
|
|
type Sender interface {
|
|
Send(ctx context.Context, msg Message) error
|
|
DefaultFrom() mail.Address
|
|
}
|
|
|
|
// LogSender logs each rendered message via slog instead of delivering it —
|
|
// the dev default for compose stacks without SMTP credentials, so mail flows
|
|
// (forgot-password, invites) can be exercised end-to-end while the content
|
|
// shows up in the service logs. It never sends.
|
|
type LogSender struct {
|
|
logger *slog.Logger
|
|
from mail.Address
|
|
}
|
|
|
|
// NewLogSender builds a sender that prints mail to logger.
|
|
func NewLogSender(logger *slog.Logger, from mail.Address) *LogSender {
|
|
return &LogSender{logger: logger, from: from}
|
|
}
|
|
|
|
// DefaultFrom returns the configured sender address.
|
|
func (s *LogSender) DefaultFrom() mail.Address { return s.from }
|
|
|
|
// Send logs the message (recipients + subject only — never the body).
|
|
func (s *LogSender) Send(_ context.Context, msg Message) error {
|
|
if s.logger == nil {
|
|
return nil
|
|
}
|
|
attrs := []any{
|
|
"from", msg.From.Address,
|
|
"recipients", len(msg.To),
|
|
"subject", msg.Subject,
|
|
}
|
|
if len(msg.To) == 1 {
|
|
attrs = append(attrs, "to", msg.To[0].Address)
|
|
}
|
|
s.logger.Info("email (log sender — not delivered)", attrs...)
|
|
return nil
|
|
}
|
|
|
|
// SMTPConfig configures the SMTP mail transport.
|
|
//
|
|
// Transport selection: SSL=true uses implicit TLS from the first byte
|
|
// (typical port 465); SSL=false connects in plain text and upgrades via
|
|
// STARTTLS when the server offers it, unless DisableSTARTTLS is set for
|
|
// relays that have no TLS at all (e.g. an internal k8s relay).
|
|
type SMTPConfig struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
SSL bool // implicit TLS connection (typically port 465)
|
|
// DisableSTARTTLS skips the opportunistic STARTTLS upgrade on plain
|
|
// connections so unencrypted internal relays keep working.
|
|
DisableSTARTTLS bool
|
|
DefaultFrom mail.Address
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type smtpSender struct {
|
|
host string
|
|
addr string
|
|
ssl bool
|
|
noStartTLS bool
|
|
username string
|
|
password string
|
|
defaultFrom mail.Address
|
|
timeout time.Duration
|
|
}
|
|
|
|
// loginAuth implements the SASL LOGIN mechanism — many internal relays only
|
|
// advertise LOGIN (and Go's net/smtp only ships PLAIN/CRAM-MD5).
|
|
type loginAuth struct {
|
|
username, password string
|
|
}
|
|
|
|
func (a *loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) {
|
|
return "LOGIN", nil, nil
|
|
}
|
|
|
|
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
|
if !more {
|
|
return nil, nil
|
|
}
|
|
switch strings.ToLower(strings.TrimRight(string(fromServer), ":")) {
|
|
case "username":
|
|
return []byte(a.username), nil
|
|
case "password":
|
|
return []byte(a.password), nil
|
|
}
|
|
return nil, fmt.Errorf("smtp sender: unexpected LOGIN challenge %q", fromServer)
|
|
}
|
|
|
|
// plainAuth implements SASL PLAIN without net/smtp's cleartext refusal.
|
|
// PlainAuth refuses to send credentials over an unencrypted connection to a
|
|
// non-local host, but internal k8s relays (port 25, STARTTLS disabled) often
|
|
// advertise only PLAIN — the operator explicitly opts into cleartext auth for
|
|
// those (see SMTPConfig.DisableSTARTTLS).
|
|
type plainAuth struct {
|
|
username, password string
|
|
}
|
|
|
|
func (a *plainAuth) Start(*smtp.ServerInfo) (string, []byte, error) {
|
|
resp := []byte("\x00" + a.username + "\x00" + a.password)
|
|
return "PLAIN", resp, nil
|
|
}
|
|
|
|
func (a *plainAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
|
if more {
|
|
return nil, fmt.Errorf("smtp sender: unexpected server challenge during PLAIN auth")
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// NewSMTP returns an SMTP-backed sender supporting implicit-TLS, STARTTLS
|
|
// and plain transports with PLAIN or LOGIN authentication.
|
|
func NewSMTP(cfg SMTPConfig) (Sender, error) {
|
|
if strings.TrimSpace(cfg.Host) == "" {
|
|
return nil, fmt.Errorf("smtp sender: missing host")
|
|
}
|
|
if cfg.Port <= 0 {
|
|
return nil, fmt.Errorf("smtp sender: invalid port %d", cfg.Port)
|
|
}
|
|
if cfg.Timeout <= 0 {
|
|
cfg.Timeout = 10 * time.Second
|
|
}
|
|
if strings.TrimSpace(cfg.DefaultFrom.Address) == "" {
|
|
return nil, fmt.Errorf("smtp sender: missing default from address")
|
|
}
|
|
return &smtpSender{
|
|
host: cfg.Host,
|
|
addr: net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)),
|
|
ssl: cfg.SSL,
|
|
noStartTLS: cfg.DisableSTARTTLS,
|
|
username: strings.TrimSpace(cfg.Username),
|
|
password: cfg.Password,
|
|
defaultFrom: cfg.DefaultFrom,
|
|
timeout: cfg.Timeout,
|
|
}, nil
|
|
}
|
|
|
|
// DefaultFrom returns the configured sender address.
|
|
func (s *smtpSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
|
|
|
// authenticate negotiates AUTH with the best mechanism both sides support:
|
|
// PLAIN on encrypted (or localhost) connections, LOGIN otherwise — net/smtp's
|
|
// PlainAuth refuses to send credentials over an unencrypted connection to a
|
|
// non-local host, so plain internal relays need LOGIN.
|
|
func (s *smtpSender) authenticate(client *smtp.Client) error {
|
|
ok, mechs := client.Extension("AUTH")
|
|
if !ok {
|
|
return fmt.Errorf("smtp sender: server does not support auth")
|
|
}
|
|
advertised := make(map[string]bool)
|
|
for _, mech := range strings.Fields(mechs) {
|
|
advertised[strings.ToUpper(mech)] = true
|
|
}
|
|
_, encrypted := client.TLSConnectionState()
|
|
encrypted = encrypted || s.ssl || s.host == "localhost" || s.host == "127.0.0.1"
|
|
var auth smtp.Auth
|
|
switch {
|
|
case advertised["PLAIN"] && encrypted:
|
|
auth = smtp.PlainAuth("", s.username, s.password, s.host)
|
|
case advertised["LOGIN"]:
|
|
auth = &loginAuth{username: s.username, password: s.password}
|
|
case advertised["PLAIN"]:
|
|
// Unencrypted PLAIN-only relay: stdlib PlainAuth would refuse to send
|
|
// credentials in the clear, so use the cleartext-capable implementation.
|
|
auth = &plainAuth{username: s.username, password: s.password}
|
|
default:
|
|
return fmt.Errorf("smtp sender: server does not support a known auth mechanism (advertised: %s)", mechs)
|
|
}
|
|
if err := client.Auth(auth); err != nil {
|
|
return fmt.Errorf("smtp sender: auth (%T): %w", auth, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Send delivers msg over SMTP. The envelope From/To are the address parts of
|
|
// msg.From/msg.To (display names stay in the headers).
|
|
func (s *smtpSender) Send(ctx context.Context, msg Message) error {
|
|
if strings.TrimSpace(msg.From.Address) == "" {
|
|
return fmt.Errorf("smtp sender: missing from address")
|
|
}
|
|
if len(msg.To) == 0 {
|
|
return fmt.Errorf("smtp sender: missing recipients")
|
|
}
|
|
if strings.TrimSpace(msg.Subject) == "" {
|
|
return fmt.Errorf("smtp sender: missing subject")
|
|
}
|
|
body, err := buildMessage(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var conn net.Conn
|
|
if s.ssl {
|
|
d := &net.Dialer{Timeout: s.timeout}
|
|
conn, err = tls.DialWithDialer(d, "tcp", s.addr, &tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12})
|
|
} else {
|
|
conn, err = (&net.Dialer{Timeout: s.timeout}).DialContext(ctx, "tcp", s.addr)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("smtp sender: dial %s: %w", s.addr, err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(s.timeout))
|
|
|
|
client, err := smtp.NewClient(conn, s.host)
|
|
if err != nil {
|
|
return fmt.Errorf("smtp sender: create client: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if !s.ssl && !s.noStartTLS {
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
|
|
return fmt.Errorf("smtp sender: starttls: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if s.username != "" {
|
|
if err := s.authenticate(client); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := client.Mail(msg.From.Address); err != nil {
|
|
return fmt.Errorf("smtp sender: mail from %s: %w", msg.From.Address, err)
|
|
}
|
|
for _, rcpt := range msg.To {
|
|
if err := client.Rcpt(rcpt.Address); err != nil {
|
|
return fmt.Errorf("smtp sender: rcpt to %s: %w", rcpt.Address, err)
|
|
}
|
|
}
|
|
w, err := client.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp sender: data: %w", err)
|
|
}
|
|
if _, err := w.Write(body); err != nil {
|
|
_ = w.Close()
|
|
return fmt.Errorf("smtp sender: write body: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("smtp sender: close body: %w", err)
|
|
}
|
|
if err := client.Quit(); err != nil {
|
|
return fmt.Errorf("smtp sender: quit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildMessage renders the RFC 5322 wire message: headers (From/To/Subject
|
|
// Q-encoded/Date/custom), then a multipart/alternative body when both text
|
|
// and HTML are present, else a single quoted-printable part.
|
|
func buildMessage(msg Message) ([]byte, error) {
|
|
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
|
return nil, fmt.Errorf("email: missing body")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
writeHeader := func(name, value string) {
|
|
buf.WriteString(name)
|
|
buf.WriteString(": ")
|
|
buf.WriteString(value)
|
|
buf.WriteString("\r\n")
|
|
}
|
|
writeHeader("From", msg.From.String())
|
|
toList := make([]string, 0, len(msg.To))
|
|
for _, rcpt := range msg.To {
|
|
toList = append(toList, rcpt.String())
|
|
}
|
|
writeHeader("To", strings.Join(toList, ", "))
|
|
writeHeader("Subject", mime.QEncoding.Encode("utf-8", msg.Subject))
|
|
writeHeader("Date", time.Now().Format(time.RFC1123Z))
|
|
// Custom headers — reserved headers are skipped to avoid clobbering the
|
|
// ones written above; CR/LF injection is rejected.
|
|
for name, value := range msg.Headers {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "from", "to", "subject", "date", "mime-version", "content-type", "content-transfer-encoding":
|
|
continue
|
|
}
|
|
if strings.ContainsAny(name, "\r\n") || strings.ContainsAny(value, "\r\n") {
|
|
continue
|
|
}
|
|
writeHeader(name, value)
|
|
}
|
|
writeHeader("MIME-Version", "1.0")
|
|
|
|
switch {
|
|
case msg.TextBody != "" && msg.HTMLBody != "":
|
|
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
|
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, boundary))
|
|
buf.WriteString("\r\n")
|
|
writeMIMEPart(&buf, boundary, "text/plain; charset=utf-8", msg.TextBody)
|
|
writeMIMEPart(&buf, boundary, "text/html; charset=utf-8", msg.HTMLBody)
|
|
buf.WriteString("--")
|
|
buf.WriteString(boundary)
|
|
buf.WriteString("--\r\n")
|
|
case msg.HTMLBody != "":
|
|
writeHeader("Content-Type", `text/html; charset=utf-8`)
|
|
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
|
buf.WriteString("\r\n")
|
|
if err := writeQuotedPrintable(&buf, msg.HTMLBody); err != nil {
|
|
return nil, fmt.Errorf("email: encode html body: %w", err)
|
|
}
|
|
default:
|
|
writeHeader("Content-Type", `text/plain; charset=utf-8`)
|
|
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
|
buf.WriteString("\r\n")
|
|
if err := writeQuotedPrintable(&buf, msg.TextBody); err != nil {
|
|
return nil, fmt.Errorf("email: encode text body: %w", err)
|
|
}
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) {
|
|
buf.WriteString("--")
|
|
buf.WriteString(boundary)
|
|
buf.WriteString("\r\n")
|
|
buf.WriteString("Content-Type: ")
|
|
buf.WriteString(contentType)
|
|
buf.WriteString("\r\n")
|
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
|
_ = writeQuotedPrintable(buf, body)
|
|
buf.WriteString("\r\n")
|
|
}
|
|
|
|
func writeQuotedPrintable(buf *bytes.Buffer, body string) error {
|
|
w := quotedprintable.NewWriter(buf)
|
|
if _, err := w.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))); err != nil {
|
|
return err
|
|
}
|
|
return w.Close()
|
|
}
|