feat: add cmd/form2mail using formmailer/ipcohort/geoip/gitshallow

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]")
This commit is contained in:
AJ ONeal 2026-04-20 22:09:11 -06:00
parent 0d4bce8a38
commit 950aea9eeb
No known key found for this signature in database
11 changed files with 620 additions and 6 deletions

1
cmd/form2mail/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/form2mail

View File

1
cmd/form2mail/README.md Normal file
View File

@ -0,0 +1 @@
https://grok.com/c/3d268fcf-e236-49f3-8f74-c007bdc795b5?rid=c834f57c-ff18-4dd6-8be8-115223ae3801

View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'/>
</head>
<body class='GF_AJAX_POSTBACK'>
<div class='gf_browser_chrome gform_wrapper gform-theme gform-theme--foundation gform-theme--framework gform-theme--orbital gform_validation_error' data-form-theme='orbital' data-form-index='0' id='gform_wrapper_1'>
<style>
#gform_wrapper_1[data-form-index="0"].gform-theme,[data-parent-form="1_0"] {
--gf-color-primary: #204ce5;
--gf-color-primary-rgb: 32, 76, 229;
--gf-color-primary-contrast: #fff;
--gf-color-primary-contrast-rgb: 255, 255, 255;
--gf-color-primary-darker: #001AB3;
--gf-color-primary-lighter: #527EFF;
--gf-color-secondary: #fff;
--gf-color-secondary-rgb: 255, 255, 255;
--gf-color-secondary-contrast: #112337;
--gf-color-secondary-contrast-rgb: 17, 35, 55;
--gf-color-secondary-darker: #F5F5F5;
--gf-color-secondary-lighter: #FFFFFF;
--gf-color-out-ctrl-light: rgba(17, 35, 55, 0.1);
--gf-color-out-ctrl-light-rgb: 17, 35, 55;
--gf-color-out-ctrl-light-darker: rgba(104, 110, 119, 0.35);
--gf-color-out-ctrl-light-lighter: #F5F5F5;
--gf-color-out-ctrl-dark: #585e6a;
--gf-color-out-ctrl-dark-rgb: 88, 94, 106;
--gf-color-out-ctrl-dark-darker: #112337;
--gf-color-out-ctrl-dark-lighter: rgba(17, 35, 55, 0.65);
--gf-color-in-ctrl: #fff;
--gf-color-in-ctrl-rgb: 255, 255, 255;
--gf-color-in-ctrl-contrast: #112337;
--gf-color-in-ctrl-contrast-rgb: 17, 35, 55;
--gf-color-in-ctrl-darker: #F5F5F5;
--gf-color-in-ctrl-lighter: #FFFFFF;
--gf-color-in-ctrl-primary: #204ce5;
--gf-color-in-ctrl-primary-rgb: 32, 76, 229;
--gf-color-in-ctrl-primary-contrast: #fff;
--gf-color-in-ctrl-primary-contrast-rgb: 255, 255, 255;
--gf-color-in-ctrl-primary-darker: #001AB3;
--gf-color-in-ctrl-primary-lighter: #527EFF;
--gf-color-in-ctrl-light: rgba(17, 35, 55, 0.1);
--gf-color-in-ctrl-light-rgb: 17, 35, 55;
--gf-color-in-ctrl-light-darker: rgba(104, 110, 119, 0.35);
--gf-color-in-ctrl-light-lighter: #F5F5F5;
--gf-color-in-ctrl-dark: #585e6a;
--gf-color-in-ctrl-dark-rgb: 88, 94, 106;
--gf-color-in-ctrl-dark-darker: #112337;
--gf-color-in-ctrl-dark-lighter: rgba(17, 35, 55, 0.65);
--gf-radius: 3px;
--gf-font-size-secondary: 14px;
--gf-font-size-tertiary: 13px;
--gf-icon-ctrl-number: url("data:image/svg+xml,%3Csvg width='8' height='14' viewBox='0 0 8 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4 0C4.26522 5.96046e-08 4.51957 0.105357 4.70711 0.292893L7.70711 3.29289C8.09763 3.68342 8.09763 4.31658 7.70711 4.70711C7.31658 5.09763 6.68342 5.09763 6.29289 4.70711L4 2.41421L1.70711 4.70711C1.31658 5.09763 0.683417 5.09763 0.292893 4.70711C-0.0976311 4.31658 -0.097631 3.68342 0.292893 3.29289L3.29289 0.292893C3.48043 0.105357 3.73478 0 4 0ZM0.292893 9.29289C0.683417 8.90237 1.31658 8.90237 1.70711 9.29289L4 11.5858L6.29289 9.29289C6.68342 8.90237 7.31658 8.90237 7.70711 9.29289C8.09763 9.68342 8.09763 10.3166 7.70711 10.7071L4.70711 13.7071C4.31658 14.0976 3.68342 14.0976 3.29289 13.7071L0.292893 10.7071C-0.0976311 10.3166 -0.0976311 9.68342 0.292893 9.29289Z' fill='rgba(17, 35, 55, 0.65)'/%3E%3C/svg%3E");
--gf-icon-ctrl-select: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0.292893 0.292893C0.683417 -0.097631 1.31658 -0.097631 1.70711 0.292893L5 3.58579L8.29289 0.292893C8.68342 -0.0976311 9.31658 -0.0976311 9.70711 0.292893C10.0976 0.683417 10.0976 1.31658 9.70711 1.70711L5.70711 5.70711C5.31658 6.09763 4.68342 6.09763 4.29289 5.70711L0.292893 1.70711C-0.0976311 1.31658 -0.0976311 0.683418 0.292893 0.292893Z' fill='rgba(17, 35, 55, 0.65)'/%3E%3C/svg%3E");
--gf-icon-ctrl-search: url("data:image/svg+xml,%3Csvg width='640' height='640' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M256 128c-70.692 0-128 57.308-128 128 0 70.691 57.308 128 128 128 70.691 0 128-57.309 128-128 0-70.692-57.309-128-128-128zM64 256c0-106.039 85.961-192 192-192s192 85.961 192 192c0 41.466-13.146 79.863-35.498 111.248l154.125 154.125c12.496 12.496 12.496 32.758 0 45.254s-32.758 12.496-45.254 0L367.248 412.502C335.862 434.854 297.467 448 256 448c-106.039 0-192-85.962-192-192z' fill='rgba(17, 35, 55, 0.65)'/%3E%3C/svg%3E");
--gf-label-space-y-secondary: var(--gf-label-space-y-md-secondary);
--gf-ctrl-border-color: #686e77;
--gf-ctrl-size: var(--gf-ctrl-size-md);
--gf-ctrl-label-color-primary: #112337;
--gf-ctrl-label-color-secondary: #112337;
--gf-ctrl-choice-size: var(--gf-ctrl-choice-size-md);
--gf-ctrl-checkbox-check-size: var(--gf-ctrl-checkbox-check-size-md);
--gf-ctrl-radio-check-size: var(--gf-ctrl-radio-check-size-md);
--gf-ctrl-btn-font-size: var(--gf-ctrl-btn-font-size-md);
--gf-ctrl-btn-padding-x: var(--gf-ctrl-btn-padding-x-md);
--gf-ctrl-btn-size: var(--gf-ctrl-btn-size-md);
--gf-ctrl-btn-border-color-secondary: #686e77;
--gf-ctrl-file-btn-bg-color-hover: #EBEBEB;
--gf-field-img-choice-size: var(--gf-field-img-choice-size-md);
--gf-field-img-choice-card-space: var(--gf-field-img-choice-card-space-md);
--gf-field-img-choice-check-ind-size: var(--gf-field-img-choice-check-ind-size-md);
--gf-field-img-choice-check-ind-icon-size: var(--gf-field-img-choice-check-ind-icon-size-md);
--gf-field-pg-steps-number-color: rgba(17, 35, 55, 0.8);
}
</style>
<div id='gf_1' class='gform_anchor' tabindex='-1'></div>
<div class="gform_validation_errors" id="gform_1_validation_container" data-js="gform-focus-validation-error" autofocus>
<h2 class='gform_submission_error hide_summary'>
<span class='gform-icon gform-icon--circle-error'></span>
<!-- NOTE: this is response managed by a mailer on the static server, not the WP instance -->
There was a problem with your submission.
<br/>
<br/>
Please email {.SupportEmail} directly.
<br/>
<br/>
For reference, this was the error:
<br/>
{.Error}
</h2>
</div>
</div>
</body>
</html>

348
cmd/form2mail/form2mail.go Normal file
View File

@ -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"
}
}

30
cmd/form2mail/go.mod Normal file
View File

@ -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
)

20
cmd/form2mail/go.sum Normal file
View File

@ -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=

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'/>
</head>
<body class='GF_AJAX_POSTBACK'>
<div id='gf_1' class='gform_anchor' tabindex='-1'></div>
<div id='gform_confirmation_wrapper_1' data-form-theme='orbital' class='gform_confirmation_wrapper gform_wrapper gform-theme gform-theme--foundation gform-theme--framework gform-theme--orbital '>
<style>
#gform_confirmation_wrapper_1.gform-theme {
--gf-color-primary: #204ce5;
--gf-color-primary-rgb: 32, 76, 229;
--gf-color-primary-contrast: #fff;
--gf-color-primary-contrast-rgb: 255, 255, 255;
--gf-color-primary-darker: #001AB3;
--gf-color-primary-lighter: #527EFF;
--gf-color-secondary: #fff;
--gf-color-secondary-rgb: 255, 255, 255;
--gf-color-secondary-contrast: #112337;
--gf-color-secondary-contrast-rgb: 17, 35, 55;
--gf-color-secondary-darker: #F5F5F5;
--gf-color-secondary-lighter: #FFFFFF;
--gf-color-out-ctrl-light: rgba(17, 35, 55, 0.1);
--gf-color-out-ctrl-light-rgb: 17, 35, 55;
--gf-color-out-ctrl-light-darker: rgba(104, 110, 119, 0.35);
--gf-color-out-ctrl-light-lighter: #F5F5F5;
--gf-color-out-ctrl-dark: #585e6a;
--gf-color-out-ctrl-dark-rgb: 88, 94, 106;
--gf-color-out-ctrl-dark-darker: #112337;
--gf-color-out-ctrl-dark-lighter: rgba(17, 35, 55, 0.65);
--gf-color-in-ctrl: #fff;
--gf-color-in-ctrl-rgb: 255, 255, 255;
--gf-color-in-ctrl-contrast: #112337;
--gf-color-in-ctrl-contrast-rgb: 17, 35, 55;
--gf-color-in-ctrl-darker: #F5F5F5;
--gf-color-in-ctrl-lighter: #FFFFFF;
--gf-color-in-ctrl-primary: #204ce5;
--gf-color-in-ctrl-primary-rgb: 32, 76, 229;
--gf-color-in-ctrl-primary-contrast: #fff;
--gf-color-in-ctrl-primary-contrast-rgb: 255, 255, 255;
--gf-color-in-ctrl-primary-darker: #001AB3;
--gf-color-in-ctrl-primary-lighter: #527EFF;
--gf-color-in-ctrl-light: rgba(17, 35, 55, 0.1);
--gf-color-in-ctrl-light-rgb: 17, 35, 55;
--gf-color-in-ctrl-light-darker: rgba(104, 110, 119, 0.35);
--gf-color-in-ctrl-light-lighter: #F5F5F5;
--gf-color-in-ctrl-dark: #585e6a;
--gf-color-in-ctrl-dark-rgb: 88, 94, 106;
--gf-color-in-ctrl-dark-darker: #112337;
--gf-color-in-ctrl-dark-lighter: rgba(17, 35, 55, 0.65);
--gf-radius: 3px;
--gf-font-size-secondary: 14px;
--gf-font-size-tertiary: 13px;
--gf-icon-ctrl-number: url("data:image/svg+xml,%3Csvg width='8' height='14' viewBox='0 0 8 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4 0C4.26522 5.96046e-08 4.51957 0.105357 4.70711 0.292893L7.70711 3.29289C8.09763 3.68342 8.09763 4.31658 7.70711 4.70711C7.31658 5.09763 6.68342 5.09763 6.29289 4.70711L4 2.41421L1.70711 4.70711C1.31658 5.09763 0.683417 5.09763 0.292893 4.70711C-0.0976311 4.31658 -0.097631 3.68342 0.292893 3.29289L3.29289 0.292893C3.48043 0.105357 3.73478 0 4 0ZM0.292893 9.29289C0.683417 8.90237 1.31658 8.90237 1.70711 9.29289L4 11.5858L6.29289 9.29289C6.68342 8.90237 7.31658 8.90237 7.70711 9.29289C8.09763 9.68342 8.09763 10.3166 7.70711 10.7071L4.70711 13.7071C4.31658 14.0976 3.68342 14.0976 3.29289 13.7071L0.292893 10.7071C-0.0976311 10.3166 -0.0976311 9.68342 0.292893 9.29289Z' fill='rgba(17, 35, 55, 0.65)'/%3E%3C/svg%3E");
--gf-icon-ctrl-select: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0.292893 0.292893C0.683417 -0.097631 1.31658 -0.097631 1.70711 0.292893L5 3.58579L8.29289 0.292893C8.68342 -0.0976311 9.31658 -0.0976311 9.70711 0.292893C10.0976 0.683417 10.0976 1.31658 9.70711 1.70711L5.70711 5.70711C5.31658 6.09763 4.68342 6.09763 4.29289 5.70711L0.292893 1.70711C-0.0976311 1.31658 -0.0976311 0.683418 0.292893 0.292893Z' fill='rgba(17, 35, 55, 0.65)'/%3E%3C/svg%3E");
--gf-icon-ctrl-search: url("data:image/svg+xml,%3Csvg width='640' height='640' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M256 128c-70.692 0-128 57.308-128 128 0 70.691 57.308 128 128 128 70.691 0 128-57.309 128-128 0-70.692-57.309-128-128-128zM64 256c0-106.039 85.961-192 192-192s192 85.961 192 192c0 41.466-13.146 79.863-35.498 111.248l154.125 154.125c12.496 12.496 12.496 32.758 0 45.254s-32.758 12.496-45.254 0L367.248 412.502C335.862 434.854 297.467 448 256 448c-106.039 0-192-85.962-192-192z' fill='rgba(17, 35, 55, 0.65)'/%3E%3C/svg%3E");
--gf-label-space-y-secondary: var(--gf-label-space-y-md-secondary);
--gf-ctrl-border-color: #686e77;
--gf-ctrl-size: var(--gf-ctrl-size-md);
--gf-ctrl-label-color-primary: #112337;
--gf-ctrl-label-color-secondary: #112337;
--gf-ctrl-choice-size: var(--gf-ctrl-choice-size-md);
--gf-ctrl-checkbox-check-size: var(--gf-ctrl-checkbox-check-size-md);
--gf-ctrl-radio-check-size: var(--gf-ctrl-radio-check-size-md);
--gf-ctrl-btn-font-size: var(--gf-ctrl-btn-font-size-md);
--gf-ctrl-btn-padding-x: var(--gf-ctrl-btn-padding-x-md);
--gf-ctrl-btn-size: var(--gf-ctrl-btn-size-md);
--gf-ctrl-btn-border-color-secondary: #686e77;
--gf-ctrl-file-btn-bg-color-hover: #EBEBEB;
--gf-field-img-choice-size: var(--gf-field-img-choice-size-md);
--gf-field-img-choice-card-space: var(--gf-field-img-choice-card-space-md);
--gf-field-img-choice-check-ind-size: var(--gf-field-img-choice-check-ind-size-md);
--gf-field-img-choice-check-ind-icon-size: var(--gf-field-img-choice-check-ind-icon-size-md);
--gf-field-pg-steps-number-color: rgba(17, 35, 55, 0.8);
}
</style>
<div id='gform_confirmation_message_1' class='gform_confirmation_message_1 gform_confirmation_message'>
<!-- NOTE: this is response managed by a mailer on the static server, not the WP instance -->
Thanks for contacting us! We will get in touch with you shortly.
</div>
</div>
</body>
</html>

View File

@ -3,6 +3,7 @@ go 1.26.1
use (
.
./cmd/check-ip
./cmd/form2mail
./net/formmailer
./net/geoip
./net/gitshallow

7
go.work.sum Normal file
View File

@ -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=

View File

@ -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("<html")) {
probe := fm.SuccessBody
if len(probe) == 0 && fm.SuccessBodyFunc != nil {
probe = fm.SuccessBodyFunc()
}
if bytes.Contains(probe[:min(512, len(probe))], []byte("<html")) {
return "text/html; charset=utf-8"
}
if bytes.HasPrefix(bytes.TrimSpace(fm.SuccessBody), []byte("{")) {
if bytes.HasPrefix(bytes.TrimSpace(probe), []byte("{")) {
return "application/json"
}
return "text/plain; charset=utf-8"
@ -321,7 +349,7 @@ func (fm *FormMailer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", fm.contentType())
_, _ = w.Write(fm.SuccessBody)
_, _ = w.Write(fm.successBody())
}
// sendMail dials SMTPHost with a bounded timeout and writes the message.
@ -392,9 +420,9 @@ func (fm *FormMailer) writeError(w http.ResponseWriter, err error, showSupport b
w.WriteHeader(http.StatusBadRequest)
support := fm.SMTPFrom
if !showSupport {
support = ""
support = fm.HiddenSupportValue
}
b := bytes.ReplaceAll(fm.ErrorBody, []byte("{.Error}"), []byte(err.Error()))
b := bytes.ReplaceAll(fm.errorBody(), []byte("{.Error}"), []byte(err.Error()))
b = bytes.ReplaceAll(b, []byte("{.SupportEmail}"), []byte(support))
_, _ = w.Write(b)
}