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 \r\n" + "To: Recipient \r\n" + "Message-ID: \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

html

\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 .\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 != "" || len(recipients) != 1 || recipients[0] != "" { 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: \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:"}, 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 }