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..41bf6c3
--- /dev/null
+++ b/cmd/form2mail/form2mail.go
@@ -0,0 +1,227 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/netip"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/term"
+
+ "github.com/joho/godotenv"
+
+ "github.com/therootcompany/golib/net/formmailer"
+)
+
+const (
+ name = "form2email"
+ licenseYear = "2026"
+ licenseOwner = "AJ ONeal"
+ licenseType = "CC0-1.0"
+
+ defaultBlocklistRepo = "https://github.com/bitwire-it/ipblocklist.git"
+
+ 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
+ blocklistRepo string
+ cacheDir string
+ geoipDir string
+ geoipBaseURL 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",
+ 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.geoipDir, "geoip-dir", "", "dir holding GeoLite2 tarballs (default: <$cache-dir>/maxmind)")
+ fs.StringVar(&cfg.geoipBaseURL, "geoip-base-url", "", "MaxMind download base URL (default: https://download.maxmind.com/geoip/databases)")
+ 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).
+ successFallback, 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)
+ }
+ errorFallback, err := os.ReadFile(cfg.errorFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "\nError: couldn't read error response file %q: %v\n\n", cfg.errorFile, err)
+ os.Exit(1)
+ }
+ successBody := hotReload(cfg.successFile, successFallback)
+ errorBody := hotReload(cfg.errorFile, errorFallback)
+
+ if cfg.smtpUser == "" {
+ cfg.smtpUser = cfg.smtpFrom
+ }
+
+ 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
+ }
+
+ // Build FormMailer — all blocklist/GeoIP setup is handled by Run().
+ 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},
+ }
+
+ fm := &formmailer.FormMailer{
+ ListenAddr: cfg.listenAddr,
+ SMTPHost: cfg.smtpHost,
+ SMTPFrom: cfg.smtpFrom,
+ SMTPTo: strings.Split(cfg.smtpToList, ","),
+ SMTPUser: cfg.smtpUser,
+ SMTPPass: cfg.smtpPass,
+ Subject: cfg.smtpSubject,
+ SuccessBody: successBody,
+ ErrorBody: errorBody,
+ ContentType: inferContentType(cfg.successFile),
+ HiddenSupportValue: "[REDACTED]",
+ TrustedProxies: []netip.Prefix{
+ netip.MustParsePrefix("127.0.0.0/8"),
+ netip.MustParsePrefix("::1/128"),
+ netip.MustParsePrefix("10.0.0.0/8"),
+ netip.MustParsePrefix("172.16.0.0/12"),
+ netip.MustParsePrefix("192.168.0.0/16"),
+ },
+ BlocklistRepo: cfg.blocklistRepo,
+ CacheDir: cfg.cacheDir,
+ GeoIPDir: cfg.geoipDir,
+ GeoIPBaseURL: cfg.geoipBaseURL,
+ GeoIPConfPath: cfg.geoipConfPath,
+ AllowedCountries: []string{"US", "CA", "MX", "CR", "VI"},
+ Fields: fields,
+ RPM: requestsPerMinute,
+ Burst: burstSize,
+ }
+
+ if err := fm.Run(context.Background()); err != nil {
+ log.Fatalf("server: %v", err)
+ }
+}
+
+// hotReload returns a function that re-reads path on each call, falling back
+// to the provided bytes on read error (logged). Used for templates that
+// operators may edit out-of-band.
+func hotReload(path string, fallback []byte) func() []byte {
+ return func() []byte {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ log.Printf("%s read: %v", path, err)
+ return fallback
+ }
+ return b
+ }
+}
+
+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..e29e869
--- /dev/null
+++ b/cmd/form2mail/go.mod
@@ -0,0 +1,23 @@
+module github.com/therootcompany/golib/cmd/form2mail
+
+go 1.26.5
+
+require (
+ github.com/joho/godotenv v1.5.1
+ github.com/therootcompany/golib/net/formmailer v0.1.0
+ golang.org/x/term v0.40.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/geoip v0.5.3 // indirect
+ github.com/therootcompany/golib/net/gitshallow v0.9.3 // indirect
+ github.com/therootcompany/golib/net/httpcache v0.5.1 // indirect
+ github.com/therootcompany/golib/net/ipcohort v0.9.0 // indirect
+ github.com/therootcompany/golib/sync/dataset v0.5.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+)
diff --git a/cmd/form2mail/go.sum b/cmd/form2mail/go.sum
new file mode 100644
index 0000000..e9063fc
--- /dev/null
+++ b/cmd/form2mail/go.sum
@@ -0,0 +1,36 @@
+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=
+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/formmailer v0.1.0 h1:Ckd7YSdi4FNoFOiqAyI0WQ+nJL4c2vK4TCNM08I89X8=
+github.com/therootcompany/golib/net/formmailer v0.1.0/go.mod h1:Eo6hF985DyZ2XsF/IxMGWTXSs3r1RVWdNzK7KsVKCk0=
+github.com/therootcompany/golib/net/geoip v0.5.3 h1:wps0TrRoGYw7XA5mWP0uOkGBF5oZ5rQ+1JPIAkRYrOU=
+github.com/therootcompany/golib/net/geoip v0.5.3/go.mod h1:3CuuG2igsZySLNrRxoRTHPw3fmw9c4b0jRajSOOKJNU=
+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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
+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/scripts/deploy.sh b/cmd/form2mail/scripts/deploy.sh
new file mode 100644
index 0000000..b4a3605
--- /dev/null
+++ b/cmd/form2mail/scripts/deploy.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+# Cross-compile form2mail for linux-amd64 and deploy to @.
+# Usage: scripts/deploy.sh @ [service-name]
+#
+# Uses the rename-old-first pattern (avoids scp ETXTBSY on running ELFs),
+# restarts via systemctl, verifies --version runs from the new inode, then
+# removes the .old backup. Leaves .old in place if verification fails so
+# rollback is `mv ~/bin/.old ~/bin/`.
+set -eu
+
+g_remote="${1:?usage: $0 @ [service-name]}"
+g_service="${2:-form2mail}"
+g_bin="form2mail"
+
+cd "$(dirname "$0")/.."
+
+echo "Building ${g_bin} for linux-amd64..."
+GOOS=linux GOARCH=amd64 go build -o "${g_bin}-linux" .
+
+echo "Renaming old binary on ${g_remote}..."
+ssh "${g_remote}" "mv ~/bin/${g_bin} ~/bin/${g_bin}.old"
+
+echo "Copying new binary..."
+scp "${g_bin}-linux" "${g_remote}:~/bin/${g_bin}"
+
+echo "Restarting ${g_service}..."
+ssh "${g_remote}" "sudo systemctl restart ${g_service}"
+sleep 2
+
+echo "Verifying..."
+if ! ssh "${g_remote}" "systemctl is-active ${g_service}" > /dev/null; then
+ echo " FAILED: ${g_service} not active. Logs:"
+ ssh "${g_remote}" "sudo journalctl -u ${g_service} -n 20 --no-pager"
+ echo " Backup still at ~/bin/${g_bin}.old — roll back with:"
+ echo " ssh ${g_remote} 'mv ~/bin/${g_bin}.old ~/bin/${g_bin} && sudo systemctl restart ${g_service}'"
+ exit 1
+fi
+
+echo "Removing backup..."
+ssh "${g_remote}" "rm ~/bin/${g_bin}.old"
+
+echo "OK: ${g_service} deployed and active."
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 @@
+
+
+
+
+
+
+
+
+
+