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