Files
emailsender/pkg/email/email.go
T
urko b3c22c261e 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.
2026-08-18 18:16:22 -06:00

362 lines
11 KiB
Go

package email
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"net"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
)
const delimiter = "**=myohmy689407924327"
const (
DefaultConnectTimeout = 10 * time.Second
DefaultOperationTimeout = 30 * time.Second
maximumEnvelopeRecipients = 100
)
// TLSMode makes transport security an explicit configuration decision.
type TLSMode string
const (
TLSModeNone TLSMode = "none"
TLSModeSTARTTLS TLSMode = "starttls"
TLSModeImplicit TLSMode = "implicit"
)
// Config is the canonical transport configuration. RootCAs, when non-nil,
// deliberately replaces the operating-system roots; callers that want to add
// a private CA should start from x509.SystemCertPool and append to it.
type Config struct {
Auth smtp.Auth
Host string
Port string
From string
TLSMode TLSMode
ConnectTimeout time.Duration
OperationTimeout time.Duration
RootCAs *x509.CertPool
}
// InsecureConfig is retained for source compatibility. NewInsecureNoAuth is
// an explicitly plaintext, no-authentication development transport.
type InsecureConfig struct {
Host string
Port string
From string
ConnectTimeout time.Duration
OperationTimeout time.Duration
}
// SecureConfig is retained for the legacy secure constructors.
type SecureConfig struct {
Auth smtp.Auth
Host string
Port string
From string
ConnectTimeout time.Duration
OperationTimeout time.Duration
RootCAs *x509.CertPool
}
type MessageWithAttachments struct {
To string
Subject string
Body string
Attachments []EmailAttachment
}
// RawMessage keeps the SMTP envelope separate from the caller-built RFC/MIME
// bytes. To and Body are deprecated compatibility fields; new callers should
// use EnvelopeRecipients and Data. EnvelopeFrom may be empty to use Config.From.
type RawMessage struct {
EnvelopeFrom string
EnvelopeRecipients []string
Data []byte
// Deprecated: use EnvelopeRecipients.
To string
// Deprecated: use Data.
Body string
}
// SMTPClientIface and SmtpDialFn remain exported for source compatibility with
// earlier test doubles. The canonical transport uses context-aware net.Conn
// dialing directly.
type SMTPClientIface interface {
StartTLS(*tls.Config) error
Auth(smtp.Auth) error
Close() error
Data() (io.WriteCloser, error)
Mail(string) error
Quit() error
Rcpt(string) error
}
type SmtpDialFn func(hostPort string) (SMTPClientIface, error) //nolint:revive // Retained for source compatibility.
type dialContextFunc func(context.Context, string, string) (net.Conn, error)
type EmailService struct {
auth smtp.Auth
host string
port string
from string
tlsMode TLSMode
connectTimeout time.Duration
operationTimeout time.Duration
rootCAs *x509.CertPool
dialContext dialContextFunc
configurationErr error
sendRawHook func(context.Context, RawMessage) error
}
// New constructs the canonical validated SMTP transport.
func New(config Config) (*EmailService, error) {
config.Host = strings.TrimSpace(config.Host)
config.Port = strings.TrimSpace(config.Port)
config.From = strings.TrimSpace(config.From)
if config.ConnectTimeout == 0 {
config.ConnectTimeout = DefaultConnectTimeout
}
if config.OperationTimeout == 0 {
config.OperationTimeout = DefaultOperationTimeout
}
if err := validateConfig(config); err != nil {
return nil, err
}
rootCAs := config.RootCAs
if rootCAs != nil {
rootCAs = rootCAs.Clone()
}
dialer := &net.Dialer{Timeout: config.ConnectTimeout}
return &EmailService{
auth: config.Auth,
host: config.Host,
port: config.Port,
from: config.From,
tlsMode: config.TLSMode,
connectTimeout: config.ConnectTimeout,
operationTimeout: config.OperationTimeout,
rootCAs: rootCAs,
dialContext: dialer.DialContext,
}, nil
}
// NewInsecureNoAuth constructs explicit plaintext SMTP for trusted local
// development systems such as Mailpit. It never sends authentication data.
func NewInsecureNoAuth(config InsecureConfig) *EmailService {
return legacyService(Config{
Host: config.Host,
Port: config.Port,
From: config.From,
TLSMode: TLSModeNone,
ConnectTimeout: config.ConnectTimeout,
OperationTimeout: config.OperationTimeout,
})
}
// NewInsecure is retained for compatibility but no longer disables TLS
// verification. It now requires verified STARTTLS exactly like NewSecure.
func NewInsecure(config SecureConfig) *EmailService { return NewSecure(config) }
// NewSecure constructs verified, required STARTTLS.
func NewSecure(config SecureConfig) *EmailService {
return legacyService(Config{
Auth: config.Auth,
Host: config.Host,
Port: config.Port,
From: config.From,
TLSMode: TLSModeSTARTTLS,
ConnectTimeout: config.ConnectTimeout,
OperationTimeout: config.OperationTimeout,
RootCAs: config.RootCAs,
})
}
// NewSecure465 constructs verified implicit TLS. The name is retained for
// compatibility; TLS mode is explicit and does not depend on the port value.
func NewSecure465(config SecureConfig) *EmailService {
return legacyService(Config{
Auth: config.Auth,
Host: config.Host,
Port: config.Port,
From: config.From,
TLSMode: TLSModeImplicit,
ConnectTimeout: config.ConnectTimeout,
OperationTimeout: config.OperationTimeout,
RootCAs: config.RootCAs,
})
}
func legacyService(config Config) *EmailService {
service, err := New(config)
if err == nil {
return service
}
return &EmailService{
connectTimeout: defaultDuration(config.ConnectTimeout, DefaultConnectTimeout),
operationTimeout: defaultDuration(config.OperationTimeout, DefaultOperationTimeout),
configurationErr: err,
}
}
func defaultDuration(value, fallback time.Duration) time.Duration {
if value <= 0 {
return fallback
}
return value
}
func validateConfig(config Config) error {
if config.Host == "" || strings.ContainsAny(config.Host, "\r\n\t /") {
return permanentError("validate configuration", errors.New("SMTP host is invalid"))
}
port, err := strconv.Atoi(config.Port)
if err != nil || port < 1 || port > 65535 {
return permanentError("validate configuration", errors.New("SMTP port is invalid"))
}
if err := validateEnvelopeAddress(config.From); err != nil {
return permanentError("validate configuration", fmt.Errorf("envelope sender: %w", err))
}
switch config.TLSMode {
case TLSModeNone, TLSModeSTARTTLS, TLSModeImplicit:
default:
return permanentError("validate configuration", errors.New("SMTP TLS mode is invalid"))
}
if config.ConnectTimeout <= 0 || config.OperationTimeout <= 0 {
return permanentError("validate configuration", errors.New("SMTP timeouts must be positive"))
}
if config.TLSMode == TLSModeNone && config.Auth != nil {
return permanentError("validate configuration", errors.New("SMTP authentication requires TLS"))
}
return nil
}
// SendEmailContext builds the legacy HTML/attachment message and sends it
// through the canonical context-aware raw path.
func (service *EmailService) SendEmailContext(ctx context.Context, emailData MessageWithAttachments) error {
if strings.TrimSpace(emailData.Subject) == "" || containsHeaderBreak(emailData.Subject) {
return permanentError("prepare message", errors.New("subject is invalid"))
}
message, err := newMessage(service.from, emailData.To, emailData.Subject).
withAttachments(emailData.Body, emailData.Attachments)
if err != nil {
return permanentError("prepare message", err)
}
return service.SendRawContext(ctx, RawMessage{
EnvelopeFrom: service.from,
EnvelopeRecipients: []string{emailData.To},
Data: message,
})
}
// SendEmail is a compatibility wrapper with an explicit bounded lifetime.
func (service *EmailService) SendEmail(emailData MessageWithAttachments) error {
ctx, cancel := service.legacyContext()
defer cancel()
return service.SendEmailContext(ctx, emailData)
}
// SendRaw is a compatibility wrapper with an explicit bounded lifetime.
func (service *EmailService) SendRaw(emailData RawMessage) error {
ctx, cancel := service.legacyContext()
defer cancel()
return service.SendRawContext(ctx, emailData)
}
func (service *EmailService) legacyContext() (context.Context, context.CancelFunc) {
connectTimeout := defaultDuration(service.connectTimeout, DefaultConnectTimeout)
operationTimeout := defaultDuration(service.operationTimeout, DefaultOperationTimeout)
return context.WithTimeout(context.Background(), connectTimeout+10*operationTimeout)
}
type message struct {
from string
to string
subject string
}
func newMessage(from, to, subject string) message {
return message{from: from, to: to, subject: subject}
}
func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) {
if err := validateEnvelopeAddress(m.from); err != nil {
return nil, fmt.Errorf("from address: %w", err)
}
if err := validateEnvelopeAddress(m.to); err != nil {
return nil, fmt.Errorf("recipient address: %w", err)
}
if strings.TrimSpace(m.subject) == "" || containsHeaderBreak(m.subject) {
return nil, errors.New("subject is invalid")
}
var output bytes.Buffer
output.WriteString("From: " + m.from + "\r\n")
output.WriteString("To: " + m.to + "\r\n")
output.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", m.subject) + "\r\n")
output.WriteString("MIME-Version: 1.0\r\n")
_, _ = fmt.Fprintf(&output, "Content-Type: multipart/mixed; boundary=%q\r\n\r\n", delimiter)
output.WriteString("--" + delimiter + "\r\n")
output.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
output.WriteString(body + "\r\n\r\n")
for _, attachment := range attachments {
if strings.TrimSpace(attachment.Title) == "" || containsHeaderBreak(attachment.Title) {
return nil, errors.New("attachment title is invalid")
}
content, err := attachment.ReadContent()
if err != nil {
return nil, err
}
output.WriteString("--" + delimiter + "\r\n")
output.WriteString("Content-Disposition: " + mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Title}) + "\r\n")
output.WriteString("Content-Type: application/octet-stream\r\n")
output.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
output.WriteString(base64.StdEncoding.EncodeToString(content) + "\r\n")
}
output.WriteString("--" + delimiter + "--\r\n")
return output.Bytes(), nil
}
type EmailAttachment struct {
File io.Reader
Title string
}
func (attachment EmailAttachment) ReadContent() ([]byte, error) {
content, err := io.ReadAll(attachment.File)
if err != nil {
return nil, fmt.Errorf("load attachment: %w", err)
}
return content, nil
}
func validateEnvelopeAddress(value string) error {
if value == "" || value != strings.TrimSpace(value) || containsHeaderBreak(value) {
return errors.New("address is invalid")
}
parsed, err := mail.ParseAddress(value)
if err != nil || parsed.Address != value {
return errors.New("address must be one mailbox without a display name")
}
return nil
}
func containsHeaderBreak(value string) bool {
return strings.ContainsAny(value, "\r\n")
}