// Package formmailer provides an HTTP handler that validates, rate-limits, // and emails contact form submissions. // // Typical setup: // // blGroup, _, inboundDS, _ := src.Datasets() // blGroup.Init() // go blGroup.Run(ctx, 47*time.Minute) // // 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}", // SuccessBody: successHTML, // ErrorBody: errorHTML, // Blacklist: inboundDS, // AllowedCountries: []string{"US", "CA", "MX"}, // } // http.Handle("POST /contact", fm) package formmailer import ( "bytes" "fmt" "log" "net" "net/http" "net/mail" "net/netip" "net/smtp" "regexp" "slices" "strings" "sync" "time" "github.com/phuslu/iploc" "golang.org/x/time/rate" "github.com/therootcompany/golib/net/dataset" "github.com/therootcompany/golib/net/ipcohort" ) const ( maxFormSize = 10 * 1024 maxMessageLength = 4000 maxCompanyLength = 200 maxNameLength = 100 maxEmailLength = 254 maxPhoneLength = 20 defaultRPM = 5 defaultBurst = 3 ) var ( ErrInvalidEmail = fmt.Errorf("email address doesn't look like an email address") ErrInvalidMX = fmt.Errorf("email address isn't deliverable") ErrInvalidPhone = fmt.Errorf("phone number is not properly formatted") ErrContentTooLong = fmt.Errorf("one or more field values was too long") ErrInvalidNewlines = fmt.Errorf("invalid use of newlines or carriage returns") phoneRe = regexp.MustCompile(`^[0-9+\-\(\) ]{7,20}$`) ) // FormFields maps logical field names to the HTML form field names. // Zero values use GravityForms-compatible defaults (input_1, input_3, etc.). type FormFields struct { Name string // default "input_1" Email string // default "input_3" Phone string // default "input_4" Company string // default "input_5" Message string // default "input_7" } func (f FormFields) get(r *http.Request, field, def string) string { key := field if key == "" { key = def } return strings.TrimSpace(r.FormValue(key)) } // 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} // SuccessBody and ErrorBody are the response bodies sent to the client. // ErrorBody may contain {.Error} and {.SupportEmail} placeholders. // Load from files before use: fm.SuccessBody, _ = os.ReadFile("success.html") SuccessBody []byte ErrorBody []byte ContentType string // inferred from SuccessBody if empty // Blacklist — if set, matching IPs are rejected before any other processing. Blacklist *dataset.Dataset[ipcohort.Cohort] // AllowedCountries — if non-nil, only requests from listed ISO codes are // accepted. Unknown country ("") is always allowed. // Example: []string{"US", "CA", "MX"} AllowedCountries []string // Fields maps logical names to HTML form field names. Fields FormFields // RPM and Burst control per-IP rate limiting. Zero uses defaults (5/3). RPM int Burst int once sync.Once mu sync.Mutex limiters map[string]*rate.Limiter } func (fm *FormMailer) init() { fm.limiters = make(map[string]*rate.Limiter) } func (fm *FormMailer) contentType() string { if fm.ContentType != "" { return fm.ContentType } // Infer from SuccessBody sniff or leave as plain text. if bytes.Contains(fm.SuccessBody[:min(512, len(fm.SuccessBody))], []byte(" maxNameLength || len(email) > maxEmailLength || len(phone) > maxPhoneLength || len(company) > maxCompanyLength || len(message) > maxMessageLength { return ErrContentTooLong } return nil } func validateEmailAndMX(email string) error { if _, err := mail.ParseAddress(email); err != nil { return ErrInvalidEmail } parts := strings.Split(email, "@") if len(parts) != 2 { return ErrInvalidEmail } if _, err := net.LookupMX(parts[1]); err != nil { return ErrInvalidMX } return nil } func validatePhone(phone string) error { if phone == "" { return nil } if !phoneRe.MatchString(phone) { return ErrInvalidPhone } return nil } func validateNoHeaderInjection(fields ...string) error { for _, f := range fields { if strings.ContainsAny(f, "\r\n") { return ErrInvalidNewlines } } return nil } // clientIP returns the originating IP, preferring X-Forwarded-For. func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if parts := strings.SplitN(xff, ",", 2); len(parts) > 0 { return strings.TrimSpace(parts[0]) } } ip := r.RemoteAddr if idx := strings.LastIndex(ip, ":"); idx > 0 { return ip[:idx] } return ip }