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 @@
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
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("