diff --git a/.golangci.yml b/.golangci.yml old mode 100755 new mode 100644 index 7358795..f544237 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,79 +1,83 @@ +version: "2" run: tests: false - timeout: 3m - skip-dirs: - - cmd/local - linters: enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - typecheck - - unused - - unconvert - gocritic - - exportloopref - - whitespace - misspell - - thelper - revive - -linters-settings: - errcheck: - exclude-functions: - - (*github.com/gin-gonic/gin.Context).AbortWithError - - (*github.com/gin-gonic/gin.Context).Error - - (github.com/gin-gonic/gin.ResponseWriter).WriteString - - (net/http.ResponseWriter).Write - - fmt.Fprintf - - fmt.Fprintln - - (*github.com/jlaffaye/ftp.Response).Close - - (*github.com/jlaffaye/ftp.ServerConn).Quit - - (golang.org/x/crypto/ssh.Conn).Close - - (*github.com/pkg/sftp.File).Close - - (*github.com/pkg/sftp.clientConn).Close - - (*compress/gzip.Reader).Close - - (io.Closer).Close - - (*os.File).Close - - (io/fs.File).Close - - (*github.com/gocraft/work.Enqueuer).Enqueue - - (*encoding/xml.Encoder).EncodeToken - - (*encoding/xml.Encoder).EncodeElement - - (*encoding/xml.Encoder).Flush - - (*encoding/xml.Encoder).Encode - - (io.Writer).Write - - (*encoding/csv.Writer).Write - - os.Remove - - (*os.File).Seek - - (*os.File).WriteString - - (*go.uber.org/zap.Logger).Sync - - io.Copy - - revive: - rules: - - name: var-naming - severity: error - disabled: false - gocritic: - enabled-tags: - - diagnostic - - style - - performance - disabled-checks: - - singleCaseSwitch - - unnecessaryBlock - - unnamedResult - - paramTypeCombine - - emptyStringTest - - regexpSimplify - - preferStringWriter - - badRegexp - - emptyFallthrough - - unlabelStmt - - nestingReduce - - hugeParam - # TODO: enable after testing - - rangeValCopy + - thelper + - unconvert + - whitespace + settings: + errcheck: + exclude-functions: + - (*github.com/gin-gonic/gin.Context).AbortWithError + - (*github.com/gin-gonic/gin.Context).Error + - (github.com/gin-gonic/gin.ResponseWriter).WriteString + - (net/http.ResponseWriter).Write + - fmt.Fprintf + - fmt.Fprintln + - (*github.com/jlaffaye/ftp.Response).Close + - (*github.com/jlaffaye/ftp.ServerConn).Quit + - (golang.org/x/crypto/ssh.Conn).Close + - (*github.com/pkg/sftp.File).Close + - (*github.com/pkg/sftp.clientConn).Close + - (*compress/gzip.Reader).Close + - (io.Closer).Close + - (*os.File).Close + - (io/fs.File).Close + - (*github.com/gocraft/work.Enqueuer).Enqueue + - (*encoding/xml.Encoder).EncodeToken + - (*encoding/xml.Encoder).EncodeElement + - (*encoding/xml.Encoder).Flush + - (*encoding/xml.Encoder).Encode + - (io.Writer).Write + - (*encoding/csv.Writer).Write + - os.Remove + - (*os.File).Seek + - (*os.File).WriteString + - (*go.uber.org/zap.Logger).Sync + - io.Copy + gocritic: + disabled-checks: + - singleCaseSwitch + - unnecessaryBlock + - unnamedResult + - paramTypeCombine + - emptyStringTest + - regexpSimplify + - preferStringWriter + - badRegexp + - emptyFallthrough + - unlabelStmt + - nestingReduce + - hugeParam + - rangeValCopy + enabled-tags: + - diagnostic + - style + - performance + revive: + rules: + - name: var-naming + severity: error + disabled: false + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index b0cf98b..d7a2681 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,34 @@ -COVERAGE_DIR=coverage +COVERAGE_DIR := coverage -lint: +.PHONY: help fmt fmt-check vet lint test test-race test-coverage check clean + +help: ## Show available targets + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +fmt: ## Format Go source files + gofmt -w . + +fmt-check: ## Check Go source formatting + @test -z "$$(gofmt -l .)" + +vet: ## Run Go static analysis + go vet ./... + +lint: ## Run golangci-lint golangci-lint run -goreportcard: - goreportcard-cli -v -test: - go test ./... -test-coverage: - rm -rf ${COVERAGE_DIR} - mkdir ${COVERAGE_DIR} - go test -v -coverprofile ${COVERAGE_DIR}/cover.out ./... - go tool cover -html ${COVERAGE_DIR}/cover.out -o ${COVERAGE_DIR}/cover.html \ No newline at end of file + +test: ## Run tests + go test ./... -count=1 + +test-race: ## Run tests with the race detector + go test -race ./... -count=1 + +test-coverage: ## Generate an HTML coverage report + mkdir -p $(COVERAGE_DIR) + go test ./... -count=1 -coverprofile=$(COVERAGE_DIR)/cover.out + go tool cover -html=$(COVERAGE_DIR)/cover.out -o $(COVERAGE_DIR)/cover.html + +check: fmt-check vet test test-race lint ## Run all validation checks + +clean: ## Remove generated coverage files + rm -rf $(COVERAGE_DIR) diff --git a/README.md b/README.md index 0f9a404..c9476c0 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,106 @@ # Email Sender -## Description +`email-sender` is a focused SMTP transport for caller-built RFC/MIME messages, +with a secondary convenience API for HTML messages and attachments. Network +operations are context-aware and bounded, and TLS uses Go's standard +certificate-chain and hostname verification. -`email-sender` is a simple Go library designed to send emails with optional attachments. It's built on top of the standard Go `net/smtp` library with additional support for sending HTML emails and handling multiple attachments. +## Canonical raw API -## Features +```go +service, err := email.New(email.Config{ + Auth: smtp.PlainAuth("", username, password, "smtp.example.com"), + Host: "smtp.example.com", + Port: "465", + From: "bounce@example.com", + TLSMode: email.TLSModeImplicit, + ConnectTimeout: 10 * time.Second, + OperationTimeout: 30 * time.Second, +}) +if err != nil { + return err +} -- Send HTML emails. -- Attach multiple files to the email. -- Built-in support for TLS encryption. -- Simple API for sending emails. - -## Installation - -Clone this repository: - -```bash -git clone https://gitea.urkob.com/urko/emailsender.git +err = service.SendRawContext(ctx, email.RawMessage{ + EnvelopeFrom: "bounce@example.com", + EnvelopeRecipients: []string{"recipient@example.com"}, + Data: rawMIMEBytes, +}) ``` -## Usage +`Data` is transmitted as the caller supplied it. The transport does not +rebuild MIME, replace multipart boundaries, or generate a second `Message-ID`. +SMTP envelope addresses are explicit and are not inferred from arbitrary MIME +headers. -Check examples in [examples](https://gitea.urkob.com/urko/emailsender/examples) +## TLS modes -## Dependencies +- `TLSModeImplicit`: TLS is established and verified before the SMTP greeting. +- `TLSModeSTARTTLS`: the server must advertise STARTTLS; absence is an error and + the transport never downgrades to plaintext. +- `TLSModeNone`: explicit plaintext SMTP for trusted local development systems + such as Mailpit. Authentication is rejected in this mode. -- Go's standard `net/smtp` package -- Go's standard `crypto/tls` package for secure email sending. +TLS 1.2 or later is required. The configured SMTP hostname becomes +`tls.Config.ServerName`; standard certificate-chain and hostname validation are +always enabled. There is no `InsecureSkipVerify` option. -## Contribution +By default Go uses the operating-system certificate pool. `Config.RootCAs`, +when non-nil, deliberately replaces that pool. To add a private CA while +retaining public roots, start with `x509.SystemCertPool()` and append the CA. -Feel free to submit issues or pull requests if you find any bugs or have suggestions for improvements. +## Contexts and timeouts + +`SendRawContext` and `SendEmailContext` are canonical. TCP establishment uses +`net.Dialer.DialContext` and `ConnectTimeout`. Every greeting, SMTP command, +TLS upgrade, DATA write, and response is bounded by the earlier of the context +deadline and `OperationTimeout`. Context cancellation closes the underlying +connection to interrupt blocked SMTP I/O; no detached send goroutine remains. + +Legacy `SendRaw` and `SendEmail` methods remain as compatibility wrappers. They +use an explicit bounded context derived from the configured timeouts. Legacy +`NewSecure` and `NewSecure465` now perform normal verified TLS. The legacy +`NewInsecure` name is retained for source compatibility but also requires +verified STARTTLS; insecure certificate behavior was intentionally removed. + +## Error classification + +Transport failures wrap `*email.Error`: + +```go +var transportError *email.Error +if errors.As(err, &transportError) && transportError.Temporary() { + // Let the caller's job system retry. +} +``` + +SMTP 4xx responses and ordinary network failures are generally transient. +SMTP 5xx responses, invalid addresses/configuration, missing required STARTTLS, +and certificate failures are permanent. Cancellation preserves +`errors.Is(err, context.Canceled)` and +`errors.Is(err, context.DeadlineExceeded)`. + +Errors include a bounded operation-stage description but never message bodies, +SMTP passwords, or authentication data. + +## SMTP delivery ambiguity + +SMTP is not an exactly-once protocol. A server can accept the final DATA while +the connection fails before the client receives the acknowledgement. A retry +may then deliver a duplicate. A deterministic caller-supplied `Message-ID` +helps downstream clients deduplicate presentation, but cannot provide +mathematical exactly-once delivery. + +## Development and tests + +Plain local SMTP must be selected deliberately with `TLSModeNone` and no +authentication. Automated tests use local deterministic SMTP/TLS servers and +never connect to internet SMTP. + +```bash +gofmt -w . +go vet ./... +go test ./... -count=1 +go test -race ./... -count=1 +git diff --check +``` diff --git a/examples/main.go b/examples/main.go index 894284e..bb047d6 100644 --- a/examples/main.go +++ b/examples/main.go @@ -2,21 +2,30 @@ package main import ( "bytes" + "context" + "log" "net/smtp" + "time" - "gitea.urkob.com/urko/emailsender/pkg/email" + "gitea.wittrail.com/urko/emailsender/pkg/email" ) func main() { - // Here fill with real data - emailService := email.NewInsecure(email.SecureConfig{ - Auth: smtp.PlainAuth("", "your@email.com", "your-password", "smtp.youremail.com"), - Host: "smtp.youremail.com", - Port: "587", - From: "your@email.com", + emailService, err := email.New(email.Config{ + Auth: smtp.PlainAuth("", "your@email.com", "your-password", "smtp.youremail.com"), + Host: "smtp.youremail.com", + Port: "587", + From: "your@email.com", + TLSMode: email.TLSModeSTARTTLS, + ConnectTimeout: 10 * time.Second, + OperationTimeout: 30 * time.Second, }) + if err != nil { + log.Fatal(err) + } - emailService.SendEmail(email.MessageWithAttachments{ + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + err = emailService.SendEmailContext(ctx, email.MessageWithAttachments{ To: "other@email.com", Subject: "Test Email", Body: "
Here your body, you can attach as html
", @@ -27,4 +36,8 @@ func main() { }, }, }) + cancel() + if err != nil { + log.Fatal(err) + } } diff --git a/go.mod b/go.mod index a825da2..c7bef2f 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,3 @@ -module gitea.urkob.com/urko/emailsender +module gitea.wittrail.com/urko/emailsender -go 1.23.4 - -require ( - github.com/joho/godotenv v1.5.1 - github.com/kelseyhightower/envconfig v1.4.0 - github.com/stretchr/testify v1.8.4 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +go 1.26.4 diff --git a/go.sum b/go.sum deleted file mode 100644 index 09dbd53..0000000 --- a/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/email/email.go b/pkg/email/email.go index 8aa4e80..0a77170 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -2,33 +2,72 @@ package email import ( "bytes" + "context" "crypto/tls" "crypto/x509" "encoding/base64" + "errors" "fmt" "io" + "mime" "net" + "net/mail" "net/smtp" - "slices" + "strconv" + "strings" "time" ) +const delimiter = "**=myohmy689407924327" + const ( - mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" - delimeter = "**=myohmy689407924327" + DefaultConnectTimeout = 10 * time.Second + DefaultOperationTimeout = 30 * time.Second + maximumEnvelopeRecipients = 100 ) -type InsecureConfig struct { - Host string - Port string - From string // Sender email address +// 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 // Sender email address + Auth smtp.Auth + Host string + Port string + From string + ConnectTimeout time.Duration + OperationTimeout time.Duration + RootCAs *x509.CertPool } type MessageWithAttachments struct { @@ -38,308 +77,209 @@ type MessageWithAttachments struct { 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 { - To string + 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(a smtp.Auth) error + Auth(smtp.Auth) error Close() error Data() (io.WriteCloser, error) - Mail(from string) error + Mail(string) error Quit() error - Rcpt(to string) error + Rcpt(string) error } -type SmtpDialFn func(hostPort string) (SMTPClientIface, 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 - tlsconfig *tls.Config - dial SmtpDialFn + 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 &EmailService{ - host: config.Host, - port: config.Port, - from: config.From, - dial: dial, - } + return legacyService(Config{ + Host: config.Host, + Port: config.Port, + From: config.From, + TLSMode: TLSModeNone, + ConnectTimeout: config.ConnectTimeout, + OperationTimeout: config.OperationTimeout, + }) } -func NewInsecure(config SecureConfig) *EmailService { - return &EmailService{ - auth: config.Auth, - host: config.Host, - port: config.Port, - from: config.From, - tlsconfig: &tls.Config{ - InsecureSkipVerify: true, - ServerName: config.Host, - }, - dial: dial, - } -} - -var validCommonNames = []string{ - "ISRG Root X1", - "R3", - "R10", - "R13", - "R11", - "E5", - "E7", - "DST Root CA X3", - "DigiCert Global Root G2", - "DigiCert Global G2 TLS RSA SHA256 2020 CA1", -} - -func customVerify(host string) func(cs tls.ConnectionState) error { - return func(cs tls.ConnectionState) error { - // Ensure we have at least one peer certificate - if len(cs.PeerCertificates) == 0 { - return fmt.Errorf("no peer certificates provided") - } - - now := time.Now() - - // Set up verification options with a DNSName check. - // This will perform hostname verification automatically. - opts := x509.VerifyOptions{ - CurrentTime: now, - DNSName: host, // assuming config.Host is accessible here - Intermediates: x509.NewCertPool(), - } - // Add all certificates except the leaf as intermediates. - for i := 1; i < len(cs.PeerCertificates); i++ { - opts.Intermediates.AddCert(cs.PeerCertificates[i]) - } - - // Verify the certificate chain (including hostname check via opts.DNSName) - if _, err := cs.PeerCertificates[0].Verify(opts); err != nil { - return fmt.Errorf("certificate chain verification failed: %w", err) - } - - // Perform additional custom checks - for _, cert := range cs.PeerCertificates { - if now.After(cert.NotAfter) { - return fmt.Errorf("certificate expired on %s", cert.NotAfter) - } - if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) { - return fmt.Errorf("certificate will expire soon on %s", cert.NotAfter) - } - - // Check that the issuer's CommonName is in our allowed list. - if !slices.Contains(validCommonNames, cert.Issuer.CommonName) { - return fmt.Errorf("untrusted certificate issuer: %s", cert.Issuer.CommonName) - } - - // Check that the public key algorithms - switch cert.PublicKeyAlgorithm { - case x509.RSA, x509.ECDSA: - // OK - default: - return fmt.Errorf("unsupported public key algorithm: %v", - cert.PublicKeyAlgorithm) - } - - } - - return nil - } -} +// 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 &EmailService{ - auth: config.Auth, - host: config.Host, - port: config.Port, - from: config.From, - tlsconfig: &tls.Config{ - InsecureSkipVerify: true, - ServerName: config.Host, - VerifyConnection: customVerify(config.Host), - }, - dial: dial, - } + 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 { - tlsCfg := tls.Config{ - // Ideally, InsecureSkipVerify: false, - // or do a proper certificate validation - InsecureSkipVerify: true, - ServerName: config.Host, - VerifyConnection: customVerify(config.Host), + 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{ - auth: config.Auth, - host: config.Host, - port: config.Port, - from: config.From, - tlsconfig: &tlsCfg, - dial: func(hostPort string) (SMTPClientIface, error) { - return dialTLS(hostPort, &tlsCfg) - }, + connectTimeout: defaultDuration(config.ConnectTimeout, DefaultConnectTimeout), + operationTimeout: defaultDuration(config.OperationTimeout, DefaultOperationTimeout), + configurationErr: err, } } -func dial(hostPort string) (SMTPClientIface, error) { - client, err := smtp.Dial(hostPort) - if err != nil { - return nil, err +func defaultDuration(value, fallback time.Duration) time.Duration { + if value <= 0 { + return fallback } - return client, nil + return value } -func dialTLS(hostPort string, tlsConfig *tls.Config) (SMTPClientIface, error) { - // 1) Create a raw TCP connection - conn, err := net.Dial("tcp", hostPort) - if err != nil { - return nil, err +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")) } - - // 2) Wrap it with TLS - tlsConn := tls.Client(conn, tlsConfig) - - // 3) Now create the SMTP client on this TLS connection - host, _, _ := net.SplitHostPort(hostPort) - c, err := smtp.NewClient(tlsConn, host) - if err != nil { - return nil, err + port, err := strconv.Atoi(config.Port) + if err != nil || port < 1 || port > 65535 { + return permanentError("validate configuration", errors.New("SMTP port is invalid")) } - return c, nil + 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 } -func (e *EmailService) SendEmail(emailData MessageWithAttachments) error { - msg, err := newMessage(e.from, emailData.To, emailData.Subject). +// 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 fmt.Errorf("error while preparing email: %w", err) - } - - switch e.port { - case "465": - return e.sendTLS(emailData.To, msg) - default: - return e.send(emailData.To, msg) + return permanentError("prepare message", err) } + return service.SendRawContext(ctx, RawMessage{ + EnvelopeFrom: service.from, + EnvelopeRecipients: []string{emailData.To}, + Data: message, + }) } -func (e *EmailService) SendRaw(emailData RawMessage) error { - switch e.port { - case "465": - return e.sendTLS(emailData.To, []byte(emailData.Body)) - default: - return e.send(emailData.To, []byte(emailData.Body)) - } +// 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) } -func (e *EmailService) send(to string, msg []byte) error { - c, err := e.dial(e.host + ":" + e.port) - if err != nil { - return fmt.Errorf("DIAL: %s", err) - } - - if e.tlsconfig != nil { - if err = c.StartTLS(e.tlsconfig); err != nil { - return fmt.Errorf("c.StartTLS: %s", err) - } - } - - // Auth - if e.auth != nil { - if err = c.Auth(e.auth); err != nil { - return fmt.Errorf("c.Auth: %s", err) - } - } - - // To && From - if err = c.Mail(e.from); err != nil { - return fmt.Errorf("c.Mail: %s", err) - } - - if err = c.Rcpt(to); err != nil { - return fmt.Errorf("c.Rcpt: %s", err) - } - - // Data - w, err := c.Data() - if err != nil { - return fmt.Errorf("c.Data: %s", err) - } - - written, err := w.Write(msg) - if err != nil { - return fmt.Errorf("w.Write: %s", err) - } - - if written <= 0 { - return fmt.Errorf("%d bytes written", written) - } - - if err = w.Close(); err != nil { - return fmt.Errorf("w.Close: %s", err) - } - - if err = c.Quit(); err != nil { - return fmt.Errorf("w.Quit: %s", err) - } - return nil +// 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 (e *EmailService) sendTLS(to string, msg []byte) error { - c, err := e.dial(e.host + ":" + e.port) // dialTLS - if err != nil { - return fmt.Errorf("DIAL: %s", err) - } - defer c.Close() - - // Auth - if err = c.Auth(e.auth); err != nil { - return fmt.Errorf("c.Auth: %s", err) - } - - // To && From - if err = c.Mail(e.from); err != nil { - return fmt.Errorf("c.Mail: %s", err) - } - - if err = c.Rcpt(to); err != nil { - return fmt.Errorf("c.Rcpt: %s", err) - } - - // Data - w, err := c.Data() - if err != nil { - return fmt.Errorf("c.Data: %s", err) - } - - written, err := w.Write(msg) - if err != nil { - return fmt.Errorf("w.Write: %s", err) - } - - if written <= 0 { - return fmt.Errorf("%d bytes written", written) - } - - if err = w.Close(); err != nil { - return fmt.Errorf("w.Close: %s", err) - } - - if err = c.Quit(); err != nil { - return fmt.Errorf("w.Quit: %s", err) - } - - return nil +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 { @@ -353,40 +293,43 @@ func newMessage(from, to, subject string) message { } func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) { - headers := make(map[string]string) - headers["From"] = m.from - headers["To"] = m.to - headers["Subject"] = m.subject - headers["MIME-Version"] = "1.0" - - var message bytes.Buffer - - for k, v := range headers { - message.WriteString(k) - message.WriteString(": ") - message.WriteString(v) - message.WriteString("\r\n") + 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") } - message.WriteString("Content-Type: " + fmt.Sprintf("multipart/mixed; boundary=\"%s\"\r\n", delimeter)) - message.WriteString("--" + delimeter + "\r\n") - message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n") - message.WriteString(body + "\r\n\r\n") + 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 { - attachmentRawFile, err := attachment.ReadContent() + 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 } - message.WriteString("--" + delimeter + "\r\n") - message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n") - message.WriteString("Content-Type: application/octet-stream\r\n") - message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") - message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n") + 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") } - message.WriteString("--" + delimeter + "--") // End the message - return message.Bytes(), nil + output.WriteString("--" + delimiter + "--\r\n") + return output.Bytes(), nil } type EmailAttachment struct { @@ -394,10 +337,25 @@ type EmailAttachment struct { Title string } -func (e EmailAttachment) ReadContent() ([]byte, error) { - bts, err := io.ReadAll(e.File) +func (attachment EmailAttachment) ReadContent() ([]byte, error) { + content, err := io.ReadAll(attachment.File) if err != nil { - return nil, fmt.Errorf("error loading attachment: %s", err) + return nil, fmt.Errorf("load attachment: %w", err) } - return bts, nil + 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") } diff --git a/pkg/email/email_test.go b/pkg/email/email_test.go old mode 100755 new mode 100644 index b9bebf4..34b89c0 --- a/pkg/email/email_test.go +++ b/pkg/email/email_test.go @@ -1,184 +1,68 @@ package email import ( - "fmt" - "net/smtp" - "os" + "bytes" + "context" + "errors" + "strings" "testing" - - "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -type config struct { - MailUser string `required:"false" split_words:"true"` - MailPassword string `required:"false" split_words:"true"` - MailHost string `required:"false" split_words:"true"` - MailPort string `required:"false" split_words:"true"` - MailFrom string `required:"false" split_words:"true"` - MailTo string `required:"false" split_words:"true"` -} - -func newConfig(envFile string) *config { - if envFile != "" { - err := godotenv.Load(envFile) - if err != nil { - panic(fmt.Errorf("godotenv.Load: %w", err)) +func TestMockSendEmailCompatibility(t *testing.T) { + t.Parallel() + called := false + service := NewMockMailService(func(params ...interface{}) { + called = true + if len(params) != 1 || params[0] != "marker" { + t.Fatalf("unexpected callback params: %#v", params) } - } + }, "marker") - cfg := &config{} - err := envconfig.Process("", cfg) - if err != nil { - panic(fmt.Errorf("envconfig.Process: %w", err)) - } - - return cfg -} - -func TestNewConfig_MissingEnvFile(t *testing.T) { - assert.Panics(t, func() { newConfig(".missing_env_file") }) -} - -func TestMockSendEmail(t *testing.T) { - service := NewMockMailService(func(params ...interface{}) {}) - - emailData := MessageWithAttachments{ + err := service.SendEmail(MessageWithAttachments{ To: "test@example.com", Subject: "Test Email", Body: "This is a test email.", - } - - err := service.SendEmail(emailData) + }) if err != nil { - t.Fatalf("expected no error, got %v", err) + t.Fatalf("SendEmail: %v", err) + } + if !called { + t.Fatal("mock callback was not invoked") } } -func TestNewInsecure(t *testing.T) { - cfg := newConfig(".env.test") - - mailSrv := NewInsecure(SecureConfig{ - Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost), - Host: cfg.MailHost, - Port: cfg.MailPort, - From: cfg.MailFrom, - }) - - t.Run("TestSendEmail", func(t *testing.T) { - data := MessageWithAttachments{ - To: cfg.MailTo, - Subject: "Mail Sender", - Body: "Hello this is a test email", +func TestStructuredMessageRejectsHeaderInjection(t *testing.T) { + t.Parallel() + service := NewMockMailService(nil) + tests := []MessageWithAttachments{ + {To: "victim@example.com", Subject: "subject\r\nBcc: attacker@example.com", Body: "body"}, + {To: "victim@example.com\r\nBcc: attacker@example.com", Subject: "subject", Body: "body"}, + { + To: "victim@example.com", + Subject: "subject", + Body: "body", + Attachments: []EmailAttachment{{ + Title: "file.txt\r\nX-Injected: yes", + File: bytes.NewBufferString("content"), + }}, + }, + } + for index, message := range tests { + if err := service.SendEmailContext(context.Background(), message); err == nil { + t.Fatalf("case %d accepted injected header", index) } - require.NoError(t, mailSrv.SendEmail(data)) - }) - - t.Run("TestSendEmailWithAttachments", func(t *testing.T) { - reader, err := os.Open("testdata/attachment1.txt") - require.NoError(t, err) - defer reader.Close() - - reader2, err := os.Open("testdata/attachment2.txt") - require.NoError(t, err) - defer reader2.Close() - - reader3, err := os.Open("testdata/attachment3.txt") - require.NoError(t, err) - defer reader3.Close() - - data := MessageWithAttachments{ - To: cfg.MailTo, - Subject: "Mail Sender", - Body: "Hello this is a test email", - Attachments: []EmailAttachment{ - { - Title: "attachment1.txt", - File: reader, - }, - { - Title: "attachment2.txt", - File: reader2, - }, - { - Title: "attachment3.txt", - File: reader3, - }, - }, - } - err = mailSrv.SendEmail(data) - require.NoError(t, err) - }) - - t.Run("TestWithAttachments", func(t *testing.T) { - msg := newMessage("from", "to", "subject") - content, err := msg.withAttachments("body", nil) - require.NoError(t, err) - assert.Greater(t, len(content), 0) - }) - - t.Run("TestSendEmail_InvalidRecipient", func(t *testing.T) { - data := MessageWithAttachments{ - To: "invalid_email", - Subject: "Test Email", - Body: "This is a test email.", - } - err := mailSrv.SendEmail(data) - assert.Error(t, err) - }) - - t.Run("TestSendEmail_FailedAuthentication", func(t *testing.T) { - // set up authentication to fail - mailSrv := NewInsecure(SecureConfig{ - Auth: smtp.PlainAuth("", "wronguser", "wrongpassword", cfg.MailHost), - Host: cfg.MailHost, - Port: cfg.MailPort, - From: cfg.MailFrom, - }) - data := MessageWithAttachments{ - To: cfg.MailTo, - Subject: "Test Email", - Body: "This is a test email.", - } - err := mailSrv.SendEmail(data) - assert.Error(t, err) - }) + } } -func TestSecure(t *testing.T) { - cfg := newConfig(".env.test") - - emailService := NewSecure(SecureConfig{ - Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost), - Host: cfg.MailHost, - Port: cfg.MailPort, - From: cfg.MailFrom, - }) - - // Assert that the tls.Config is set up correctly - assert.NotNil(t, emailService.tlsconfig) - assert.True(t, emailService.tlsconfig.InsecureSkipVerify) - assert.Equal(t, cfg.MailHost, emailService.tlsconfig.ServerName) - assert.NotNil(t, emailService.tlsconfig.VerifyConnection) - - t.Run("TestSendEmail", func(t *testing.T) { - // Mock the client and test the StartTLS method - var called bool - mockDialFn := func(hostPort string) (SMTPClientIface, error) { - called = true - return &mockSMTP{}, nil - } - emailService.dial = mockDialFn - - data := MessageWithAttachments{ - To: cfg.MailTo, - Subject: "Mail Sender", - Body: "Hello this is a test email", - } - require.NoError(t, emailService.SendEmail(data)) - assert.Equal(t, true, called) - }) - +func TestLegacyConstructorReturnsSafeConfigurationError(t *testing.T) { + t.Parallel() + service := NewSecure(SecureConfig{Host: "", Port: "587", From: "sender@example.com"}) + err := service.SendRaw(RawMessage{To: "recipient@example.com", Body: "Subject: x\r\n\r\nbody"}) + var transportError *Error + if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent { + t.Fatalf("error=%v, want permanent configuration error", err) + } + if strings.Contains(err.Error(), "recipient@example.com") { + t.Fatalf("configuration error leaked message data: %v", err) + } } diff --git a/pkg/email/errors.go b/pkg/email/errors.go new file mode 100644 index 0000000..c499699 --- /dev/null +++ b/pkg/email/errors.go @@ -0,0 +1,141 @@ +package email + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/textproto" + "strings" + "time" +) + +// ErrorKind is the transport-level disposition callers use to decide whether +// a delivery may be retried. Context cancellation remains separately visible +// through errors.Is. +type ErrorKind string + +const ( + ErrorKindTransient ErrorKind = "transient" + ErrorKindPermanent ErrorKind = "permanent" + ErrorKindCanceled ErrorKind = "canceled" +) + +const maximumErrorDetailLength = 256 + +// Error describes a failed SMTP transport stage without exposing credentials +// or message contents. Err remains wrapped for errors.Is/errors.As inspection. +type Error struct { + Kind ErrorKind + Operation string + SMTPCode int + Err error +} + +func (err *Error) Error() string { + if err == nil { + return "smtp transport failed" + } + operation := strings.TrimSpace(err.Operation) + if operation == "" { + operation = "smtp" + } + detail := safeErrorDetail(err.Err) + if err.SMTPCode > 0 { + return fmt.Sprintf("%s failed (SMTP %d): %s", operation, err.SMTPCode, detail) + } + return fmt.Sprintf("%s failed: %s", operation, detail) +} + +func (err *Error) Unwrap() error { + if err == nil { + return nil + } + return err.Err +} + +// Temporary reports whether retrying the same logical delivery can reasonably +// succeed without changing its content or configuration. +func (err *Error) Temporary() bool { + return err != nil && err.Kind == ErrorKindTransient +} + +func classifyError(ctx context.Context, operation string, cause error, fallback ErrorKind) error { + if cause == nil { + return nil + } + if contextErr := ctx.Err(); contextErr != nil { + return &Error{ + Kind: ErrorKindCanceled, + Operation: operation, + Err: errors.Join(contextErr, cause), + } + } + if deadline, hasDeadline := ctx.Deadline(); hasDeadline && !time.Now().Before(deadline) { + return &Error{ + Kind: ErrorKindCanceled, + Operation: operation, + Err: errors.Join(context.DeadlineExceeded, cause), + } + } + if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) { + return &Error{Kind: ErrorKindCanceled, Operation: operation, Err: cause} + } + + kind := fallback + code := 0 + var smtpError *textproto.Error + switch { + case errors.As(cause, &smtpError): + code = smtpError.Code + switch { + case code >= 400 && code < 500: + kind = ErrorKindTransient + case code >= 500 && code < 600: + kind = ErrorKindPermanent + } + case isCertificateError(cause): + kind = ErrorKindPermanent + default: + var networkError net.Error + if errors.As(cause, &networkError) || errors.Is(cause, io.ErrUnexpectedEOF) || errors.Is(cause, io.EOF) { + kind = ErrorKindTransient + } + } + + return &Error{Kind: kind, Operation: operation, SMTPCode: code, Err: cause} +} + +func permanentError(operation string, cause error) error { + return &Error{Kind: ErrorKindPermanent, Operation: operation, Err: cause} +} + +func isCertificateError(err error) bool { + var unknownAuthority x509.UnknownAuthorityError + var hostname x509.HostnameError + var invalid x509.CertificateInvalidError + return errors.As(err, &unknownAuthority) || errors.As(err, &hostname) || errors.As(err, &invalid) +} + +func safeErrorDetail(err error) string { + if err == nil { + return "unknown error" + } + detail := strings.Map(func(character rune) rune { + switch character { + case '\r', '\n', '\t': + return ' ' + default: + return character + } + }, strings.TrimSpace(err.Error())) + if detail == "" { + detail = "unknown error" + } + if len(detail) > maximumErrorDetailLength { + detail = detail[:maximumErrorDetailLength] + } + return detail +} diff --git a/pkg/email/mock.go b/pkg/email/mock.go index c7944bb..ee2ae0d 100644 --- a/pkg/email/mock.go +++ b/pkg/email/mock.go @@ -1,61 +1,26 @@ package email import ( - "crypto/tls" - "io" - "net/smtp" + "context" ) type MockCallbackFn func(params ...interface{}) +// NewMockMailService is retained for compatibility. It never creates a network +// connection and contains no insecure TLS configuration. func NewMockMailService(callbackFn MockCallbackFn, params ...interface{}) *EmailService { return &EmailService{ - auth: smtp.PlainAuth("", "", "", ""), - host: "", - port: "", - from: "", - tlsconfig: &tls.Config{ - InsecureSkipVerify: true, - ServerName: "", - }, - dial: func(hostPort string) (SMTPClientIface, error) { - callbackFn(params) - return &mockSMTP{}, nil + from: "mock@example.invalid", + connectTimeout: DefaultConnectTimeout, + operationTimeout: DefaultOperationTimeout, + sendRawHook: func(ctx context.Context, _ RawMessage) error { + if err := ctx.Err(); err != nil { + return err + } + if callbackFn != nil { + callbackFn(params...) + } + return nil }, } - -} - -type mockWriter struct{} - -func (w *mockWriter) Close() error { - return nil -} -func (w *mockWriter) Write(p []byte) (n int, err error) { - return 10, nil -} - -// Mock SMTP Client -type mockSMTP struct{} - -func (m *mockSMTP) StartTLS(*tls.Config) error { - return nil -} -func (m *mockSMTP) Auth(a smtp.Auth) error { - return nil -} -func (m *mockSMTP) Close() error { - return nil -} -func (m *mockSMTP) Data() (io.WriteCloser, error) { - return &mockWriter{}, nil -} -func (m *mockSMTP) Mail(from string) error { - return nil -} -func (m *mockSMTP) Quit() error { - return nil -} -func (m *mockSMTP) Rcpt(to string) error { - return nil } diff --git a/pkg/email/transport.go b/pkg/email/transport.go new file mode 100644 index 0000000..25f4799 --- /dev/null +++ b/pkg/email/transport.go @@ -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, + } +} diff --git a/pkg/email/transport_test.go b/pkg/email/transport_test.go new file mode 100644 index 0000000..71c3fb7 --- /dev/null +++ b/pkg/email/transport_test.go @@ -0,0 +1,647 @@ +package email + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/smtp" + "strings" + "sync" + "testing" + "time" +) + +const testRawMessage = "From: Senderhtml
\r\n" + + "--phase12--\r\n" + +type smtpServerOptions struct { + mode TLSMode + tlsConfig *tls.Config + advertiseSTARTTLS bool + stallGreeting bool + stallTLSHandshake bool + stallSTARTTLS bool + stallDATAResponse bool + rcptResponse string +} + +type smtpTestServer struct { + t *testing.T + listener net.Listener + options smtpServerOptions + mu sync.Mutex + connection net.Conn + envelopeFrom string + recipients []string + message []byte + authBeforeTLS bool + authSeen bool + startTLSSeen bool + deliverySignal chan struct{} + signalOnce sync.Once + done chan struct{} +} + +func newSMTPTestServer(t *testing.T, options smtpServerOptions) *smtpTestServer { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := &smtpTestServer{ + t: t, + listener: listener, + options: options, + deliverySignal: make(chan struct{}), + done: make(chan struct{}), + } + go server.serve() + t.Cleanup(func() { + _ = listener.Close() + server.mu.Lock() + if server.connection != nil { + _ = server.connection.Close() + } + server.mu.Unlock() + select { + case <-server.done: + case <-time.After(time.Second): + t.Error("SMTP test server did not stop") + } + }) + return server +} + +func (server *smtpTestServer) port() string { + server.t.Helper() + _, port, err := net.SplitHostPort(server.listener.Addr().String()) + if err != nil { + server.t.Fatal(err) + } + return port +} + +func (server *smtpTestServer) serve() { + defer close(server.done) + connection, err := server.listener.Accept() + if err != nil { + return + } + server.mu.Lock() + server.connection = connection + server.mu.Unlock() + defer connection.Close() + + if server.options.stallTLSHandshake { + _, _ = io.Copy(io.Discard, connection) + return + } + + tlsActive := false + if server.options.mode == TLSModeImplicit { + connection = tls.Server(connection, server.options.tlsConfig) + if err := connection.(*tls.Conn).Handshake(); err != nil { + return + } + tlsActive = true + } + if server.options.stallGreeting { + _, _ = io.Copy(io.Discard, connection) + return + } + + reader := bufio.NewReader(connection) + writer := bufio.NewWriter(connection) + if !writeSMTPResponse(writer, "220 localhost ESMTP ready\r\n") { + return + } + + for { + line, readErr := reader.ReadString('\n') + if readErr != nil { + return + } + command := strings.ToUpper(strings.TrimSpace(line)) + switch { + case strings.HasPrefix(command, "EHLO "): + response := "250-localhost\r\n" + if server.options.advertiseSTARTTLS && !tlsActive { + response += "250-STARTTLS\r\n" + } + if tlsActive { + response += "250 AUTH PLAIN\r\n" + } else { + response += "250 OK\r\n" + } + if !writeSMTPResponse(writer, response) { + return + } + case strings.HasPrefix(command, "HELO "): + if !writeSMTPResponse(writer, "250 localhost\r\n") { + return + } + case command == "STARTTLS": + server.mu.Lock() + server.startTLSSeen = true + server.mu.Unlock() + if !server.options.advertiseSTARTTLS || tlsActive { + if !writeSMTPResponse(writer, "454 TLS unavailable\r\n") { + return + } + continue + } + if !writeSMTPResponse(writer, "220 Ready to start TLS\r\n") { + return + } + if server.options.stallSTARTTLS { + _, _ = io.Copy(io.Discard, connection) + return + } + connection = tls.Server(connection, server.options.tlsConfig) + if err := connection.(*tls.Conn).Handshake(); err != nil { + return + } + tlsActive = true + reader = bufio.NewReader(connection) + writer = bufio.NewWriter(connection) + case strings.HasPrefix(command, "AUTH "): + server.mu.Lock() + server.authSeen = true + server.authBeforeTLS = !tlsActive + server.mu.Unlock() + if !writeSMTPResponse(writer, "235 Authentication successful\r\n") { + return + } + case strings.HasPrefix(command, "MAIL FROM:"): + server.mu.Lock() + server.envelopeFrom = strings.TrimSpace(line[len("MAIL FROM:"):]) + server.mu.Unlock() + if !writeSMTPResponse(writer, "250 sender accepted\r\n") { + return + } + case strings.HasPrefix(command, "RCPT TO:"): + server.mu.Lock() + server.recipients = append(server.recipients, strings.TrimSpace(line[len("RCPT TO:"):])) + server.mu.Unlock() + response := server.options.rcptResponse + if response == "" { + response = "250 recipient accepted\r\n" + } + if !writeSMTPResponse(writer, response) { + return + } + case command == "DATA": + if !writeSMTPResponse(writer, "354 End data with