mirror of
https://github.com/wiggin77/mailrelay.git
synced 2025-08-12 16:37:06 +02:00
77
Makefile
Normal file
77
Makefile
Normal file
@ -0,0 +1,77 @@
|
||||
# Makefile for mailrelay
|
||||
|
||||
# Binary name
|
||||
BINARY_NAME=mailrelay
|
||||
|
||||
# Build directories
|
||||
BUILD_DIR=build
|
||||
LINUX_DIR=$(BUILD_DIR)/linux_amd64
|
||||
WINDOWS_DIR=$(BUILD_DIR)/windows_amd64
|
||||
OSX_DIR=$(BUILD_DIR)/osx_amd64
|
||||
OPENBSD_DIR=$(BUILD_DIR)/openbsd_amd64
|
||||
ARM_DIR=$(BUILD_DIR)/linux_arm64
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
all: build
|
||||
|
||||
# Build for current architecture
|
||||
.PHONY: build
|
||||
build:
|
||||
go build -o $(BINARY_NAME)
|
||||
|
||||
# Run tests
|
||||
.PHONY: test
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Build for all supported architectures
|
||||
.PHONY: buildall
|
||||
buildall: clean
|
||||
@echo "Building for all architectures..."
|
||||
@mkdir -p $(LINUX_DIR) $(WINDOWS_DIR) $(OSX_DIR) $(OPENBSD_DIR) $(ARM_DIR)
|
||||
@echo "Building Linux AMD64..."
|
||||
env GOOS=linux GOARCH=amd64 go build -o $(LINUX_DIR)/$(BINARY_NAME)-linux-amd64
|
||||
@echo "Building Windows AMD64..."
|
||||
env GOOS=windows GOARCH=amd64 go build -o $(WINDOWS_DIR)/$(BINARY_NAME)-windows-amd64.exe
|
||||
@echo "Building macOS AMD64..."
|
||||
env GOOS=darwin GOARCH=amd64 go build -o $(OSX_DIR)/$(BINARY_NAME)-osx-amd64
|
||||
@echo "Building OpenBSD AMD64..."
|
||||
env GOOS=openbsd GOARCH=amd64 go build -o $(OPENBSD_DIR)/$(BINARY_NAME)-openbsd-amd64
|
||||
@echo "Building Linux ARM64..."
|
||||
env GOOS=linux GOARCH=arm64 go build -o $(ARM_DIR)/$(BINARY_NAME)-linux-arm64
|
||||
@echo "Build complete!"
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
rm -f $(BINARY_NAME)
|
||||
|
||||
# Run the application
|
||||
.PHONY: run
|
||||
run: build
|
||||
./$(BINARY_NAME)
|
||||
|
||||
# Run with test configuration
|
||||
.PHONY: test-config
|
||||
test-config: build
|
||||
./$(BINARY_NAME) -config=./mailrelay.json -test -sender=test@example.com -rcpt=recipient@example.com
|
||||
|
||||
# Check IP address
|
||||
.PHONY: check-ip
|
||||
check-ip: build
|
||||
./$(BINARY_NAME) -config=./mailrelay.json -checkIP -ip=$(IP)
|
||||
|
||||
# Show help
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build for current architecture"
|
||||
@echo " buildall - Build for all supported architectures"
|
||||
@echo " test - Run tests"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " run - Build and run the application"
|
||||
@echo " test-config - Test configuration with sample email"
|
||||
@echo " check-ip - Check if IP is allowed (use IP=x.x.x.x)"
|
||||
@echo " help - Show this help message"
|
104
auth_test.go
Normal file
104
auth_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/smtp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoginAuth(t *testing.T) {
|
||||
auth := LoginAuth("testuser", "testpass")
|
||||
assert.NotNil(t, auth)
|
||||
|
||||
// Type assertion to ensure we get the right type
|
||||
loginAuth, ok := auth.(*loginAuth)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "testuser", loginAuth.username)
|
||||
assert.Equal(t, "testpass", loginAuth.password)
|
||||
}
|
||||
|
||||
func TestLoginAuthStart(t *testing.T) {
|
||||
auth := &loginAuth{
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
}
|
||||
|
||||
method, resp, err := auth.Start(&smtp.ServerInfo{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LOGIN", method)
|
||||
assert.Empty(t, resp)
|
||||
}
|
||||
|
||||
func TestLoginAuthNext(t *testing.T) {
|
||||
auth := &loginAuth{
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
serverMsg string
|
||||
more bool
|
||||
expected string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "username prompt - User Name",
|
||||
serverMsg: "User Name",
|
||||
more: true,
|
||||
expected: "testuser",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "username prompt - Username:",
|
||||
serverMsg: "Username:",
|
||||
more: true,
|
||||
expected: "testuser",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "password prompt - Password",
|
||||
serverMsg: "Password",
|
||||
more: true,
|
||||
expected: "testpass",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "password prompt - Password:",
|
||||
serverMsg: "Password:",
|
||||
more: true,
|
||||
expected: "testpass",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "unknown server response",
|
||||
serverMsg: "Unknown Prompt",
|
||||
more: true,
|
||||
expected: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "more is false",
|
||||
serverMsg: "anything",
|
||||
more: false,
|
||||
expected: "",
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := auth.Next([]byte(tt.serverMsg), tt.more)
|
||||
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown server response")
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, string(resp))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
21
build.sh
21
build.sh
@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# build Linux
|
||||
echo building Linux...
|
||||
env GOOS=linux GOARCH=amd64 go build -o ./build/linux_amd64/mailrelay-linux-amd64
|
||||
|
||||
# build Windows
|
||||
echo building Windows...
|
||||
env GOOS=windows GOARCH=amd64 go build -o ./build/windows_amd64/mailrelay-windows-amd64.exe
|
||||
|
||||
# build OSX
|
||||
echo building OSX...
|
||||
env GOOS=darwin GOARCH=amd64 go build -o ./build/osx_amd64/mailrelay-osx-amd64
|
||||
|
||||
# build OpenBSD
|
||||
echo building OpenBSD...
|
||||
env GOOS=openbsd GOARCH=amd64 go build -o ./build/openbsd_amd64/mailrelay-openbsd-amd64
|
||||
|
||||
# build Linux ARM64 (Raspberry PI)
|
||||
echo "building Linux ARM64 (Raspberry PI)..."
|
||||
env GOOS=linux GOARCH=arm go build -o ./build/linux_amd64/mailrelay-linux-arm64
|
@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
|
||||
"github.com/flashmob/go-guerrilla/mail"
|
||||
"github.com/pkg/errors"
|
||||
@ -19,7 +20,7 @@ type closeable interface {
|
||||
|
||||
// sendMail sends the contents of the envelope to a SMTP server.
|
||||
func sendMail(e *mail.Envelope, config *relayConfig) error {
|
||||
server := fmt.Sprintf("%s:%d", config.Server, config.Port)
|
||||
server := net.JoinHostPort(config.Server, strconv.Itoa(config.Port))
|
||||
to := getTo(e)
|
||||
|
||||
var msg bytes.Buffer
|
||||
@ -36,7 +37,7 @@ func sendMail(e *mail.Envelope, config *relayConfig) error {
|
||||
|
||||
if AllowedSendersFilter.Blocked(e.RemoteIP) {
|
||||
Logger.Info("Remote IP of " + e.RemoteIP + " not allowed to send email.")
|
||||
return errors.Wrap(err, "Remote IP of "+e.RemoteIP+" not allowed to send email.")
|
||||
return errors.New("Remote IP of " + e.RemoteIP + " not allowed to send email.")
|
||||
}
|
||||
|
||||
tlsconfig := &tls.Config{
|
||||
|
105
client_test.go
Normal file
105
client_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/textproto"
|
||||
"testing"
|
||||
|
||||
"github.com/flashmob/go-guerrilla/mail"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetTo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envelope *mail.Envelope
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "single recipient",
|
||||
envelope: &mail.Envelope{
|
||||
RcptTo: []mail.Address{
|
||||
{User: "user1", Host: "example.com"},
|
||||
},
|
||||
},
|
||||
expected: []string{"user1@example.com"},
|
||||
},
|
||||
{
|
||||
name: "multiple recipients",
|
||||
envelope: &mail.Envelope{
|
||||
RcptTo: []mail.Address{
|
||||
{User: "user1", Host: "example.com"},
|
||||
{User: "user2", Host: "test.com"},
|
||||
{User: "admin", Host: "company.org"},
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"user1@example.com",
|
||||
"user2@test.com",
|
||||
"admin@company.org",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no recipients",
|
||||
envelope: &mail.Envelope{RcptTo: []mail.Address{}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "nil envelope recipients",
|
||||
envelope: &mail.Envelope{RcptTo: nil},
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getTo(tt.envelope)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuitError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SMTP 221 code (acceptable)",
|
||||
err: &textproto.Error{Code: 221, Msg: "Bye"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SMTP 250 code (acceptable)",
|
||||
err: &textproto.Error{Code: 250, Msg: "OK"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SMTP 550 error code",
|
||||
err: &textproto.Error{Code: 550, Msg: "Mailbox not found"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "SMTP 421 error code",
|
||||
err: &textproto.Error{Code: 421, Msg: "Service not available"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-textproto error",
|
||||
err: assert.AnError,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isQuitError(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
97
config_test.go
Normal file
97
config_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
var cfg mailRelayConfig
|
||||
configDefaults(&cfg)
|
||||
|
||||
assert.Equal(t, DefaultSTMPPort, cfg.SMTPPort)
|
||||
assert.Equal(t, false, cfg.SMTPStartTLS)
|
||||
assert.Equal(t, false, cfg.SMTPLoginAuthType)
|
||||
assert.Equal(t, int64(DefaultMaxEmailSize), cfg.MaxEmailSize)
|
||||
assert.Equal(t, false, cfg.SkipCertVerify)
|
||||
assert.Equal(t, DefaultLocalListenIP, cfg.LocalListenIP)
|
||||
assert.Equal(t, DefaultLocalListenPort, cfg.LocalListenPort)
|
||||
assert.Equal(t, []string{"*"}, cfg.AllowedHosts)
|
||||
assert.Equal(t, "*", cfg.AllowedSenders)
|
||||
assert.Equal(t, DefaultTimeoutSecs, cfg.TimeoutSecs)
|
||||
}
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
wantErr bool
|
||||
validate func(t *testing.T, cfg *mailRelayConfig)
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
filename: "testdata/valid.json",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cfg *mailRelayConfig) {
|
||||
assert.Equal(t, "smtp.test.com", cfg.SMTPServer)
|
||||
assert.Equal(t, 587, cfg.SMTPPort)
|
||||
assert.Equal(t, true, cfg.SMTPStartTLS)
|
||||
assert.Equal(t, "testuser@test.com", cfg.SMTPUsername)
|
||||
assert.Equal(t, "testpassword", cfg.SMTPPassword)
|
||||
assert.Equal(t, "relay.test.com", cfg.SMTPHelo)
|
||||
assert.Equal(t, "127.0.0.1", cfg.LocalListenIP)
|
||||
assert.Equal(t, 2525, cfg.LocalListenPort)
|
||||
assert.Equal(t, []string{"test.com", "example.com"}, cfg.AllowedHosts)
|
||||
assert.Equal(t, 60, cfg.TimeoutSecs)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal config with defaults",
|
||||
filename: "testdata/minimal.json",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, cfg *mailRelayConfig) {
|
||||
assert.Equal(t, "smtp.minimal.com", cfg.SMTPServer)
|
||||
assert.Equal(t, "user@minimal.com", cfg.SMTPUsername)
|
||||
assert.Equal(t, "password", cfg.SMTPPassword)
|
||||
// Check that defaults are applied
|
||||
assert.Equal(t, DefaultSTMPPort, cfg.SMTPPort)
|
||||
assert.Equal(t, DefaultLocalListenIP, cfg.LocalListenIP)
|
||||
assert.Equal(t, DefaultLocalListenPort, cfg.LocalListenPort)
|
||||
assert.Equal(t, []string{"*"}, cfg.AllowedHosts)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
filename: "testdata/invalid.json",
|
||||
wantErr: true,
|
||||
validate: nil,
|
||||
},
|
||||
{
|
||||
name: "nonexistent file",
|
||||
filename: "testdata/nonexistent.json",
|
||||
wantErr: true,
|
||||
validate: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg, err := loadConfig(tt.filename)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cfg)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
4
go.mod
4
go.mod
@ -6,15 +6,17 @@ require (
|
||||
github.com/flashmob/go-guerrilla v1.6.1
|
||||
github.com/jpillora/ipfilter v1.2.2
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/stretchr/testify v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/EventBus v0.0.0-20180103000110-68a521d7cbbb // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
|
||||
github.com/phuslu/iploc v1.0.20200807 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sirupsen/logrus v1.6.0 // indirect
|
||||
github.com/stretchr/testify v1.5.1 // indirect
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
|
||||
golang.org/x/sys v0.1.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||
|
1
go.sum
1
go.sum
@ -29,6 +29,7 @@ github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoi
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
|
370
integration_test.go
Normal file
370
integration_test.go
Normal file
@ -0,0 +1,370 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/flashmob/go-guerrilla/log"
|
||||
"github.com/flashmob/go-guerrilla/mail"
|
||||
"github.com/jpillora/ipfilter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupTestLogger initializes the logger for testing
|
||||
func setupTestLogger(t *testing.T) {
|
||||
var err error
|
||||
Logger, err = log.GetLogger("stdout", "info")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSendMail_Success(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
|
||||
// Start mock SMTP server
|
||||
server := NewMockSMTPServer(t)
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure for testing
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient1", Host: "example.com"},
|
||||
{User: "recipient2", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Test\r\n\r\nThis is a test email."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Set up IP filter to allow this IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
AllowedIPs: []string{"127.0.0.1"},
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the mock server received the email
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
assert.Equal(t, []string{"recipient1@example.com", "recipient2@example.com"}, conn.To)
|
||||
assert.Contains(t, conn.Data, "Subject: Test")
|
||||
assert.Contains(t, conn.Data, "This is a test email.")
|
||||
}
|
||||
|
||||
func TestSendMail_WithAuthentication(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server with auth requirement
|
||||
server := NewMockSMTPServer(t)
|
||||
server.RequireAuth = true
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure with authentication
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
SkipVerify: true,
|
||||
HeloHost: "relay.test.com",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Auth Test\r\n\r\nAuthenticated email."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify authentication was used
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.NotEmpty(t, conn.AuthUser)
|
||||
assert.NotEmpty(t, conn.AuthPass)
|
||||
}
|
||||
|
||||
func TestSendMail_WithLoginAuth(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server with LOGIN auth support
|
||||
server := NewMockSMTPServer(t)
|
||||
server.RequireAuth = true
|
||||
server.SupportLoginAuth = true
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure with LOGIN authentication
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: true,
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: LOGIN Auth Test\r\n\r\nLOGIN authenticated email."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify LOGIN authentication was used
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.NotEmpty(t, conn.AuthUser)
|
||||
assert.NotEmpty(t, conn.AuthPass)
|
||||
}
|
||||
|
||||
func TestSendMail_IPFiltering_Blocked(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server
|
||||
server := NewMockSMTPServer(t)
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure server
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope with blocked IP
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Test\r\n\r\nThis should be blocked."),
|
||||
RemoteIP: "192.168.1.100", // This IP will be blocked
|
||||
}
|
||||
|
||||
// Set up IP filter to block this IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
AllowedIPs: []string{"127.0.0.1"},
|
||||
BlockByDefault: true,
|
||||
})
|
||||
|
||||
// Send email - should fail due to IP filtering
|
||||
err := sendMail(envelope, config)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "192.168.1.100")
|
||||
assert.Contains(t, err.Error(), "not allowed to send email")
|
||||
|
||||
// Verify no email was sent to the server
|
||||
conn := server.GetLastConnection()
|
||||
// The connection might be nil or have no data since the IP was blocked before SMTP
|
||||
if conn != nil {
|
||||
assert.Empty(t, conn.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMail_IPFiltering_Allowed(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server
|
||||
server := NewMockSMTPServer(t)
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure server
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope with allowed IP
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Test\r\n\r\nThis should be allowed."),
|
||||
RemoteIP: "192.168.1.100",
|
||||
}
|
||||
|
||||
// Set up IP filter to allow this specific IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
AllowedIPs: []string{"192.168.1.0/24"},
|
||||
BlockByDefault: true,
|
||||
})
|
||||
|
||||
// Send email - should succeed
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify email was sent to the server
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
assert.Equal(t, []string{"recipient@example.com"}, conn.To)
|
||||
}
|
||||
|
||||
func TestSendMail_ServerErrors(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
failCommand string
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
name: "MAIL command fails",
|
||||
failCommand: "MAIL",
|
||||
expectError: "mail error",
|
||||
},
|
||||
{
|
||||
name: "RCPT command fails",
|
||||
failCommand: "RCPT",
|
||||
expectError: "rcpt error",
|
||||
},
|
||||
{
|
||||
name: "DATA command fails",
|
||||
failCommand: "DATA",
|
||||
expectError: "data error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Start mock SMTP server
|
||||
server := NewMockSMTPServer(t)
|
||||
server.FailCommands[tt.failCommand] = true
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure server
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Test\r\n\r\nThis should fail."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email - should fail
|
||||
err := sendMail(envelope, config)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMail_ConnectionTimeout(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server with delay
|
||||
server := NewMockSMTPServer(t)
|
||||
server.ResponseDelay = 100 * time.Millisecond
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure server
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Timeout Test\r\n\r\nThis tests server delays."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email - should still succeed despite delay
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify email was eventually sent
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
}
|
448
mock_smtp_server.go
Normal file
448
mock_smtp_server.go
Normal file
@ -0,0 +1,448 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockSMTPServer represents a mock SMTP server for testing
|
||||
type MockSMTPServer struct {
|
||||
listener net.Listener
|
||||
tlsConfig *tls.Config
|
||||
address string
|
||||
port int
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
|
||||
// Recorded interactions
|
||||
Connections []MockConnection
|
||||
|
||||
// Configuration
|
||||
RequireAuth bool
|
||||
RequireSTARTTLS bool
|
||||
SupportLoginAuth bool
|
||||
ResponseDelay time.Duration
|
||||
FailCommands map[string]bool // Commands to fail
|
||||
CustomResponses map[string]string
|
||||
ImplicitTLS bool // True if server uses implicit TLS (like port 465)
|
||||
}
|
||||
|
||||
type MockConnection struct {
|
||||
Commands []string
|
||||
From string
|
||||
To []string
|
||||
Data string
|
||||
AuthUser string
|
||||
AuthPass string
|
||||
UsedTLS bool
|
||||
}
|
||||
|
||||
// NewMockSMTPServer creates a new mock SMTP server
|
||||
func NewMockSMTPServer(t *testing.T) *MockSMTPServer {
|
||||
cert, err := generateTestCert()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate test certificate: %v", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
|
||||
return &MockSMTPServer{
|
||||
tlsConfig: tlsConfig,
|
||||
Connections: make([]MockConnection, 0),
|
||||
FailCommands: make(map[string]bool),
|
||||
CustomResponses: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the mock SMTP server
|
||||
func (s *MockSMTPServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.listener = listener
|
||||
s.address = listener.Addr().(*net.TCPAddr).IP.String()
|
||||
s.port = listener.Addr().(*net.TCPAddr).Port
|
||||
s.running = true
|
||||
|
||||
go s.acceptConnections()
|
||||
|
||||
// Give the server a moment to start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartTLS starts the mock SMTP server with implicit TLS
|
||||
func (s *MockSMTPServer) StartTLS() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tlsListener := tls.NewListener(listener, s.tlsConfig)
|
||||
s.listener = tlsListener
|
||||
s.address = listener.Addr().(*net.TCPAddr).IP.String()
|
||||
s.port = listener.Addr().(*net.TCPAddr).Port
|
||||
s.running = true
|
||||
s.ImplicitTLS = true
|
||||
|
||||
go s.acceptConnections()
|
||||
|
||||
// Give the server a moment to start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the mock SMTP server
|
||||
func (s *MockSMTPServer) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.listener != nil {
|
||||
s.listener.Close()
|
||||
s.running = false
|
||||
}
|
||||
}
|
||||
|
||||
// Address returns the server address
|
||||
func (s *MockSMTPServer) Address() string {
|
||||
return s.address
|
||||
}
|
||||
|
||||
// Port returns the server port
|
||||
func (s *MockSMTPServer) Port() int {
|
||||
return s.port
|
||||
}
|
||||
|
||||
// GetLastConnection returns the most recent connection
|
||||
func (s *MockSMTPServer) GetLastConnection() *MockConnection {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(s.Connections) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.Connections[len(s.Connections)-1]
|
||||
}
|
||||
|
||||
// Reset clears all recorded connections
|
||||
func (s *MockSMTPServer) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.Connections = make([]MockConnection, 0)
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) acceptConnections() {
|
||||
for s.running {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
if s.running {
|
||||
fmt.Printf("Accept error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
go s.handleConnection(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
if s.ResponseDelay > 0 {
|
||||
time.Sleep(s.ResponseDelay)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
|
||||
mockConn := MockConnection{
|
||||
Commands: make([]string, 0),
|
||||
To: make([]string, 0),
|
||||
}
|
||||
|
||||
// Check if this is a TLS connection (implicit TLS or post-STARTTLS)
|
||||
if _, ok := conn.(*tls.Conn); ok || s.ImplicitTLS {
|
||||
mockConn.UsedTLS = true
|
||||
}
|
||||
|
||||
// Send greeting
|
||||
writer.WriteString("220 mock.smtp.server ESMTP ready\r\n")
|
||||
writer.Flush()
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
mockConn.Commands = append(mockConn.Commands, line)
|
||||
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
|
||||
// Check if we should fail this command
|
||||
if s.FailCommands[cmd] {
|
||||
writer.WriteString("550 Command failed\r\n")
|
||||
writer.Flush()
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for custom responses
|
||||
if response, exists := s.CustomResponses[cmd]; exists {
|
||||
writer.WriteString(response + "\r\n")
|
||||
writer.Flush()
|
||||
continue
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "EHLO", "HELO":
|
||||
s.handleEHLO(writer)
|
||||
case "STARTTLS":
|
||||
tlsConn, newReader, newWriter, upgraded := s.handleSTARTTLS(conn, reader, writer, &mockConn)
|
||||
if upgraded {
|
||||
// Connection was upgraded to TLS, switch to new connection
|
||||
conn = tlsConn
|
||||
reader = newReader
|
||||
writer = newWriter
|
||||
}
|
||||
case "AUTH":
|
||||
s.handleAUTH(parts, reader, writer, &mockConn)
|
||||
case "MAIL":
|
||||
s.handleMAIL(parts, writer, &mockConn)
|
||||
case "RCPT":
|
||||
s.handleRCPT(parts, writer, &mockConn)
|
||||
case "DATA":
|
||||
s.handleDATA(reader, writer, &mockConn)
|
||||
case "QUIT":
|
||||
writer.WriteString("221 Bye\r\n")
|
||||
writer.Flush()
|
||||
s.mu.Lock()
|
||||
s.Connections = append(s.Connections, mockConn)
|
||||
s.mu.Unlock()
|
||||
return
|
||||
default:
|
||||
writer.WriteString("500 Command not recognized\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.Connections = append(s.Connections, mockConn)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) handleEHLO(writer *bufio.Writer) {
|
||||
writer.WriteString("250-mock.smtp.server\r\n")
|
||||
if s.RequireSTARTTLS {
|
||||
writer.WriteString("250-STARTTLS\r\n")
|
||||
}
|
||||
if s.RequireAuth {
|
||||
if s.SupportLoginAuth {
|
||||
writer.WriteString("250-AUTH PLAIN LOGIN\r\n")
|
||||
} else {
|
||||
writer.WriteString("250-AUTH PLAIN\r\n")
|
||||
}
|
||||
}
|
||||
writer.WriteString("250 SIZE 10240000\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) handleSTARTTLS(conn net.Conn, reader *bufio.Reader, writer *bufio.Writer, mockConn *MockConnection) (*tls.Conn, *bufio.Reader, *bufio.Writer, bool) {
|
||||
writer.WriteString("220 Ready to start TLS\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Upgrade the connection to TLS
|
||||
tlsConn := tls.Server(conn, s.tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
// TLS handshake failed, return original connection
|
||||
return nil, reader, writer, false
|
||||
}
|
||||
|
||||
mockConn.UsedTLS = true
|
||||
|
||||
// Return new TLS connection and readers/writers
|
||||
newReader := bufio.NewReader(tlsConn)
|
||||
newWriter := bufio.NewWriter(tlsConn)
|
||||
|
||||
return tlsConn, newReader, newWriter, true
|
||||
}
|
||||
|
||||
|
||||
func (s *MockSMTPServer) handleAUTH(parts []string, reader *bufio.Reader, writer *bufio.Writer, mockConn *MockConnection) {
|
||||
if len(parts) < 2 {
|
||||
writer.WriteString("501 Syntax error\r\n")
|
||||
writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
authType := strings.ToUpper(parts[1])
|
||||
|
||||
switch authType {
|
||||
case "PLAIN":
|
||||
// PLAIN auth can be sent in initial command or as a response to challenge
|
||||
if len(parts) > 2 {
|
||||
// Credentials provided in initial command
|
||||
// authData := parts[2] // In a real implementation, we'd decode base64 and parse username/password
|
||||
mockConn.AuthUser = "testuser"
|
||||
mockConn.AuthPass = "testpass"
|
||||
|
||||
writer.WriteString("235 Authentication successful\r\n")
|
||||
writer.Flush()
|
||||
} else {
|
||||
// Challenge/response mode
|
||||
writer.WriteString("334 \r\n")
|
||||
writer.Flush()
|
||||
|
||||
authData, _ := reader.ReadString('\n')
|
||||
authData = strings.TrimSpace(authData)
|
||||
// In a real implementation, we'd decode base64 and parse username/password
|
||||
mockConn.AuthUser = "testuser"
|
||||
mockConn.AuthPass = "testpass"
|
||||
|
||||
writer.WriteString("235 Authentication successful\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
case "LOGIN":
|
||||
writer.WriteString("334 VXNlcm5hbWU6\r\n") // "Username:" in base64
|
||||
writer.Flush()
|
||||
|
||||
username, _ := reader.ReadString('\n')
|
||||
mockConn.AuthUser = strings.TrimSpace(username)
|
||||
|
||||
writer.WriteString("334 UGFzc3dvcmQ6\r\n") // "Password:" in base64
|
||||
writer.Flush()
|
||||
|
||||
password, _ := reader.ReadString('\n')
|
||||
mockConn.AuthPass = strings.TrimSpace(password)
|
||||
|
||||
writer.WriteString("235 Authentication successful\r\n")
|
||||
writer.Flush()
|
||||
|
||||
default:
|
||||
writer.WriteString("504 Authentication mechanism not supported\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) handleMAIL(parts []string, writer *bufio.Writer, mockConn *MockConnection) {
|
||||
if len(parts) < 2 {
|
||||
writer.WriteString("501 Syntax error\r\n")
|
||||
writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
fromAddr := strings.TrimPrefix(parts[1], "FROM:")
|
||||
fromAddr = strings.Trim(fromAddr, "<>")
|
||||
mockConn.From = fromAddr
|
||||
|
||||
writer.WriteString("250 OK\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) handleRCPT(parts []string, writer *bufio.Writer, mockConn *MockConnection) {
|
||||
if len(parts) < 2 {
|
||||
writer.WriteString("501 Syntax error\r\n")
|
||||
writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
toAddr := strings.TrimPrefix(parts[1], "TO:")
|
||||
toAddr = strings.Trim(toAddr, "<>")
|
||||
mockConn.To = append(mockConn.To, toAddr)
|
||||
|
||||
writer.WriteString("250 OK\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func (s *MockSMTPServer) handleDATA(reader *bufio.Reader, writer *bufio.Writer, mockConn *MockConnection) {
|
||||
writer.WriteString("354 Start mail input; end with <CRLF>.<CRLF>\r\n")
|
||||
writer.Flush()
|
||||
|
||||
var dataBuilder strings.Builder
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if strings.TrimSpace(line) == "." {
|
||||
break
|
||||
}
|
||||
|
||||
dataBuilder.WriteString(line)
|
||||
}
|
||||
|
||||
mockConn.Data = dataBuilder.String()
|
||||
|
||||
writer.WriteString("250 OK: message accepted\r\n")
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
// generateTestCert creates a self-signed certificate for testing
|
||||
func generateTestCert() (tls.Certificate, error) {
|
||||
// Generate a private key
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
// Create certificate template
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test"},
|
||||
Country: []string{"US"},
|
||||
Province: []string{""},
|
||||
Locality: []string{"Test City"},
|
||||
StreetAddress: []string{""},
|
||||
PostalCode: []string{""},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
|
||||
// Generate the certificate
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
// Create the tls.Certificate
|
||||
return tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: priv,
|
||||
}, nil
|
||||
}
|
3
testdata/allowed_ips.txt
vendored
Normal file
3
testdata/allowed_ips.txt
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
192.168.1.0/24
|
||||
10.0.0.0/8
|
||||
127.0.0.1
|
5
testdata/invalid.json
vendored
Normal file
5
testdata/invalid.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"smtp_server": "smtp.test.com",
|
||||
"smtp_port": "invalid_port",
|
||||
"missing_quote: "value"
|
||||
}
|
5
testdata/minimal.json
vendored
Normal file
5
testdata/minimal.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"smtp_server": "smtp.minimal.com",
|
||||
"smtp_username": "user@minimal.com",
|
||||
"smtp_password": "password"
|
||||
}
|
16
testdata/valid.json
vendored
Normal file
16
testdata/valid.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"smtp_server": "smtp.test.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_starttls": true,
|
||||
"smtp_login_auth_type": false,
|
||||
"smtp_username": "testuser@test.com",
|
||||
"smtp_password": "testpassword",
|
||||
"smtp_helo": "relay.test.com",
|
||||
"smtp_skip_cert_verify": false,
|
||||
"smtp_max_email_size": 10485760,
|
||||
"local_listen_ip": "127.0.0.1",
|
||||
"local_listen_port": 2525,
|
||||
"allowed_hosts": ["test.com", "example.com"],
|
||||
"allowed_senders": "*",
|
||||
"timeout_secs": 60
|
||||
}
|
252
tls_test.go
Normal file
252
tls_test.go
Normal file
@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/flashmob/go-guerrilla/mail"
|
||||
"github.com/jpillora/ipfilter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSendMail_STARTTLS(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server with STARTTLS requirement
|
||||
server := NewMockSMTPServer(t)
|
||||
server.RequireSTARTTLS = true
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure for STARTTLS
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: STARTTLS Test\r\n\r\nThis tests STARTTLS."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the connection was established
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
assert.Equal(t, []string{"recipient@example.com"}, conn.To)
|
||||
|
||||
// Verify STARTTLS was used (check commands include STARTTLS)
|
||||
starttlsFound := false
|
||||
for _, cmd := range conn.Commands {
|
||||
if cmd == "STARTTLS" {
|
||||
starttlsFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, starttlsFound, "STARTTLS command should have been sent")
|
||||
assert.True(t, conn.UsedTLS, "Connection should be marked as using TLS")
|
||||
}
|
||||
|
||||
func TestSendMail_ImplicitTLS(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server with implicit TLS
|
||||
server := NewMockSMTPServer(t)
|
||||
require.NoError(t, server.StartTLS())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure for implicit TLS (no STARTTLS)
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: false,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: true,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: Implicit TLS Test\r\n\r\nThis tests implicit TLS."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the connection was established
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
assert.Equal(t, []string{"recipient@example.com"}, conn.To)
|
||||
|
||||
// Verify no STARTTLS command was sent (since we're using implicit TLS)
|
||||
starttlsFound := false
|
||||
for _, cmd := range conn.Commands {
|
||||
if cmd == "STARTTLS" {
|
||||
starttlsFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, starttlsFound, "STARTTLS command should not have been sent for implicit TLS")
|
||||
}
|
||||
|
||||
func TestSendMail_TLSWithAuthentication(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Start mock SMTP server with both TLS and authentication
|
||||
server := NewMockSMTPServer(t)
|
||||
server.RequireSTARTTLS = true
|
||||
server.RequireAuth = true
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Stop()
|
||||
|
||||
// Configure for STARTTLS with authentication
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: true,
|
||||
LoginAuthType: false,
|
||||
Username: "tlsuser",
|
||||
Password: "tlspass",
|
||||
SkipVerify: true,
|
||||
HeloHost: "secure.relay.com",
|
||||
}
|
||||
|
||||
// Create test envelope
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: TLS + Auth Test\r\n\r\nThis tests TLS with authentication."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the connection was established with both TLS and auth
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
assert.Equal(t, []string{"recipient@example.com"}, conn.To)
|
||||
assert.True(t, conn.UsedTLS, "Connection should use TLS")
|
||||
assert.NotEmpty(t, conn.AuthUser, "Authentication should have been used")
|
||||
assert.NotEmpty(t, conn.AuthPass, "Authentication should have been used")
|
||||
|
||||
// Verify command sequence (STARTTLS should come before AUTH)
|
||||
starttlsIndex := -1
|
||||
authIndex := -1
|
||||
for i, cmd := range conn.Commands {
|
||||
if cmd == "STARTTLS" {
|
||||
starttlsIndex = i
|
||||
}
|
||||
if len(cmd) >= 4 && cmd[:4] == "AUTH" {
|
||||
authIndex = i
|
||||
}
|
||||
}
|
||||
assert.True(t, starttlsIndex >= 0, "STARTTLS command should be present")
|
||||
assert.True(t, authIndex >= 0, "AUTH command should be present")
|
||||
assert.True(t, starttlsIndex < authIndex, "STARTTLS should come before AUTH")
|
||||
}
|
||||
|
||||
func TestSendMail_TLSSkipVerify(t *testing.T) {
|
||||
setupTestLogger(t)
|
||||
// Test that we can handle certificate verification settings
|
||||
server := NewMockSMTPServer(t)
|
||||
require.NoError(t, server.StartTLS()) // Use implicit TLS
|
||||
defer server.Stop()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
skipVerify bool
|
||||
}{
|
||||
{"skip certificate verification", true},
|
||||
{"enforce certificate verification", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server.Reset()
|
||||
|
||||
config := &relayConfig{
|
||||
Server: server.Address(),
|
||||
Port: server.Port(),
|
||||
STARTTLS: false,
|
||||
LoginAuthType: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
SkipVerify: tt.skipVerify,
|
||||
HeloHost: "",
|
||||
}
|
||||
|
||||
envelope := &mail.Envelope{
|
||||
MailFrom: mail.Address{User: "sender", Host: "test.com"},
|
||||
RcptTo: []mail.Address{
|
||||
{User: "recipient", Host: "example.com"},
|
||||
},
|
||||
Data: *bytes.NewBufferString("Subject: TLS Verify Test\r\n\r\nTesting certificate verification."),
|
||||
RemoteIP: "127.0.0.1",
|
||||
}
|
||||
|
||||
// Allow IP
|
||||
AllowedSendersFilter = ipfilter.New(ipfilter.Options{
|
||||
BlockByDefault: false,
|
||||
})
|
||||
|
||||
// Send email
|
||||
err := sendMail(envelope, config)
|
||||
|
||||
if tt.skipVerify {
|
||||
// Should succeed when skipping verification
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify email was sent
|
||||
conn := server.GetLastConnection()
|
||||
require.NotNil(t, conn)
|
||||
assert.Equal(t, "sender@test.com", conn.From)
|
||||
} else {
|
||||
// Should fail when enforcing verification with self-signed cert
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "certificate")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user