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
+228
View File
@@ -0,0 +1,228 @@
package email
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/smtp"
"time"
)
// SendRawContext sends caller-built RFC/MIME bytes without rewriting headers,
// boundaries, or Message-ID. The supplied context drives dialing, TLS
// handshakes, and cancellation of blocked SMTP I/O.
func (service *EmailService) SendRawContext(ctx context.Context, message RawMessage) error {
if service == nil {
return permanentError("validate configuration", errors.New("email service is nil"))
}
if ctx == nil {
return permanentError("validate context", errors.New("context is nil"))
}
if err := ctx.Err(); err != nil {
return classifyError(ctx, "begin SMTP delivery", err, ErrorKindCanceled)
}
if service.configurationErr != nil {
return service.configurationErr
}
if service.sendRawHook != nil {
return service.sendRawHook(ctx, message)
}
envelope, err := service.resolveRawMessage(message)
if err != nil {
return permanentError("validate raw message", err)
}
address := net.JoinHostPort(service.host, service.port)
rawConnection, err := service.dialContext(ctx, "tcp", address)
if err != nil {
return classifyError(ctx, "dial SMTP", err, ErrorKindTransient)
}
defer rawConnection.Close()
// Closing the actual socket is what interrupts an SMTP command blocked in
// net/textproto. No detached send goroutine is used.
stopCancellation := context.AfterFunc(ctx, func() {
_ = rawConnection.SetDeadline(time.Now())
_ = rawConnection.Close()
})
defer stopCancellation()
connection := rawConnection
if service.tlsMode == TLSModeImplicit {
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set implicit TLS deadline", err, ErrorKindTransient)
}
secureConnection := tls.Client(connection, service.tlsConfig())
if err := secureConnection.HandshakeContext(ctx); err != nil {
return classifyError(ctx, "implicit TLS handshake", err, ErrorKindTransient)
}
connection = secureConnection
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set SMTP greeting deadline", err, ErrorKindTransient)
}
client, err := smtp.NewClient(connection, service.host)
if err != nil {
return classifyError(ctx, "read SMTP greeting", err, ErrorKindTransient)
}
defer client.Close()
if err := service.hello(ctx, connection, client); err != nil {
return err
}
if service.tlsMode == TLSModeSTARTTLS {
if supported, _ := client.Extension("STARTTLS"); !supported {
return permanentError("start TLS", errors.New("SMTP server does not advertise STARTTLS"))
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set STARTTLS deadline", err, ErrorKindTransient)
}
if err := client.StartTLS(service.tlsConfig()); err != nil {
return classifyError(ctx, "start TLS", err, ErrorKindTransient)
}
}
if service.auth != nil {
if service.tlsMode == TLSModeNone {
return permanentError("authenticate SMTP", errors.New("authentication requires TLS"))
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set SMTP authentication deadline", err, ErrorKindTransient)
}
if err := client.Auth(service.auth); err != nil {
return classifyError(ctx, "authenticate SMTP", err, ErrorKindPermanent)
}
}
if err := service.smtpCommand(ctx, connection, "MAIL FROM", func() error {
return client.Mail(envelope.from)
}); err != nil {
return err
}
for _, recipient := range envelope.recipients {
if err := service.smtpCommand(ctx, connection, "RCPT TO", func() error {
return client.Rcpt(recipient)
}); err != nil {
return err
}
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set SMTP DATA deadline", err, ErrorKindTransient)
}
writer, err := client.Data()
if err != nil {
return classifyError(ctx, "begin SMTP DATA", err, ErrorKindPermanent)
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set message write deadline", err, ErrorKindTransient)
}
written, err := io.Copy(writer, bytes.NewReader(envelope.data))
if err != nil {
return classifyError(ctx, "write SMTP DATA", err, ErrorKindTransient)
}
if written != int64(len(envelope.data)) {
return classifyError(ctx, "write SMTP DATA", io.ErrShortWrite, ErrorKindTransient)
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set SMTP acknowledgement deadline", err, ErrorKindTransient)
}
if err := writer.Close(); err != nil {
// The server may already have accepted DATA even when its final response
// is lost. Retrying can therefore duplicate delivery.
return classifyError(ctx, "finish SMTP DATA", err, ErrorKindTransient)
}
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set SMTP quit deadline", err, ErrorKindTransient)
}
if err := client.Quit(); err != nil {
return classifyError(ctx, "quit SMTP", err, ErrorKindTransient)
}
return nil
}
type resolvedRawMessage struct {
from string
recipients []string
data []byte
}
func (service *EmailService) resolveRawMessage(message RawMessage) (resolvedRawMessage, error) {
if len(message.EnvelopeRecipients) > 0 && message.To != "" {
return resolvedRawMessage{}, errors.New("canonical and deprecated recipient fields cannot both be set")
}
if len(message.Data) > 0 && message.Body != "" {
return resolvedRawMessage{}, errors.New("canonical and deprecated message fields cannot both be set")
}
from := message.EnvelopeFrom
if from == "" {
from = service.from
}
recipients := append([]string(nil), message.EnvelopeRecipients...)
if len(recipients) == 0 && message.To != "" {
recipients = []string{message.To}
}
data := append([]byte(nil), message.Data...)
if len(data) == 0 && message.Body != "" {
data = []byte(message.Body)
}
if err := validateEnvelopeAddress(from); err != nil {
return resolvedRawMessage{}, fmt.Errorf("envelope sender: %w", err)
}
if len(recipients) == 0 || len(recipients) > maximumEnvelopeRecipients {
return resolvedRawMessage{}, errors.New("envelope recipient count is invalid")
}
for _, recipient := range recipients {
if err := validateEnvelopeAddress(recipient); err != nil {
return resolvedRawMessage{}, fmt.Errorf("envelope recipient: %w", err)
}
}
if len(data) == 0 {
return resolvedRawMessage{}, errors.New("raw RFC message is empty")
}
return resolvedRawMessage{from: from, recipients: recipients, data: data}, nil
}
func (service *EmailService) hello(ctx context.Context, connection net.Conn, client *smtp.Client) error {
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set SMTP hello deadline", err, ErrorKindTransient)
}
if err := client.Hello("localhost"); err != nil {
return classifyError(ctx, "SMTP hello", err, ErrorKindTransient)
}
return nil
}
func (service *EmailService) smtpCommand(ctx context.Context, connection net.Conn, operation string, command func() error) error {
if err := service.setDeadline(connection, ctx); err != nil {
return classifyError(ctx, "set "+operation+" deadline", err, ErrorKindTransient)
}
if err := command(); err != nil {
return classifyError(ctx, operation, err, ErrorKindPermanent)
}
return nil
}
func (service *EmailService) setDeadline(connection net.Conn, ctx context.Context) error {
deadline := time.Now().Add(service.operationTimeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
deadline = contextDeadline
}
return connection.SetDeadline(deadline)
}
func (service *EmailService) tlsConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: service.host,
RootCAs: service.rootCAs,
}
}