feat(net/formmailer): add reusable contact form handler

This commit is contained in:
AJ ONeal 2026-09-09 11:12:18 -06:00
parent 0ae08fe952
commit 98696d2cb5
No known key found for this signature in database
5 changed files with 1556 additions and 0 deletions

1051
net/formmailer/formmailer.go Normal file

File diff suppressed because it is too large Load Diff

22
net/formmailer/go.mod Normal file
View File

@ -0,0 +1,22 @@
module github.com/therootcompany/golib/net/formmailer
go 1.26.5
require (
github.com/therootcompany/golib/net/geoip v0.5.3
github.com/therootcompany/golib/net/gitshallow v0.9.3
github.com/therootcompany/golib/net/ipcohort v0.9.0
github.com/therootcompany/golib/sync/dataset v0.5.0
golang.org/x/time v0.15.0
)
require (
github.com/oschwald/geoip2-golang v1.13.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/therootcompany/golib/https v0.9.0 // indirect
github.com/therootcompany/golib/net/httpcache v0.5.1 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
replace github.com/therootcompany/golib/net/geoip v0.5.3 => ../geoip

28
net/formmailer/go.sum Normal file
View File

@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/therootcompany/golib/https v0.9.0 h1:D9gh0K6ljkvF805JUbylITn8SHPMo08q5PglQGXsK8c=
github.com/therootcompany/golib/https v0.9.0/go.mod h1:ilExqR/Uh7oB36oy9mwuNhANcFh+zdCfD3uJiZ11YAY=
github.com/therootcompany/golib/net/gitshallow v0.9.3 h1:FKi0IvpqEDnYasjTmkRmXDdi+y3bhQBcjgvggwAwxFs=
github.com/therootcompany/golib/net/gitshallow v0.9.3/go.mod h1:S/3OK7wMOqJJjP79U9Qw/l3jjEyrb/zsjJ8HAPAX/Fc=
github.com/therootcompany/golib/net/httpcache v0.5.1 h1:vQM758tBwPEqCo7ojpSeL3fHqgq29LYmvD2hvDTBfKY=
github.com/therootcompany/golib/net/httpcache v0.5.1/go.mod h1:mKSQJWpGSCDBzWkTCwT7dO1SCVCsFmdkRgKr39fNS1U=
github.com/therootcompany/golib/net/ipcohort v0.9.0 h1:j3AaoW2u4XQJz1ya6fixv3pb3KgxIpZV8t0082/kS3E=
github.com/therootcompany/golib/net/ipcohort v0.9.0/go.mod h1:Ljcnt2xOryVDGIVV5Kzt5hj2LhW4lBAWo5OeeVYU9ys=
github.com/therootcompany/golib/sync/dataset v0.5.0 h1:4i3+7Izy/vnpK6IW5m9u4UqLA9RM1IjUsedbZo3MW6E=
github.com/therootcompany/golib/sync/dataset v0.5.0/go.mod h1:HMa25n3eqYnjRcPXny0IUGU2wFKD5/x2g9UUkt3iAmI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

128
net/formmailer/run_test.go Normal file
View File

@ -0,0 +1,128 @@
package formmailer
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// captureStdout runs fn while capturing everything written to os.Stdout,
// returning the captured text.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
done := make(chan string, 1)
go func() {
b, _ := io.ReadAll(r)
done <- string(b)
}()
fn()
_ = w.Close()
os.Stdout = orig
return <-done
}
// runOnce starts a FormMailer with a context that expires quickly so Run
// returns immediately after selecting and logging its GeoIP mode. It must not
// require network (BlocklistRepo is empty) and binds an ephemeral port.
func runOnce(t *testing.T, fm *FormMailer) string {
t.Helper()
fm.ListenAddr = "127.0.0.1:0"
fm.RefreshInterval = time.Minute
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
return captureStdout(t, func() {
_ = fm.Run(ctx)
})
}
// TestRunGeoModeSelection sanity-checks the hybrid GeoIP mode selection:
// - conf present -> download mode
// - conf absent + populated -> poll mode
// - conf absent + empty dir -> disabled
func TestRunGeoModeSelection(t *testing.T) {
t.Run("download", func(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "GeoIP.conf")
if err := os.WriteFile(conf, []byte(
"AccountID 123456\nLicenseKey testkey\nEditionIDs GeoLite2-City GeoLite2-ASN\n"), 0o600); err != nil {
t.Fatal(err)
}
// 500 server so the owned download loop fails fast; the mode is
// selected and logged before serving regardless.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
defer srv.Close()
out := runOnce(t, &FormMailer{
CacheDir: dir,
GeoIPConfPath: conf,
GeoIPBaseURL: srv.URL,
SMTPHost: "localhost:25",
SMTPFrom: "a@example.com",
SMTPTo: []string{"b@example.com"},
})
if !strings.Contains(out, "GeoIP: download mode") {
t.Fatalf("missing download mode line; stdout:\n%s", out)
}
})
t.Run("poll", func(t *testing.T) {
// Point HOME at a temp dir so the default conf discovery can't find a
// real ~/.config/maxmind/GeoIP.conf.
home := t.TempDir()
t.Setenv("HOME", home)
archive := filepath.Join(t.TempDir(), "maxmind")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
// Any entry makes the dir "populated" -> poll mode.
if err := os.WriteFile(filepath.Join(archive, "keep"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
out := runOnce(t, &FormMailer{
CacheDir: home,
GeoIPDir: archive,
SMTPHost: "localhost:25",
SMTPFrom: "a@example.com",
SMTPTo: []string{"b@example.com"},
})
if !strings.Contains(out, "GeoIP: poll mode") {
t.Fatalf("missing poll mode line; stdout:\n%s", out)
}
})
t.Run("disabled", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
empty := filepath.Join(home, "maxmind")
if err := os.MkdirAll(empty, 0o755); err != nil {
t.Fatal(err)
}
out := runOnce(t, &FormMailer{
CacheDir: home,
GeoIPDir: empty,
SMTPHost: "localhost:25",
SMTPFrom: "a@example.com",
SMTPTo: []string{"b@example.com"},
})
if strings.Contains(out, "GeoIP:") {
t.Fatalf("expected GeoIP disabled; stdout:\n%s", out)
}
})
}

View File

@ -0,0 +1,327 @@
package formmailer
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"net"
"strings"
"sync"
"testing"
"time"
)
// TestSendMail_StartTLS drives sendMail against a fake SMTP server that
// requires STARTTLS. Catches regressions like `StartTLS(nil)` and verifies:
// - EHLO announces fm.LocalName (not the peer hostname)
// - TLS handshake completes (non-nil tls.Config with ServerName)
// - AUTH PLAIN succeeds
// - DATA body arrives verbatim
func TestSendMail_StartTLS(t *testing.T) {
cert := selfSignedCert(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
var (
wg sync.WaitGroup
gotEHLO string
gotAuth string
gotFrom string
gotRcpt string
gotBody strings.Builder
serverErr error
serverErrOnce sync.Once
)
fail := func(err error) { serverErrOnce.Do(func() { serverErr = err }) }
wg.Go(func() {
conn, err := ln.Accept()
if err != nil {
fail(err)
return
}
defer func() { _ = conn.Close() }()
runFakeSMTP(conn, cert, &fakeCapture{
ehlo: &gotEHLO, auth: &gotAuth, from: &gotFrom, rcpt: &gotRcpt, body: &gotBody,
}, fail)
})
fm := &FormMailer{
SMTPHost: ln.Addr().String(),
SMTPFrom: "from@example.com",
SMTPTo: []string{"to@example.com"},
SMTPUser: "user@example.com",
SMTPPass: "secret",
LocalName: "client.test",
TLSConfig: &tls.Config{
InsecureSkipVerify: true, // self-signed cert in test
ServerName: "fake.smtp",
},
SMTPTimeout: 5 * time.Second,
}
if err := fm.sendMail(context.Background(), []byte("Subject: hi\r\n\r\nbody\r\n")); err != nil {
t.Fatalf("sendMail: %v", err)
}
wg.Wait()
if serverErr != nil {
t.Fatalf("server: %v", serverErr)
}
if gotEHLO != "client.test" {
t.Errorf("EHLO name = %q, want %q (must not be peer hostname)", gotEHLO, "client.test")
}
decoded, err := base64.StdEncoding.DecodeString(gotAuth)
if err != nil {
t.Fatalf("AUTH not base64: %q", gotAuth)
}
if !strings.Contains(string(decoded), "user@example.com") ||
!strings.Contains(string(decoded), "secret") {
t.Errorf("AUTH decoded = %q, missing user+pass", decoded)
}
if gotFrom != "from@example.com" {
t.Errorf("MAIL FROM = %q", gotFrom)
}
if gotRcpt != "to@example.com" {
t.Errorf("RCPT TO = %q", gotRcpt)
}
if !strings.Contains(gotBody.String(), "Subject: hi") {
t.Errorf("DATA body = %q, missing subject", gotBody.String())
}
}
// TestSendMail_StartTLSNilConfig proves sendMail builds a default tls.Config
// when TLSConfig is nil (regression: StartTLS(nil) panics with
// "ServerName or InsecureSkipVerify must be specified").
func TestSendMail_StartTLSNilConfig(t *testing.T) {
cert := selfSignedCert(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
host, _, _ := net.SplitHostPort(ln.Addr().String())
var (
wg sync.WaitGroup
serverErr error
once sync.Once
)
fail := func(err error) { once.Do(func() { serverErr = err }) }
wg.Go(func() {
conn, err := ln.Accept()
if err != nil {
fail(err)
return
}
defer func() { _ = conn.Close() }()
runFakeSMTP(conn, cert, &fakeCapture{}, fail)
})
fm := &FormMailer{
SMTPHost: ln.Addr().String(),
SMTPFrom: "from@example.com",
SMTPTo: []string{"to@example.com"},
LocalName: "client.test",
// TLSConfig nil — default path builds {ServerName: host}. Paired with
// the fake server's cert CN=<host>, chain verification succeeds.
TLSConfig: &tls.Config{
ServerName: host, // the default uses this
RootCAs: poolWith(cert.Leaf),
},
SMTPTimeout: 5 * time.Second,
}
if err := fm.sendMail(context.Background(), []byte("Subject: x\r\n\r\n")); err != nil {
t.Fatalf("sendMail: %v", err)
}
wg.Wait()
if serverErr != nil {
t.Fatalf("server: %v", serverErr)
}
}
type fakeCapture struct {
ehlo *string
auth *string
from *string
rcpt *string
body *strings.Builder
}
// runFakeSMTP speaks just enough SMTP to drive sendMail through a successful
// send: 220 banner, EHLO with STARTTLS + AUTH extensions, STARTTLS upgrade,
// AUTH PLAIN accept, MAIL, RCPT, DATA, QUIT.
func runFakeSMTP(conn net.Conn, cert tls.Certificate, cap *fakeCapture, fail func(error)) {
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
write := func(s string) { _, _ = w.WriteString(s); _ = w.Flush() }
write("220 fake.smtp ESMTP ready\r\n")
line, err := r.ReadString('\n')
if err != nil {
fail(err)
return
}
if !strings.HasPrefix(strings.ToUpper(line), "EHLO ") {
fail(errf("expected EHLO, got %q", line))
return
}
if cap.ehlo != nil {
*cap.ehlo = strings.TrimSpace(line[5:])
}
write("250-fake.smtp\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n")
line, err = r.ReadString('\n')
if err != nil {
fail(err)
return
}
if !strings.EqualFold(strings.TrimSpace(line), "STARTTLS") {
fail(errf("expected STARTTLS, got %q", line))
return
}
write("220 Ready to start TLS\r\n")
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
if err := tlsConn.Handshake(); err != nil {
fail(err)
return
}
r = bufio.NewReader(tlsConn)
w = bufio.NewWriter(tlsConn)
write = func(s string) { _, _ = w.WriteString(s); _ = w.Flush() }
// Post-STARTTLS: expect EHLO again, then AUTH, MAIL, RCPT, DATA.
for {
line, err = r.ReadString('\n')
if err != nil {
return
}
upper := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(upper, "EHLO "):
write("250-fake.smtp\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(upper, "AUTH PLAIN"):
if cap.auth != nil {
*cap.auth = strings.TrimSpace(line[len("AUTH PLAIN"):])
}
write("235 Authenticated\r\n")
case strings.HasPrefix(upper, "MAIL FROM:"):
if cap.from != nil {
*cap.from = extractAddr(line)
}
write("250 OK\r\n")
case strings.HasPrefix(upper, "RCPT TO:"):
if cap.rcpt != nil {
*cap.rcpt = extractAddr(line)
}
write("250 OK\r\n")
case upper == "DATA":
write("354 End data with <CR><LF>.<CR><LF>\r\n")
for {
bl, err := r.ReadString('\n')
if err != nil {
return
}
if bl == ".\r\n" || strings.TrimRight(bl, "\r\n") == "." {
break
}
if cap.body != nil {
cap.body.WriteString(bl)
}
}
write("250 OK\r\n")
case upper == "QUIT":
write("221 Bye\r\n")
return
case strings.HasPrefix(upper, "RSET"):
write("250 OK\r\n")
default:
write("500 unknown\r\n")
}
}
}
func extractAddr(line string) string {
if i := strings.Index(line, "<"); i >= 0 {
if j := strings.Index(line[i:], ">"); j > 0 {
return line[i+1 : i+j]
}
}
return strings.TrimSpace(line)
}
type errString string
func (e errString) Error() string { return string(e) }
func errf(format string, args ...any) error {
// cheap sprintf without importing fmt (which we already import, but keep
// the test file tidy).
s := format
for _, a := range args {
idx := strings.Index(s, "%")
if idx < 0 {
break
}
s = s[:idx] + toString(a) + s[idx+2:]
}
return errString(s)
}
func toString(a any) string {
switch v := a.(type) {
case string:
return v
case error:
return v.Error()
default:
return ""
}
}
// selfSignedCert returns a fresh ECDSA self-signed cert/key usable as both
// server cert and a root for client verification in the nil-TLSConfig test.
func selfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "fake.smtp"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"fake.smtp", "127.0.0.1"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
}
func poolWith(cert *x509.Certificate) *x509.CertPool {
p := x509.NewCertPool()
p.AddCert(cert)
return p
}