feat: harden SMTP transport

Add explicit TLS modes, context-aware delivery, and typed transport errors.
Preserve raw MIME messages and cover the new delivery paths with local SMTP tests.
This commit is contained in:
2026-08-18 18:16:22 -06:00
parent 537fbeebd9
commit b3c22c261e
12 changed files with 1560 additions and 652 deletions
+647
View File
@@ -0,0 +1,647 @@
package email
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/smtp"
"strings"
"sync"
"testing"
"time"
)
const testRawMessage = "From: Sender <sender@example.com>\r\n" +
"To: Recipient <recipient@example.com>\r\n" +
"Message-ID: <stable-id@example.com>\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/alternative; boundary=phase12\r\n\r\n" +
"--phase12\r\nContent-Type: text/plain\r\n\r\nplain\r\n" +
"--phase12\r\nContent-Type: text/html\r\n\r\n<p>html</p>\r\n" +
"--phase12--\r\n"
type smtpServerOptions struct {
mode TLSMode
tlsConfig *tls.Config
advertiseSTARTTLS bool
stallGreeting bool
stallTLSHandshake bool
stallSTARTTLS bool
stallDATAResponse bool
rcptResponse string
}
type smtpTestServer struct {
t *testing.T
listener net.Listener
options smtpServerOptions
mu sync.Mutex
connection net.Conn
envelopeFrom string
recipients []string
message []byte
authBeforeTLS bool
authSeen bool
startTLSSeen bool
deliverySignal chan struct{}
signalOnce sync.Once
done chan struct{}
}
func newSMTPTestServer(t *testing.T, options smtpServerOptions) *smtpTestServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := &smtpTestServer{
t: t,
listener: listener,
options: options,
deliverySignal: make(chan struct{}),
done: make(chan struct{}),
}
go server.serve()
t.Cleanup(func() {
_ = listener.Close()
server.mu.Lock()
if server.connection != nil {
_ = server.connection.Close()
}
server.mu.Unlock()
select {
case <-server.done:
case <-time.After(time.Second):
t.Error("SMTP test server did not stop")
}
})
return server
}
func (server *smtpTestServer) port() string {
server.t.Helper()
_, port, err := net.SplitHostPort(server.listener.Addr().String())
if err != nil {
server.t.Fatal(err)
}
return port
}
func (server *smtpTestServer) serve() {
defer close(server.done)
connection, err := server.listener.Accept()
if err != nil {
return
}
server.mu.Lock()
server.connection = connection
server.mu.Unlock()
defer connection.Close()
if server.options.stallTLSHandshake {
_, _ = io.Copy(io.Discard, connection)
return
}
tlsActive := false
if server.options.mode == TLSModeImplicit {
connection = tls.Server(connection, server.options.tlsConfig)
if err := connection.(*tls.Conn).Handshake(); err != nil {
return
}
tlsActive = true
}
if server.options.stallGreeting {
_, _ = io.Copy(io.Discard, connection)
return
}
reader := bufio.NewReader(connection)
writer := bufio.NewWriter(connection)
if !writeSMTPResponse(writer, "220 localhost ESMTP ready\r\n") {
return
}
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
return
}
command := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(command, "EHLO "):
response := "250-localhost\r\n"
if server.options.advertiseSTARTTLS && !tlsActive {
response += "250-STARTTLS\r\n"
}
if tlsActive {
response += "250 AUTH PLAIN\r\n"
} else {
response += "250 OK\r\n"
}
if !writeSMTPResponse(writer, response) {
return
}
case strings.HasPrefix(command, "HELO "):
if !writeSMTPResponse(writer, "250 localhost\r\n") {
return
}
case command == "STARTTLS":
server.mu.Lock()
server.startTLSSeen = true
server.mu.Unlock()
if !server.options.advertiseSTARTTLS || tlsActive {
if !writeSMTPResponse(writer, "454 TLS unavailable\r\n") {
return
}
continue
}
if !writeSMTPResponse(writer, "220 Ready to start TLS\r\n") {
return
}
if server.options.stallSTARTTLS {
_, _ = io.Copy(io.Discard, connection)
return
}
connection = tls.Server(connection, server.options.tlsConfig)
if err := connection.(*tls.Conn).Handshake(); err != nil {
return
}
tlsActive = true
reader = bufio.NewReader(connection)
writer = bufio.NewWriter(connection)
case strings.HasPrefix(command, "AUTH "):
server.mu.Lock()
server.authSeen = true
server.authBeforeTLS = !tlsActive
server.mu.Unlock()
if !writeSMTPResponse(writer, "235 Authentication successful\r\n") {
return
}
case strings.HasPrefix(command, "MAIL FROM:"):
server.mu.Lock()
server.envelopeFrom = strings.TrimSpace(line[len("MAIL FROM:"):])
server.mu.Unlock()
if !writeSMTPResponse(writer, "250 sender accepted\r\n") {
return
}
case strings.HasPrefix(command, "RCPT TO:"):
server.mu.Lock()
server.recipients = append(server.recipients, strings.TrimSpace(line[len("RCPT TO:"):]))
server.mu.Unlock()
response := server.options.rcptResponse
if response == "" {
response = "250 recipient accepted\r\n"
}
if !writeSMTPResponse(writer, response) {
return
}
case command == "DATA":
if !writeSMTPResponse(writer, "354 End data with <CRLF>.<CRLF>\r\n") {
return
}
var data strings.Builder
for {
dataLine, dataErr := reader.ReadString('\n')
if dataErr != nil {
return
}
if dataLine == ".\r\n" {
break
}
if strings.HasPrefix(dataLine, "..") {
dataLine = dataLine[1:]
}
data.WriteString(dataLine)
}
server.mu.Lock()
server.message = []byte(data.String())
server.mu.Unlock()
server.signalOnce.Do(func() { close(server.deliverySignal) })
if server.options.stallDATAResponse {
_, _ = io.Copy(io.Discard, connection)
return
}
if !writeSMTPResponse(writer, "250 message accepted\r\n") {
return
}
case command == "QUIT":
_ = writeSMTPResponse(writer, "221 closing connection\r\n")
return
default:
if !writeSMTPResponse(writer, "500 unsupported command\r\n") {
return
}
}
}
}
func writeSMTPResponse(writer *bufio.Writer, response string) bool {
if _, err := writer.WriteString(response); err != nil {
return false
}
return writer.Flush() == nil
}
func (server *smtpTestServer) snapshot() (string, []string, []byte, bool, bool) {
server.mu.Lock()
defer server.mu.Unlock()
return server.envelopeFrom, append([]string(nil), server.recipients...), append([]byte(nil), server.message...), server.authSeen, server.authBeforeTLS
}
func TestPlainRawSendPreservesEnvelopeAndMIME(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone})
service := newTestService(t, server, Config{TLSMode: TLSModeNone})
err := service.SendRawContext(context.Background(), RawMessage{
EnvelopeFrom: "bounce@example.com",
EnvelopeRecipients: []string{"recipient@example.com"},
Data: []byte(testRawMessage),
})
if err != nil {
t.Fatalf("SendRawContext: %v", err)
}
from, recipients, raw, _, _ := server.snapshot()
if from != "<bounce@example.com>" || len(recipients) != 1 || recipients[0] != "<recipient@example.com>" {
t.Fatalf("unexpected envelope: from=%q recipients=%#v", from, recipients)
}
if string(raw) != testRawMessage {
t.Fatalf("raw MIME changed\n got: %q\nwant: %q", raw, testRawMessage)
}
if !strings.Contains(string(raw), "Message-ID: <stable-id@example.com>\r\n") {
t.Fatal("caller-supplied Message-ID was not preserved")
}
}
func TestImplicitTLSCertificateVerification(t *testing.T) {
validCertificate, trustedRoots := newTestCertificate(t, "localhost", time.Now().Add(time.Hour))
t.Run("trusted certificate succeeds", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeImplicit,
tlsConfig: &tls.Config{Certificates: []tls.Certificate{validCertificate}, MinVersion: tls.VersionTLS12},
})
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: trustedRoots})
if err := service.SendRawContext(context.Background(), canonicalTestMessage()); err != nil {
t.Fatalf("implicit TLS send: %v", err)
}
})
t.Run("wrong hostname fails", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeImplicit,
tlsConfig: &tls.Config{Certificates: []tls.Certificate{validCertificate}, MinVersion: tls.VersionTLS12},
})
service := newTestServiceWithHost(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: trustedRoots}, "127.0.0.1")
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
})
t.Run("untrusted CA fails", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeImplicit,
tlsConfig: &tls.Config{Certificates: []tls.Certificate{validCertificate}, MinVersion: tls.VersionTLS12},
})
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: x509.NewCertPool()})
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
})
t.Run("expired certificate fails", func(t *testing.T) {
expiredCertificate, roots := newTestCertificate(t, "localhost", time.Now().Add(-time.Hour))
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeImplicit,
tlsConfig: &tls.Config{Certificates: []tls.Certificate{expiredCertificate}, MinVersion: tls.VersionTLS12},
})
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: roots})
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
})
}
func TestSTARTTLSRequiredAndAuthenticatedOnlyAfterTLS(t *testing.T) {
certificate, roots := newTestCertificate(t, "localhost", time.Now().Add(time.Hour))
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeSTARTTLS,
advertiseSTARTTLS: true,
tlsConfig: &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12},
})
service := newTestService(t, server, Config{
TLSMode: TLSModeSTARTTLS,
RootCAs: roots,
Auth: smtp.PlainAuth("", "mailer", "not-logged", "localhost"),
})
if err := service.SendRawContext(context.Background(), canonicalTestMessage()); err != nil {
t.Fatalf("STARTTLS send: %v", err)
}
_, _, _, authSeen, authBeforeTLS := server.snapshot()
if !authSeen || authBeforeTLS {
t.Fatalf("authentication state: seen=%v beforeTLS=%v", authSeen, authBeforeTLS)
}
}
func TestSTARTTLSMissingOrInvalidCertificateFails(t *testing.T) {
certificate, roots := newTestCertificate(t, "localhost", time.Now().Add(time.Hour))
t.Run("missing capability", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeSTARTTLS, advertiseSTARTTLS: false})
service := newTestService(t, server, Config{TLSMode: TLSModeSTARTTLS, RootCAs: roots})
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
})
t.Run("invalid certificate", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeSTARTTLS,
advertiseSTARTTLS: true,
tlsConfig: &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12},
})
service := newTestService(t, server, Config{TLSMode: TLSModeSTARTTLS, RootCAs: x509.NewCertPool()})
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
})
}
func TestContextAndOperationTimeoutsInterruptNetworkIO(t *testing.T) {
t.Run("dial context", func(t *testing.T) {
service, err := New(baseTestConfig("localhost", "2525", Config{TLSMode: TLSModeNone}))
if err != nil {
t.Fatal(err)
}
service.dialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
started := time.Now()
err = service.SendRawContext(ctx, canonicalTestMessage())
assertPromptContextError(t, started, err, context.DeadlineExceeded)
})
t.Run("SMTP greeting read stall", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, stallGreeting: true})
service := newTestService(t, server, Config{TLSMode: TLSModeNone, OperationTimeout: 50 * time.Millisecond})
started := time.Now()
err := service.SendRawContext(context.Background(), canonicalTestMessage())
if time.Since(started) > time.Second {
t.Fatalf("operation timeout returned too slowly: %s", time.Since(started))
}
var transportError *Error
if !errors.As(err, &transportError) || !transportError.Temporary() {
t.Fatalf("error=%v, want transient operation timeout", err)
}
})
t.Run("context cancels SMTP read", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, stallGreeting: true})
service := newTestService(t, server, Config{TLSMode: TLSModeNone, OperationTimeout: 5 * time.Second})
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
started := time.Now()
err := service.SendRawContext(ctx, canonicalTestMessage())
assertPromptContextError(t, started, err, context.DeadlineExceeded)
})
t.Run("implicit TLS handshake cancellation", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeImplicit, stallTLSHandshake: true})
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: x509.NewCertPool(), OperationTimeout: 5 * time.Second})
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
started := time.Now()
err := service.SendRawContext(ctx, canonicalTestMessage())
assertPromptContextError(t, started, err, context.DeadlineExceeded)
})
t.Run("STARTTLS handshake cancellation", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{
mode: TLSModeSTARTTLS,
advertiseSTARTTLS: true,
stallSTARTTLS: true,
})
service := newTestService(t, server, Config{TLSMode: TLSModeSTARTTLS, RootCAs: x509.NewCertPool(), OperationTimeout: 5 * time.Second})
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
started := time.Now()
err := service.SendRawContext(ctx, canonicalTestMessage())
assertPromptContextError(t, started, err, context.DeadlineExceeded)
})
t.Run("DATA acknowledgement stall", func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, stallDATAResponse: true})
service := newTestService(t, server, Config{TLSMode: TLSModeNone, OperationTimeout: 5 * time.Second})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
started := time.Now()
err := service.SendRawContext(ctx, canonicalTestMessage())
assertPromptContextError(t, started, err, context.DeadlineExceeded)
})
}
func TestSMTPResponseClassification(t *testing.T) {
tests := []struct {
name string
response string
temporary bool
code int
}{
{name: "temporary 4xx", response: "450 mailbox temporarily unavailable\r\n", temporary: true, code: 450},
{name: "permanent 5xx", response: "550 mailbox unavailable\r\n", temporary: false, code: 550},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, rcptResponse: test.response})
service := newTestService(t, server, Config{TLSMode: TLSModeNone})
err := service.SendRawContext(context.Background(), canonicalTestMessage())
var transportError *Error
if !errors.As(err, &transportError) || transportError.Temporary() != test.temporary || transportError.SMTPCode != test.code {
t.Fatalf("error=%v classification=%+v", err, transportError)
}
})
}
}
func TestCanonicalConfigurationRejectsPlainAuthenticationAndInjection(t *testing.T) {
tests := []Config{
baseTestConfig("localhost", "2525", Config{TLSMode: TLSModeNone, Auth: smtp.PlainAuth("", "u", "p", "localhost")}),
baseTestConfig("bad\r\nhost", "2525", Config{TLSMode: TLSModeImplicit}),
baseTestConfig("localhost", "2525", Config{TLSMode: "automatic"}),
}
for index, config := range tests {
if _, err := New(config); err == nil {
t.Fatalf("case %d accepted invalid configuration", index)
}
}
}
func TestRawEnvelopeValidationAndCompatibilityFields(t *testing.T) {
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone})
service := newTestService(t, server, Config{TLSMode: TLSModeNone})
if err := service.SendRaw(RawMessage{To: "recipient@example.com", Body: testRawMessage}); err != nil {
t.Fatalf("legacy SendRaw: %v", err)
}
invalidService, err := New(baseTestConfig("localhost", "2525", Config{TLSMode: TLSModeNone}))
if err != nil {
t.Fatal(err)
}
invalid := []RawMessage{
{EnvelopeRecipients: []string{"recipient@example.com\r\nRCPT TO:<other@example.com>"}, Data: []byte(testRawMessage)},
{EnvelopeRecipients: []string{"recipient@example.com"}, Data: []byte(testRawMessage), To: "other@example.com"},
{EnvelopeRecipients: []string{"recipient@example.com"}},
}
for index, message := range invalid {
err := invalidService.SendRawContext(context.Background(), message)
var transportError *Error
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent {
t.Fatalf("case %d error=%v, want permanent validation failure", index, err)
}
}
}
func canonicalTestMessage() RawMessage {
return RawMessage{
EnvelopeFrom: "sender@example.com",
EnvelopeRecipients: []string{"recipient@example.com"},
Data: []byte(testRawMessage),
}
}
func newTestService(t *testing.T, server *smtpTestServer, overrides Config) *EmailService {
t.Helper()
return newTestServiceWithHost(t, server, overrides, "localhost")
}
func newTestServiceWithHost(t *testing.T, server *smtpTestServer, overrides Config, host string) *EmailService {
t.Helper()
config := baseTestConfig(host, server.port(), overrides)
service, err := New(config)
if err != nil {
t.Fatal(err)
}
return service
}
func baseTestConfig(host, port string, overrides Config) Config {
config := Config{
Host: host,
Port: port,
From: "sender@example.com",
TLSMode: TLSModeNone,
ConnectTimeout: time.Second,
OperationTimeout: time.Second,
}
if overrides.Auth != nil {
config.Auth = overrides.Auth
}
if overrides.TLSMode != "" {
config.TLSMode = overrides.TLSMode
}
if overrides.ConnectTimeout != 0 {
config.ConnectTimeout = overrides.ConnectTimeout
}
if overrides.OperationTimeout != 0 {
config.OperationTimeout = overrides.OperationTimeout
}
if overrides.RootCAs != nil {
config.RootCAs = overrides.RootCAs
}
return config
}
func assertPermanentTransportError(t *testing.T, err error) {
t.Helper()
var transportError *Error
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent || transportError.Temporary() {
t.Fatalf("error=%v classification=%+v, want permanent", err, transportError)
}
}
func assertPromptContextError(t *testing.T, started time.Time, err error, target error) {
t.Helper()
if !errors.Is(err, target) {
t.Fatalf("error=%v, want errors.Is(%v)", err, target)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("context cancellation took %s", elapsed)
}
var transportError *Error
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindCanceled {
t.Fatalf("error=%v classification=%+v, want canceled", err, transportError)
}
}
func newTestCertificate(t *testing.T, host string, notAfter time.Time) (tls.Certificate, *x509.CertPool) {
t.Helper()
now := time.Now()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Phase 12 Test CA"},
NotBefore: now.Add(-24 * time.Hour),
NotAfter: now.Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
caCertificate, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatal(err)
}
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: host},
NotBefore: now.Add(-2 * time.Hour),
NotAfter: notAfter,
DNSNames: []string{host},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCertificate, &serverKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
certificatePEM = append(certificatePEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
keyDER, err := x509.MarshalPKCS8PrivateKey(serverKey)
if err != nil {
t.Fatal(err)
}
certificate, err := tls.X509KeyPair(certificatePEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(caCertificate)
return certificate, roots
}
func ExampleEmailService_SendRawContext() {
service, _ := New(Config{
Host: "smtp.example.com",
Port: "465",
From: "sender@example.com",
TLSMode: TLSModeImplicit,
ConnectTimeout: 10 * time.Second,
OperationTimeout: 30 * time.Second,
})
_ = service
fmt.Println("configured verified implicit TLS")
// Output: configured verified implicit TLS
}