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.
229 lines
7.6 KiB
Go
229 lines
7.6 KiB
Go
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,
|
|
}
|
|
}
|