From 950aea9eeb006058eaabf1acac8a5f5e513ea549 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 20 Apr 2026 22:09:11 -0600 Subject: [PATCH] feat: add cmd/form2mail using formmailer/ipcohort/geoip/gitshallow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in replacement for the legacy standalone form2mail binary. Preserves CLI flags, env vars, .env loading, password prompt, response file hot-reload, field mapping (input_1/3/4/5/7), rate limit (5/min, burst 3), North-America country gate, .ru silent-drop, routes, startup banner, and [REDACTED] placeholder for bot rejections. Changes vs. legacy: - Replaces embedded iploc with net/geoip (requires GeoIP.conf) - Reads bitwire-it repo's native layout (tables/inbound/*) instead of a hand-managed flat inbound.txt - gitshallow.Repo with GCInterval=24 keeps .git from accumulating orphaned blobs after hourly upstream updates formmailer additions to support form2mail's legacy behavior: - SuccessBodyFunc / ErrorBodyFunc — per-request body providers for hot-reloadable templates - HiddenSupportValue — string to render in place of {.SupportEmail} for blacklist/bot rejections (form2mail uses "[REDACTED]") --- cmd/form2mail/.gitignore | 1 + cmd/form2mail/ENVS_IN_HOME_CONFIG | 0 cmd/form2mail/README.md | 1 + cmd/form2mail/error-file.html | 95 ++++++++ cmd/form2mail/form2mail.go | 348 ++++++++++++++++++++++++++++++ cmd/form2mail/go.mod | 30 +++ cmd/form2mail/go.sum | 20 ++ cmd/form2mail/success-file.html | 83 +++++++ go.work | 1 + go.work.sum | 7 + net/formmailer/formmailer.go | 40 +++- 11 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 cmd/form2mail/.gitignore create mode 100644 cmd/form2mail/ENVS_IN_HOME_CONFIG create mode 100644 cmd/form2mail/README.md create mode 100644 cmd/form2mail/error-file.html create mode 100644 cmd/form2mail/form2mail.go create mode 100644 cmd/form2mail/go.mod create mode 100644 cmd/form2mail/go.sum create mode 100644 cmd/form2mail/success-file.html create mode 100644 go.work.sum diff --git a/cmd/form2mail/.gitignore b/cmd/form2mail/.gitignore new file mode 100644 index 0000000..b5a38fe --- /dev/null +++ b/cmd/form2mail/.gitignore @@ -0,0 +1 @@ +/form2mail diff --git a/cmd/form2mail/ENVS_IN_HOME_CONFIG b/cmd/form2mail/ENVS_IN_HOME_CONFIG new file mode 100644 index 0000000..e69de29 diff --git a/cmd/form2mail/README.md b/cmd/form2mail/README.md new file mode 100644 index 0000000..bbd6bcb --- /dev/null +++ b/cmd/form2mail/README.md @@ -0,0 +1 @@ +https://grok.com/c/3d268fcf-e236-49f3-8f74-c007bdc795b5?rid=c834f57c-ff18-4dd6-8be8-115223ae3801 diff --git a/cmd/form2mail/error-file.html b/cmd/form2mail/error-file.html new file mode 100644 index 0000000..b6fa72a --- /dev/null +++ b/cmd/form2mail/error-file.html @@ -0,0 +1,95 @@ + + + + + + +
+ +
+
+

+ + + There was a problem with your submission. +
+
+ Please email {.SupportEmail} directly. +
+
+ For reference, this was the error: +
+ {.Error} +

+
+
+ + + diff --git a/cmd/form2mail/form2mail.go b/cmd/form2mail/form2mail.go new file mode 100644 index 0000000..40432b2 --- /dev/null +++ b/cmd/form2mail/form2mail.go @@ -0,0 +1,348 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/term" + + "github.com/joho/godotenv" + + "github.com/therootcompany/golib/net/formmailer" + "github.com/therootcompany/golib/net/geoip" + "github.com/therootcompany/golib/net/gitshallow" + "github.com/therootcompany/golib/net/httpcache" + "github.com/therootcompany/golib/net/ipcohort" + "github.com/therootcompany/golib/sync/dataset" +) + +const ( + name = "form2email" + licenseYear = "2026" + licenseOwner = "AJ ONeal" + licenseType = "CC0-1.0" + + defaultBlocklistRepo = "git@github.com:bitwire-it/ipblocklist.git" + refreshInterval = 47 * time.Minute + + requestsPerMinute = 5 + burstSize = 3 +) + +var ( + version = "0.0.0-dev" + commit = "0000000" + date = "0001-01-01T00:00:00Z" +) + +func printVersion(out io.Writer) { + if len(commit) > 7 { + commit = commit[:7] + } + _, _ = fmt.Fprintf(out, "%s v%s %s (%s)\n", name, version, commit, date) + _, _ = fmt.Fprintf(out, "Copyright (C) %s %s\n", licenseYear, licenseOwner) + _, _ = fmt.Fprintf(out, "Licensed under the %s license\n", licenseType) +} + +type MainConfig struct { + showVersion bool + listenAddr string + smtpHost string + smtpFrom string + smtpToList string + smtpUser string + smtpPass string + smtpSubject string + successFile string + errorFile string + responseType string + blocklistRepo string + cacheDir string + geoipConfPath string +} + +func main() { + home, _ := os.UserHomeDir() + _ = godotenv.Load() + _ = godotenv.Load(filepath.Join(home, ".config/form2mail/env")) + + cfg := MainConfig{ + listenAddr: "localhost:3081", + smtpHost: os.Getenv("SMTP_HOST"), + smtpFrom: os.Getenv("SMTP_FROM"), + smtpToList: os.Getenv("SMTP_TO"), + smtpUser: os.Getenv("SMTP_USER"), + smtpSubject: "Website contact request from {.Email}", + successFile: "success-file.html", + errorFile: "error-file.html", + responseType: "text/plain", + blocklistRepo: defaultBlocklistRepo, + } + + fs := flag.NewFlagSet("", flag.ContinueOnError) + fs.BoolVar(&cfg.showVersion, "version", false, "Print version and exit") + fs.StringVar(&cfg.listenAddr, "listen", cfg.listenAddr, "Address to listen on") + fs.StringVar(&cfg.smtpHost, "smtp-host", cfg.smtpHost, "SMTP server:port e.g. smtp.gmail.com:587 (required)") + fs.StringVar(&cfg.smtpFrom, "smtp-from", cfg.smtpFrom, "Sender email e.g. you@gmail.com (required)") + fs.StringVar(&cfg.smtpToList, "smtp-to", cfg.smtpToList, "Recipient email e.g. alerts@yourdomain.com (required)") + fs.StringVar(&cfg.smtpUser, "smtp-user", cfg.smtpUser, "SMTP username (defaults to smtp-from if not set)") + fs.StringVar(&cfg.successFile, "success-file", cfg.successFile, "HTML or JSON file to reply with on success.") + fs.StringVar(&cfg.errorFile, "error-file", cfg.errorFile, "HTML or JSON file to reply with on failure.") + fs.StringVar(&cfg.blocklistRepo, "blocklist-repo", cfg.blocklistRepo, "git URL of the bitwire-it-compatible blocklist repo") + fs.StringVar(&cfg.cacheDir, "cache-dir", "", "cache parent dir (default: ~/.cache)") + fs.StringVar(&cfg.geoipConfPath, "geoip-conf", "", "path to GeoIP.conf (default: ./GeoIP.conf or ~/.config/maxmind/GeoIP.conf)") + + fs.Usage = func() { + printVersion(os.Stderr) + fmt.Fprintln(os.Stderr, "\nUSAGE") + fmt.Fprintln(os.Stderr, " form2email [options]") + fs.PrintDefaults() + fmt.Fprintln(os.Stderr, "\nEnv vars (overrides flags): SMTP_HOST, SMTP_FROM, SMTP_TO, SMTP_USER, SMTP_PASS") + } + + if err := fs.Parse(os.Args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err) + fs.Usage() + os.Exit(1) + } + + if cfg.showVersion { + printVersion(os.Stdout) + return + } + + if cfg.smtpHost == "" || cfg.smtpFrom == "" || cfg.smtpToList == "" { + fmt.Fprintf(os.Stderr, "\nError: missing required SMTP settings\n\n") + fs.Usage() + fmt.Fprintf(os.Stderr, "\nError: missing required SMTP settings\n\n") + os.Exit(1) + } + printVersion(os.Stderr) + + // Verify templates are readable at startup; re-read on each request so + // operators can edit HTML without restarting (matches legacy behavior). + successBody, err := os.ReadFile(cfg.successFile) + if err != nil { + fmt.Fprintf(os.Stderr, "\nError: couldn't read success response file %q: %v\n\n", cfg.successFile, err) + os.Exit(1) + } + if _, err := os.ReadFile(cfg.errorFile); err != nil { + fmt.Fprintf(os.Stderr, "\nError: couldn't read error response file %q: %v\n\n", cfg.errorFile, err) + os.Exit(1) + } + successBodyFn := func() []byte { + b, err := os.ReadFile(cfg.successFile) + if err != nil { + log.Printf("success-file read: %v", err) + return successBody + } + return b + } + errorBodyFn := func() []byte { + b, err := os.ReadFile(cfg.errorFile) + if err != nil { + log.Printf("error-file read: %v", err) + return nil + } + return b + } + + if cfg.smtpUser == "" { + cfg.smtpUser = cfg.smtpFrom + } + if cfg.smtpFrom == "" { + cfg.smtpFrom = cfg.smtpUser + } + + if pass, hasPass := os.LookupEnv("SMTP_PASS"); !hasPass { + fmt.Fprintf(os.Stderr, "SMTP_PASS not set → ") + pwBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + log.Fatalf("Failed to read password: %v", err) + } + fmt.Fprintln(os.Stderr) + cfg.smtpPass = strings.TrimSpace(string(pwBytes)) + } else { + cfg.smtpPass = pass + } + + cfg.responseType = inferContentType(cfg.successFile) + + if cfg.cacheDir == "" { + cfg.cacheDir = filepath.Join(home, ".cache") + } + + // GeoIP config discovery: explicit --geoip-conf wins; otherwise default paths. + if cfg.geoipConfPath == "" { + for _, p := range geoip.DefaultConfPaths() { + if _, err := os.Stat(p); err == nil { + cfg.geoipConfPath = p + break + } + } + } + if cfg.geoipConfPath == "" { + log.Fatalf("geoip-conf: not found; set --geoip-conf or place GeoIP.conf in a default location.\n"+ + "GeoLite2 registration is free at https://www.maxmind.com/en/geolite2/signup\n"+ + "Default search paths: %v", geoip.DefaultConfPaths()) + } + confData, err := os.ReadFile(cfg.geoipConfPath) + if err != nil { + log.Fatalf("geoip-conf: %v", err) + } + conf, err := geoip.ParseConf(string(confData)) + if err != nil { + log.Fatalf("geoip-conf: %v", err) + } + geoipBasicAuth := httpcache.BasicAuth(conf.AccountID, conf.LicenseKey) + + // Blocklist: gitshallow-backed cohort, reloaded on each git HEAD change. + repo := gitshallow.New(cfg.blocklistRepo, filepath.Join(cfg.cacheDir, "bitwire-it"), 1, "") + repo.MaxAge = refreshInterval + // Aggressive GC every 24 fetches (~roughly daily at 47min cadence). + // bitwire-it auto-commits hourly with large blobs; without prune=now the + // 2-week default grace window lets orphaned objects accumulate into GB. + repo.GCInterval = 24 + blocklistSet := dataset.NewSet(repo) + blacklist := dataset.AddInitial(blocklistSet, ipcohort.New(), func(_ context.Context) (*ipcohort.Cohort, error) { + return ipcohort.LoadFiles( + repo.FilePath("tables/inbound/single_ips.txt"), + repo.FilePath("tables/inbound/networks.txt"), + ) + }) + fmt.Fprint(os.Stderr, "Syncing git repo ... ") + tBL := time.Now() + if err := blocklistSet.Load(context.Background()); err != nil { + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, "error: ip cohort: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "%s (entries=%d)\n", + time.Since(tBL).Round(time.Millisecond), blacklist.Value().Size()) + } + + // GeoIP: City + ASN tarballs via httpcache conditional GETs. + maxmindDir := filepath.Join(cfg.cacheDir, "maxmind") + authHeader := http.Header{"Authorization": []string{geoipBasicAuth}} + geoSet := dataset.NewSet( + &httpcache.Cacher{ + URL: geoip.DownloadBase + "/GeoLite2-City/download?suffix=tar.gz", + Path: filepath.Join(maxmindDir, geoip.TarGzName(geoip.CityEdition)), + MaxAge: 3 * 24 * time.Hour, + Header: authHeader, + }, + &httpcache.Cacher{ + URL: geoip.DownloadBase + "/GeoLite2-ASN/download?suffix=tar.gz", + Path: filepath.Join(maxmindDir, geoip.TarGzName(geoip.ASNEdition)), + MaxAge: 3 * 24 * time.Hour, + Header: authHeader, + }, + ) + geo := dataset.Add(geoSet, func(_ context.Context) (*geoip.Databases, error) { + return geoip.Open(maxmindDir) + }) + fmt.Fprint(os.Stderr, "Loading geoip... ") + tGeo := time.Now() + if err := geoSet.Load(context.Background()); err != nil { + fmt.Fprintln(os.Stderr) + log.Fatalf("geoip: %v", err) + } + fmt.Fprintf(os.Stderr, "%s\n", time.Since(tGeo).Round(time.Millisecond)) + + fm := &formmailer.FormMailer{ + SMTPHost: cfg.smtpHost, + SMTPFrom: cfg.smtpFrom, + SMTPTo: strings.Split(cfg.smtpToList, ","), + SMTPUser: cfg.smtpUser, + SMTPPass: cfg.smtpPass, + Subject: cfg.smtpSubject, + SuccessBody: successBody, // fallback if read fails + SuccessBodyFunc: successBodyFn, + ErrorBodyFunc: errorBodyFn, + ContentType: cfg.responseType, + // Legacy behavior: bot/blacklist rejections render {.SupportEmail} + // as "[REDACTED]" rather than leaving it blank. + HiddenSupportValue: "[REDACTED]", + Blacklist: blacklist, + Geo: geo, + // North America + unknown. Unknown ("") is always allowed by formmailer. + AllowedCountries: []string{"US", "CA", "MX", "CR", "VI"}, + 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: "Message", FormName: "input_7", Kind: formmailer.KindMessage}, + }, + RPM: requestsPerMinute, + Burst: burstSize, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go blocklistSet.Tick(ctx, refreshInterval, func(err error) { + log.Printf("blocklist refresh: %v", err) + }) + go geoSet.Tick(ctx, refreshInterval, func(err error) { + log.Printf("geoip refresh: %v", err) + }) + + contact := silentDropRU(fm, successBody, cfg.responseType) + http.Handle("POST /contact", contact) + http.Handle("POST /contact/", contact) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintln(w, "form2email server running. POST form data to /contact") + }) + + fmt.Printf("form2email listening on http://%s\n", cfg.listenAddr) + fmt.Printf("Forwarding submissions from %s → %s via %s\n", cfg.smtpFrom, cfg.smtpToList, cfg.smtpHost) + fmt.Printf("Rate limit: ~%d req/min per IP (burst %d)\n", requestsPerMinute, burstSize) + fmt.Println("CTRL+C to stop") + + log.Fatal(http.ListenAndServe(cfg.listenAddr, nil)) +} + +// silentDropRU returns a handler that silently returns the success body for +// submissions whose "input_3" email ends with ".ru" — legacy spam-trap +// behavior from the original form2mail. All other submissions fall through +// to fm. +func silentDropRU(fm http.Handler, successBody []byte, contentType string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ParseMultipartForm is safe to call twice; formmailer will see the + // already-parsed form. Use a bounded reader to match formmailer's cap. + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) + if err := r.ParseMultipartForm(10 * 1024); err == nil { + email := strings.ToLower(strings.TrimSpace(r.FormValue("input_3"))) + if strings.HasSuffix(email, ".ru") { + w.Header().Set("Content-Type", contentType) + _, _ = w.Write(successBody) + return + } + } + fm.ServeHTTP(w, r) + }) +} + +func inferContentType(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".html", ".htm": + return "text/html; charset=utf-8" + case ".json": + return "application/json" + default: + return "text/plain; charset=utf-8" + } +} diff --git a/cmd/form2mail/go.mod b/cmd/form2mail/go.mod new file mode 100644 index 0000000..1afdb98 --- /dev/null +++ b/cmd/form2mail/go.mod @@ -0,0 +1,30 @@ +module github.com/therootcompany/golib/cmd/form2mail + +go 1.26.0 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/therootcompany/golib/net/formmailer v0.0.0 + github.com/therootcompany/golib/net/geoip v0.0.0 + github.com/therootcompany/golib/net/gitshallow v0.0.0 + github.com/therootcompany/golib/net/httpcache v0.0.0 + github.com/therootcompany/golib/net/ipcohort v0.0.0 + github.com/therootcompany/golib/sync/dataset v0.0.0 + golang.org/x/term v0.39.0 +) + +require ( + github.com/oschwald/geoip2-golang v1.13.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect +) + +replace ( + github.com/therootcompany/golib/net/formmailer v0.0.0 => ../../net/formmailer + github.com/therootcompany/golib/net/geoip v0.0.0 => ../../net/geoip + github.com/therootcompany/golib/net/gitshallow v0.0.0 => ../../net/gitshallow + github.com/therootcompany/golib/net/httpcache v0.0.0 => ../../net/httpcache + github.com/therootcompany/golib/net/ipcohort v0.0.0 => ../../net/ipcohort + github.com/therootcompany/golib/sync/dataset v0.0.0 => ../../sync/dataset +) diff --git a/cmd/form2mail/go.sum b/cmd/form2mail/go.sum new file mode 100644 index 0000000..24f985c --- /dev/null +++ b/cmd/form2mail/go.sum @@ -0,0 +1,20 @@ +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +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= diff --git a/cmd/form2mail/success-file.html b/cmd/form2mail/success-file.html new file mode 100644 index 0000000..2fa6870 --- /dev/null +++ b/cmd/form2mail/success-file.html @@ -0,0 +1,83 @@ + + + + + + +
+
+ +
+ + Thanks for contacting us! We will get in touch with you shortly. +
+
+ + diff --git a/go.work b/go.work index 29a4b42..d8b4543 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.26.1 use ( . ./cmd/check-ip + ./cmd/form2mail ./net/formmailer ./net/geoip ./net/gitshallow diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..07d502d --- /dev/null +++ b/go.work.sum @@ -0,0 +1,7 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/net/formmailer/formmailer.go b/net/formmailer/formmailer.go index 954f72f..05c4c1e 100644 --- a/net/formmailer/formmailer.go +++ b/net/formmailer/formmailer.go @@ -141,7 +141,17 @@ type FormMailer struct { // ErrorBody may contain {.Error} and {.SupportEmail} placeholders. SuccessBody []byte ErrorBody []byte - ContentType string // inferred from SuccessBody if empty + // SuccessBodyFunc / ErrorBodyFunc, if set, are called per request and + // override SuccessBody / ErrorBody. Use for hot-reloadable templates + // (e.g. re-read an HTML file on every request). + SuccessBodyFunc func() []byte + ErrorBodyFunc func() []byte + ContentType string // inferred from SuccessBody if empty + + // HiddenSupportValue replaces {.SupportEmail} on error responses for + // requests that should not learn the operator's address (blacklist / bot + // rejections). Zero value "" hides the placeholder entirely. + HiddenSupportValue string // Blacklist — if set, matching IPs are rejected before any other processing. Blacklist *dataset.View[ipcohort.Cohort] @@ -192,14 +202,32 @@ func (fm *FormMailer) init() { } } +func (fm *FormMailer) successBody() []byte { + if fm.SuccessBodyFunc != nil { + return fm.SuccessBodyFunc() + } + return fm.SuccessBody +} + +func (fm *FormMailer) errorBody() []byte { + if fm.ErrorBodyFunc != nil { + return fm.ErrorBodyFunc() + } + return fm.ErrorBody +} + func (fm *FormMailer) contentType() string { if fm.ContentType != "" { return fm.ContentType } - if bytes.Contains(fm.SuccessBody[:min(512, len(fm.SuccessBody))], []byte("