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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user