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