11 Commits

Author SHA1 Message Date
950aea9eeb
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]")
2026-04-20 22:09:11 -06:00
0d4bce8a38
refactor(formmailer): use geoip instead of iploc for country check
Swap github.com/phuslu/iploc for the shared net/geoip package,
matching the pattern established by check-ip. AllowedCountries now
reads CountryISO off fm.Geo.Value().Lookup(ipStr) instead of
iploc.IPCountry, so the same GeoLite2 databases serve both callers
and refresh on the same cadence.

New field: Geo *dataset.View[geoip.Databases]. Required when
AllowedCountries is set; if Value() is nil (pre-load), the check is
skipped (unknown = allow), matching the prior iploc behavior on
unknown IPs.
2026-04-20 20:35:18 -06:00
06e6cfa211
fix(formmailer): cap request body with MaxBytesReader
ParseMultipartForm(maxFormSize) caps post-header bytes but doesn't
bound the raw body transfer, so a slow/chunked POST can burn server
time before rejection. Wrap r.Body in http.MaxBytesReader so the
transport cuts off over-size bodies immediately.
2026-04-20 20:02:41 -06:00
b77872623a
feat(formmailer)!: replace FormFields struct with ordered []Field
Form inputs are now declared as an ordered slice with Kind-driven
validation (KindText, KindEmail, KindPhone, KindMessage). Arbitrary
input names are fine — callers pick the Label shown in the email
body and the FormName of the HTML input. Per-field MaxLen and
Required overrides supported; defaults come from Kind.

Exactly one KindEmail entry is required (used for Reply-To, Subject
{.Email} substitution, and the MX check); misconfiguration is
detected at first request and returns 500.

Email body, log line, and validation now iterate Fields in order, so
the email preserves the form's declared layout.

BREAKING: FormMailer.Fields is now []Field, not FormFields struct.
Callers must migrate to the slice form.
2026-04-20 19:45:53 -06:00
f972d6f117
refactor(formmailer): use *dataset.View directly, tighter timeouts
- Drop CohortSource interface — it had exactly one implementation.
  Blacklist is now *dataset.View[ipcohort.Cohort] directly, matching
  check-ip's usage. One concrete type, no premature abstraction.
- SMTP 15s → 5s, MX 3s → 2s. A relay or resolver that isn't
  responding inside those bounds isn't going to deliver the mail;
  faster failure is better than holding the request goroutine.
2026-04-20 19:36:51 -06:00
b23610fdf1
refactor(formmailer): production-readiness + dataset.View compatibility
- Blacklist is now a CohortSource interface (Value() *ipcohort.Cohort).
  *dataset.View[ipcohort.Cohort] satisfies it directly; callers with
  an atomic.Pointer can wrap. Drops the atomic/sync import from the
  public API.
- SMTP send now uses net.Dialer.DialContext with a bounded SMTPTimeout
  (default 15s) and conn deadline, so a slow/hung relay no longer holds
  the request goroutine for WriteTimeout. Opportunistic STARTTLS added.
- MX lookup uses net.DefaultResolver.LookupMX with a bounded MXTimeout
  (default 3s), cancellable via r.Context().
- clientIP uses net.SplitHostPort (was LastIndex(":"), broken for IPv6).
- Per-IP limiter map now has a 10-minute TTL with opportunistic sweep
  every 1024 requests — previously grew unbounded.
- Sentinel errors switched to errors.New; fmt.Errorf was unused.
2026-04-20 19:32:40 -06:00
01a9185c03
refactor: delete net/dataset package
check-ip and geoip no longer use it; formmailer now takes
*atomic.Pointer[ipcohort.Cohort] for Blacklist so callers own the
refresh + swap lifecycle directly. gitshallow doc comments that
referenced dataset.Syncer are trimmed.

The concepts the package tried to share (atomic-swap, group sync,
ticker-driven refresh) may come back under sync/dataset once we have
more than one in-tree caller that wants them.
2026-04-20 13:22:08 -06:00
994d91b2bf
refactor: dataset.Add returns *Dataset, no View; main uses Group for all cases
Remove View[T] — Add now returns *Dataset[T] directly. Callers use Load()
on the returned Dataset; Init/Run belong to the owning Group.

main.go simplified: declare syncer + file paths per case, then one
g.Init() and one g.Run(). No manual loops over individual datasets.
Add gitshallow.Repo.FilePath helper.
2026-04-20 12:48:38 -06:00
34a54c2d66
refactor: multi-module workspace + dataset owns Syncer interface
- Each package gets its own go.mod: net/{dataset,httpcache,gitshallow,ipcohort,geoip,formmailer}
- go.work with replace directives for cross-module workspace resolution
- dataset.Syncer/NopSyncer moved here from httpcache; callers duck-type it
- dataset.View[T] returned by Add to prevent Init/Sync/Run misuse on group members
- cmd/check-ip moved from net/ipcohort/cmd/check-ip to top-level cmd/check-ip
- Add net/ipcohort/cmd/ipcohort-contains for standalone cohort membership testing
2026-04-20 11:22:01 -06:00
225faec549
fix: FormFields defaults to GravityForms-compatible input_N names 2026-04-20 11:02:46 -06:00
d57c810c2e
feat: add net/formmailer with updated paradigms
Rewrite from feat-formmailer WIP:
- Blacklist is *dataset.View[ipcohort.Cohort] — caller wires dataset group
- http.Handler via ServeHTTP — drop-in for any mux
- SuccessBody/ErrorBody []byte — caller loads files; no file I/O per request
- Rate limiter per-instance (sync.Once init), not global
- Fields configurable (default standard names, not GravityForms input_N)
- AllowedCountries []string for geo-blocking via iploc (nil = allow all)
- ContainsAddr used directly (pre-parsed netip.Addr, no re-parse)
- No Init()/Run() — caller drives dataset lifecycle
- Fix getErrorBotty typo; expose support email only to legitimate errors
2026-04-20 11:01:15 -06:00