diff --git a/net/formmailer/formmailer.go b/net/formmailer/formmailer.go new file mode 100644 index 0000000..7388e81 --- /dev/null +++ b/net/formmailer/formmailer.go @@ -0,0 +1,557 @@ +// 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 setup: +// +// blacklist := dataset.Add(set, func(ctx context.Context) (*ipcohort.Cohort, error) { ... }) +// geo := dataset.Add(geoSet, func(ctx context.Context) (*geoip.Databases, error) { ... }) +// +// 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}, +// {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}, +// }, +// SuccessBody: successHTML, +// ErrorBody: errorHTML, +// Blacklist: blacklist, +// } +// http.Handle("POST /contact", fm) +package formmailer + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/mail" + "net/netip" + "net/smtp" + "os" + "regexp" + "slices" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/therootcompany/golib/net/geoip" + "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 + + 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. +type FormMailer struct { + // 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 + + // Blacklist — if set, matching IPs are rejected before any other processing. + Blacklist *dataset.View[ipcohort.Cohort] + + // Geo — required when AllowedCountries is set. Provides the GeoLite2 + // City/ASN databases used for country lookup. + Geo *dataset.View[geoip.Databases] + + // AllowedCountries — if non-nil, only requests from listed ISO codes are + // accepted. Unknown country ("") is always allowed. Requires Geo to be set. + AllowedCountries []string + + // 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 + + once sync.Once + initErr error + emailIdx int // index into Fields of the KindEmail entry + mu sync.Mutex + limiters map[string]*limiterEntry + reqCount uint64 +} + +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(" 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 +} diff --git a/net/formmailer/go.mod b/net/formmailer/go.mod new file mode 100644 index 0000000..fb58e7a --- /dev/null +++ b/net/formmailer/go.mod @@ -0,0 +1,24 @@ +module github.com/therootcompany/golib/net/formmailer + +go 1.26.0 + +require ( + github.com/therootcompany/golib/net/geoip v0.0.0 + github.com/therootcompany/golib/net/ipcohort v0.0.0 + github.com/therootcompany/golib/sync/dataset v0.0.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 + golang.org/x/sys v0.45.0 // indirect +) + +replace github.com/therootcompany/golib/net/geoip => ../geoip + +replace github.com/therootcompany/golib/net/ipcohort => ../ipcohort + +replace github.com/therootcompany/golib/sync/dataset => ../../sync/dataset + +replace github.com/therootcompany/golib/net/httpcache => ../httpcache diff --git a/net/formmailer/go.sum b/net/formmailer/go.sum new file mode 100644 index 0000000..9bdf766 --- /dev/null +++ b/net/formmailer/go.sum @@ -0,0 +1,18 @@ +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= +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/sendmail_test.go b/net/formmailer/sendmail_test.go new file mode 100644 index 0000000..ecdd701 --- /dev/null +++ b/net/formmailer/sendmail_test.go @@ -0,0 +1,328 @@ +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 +} +