From d7df8952613c1f2635f15a5b24d1b29adb30ee87 Mon Sep 17 00:00:00 2001
From: Ralph Slooten <axllent@gmail.com>
Date: Sun, 26 Jan 2025 12:39:39 +1300
Subject: [PATCH] Feature: SMTP auto-forwarding option (#414)

---
 README.md                     |   1 +
 cmd/root.go                   |  26 +++-
 config/config.go              | 264 +++++--------------------------
 config/utils.go               |  51 ++++++
 config/validators.go          | 282 ++++++++++++++++++++++++++++++++++
 internal/smtpd/forward.go     | 111 +++++++++++++
 internal/smtpd/main.go        |   3 +
 internal/smtpd/relay.go       |  11 +-
 internal/tools/headers.go     |   8 +-
 server/ui/api/v1/swagger.json |   3 +-
 10 files changed, 522 insertions(+), 238 deletions(-)
 create mode 100644 config/utils.go
 create mode 100644 config/validators.go
 create mode 100644 internal/smtpd/forward.go

diff --git a/README.md b/README.md
index 11ed78ba0..285302f3f 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
 - [Create screenshots](https://mailpit.axllent.org/docs/usage/html-screenshots/) of HTML messages via web UI
 - Mobile and tablet HTML preview toggle in desktop mode
 - [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
+- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
 - [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
 - Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
 easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
diff --git a/cmd/root.go b/cmd/root.go
index 9c7c6333f..29aa291d9 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -123,6 +123,9 @@ func init() {
 	rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
 	rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
 
+	// SMTP forwarding
+	rootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, "smtp-forward-config", config.SMTPForwardConfigFile, "SMTP forwarding configuration file for all messages")
+
 	// Chaos
 	rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
 	rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
@@ -213,7 +216,7 @@ func initConfigFromEnv() {
 	}
 	config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
 	if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
-		logger.Log().Errorf(err.Error())
+		logger.Log().Error(err.Error())
 	}
 	config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
 	config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
@@ -236,7 +239,7 @@ func initConfigFromEnv() {
 	}
 	config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
 	if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
-		logger.Log().Errorf(err.Error())
+		logger.Log().Error(err.Error())
 	}
 	if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
 		config.SMTPAuthAcceptAny = true
@@ -287,6 +290,23 @@ func initConfigFromEnv() {
 	config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
 	config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
 
+	// SMTP forwarding
+	config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
+	config.SMTPForwardConfig = config.SMTPForwardConfigStruct{}
+	config.SMTPForwardConfig.Host = os.Getenv("MP_SMTP_FORWARD_HOST")
+	if len(os.Getenv("MP_SMTP_FORWARD_PORT")) > 0 {
+		config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
+	}
+	config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
+	config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
+	config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
+	config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
+	config.SMTPForwardConfig.Password = os.Getenv("MP_SMTP_FORWARD_PASSWORD")
+	config.SMTPForwardConfig.Secret = os.Getenv("MP_SMTP_FORWARD_SECRET")
+	config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
+	config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
+	config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
+
 	// Chaos
 	chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
 	config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
@@ -297,7 +317,7 @@ func initConfigFromEnv() {
 	}
 	config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
 	if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
-		logger.Log().Errorf(err.Error())
+		logger.Log().Error(err.Error())
 	}
 	config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
 	config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
diff --git a/config/config.go b/config/config.go
index bbe671333..1bcdcc3ff 100644
--- a/config/config.go
+++ b/config/config.go
@@ -5,13 +5,10 @@ import (
 	"errors"
 	"fmt"
 	"net"
-	"net/mail"
-	"net/url"
 	"os"
 	"path"
 	"path/filepath"
 	"regexp"
-	"strconv"
 	"strings"
 
 	"github.com/axllent/mailpit/internal/auth"
@@ -19,7 +16,6 @@ import (
 	"github.com/axllent/mailpit/internal/smtpd/chaos"
 	"github.com/axllent/mailpit/internal/spamassassin"
 	"github.com/axllent/mailpit/internal/tools"
-	"gopkg.in/yaml.v3"
 )
 
 var (
@@ -116,22 +112,12 @@ var (
 	// including x-tags & plus-addresses
 	TagsDisable string
 
-	// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
+	// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server
 	SMTPRelayConfigFile string
 
-	// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
+	// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
 	SMTPRelayConfig SMTPRelayConfigStruct
 
-	// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
-	// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
-	SMTPStrictRFCHeaders bool
-
-	// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
-	SMTPAllowedRecipients string
-
-	// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
-	SMTPAllowedRecipientsRegexp *regexp.Regexp
-
 	// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
 	ReleaseEnabled = false
 
@@ -145,6 +131,22 @@ var (
 	// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
 	SMTPRelayMatchingRegexp *regexp.Regexp
 
+	// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server
+	SMTPForwardConfigFile string
+
+	// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server
+	SMTPForwardConfig SMTPForwardConfigStruct
+
+	// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
+	// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
+	SMTPStrictRFCHeaders bool
+
+	// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
+	SMTPAllowedRecipients string
+
+	// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
+	SMTPAllowedRecipientsRegexp *regexp.Regexp
+
 	// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
 	POP3Listen = "[::]:1110"
 
@@ -215,6 +217,21 @@ type SMTPRelayConfigStruct struct {
 	RecipientAllowlist string `yaml:"recipient-allowlist"`
 }
 
+// SMTPForwardConfigStruct struct for parsing yaml & storing variables
+type SMTPForwardConfigStruct struct {
+	To            string `yaml:"to"`             // comma-separated list of email addresses
+	Host          string `yaml:"host"`           // SMTP host
+	Port          int    `yaml:"port"`           // SMTP port
+	STARTTLS      bool   `yaml:"starttls"`       // whether to use STARTTLS
+	AllowInsecure bool   `yaml:"allow-insecure"` // allow insecure authentication
+	Auth          string `yaml:"auth"`           // none, plain, login, cram-md5
+	Username      string `yaml:"username"`       // plain & cram-md5
+	Password      string `yaml:"password"`       // plain
+	Secret        string `yaml:"secret"`         // cram-md5
+	ReturnPath    string `yaml:"return-path"`    // allow overriding the bounce address
+	OverrideFrom  string `yaml:"override-from"`  // allow overriding of the from address
+}
+
 // VerifyConfig wil do some basic checking
 func VerifyConfig() error {
 	cssFontRestriction := "*"
@@ -479,221 +496,20 @@ func VerifyConfig() error {
 		logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
 	}
 
-	if DemoMode {
-		MaxMessages = 1000
-		// this deserves a warning
-		logger.Log().Info("demo mode enabled")
-	}
-
-	return nil
-}
-
-// Parse the --max-age value (if set)
-func parseMaxAge() error {
-	if MaxAge == "" {
-		return nil
-	}
-
-	re := regexp.MustCompile(`^\d+(h|d)$`)
-	if !re.MatchString(MaxAge) {
-		return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
-	}
-
-	if strings.HasSuffix(MaxAge, "h") {
-		hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
-		if err != nil {
-			return err
-		}
-
-		MaxAgeInHours = hours
-
-		return nil
-	}
-
-	days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
-	if err != nil {
-		return err
-	}
-
-	logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
-
-	MaxAgeInHours = days * 24
-	return nil
-}
-
-// Parse the SMTPRelayConfigFile (if set)
-func parseRelayConfig(c string) error {
-	if c == "" {
-		return nil
-	}
-
-	c = filepath.Clean(c)
-
-	if !isFile(c) {
-		return fmt.Errorf("[smtp] relay configuration not found or readable: %s", c)
-	}
-
-	data, err := os.ReadFile(c)
-	if err != nil {
+	if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
 		return err
 	}
 
-	if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
+	// separate forwarding config validation to account for environment variables
+	if err := validateForwardConfig(); err != nil {
 		return err
 	}
 
-	if SMTPRelayConfig.Host == "" {
-		return errors.New("[smtp] relay host not set")
-	}
-
-	// DEPRECATED 2024/03/12
-	if SMTPRelayConfig.RecipientAllowlist != "" {
-		logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
-		if SMTPRelayConfig.AllowedRecipients == "" {
-			SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
-		}
-	}
-
-	return nil
-}
-
-// Validate the SMTPRelayConfig (if Host is set)
-func validateRelayConfig() error {
-	if SMTPRelayConfig.Host == "" {
-		return nil
-	}
-
-	if SMTPRelayConfig.Port == 0 {
-		SMTPRelayConfig.Port = 25 // default
-	}
-
-	SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
-
-	if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
-		SMTPRelayConfig.Auth = "none"
-	} else if SMTPRelayConfig.Auth == "plain" {
-		if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
-			return fmt.Errorf("[smtp] relay host username or password not set for PLAIN authentication")
-		}
-	} else if SMTPRelayConfig.Auth == "login" {
-		SMTPRelayConfig.Auth = "login"
-		if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
-			return fmt.Errorf("[smtp] relay host username or password not set for LOGIN authentication")
-		}
-	} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
-		SMTPRelayConfig.Auth = "cram-md5"
-		if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
-			return fmt.Errorf("[smtp] relay host username or secret not set for CRAM-MD5 authentication")
-		}
-	} else {
-		return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
-	}
-
-	ReleaseEnabled = true
-
-	logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
-
-	if SMTPRelayConfig.AllowedRecipients != "" {
-		re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
-		if err != nil {
-			return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
-		}
-
-		SMTPRelayConfig.AllowedRecipientsRegexp = re
-		logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
-	}
-
-	if SMTPRelayConfig.BlockedRecipients != "" {
-		re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
-		if err != nil {
-			return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
-		}
-
-		SMTPRelayConfig.BlockedRecipientsRegexp = re
-		logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
-	}
-
-	if SMTPRelayConfig.OverrideFrom != "" {
-		m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
-		if err != nil {
-			return fmt.Errorf("[smtp] relay override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
-		}
-
-		SMTPRelayConfig.OverrideFrom = m.Address
-	}
-
-	return nil
-}
-
-func parseChaosTriggers() error {
-	if ChaosTriggers == "" {
-		return nil
-	}
-
-	re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
-
-	parts := strings.Split(ChaosTriggers, ",")
-	for _, p := range parts {
-		p = strings.TrimSpace(p)
-		if !re.MatchString(p) {
-			return fmt.Errorf("invalid argument: %s", p)
-		}
-
-		matches := re.FindAllStringSubmatch(p, 1)
-		key := matches[0][1]
-		errorCode, err := strconv.Atoi(matches[0][2])
-		if err != nil {
-			return err
-		}
-		probability, err := strconv.Atoi(matches[0][3])
-		if err != nil {
-			return err
-		}
-
-		if err := chaos.Set(key, errorCode, probability); err != nil {
-			return err
-		}
+	if DemoMode {
+		MaxMessages = 1000
+		// this deserves a warning
+		logger.Log().Info("demo mode enabled")
 	}
 
 	return nil
 }
-
-// IsFile returns whether a file exists and is readable
-func isFile(path string) bool {
-	f, err := os.Open(filepath.Clean(path))
-	defer f.Close()
-	return err == nil
-}
-
-// IsDir returns whether a path is a directory
-func isDir(path string) bool {
-	info, err := os.Stat(path)
-	if err != nil || os.IsNotExist(err) || !info.IsDir() {
-		return false
-	}
-
-	return true
-}
-
-func isValidURL(s string) bool {
-	u, err := url.ParseRequestURI(s)
-	if err != nil {
-		return false
-	}
-
-	return strings.HasPrefix(u.Scheme, "http")
-}
-
-// DBTenantID converts a tenant ID to a DB-friendly value if set
-func DBTenantID(s string) string {
-	s = tools.Normalize(s)
-	if s != "" {
-		re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
-		s = re.ReplaceAllString(s, "_")
-		if !strings.HasSuffix(s, "_") {
-			s = s + "_"
-		}
-	}
-
-	return s
-}
diff --git a/config/utils.go b/config/utils.go
new file mode 100644
index 000000000..b4d846a0a
--- /dev/null
+++ b/config/utils.go
@@ -0,0 +1,51 @@
+package config
+
+import (
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/axllent/mailpit/internal/tools"
+)
+
+// IsFile returns whether a file exists and is readable
+func isFile(path string) bool {
+	f, err := os.Open(filepath.Clean(path))
+	defer f.Close()
+	return err == nil
+}
+
+// IsDir returns whether a path is a directory
+func isDir(path string) bool {
+	info, err := os.Stat(path)
+	if err != nil || os.IsNotExist(err) || !info.IsDir() {
+		return false
+	}
+
+	return true
+}
+
+func isValidURL(s string) bool {
+	u, err := url.ParseRequestURI(s)
+	if err != nil {
+		return false
+	}
+
+	return strings.HasPrefix(u.Scheme, "http")
+}
+
+// DBTenantID converts a tenant ID to a DB-friendly value if set
+func DBTenantID(s string) string {
+	s = tools.Normalize(s)
+	if s != "" {
+		re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
+		s = re.ReplaceAllString(s, "_")
+		if !strings.HasSuffix(s, "_") {
+			s = s + "_"
+		}
+	}
+
+	return s
+}
diff --git a/config/validators.go b/config/validators.go
new file mode 100644
index 000000000..1056a9df8
--- /dev/null
+++ b/config/validators.go
@@ -0,0 +1,282 @@
+package config
+
+import (
+	"errors"
+	"fmt"
+	"net/mail"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/axllent/mailpit/internal/logger"
+	"github.com/axllent/mailpit/internal/smtpd/chaos"
+	"gopkg.in/yaml.v3"
+)
+
+// Parse the --max-age value (if set)
+func parseMaxAge() error {
+	if MaxAge == "" {
+		return nil
+	}
+
+	re := regexp.MustCompile(`^\d+(h|d)$`)
+	if !re.MatchString(MaxAge) {
+		return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
+	}
+
+	if strings.HasSuffix(MaxAge, "h") {
+		hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
+		if err != nil {
+			return err
+		}
+
+		MaxAgeInHours = hours
+
+		return nil
+	}
+
+	days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
+	if err != nil {
+		return err
+	}
+
+	logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
+
+	MaxAgeInHours = days * 24
+	return nil
+}
+
+// Parse the SMTPRelayConfigFile (if set)
+func parseRelayConfig(c string) error {
+	if c == "" {
+		return nil
+	}
+
+	c = filepath.Clean(c)
+
+	if !isFile(c) {
+		return fmt.Errorf("[relay] configuration not found or readable: %s", c)
+	}
+
+	data, err := os.ReadFile(c)
+	if err != nil {
+		return err
+	}
+
+	if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
+		return err
+	}
+
+	if SMTPRelayConfig.Host == "" {
+		return errors.New("[relay] host not set")
+	}
+
+	// DEPRECATED 2024/03/12
+	if SMTPRelayConfig.RecipientAllowlist != "" {
+		logger.Log().Warn("[relay] 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
+		if SMTPRelayConfig.AllowedRecipients == "" {
+			SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
+		}
+	}
+
+	return nil
+}
+
+// Validate the SMTPRelayConfig (if Host is set)
+func validateRelayConfig() error {
+	if SMTPRelayConfig.Host == "" {
+		return nil
+	}
+
+	if SMTPRelayConfig.Port == 0 {
+		SMTPRelayConfig.Port = 25 // default
+	}
+
+	SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
+
+	if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
+		SMTPRelayConfig.Auth = "none"
+	} else if SMTPRelayConfig.Auth == "plain" {
+		if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
+			return fmt.Errorf("[relay] host username or password not set for PLAIN authentication")
+		}
+	} else if SMTPRelayConfig.Auth == "login" {
+		SMTPRelayConfig.Auth = "login"
+		if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
+			return fmt.Errorf("[relay] host username or password not set for LOGIN authentication")
+		}
+	} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
+		SMTPRelayConfig.Auth = "cram-md5"
+		if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
+			return fmt.Errorf("[relay] host username or secret not set for CRAM-MD5 authentication")
+		}
+	} else {
+		return fmt.Errorf("[relay] authentication method not supported: %s", SMTPRelayConfig.Auth)
+	}
+
+	if SMTPRelayConfig.AllowedRecipients != "" {
+		re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
+		if err != nil {
+			return fmt.Errorf("[relay] failed to compile recipient allowlist regexp: %s", err.Error())
+		}
+
+		SMTPRelayConfig.AllowedRecipientsRegexp = re
+		logger.Log().Infof("[relay] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
+	}
+
+	if SMTPRelayConfig.BlockedRecipients != "" {
+		re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
+		if err != nil {
+			return fmt.Errorf("[relay] failed to compile recipient blocklist regexp: %s", err.Error())
+		}
+
+		SMTPRelayConfig.BlockedRecipientsRegexp = re
+		logger.Log().Infof("[relay] recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
+	}
+
+	if SMTPRelayConfig.OverrideFrom != "" {
+		m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
+		if err != nil {
+			return fmt.Errorf("[relay] override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
+		}
+
+		SMTPRelayConfig.OverrideFrom = m.Address
+	}
+
+	ReleaseEnabled = true
+
+	logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
+
+	return nil
+}
+
+// Parse the SMTPForwardConfigFile (if set)
+func parseForwardConfig(c string) error {
+	if c == "" {
+		return nil
+	}
+
+	c = filepath.Clean(c)
+
+	if !isFile(c) {
+		return fmt.Errorf("[forward] configuration not found or readable: %s", c)
+	}
+
+	data, err := os.ReadFile(c)
+	if err != nil {
+		return err
+	}
+
+	if err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {
+		return err
+	}
+
+	if SMTPForwardConfig.Host == "" {
+		return errors.New("[forward] host not set")
+	}
+
+	return nil
+}
+
+// Validate the SMTPForwardConfig (if Host is set)
+func validateForwardConfig() error {
+	if SMTPForwardConfig.Host == "" {
+		return nil
+	}
+
+	if SMTPForwardConfig.Port == 0 {
+		SMTPForwardConfig.Port = 25 // default
+	}
+
+	SMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)
+
+	if SMTPForwardConfig.Auth == "" || SMTPForwardConfig.Auth == "none" || SMTPForwardConfig.Auth == "false" {
+		SMTPForwardConfig.Auth = "none"
+	} else if SMTPForwardConfig.Auth == "plain" {
+		if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
+			return fmt.Errorf("[forward] host username or password not set for PLAIN authentication")
+		}
+	} else if SMTPForwardConfig.Auth == "login" {
+		SMTPForwardConfig.Auth = "login"
+		if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
+			return fmt.Errorf("[forward] host username or password not set for LOGIN authentication")
+		}
+	} else if strings.HasPrefix(SMTPForwardConfig.Auth, "cram") {
+		SMTPForwardConfig.Auth = "cram-md5"
+		if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Secret == "" {
+			return fmt.Errorf("[forward] host username or secret not set for CRAM-MD5 authentication")
+		}
+	} else {
+		return fmt.Errorf("[forward] authentication method not supported: %s", SMTPForwardConfig.Auth)
+	}
+
+	if SMTPForwardConfig.To == "" {
+		return errors.New("[forward] To addresses missing")
+	}
+
+	to := []string{}
+	addresses := strings.Split(SMTPForwardConfig.To, ",")
+	for _, a := range addresses {
+		a = strings.TrimSpace(a)
+		m, err := mail.ParseAddress(a)
+		if err != nil {
+			return fmt.Errorf("[forward] To address is not a valid email address: %s", a)
+		}
+		to = append(to, m.Address)
+	}
+
+	if len(to) == 0 {
+		return errors.New("[forward] no valid To addresses found")
+	}
+
+	// overwrite the To field with the cleaned up list
+	SMTPForwardConfig.To = strings.Join(to, ",")
+
+	if SMTPForwardConfig.OverrideFrom != "" {
+		m, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)
+		if err != nil {
+			return fmt.Errorf("[forward] override-from is not a valid email address: %s", SMTPForwardConfig.OverrideFrom)
+		}
+
+		SMTPForwardConfig.OverrideFrom = m.Address
+	}
+
+	logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
+
+	return nil
+}
+
+func parseChaosTriggers() error {
+	if ChaosTriggers == "" {
+		return nil
+	}
+
+	re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
+
+	parts := strings.Split(ChaosTriggers, ",")
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if !re.MatchString(p) {
+			return fmt.Errorf("invalid argument: %s", p)
+		}
+
+		matches := re.FindAllStringSubmatch(p, 1)
+		key := matches[0][1]
+		errorCode, err := strconv.Atoi(matches[0][2])
+		if err != nil {
+			return err
+		}
+		probability, err := strconv.Atoi(matches[0][3])
+		if err != nil {
+			return err
+		}
+
+		if err := chaos.Set(key, errorCode, probability); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/internal/smtpd/forward.go b/internal/smtpd/forward.go
new file mode 100644
index 000000000..ec0d2748f
--- /dev/null
+++ b/internal/smtpd/forward.go
@@ -0,0 +1,111 @@
+package smtpd
+
+import (
+	"crypto/tls"
+	"fmt"
+	"net/smtp"
+	"strings"
+
+	"github.com/axllent/mailpit/config"
+	"github.com/axllent/mailpit/internal/logger"
+	"github.com/axllent/mailpit/internal/tools"
+)
+
+// Wrapper to forward messages if configured
+func autoForwardMessage(from string, data *[]byte) {
+	if config.SMTPForwardConfig.Host == "" {
+		return
+	}
+
+	if err := forward(from, *data); err != nil {
+		logger.Log().Errorf("[forward] error: %s", err.Error())
+	} else {
+		logger.Log().Debugf("[forward] message from %s to %s via %s:%d",
+			from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
+	}
+}
+
+// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
+func forward(from string, msg []byte) error {
+	addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
+
+	c, err := smtp.Dial(addr)
+	if err != nil {
+		return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
+	}
+
+	defer c.Close()
+
+	if config.SMTPForwardConfig.STARTTLS {
+		conf := &tls.Config{ServerName: config.SMTPForwardConfig.Host} // #nosec
+
+		conf.InsecureSkipVerify = config.SMTPForwardConfig.AllowInsecure
+
+		if err = c.StartTLS(conf); err != nil {
+			return fmt.Errorf("error creating StartTLS config: %s", err.Error())
+		}
+	}
+
+	auth := forwardAuthFromConfig()
+
+	if auth != nil {
+		if err = c.Auth(auth); err != nil {
+			return fmt.Errorf("error response to AUTH command: %s", err.Error())
+		}
+	}
+
+	if config.SMTPForwardConfig.OverrideFrom != "" {
+		msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)
+		if err != nil {
+			return fmt.Errorf("error overriding From header: %s", err.Error())
+		}
+
+		from = config.SMTPForwardConfig.OverrideFrom
+	}
+
+	if err = c.Mail(from); err != nil {
+		return fmt.Errorf("error response to MAIL command: %s", err.Error())
+	}
+
+	to := strings.Split(config.SMTPForwardConfig.To, ",")
+
+	for _, addr := range to {
+		if err = c.Rcpt(addr); err != nil {
+			logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
+		}
+	}
+
+	w, err := c.Data()
+	if err != nil {
+		return fmt.Errorf("error response to DATA command: %s", err.Error())
+	}
+
+	if _, err := w.Write(msg); err != nil {
+		return fmt.Errorf("error sending message: %s", err.Error())
+	}
+
+	if err := w.Close(); err != nil {
+		return fmt.Errorf("error closing connection: %s", err.Error())
+	}
+
+	return c.Quit()
+}
+
+// Return the SMTP forwarding authentication based on config
+func forwardAuthFromConfig() smtp.Auth {
+	var a smtp.Auth
+
+	if config.SMTPForwardConfig.Auth == "plain" {
+		a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)
+	}
+
+	if config.SMTPForwardConfig.Auth == "login" {
+		a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)
+	}
+
+	if config.SMTPForwardConfig.Auth == "cram-md5" {
+		a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)
+	}
+
+	return a
+}
diff --git a/internal/smtpd/main.go b/internal/smtpd/main.go
index 8950ec26d..e9309a1f6 100644
--- a/internal/smtpd/main.go
+++ b/internal/smtpd/main.go
@@ -87,6 +87,9 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
 	// if enabled, this may conditionally relay the email through to the preconfigured smtp server
 	autoRelayMessage(from, to, &data)
 
+	// if enabled, this will forward a copy to preconfigured addresses
+	autoForwardMessage(from, &data)
+
 	// build array of all addresses in the header to compare to the []to array
 	emails, hasBccHeader := scanAddressesInHeader(msg.Header)
 
diff --git a/internal/smtpd/relay.go b/internal/smtpd/relay.go
index 4b7d80a48..21e379cc7 100644
--- a/internal/smtpd/relay.go
+++ b/internal/smtpd/relay.go
@@ -12,12 +12,13 @@ import (
 	"github.com/axllent/mailpit/internal/tools"
 )
 
+// Wrapper to auto relay messages if configured
 func autoRelayMessage(from string, to []string, data *[]byte) {
 	if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
 		filteredTo := []string{}
 		for _, address := range to {
 			if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
-				logger.Log().Debugf("[smtp] ignoring auto-relay to %s: found in blocklist", address)
+				logger.Log().Debugf("[relay] ignoring auto-relay to %s: found in blocklist", address)
 				continue
 			}
 
@@ -32,9 +33,9 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
 
 	if config.SMTPRelayAll {
 		if err := Relay(from, to, *data); err != nil {
-			logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
+			logger.Log().Errorf("[relay] error: %s", err.Error())
 		} else {
-			logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
+			logger.Log().Debugf("[relay] sent message to %s from %s via %s:%d",
 				strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
 		}
 	} else if config.SMTPRelayMatchingRegexp != nil {
@@ -50,9 +51,9 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
 		}
 
 		if err := Relay(from, filtered, *data); err != nil {
-			logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
+			logger.Log().Errorf("[relay] error: %s", err.Error())
 		} else {
-			logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
+			logger.Log().Debugf("[relay] auto-relay message to %s from %s via %s:%d",
 				strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
 		}
 	}
diff --git a/internal/tools/headers.go b/internal/tools/headers.go
index d80208949..0a8ad4615 100644
--- a/internal/tools/headers.go
+++ b/internal/tools/headers.go
@@ -49,7 +49,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
 			}
 
 			if len(hdr) > 0 {
-				logger.Log().Debugf("[release] removed %s header", hdr)
+				logger.Log().Debugf("[relay] removed %s header", hdr)
 				msg = bytes.Replace(msg, hdr, []byte(""), 1)
 			}
 		}
@@ -91,7 +91,7 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
 		}
 
 		if len(hdr) > 0 {
-			logger.Log().Debugf("[release] replaced %s header", hdr)
+			logger.Log().Debugf("[relay] replaced %s header", hdr)
 			msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
 		}
 	}
@@ -148,12 +148,12 @@ func OverrideFromHeader(msg []byte, address string) ([]byte, error) {
 			// insert the original From header as X-Original-From
 			msg = append([]byte("X-Original-From: "+originalFrom+"\r\n"), msg...)
 
-			logger.Log().Debugf("[release] Replaced From email address with %s", address)
+			logger.Log().Debugf("[relay] Replaced From email address with %s", address)
 		}
 	} else {
 		// no From header, so add one
 		msg = append([]byte("From: "+address+"\r\n"), msg...)
-		logger.Log().Debugf("[release] Added From email: %s", address)
+		logger.Log().Debugf("[relay] Added From email: %s", address)
 	}
 
 	return msg, nil
diff --git a/server/ui/api/v1/swagger.json b/server/ui/api/v1/swagger.json
index fdc4f63c3..f2e23d49a 100644
--- a/server/ui/api/v1/swagger.json
+++ b/server/ui/api/v1/swagger.json
@@ -1900,7 +1900,7 @@
       "x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos"
     },
     "Triggers": {
-      "description": "ChaosTriggers is the Chaos configuration",
+      "description": "Triggers for the Chaos configuration",
       "type": "object",
       "properties": {
         "Authentication": {
@@ -1913,7 +1913,6 @@
           "$ref": "#/definitions/Trigger"
         }
       },
-      "x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos",
       "$ref": "#/definitions/Triggers"
     },
     "WebUIConfiguration": {