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:
Executable → Regular
+32
-28
@@ -1,27 +1,15 @@
|
|||||||
|
version: "2"
|
||||||
run:
|
run:
|
||||||
tests: false
|
tests: false
|
||||||
timeout: 3m
|
|
||||||
skip-dirs:
|
|
||||||
- cmd/local
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
|
||||||
- gosimple
|
|
||||||
- govet
|
|
||||||
- ineffassign
|
|
||||||
- staticcheck
|
|
||||||
- typecheck
|
|
||||||
- unused
|
|
||||||
- unconvert
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- exportloopref
|
|
||||||
- whitespace
|
|
||||||
- misspell
|
- misspell
|
||||||
- thelper
|
|
||||||
- revive
|
- revive
|
||||||
|
- thelper
|
||||||
linters-settings:
|
- unconvert
|
||||||
|
- whitespace
|
||||||
|
settings:
|
||||||
errcheck:
|
errcheck:
|
||||||
exclude-functions:
|
exclude-functions:
|
||||||
- (*github.com/gin-gonic/gin.Context).AbortWithError
|
- (*github.com/gin-gonic/gin.Context).AbortWithError
|
||||||
@@ -51,17 +39,7 @@ linters-settings:
|
|||||||
- (*os.File).WriteString
|
- (*os.File).WriteString
|
||||||
- (*go.uber.org/zap.Logger).Sync
|
- (*go.uber.org/zap.Logger).Sync
|
||||||
- io.Copy
|
- io.Copy
|
||||||
|
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: var-naming
|
|
||||||
severity: error
|
|
||||||
disabled: false
|
|
||||||
gocritic:
|
gocritic:
|
||||||
enabled-tags:
|
|
||||||
- diagnostic
|
|
||||||
- style
|
|
||||||
- performance
|
|
||||||
disabled-checks:
|
disabled-checks:
|
||||||
- singleCaseSwitch
|
- singleCaseSwitch
|
||||||
- unnecessaryBlock
|
- unnecessaryBlock
|
||||||
@@ -75,5 +53,31 @@ linters-settings:
|
|||||||
- unlabelStmt
|
- unlabelStmt
|
||||||
- nestingReduce
|
- nestingReduce
|
||||||
- hugeParam
|
- hugeParam
|
||||||
# TODO: enable after testing
|
|
||||||
- rangeValCopy
|
- 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$
|
||||||
|
|||||||
@@ -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
|
golangci-lint run
|
||||||
goreportcard:
|
|
||||||
goreportcard-cli -v
|
test: ## Run tests
|
||||||
test:
|
go test ./... -count=1
|
||||||
go test ./...
|
|
||||||
test-coverage:
|
test-race: ## Run tests with the race detector
|
||||||
rm -rf ${COVERAGE_DIR}
|
go test -race ./... -count=1
|
||||||
mkdir ${COVERAGE_DIR}
|
|
||||||
go test -v -coverprofile ${COVERAGE_DIR}/cover.out ./...
|
test-coverage: ## Generate an HTML coverage report
|
||||||
go tool cover -html ${COVERAGE_DIR}/cover.out -o ${COVERAGE_DIR}/cover.html
|
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)
|
||||||
|
|||||||
@@ -1,33 +1,106 @@
|
|||||||
# Email Sender
|
# 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.
|
err = service.SendRawContext(ctx, email.RawMessage{
|
||||||
- Attach multiple files to the email.
|
EnvelopeFrom: "bounce@example.com",
|
||||||
- Built-in support for TLS encryption.
|
EnvelopeRecipients: []string{"recipient@example.com"},
|
||||||
- Simple API for sending emails.
|
Data: rawMIMEBytes,
|
||||||
|
})
|
||||||
## Installation
|
|
||||||
|
|
||||||
Clone this repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://gitea.urkob.com/urko/emailsender.git
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
TLS 1.2 or later is required. The configured SMTP hostname becomes
|
||||||
- Go's standard `crypto/tls` package for secure email sending.
|
`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
|
||||||
|
```
|
||||||
|
|||||||
+17
-4
@@ -2,21 +2,30 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.urkob.com/urko/emailsender/pkg/email"
|
"gitea.wittrail.com/urko/emailsender/pkg/email"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Here fill with real data
|
emailService, err := email.New(email.Config{
|
||||||
emailService := email.NewInsecure(email.SecureConfig{
|
|
||||||
Auth: smtp.PlainAuth("", "your@email.com", "your-password", "smtp.youremail.com"),
|
Auth: smtp.PlainAuth("", "your@email.com", "your-password", "smtp.youremail.com"),
|
||||||
Host: "smtp.youremail.com",
|
Host: "smtp.youremail.com",
|
||||||
Port: "587",
|
Port: "587",
|
||||||
From: "your@email.com",
|
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",
|
To: "other@email.com",
|
||||||
Subject: "Test Email",
|
Subject: "Test Email",
|
||||||
Body: "<html><body><p>Here your body, you can attach as html<p/></body></html>",
|
Body: "<html><body><p>Here your body, you can attach as html<p/></body></html>",
|
||||||
@@ -27,4 +36,8 @@ func main() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
module gitea.urkob.com/urko/emailsender
|
module gitea.wittrail.com/urko/emailsender
|
||||||
|
|
||||||
go 1.23.4
|
go 1.26.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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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=
|
|
||||||
+247
-289
@@ -2,33 +2,72 @@ package email
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
|
"net/mail"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"slices"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const delimiter = "**=myohmy689407924327"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
|
DefaultConnectTimeout = 10 * time.Second
|
||||||
delimeter = "**=myohmy689407924327"
|
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 {
|
type InsecureConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
From string // Sender email address
|
From string
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
OperationTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecureConfig is retained for the legacy secure constructors.
|
||||||
type SecureConfig struct {
|
type SecureConfig struct {
|
||||||
Auth smtp.Auth
|
Auth smtp.Auth
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
From string // Sender email address
|
From string
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
OperationTimeout time.Duration
|
||||||
|
RootCAs *x509.CertPool
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageWithAttachments struct {
|
type MessageWithAttachments struct {
|
||||||
@@ -38,308 +77,209 @@ type MessageWithAttachments struct {
|
|||||||
Attachments []EmailAttachment
|
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 {
|
type RawMessage struct {
|
||||||
|
EnvelopeFrom string
|
||||||
|
EnvelopeRecipients []string
|
||||||
|
Data []byte
|
||||||
|
|
||||||
|
// Deprecated: use EnvelopeRecipients.
|
||||||
To string
|
To string
|
||||||
|
// Deprecated: use Data.
|
||||||
Body string
|
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 {
|
type SMTPClientIface interface {
|
||||||
StartTLS(*tls.Config) error
|
StartTLS(*tls.Config) error
|
||||||
Auth(a smtp.Auth) error
|
Auth(smtp.Auth) error
|
||||||
Close() error
|
Close() error
|
||||||
Data() (io.WriteCloser, error)
|
Data() (io.WriteCloser, error)
|
||||||
Mail(from string) error
|
Mail(string) error
|
||||||
Quit() 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 {
|
type EmailService struct {
|
||||||
auth smtp.Auth
|
auth smtp.Auth
|
||||||
host string
|
host string
|
||||||
port string
|
port string
|
||||||
from string
|
from string
|
||||||
tlsconfig *tls.Config
|
tlsMode TLSMode
|
||||||
dial SmtpDialFn
|
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 {
|
func NewInsecureNoAuth(config InsecureConfig) *EmailService {
|
||||||
return &EmailService{
|
return legacyService(Config{
|
||||||
host: config.Host,
|
Host: config.Host,
|
||||||
port: config.Port,
|
Port: config.Port,
|
||||||
from: config.From,
|
From: config.From,
|
||||||
dial: dial,
|
TLSMode: TLSModeNone,
|
||||||
}
|
ConnectTimeout: config.ConnectTimeout,
|
||||||
|
OperationTimeout: config.OperationTimeout,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInsecure(config SecureConfig) *EmailService {
|
// NewInsecure is retained for compatibility but no longer disables TLS
|
||||||
return &EmailService{
|
// verification. It now requires verified STARTTLS exactly like NewSecure.
|
||||||
auth: config.Auth,
|
func NewInsecure(config SecureConfig) *EmailService { return NewSecure(config) }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// NewSecure constructs verified, required STARTTLS.
|
||||||
func NewSecure(config SecureConfig) *EmailService {
|
func NewSecure(config SecureConfig) *EmailService {
|
||||||
return &EmailService{
|
return legacyService(Config{
|
||||||
auth: config.Auth,
|
Auth: config.Auth,
|
||||||
host: config.Host,
|
Host: config.Host,
|
||||||
port: config.Port,
|
Port: config.Port,
|
||||||
from: config.From,
|
From: config.From,
|
||||||
tlsconfig: &tls.Config{
|
TLSMode: TLSModeSTARTTLS,
|
||||||
InsecureSkipVerify: true,
|
ConnectTimeout: config.ConnectTimeout,
|
||||||
ServerName: config.Host,
|
OperationTimeout: config.OperationTimeout,
|
||||||
VerifyConnection: customVerify(config.Host),
|
RootCAs: config.RootCAs,
|
||||||
},
|
})
|
||||||
dial: dial,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func NewSecure465(config SecureConfig) *EmailService {
|
||||||
tlsCfg := tls.Config{
|
return legacyService(Config{
|
||||||
// Ideally, InsecureSkipVerify: false,
|
Auth: config.Auth,
|
||||||
// or do a proper certificate validation
|
Host: config.Host,
|
||||||
InsecureSkipVerify: true,
|
Port: config.Port,
|
||||||
ServerName: config.Host,
|
From: config.From,
|
||||||
VerifyConnection: customVerify(config.Host),
|
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{
|
return &EmailService{
|
||||||
auth: config.Auth,
|
connectTimeout: defaultDuration(config.ConnectTimeout, DefaultConnectTimeout),
|
||||||
host: config.Host,
|
operationTimeout: defaultDuration(config.OperationTimeout, DefaultOperationTimeout),
|
||||||
port: config.Port,
|
configurationErr: err,
|
||||||
from: config.From,
|
|
||||||
tlsconfig: &tlsCfg,
|
|
||||||
dial: func(hostPort string) (SMTPClientIface, error) {
|
|
||||||
return dialTLS(hostPort, &tlsCfg)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dial(hostPort string) (SMTPClientIface, error) {
|
func defaultDuration(value, fallback time.Duration) time.Duration {
|
||||||
client, err := smtp.Dial(hostPort)
|
if value <= 0 {
|
||||||
if err != nil {
|
return fallback
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return client, nil
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func dialTLS(hostPort string, tlsConfig *tls.Config) (SMTPClientIface, error) {
|
func validateConfig(config Config) error {
|
||||||
// 1) Create a raw TCP connection
|
if config.Host == "" || strings.ContainsAny(config.Host, "\r\n\t /") {
|
||||||
conn, err := net.Dial("tcp", hostPort)
|
return permanentError("validate configuration", errors.New("SMTP host is invalid"))
|
||||||
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"))
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Wrap it with TLS
|
// SendEmailContext builds the legacy HTML/attachment message and sends it
|
||||||
tlsConn := tls.Client(conn, tlsConfig)
|
// through the canonical context-aware raw path.
|
||||||
|
func (service *EmailService) SendEmailContext(ctx context.Context, emailData MessageWithAttachments) error {
|
||||||
// 3) Now create the SMTP client on this TLS connection
|
if strings.TrimSpace(emailData.Subject) == "" || containsHeaderBreak(emailData.Subject) {
|
||||||
host, _, _ := net.SplitHostPort(hostPort)
|
return permanentError("prepare message", errors.New("subject is invalid"))
|
||||||
c, err := smtp.NewClient(tlsConn, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return c, nil
|
message, err := newMessage(service.from, emailData.To, emailData.Subject).
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EmailService) SendEmail(emailData MessageWithAttachments) error {
|
|
||||||
msg, err := newMessage(e.from, emailData.To, emailData.Subject).
|
|
||||||
withAttachments(emailData.Body, emailData.Attachments)
|
withAttachments(emailData.Body, emailData.Attachments)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error while preparing email: %w", err)
|
return permanentError("prepare message", err)
|
||||||
|
}
|
||||||
|
return service.SendRawContext(ctx, RawMessage{
|
||||||
|
EnvelopeFrom: service.from,
|
||||||
|
EnvelopeRecipients: []string{emailData.To},
|
||||||
|
Data: message,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
switch e.port {
|
// SendEmail is a compatibility wrapper with an explicit bounded lifetime.
|
||||||
case "465":
|
func (service *EmailService) SendEmail(emailData MessageWithAttachments) error {
|
||||||
return e.sendTLS(emailData.To, msg)
|
ctx, cancel := service.legacyContext()
|
||||||
default:
|
defer cancel()
|
||||||
return e.send(emailData.To, msg)
|
return service.SendEmailContext(ctx, emailData)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmailService) SendRaw(emailData RawMessage) error {
|
// SendRaw is a compatibility wrapper with an explicit bounded lifetime.
|
||||||
switch e.port {
|
func (service *EmailService) SendRaw(emailData RawMessage) error {
|
||||||
case "465":
|
ctx, cancel := service.legacyContext()
|
||||||
return e.sendTLS(emailData.To, []byte(emailData.Body))
|
defer cancel()
|
||||||
default:
|
return service.SendRawContext(ctx, emailData)
|
||||||
return e.send(emailData.To, []byte(emailData.Body))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmailService) send(to string, msg []byte) error {
|
func (service *EmailService) legacyContext() (context.Context, context.CancelFunc) {
|
||||||
c, err := e.dial(e.host + ":" + e.port)
|
connectTimeout := defaultDuration(service.connectTimeout, DefaultConnectTimeout)
|
||||||
if err != nil {
|
operationTimeout := defaultDuration(service.operationTimeout, DefaultOperationTimeout)
|
||||||
return fmt.Errorf("DIAL: %s", err)
|
return context.WithTimeout(context.Background(), connectTimeout+10*operationTimeout)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type message struct {
|
type message struct {
|
||||||
@@ -353,40 +293,43 @@ func newMessage(from, to, subject string) message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) {
|
func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) {
|
||||||
headers := make(map[string]string)
|
if err := validateEnvelopeAddress(m.from); err != nil {
|
||||||
headers["From"] = m.from
|
return nil, fmt.Errorf("from address: %w", err)
|
||||||
headers["To"] = m.to
|
}
|
||||||
headers["Subject"] = m.subject
|
if err := validateEnvelopeAddress(m.to); err != nil {
|
||||||
headers["MIME-Version"] = "1.0"
|
return nil, fmt.Errorf("recipient address: %w", err)
|
||||||
|
}
|
||||||
var message bytes.Buffer
|
if strings.TrimSpace(m.subject) == "" || containsHeaderBreak(m.subject) {
|
||||||
|
return nil, errors.New("subject is invalid")
|
||||||
for k, v := range headers {
|
|
||||||
message.WriteString(k)
|
|
||||||
message.WriteString(": ")
|
|
||||||
message.WriteString(v)
|
|
||||||
message.WriteString("\r\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message.WriteString("Content-Type: " + fmt.Sprintf("multipart/mixed; boundary=\"%s\"\r\n", delimeter))
|
var output bytes.Buffer
|
||||||
message.WriteString("--" + delimeter + "\r\n")
|
output.WriteString("From: " + m.from + "\r\n")
|
||||||
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
output.WriteString("To: " + m.to + "\r\n")
|
||||||
message.WriteString(body + "\r\n\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 {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
message.WriteString("--" + delimeter + "\r\n")
|
output.WriteString("--" + delimiter + "\r\n")
|
||||||
message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n")
|
output.WriteString("Content-Disposition: " + mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Title}) + "\r\n")
|
||||||
message.WriteString("Content-Type: application/octet-stream\r\n")
|
output.WriteString("Content-Type: application/octet-stream\r\n")
|
||||||
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
output.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||||
message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n")
|
output.WriteString(base64.StdEncoding.EncodeToString(content) + "\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
message.WriteString("--" + delimeter + "--") // End the message
|
output.WriteString("--" + delimiter + "--\r\n")
|
||||||
return message.Bytes(), nil
|
return output.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmailAttachment struct {
|
type EmailAttachment struct {
|
||||||
@@ -394,10 +337,25 @@ type EmailAttachment struct {
|
|||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e EmailAttachment) ReadContent() ([]byte, error) {
|
func (attachment EmailAttachment) ReadContent() ([]byte, error) {
|
||||||
bts, err := io.ReadAll(e.File)
|
content, err := io.ReadAll(attachment.File)
|
||||||
if err != nil {
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
Executable → Regular
+43
-159
@@ -1,184 +1,68 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"bytes"
|
||||||
"net/smtp"
|
"context"
|
||||||
"os"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/kelseyhightower/envconfig"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type config struct {
|
func TestMockSendEmailCompatibility(t *testing.T) {
|
||||||
MailUser string `required:"false" split_words:"true"`
|
t.Parallel()
|
||||||
MailPassword string `required:"false" split_words:"true"`
|
called := false
|
||||||
MailHost string `required:"false" split_words:"true"`
|
service := NewMockMailService(func(params ...interface{}) {
|
||||||
MailPort string `required:"false" split_words:"true"`
|
called = true
|
||||||
MailFrom string `required:"false" split_words:"true"`
|
if len(params) != 1 || params[0] != "marker" {
|
||||||
MailTo string `required:"false" split_words:"true"`
|
t.Fatalf("unexpected callback params: %#v", params)
|
||||||
}
|
}
|
||||||
|
}, "marker")
|
||||||
|
|
||||||
func newConfig(envFile string) *config {
|
err := service.SendEmail(MessageWithAttachments{
|
||||||
if envFile != "" {
|
|
||||||
err := godotenv.Load(envFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Errorf("godotenv.Load: %w", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
|
||||||
To: "test@example.com",
|
To: "test@example.com",
|
||||||
Subject: "Test Email",
|
Subject: "Test Email",
|
||||||
Body: "This is a test email.",
|
Body: "This is a test email.",
|
||||||
}
|
})
|
||||||
|
|
||||||
err := service.SendEmail(emailData)
|
|
||||||
if err != nil {
|
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) {
|
func TestStructuredMessageRejectsHeaderInjection(t *testing.T) {
|
||||||
cfg := newConfig(".env.test")
|
t.Parallel()
|
||||||
|
service := NewMockMailService(nil)
|
||||||
mailSrv := NewInsecure(SecureConfig{
|
tests := []MessageWithAttachments{
|
||||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
{To: "victim@example.com", Subject: "subject\r\nBcc: attacker@example.com", Body: "body"},
|
||||||
Host: cfg.MailHost,
|
{To: "victim@example.com\r\nBcc: attacker@example.com", Subject: "subject", Body: "body"},
|
||||||
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",
|
|
||||||
}
|
|
||||||
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",
|
To: "victim@example.com",
|
||||||
File: reader,
|
Subject: "subject",
|
||||||
},
|
Body: "body",
|
||||||
{
|
Attachments: []EmailAttachment{{
|
||||||
Title: "attachment2.txt",
|
Title: "file.txt\r\nX-Injected: yes",
|
||||||
File: reader2,
|
File: bytes.NewBufferString("content"),
|
||||||
},
|
}},
|
||||||
{
|
|
||||||
Title: "attachment3.txt",
|
|
||||||
File: reader3,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err = mailSrv.SendEmail(data)
|
for index, message := range tests {
|
||||||
require.NoError(t, err)
|
if err := service.SendEmailContext(context.Background(), message); err == nil {
|
||||||
})
|
t.Fatalf("case %d accepted injected header", index)
|
||||||
|
|
||||||
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) {
|
func TestLegacyConstructorReturnsSafeConfigurationError(t *testing.T) {
|
||||||
cfg := newConfig(".env.test")
|
t.Parallel()
|
||||||
|
service := NewSecure(SecureConfig{Host: "", Port: "587", From: "sender@example.com"})
|
||||||
emailService := NewSecure(SecureConfig{
|
err := service.SendRaw(RawMessage{To: "recipient@example.com", Body: "Subject: x\r\n\r\nbody"})
|
||||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
var transportError *Error
|
||||||
Host: cfg.MailHost,
|
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent {
|
||||||
Port: cfg.MailPort,
|
t.Fatalf("error=%v, want permanent configuration error", err)
|
||||||
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
|
if strings.Contains(err.Error(), "recipient@example.com") {
|
||||||
|
t.Fatalf("configuration error leaked message data: %v", err)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+14
-49
@@ -1,61 +1,26 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"context"
|
||||||
"io"
|
|
||||||
"net/smtp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MockCallbackFn func(params ...interface{})
|
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 {
|
func NewMockMailService(callbackFn MockCallbackFn, params ...interface{}) *EmailService {
|
||||||
return &EmailService{
|
return &EmailService{
|
||||||
auth: smtp.PlainAuth("", "", "", ""),
|
from: "mock@example.invalid",
|
||||||
host: "",
|
connectTimeout: DefaultConnectTimeout,
|
||||||
port: "",
|
operationTimeout: DefaultOperationTimeout,
|
||||||
from: "",
|
sendRawHook: func(ctx context.Context, _ RawMessage) error {
|
||||||
tlsconfig: &tls.Config{
|
if err := ctx.Err(); err != nil {
|
||||||
InsecureSkipVerify: true,
|
return err
|
||||||
ServerName: "",
|
}
|
||||||
},
|
if callbackFn != nil {
|
||||||
dial: func(hostPort string) (SMTPClientIface, error) {
|
callbackFn(params...)
|
||||||
callbackFn(params)
|
}
|
||||||
return &mockSMTP{}, nil
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: Sender <sender@example.com>\r\n" +
|
||||||
|
"To: Recipient <recipient@example.com>\r\n" +
|
||||||
|
"Message-ID: <stable-id@example.com>\r\n" +
|
||||||
|
"MIME-Version: 1.0\r\n" +
|
||||||
|
"Content-Type: multipart/alternative; boundary=phase12\r\n\r\n" +
|
||||||
|
"--phase12\r\nContent-Type: text/plain\r\n\r\nplain\r\n" +
|
||||||
|
"--phase12\r\nContent-Type: text/html\r\n\r\n<p>html</p>\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 <CRLF>.<CRLF>\r\n") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var data strings.Builder
|
||||||
|
for {
|
||||||
|
dataLine, dataErr := reader.ReadString('\n')
|
||||||
|
if dataErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dataLine == ".\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(dataLine, "..") {
|
||||||
|
dataLine = dataLine[1:]
|
||||||
|
}
|
||||||
|
data.WriteString(dataLine)
|
||||||
|
}
|
||||||
|
server.mu.Lock()
|
||||||
|
server.message = []byte(data.String())
|
||||||
|
server.mu.Unlock()
|
||||||
|
server.signalOnce.Do(func() { close(server.deliverySignal) })
|
||||||
|
if server.options.stallDATAResponse {
|
||||||
|
_, _ = io.Copy(io.Discard, connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !writeSMTPResponse(writer, "250 message accepted\r\n") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case command == "QUIT":
|
||||||
|
_ = writeSMTPResponse(writer, "221 closing connection\r\n")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if !writeSMTPResponse(writer, "500 unsupported command\r\n") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSMTPResponse(writer *bufio.Writer, response string) bool {
|
||||||
|
if _, err := writer.WriteString(response); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return writer.Flush() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *smtpTestServer) snapshot() (string, []string, []byte, bool, bool) {
|
||||||
|
server.mu.Lock()
|
||||||
|
defer server.mu.Unlock()
|
||||||
|
return server.envelopeFrom, append([]string(nil), server.recipients...), append([]byte(nil), server.message...), server.authSeen, server.authBeforeTLS
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlainRawSendPreservesEnvelopeAndMIME(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeNone})
|
||||||
|
|
||||||
|
err := service.SendRawContext(context.Background(), RawMessage{
|
||||||
|
EnvelopeFrom: "bounce@example.com",
|
||||||
|
EnvelopeRecipients: []string{"recipient@example.com"},
|
||||||
|
Data: []byte(testRawMessage),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendRawContext: %v", err)
|
||||||
|
}
|
||||||
|
from, recipients, raw, _, _ := server.snapshot()
|
||||||
|
if from != "<bounce@example.com>" || len(recipients) != 1 || recipients[0] != "<recipient@example.com>" {
|
||||||
|
t.Fatalf("unexpected envelope: from=%q recipients=%#v", from, recipients)
|
||||||
|
}
|
||||||
|
if string(raw) != testRawMessage {
|
||||||
|
t.Fatalf("raw MIME changed\n got: %q\nwant: %q", raw, testRawMessage)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "Message-ID: <stable-id@example.com>\r\n") {
|
||||||
|
t.Fatal("caller-supplied Message-ID was not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImplicitTLSCertificateVerification(t *testing.T) {
|
||||||
|
validCertificate, trustedRoots := newTestCertificate(t, "localhost", time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
t.Run("trusted certificate succeeds", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeImplicit,
|
||||||
|
tlsConfig: &tls.Config{Certificates: []tls.Certificate{validCertificate}, MinVersion: tls.VersionTLS12},
|
||||||
|
})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: trustedRoots})
|
||||||
|
if err := service.SendRawContext(context.Background(), canonicalTestMessage()); err != nil {
|
||||||
|
t.Fatalf("implicit TLS send: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong hostname fails", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeImplicit,
|
||||||
|
tlsConfig: &tls.Config{Certificates: []tls.Certificate{validCertificate}, MinVersion: tls.VersionTLS12},
|
||||||
|
})
|
||||||
|
service := newTestServiceWithHost(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: trustedRoots}, "127.0.0.1")
|
||||||
|
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("untrusted CA fails", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeImplicit,
|
||||||
|
tlsConfig: &tls.Config{Certificates: []tls.Certificate{validCertificate}, MinVersion: tls.VersionTLS12},
|
||||||
|
})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: x509.NewCertPool()})
|
||||||
|
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired certificate fails", func(t *testing.T) {
|
||||||
|
expiredCertificate, roots := newTestCertificate(t, "localhost", time.Now().Add(-time.Hour))
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeImplicit,
|
||||||
|
tlsConfig: &tls.Config{Certificates: []tls.Certificate{expiredCertificate}, MinVersion: tls.VersionTLS12},
|
||||||
|
})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: roots})
|
||||||
|
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTARTTLSRequiredAndAuthenticatedOnlyAfterTLS(t *testing.T) {
|
||||||
|
certificate, roots := newTestCertificate(t, "localhost", time.Now().Add(time.Hour))
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeSTARTTLS,
|
||||||
|
advertiseSTARTTLS: true,
|
||||||
|
tlsConfig: &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12},
|
||||||
|
})
|
||||||
|
service := newTestService(t, server, Config{
|
||||||
|
TLSMode: TLSModeSTARTTLS,
|
||||||
|
RootCAs: roots,
|
||||||
|
Auth: smtp.PlainAuth("", "mailer", "not-logged", "localhost"),
|
||||||
|
})
|
||||||
|
if err := service.SendRawContext(context.Background(), canonicalTestMessage()); err != nil {
|
||||||
|
t.Fatalf("STARTTLS send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, _, authSeen, authBeforeTLS := server.snapshot()
|
||||||
|
if !authSeen || authBeforeTLS {
|
||||||
|
t.Fatalf("authentication state: seen=%v beforeTLS=%v", authSeen, authBeforeTLS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSTARTTLSMissingOrInvalidCertificateFails(t *testing.T) {
|
||||||
|
certificate, roots := newTestCertificate(t, "localhost", time.Now().Add(time.Hour))
|
||||||
|
t.Run("missing capability", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeSTARTTLS, advertiseSTARTTLS: false})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeSTARTTLS, RootCAs: roots})
|
||||||
|
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
|
||||||
|
})
|
||||||
|
t.Run("invalid certificate", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeSTARTTLS,
|
||||||
|
advertiseSTARTTLS: true,
|
||||||
|
tlsConfig: &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12},
|
||||||
|
})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeSTARTTLS, RootCAs: x509.NewCertPool()})
|
||||||
|
assertPermanentTransportError(t, service.SendRawContext(context.Background(), canonicalTestMessage()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextAndOperationTimeoutsInterruptNetworkIO(t *testing.T) {
|
||||||
|
t.Run("dial context", func(t *testing.T) {
|
||||||
|
service, err := New(baseTestConfig("localhost", "2525", Config{TLSMode: TLSModeNone}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service.dialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
err = service.SendRawContext(ctx, canonicalTestMessage())
|
||||||
|
assertPromptContextError(t, started, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SMTP greeting read stall", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, stallGreeting: true})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeNone, OperationTimeout: 50 * time.Millisecond})
|
||||||
|
started := time.Now()
|
||||||
|
err := service.SendRawContext(context.Background(), canonicalTestMessage())
|
||||||
|
if time.Since(started) > time.Second {
|
||||||
|
t.Fatalf("operation timeout returned too slowly: %s", time.Since(started))
|
||||||
|
}
|
||||||
|
var transportError *Error
|
||||||
|
if !errors.As(err, &transportError) || !transportError.Temporary() {
|
||||||
|
t.Fatalf("error=%v, want transient operation timeout", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("context cancels SMTP read", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, stallGreeting: true})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeNone, OperationTimeout: 5 * time.Second})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
err := service.SendRawContext(ctx, canonicalTestMessage())
|
||||||
|
assertPromptContextError(t, started, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implicit TLS handshake cancellation", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeImplicit, stallTLSHandshake: true})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeImplicit, RootCAs: x509.NewCertPool(), OperationTimeout: 5 * time.Second})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
err := service.SendRawContext(ctx, canonicalTestMessage())
|
||||||
|
assertPromptContextError(t, started, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("STARTTLS handshake cancellation", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{
|
||||||
|
mode: TLSModeSTARTTLS,
|
||||||
|
advertiseSTARTTLS: true,
|
||||||
|
stallSTARTTLS: true,
|
||||||
|
})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeSTARTTLS, RootCAs: x509.NewCertPool(), OperationTimeout: 5 * time.Second})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
err := service.SendRawContext(ctx, canonicalTestMessage())
|
||||||
|
assertPromptContextError(t, started, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DATA acknowledgement stall", func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, stallDATAResponse: true})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeNone, OperationTimeout: 5 * time.Second})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
err := service.SendRawContext(ctx, canonicalTestMessage())
|
||||||
|
assertPromptContextError(t, started, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPResponseClassification(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
temporary bool
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{name: "temporary 4xx", response: "450 mailbox temporarily unavailable\r\n", temporary: true, code: 450},
|
||||||
|
{name: "permanent 5xx", response: "550 mailbox unavailable\r\n", temporary: false, code: 550},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone, rcptResponse: test.response})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeNone})
|
||||||
|
err := service.SendRawContext(context.Background(), canonicalTestMessage())
|
||||||
|
var transportError *Error
|
||||||
|
if !errors.As(err, &transportError) || transportError.Temporary() != test.temporary || transportError.SMTPCode != test.code {
|
||||||
|
t.Fatalf("error=%v classification=%+v", err, transportError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalConfigurationRejectsPlainAuthenticationAndInjection(t *testing.T) {
|
||||||
|
tests := []Config{
|
||||||
|
baseTestConfig("localhost", "2525", Config{TLSMode: TLSModeNone, Auth: smtp.PlainAuth("", "u", "p", "localhost")}),
|
||||||
|
baseTestConfig("bad\r\nhost", "2525", Config{TLSMode: TLSModeImplicit}),
|
||||||
|
baseTestConfig("localhost", "2525", Config{TLSMode: "automatic"}),
|
||||||
|
}
|
||||||
|
for index, config := range tests {
|
||||||
|
if _, err := New(config); err == nil {
|
||||||
|
t.Fatalf("case %d accepted invalid configuration", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawEnvelopeValidationAndCompatibilityFields(t *testing.T) {
|
||||||
|
server := newSMTPTestServer(t, smtpServerOptions{mode: TLSModeNone})
|
||||||
|
service := newTestService(t, server, Config{TLSMode: TLSModeNone})
|
||||||
|
if err := service.SendRaw(RawMessage{To: "recipient@example.com", Body: testRawMessage}); err != nil {
|
||||||
|
t.Fatalf("legacy SendRaw: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidService, err := New(baseTestConfig("localhost", "2525", Config{TLSMode: TLSModeNone}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
invalid := []RawMessage{
|
||||||
|
{EnvelopeRecipients: []string{"recipient@example.com\r\nRCPT TO:<other@example.com>"}, Data: []byte(testRawMessage)},
|
||||||
|
{EnvelopeRecipients: []string{"recipient@example.com"}, Data: []byte(testRawMessage), To: "other@example.com"},
|
||||||
|
{EnvelopeRecipients: []string{"recipient@example.com"}},
|
||||||
|
}
|
||||||
|
for index, message := range invalid {
|
||||||
|
err := invalidService.SendRawContext(context.Background(), message)
|
||||||
|
var transportError *Error
|
||||||
|
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent {
|
||||||
|
t.Fatalf("case %d error=%v, want permanent validation failure", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalTestMessage() RawMessage {
|
||||||
|
return RawMessage{
|
||||||
|
EnvelopeFrom: "sender@example.com",
|
||||||
|
EnvelopeRecipients: []string{"recipient@example.com"},
|
||||||
|
Data: []byte(testRawMessage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestService(t *testing.T, server *smtpTestServer, overrides Config) *EmailService {
|
||||||
|
t.Helper()
|
||||||
|
return newTestServiceWithHost(t, server, overrides, "localhost")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServiceWithHost(t *testing.T, server *smtpTestServer, overrides Config, host string) *EmailService {
|
||||||
|
t.Helper()
|
||||||
|
config := baseTestConfig(host, server.port(), overrides)
|
||||||
|
service, err := New(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseTestConfig(host, port string, overrides Config) Config {
|
||||||
|
config := Config{
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
From: "sender@example.com",
|
||||||
|
TLSMode: TLSModeNone,
|
||||||
|
ConnectTimeout: time.Second,
|
||||||
|
OperationTimeout: time.Second,
|
||||||
|
}
|
||||||
|
if overrides.Auth != nil {
|
||||||
|
config.Auth = overrides.Auth
|
||||||
|
}
|
||||||
|
if overrides.TLSMode != "" {
|
||||||
|
config.TLSMode = overrides.TLSMode
|
||||||
|
}
|
||||||
|
if overrides.ConnectTimeout != 0 {
|
||||||
|
config.ConnectTimeout = overrides.ConnectTimeout
|
||||||
|
}
|
||||||
|
if overrides.OperationTimeout != 0 {
|
||||||
|
config.OperationTimeout = overrides.OperationTimeout
|
||||||
|
}
|
||||||
|
if overrides.RootCAs != nil {
|
||||||
|
config.RootCAs = overrides.RootCAs
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPermanentTransportError(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
var transportError *Error
|
||||||
|
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent || transportError.Temporary() {
|
||||||
|
t.Fatalf("error=%v classification=%+v, want permanent", err, transportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPromptContextError(t *testing.T, started time.Time, err error, target error) {
|
||||||
|
t.Helper()
|
||||||
|
if !errors.Is(err, target) {
|
||||||
|
t.Fatalf("error=%v, want errors.Is(%v)", err, target)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(started); elapsed > time.Second {
|
||||||
|
t.Fatalf("context cancellation took %s", elapsed)
|
||||||
|
}
|
||||||
|
var transportError *Error
|
||||||
|
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindCanceled {
|
||||||
|
t.Fatalf("error=%v classification=%+v, want canceled", err, transportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestCertificate(t *testing.T, host string, notAfter time.Time) (tls.Certificate, *x509.CertPool) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
caTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "Phase 12 Test CA"},
|
||||||
|
NotBefore: now.Add(-24 * time.Hour),
|
||||||
|
NotAfter: now.Add(24 * time.Hour),
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
}
|
||||||
|
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
caCertificate, err := x509.ParseCertificate(caDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
leafTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(2),
|
||||||
|
Subject: pkix.Name{CommonName: host},
|
||||||
|
NotBefore: now.Add(-2 * time.Hour),
|
||||||
|
NotAfter: notAfter,
|
||||||
|
DNSNames: []string{host},
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCertificate, &serverKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
|
||||||
|
certificatePEM = append(certificatePEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(serverKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
certificate, err := tls.X509KeyPair(certificatePEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(caCertificate)
|
||||||
|
return certificate, roots
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleEmailService_SendRawContext() {
|
||||||
|
service, _ := New(Config{
|
||||||
|
Host: "smtp.example.com",
|
||||||
|
Port: "465",
|
||||||
|
From: "sender@example.com",
|
||||||
|
TLSMode: TLSModeImplicit,
|
||||||
|
ConnectTimeout: 10 * time.Second,
|
||||||
|
OperationTimeout: 30 * time.Second,
|
||||||
|
})
|
||||||
|
_ = service
|
||||||
|
fmt.Println("configured verified implicit TLS")
|
||||||
|
// Output: configured verified implicit TLS
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user