Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ SMTP_HEALTH_PORT=
SMTP_HEALTH_DISABLE=false
SMTP_QUEUE_PATH=./data/spool
SMTP_QUEUE_WORKERS=
SMTP_RETENTION_DAYS=7

# Authentication
SMTP_AUTH_USERS=
SMTP_AUTH_INSECURE=false

# Access control
SMTP_ALLOW_NETWORKS=127.0.0.1/32
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
- Config: Document and surface the new `SMTP_QUEUE_WORKERS` environment variable across README, `.env.example`, install tooling, and the marketing site.
- Brand: Refresh GopherPost logo and favicon with updated gopher-and-envelope concept; refine site header hover styling.
- Brand: Refresh GopherPost logo and favicon with updated gopher-and-envelope concept; add site styling for ringed logo hover state.
- Queue: Persist delivery queue state to disk so pending deliveries survive restarts; restored entries are logged on startup.
- Storage: Add configurable retention policy via `SMTP_RETENTION_DAYS` (default 7 days) with automatic cleanup of expired spool directories.
- Auth: Implement SMTP AUTH (PLAIN and LOGIN mechanisms) via `SMTP_AUTH_USERS` configuration; AUTH is only advertised over TLS unless `SMTP_AUTH_INSECURE` is set. Authenticated users bypass domain restrictions.

## v0.4.0
- Added subscription-based audit fan-out so `/healthz` can stream live debug logs when `SMTP_DEBUG=true`.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ SMTP_HEALTH_PORT # Override only the port component of the health address (e.g.
SMTP_HEALTH_DISABLE # Disable the health endpoint when `true` (default `false`).
SMTP_QUEUE_PATH # Directory used to persist inbound messages (default ./data/spool).
SMTP_QUEUE_WORKERS # Number of concurrent delivery workers processing the outbound queue (default logical CPU count).
SMTP_RETENTION_DAYS # Number of days to retain stored messages before automatic cleanup (default 7).
```
#### Authentication

```yml
SMTP_AUTH_USERS # Comma-separated list of user:password pairs for SMTP AUTH (e.g. alice:secret,bob:pass123).
SMTP_AUTH_INSECURE # Allow AUTH without TLS when `true` (default `false`, strongly discouraged).
```
When `SMTP_AUTH_USERS` is configured, the server advertises AUTH PLAIN and AUTH LOGIN in EHLO responses (only over TLS unless `SMTP_AUTH_INSECURE` is set). Authenticated users bypass the `SMTP_REQUIRE_LOCAL_DOMAIN` restriction, allowing them to send from any address.

#### Access control

```yml
Expand All @@ -73,7 +82,7 @@ SMTP_DKIM_KEY_PATH # Filesystem path to the DKIM private key (e.g. /etc/dkim/mai
SMTP_DKIM_PRIVATE_KEY # Inline PEM-formatted DKIM private key (e.g. -----BEGIN RSA PRIVATE KEY-----).
SMTP_DKIM_DOMAIN # Domain to sign messages as when overriding the sender domain (e.g. example.com).
```
**Security note:** configure `SMTP_ALLOW_NETWORKS`, `SMTP_ALLOW_HOSTS`, and `SMTP_REQUIRE_LOCAL_DOMAIN` to enforce ingress and sender restrictions. The server lacks authentication, so deploy behind firewalls or proxies and run as a non-root service account.
**Security note:** configure `SMTP_ALLOW_NETWORKS`, `SMTP_ALLOW_HOSTS`, and `SMTP_REQUIRE_LOCAL_DOMAIN` to enforce ingress and sender restrictions. For authenticated access, configure `SMTP_AUTH_USERS` and ensure TLS is enabled. Deploy behind firewalls or proxies and run as a non-root service account.

Use an absolute path for `SMTP_QUEUE_PATH` when running the daemon under systemd so that the service `ReadWritePaths` setting can be aligned.

Expand Down
141 changes: 141 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Package auth provides SMTP authentication support.
package auth

import (
"crypto/subtle"
"encoding/base64"
"errors"
"os"
"strings"
"sync"
)

var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrAuthNotConfigured = errors.New("authentication not configured")
ErrMalformedAuth = errors.New("malformed authentication data")
)

// Credentials holds configured authentication credentials.
type Credentials struct {
mu sync.RWMutex
users map[string]string // username -> password
enabled bool
insecure bool // allow AUTH without TLS
}

var creds = &Credentials{
users: make(map[string]string),
}

// LoadFromEnv loads authentication configuration from environment variables.
// SMTP_AUTH_USERS: comma-separated list of user:password pairs
// SMTP_AUTH_INSECURE: allow AUTH without TLS (default false)
func LoadFromEnv() {
creds.mu.Lock()
defer creds.mu.Unlock()

creds.users = make(map[string]string)
creds.enabled = false

usersEnv := strings.TrimSpace(os.Getenv("SMTP_AUTH_USERS"))
if usersEnv == "" {
return
}

pairs := strings.Split(usersEnv, ",")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
parts := strings.SplitN(pair, ":", 2)
if len(parts) != 2 {
continue
}
username := strings.TrimSpace(parts[0])
password := parts[1] // Don't trim password - spaces may be intentional
if username != "" && password != "" {
creds.users[username] = password
}
}

creds.enabled = len(creds.users) > 0
creds.insecure = strings.EqualFold(os.Getenv("SMTP_AUTH_INSECURE"), "true")
}

// Enabled returns true if authentication is configured.
func Enabled() bool {
creds.mu.RLock()
defer creds.mu.RUnlock()
return creds.enabled
}

// AllowInsecure returns true if AUTH is permitted without TLS.
func AllowInsecure() bool {
creds.mu.RLock()
defer creds.mu.RUnlock()
return creds.insecure
}

// Validate checks if the given credentials are valid.
func Validate(username, password string) error {
creds.mu.RLock()
defer creds.mu.RUnlock()

if !creds.enabled {
return ErrAuthNotConfigured
}

expected, ok := creds.users[username]
if !ok {
return ErrInvalidCredentials
}

if subtle.ConstantTimeCompare([]byte(password), []byte(expected)) != 1 {
return ErrInvalidCredentials
}

return nil
}

// DecodePlain decodes SASL PLAIN authentication data.
// Format: base64(authzid\0authcid\0password) or base64(\0username\0password)
func DecodePlain(encoded string) (username, password string, err error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", ErrMalformedAuth
}

// PLAIN format: authzid\0authcid\0password
// authzid is optional, authcid is the username
parts := strings.Split(string(decoded), "\x00")
if len(parts) != 3 {
return "", "", ErrMalformedAuth
}

// Use authcid (second part) as username
username = parts[1]
password = parts[2]

if username == "" {
return "", "", ErrMalformedAuth
}

return username, password, nil
}

// DecodeLogin decodes SASL LOGIN username or password.
// Each is simply base64 encoded.
func DecodeLogin(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", ErrMalformedAuth
}
return string(decoded), nil
}

// EncodeChallenge encodes a LOGIN challenge (Username: or Password:).
func EncodeChallenge(text string) string {
return base64.StdEncoding.EncodeToString([]byte(text))
}
143 changes: 143 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package auth

import (
"encoding/base64"
"os"
"testing"
)

func TestLoadFromEnv(t *testing.T) {
// Save and restore env
origUsers := os.Getenv("SMTP_AUTH_USERS")
origInsecure := os.Getenv("SMTP_AUTH_INSECURE")
defer func() {
os.Setenv("SMTP_AUTH_USERS", origUsers)
os.Setenv("SMTP_AUTH_INSECURE", origInsecure)
}()

// Test with users configured
os.Setenv("SMTP_AUTH_USERS", "alice:secret123,bob:password456")
os.Setenv("SMTP_AUTH_INSECURE", "false")
LoadFromEnv()

if !Enabled() {
t.Error("expected auth to be enabled")
}
if AllowInsecure() {
t.Error("expected insecure to be false")
}

// Validate correct credentials
if err := Validate("alice", "secret123"); err != nil {
t.Errorf("valid credentials rejected: %v", err)
}
if err := Validate("bob", "password456"); err != nil {
t.Errorf("valid credentials rejected: %v", err)
}

// Validate incorrect credentials
if err := Validate("alice", "wrong"); err != ErrInvalidCredentials {
t.Errorf("expected ErrInvalidCredentials, got %v", err)
}
if err := Validate("unknown", "secret123"); err != ErrInvalidCredentials {
t.Errorf("expected ErrInvalidCredentials, got %v", err)
}

// Test insecure mode
os.Setenv("SMTP_AUTH_INSECURE", "true")
LoadFromEnv()
if !AllowInsecure() {
t.Error("expected insecure to be true")
}

// Test empty config
os.Setenv("SMTP_AUTH_USERS", "")
LoadFromEnv()
if Enabled() {
t.Error("expected auth to be disabled with empty users")
}
}

func TestDecodePlain(t *testing.T) {
tests := []struct {
name string
input string
wantUser string
wantPass string
wantErr bool
}{
{
name: "valid with authzid",
input: base64.StdEncoding.EncodeToString([]byte("authzid\x00username\x00password")),
wantUser: "username",
wantPass: "password",
},
{
name: "valid without authzid",
input: base64.StdEncoding.EncodeToString([]byte("\x00username\x00password")),
wantUser: "username",
wantPass: "password",
},
{
name: "invalid base64",
input: "not-valid-base64!!!",
wantErr: true,
},
{
name: "wrong format",
input: base64.StdEncoding.EncodeToString([]byte("just-one-part")),
wantErr: true,
},
{
name: "empty username",
input: base64.StdEncoding.EncodeToString([]byte("authzid\x00\x00password")),
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, pass, err := DecodePlain(tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if user != tt.wantUser {
t.Errorf("user = %q, want %q", user, tt.wantUser)
}
if pass != tt.wantPass {
t.Errorf("pass = %q, want %q", pass, tt.wantPass)
}
})
}
}

func TestDecodeLogin(t *testing.T) {
encoded := base64.StdEncoding.EncodeToString([]byte("testuser"))
decoded, err := DecodeLogin(encoded)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if decoded != "testuser" {
t.Errorf("got %q, want %q", decoded, "testuser")
}

_, err = DecodeLogin("invalid!!!")
if err == nil {
t.Error("expected error for invalid base64")
}
}

func TestEncodeChallenge(t *testing.T) {
result := EncodeChallenge("Username:")
expected := base64.StdEncoding.EncodeToString([]byte("Username:"))
if result != expected {
t.Errorf("got %q, want %q", result, expected)
}
}
Loading