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.
362 lines
11 KiB
Go
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")
|
|
}
|