b3c22c261e
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.
648 lines
21 KiB
Go
648 lines
21 KiB
Go
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
|
|
}
|