mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
feat(net/formmailer): add reusable contact form handler
This commit is contained in:
parent
0ae08fe952
commit
98696d2cb5
1051
net/formmailer/formmailer.go
Normal file
1051
net/formmailer/formmailer.go
Normal file
File diff suppressed because it is too large
Load Diff
22
net/formmailer/go.mod
Normal file
22
net/formmailer/go.mod
Normal file
@ -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
|
||||||
28
net/formmailer/go.sum
Normal file
28
net/formmailer/go.sum
Normal file
@ -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=
|
||||||
128
net/formmailer/run_test.go
Normal file
128
net/formmailer/run_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
327
net/formmailer/sendmail_test.go
Normal file
327
net/formmailer/sendmail_test.go
Normal file
@ -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=<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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user