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.
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.
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.
- 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.
- 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.
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.
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.
- 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
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