golib/net/formmailer/formmailer.go
AJ ONeal 271b22fa1f
refactor(formmailer): ergonomic cleanups after prod deploy learnings
- Collapse SuccessBody/SuccessBodyFunc (and Error*) into a single required
  func() []byte. Callers build closures with baked-in fallbacks; one field,
  one code path, no priority ordering.
- Split EHLO local name from peer hostname. Add LocalName field + default
  to os.Hostname(). smtp.NewClient gets the peer name (for AUTH + TLS
  binding); c.Hello(localName) overrides the EHLO greeting separately.
  Stdlib conflates these but they're semantically distinct.
- Add TLSConfig override field (supports custom CAs, cert pinning, and
  test-time InsecureSkipVerify). Default is still ServerName: <SMTP host>.
- Add TrustedProxies []netip.Prefix. X-Forwarded-For is now only honored
  when r.RemoteAddr is in the trusted set — otherwise any client can forge
  their IP to bypass rate limits, blacklist, and geo gating.
- Integration tests drive sendMail against a fake SMTP server that does
  STARTTLS + AUTH PLAIN. Regression test for StartTLS(nil) panic (Go 1.22+
  requires ServerName or InsecureSkipVerify).

cmd/form2mail:
- Extract hotReload() helper; replaces the inline closures.
- silentDropRU looks up the email FormName from Fields by Kind instead of
  hardcoding "input_3" (survives field renames).
- Set TrustedProxies to loopback (proxy runs on same host).
- scripts/deploy.sh automates the rename-first / scp / restart / verify /
  cleanup cycle — leaves .old in place on verification failure for rollback.
2026-04-20 23:20:13 -06:00

558 lines
16 KiB
Go

// Package formmailer provides an HTTP handler that validates, rate-limits,
// and emails contact form submissions.
//
// Fields are declared as an ordered slice; each Field names the HTML input,
// the label for the email body, and the validation Kind. Exactly one Kind
// must be KindEmail — its value is used for Reply-To, Subject substitution,
// and the MX check.
//
// Typical setup:
//
// blacklist := dataset.Add(set, func(ctx context.Context) (*ipcohort.Cohort, error) { ... })
// geo := dataset.Add(geoSet, func(ctx context.Context) (*geoip.Databases, error) { ... })
//
// fm := &formmailer.FormMailer{
// SMTPHost: "smtp.example.com:587",
// SMTPFrom: "noreply@example.com",
// SMTPTo: []string{"contact@example.com"},
// SMTPUser: "noreply@example.com",
// SMTPPass: os.Getenv("SMTP_PASS"),
// Subject: "Contact from {.Email}",
// Fields: []formmailer.Field{
// {Label: "Name", FormName: "input_1", Kind: formmailer.KindText},
// {Label: "Email", FormName: "input_3", Kind: formmailer.KindEmail},
// {Label: "Phone", FormName: "input_4", Kind: formmailer.KindPhone},
// {Label: "Company", FormName: "input_5", Kind: formmailer.KindText},
// {Label: "Budget", FormName: "input_8", Kind: formmailer.KindText},
// {Label: "Message", FormName: "input_7", Kind: formmailer.KindMessage},
// },
// SuccessBody: successHTML,
// ErrorBody: errorHTML,
// Blacklist: blacklist,
// }
// http.Handle("POST /contact", fm)
package formmailer
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/mail"
"net/netip"
"net/smtp"
"os"
"regexp"
"slices"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/therootcompany/golib/net/geoip"
"github.com/therootcompany/golib/net/ipcohort"
"github.com/therootcompany/golib/sync/dataset"
)
const (
maxFormSize = 10 * 1024
// Default per-Kind length caps; override with Field.MaxLen.
maxEmailLength = 254
maxPhoneLength = 20
maxTextLength = 200
maxMessageLength = 4000
defaultRPM = 5
defaultBurst = 3
defaultSMTPTimeout = 5 * time.Second
defaultMXTimeout = 2 * time.Second
limiterTTL = 10 * time.Minute
limiterSweepEvery = 1024 // sweep once every N handler invocations
)
var (
ErrInvalidEmail = errors.New("email address doesn't look like an email address")
ErrInvalidMX = errors.New("email address isn't deliverable")
ErrInvalidPhone = errors.New("phone number is not properly formatted")
ErrContentTooLong = errors.New("one or more field values was too long")
ErrInvalidNewlines = errors.New("invalid use of newlines or carriage returns")
ErrMissingRequired = errors.New("required field was empty")
ErrNoEmailField = errors.New("FormMailer.Fields must contain exactly one KindEmail field")
phoneRe = regexp.MustCompile(`^[0-9+\-\(\) ]{7,20}$`)
)
// FieldKind picks validation rules and default length cap for a Field.
type FieldKind int
const (
KindText FieldKind = iota // default; length-capped text
KindEmail // RFC 5321 parse + MX lookup
KindPhone // phoneRe match
KindMessage // long free text (body of the submission)
)
// Field declares one form input. Order is preserved in the email body.
type Field struct {
Label string // shown in email body, e.g. "Name"
FormName string // HTML form field name, e.g. "input_1"
Kind FieldKind // validation rules + default MaxLen
MaxLen int // 0 = default for Kind
Required bool // if true, empty value is rejected
}
func (f Field) maxLen() int {
if f.MaxLen > 0 {
return f.MaxLen
}
switch f.Kind {
case KindEmail:
return maxEmailLength
case KindPhone:
return maxPhoneLength
case KindMessage:
return maxMessageLength
default:
return maxTextLength
}
}
// FormMailer is an http.Handler that validates and emails contact form submissions.
type FormMailer struct {
// SMTP
SMTPHost string
SMTPFrom string
SMTPTo []string
SMTPUser string
SMTPPass string
Subject string // may contain {.Email}
// LocalName is the hostname the client announces in EHLO. Zero uses
// os.Hostname(). Matters for relay (port 25) where receivers may reject
// EHLO names that don't reverse-resolve; on submission (587) it's
// informational. NEVER the peer's name — that's TLS SNI / auth binding.
LocalName string
// TLSConfig overrides the StartTLS tls.Config. Zero uses
// &tls.Config{ServerName: <host from SMTPHost>}. Set to supply a custom
// root CA bundle, to disable verification (in tests), or to pin a cert.
TLSConfig *tls.Config
// SMTPTimeout bounds the entire connect+auth+send cycle. Zero uses 5s.
SMTPTimeout time.Duration
// MXTimeout bounds the per-submission MX lookup. Zero uses 2s.
MXTimeout time.Duration
// SuccessBody and ErrorBody are called per request and return the response
// body sent to the client. Callers typically build them as closures that
// re-read an HTML file each invocation (hot-reload) with a baked-in fallback
// to a copy read at startup. ErrorBody output may contain {.Error} and
// {.SupportEmail} placeholders.
SuccessBody func() []byte
ErrorBody func() []byte
ContentType string // inferred from a probe of SuccessBody() if empty
// HiddenSupportValue replaces {.SupportEmail} in ErrorBody output when the
// request should not learn the operator's real address — i.e. bot and
// blacklist rejections. Legitimate validation errors see the real support
// email. Zero value "" strips the placeholder entirely.
HiddenSupportValue string
// TrustedProxies — list of CIDRs whose X-Forwarded-For header we honor.
// Empty means never trust XFF (use r.RemoteAddr as-is). Without this,
// any client could forge their IP to bypass rate limiting, blacklist,
// and country gating.
TrustedProxies []netip.Prefix
// Blacklist — if set, matching IPs are rejected before any other processing.
Blacklist *dataset.View[ipcohort.Cohort]
// Geo — required when AllowedCountries is set. Provides the GeoLite2
// City/ASN databases used for country lookup.
Geo *dataset.View[geoip.Databases]
// AllowedCountries — if non-nil, only requests from listed ISO codes are
// accepted. Unknown country ("") is always allowed. Requires Geo to be set.
AllowedCountries []string
// Fields declares the form inputs in display order. Exactly one entry
// must have Kind == KindEmail.
Fields []Field
// RPM and Burst control per-IP rate limiting. Zero uses defaults (5/3).
RPM int
Burst int
once sync.Once
initErr error
emailIdx int // index into Fields of the KindEmail entry
mu sync.Mutex
limiters map[string]*limiterEntry
reqCount uint64
}
type limiterEntry struct {
lim *rate.Limiter
lastUsed time.Time
}
func (fm *FormMailer) init() {
fm.limiters = make(map[string]*limiterEntry)
fm.emailIdx = -1
for i, f := range fm.Fields {
if f.Kind == KindEmail {
if fm.emailIdx >= 0 {
fm.initErr = ErrNoEmailField
return
}
fm.emailIdx = i
}
}
if fm.emailIdx < 0 {
fm.initErr = ErrNoEmailField
}
}
func (fm *FormMailer) successBody() []byte {
if fm.SuccessBody == nil {
return nil
}
return fm.SuccessBody()
}
func (fm *FormMailer) errorBody() []byte {
if fm.ErrorBody == nil {
return nil
}
return fm.ErrorBody()
}
func (fm *FormMailer) contentType() string {
if fm.ContentType != "" {
return fm.ContentType
}
probe := fm.successBody()
if bytes.Contains(probe[:min(512, len(probe))], []byte("<html")) {
return "text/html; charset=utf-8"
}
if bytes.HasPrefix(bytes.TrimSpace(probe), []byte("{")) {
return "application/json"
}
return "text/plain; charset=utf-8"
}
func (fm *FormMailer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fm.once.Do(fm.init)
if fm.initErr != nil {
log.Printf("contact form: misconfigured: %v", fm.initErr)
http.Error(w, "contact form misconfigured", http.StatusInternalServerError)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormSize)
if err := r.ParseMultipartForm(maxFormSize); err != nil {
http.Error(w, "form too large or invalid", http.StatusBadRequest)
return
}
ipStr := fm.clientIP(r)
ip, err := netip.ParseAddr(ipStr)
if err != nil {
fm.writeError(w, fmt.Errorf("malformed client IP"), true)
return
}
if fm.Blacklist != nil {
if c := fm.Blacklist.Value(); c != nil && c.ContainsAddr(ip) {
fm.writeError(w, fmt.Errorf("automated requests are not accepted"), false)
return
}
}
if fm.AllowedCountries != nil {
if geo := fm.Geo.Value(); geo != nil {
country := geo.Lookup(ipStr).CountryISO
if country != "" && !slices.Contains(fm.AllowedCountries, country) {
fm.writeError(w, fmt.Errorf("submissions from your region are not accepted; please email us directly"), true)
return
}
}
}
if !fm.allow(ipStr) {
http.Error(w, "rate limit exceeded — please try again later", http.StatusTooManyRequests)
return
}
values := make([]string, len(fm.Fields))
for i, f := range fm.Fields {
v := strings.TrimSpace(r.FormValue(f.FormName))
if f.Kind == KindEmail {
v = strings.ToLower(v)
}
values[i] = v
}
for i, f := range fm.Fields {
v := values[i]
if f.Required && v == "" {
fm.writeError(w, fmt.Errorf("%w: %s", ErrMissingRequired, f.Label), true)
return
}
if len(v) > f.maxLen() {
fm.writeError(w, ErrContentTooLong, true)
return
}
// Header-injection check: all fields except free-form message bodies.
if f.Kind != KindMessage && strings.ContainsAny(v, "\r\n") {
fm.writeError(w, ErrInvalidNewlines, true)
return
}
switch f.Kind {
case KindPhone:
if err := validatePhone(v); err != nil {
fm.writeError(w, err, true)
return
}
case KindEmail:
if err := fm.validateEmailAndMX(r.Context(), v); err != nil {
fm.writeError(w, err, true)
return
}
}
}
email := values[fm.emailIdx]
var logBuf strings.Builder
fmt.Fprintf(&logBuf, "contact form: ip=%s", ipStr)
for i, f := range fm.Fields {
v := values[i]
if len(v) > 100 {
v = v[:100]
}
fmt.Fprintf(&logBuf, " %s=%q", f.Label, v)
}
log.Print(logBuf.String())
subject := strings.ReplaceAll(fm.Subject, "{.Email}", email)
var body strings.Builder
body.WriteString("New contact form submission:\n\n")
for i, f := range fm.Fields {
if f.Kind == KindMessage {
fmt.Fprintf(&body, "%s:\n%s\n", f.Label, values[i])
continue
}
fmt.Fprintf(&body, "%s: %s\n", f.Label, values[i])
}
msg := fmt.Appendf(nil,
"To: %s\r\nFrom: %s\r\nReply-To: %s\r\nSubject: %s\r\n\r\n%s\r\n",
strings.Join(fm.SMTPTo, ", "), fm.SMTPFrom, email, subject, body.String(),
)
if err := fm.sendMail(r.Context(), msg); err != nil {
log.Printf("contact form: smtp error: %v", err)
http.Error(w, "failed to send — please try again later", http.StatusInternalServerError)
return
}
log.Printf("contact form: sent ip=%s from=%q to=%s", ipStr, email, strings.Join(fm.SMTPTo, ","))
w.Header().Set("Content-Type", fm.contentType())
_, _ = w.Write(fm.successBody())
}
// sendMail dials SMTPHost with a bounded timeout and writes the message.
// Uses smtp.NewClient directly so the dial respects ctx; stdlib smtp.SendMail
// has no context plumbing.
func (fm *FormMailer) sendMail(ctx context.Context, msg []byte) error {
timeout := fm.SMTPTimeout
if timeout == 0 {
timeout = defaultSMTPTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d := net.Dialer{}
conn, err := d.DialContext(ctx, "tcp", fm.SMTPHost)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
hostname, _, err := net.SplitHostPort(fm.SMTPHost)
if err != nil {
hostname = fm.SMTPHost
}
// NewClient sets the peer name used for AUTH binding and TLS SNI — that
// must be the SMTP server's hostname. Hello() overrides the *client*
// identity announced in EHLO; stdlib conflates these into one API but
// they're semantically distinct.
c, err := smtp.NewClient(conn, hostname)
if err != nil {
_ = conn.Close()
return fmt.Errorf("smtp client: %w", err)
}
defer func() { _ = c.Close() }()
localName := fm.LocalName
if localName == "" {
if h, err := os.Hostname(); err == nil {
localName = h
} else {
localName = "localhost"
}
}
if err := c.Hello(localName); err != nil {
return fmt.Errorf("ehlo: %w", err)
}
if ok, _ := c.Extension("STARTTLS"); ok {
tlsCfg := fm.TLSConfig
if tlsCfg == nil {
tlsCfg = &tls.Config{ServerName: hostname}
}
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if fm.SMTPUser != "" {
auth := smtp.PlainAuth("", fm.SMTPUser, fm.SMTPPass, hostname)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := c.Mail(fm.SMTPFrom); err != nil {
return fmt.Errorf("mail from: %w", err)
}
for _, to := range fm.SMTPTo {
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("rcpt to %s: %w", to, err)
}
}
wc, err := c.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err := wc.Write(msg); err != nil {
_ = wc.Close()
return fmt.Errorf("write: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return c.Quit()
}
func (fm *FormMailer) writeError(w http.ResponseWriter, err error, showSupport bool) {
w.Header().Set("Content-Type", fm.contentType())
w.WriteHeader(http.StatusBadRequest)
support := fm.SMTPFrom
if !showSupport {
support = fm.HiddenSupportValue
}
b := bytes.ReplaceAll(fm.errorBody(), []byte("{.Error}"), []byte(err.Error()))
b = bytes.ReplaceAll(b, []byte("{.SupportEmail}"), []byte(support))
_, _ = w.Write(b)
}
func (fm *FormMailer) allow(ipStr string) bool {
rpm := fm.RPM
if rpm == 0 {
rpm = defaultRPM
}
burst := fm.Burst
if burst == 0 {
burst = defaultBurst
}
now := time.Now()
fm.mu.Lock()
e, ok := fm.limiters[ipStr]
if !ok {
e = &limiterEntry{
lim: rate.NewLimiter(rate.Every(time.Minute/time.Duration(rpm)), burst),
}
fm.limiters[ipStr] = e
}
e.lastUsed = now
fm.reqCount++
if fm.reqCount%limiterSweepEvery == 0 {
for k, v := range fm.limiters {
if now.Sub(v.lastUsed) > limiterTTL {
delete(fm.limiters, k)
}
}
}
lim := e.lim
fm.mu.Unlock()
if !lim.Allow() {
log.Printf("contact form: rate limited ip=%s", ipStr)
return false
}
return true
}
func (fm *FormMailer) validateEmailAndMX(ctx context.Context, email string) error {
if email == "" {
return ErrInvalidEmail
}
if _, err := mail.ParseAddress(email); err != nil {
return ErrInvalidEmail
}
_, domain, ok := strings.Cut(email, "@")
if !ok {
return ErrInvalidEmail
}
timeout := fm.MXTimeout
if timeout == 0 {
timeout = defaultMXTimeout
}
lookupCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if _, err := net.DefaultResolver.LookupMX(lookupCtx, domain); err != nil {
return ErrInvalidMX
}
return nil
}
func validatePhone(phone string) error {
if phone == "" {
return nil
}
if !phoneRe.MatchString(phone) {
return ErrInvalidPhone
}
return nil
}
// clientIP returns the originating IP. If the immediate peer (r.RemoteAddr)
// is inside TrustedProxies, the left-most X-Forwarded-For entry is used
// instead. Without TrustedProxies, XFF is ignored — otherwise any client
// could forge their address to bypass rate limits and geo gating.
func (fm *FormMailer) clientIP(r *http.Request) string {
remote := r.RemoteAddr
if host, _, err := net.SplitHostPort(remote); err == nil {
remote = host
}
peer, err := netip.ParseAddr(remote)
if err != nil {
return remote
}
for _, cidr := range fm.TrustedProxies {
if cidr.Contains(peer) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
return strings.TrimSpace(first)
}
break
}
}
return remote
}