Files
platform/email/email.go
T
mats 59c737b4fb go-auth: transactional mail + forgot-password flow, MCP mail parity
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>
2026-09-01 19:50:10 +02:00

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()
}