From fb9df12a6a644d2980c3ce73c13f9f6009a8c0d5 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 9 Sep 2026 11:13:12 -0600 Subject: [PATCH] feat(cmd/form2mail): use released formmailer modules --- cmd/form2mail/README.md | 1 + cmd/form2mail/error-file.html | 95 +++++++++++++ cmd/form2mail/form2mail.go | 227 ++++++++++++++++++++++++++++++++ cmd/form2mail/go.mod | 23 ++++ cmd/form2mail/go.sum | 36 +++++ cmd/form2mail/scripts/deploy.sh | 42 ++++++ cmd/form2mail/success-file.html | 83 ++++++++++++ 7 files changed, 507 insertions(+) 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/scripts/deploy.sh create mode 100644 cmd/form2mail/success-file.html 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..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 @@ + + + + + + +
+
+ +
+ + Thanks for contacting us! We will get in touch with you shortly. +
+
+ +