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.
This commit is contained in:
AJ ONeal 2026-04-30 16:49:16 -06:00
parent e2e2a9694a
commit b7b2db4de5
No known key found for this signature in database
3 changed files with 895 additions and 0 deletions

View File

@ -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: <host from SMTPHost>}. 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("<html")) {
return "text/html; charset=utf-8"
}
if bytes.HasPrefix(bytes.TrimSpace(probe), []byte("{")) {
return "application/json"
}
return "text/plain; charset=utf-8"
}
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
}
if fm.Blacklist != nil {
if c := fm.Blacklist.Value(); c != nil && c.ContainsAddr(ip) {
fm.writeError(w, fmt.Errorf("automated requests are not accepted"), false)
return
}
}
if fm.AllowedCountries != nil {
if geo := fm.Geo.Value(); geo != nil {
country := geo.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
}

10
net/formmailer/go.mod Normal file
View File

@ -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
)

View File

@ -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=<host>, 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 <CR><LF>.<CR><LF>\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
}