diff --git a/cmd/form2mail/.gitignore b/cmd/form2mail/.gitignore
new file mode 100644
index 0000000..1a8843b
--- /dev/null
+++ b/cmd/form2mail/.gitignore
@@ -0,0 +1,2 @@
+/form2mail
+/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..8e1e19f
--- /dev/null
+++ b/cmd/form2mail/form2mail.go
@@ -0,0 +1,395 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/netip"
+ "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 = "https://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).
+ 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 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))
+
+ 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{
+ 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: cfg.responseType,
+ // Bot/blacklist rejections render {.SupportEmail} as "[REDACTED]"
+ // rather than leaking the real address. Validation errors (wrong
+ // format, missing field) still show the real support email so users
+ // know where to write in.
+ HiddenSupportValue: "[REDACTED]",
+ // Honor X-Forwarded-For from loopback (dev/ssh-tunnel) and RFC1918
+ // ranges (the TLS router lives on a private 10.x / 172.16.x address
+ // and fronts this service). Any other peer has XFF ignored, so a
+ // direct public request can't forge its origin IP.
+ 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"),
+ },
+ Blacklist: blacklist,
+ Geo: geo,
+ // North America + unknown. Unknown ("") is always allowed by formmailer.
+ AllowedCountries: []string{"US", "CA", "MX", "CR", "VI"},
+ Fields: fields,
+ RPM: requestsPerMinute,
+ Burst: burstSize,
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ defer func() { _ = geoSet.Close() }()
+ 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)
+ })
+
+ mux := http.NewServeMux()
+ emailFormName := ""
+ for _, f := range fields {
+ if f.Kind == formmailer.KindEmail {
+ emailFormName = f.FormName
+ break
+ }
+ }
+ contact := silentDropRU(fm, emailFormName, successBody, cfg.responseType)
+ mux.Handle("POST /contact", contact)
+ mux.Handle("POST /contact/", contact)
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprintln(w, "form2email server running. POST form data to /contact")
+ })
+
+ srv := &http.Server{Addr: cfg.listenAddr, Handler: mux}
+
+ 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")
+
+ serveErr := make(chan error, 1)
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ serveErr <- err
+ }
+ close(serveErr)
+ }()
+
+ select {
+ case <-ctx.Done():
+ log.Printf("shutdown: %v", ctx.Err())
+ case err := <-serveErr:
+ if err != nil {
+ log.Fatalf("listen: %v", err)
+ }
+ }
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Printf("shutdown: %v", err)
+ }
+}
+
+// silentDropRU returns a handler that silently returns the success body for
+// submissions whose email (emailFormName form input) ends with ".ru" —
+// legacy spam-trap behavior from the original form2mail. All other
+// submissions fall through to fm.
+func silentDropRU(fm http.Handler, emailFormName string, successBody func() []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(emailFormName)))
+ if strings.HasSuffix(email, ".ru") {
+ w.Header().Set("Content-Type", contentType)
+ _, _ = w.Write(successBody())
+ return
+ }
+ }
+ fm.ServeHTTP(w, r)
+ })
+}
+
+// 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..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/scripts/deploy.sh b/cmd/form2mail/scripts/deploy.sh
new file mode 100755
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 @@
+
+
+
+
+
+
+
+
+
+