From d57c810c2eb7339a8b5a9d530109f9ca422f9229 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 20 Apr 2026 11:01:15 -0600 Subject: [PATCH] feat: add net/formmailer with updated paradigms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite from feat-formmailer WIP: - Blacklist is *dataset.View[ipcohort.Cohort] — caller wires dataset group - http.Handler via ServeHTTP — drop-in for any mux - SuccessBody/ErrorBody []byte — caller loads files; no file I/O per request - Rate limiter per-instance (sync.Once init), not global - Fields configurable (default standard names, not GravityForms input_N) - AllowedCountries []string for geo-blocking via iploc (nil = allow all) - ContainsAddr used directly (pre-parsed netip.Addr, no re-parse) - No Init()/Run() — caller drives dataset lifecycle - Fix getErrorBotty typo; expose support email only to legitimate errors --- go.mod | 2 + go.sum | 4 + net/formmailer/formmailer.go | 321 +++++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 net/formmailer/formmailer.go diff --git a/go.mod b/go.mod index fc9efa0..56051bb 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,7 @@ go 1.26.0 require ( github.com/oschwald/geoip2-golang v1.13.0 // indirect github.com/oschwald/maxminddb-golang v1.13.0 // indirect + github.com/phuslu/iploc v1.0.20260415 // indirect golang.org/x/sys v0.20.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index fcb2781..b73aa37 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,9 @@ github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNs github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/phuslu/iploc v1.0.20260415 h1:k0yQ1+kIgYAQXIp2OPp/CwbXpmuAwr/QsdY5lWnFnu8= +github.com/phuslu/iploc v1.0.20260415/go.mod h1:VZqAWoi2A80YPvfk1AizLGHavNIG9nhBC8d87D/SeVs= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/net/formmailer/formmailer.go b/net/formmailer/formmailer.go new file mode 100644 index 0000000..5352122 --- /dev/null +++ b/net/formmailer/formmailer.go @@ -0,0 +1,321 @@ +// 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 fall back to the field name itself ("name", "email", etc.). +type FormFields struct { + Name string // default "name" + Email string // default "email" + Phone string // default "phone" + Company string // default "company" + Message string // default "message" +} + +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.View[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 +} +