From b7b2db4de5d53cfe0ece35709be2b3877d176d92 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 30 Apr 2026 16:49:16 -0600 Subject: [PATCH] feat(net/formmailer): contact-form mailer with cohort filter + STARTTLS New package handling HTTP POST contact forms: ordered []Field config (GravityForms-compatible input_N defaults), MaxBytesReader body cap, geoip+ipcohort gating, X-Forwarded-For trust via TrustedProxies CIDRs, and SMTP submission over STARTTLS. EHLO LocalName is split from the NewClient peer host so the client identifies itself correctly while PlainAuth still binds to the real server name. Hot-reloadable success/ error response bodies via func() []byte. Includes a fake-SMTP integration test that catches StartTLS(nil) regressions. --- net/formmailer/formmailer.go | 557 ++++++++++++++++++++++++++++++++ net/formmailer/go.mod | 10 + net/formmailer/sendmail_test.go | 328 +++++++++++++++++++ 3 files changed, 895 insertions(+) create mode 100644 net/formmailer/formmailer.go create mode 100644 net/formmailer/go.mod create mode 100644 net/formmailer/sendmail_test.go 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..ba2e1bc --- /dev/null +++ b/net/formmailer/go.mod @@ -0,0 +1,10 @@ +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 +) 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 +} +