diff --git a/net/formmailer/formmailer.go b/net/formmailer/formmailer.go new file mode 100644 index 0000000..dda4fc6 --- /dev/null +++ b/net/formmailer/formmailer.go @@ -0,0 +1,1051 @@ +// Package formmailer provides an HTTP handler that validates, rate-limits, +// and emails contact form submissions. +// +// Fields are declared as an ordered slice; each Field names the HTML input, +// the label for the email body, and the validation Kind. Exactly one Kind +// must be KindEmail — its value is used for Reply-To, Subject substitution, +// and the MX check. +// +// Typical self-contained setup (Run): +// +// fm := &formmailer.FormMailer{ +// ListenAddr: ":3081", +// SMTPHost: "smtp.example.com:587", +// SMTPFrom: "noreply@example.com", +// SMTPTo: []string{"contact@example.com"}, +// SMTPUser: "noreply@example.com", +// SMTPPass: os.Getenv("SMTP_PASS"), +// Subject: "Contact from {.Email}", +// BlocklistRepo: "https://github.com/bitwire-it/ipblocklist.git", +// CacheDir: "", // defaults to ~/.cache +// GeoIPConfPath: "", // defaults to ./GeoIP.conf or ~/.config/maxmind/GeoIP.conf +// SuccessBody: successHTML, +// ErrorBody: errorHTML, +// 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: "Budget", FormName: "input_8", Kind: formmailer.KindText}, +// {Label: "Message", FormName: "input_7", Kind: formmailer.KindMessage}, +// }, +// AllowedCountries: []string{"US", "CA"}, +// } +// if err := fm.Run(ctx); err != nil { +// log.Fatal(err) +// } +// +// Programmatic setup (composable): +// +// fm := &formmailer.FormMailer{ +// SMTPHost: "smtp.example.com:587", +// SMTPFrom: "noreply@example.com", +// SMTPTo: []string{"contact@example.com"}, +// SMTPUser: "noreply@example.com", +// SMTPPass: os.Getenv("SMTP_PASS"), +// Subject: "Contact from {.Email}", +// Fields: []formmailer.Field{ +// {Label: "Name", FormName: "input_1", Kind: formmailer.KindText}, +// {Label: "Email", FormName: "input_3", Kind: formmailer.KindEmail}, +// }, +// SuccessBody: successHTML, +// ErrorBody: errorHTML, +// } +// http.Handle("POST /contact", fm) +package formmailer + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/mail" + "net/netip" + "net/smtp" + "os" + "os/signal" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "syscall" + "time" + + "golang.org/x/time/rate" + + "github.com/therootcompany/golib/net/geoip" + "github.com/therootcompany/golib/net/gitshallow" + "github.com/therootcompany/golib/net/ipcohort" + "github.com/therootcompany/golib/sync/dataset" +) + +const ( + maxFormSize = 10 * 1024 + + // Default per-Kind length caps; override with Field.MaxLen. + maxEmailLength = 254 + maxPhoneLength = 20 + maxTextLength = 200 + maxMessageLength = 4000 + + defaultRPM = 5 + defaultBurst = 3 + defaultSMTPTimeout = 5 * time.Second + defaultMXTimeout = 2 * time.Second + defaultRefresh = 47 * time.Minute + defaultGCInterval = 24 // gc every N fetches (~daily at 47min cadence) + + limiterTTL = 10 * time.Minute + limiterSweepEvery = 1024 // sweep once every N handler invocations +) + +var ( + ErrInvalidEmail = errors.New("email address doesn't look like an email address") + ErrInvalidMX = errors.New("email address isn't deliverable") + ErrInvalidPhone = errors.New("phone number is not properly formatted") + ErrContentTooLong = errors.New("one or more field values was too long") + ErrInvalidNewlines = errors.New("invalid use of newlines or carriage returns") + ErrMissingRequired = errors.New("required field was empty") + ErrNoEmailField = errors.New("FormMailer.Fields must contain exactly one KindEmail field") + + phoneRe = regexp.MustCompile(`^[0-9+\-\(\) ]{7,20}$`) +) + +// FieldKind picks validation rules and default length cap for a Field. +type FieldKind int + +const ( + KindText FieldKind = iota // default; length-capped text + KindEmail // RFC 5321 parse + MX lookup + KindPhone // phoneRe match + KindMessage // long free text (body of the submission) +) + +// Field declares one form input. Order is preserved in the email body. +type Field struct { + Label string // shown in email body, e.g. "Name" + FormName string // HTML form field name, e.g. "input_1" + Kind FieldKind // validation rules + default MaxLen + MaxLen int // 0 = default for Kind + Required bool // if true, empty value is rejected +} + +func (f Field) maxLen() int { + if f.MaxLen > 0 { + return f.MaxLen + } + switch f.Kind { + case KindEmail: + return maxEmailLength + case KindPhone: + return maxPhoneLength + case KindMessage: + return maxMessageLength + default: + return maxTextLength + } +} + +// FormMailer is an http.Handler that validates and emails contact form +// submissions. It can be used as a composable handler (ServeHTTP) or as a +// self-contained server (Run). +// +// When used with Run, FormMailer sets up its own blocklist and GeoIP data +// sources using gitshallow, ipcohort, and local geoip archives. When used as a handler, callers pass in +// pre-built *dataset.View values via the Blacklist and Geo fields. +type FormMailer struct { + // ListenAddr is the address to listen on when using Run. Zero uses ":3081". + ListenAddr string + + // SMTP + SMTPHost string + SMTPFrom string + SMTPTo []string + SMTPUser string + SMTPPass string + Subject string // may contain {.Email} + // LocalName is the hostname the client announces in EHLO. Zero uses + // os.Hostname(). Matters for relay (port 25) where receivers may reject + // EHLO names that don't reverse-resolve; on submission (587) it's + // informational. NEVER the peer's name — that's TLS SNI / auth binding. + LocalName string + // TLSConfig overrides the StartTLS tls.Config. Zero uses + // &tls.Config{ServerName: }. Set to supply a custom + // root CA bundle, to disable verification (in tests), or to pin a cert. + TLSConfig *tls.Config + + // SMTPTimeout bounds the entire connect+auth+send cycle. Zero uses 5s. + SMTPTimeout time.Duration + // MXTimeout bounds the per-submission MX lookup. Zero uses 2s. + MXTimeout time.Duration + + // SuccessBody and ErrorBody are called per request and return the response + // body sent to the client. Callers typically build them as closures that + // re-read an HTML file each invocation (hot-reload) with a baked-in fallback + // to a copy read at startup. ErrorBody output may contain {.Error} and + // {.SupportEmail} placeholders. + SuccessBody func() []byte + ErrorBody func() []byte + ContentType string // inferred from a probe of SuccessBody() if empty + + // HiddenSupportValue replaces {.SupportEmail} in ErrorBody output when the + // request should not learn the operator's real address — i.e. bot and + // blacklist rejections. Legitimate validation errors see the real support + // email. Zero value "" strips the placeholder entirely. + HiddenSupportValue string + + // TrustedProxies — list of CIDRs whose X-Forwarded-For header we honor. + // Empty means never trust XFF (use r.RemoteAddr as-is). Without this, + // any client could forge their IP to bypass rate limiting, blacklist, + // and country gating. + TrustedProxies []netip.Prefix + + // Fields declares the form inputs in display order. Exactly one entry + // must have Kind == KindEmail. + Fields []Field + + // RPM and Burst control per-IP rate limiting. Zero uses defaults (5/3). + RPM int + Burst int + + // --- Blocklist configuration (used by Run) --- + + // BlocklistRepo is the git URL of the blocklist repo (e.g. bitwire-it). + // Zero means no blocklist check. + BlocklistRepo string + // BlocklistPaths are the paths within the repo to load as the inbound + // cohort (e.g. "tables/inbound/single_ips.txt", "tables/inbound/networks.txt"). + // Zero defaults to the two standard bitwire-it paths. + BlocklistPaths []string + // CacheDir is the parent directory for cached data. Zero defaults to + // ~/.cache. + CacheDir string + // RefreshInterval controls how often blocklists and GeoIP are reloaded. + // Zero uses 47 minutes. + RefreshInterval time.Duration + // GCInterval controls explicit GC after blocklist fetches. 0 defaults + // to 24 (once daily at 47min cadence). -1 disables explicit GC. + GCInterval int + + // --- GeoIP configuration (used by Run) --- + + // GeoIPConfPath is the path to a GeoIP.conf file. Zero searches + // ./GeoIP.conf and ~/.config/maxmind/GeoIP.conf. Its presence selects + // download mode (formmailer owns the geoip-update loop); its absence + // falls back to polling pre-existing archives. + GeoIPConfPath string + // GeoIPDir is the directory holding the GeoLite2 tarballs. Zero uses + // ~/.cache/maxmind. Ignored when using programmatic setup. + GeoIPDir string + // GeoIPBaseURL overrides the MaxMind download base. Zero uses the + // geoip.DownloadBase default. Library-level; not exposed on the CLI. + GeoIPBaseURL string + // GeoIPFreshDays gates downloads: skip HTTP when a local archive is + // younger than N days. Zero uses 3. Maps to httpcache.Cacher.MaxAge; + // a library-level option, rare to expose all the way up to the CLI. + GeoIPFreshDays int + // AllowedCountries — if non-nil, only requests from listed ISO codes are + // accepted. Unknown country ("") is always allowed. Requires GeoIP to be + // loaded. + AllowedCountries []string + + // --- Programmatic setup (alternative to Run) --- + + // Blacklist — if set, matching IPs are rejected before any other + // processing. Ignored when using Run (which builds its own). + Blacklist *dataset.View[ipcohort.Cohort] + // Geo — required when AllowedCountries is set and using programmatic + // setup. Provides the GeoLite2 City/ASN databases used for country + // lookup. Ignored when using Run (which builds its own). + Geo *dataset.View[geoip.Databases] + + // --- Internal state --- + + once sync.Once + initErr error + emailIdx int // index into Fields of the KindEmail entry + mu sync.Mutex + limiters map[string]*limiterEntry + reqCount uint64 + + // Run-only state + blocklistSet *dataset.Set + geoSet *dataset.Set + blocklistView *dataset.View[ipcohort.Cohort] + geoView *dataset.View[geoip.Databases] + server *http.Server + serverErr chan error + ctx context.Context + cancel context.CancelFunc + blocklistReady chan struct{} // closed when blocklist is first loaded + geoReady chan struct{} // closed when geoip is first loaded +} + +type limiterEntry struct { + lim *rate.Limiter + lastUsed time.Time +} + +func (fm *FormMailer) init() { + fm.limiters = make(map[string]*limiterEntry) + fm.emailIdx = -1 + for i, f := range fm.Fields { + if f.Kind == KindEmail { + if fm.emailIdx >= 0 { + fm.initErr = ErrNoEmailField + return + } + fm.emailIdx = i + } + } + if fm.emailIdx < 0 { + fm.initErr = ErrNoEmailField + } +} + +func (fm *FormMailer) successBody() []byte { + if fm.SuccessBody == nil { + return nil + } + return fm.SuccessBody() +} + +func (fm *FormMailer) errorBody() []byte { + if fm.ErrorBody == nil { + return nil + } + return fm.ErrorBody() +} + +func (fm *FormMailer) contentType() string { + if fm.ContentType != "" { + return fm.ContentType + } + probe := fm.successBody() + if bytes.Contains(probe[:min(512, len(probe))], []byte(" 0 + } + + // Decide GeoIP mode from what's on disk. A present GeoIP.conf selects + // download mode (formmailer owns the geoip-update loop via + // geoip.Fetcher); otherwise, if the archive dir is already populated, + // poll it (external job owns downloads). Either way the view loader + // extracts the archives in memory. The refresh interval is the + // dataset layer's job (Set.Tick); freshness gating is httpcache's + // (Cacher.MaxAge == fresh-days). + confExists := fm.GeoIPConfPath != "" + if confExists { + if _, err := os.Stat(fm.GeoIPConfPath); err != nil { + confExists = false + log.Printf("geoip: configured conf %s not found; falling back to poll mode", fm.GeoIPConfPath) + } + } + if !confExists { + for _, p := range geoip.DefaultConfPaths() { + if _, err := os.Stat(p); err == nil { + fm.GeoIPConfPath = p + confExists = true + break + } + } + } + + var geoSet *dataset.Set + var geoView *dataset.View[geoip.Databases] + geoMode := "disabled" + switch { + case confExists: + // Download mode: formmailer owns the geoip-update loop. + geoMode = "download" + geoFetcher, ferr := geoip.NewFetcher(fm.GeoIPConfPath) + if ferr != nil { + log.Printf("geoip: %v", ferr) + } else { + geoFetcher.Dir = geoDir + geoFetcher.BaseURL = fm.GeoIPBaseURL + geoFetcher.FreshDays = fm.GeoIPFreshDays + geoSet = dataset.NewSet(geoFetcher) + geoView = dataset.Add(geoSet, func(ctx context.Context) (*geoip.Databases, error) { + return geoip.Open(geoDir) + }) + } + case dirPopulated(geoDir): + // Poll mode: an external job owns the downloads; we just reload. + geoMode = "poll" + cityPath := filepath.Join(geoDir, geoip.TarGzName(geoip.CityEdition)) + asnPath := filepath.Join(geoDir, geoip.TarGzName(geoip.ASNEdition)) + geoSet = dataset.NewSet(dataset.PollFiles(cityPath, asnPath)) + geoView = dataset.Add(geoSet, func(ctx context.Context) (*geoip.Databases, error) { + return geoip.Open(geoDir) + }) + } + + // Keep the views used by ServeHTTP in sync with the views owned by Run. + // The local variables above are also passed to healthz, but ServeHTTP uses + // these fields to select the Run-managed datasets. + fm.blocklistSet = blocklistSet + fm.blocklistView = blocklistView + fm.geoSet = geoSet + fm.geoView = geoView + + // Signal channels for readiness. + fm.blocklistReady = make(chan struct{}) + fm.geoReady = make(chan struct{}) + + // Context for background goroutines. + fm.ctx, fm.cancel = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + + // Initial load. + if blocklistSet != nil { + fmt.Fprint(os.Stderr, "Syncing blocklist ... ") + t := time.Now() + if err := blocklistSet.Load(fm.ctx); err != nil { + fmt.Fprintln(os.Stderr) + log.Printf("blocklist: %v", err) + } else { + sz := 0 + if v := blocklistView.Value(); v != nil { + sz = v.Size() + } + fmt.Fprintf(os.Stderr, "%s (entries=%d)\n", + time.Since(t).Round(time.Millisecond), sz) + close(fm.blocklistReady) + } + } + + if geoSet != nil { + fmt.Fprint(os.Stderr, "Loading geoip ... ") + t := time.Now() + if err := geoSet.Load(fm.ctx); err != nil { + fmt.Fprintln(os.Stderr) + log.Printf("geoip: %v", err) + } else { + fmt.Fprintf(os.Stderr, "%s\n", time.Since(t).Round(time.Millisecond)) + close(fm.geoReady) + } + } + + // Background refreshes. + if blocklistSet != nil { + go blocklistSet.Tick(fm.ctx, fm.RefreshInterval, func(err error) { + log.Printf("blocklist refresh: %v", err) + }) + } + if geoSet != nil { + go geoSet.Tick(fm.ctx, fm.RefreshInterval, func(err error) { + log.Printf("geoip refresh: %v", err) + }) + } + + // Build the handler. + mux := http.NewServeMux() + + // Form handler. + emailFormName := "" + for _, f := range fm.Fields { + if f.Kind == KindEmail { + emailFormName = f.FormName + break + } + } + contact := fm.handler(emailFormName) + mux.Handle("POST /contact", contact) + mux.Handle("POST /contact/", contact) + + // Healthz endpoint. + mux.HandleFunc("GET /healthz", fm.healthz(blocklistView, geoView)) + + // Root. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintln(w, "formmailer server running. POST form data to /contact") + }) + + // Server. + fm.server = &http.Server{ + Addr: fm.ListenAddr, + Handler: mux, + BaseContext: func(_ net.Listener) context.Context { + return fm.ctx + }, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 16, // 64KB + } + + fmt.Printf("formmailer listening on http://%s\n", fm.ListenAddr) + fmt.Printf("Forwarding submissions from %s → %s via %s\n", + fm.SMTPFrom, strings.Join(fm.SMTPTo, ","), fm.SMTPHost) + fmt.Printf("Rate limit: ~%d req/min per IP (burst %d)\n", + maxRate(fm.RPM), maxBurst(fm.Burst)) + if fm.BlocklistRepo != "" { + fmt.Println("Blocklist: enabled") + } + if geoSet != nil { + switch geoMode { + case "download": + fmt.Printf("GeoIP: download mode (conf=%s, dir=%s)\n", fm.GeoIPConfPath, geoDir) + case "poll": + fmt.Printf("GeoIP: poll mode (dir=%s)\n", geoDir) + } + } + if len(fm.AllowedCountries) > 0 { + fmt.Printf("Country gate: %s\n", strings.Join(fm.AllowedCountries, ", ")) + } + fmt.Println("CTRL+C to stop") + + // Start server. + fm.serverErr = make(chan error, 1) + go func() { + if err := fm.server.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + fm.serverErr <- err + } + close(fm.serverErr) + }() + + // Wait for shutdown or error. + select { + case <-fm.ctx.Done(): + log.Printf("shutdown: %v", fm.ctx.Err()) + case err := <-fm.serverErr: + if err != nil { + return fmt.Errorf("listen: %w", err) + } + } + + // Graceful shutdown. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := fm.server.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } + + // Close datasets. + if blocklistSet != nil { + _ = blocklistSet.Close() + } + if geoSet != nil { + _ = geoSet.Close() + } + return nil +} + +// handler returns the form submission handler, optionally wrapping it with +// the .ru silent-drop trap (legacy spam-trap behavior). +func (fm *FormMailer) handler(emailFormName string) http.Handler { + h := http.Handler(fm) + if emailFormName != "" { + h = silentDropRU(h, emailFormName, fm.successBody, fm.contentType()) + } + return h +} + +// healthz returns a handler that reports dataset load status. +func (fm *FormMailer) healthz( + blocklist *dataset.View[ipcohort.Cohort], + geo *dataset.View[geoip.Databases], +) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + type dsStatus struct { + Loaded bool `json:"loaded"` + Size int `json:"size,omitzero"` + LoadedAt time.Time `json:"loaded_at,omitzero"` + } + cohortStatus := func(v *dataset.View[ipcohort.Cohort]) dsStatus { + s := dsStatus{LoadedAt: v.LoadedAt()} + if cur := v.Value(); cur != nil { + s.Loaded, s.Size = true, cur.Size() + } + return s + } + + status := make(map[string]dsStatus) + if blocklist != nil { + status["blocklist"] = cohortStatus(blocklist) + } + if geo != nil { + s := dsStatus{LoadedAt: geo.LoadedAt()} + if v := geo.Value(); v != nil { + s.Loaded = true + } + status["geoip"] = s + } + + ready := true + if blocklist != nil && !status["blocklist"].Loaded { + ready = false + } + if geo != nil && !status["geoip"].Loaded { + ready = false + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if !ready { + w.WriteHeader(http.StatusServiceUnavailable) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(map[string]any{ + "ready": ready, + "databases": status, + }) + } +} + +// ServeHTTP implements http.Handler. It validates, rate-limits, and emails +// contact form submissions. When using Run, the handler is wired internally; +// when used programmatically, callers wire it into their own mux. +func (fm *FormMailer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + fm.once.Do(fm.init) + if fm.initErr != nil { + log.Printf("contact form: misconfigured: %v", fm.initErr) + http.Error(w, "contact form misconfigured", http.StatusInternalServerError) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxFormSize) + if err := r.ParseMultipartForm(maxFormSize); err != nil { + http.Error(w, "form too large or invalid", http.StatusBadRequest) + return + } + + ipStr := fm.clientIP(r) + ip, err := netip.ParseAddr(ipStr) + if err != nil { + fm.writeError(w, fmt.Errorf("malformed client IP"), true) + return + } + + // Use Run-provided views or programmatic views. + var blacklist *dataset.View[ipcohort.Cohort] + var geo *dataset.View[geoip.Databases] + if fm.BlocklistRepo != "" { + blacklist = fm.blocklistView + } else { + blacklist = fm.Blacklist + } + if fm.BlocklistRepo != "" || fm.geoView != nil { + geo = fm.geoView + } else { + geo = fm.Geo + } + + if blacklist != nil { + if c := blacklist.Value(); c != nil && c.ContainsAddr(ip) { + fm.writeError(w, fmt.Errorf("automated requests are not accepted"), false) + return + } + } + + if fm.AllowedCountries != nil && geo != nil { + if v := geo.Value(); v != nil { + country := v.Lookup(ipStr).CountryISO + if country != "" && !slices.Contains(fm.AllowedCountries, country) { + fm.writeError(w, + fmt.Errorf("submissions from your region are not accepted; please email us directly"), true) + return + } + } + } + + if !fm.allow(ipStr) { + http.Error(w, "rate limit exceeded — please try again later", http.StatusTooManyRequests) + return + } + + values := make([]string, len(fm.Fields)) + for i, f := range fm.Fields { + v := strings.TrimSpace(r.FormValue(f.FormName)) + if f.Kind == KindEmail { + v = strings.ToLower(v) + } + values[i] = v + } + + for i, f := range fm.Fields { + v := values[i] + if f.Required && v == "" { + fm.writeError(w, fmt.Errorf("%w: %s", ErrMissingRequired, f.Label), true) + return + } + if len(v) > f.maxLen() { + fm.writeError(w, ErrContentTooLong, true) + return + } + // Header-injection check: all fields except free-form message bodies. + if f.Kind != KindMessage && strings.ContainsAny(v, "\r\n") { + fm.writeError(w, ErrInvalidNewlines, true) + return + } + switch f.Kind { + case KindPhone: + if err := validatePhone(v); err != nil { + fm.writeError(w, err, true) + return + } + case KindEmail: + if err := fm.validateEmailAndMX(r.Context(), v); err != nil { + fm.writeError(w, err, true) + return + } + } + } + + email := values[fm.emailIdx] + + var logBuf strings.Builder + fmt.Fprintf(&logBuf, "contact form: ip=%s", ipStr) + for i, f := range fm.Fields { + v := values[i] + if len(v) > 100 { + v = v[:100] + } + fmt.Fprintf(&logBuf, " %s=%q", f.Label, v) + } + log.Print(logBuf.String()) + + subject := strings.ReplaceAll(fm.Subject, "{.Email}", email) + var body strings.Builder + body.WriteString("New contact form submission:\n\n") + for i, f := range fm.Fields { + if f.Kind == KindMessage { + fmt.Fprintf(&body, "%s:\n%s\n", f.Label, values[i]) + continue + } + fmt.Fprintf(&body, "%s: %s\n", f.Label, values[i]) + } + msg := fmt.Appendf(nil, + "To: %s\r\nFrom: %s\r\nReply-To: %s\r\nSubject: %s\r\n\r\n%s\r\n", + strings.Join(fm.SMTPTo, ", "), fm.SMTPFrom, email, subject, body.String(), + ) + + if err := fm.sendMail(r.Context(), msg); err != nil { + log.Printf("contact form: smtp error: %v", err) + http.Error(w, "failed to send — please try again later", http.StatusInternalServerError) + return + } + log.Printf("contact form: sent ip=%s from=%q to=%s", ipStr, email, strings.Join(fm.SMTPTo, ",")) + + w.Header().Set("Content-Type", fm.contentType()) + _, _ = w.Write(fm.successBody()) +} + +// sendMail dials SMTPHost with a bounded timeout and writes the message. +// Uses smtp.NewClient directly so the dial respects ctx; stdlib smtp.SendMail +// has no context plumbing. +func (fm *FormMailer) sendMail(ctx context.Context, msg []byte) error { + timeout := fm.SMTPTimeout + if timeout == 0 { + timeout = defaultSMTPTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + d := net.Dialer{} + conn, err := d.DialContext(ctx, "tcp", fm.SMTPHost) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + if dl, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(dl) + } + hostname, _, err := net.SplitHostPort(fm.SMTPHost) + if err != nil { + hostname = fm.SMTPHost + } + // NewClient sets the peer name used for AUTH binding and TLS SNI — that + // must be the SMTP server's hostname. Hello() overrides the *client* + // identity announced in EHLO; stdlib conflates these into one API but + // they're semantically distinct. + c, err := smtp.NewClient(conn, hostname) + if err != nil { + _ = conn.Close() + return fmt.Errorf("smtp client: %w", err) + } + defer func() { _ = c.Close() }() + localName := fm.LocalName + if localName == "" { + if h, err := os.Hostname(); err == nil { + localName = h + } else { + localName = "localhost" + } + } + if err := c.Hello(localName); err != nil { + return fmt.Errorf("ehlo: %w", err) + } + + if ok, _ := c.Extension("STARTTLS"); ok { + tlsCfg := fm.TLSConfig + if tlsCfg == nil { + tlsCfg = &tls.Config{ServerName: hostname} + } + if err := c.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } + if fm.SMTPUser != "" { + auth := smtp.PlainAuth("", fm.SMTPUser, fm.SMTPPass, hostname) + if err := c.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + if err := c.Mail(fm.SMTPFrom); err != nil { + return fmt.Errorf("mail from: %w", err) + } + for _, to := range fm.SMTPTo { + if err := c.Rcpt(to); err != nil { + return fmt.Errorf("rcpt to %s: %w", to, err) + } + } + wc, err := c.Data() + if err != nil { + return fmt.Errorf("data: %w", err) + } + if _, err := wc.Write(msg); err != nil { + _ = wc.Close() + return fmt.Errorf("write: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("close data: %w", err) + } + return c.Quit() +} + +func (fm *FormMailer) writeError(w http.ResponseWriter, err error, showSupport bool) { + w.Header().Set("Content-Type", fm.contentType()) + w.WriteHeader(http.StatusBadRequest) + support := fm.SMTPFrom + if !showSupport { + support = fm.HiddenSupportValue + } + b := bytes.ReplaceAll(fm.errorBody(), []byte("{.Error}"), []byte(err.Error())) + b = bytes.ReplaceAll(b, []byte("{.SupportEmail}"), []byte(support)) + _, _ = w.Write(b) +} + +func (fm *FormMailer) allow(ipStr string) bool { + rpm := fm.RPM + if rpm == 0 { + rpm = defaultRPM + } + burst := fm.Burst + if burst == 0 { + burst = defaultBurst + } + + now := time.Now() + fm.mu.Lock() + e, ok := fm.limiters[ipStr] + if !ok { + e = &limiterEntry{ + lim: rate.NewLimiter(rate.Every(time.Minute/time.Duration(rpm)), burst), + } + fm.limiters[ipStr] = e + } + e.lastUsed = now + fm.reqCount++ + if fm.reqCount%limiterSweepEvery == 0 { + for k, v := range fm.limiters { + if now.Sub(v.lastUsed) > limiterTTL { + delete(fm.limiters, k) + } + } + } + lim := e.lim + fm.mu.Unlock() + + if !lim.Allow() { + log.Printf("contact form: rate limited ip=%s", ipStr) + return false + } + return true +} + +func (fm *FormMailer) validateEmailAndMX(ctx context.Context, email string) error { + if email == "" { + return ErrInvalidEmail + } + if _, err := mail.ParseAddress(email); err != nil { + return ErrInvalidEmail + } + _, domain, ok := strings.Cut(email, "@") + if !ok { + return ErrInvalidEmail + } + timeout := fm.MXTimeout + if timeout == 0 { + timeout = defaultMXTimeout + } + lookupCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if _, err := net.DefaultResolver.LookupMX(lookupCtx, domain); err != nil { + return ErrInvalidMX + } + return nil +} + +func validatePhone(phone string) error { + if phone == "" { + return nil + } + if !phoneRe.MatchString(phone) { + return ErrInvalidPhone + } + return nil +} + +// clientIP returns the originating IP. If the immediate peer (r.RemoteAddr) +// is inside TrustedProxies, the left-most X-Forwarded-For entry is used +// instead. Without TrustedProxies, XFF is ignored — otherwise any client +// could forge their address to bypass rate limits and geo gating. +func (fm *FormMailer) clientIP(r *http.Request) string { + remote := r.RemoteAddr + if host, _, err := net.SplitHostPort(remote); err == nil { + remote = host + } + peer, err := netip.ParseAddr(remote) + if err != nil { + return remote + } + for _, cidr := range fm.TrustedProxies { + if cidr.Contains(peer) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + first, _, _ := strings.Cut(xff, ",") + return strings.TrimSpace(first) + } + break + } + } + return remote +} + +// 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 h. +func silentDropRU(h 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 + } + } + h.ServeHTTP(w, r) + }) +} + +// maxRate and maxBurst are helpers to avoid zero values in log output. +func maxRate(r int) int { + if r == 0 { + return defaultRPM + } + return r +} +func maxBurst(b int) int { + if b == 0 { + return defaultBurst + } + return b +} diff --git a/net/formmailer/go.mod b/net/formmailer/go.mod new file mode 100644 index 0000000..a720f30 --- /dev/null +++ b/net/formmailer/go.mod @@ -0,0 +1,22 @@ +module github.com/therootcompany/golib/net/formmailer + +go 1.26.5 + +require ( + github.com/therootcompany/golib/net/geoip v0.5.3 + github.com/therootcompany/golib/net/gitshallow v0.9.3 + github.com/therootcompany/golib/net/ipcohort v0.9.0 + github.com/therootcompany/golib/sync/dataset v0.5.0 + golang.org/x/time v0.15.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/httpcache v0.5.1 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect +) + +replace github.com/therootcompany/golib/net/geoip v0.5.3 => ../geoip diff --git a/net/formmailer/go.sum b/net/formmailer/go.sum new file mode 100644 index 0000000..ff78e2e --- /dev/null +++ b/net/formmailer/go.sum @@ -0,0 +1,28 @@ +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/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/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/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/net/formmailer/run_test.go b/net/formmailer/run_test.go new file mode 100644 index 0000000..10e78f9 --- /dev/null +++ b/net/formmailer/run_test.go @@ -0,0 +1,128 @@ +package formmailer + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// captureStdout runs fn while capturing everything written to os.Stdout, +// returning the captured text. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + fn() + _ = w.Close() + os.Stdout = orig + return <-done +} + +// runOnce starts a FormMailer with a context that expires quickly so Run +// returns immediately after selecting and logging its GeoIP mode. It must not +// require network (BlocklistRepo is empty) and binds an ephemeral port. +func runOnce(t *testing.T, fm *FormMailer) string { + t.Helper() + fm.ListenAddr = "127.0.0.1:0" + fm.RefreshInterval = time.Minute + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return captureStdout(t, func() { + _ = fm.Run(ctx) + }) +} + +// TestRunGeoModeSelection sanity-checks the hybrid GeoIP mode selection: +// - conf present -> download mode +// - conf absent + populated -> poll mode +// - conf absent + empty dir -> disabled +func TestRunGeoModeSelection(t *testing.T) { + t.Run("download", func(t *testing.T) { + dir := t.TempDir() + conf := filepath.Join(dir, "GeoIP.conf") + if err := os.WriteFile(conf, []byte( + "AccountID 123456\nLicenseKey testkey\nEditionIDs GeoLite2-City GeoLite2-ASN\n"), 0o600); err != nil { + t.Fatal(err) + } + // 500 server so the owned download loop fails fast; the mode is + // selected and logged before serving regardless. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer srv.Close() + + out := runOnce(t, &FormMailer{ + CacheDir: dir, + GeoIPConfPath: conf, + GeoIPBaseURL: srv.URL, + SMTPHost: "localhost:25", + SMTPFrom: "a@example.com", + SMTPTo: []string{"b@example.com"}, + }) + if !strings.Contains(out, "GeoIP: download mode") { + t.Fatalf("missing download mode line; stdout:\n%s", out) + } + }) + + t.Run("poll", func(t *testing.T) { + // Point HOME at a temp dir so the default conf discovery can't find a + // real ~/.config/maxmind/GeoIP.conf. + home := t.TempDir() + t.Setenv("HOME", home) + + archive := filepath.Join(t.TempDir(), "maxmind") + if err := os.MkdirAll(archive, 0o755); err != nil { + t.Fatal(err) + } + // Any entry makes the dir "populated" -> poll mode. + if err := os.WriteFile(filepath.Join(archive, "keep"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + out := runOnce(t, &FormMailer{ + CacheDir: home, + GeoIPDir: archive, + SMTPHost: "localhost:25", + SMTPFrom: "a@example.com", + SMTPTo: []string{"b@example.com"}, + }) + if !strings.Contains(out, "GeoIP: poll mode") { + t.Fatalf("missing poll mode line; stdout:\n%s", out) + } + }) + + t.Run("disabled", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + empty := filepath.Join(home, "maxmind") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatal(err) + } + + out := runOnce(t, &FormMailer{ + CacheDir: home, + GeoIPDir: empty, + SMTPHost: "localhost:25", + SMTPFrom: "a@example.com", + SMTPTo: []string{"b@example.com"}, + }) + if strings.Contains(out, "GeoIP:") { + t.Fatalf("expected GeoIP disabled; stdout:\n%s", out) + } + }) +} diff --git a/net/formmailer/sendmail_test.go b/net/formmailer/sendmail_test.go new file mode 100644 index 0000000..c5dd4cd --- /dev/null +++ b/net/formmailer/sendmail_test.go @@ -0,0 +1,327 @@ +package formmailer + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" +) + +// TestSendMail_StartTLS drives sendMail against a fake SMTP server that +// requires STARTTLS. Catches regressions like `StartTLS(nil)` and verifies: +// - EHLO announces fm.LocalName (not the peer hostname) +// - TLS handshake completes (non-nil tls.Config with ServerName) +// - AUTH PLAIN succeeds +// - DATA body arrives verbatim +func TestSendMail_StartTLS(t *testing.T) { + cert := selfSignedCert(t) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + var ( + wg sync.WaitGroup + gotEHLO string + gotAuth string + gotFrom string + gotRcpt string + gotBody strings.Builder + serverErr error + serverErrOnce sync.Once + ) + fail := func(err error) { serverErrOnce.Do(func() { serverErr = err }) } + + wg.Go(func() { + conn, err := ln.Accept() + if err != nil { + fail(err) + return + } + defer func() { _ = conn.Close() }() + runFakeSMTP(conn, cert, &fakeCapture{ + ehlo: &gotEHLO, auth: &gotAuth, from: &gotFrom, rcpt: &gotRcpt, body: &gotBody, + }, fail) + }) + + fm := &FormMailer{ + SMTPHost: ln.Addr().String(), + SMTPFrom: "from@example.com", + SMTPTo: []string{"to@example.com"}, + SMTPUser: "user@example.com", + SMTPPass: "secret", + LocalName: "client.test", + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, // self-signed cert in test + ServerName: "fake.smtp", + }, + SMTPTimeout: 5 * time.Second, + } + + if err := fm.sendMail(context.Background(), []byte("Subject: hi\r\n\r\nbody\r\n")); err != nil { + t.Fatalf("sendMail: %v", err) + } + wg.Wait() + if serverErr != nil { + t.Fatalf("server: %v", serverErr) + } + if gotEHLO != "client.test" { + t.Errorf("EHLO name = %q, want %q (must not be peer hostname)", gotEHLO, "client.test") + } + decoded, err := base64.StdEncoding.DecodeString(gotAuth) + if err != nil { + t.Fatalf("AUTH not base64: %q", gotAuth) + } + if !strings.Contains(string(decoded), "user@example.com") || + !strings.Contains(string(decoded), "secret") { + t.Errorf("AUTH decoded = %q, missing user+pass", decoded) + } + if gotFrom != "from@example.com" { + t.Errorf("MAIL FROM = %q", gotFrom) + } + if gotRcpt != "to@example.com" { + t.Errorf("RCPT TO = %q", gotRcpt) + } + if !strings.Contains(gotBody.String(), "Subject: hi") { + t.Errorf("DATA body = %q, missing subject", gotBody.String()) + } +} + +// TestSendMail_StartTLSNilConfig proves sendMail builds a default tls.Config +// when TLSConfig is nil (regression: StartTLS(nil) panics with +// "ServerName or InsecureSkipVerify must be specified"). +func TestSendMail_StartTLSNilConfig(t *testing.T) { + cert := selfSignedCert(t) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + host, _, _ := net.SplitHostPort(ln.Addr().String()) + + var ( + wg sync.WaitGroup + serverErr error + once sync.Once + ) + fail := func(err error) { once.Do(func() { serverErr = err }) } + + wg.Go(func() { + conn, err := ln.Accept() + if err != nil { + fail(err) + return + } + defer func() { _ = conn.Close() }() + runFakeSMTP(conn, cert, &fakeCapture{}, fail) + }) + + fm := &FormMailer{ + SMTPHost: ln.Addr().String(), + SMTPFrom: "from@example.com", + SMTPTo: []string{"to@example.com"}, + LocalName: "client.test", + // TLSConfig nil — default path builds {ServerName: host}. Paired with + // the fake server's cert CN=, chain verification succeeds. + TLSConfig: &tls.Config{ + ServerName: host, // the default uses this + RootCAs: poolWith(cert.Leaf), + }, + SMTPTimeout: 5 * time.Second, + } + + if err := fm.sendMail(context.Background(), []byte("Subject: x\r\n\r\n")); err != nil { + t.Fatalf("sendMail: %v", err) + } + wg.Wait() + if serverErr != nil { + t.Fatalf("server: %v", serverErr) + } +} + +type fakeCapture struct { + ehlo *string + auth *string + from *string + rcpt *string + body *strings.Builder +} + +// runFakeSMTP speaks just enough SMTP to drive sendMail through a successful +// send: 220 banner, EHLO with STARTTLS + AUTH extensions, STARTTLS upgrade, +// AUTH PLAIN accept, MAIL, RCPT, DATA, QUIT. +func runFakeSMTP(conn net.Conn, cert tls.Certificate, cap *fakeCapture, fail func(error)) { + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + write := func(s string) { _, _ = w.WriteString(s); _ = w.Flush() } + + write("220 fake.smtp ESMTP ready\r\n") + line, err := r.ReadString('\n') + if err != nil { + fail(err) + return + } + if !strings.HasPrefix(strings.ToUpper(line), "EHLO ") { + fail(errf("expected EHLO, got %q", line)) + return + } + if cap.ehlo != nil { + *cap.ehlo = strings.TrimSpace(line[5:]) + } + write("250-fake.smtp\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n") + + line, err = r.ReadString('\n') + if err != nil { + fail(err) + return + } + if !strings.EqualFold(strings.TrimSpace(line), "STARTTLS") { + fail(errf("expected STARTTLS, got %q", line)) + return + } + write("220 Ready to start TLS\r\n") + + tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + if err := tlsConn.Handshake(); err != nil { + fail(err) + return + } + r = bufio.NewReader(tlsConn) + w = bufio.NewWriter(tlsConn) + write = func(s string) { _, _ = w.WriteString(s); _ = w.Flush() } + + // Post-STARTTLS: expect EHLO again, then AUTH, MAIL, RCPT, DATA. + for { + line, err = r.ReadString('\n') + if err != nil { + return + } + upper := strings.ToUpper(strings.TrimSpace(line)) + switch { + case strings.HasPrefix(upper, "EHLO "): + write("250-fake.smtp\r\n250 AUTH PLAIN\r\n") + case strings.HasPrefix(upper, "AUTH PLAIN"): + if cap.auth != nil { + *cap.auth = strings.TrimSpace(line[len("AUTH PLAIN"):]) + } + write("235 Authenticated\r\n") + case strings.HasPrefix(upper, "MAIL FROM:"): + if cap.from != nil { + *cap.from = extractAddr(line) + } + write("250 OK\r\n") + case strings.HasPrefix(upper, "RCPT TO:"): + if cap.rcpt != nil { + *cap.rcpt = extractAddr(line) + } + write("250 OK\r\n") + case upper == "DATA": + write("354 End data with .\r\n") + for { + bl, err := r.ReadString('\n') + if err != nil { + return + } + if bl == ".\r\n" || strings.TrimRight(bl, "\r\n") == "." { + break + } + if cap.body != nil { + cap.body.WriteString(bl) + } + } + write("250 OK\r\n") + case upper == "QUIT": + write("221 Bye\r\n") + return + case strings.HasPrefix(upper, "RSET"): + write("250 OK\r\n") + default: + write("500 unknown\r\n") + } + } +} + +func extractAddr(line string) string { + if i := strings.Index(line, "<"); i >= 0 { + if j := strings.Index(line[i:], ">"); j > 0 { + return line[i+1 : i+j] + } + } + return strings.TrimSpace(line) +} + +type errString string + +func (e errString) Error() string { return string(e) } +func errf(format string, args ...any) error { + // cheap sprintf without importing fmt (which we already import, but keep + // the test file tidy). + s := format + for _, a := range args { + idx := strings.Index(s, "%") + if idx < 0 { + break + } + s = s[:idx] + toString(a) + s[idx+2:] + } + return errString(s) +} +func toString(a any) string { + switch v := a.(type) { + case string: + return v + case error: + return v.Error() + default: + return "" + } +} + +// selfSignedCert returns a fresh ECDSA self-signed cert/key usable as both +// server cert and a root for client verification in the nil-TLSConfig test. +func selfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "fake.smtp"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + DNSNames: []string{"fake.smtp", "127.0.0.1"}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf} +} + +func poolWith(cert *x509.Certificate) *x509.CertPool { + p := x509.NewCertPool() + p.AddCert(cert) + return p +}